Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/extension/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ and the Run Inspector renders the live solve.

## Workflow (this is the whole job)

1. Read the bundled template `solve_template.jl` in this project dir.
1. Read the bundled template `solve_template.jl` at its absolute path:
`{{TEMPLATE_PATH}}`.
2. Copy it to a working file (e.g. `solve.jl`) and fill in the `# FILL IN`
parameter block from the user's request: transmon frequency `ω` (GHz),
anharmonicity `δ` (GHz), `levels`, the target gate, gate time `T` (ns),
Expand Down Expand Up @@ -55,8 +56,7 @@ correct loader in this Piccolo.
## Julia project

<!-- AMICO_JULIA_PROJECT --> The Julia project to pass as `--project` is:
**{{JULIA_PROJECT}}**. Always pass it. If it reads `UNSET`, omit `--project`
and tell the user `amicode.juliaProject` is not configured.
**{{JULIA_PROJECT}}**. Always pass it.

## Style

Expand Down
2 changes: 1 addition & 1 deletion packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
"amicode.juliaProject": {
"type": "string",
"default": "",
"description": "Julia project (--project) the agent passes to amico-run. Empty = agent omits --project."
"description": "Julia project (--project) the agent passes to amico-run. Empty = defaults to ~/.amico/julia (the provisioned project)."
},
"amicode.runsRoot": {
"type": "string",
Expand Down
12 changes: 10 additions & 2 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ChatPanel } from "./chat_panel";
import { registerRunInspector } from "./run_inspector";
import { registerTrees } from "./trees";
import { StatusBarManager } from "./status_bar";
import { prepareOpencodeProject } from "./opencode_config";
import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from "./opencode_config";
import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths";
import { OpencodeEventClient } from "./sse_client";
import { RunsRootWatcher } from "./file_watcher";
Expand Down Expand Up @@ -55,7 +55,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
const opencodeProject = prepareOpencodeProject({
agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"),
templateSrc: path.resolve(ctx.extensionPath, "templates", "solve_template.jl"),
juliaProject: vscode.workspace.getConfiguration("amicode").get<string>("juliaProject", ""),
juliaProject: resolveJuliaProject(
vscode.workspace.getConfiguration("amicode").get<string>("juliaProject", ""),
),
});
opencodeChannel.appendLine(`[boot] opencode project dir: ${opencodeProject.projectDir}`);
opencodeChannel.appendLine(`[boot] AGENTS.md: ${opencodeProject.agentsPath}`);
Expand Down Expand Up @@ -96,6 +98,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
cwd: opencodeProject.projectDir,
env: {
PATH: `${amicoRunBinDir ? amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`,
// Inject the amico solve workflow as opencode `instructions` (loaded for
// every session regardless of its cwd) — merges over the user's global
// config, so the model/provider are preserved. This is what makes the
// chat actually author + run solves instead of behaving like vanilla
// opencode (the session cwd is the workspace, not opencodeProject.projectDir).
OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent(opencodeProject.agentsPath),
},
channel: opencodeChannel,
});
Expand Down
74 changes: 50 additions & 24 deletions packages/extension/src/opencode_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,49 +6,75 @@ import * as os from "node:os";
// Prepare a per-session opencode project directory.
//
// opencode invokes amico-run via its built-in `bash` tool — no MCP, no
// callback HTTP. We deliver into the session: (a) AGENTS.md (auto-loaded LLM
// context, with the Julia project path substituted in), and (b) the vetted
// solve_template.jl the agent copies + fills in. PATH augmentation (so
// `amico-run` resolves) happens at spawn time in extension.ts.
// callback HTTP. The amico solve workflow (AGENTS.md) reaches the agent via
// opencode's `instructions` config (see buildOpencodeConfigContent + the
// OPENCODE_CONFIG_CONTENT spawn env in extension.ts), which is loaded for
// every session regardless of the session's working directory. opencode's web
// UI runs the session in the VS Code workspace folder, NOT this temp dir, so
// the temp dir exists only to hold the substituted AGENTS.md — the absolute
// path `instructions` points at. PATH augmentation (so `amico-run` resolves)
// happens at spawn time in extension.ts.
//
// LIFETIME INVARIANT: opencode reads the `instructions` file lazily, per
// message — and a missing file fails *silently* (empty instruction set →
// regression to vanilla opencode). The temp dir is created at activate() and
// is never cleaned by the extension, so it persists for the server's lifetime.
// Do NOT add temp-dir cleanup without moving AGENTS.md somewhere equally durable.
// ============================================================================

