Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3026004
chore: convert repo to pnpm workspace; move extension to packages/ext…
Rchari1 Jun 10, 2026
562111f
feat(amico-run): package skeleton + public API types
Rchari1 Jun 10, 2026
3e4b95a
feat(amico-run): telemetry line classifier
Rchari1 Jun 10, 2026
3c0da8a
feat(amico-run): run-dir primitives (lab-id, runId, atomic writers)
Rchari1 Jun 10, 2026
b5d5ce4
feat(amico-run): provisional schema validators
Rchari1 Jun 10, 2026
f936ce1
feat(amico-run): LocalExecutor — submit lifecycle, event stream, cont…
Rchari1 Jun 10, 2026
9991d04
test(amico-run): §6 failure matrix + abort lanes
Rchari1 Jun 10, 2026
bc0f170
feat(amico-run): CLI + single-file bundle + launcher; delete pre-D9 b…
Rchari1 Jun 10, 2026
7369449
ci: fast-tier workflow + extension type-level contract check
Rchari1 Jun 10, 2026
bae0df8
test(amico-run): slow-tier real Piccolo solves validate the run-dir c…
Rchari1 Jun 10, 2026
f8c8cde
ci: let packageManager pin pnpm (action-setup rejects double pinning)
Rchari1 Jun 10, 2026
be3a478
fix(amico-run): address β.1 review (#7) — index TSV escaping, cli une…
Rchari1 Jun 17, 2026
b2e604f
feat(extension): opencode pin manifest + fetcher manifest/platform logic
Rchari1 Jun 10, 2026
300b1f1
feat(extension): fetcher download/verify/unpack + recorded v1.17.3 pi…
Rchari1 Jun 10, 2026
5de7bca
feat(extension): resolve vendored opencode binary; kill $PATH assumpt…
Rchari1 Jun 10, 2026
968fe03
test(extension): boot-smoke gate for the vendored opencode pin; delet…
Rchari1 Jun 10, 2026
26d9018
ci: extension unit suite in fast job; two-platform opencode boot-smok…
Rchari1 Jun 10, 2026
98ed8b7
Merge main into beta2: clear the #7-squash conflict
jack-champagne Jun 19, 2026
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
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,17 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm -r run build
- run: pnpm -r run typecheck
- run: pnpm --filter @amicode/amico-run test # includes the S31 grep-rule test
- run: pnpm -r run test # amico-run suite (incl. S31 grep rule) + extension unit suite
boot-smoke:
strategy:
matrix:
os: [ubuntu-latest, macos-14] # linux-x64 + darwin-arm64 — both pins machine-gated (spec §5)
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm --filter amicode-v2 fetch:opencode

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.

Both matrix legs re-download ~157MB on every run. Worth an actions/cache step keyed on hashFiles('packages/extension/opencode.lock.json'), restoring packages/extension/vendor/ before this step — the .sha256 idempotency stamp already makes fetch:opencode a no-op on a cache hit, so unchanged pins skip the download entirely.

- run: pnpm --filter amicode-v2 test:smoke
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist/
*.vsix
.vscode-test/
*.log
packages/extension/vendor/
11 changes: 11 additions & 0 deletions packages/extension/.vscodeignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# This file's EXISTENCE stops vsce falling back to .gitignore (which excludes vendor/).
# The explicit negation keeps the vendored opencode shipping even if a future
# exclusion pattern would otherwise swallow it — that's the trap β.2 exists to kill.
!vendor/**
src/**
test/**
scripts/**
node_modules/**
esbuild.config.mjs
tsconfig.json
**/*.map
7 changes: 7 additions & 0 deletions packages/extension/opencode.lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"version": "1.17.3",
"platforms": {
"darwin-arm64": { "asset": "opencode-darwin-arm64.zip", "sha256": "b49948f96d8e92c577d54854e2f038389d03c3dfbbeacc44643b73123210fd13" },
"linux-x64": { "asset": "opencode-linux-x64.tar.gz", "sha256": "d4bd238a2c1ff56aca1cd3397d21a0a317f5992234517a7f8e2afbbd72010a7d" }
}
}
11 changes: 7 additions & 4 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@
"properties": {
"amicode.opencodeBinary": {
"type": "string",
"default": "opencode",
"description": "Path or command name for the opencode CLI."
"default": "",
"description": "Override path to the opencode CLI (dev only). Empty = use the vendored binary."
},
"amicode.juliaProject": {
"type": "string",
Expand All @@ -75,13 +75,16 @@
"build": "node esbuild.config.mjs",
"watch": "node esbuild.config.mjs --watch",
"typecheck": "tsc --noEmit",
"test": "echo 'extension: spike smoke tests in test/ need opencode+julia (not headless); skipped'"
"test": "vitest run --passWithNoTests",
"test:smoke": "node test/boot_smoke.mjs",
"fetch:opencode": "node scripts/fetch_opencode.mjs"
},
"devDependencies": {
"@amicode/amico-run": "workspace:*",
"@types/node": "^22.0.0",
"@types/vscode": "^1.95.0",
"esbuild": "^0.24.0",
"typescript": "^5.6.0"
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
104 changes: 104 additions & 0 deletions packages/extension/scripts/fetch_opencode.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env node
// Download-at-build vendoring of the opencode chat-server binary, pinned by
// opencode.lock.json (spec §2/§3). Importable module + CLI in one file.
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import {
chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync,
renameSync, rmSync, writeFileSync,
} from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'

const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')

export function loadManifest(root = PKG_ROOT) {
const m = JSON.parse(readFileSync(join(root, 'opencode.lock.json'), 'utf8'))
if (typeof m.version !== 'string' || m.version === '') throw new Error('manifest: version must be a non-empty string')
const platforms = m.platforms ?? {}
if (Object.keys(platforms).length === 0) throw new Error('manifest: platforms missing')
for (const [key, p] of Object.entries(platforms)) {
if (typeof p.asset !== 'string' || p.asset === '') throw new Error(`manifest: ${key}.asset missing`)
if (!/^[0-9a-f]{64}$/.test(p.sha256 ?? '')) throw new Error(`manifest: ${key}.sha256 must be 64 hex chars`)
}
return m
}

export function resolvePlatform(manifest, flag) {
const key = flag ?? `${process.platform}-${process.arch}`
if (!(key in manifest.platforms)) {
throw new Error(`platform ${key} not supported (supported: ${Object.keys(manifest.platforms).join(', ')})`)
}
return key
}

export function assetUrl(manifest, platform) {
return `https://github.com/sst/opencode/releases/download/v${manifest.version}/${manifest.platforms[platform].asset}`
}

export const sha256 = (buf) => createHash('sha256').update(buf).digest('hex')

async function defaultDownload(url) {
let r
try { r = await fetch(url) } catch (e) {
throw new Error(`download failed: ${e.message} for ${url}`) // spec §6: URL on connection failures too
}
if (!r.ok) throw new Error(`download failed: HTTP ${r.status} for ${url}`)
return Buffer.from(await r.arrayBuffer())
}

export async function fetchOpencode({ root = PKG_ROOT, platform, download = defaultDownload } = {}) {
const manifest = loadManifest(root)
const key = resolvePlatform(manifest, platform)
const { asset, sha256: want } = manifest.platforms[key]
const destDir = join(root, 'vendor', 'opencode', key)
const bin = join(destDir, 'opencode')
const stamp = join(destDir, '.sha256')

if (existsSync(bin) && existsSync(stamp) && readFileSync(stamp, 'utf8').trim() === want) {
return { skipped: true, path: bin } // offline repeat builds
}

const bytes = await download(assetUrl(manifest, key))
const got = sha256(bytes)
if (got !== want) {
// Possible supply-chain signal: no retry, no override (spec §3 step 4).
throw new Error(`SHA256 mismatch for ${asset}: expected ${want}, actual ${got}`)
}

mkdirSync(destDir, { recursive: true })
const work = mkdtempSync(join(destDir, '.unpack-')) // same fs → rename is atomic
try {
const archive = join(work, asset)
writeFileSync(archive, bytes)
if (asset.endsWith('.zip')) execFileSync('unzip', ['-oq', archive, '-d', work])
else execFileSync('tar', ['-xzf', archive, '-C', work])
if (!existsSync(join(work, 'opencode'))) throw new Error(`archive ${asset} did not contain a flat 'opencode' binary`)
renameSync(join(work, 'opencode'), bin)
chmodSync(bin, 0o755)
writeFileSync(stamp, got + '\n') // stamp last (spec §3 step 5)
} finally {
rmSync(work, { recursive: true, force: true })
}
return { skipped: false, path: bin }
}

async function main(argv) {
const flagIdx = argv.indexOf('--platform')
const platform = flagIdx >= 0 ? argv[flagIdx + 1] : undefined
if (argv.includes('--record')) { // pin-time only (spec §3 step 6)
const manifest = loadManifest()
for (const key of Object.keys(manifest.platforms)) {
const bytes = await defaultDownload(assetUrl(manifest, key))
console.log(`${key} ${sha256(bytes)}`)
}
return 0
}
const r = await fetchOpencode({ platform })
console.log(r.skipped ? `[fetch-opencode] up to date: ${r.path}` : `[fetch-opencode] installed: ${r.path}`)
return 0
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main(process.argv.slice(2)).then(c => { process.exitCode = c }, e => { console.error(`[fetch-opencode] ${e.message}`); process.exitCode = 1 })
}
88 changes: 55 additions & 33 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as vscode from "vscode";
import * as path from "node:path";
import * as fs from "node:fs";
import { ServerManager } from "./server_manager";
import { resolveOpencodeBinary, OpencodeMissingError } from "./opencode_binary";
import { ChatPanel } from "./chat_panel";
import { registerRunInspector } from "./run_inspector";
import { registerTrees } from "./trees";
Expand Down Expand Up @@ -53,39 +54,60 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
opencodeChannel.appendLine(`[boot] opencode project dir: ${opencodeProject.projectDir}`);
opencodeChannel.appendLine(`[boot] AGENTS.md: ${opencodeProject.agentsPath}`);

// 4. Spawn opencode. PATH=binDir:$PATH so `amico-run` resolves; env vars
// also tell amico-run where to find julia+script in case the user has
// multiple checkouts.
const binary = vscode.workspace.getConfiguration("amicode").get<string>("opencodeBinary", "opencode");
const juliaScript = resolveJuliaScript(ctx);
const juliaProject = resolveJuliaProject();
serverManager = new ServerManager({
binary,
cwd: opencodeProject.projectDir,
env: {
PATH: `${binDir}:${process.env.PATH ?? ""}`,
AMICO_JULIA_SCRIPT: juliaScript,
AMICO_JULIA_PROJECT: juliaProject,
AMICO_RUNS_ROOT: runsRoot,
},
channel: opencodeChannel,
});
ctx.subscriptions.push({ dispose: () => serverManager?.stop() });

// SSE event channel — opens once opencode is healthy.
sseClient = new OpencodeEventClient({ channel: opencodeChannel, statusBar });
ctx.subscriptions.push(sseClient);

serverManager.onReady((url) => {
opencodeReadyUrl = url;
statusBar?.setServerReady(true);
sseClient?.connect(url);
});

serverManager.start().catch((err) => {
vscode.window.showErrorMessage(`Amicode: opencode failed to start — ${err.message}`);
opencodeChannel.appendLine(`[boot] start failed: ${err.stack ?? err.message}`);
});
// 4. Spawn opencode — the VENDORED binary by default (spec §4; S35, kills
// Assumption 4). Config override is a dev-only escape hatch. On a missing
// binary, chat is disabled but the rest of the extension (inspector,
// watcher, commands) still activates.
let binary: string | undefined;
try {
const resolved = resolveOpencodeBinary(
ctx.extensionPath,
vscode.workspace.getConfiguration("amicode").get<string>("opencodeBinary", ""),
);
binary = resolved.path;
opencodeChannel.appendLine(
resolved.source === "config-override"
? `[boot] OVERRIDE: amicode.opencodeBinary = ${binary}`
: `[boot] vendored opencode: ${binary}`,
);
} catch (e) {
if (e instanceof OpencodeMissingError) {
opencodeChannel.appendLine(`[boot] ${e.message} — chat disabled`);
void vscode.window.showErrorMessage(`Amicode: ${e.message}`);
} else throw e;
}

if (binary !== undefined) {
const juliaScript = resolveJuliaScript(ctx);
const juliaProject = resolveJuliaProject();
serverManager = new ServerManager({
binary,
cwd: opencodeProject.projectDir,
env: {
PATH: `${binDir}:${process.env.PATH ?? ""}`,
AMICO_JULIA_SCRIPT: juliaScript,

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.

@Rchari1 — this AMICO_* env + PATH=binDir:$PATH injection looks like leftover spike wiring, and it cuts against β.1's all-argv / no-AMICO_* contract (S37 / Q90 — amico-run takes everything via argv, nothing from env). I'm inclined to kill it. Before we rip it out: is there something the opencode server still needs these for (a tool the chat slice invokes that reads them), or can β.3 pass them through amico-run flags instead? Want your read.

AMICO_JULIA_PROJECT: juliaProject,
AMICO_RUNS_ROOT: runsRoot,
},
channel: opencodeChannel,
});
ctx.subscriptions.push({ dispose: () => serverManager?.stop() });

// SSE event channel — opens once opencode is healthy.
sseClient = new OpencodeEventClient({ channel: opencodeChannel, statusBar });
ctx.subscriptions.push(sseClient);

serverManager.onReady((url) => {
opencodeReadyUrl = url;
statusBar?.setServerReady(true);
sseClient?.connect(url);
});

serverManager.start().catch((err) => {
vscode.window.showErrorMessage(`Amicode: opencode failed to start — ${err.message}`);
opencodeChannel.appendLine(`[boot] start failed: ${err.stack ?? err.message}`);
});
}

// 5. Commands
ctx.subscriptions.push(
Expand Down
28 changes: 28 additions & 0 deletions packages/extension/src/opencode_binary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { accessSync, constants } from "node:fs";
import { join } from "node:path";

export class OpencodeMissingError extends Error {}

export interface ResolvedBinary { path: string; source: "config-override" | "vendored" }

const SUPPORTED = ["darwin-arm64", "linux-x64"] as const;

/** Spec §4: config override → vendored → hard error. NO $PATH fallback (Assumption 4 stays dead). */
export function resolveOpencodeBinary(extensionRoot: string, configValue: string): ResolvedBinary {
const override = (configValue ?? "").trim();
if (override !== "") return { path: override, source: "config-override" };

const key = `${process.platform}-${process.arch}`;
if (!(SUPPORTED as readonly string[]).includes(key)) {
throw new OpencodeMissingError(`platform ${key} not supported (supported: ${SUPPORTED.join(", ")})`);
}
const vendored = join(extensionRoot, "vendor", "opencode", key, "opencode");
try {
accessSync(vendored, constants.X_OK);
} catch {
throw new OpencodeMissingError(
`vendored opencode missing at ${vendored} — run \`pnpm --filter amicode-v2 fetch:opencode\` (dev) or reinstall the extension`,
);
}
return { path: vendored, source: "vendored" };
}
74 changes: 74 additions & 0 deletions packages/extension/test/boot_smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env node
// β.2 boot-smoke gate (spec §5): the VENDORED opencode binary must serve
// GET /event as an SSE stream (HTTP 200, text/event-stream) against a
// synthesized project, with no LLM creds. Exit 0 = pass.
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:net'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const key = `${process.platform}-${process.arch}`
const bin = join(PKG_ROOT, 'vendor', 'opencode', key, 'opencode')

const fail = (msg, code = 1) => { console.error(`[smoke] FAIL: ${msg}`); process.exit(code) }

if (!existsSync(bin)) fail(`vendored binary missing at ${bin} — run \`pnpm --filter amicode-v2 fetch:opencode\``, 10)

// Synthesize a minimal opencode project (inline port of the spike's prepareOpencodeProject).
const proj = mkdtempSync(join(tmpdir(), 'amicode-smoke-'))
mkdirSync(join(proj, '.opencode'), { recursive: true })
writeFileSync(join(proj, 'AGENTS.md'), '# Amicode boot smoke\n')
writeFileSync(join(proj, '.opencode', 'opencode.json'),
JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2))

const port = await new Promise((res, rej) => {
const srv = createServer()
srv.listen(0, '127.0.0.1', () => { const p = srv.address().port; srv.close(() => res(p)) })
srv.on('error', rej)
})

console.log(`[smoke] spawning ${bin} serve --port ${port}`)
let done = false
let serverLog = ''
const child = spawn(bin, ['serve', '--port', String(port)], { cwd: proj, stdio: ['ignore', 'pipe', 'pipe'] })
child.on('error', e => fail(`spawn failed: ${e.message}`))
child.stdout.on('data', d => { serverLog += d })
child.stderr.on('data', d => { serverLog += d })
child.on('exit', (c, s) => { if (!done) fail(`opencode exited early (code=${c} signal=${s})\n--- server output ---\n${serverLog}`) })

// Poll until the server answers, 30s cap.
const deadline = Date.now() + 30_000
let up = false
while (Date.now() < deadline && !up) {
try {
const r = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) })
if (r.status < 500) up = true
} catch { /* not up yet */ }
if (!up) await new Promise(r => setTimeout(r, 200))
}
if (!up) { done = true; child.kill('SIGKILL'); fail(`server not up within 30s\n--- server output ---\n${serverLog}`) }

// THE gate: /event answers 200 with an SSE content-type. fetch resolves on
// headers (the SSE body streaming doesn't block us); 10s cap so a header-less
// hang fails fast instead of eating the CI job timeout.
const ctrl = new AbortController()
const gateTimer = setTimeout(() => ctrl.abort(), 10_000)
const ev = await fetch(`http://127.0.0.1:${port}/event`, { signal: ctrl.signal })
clearTimeout(gateTimer)
const ctype = ev.headers.get('content-type') ?? ''
console.log(`[smoke] GET /event → ${ev.status} (${ctype})`)
if (ev.status !== 200) { done = true; child.kill('SIGKILL'); fail(`/event status ${ev.status}, want 200`) }
if (!ctype.includes('text/event-stream')) { done = true; child.kill('SIGKILL'); fail(`/event content-type "${ctype}", want text/event-stream`) }
ctrl.abort()

// Clean shutdown: SIGTERM, exit within 5s.
done = true
const exited = new Promise(res => child.on('exit', res))
child.kill('SIGTERM')
const t = setTimeout(() => child.kill('SIGKILL'), 5_000)
await exited
clearTimeout(t)
console.log('[smoke] PASS')
Loading
Loading