/** Resolve the Julia project (--project) the agent should pass. A configured,
* non-empty value wins (trimmed); otherwise default to the β.4-provisioned
* project at ~/.amico/julia. (The VS Code config default is "", which `??`
* does NOT catch — hence an explicit empty check rather than a nullish one.) */
export function resolveJuliaProject(configValue: string): string {
const v = configValue.trim();
return v === "" ? path.join(os.homedir(), ".amico", "julia") : v;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't expand a leading ~~/foo reaches --project literally. resolveRunsRoot already handles this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e21539c (on #23, stacked above) — resolveJuliaProject now expands a leading ~ (~ → home, ~/foo → join(home, foo)), matching resolveRunsRoot. Test added in opencode_config.test.ts. Folded into #23 rather than restacking the chain for a one-liner.

}

/** Build the OPENCODE_CONFIG_CONTENT value: a config object that injects the
* amico AGENTS.md as a top-level `instructions` entry. opencode MERGES this
* over the user's global config (model/provider preserved) for every session,
* independent of the session's working directory. */
export function buildOpencodeConfigContent(agentsPath: string): string {
return JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: [agentsPath],
});
}

export interface OpencodeConfigOptions {
/** Absolute path to packages/extension/AGENTS.md to copy into the project dir. */
/** Absolute path to packages/extension/AGENTS.md to substitute + write into the project dir. */
agentsSrc: string;
/** Absolute path to the vetted solve_template.jl to copy into the project dir. */
/** Absolute path to the vetted solve_template.jl. Substituted into AGENTS.md
* as {{TEMPLATE_PATH}} (the agent reads it there; it is not copied). */
templateSrc: string;
/** Julia project (--project) the agent should use; substituted into AGENTS.md.
* undefined → "UNSET" (AGENTS.md tells the agent to omit --project). */
/** Julia project (--project) the agent should use; already resolved (see
* resolveJuliaProject). Substituted into AGENTS.md as {{JULIA_PROJECT}}. */
juliaProject: string | undefined;
}

export interface OpencodeProject {
projectDir: string;
agentsPath: string;
/** The vetted template the agent reads — the bundled source (absolute), not a copy. */
templatePath: string;
}

export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodeProject {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-v2-"));
fs.mkdirSync(path.join(projectDir, ".opencode"), { recursive: true });

// AGENTS.md: read → substitute {{JULIA_PROJECT}} → write (auto-loaded by opencode).
// AGENTS.md: read → substitute {{JULIA_PROJECT}} + {{TEMPLATE_PATH}} → write.
// This file is the target of opencode's `instructions` config (absolute path).
const agentsPath = path.join(projectDir, "AGENTS.md");
const raw = fs.existsSync(opts.agentsSrc)
? fs.readFileSync(opts.agentsSrc, "utf8")
: "# Amicode\nRead solve_template.jl, fill params, run `amico-run <script>`.\n";
fs.writeFileSync(agentsPath, raw.replaceAll("{{JULIA_PROJECT}}", opts.juliaProject ?? "UNSET"), "utf8");

// The vetted template the agent copies and fills in.
const templatePath = path.join(projectDir, "solve_template.jl");
if (fs.existsSync(opts.templateSrc)) fs.copyFileSync(opts.templateSrc, templatePath);

// Minimal opencode config so it treats this dir as a project root.
fs.writeFileSync(
path.join(projectDir, ".opencode", "opencode.json"),
JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2),
"utf8",
);
: "# Amicode\nRead the template at {{TEMPLATE_PATH}}, fill params, run `amico-run <script>`.\n";
const filled = raw
.replaceAll("{{JULIA_PROJECT}}", opts.juliaProject ?? resolveJuliaProject(""))
.replaceAll("{{TEMPLATE_PATH}}", opts.templateSrc);
fs.writeFileSync(agentsPath, filled, "utf8");

return { projectDir, agentsPath, templatePath };
// The agent reads the template from its bundled absolute path (the session
// cwd is the workspace, not this temp dir — so no copy is made here).
return { projectDir, agentsPath, templatePath: opts.templateSrc };
}
4 changes: 4 additions & 0 deletions packages/extension/test/agents_md.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ describe('AGENTS.md teaches the D9/D10 script-authoring workflow', () => {
expect(AGENTS).toMatch(/solve_template\.jl/)
expect(AGENTS).toMatch(/amico-run .*solve\.jl/) // the actual invocation it teaches
})
it('references the template by absolute path (substituted at session prep), not "in this project dir"', () => {
expect(AGENTS).toMatch(/\{\{TEMPLATE_PATH\}\}/) // session cwd is the workspace, not the temp dir
expect(AGENTS).not.toMatch(/in this project dir/)
})
it('teaches the portable detached launch (nohup + & in a subshell + watch inspector), not setsid', () => {
expect(AGENTS).toMatch(/nohup/)
expect(AGENTS).toMatch(/&\s*\)/) // backgrounded inside a subshell
Expand Down
46 changes: 32 additions & 14 deletions packages/extension/test/opencode_config.test.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,52 @@
import { describe, it, expect } from 'vitest'
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { tmpdir, homedir } from 'node:os'
import { join } from 'node:path'
import { prepareOpencodeProject } from '../src/opencode_config'
import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent } from '../src/opencode_config'

function fakeExtRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'extroot-'))
writeFileSync(join(root, 'AGENTS.md'), '# A\nproject: {{JULIA_PROJECT}}\n')
writeFileSync(join(root, 'AGENTS.md'), '# A\nproject: {{JULIA_PROJECT}}\ntemplate: {{TEMPLATE_PATH}}\n')
mkdirSync(join(root, 'templates'))
writeFileSync(join(root, 'templates', 'solve_template.jl'), '# template\n')
return root
}

describe('resolveJuliaProject', () => {
const def = join(homedir(), '.amico', 'julia')
it('defaults to ~/.amico/julia when empty or whitespace', () => {
expect(resolveJuliaProject('')).toBe(def)
expect(resolveJuliaProject(' ')).toBe(def)
})
it('uses a configured value, trimmed', () => {
expect(resolveJuliaProject('/opt/piccolo')).toBe('/opt/piccolo')
expect(resolveJuliaProject(' /opt/p ')).toBe('/opt/p')
})
})

describe('buildOpencodeConfigContent', () => {
it('emits valid JSON whose instructions points at the (absolute) agents file', () => {
const cfg = JSON.parse(buildOpencodeConfigContent('/abs/AGENTS.md'))
expect(cfg.instructions).toEqual(['/abs/AGENTS.md'])
})
})

describe('prepareOpencodeProject', () => {
it('copies AGENTS.md + template into the session and substitutes the julia project', () => {
it('substitutes the julia project AND the absolute template path, leaving no placeholders', () => {
const ext = fakeExtRoot()
const p = prepareOpencodeProject({
agentsSrc: join(ext, 'AGENTS.md'),
templateSrc: join(ext, 'templates', 'solve_template.jl'),
juliaProject: '/opt/piccolo',
})
expect(existsSync(join(p.projectDir, 'solve_template.jl'))).toBe(true)
const templateSrc = join(ext, 'templates', 'solve_template.jl')
const p = prepareOpencodeProject({ agentsSrc: join(ext, 'AGENTS.md'), templateSrc, juliaProject: '/opt/piccolo' })
const agents = readFileSync(p.agentsPath, 'utf8')
expect(agents).toContain('/opt/piccolo')
expect(agents).not.toContain('{{JULIA_PROJECT}}')
expect(agents).toContain(templateSrc) // {{TEMPLATE_PATH}} → the absolute bundled template
expect(agents).not.toMatch(/\{\{.*?\}\}/) // no residual placeholders
expect(p.templatePath).toBe(templateSrc) // points at the bundled source, not a copy
})
it('substitutes UNSET when no project configured', () => {
it('does NOT copy the template or write a vestigial .opencode/opencode.json into the session dir', () => {
const ext = fakeExtRoot()
const p = prepareOpencodeProject({ agentsSrc: join(ext, 'AGENTS.md'),
templateSrc: join(ext, 'templates', 'solve_template.jl'), juliaProject: undefined })
expect(readFileSync(p.agentsPath, 'utf8')).toContain('UNSET')
templateSrc: join(ext, 'templates', 'solve_template.jl'), juliaProject: '/opt/piccolo' })
expect(existsSync(join(p.projectDir, 'solve_template.jl'))).toBe(false)
expect(existsSync(join(p.projectDir, '.opencode', 'opencode.json'))).toBe(false)
})
})
Loading