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
16 changes: 14 additions & 2 deletions packages/amico-run/src/agent_spawn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,8 +31,12 @@ import type { Finding } from "./lenses.js";

export const DEFAULT_CRITIC_MODEL = "anthropic/claude-opus-5";
export const DEFAULT_CRITIC_VARIANT = "high";
/** Per-child ceiling (§3.7). A critic that has not answered in two minutes has failed. */
export const CRITIC_TIMEOUT_MS = 120_000;
/** Per-child ceiling (§3.7). A critic that has not answered in four minutes has failed.
* 240s, not 120s: a frontier-class review of a full spec on a free-tier model takes
* ~3 minutes measured (178s for a 13k-token decomposition pass, 2026-07-31) — the
* two-minute ceiling read legitimate slow answers as failures and silently degraded
* every review to approved-mechanical/degraded. */
export const CRITIC_TIMEOUT_MS = 240_000;

/** Why a lens has no findings, which is NOT the same question as whether it ran.
*
Expand DownExpand Up@@ -99,6 +103,13 @@ export function resolveAgentBin(env: NodeJS.ProcessEnv = process.env): string |
* already authenticated against. */
const ENV_ALLOWLIST = ["HOME", "PATH", "TMPDIR", "SHELL", "LANG", "LC_ALL", "TERM"];
const ENV_ALLOW_PREFIXES = ["XDG_", "OPENCODE_"];
/** Live-session pointers: when amico runs INSIDE a live Amicode session, these
* ride the OPENCODE_ prefix allowance into the child, and the child's headless
* `run` tries to resolve the PARENT's session — failing with "Session not
* found" before the critic ever starts. The child spawns its own runtime; the
* parent's session pointers are never valid for it. Config vars
* (OPENCODE_CONFIG_CONTENT/DIR) stay — those are the legitimate prefix users. */
const ENV_DENYLIST = new Set(["OPENCODE", "OPENCODE_PID", "OPENCODE_SERVER_PASSWORD"]);

export function buildChildEnv(
parent: NodeJS.ProcessEnv = process.env,
Expand All@@ -107,6 +118,7 @@ export function buildChildEnv(
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(parent)) {
if (v === undefined) continue;
if (ENV_DENYLIST.has(k)) continue;
if (ENV_ALLOWLIST.includes(k) || ENV_ALLOW_PREFIXES.some((p) => k.startsWith(p))) out[k] = v;
}
return { ...out, ...extra };
Expand Down
8 changes: 8 additions & 0 deletions packages/amico-run/src/plan_compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -469,8 +469,16 @@ function defaultPlanner(specPath: string, env?: NodeJS.ProcessEnv): ((specText:
agent: "planner",
model: criticModel(e),
env: e,
// Plan generation is a bigger output than a critic pass (a full gated
// plan JSON, not findings) — measured beyond the 240s critic ceiling on
// a free-tier model, so the planner gets its own ceiling (2026-07-31).
timeoutMs: PLANNER_TIMEOUT_MS,
prompt: "Compile the spec in your working directory into a plan. Reply with the JSON object only.",
specText,
specFilename: specPath.split("/").pop() ?? "spec.md",
});
}

/** Planner-only spawn ceiling: ~2.7× the measured 178s critic pass at the same
* tier (2026-07-31). Anything longer is a hung child, not a slow answer. */
export const PLANNER_TIMEOUT_MS = 480_000;
13 changes: 13 additions & 0 deletions packages/amico-run/test/agent_spawn.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -348,4 +348,17 @@ describe("buildChildEnv", () => {
it("lets explicit extras through", () => {
expect(buildChildEnv({ HOME: "/h" }, { OPENCODE_CONFIG_CONTENT: "{}" }).OPENCODE_CONFIG_CONTENT).toBe("{}");
});
it("strips live-session pointers even though they carry the OPENCODE_ prefix", () => {
// Regression: spawned from inside a live Amicode session, these vars made
// the child's headless `run` resolve the PARENT's session → "Session not
// found", killing every critic before it started. Config vars must stay.
const env = buildChildEnv({
HOME: "/h",
OPENCODE: "1",
OPENCODE_PID: "3434",
OPENCODE_SERVER_PASSWORD: "live-pw",
OPENCODE_CONFIG_CONTENT: "{}",
});
expect(env).toEqual({ HOME: "/h", OPENCODE_CONFIG_CONTENT: "{}" });
});
});
12 changes: 12 additions & 0 deletions packages/extension/esbuild.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,18 @@ const targets = [
logLevel: "info",
loader: { ".svg": "text" },
},
// Chat Deck webview bundle — pane-manager shell (src/deck/shell.ts + model)
{
entryPoints: ["src/deck/shell.ts"],
bundle: true,
platform: "browser",
target: "es2022",
format: "iife",
outfile: "dist/deck_shell.js",
sourcemap: true,
minify: false,
logLevel: "info",
},
];

if (watch) {
Expand Down
12 changes: 12 additions & 0 deletions packages/extension/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@
],
"activationEvents": [
"onCommand:amicode.openChat",
"onCommand:amicode.newChat",
"onCommand:amicode.chatDeck",
"onCommand:amicode.openInspector",
"onView:amicode.runInspector",
"onView:amicode.deviceInspector",
Expand DownExpand Up@@ -92,6 +94,16 @@
"title": "Amicode: Open Chat",
"icon": "$(comment-discussion)"
},
{
"command": "amicode.newChat",
"title": "Amicode: New Chat (Side by Side)",
"icon": "$(add)"
},
{
"command": "amicode.chatDeck",
"title": "Amicode: Open Chat Deck (Panes in One Tab)",
"icon": "$(layout)"
},
{
"command": "amicode.setupVault",
"title": "Amicode: Set up a personal vault"
Expand Down
138 changes: 138 additions & 0 deletions packages/extension/src/chat_bridge.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as vscode from "vscode";
import * as path from "node:path";
import * as os from "node:os";

// ============================================================================
// The amicode iframe⇄extension command bridge, shared by ChatPanel (one
// iframe) and DeckPanel (N panes). The framed app renders LLM output, so the
// handler is paranoid by construction: strict command allowlist, https-only
// externals, visibility-gated clipboard reads, bounded payloads. Panes tag
// their messages with an opaque `tab` id; replies ECHO it so the shell can
// route the answer back to the asking pane. Single-iframe panels leave `tab`
// undefined and their relay simply forwards to the one iframe.
// ============================================================================

// Commands the in-app palette (opencode "Amico" command group) may trigger via
// the iframe→parent→extension postMessage bridge. STRICT allowlist: the framed
// app renders LLM output, so we never executeCommand anything outside this set.
export const BRIDGE_ALLOWED_COMMANDS: ReadonlySet<string> = new Set([
"amicode.restartServer",
"amicode.distillNow",
"amicode.stopRun",
"amicode.savePulse",
"amicode.openRunDir",
"amicode.openInspector",
// ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's —
// the fork forwards it here so the editor's Command Palette (where every
// Amicode: command lives) opens as users expect.
"workbench.action.showCommands",
]);

/** Side channels the handler needs from its host panel. */
export interface BridgeIo {
/** Clipboard reads only answer while the user can see the chat. */
visible(): boolean;
/** Replies (clipboard text) go back to the host webview; `tab` echoes along. */
postToWebview(msg: unknown): void;
}

const isAmicode = (msg: unknown): msg is { source: "amicode"; kind: string; tab?: string } =>
!!msg && typeof msg === "object" && (msg as { source?: unknown }).source === "amicode";

/** Handle one envelope from a framed app. Returns true when the message was
* consumed (hosts log the rest). */
export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean {
if (!isAmicode(msg)) return false;

// target=_blank/window.open are dead inside the framed app — open https
// links via the editor (system browser). https-only; scheme is
// case-insensitive (RFC 3986).
if (
msg.kind === "open-external" &&
typeof (msg as { url?: unknown }).url === "string" &&
/^https:\/\//i.test((msg as unknown as { url: string }).url)
) {
void vscode.env.openExternal(vscode.Uri.parse((msg as unknown as { url: string }).url));
return true;
}

// Paste bridge: navigator.clipboard is unavailable to the framed app (the
// webview parent has no clipboard-read to delegate), so the app asks US —
// the extension host reads the OS clipboard and replies. Visibility gate:
// the app renders LLM-driven content, so a hidden panel must not be able to
// sample the clipboard in the background.
if (msg.kind === "clipboard-request") {
if (!io.visible()) return true;
void vscode.env.clipboard.readText().then((text) =>
io.postToWebview({
source: "amicode",
kind: "clipboard",
nonce: (msg as { nonce?: string }).nonce,
text,
tab: msg.tab,
}),
);
return true;
}

// Copy bridge (mirror of clipboard-request): the framed app's native copy
// can't reach the OS clipboard, so an in-chat ⌘C posts the text here and we
// write it via vscode.env.clipboard — otherwise the paste bridge above reads
// back stale content. Same visibility gate; payload is untrusted, so bound it.
if (msg.kind === "clipboard-write" && typeof (msg as { text?: unknown }).text === "string") {
if (!io.visible()) return true;
const text = (msg as unknown as { text: string }).text;
if (text.length > 5_000_000) return true;
void vscode.env.clipboard.writeText(text);
return true;
}

// Save bridge (run-card PNG export): downloads are dead inside the framed
// app — the extension shows a save dialog and writes the file. PNG-only,
// basename-only, bounded size: the payload is untrusted.
if (
msg.kind === "save-file" &&
typeof (msg as { filename?: unknown }).filename === "string" &&
typeof (msg as { dataUrl?: unknown }).dataUrl === "string"
) {
const raw = msg as unknown as { filename: string; dataUrl: string };
const prefix = "data:image/png;base64,";
const base64 = raw.dataUrl.startsWith(prefix) ? raw.dataUrl.slice(prefix.length) : undefined;
const name = path.basename(raw.filename).replace(/[^\w.-]+/g, "-");
if (!base64 || base64.length > 24_000_000 || !name.endsWith(".png")) return true;
void (async () => {
const target = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)),
filters: { Images: ["png"] },
});
if (!target) return;
await vscode.workspace.fs.writeFile(target, Buffer.from(base64, "base64"));
const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal");
if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target);
})();
return true;
}

// The "Amico" palette group — allowlisted commands only.
if (msg.kind === "command") {
const command = (msg as unknown as { command?: unknown }).command;
if (typeof command === "string" && BRIDGE_ALLOWED_COMMANDS.has(command)) {
void vscode.commands.executeCommand(command);
return true;
}
return false;
}

// Dashboard "Default model" control mirrors its choice into the
// amicode.defaultModel setting, so the config pin (headless / first turn)
// tracks the UI. "provider/model-id" only, bounded — untrusted.
if (msg.kind === "set-default-model" && typeof (msg as { model?: unknown }).model === "string") {
const model = (msg as unknown as { model: string }).model.trim();
if (model.length > 0 && model.length <= 200 && /^[\w.-]+\/[\w.:-]+$/.test(model)) {
void vscode.workspace.getConfiguration("amicode").update("defaultModel", model, vscode.ConfigurationTarget.Global);
}
return true;
}

return false;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
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
16 changes: 14 additions & 2 deletions packages/amico-run/src/agent_spawn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,8 +31,12 @@ import type { Finding } from "./lenses.js";

export const DEFAULT_CRITIC_MODEL = "anthropic/claude-opus-5";
export const DEFAULT_CRITIC_VARIANT = "high";
/** Per-child ceiling (§3.7). A critic that has not answered in two minutes has failed. */
export const CRITIC_TIMEOUT_MS = 120_000;
/** Per-child ceiling (§3.7). A critic that has not answered in four minutes has failed.
* 240s, not 120s: a frontier-class review of a full spec on a free-tier model takes
* ~3 minutes measured (178s for a 13k-token decomposition pass, 2026-07-31) — the
* two-minute ceiling read legitimate slow answers as failures and silently degraded
* every review to approved-mechanical/degraded. */
export const CRITIC_TIMEOUT_MS = 240_000;

/** Why a lens has no findings, which is NOT the same question as whether it ran.
*
Expand DownExpand Up@@ -99,6 +103,13 @@ export function resolveAgentBin(env: NodeJS.ProcessEnv = process.env): string |
* already authenticated against. */
const ENV_ALLOWLIST = ["HOME", "PATH", "TMPDIR", "SHELL", "LANG", "LC_ALL", "TERM"];
const ENV_ALLOW_PREFIXES = ["XDG_", "OPENCODE_"];
/** Live-session pointers: when amico runs INSIDE a live Amicode session, these
* ride the OPENCODE_ prefix allowance into the child, and the child's headless
* `run` tries to resolve the PARENT's session — failing with "Session not
* found" before the critic ever starts. The child spawns its own runtime; the
* parent's session pointers are never valid for it. Config vars
* (OPENCODE_CONFIG_CONTENT/DIR) stay — those are the legitimate prefix users. */
const ENV_DENYLIST = new Set(["OPENCODE", "OPENCODE_PID", "OPENCODE_SERVER_PASSWORD"]);

export function buildChildEnv(
parent: NodeJS.ProcessEnv = process.env,
Expand All@@ -107,6 +118,7 @@ export function buildChildEnv(
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(parent)) {
if (v === undefined) continue;
if (ENV_DENYLIST.has(k)) continue;
if (ENV_ALLOWLIST.includes(k) || ENV_ALLOW_PREFIXES.some((p) => k.startsWith(p))) out[k] = v;
}
return { ...out, ...extra };
Expand Down
8 changes: 8 additions & 0 deletions packages/amico-run/src/plan_compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -469,8 +469,16 @@ function defaultPlanner(specPath: string, env?: NodeJS.ProcessEnv): ((specText:
agent: "planner",
model: criticModel(e),
env: e,
// Plan generation is a bigger output than a critic pass (a full gated
// plan JSON, not findings) — measured beyond the 240s critic ceiling on
// a free-tier model, so the planner gets its own ceiling (2026-07-31).
timeoutMs: PLANNER_TIMEOUT_MS,
prompt: "Compile the spec in your working directory into a plan. Reply with the JSON object only.",
specText,
specFilename: specPath.split("/").pop() ?? "spec.md",
});
}

/** Planner-only spawn ceiling: ~2.7× the measured 178s critic pass at the same
* tier (2026-07-31). Anything longer is a hung child, not a slow answer. */
export const PLANNER_TIMEOUT_MS = 480_000;
13 changes: 13 additions & 0 deletions packages/amico-run/test/agent_spawn.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -348,4 +348,17 @@ describe("buildChildEnv", () => {
it("lets explicit extras through", () => {
expect(buildChildEnv({ HOME: "/h" }, { OPENCODE_CONFIG_CONTENT: "{}" }).OPENCODE_CONFIG_CONTENT).toBe("{}");
});
it("strips live-session pointers even though they carry the OPENCODE_ prefix", () => {
// Regression: spawned from inside a live Amicode session, these vars made
// the child's headless `run` resolve the PARENT's session → "Session not
// found", killing every critic before it started. Config vars must stay.
const env = buildChildEnv({
HOME: "/h",
OPENCODE: "1",
OPENCODE_PID: "3434",
OPENCODE_SERVER_PASSWORD: "live-pw",
OPENCODE_CONFIG_CONTENT: "{}",
});
expect(env).toEqual({ HOME: "/h", OPENCODE_CONFIG_CONTENT: "{}" });
});
});
12 changes: 12 additions & 0 deletions packages/extension/esbuild.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,18 @@ const targets = [
logLevel: "info",
loader: { ".svg": "text" },
},
// Chat Deck webview bundle — pane-manager shell (src/deck/shell.ts + model)
{
entryPoints: ["src/deck/shell.ts"],
bundle: true,
platform: "browser",
target: "es2022",
format: "iife",
outfile: "dist/deck_shell.js",
sourcemap: true,
minify: false,
logLevel: "info",
},
];

if (watch) {
Expand Down
12 changes: 12 additions & 0 deletions packages/extension/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@
],
"activationEvents": [
"onCommand:amicode.openChat",
"onCommand:amicode.newChat",
"onCommand:amicode.chatDeck",
"onCommand:amicode.openInspector",
"onView:amicode.runInspector",
"onView:amicode.deviceInspector",
Expand DownExpand Up@@ -92,6 +94,16 @@
"title": "Amicode: Open Chat",
"icon": "$(comment-discussion)"
},
{
"command": "amicode.newChat",
"title": "Amicode: New Chat (Side by Side)",
"icon": "$(add)"
},
{
"command": "amicode.chatDeck",
"title": "Amicode: Open Chat Deck (Panes in One Tab)",
"icon": "$(layout)"
},
{
"command": "amicode.setupVault",
"title": "Amicode: Set up a personal vault"
Expand Down
138 changes: 138 additions & 0 deletions packages/extension/src/chat_bridge.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as vscode from "vscode";
import * as path from "node:path";
import * as os from "node:os";

// ============================================================================
// The amicode iframe⇄extension command bridge, shared by ChatPanel (one
// iframe) and DeckPanel (N panes). The framed app renders LLM output, so the
// handler is paranoid by construction: strict command allowlist, https-only
// externals, visibility-gated clipboard reads, bounded payloads. Panes tag
// their messages with an opaque `tab` id; replies ECHO it so the shell can
// route the answer back to the asking pane. Single-iframe panels leave `tab`
// undefined and their relay simply forwards to the one iframe.
// ============================================================================

// Commands the in-app palette (opencode "Amico" command group) may trigger via
// the iframe→parent→extension postMessage bridge. STRICT allowlist: the framed
// app renders LLM output, so we never executeCommand anything outside this set.
export const BRIDGE_ALLOWED_COMMANDS: ReadonlySet<string> = new Set([
"amicode.restartServer",
"amicode.distillNow",
"amicode.stopRun",
"amicode.savePulse",
"amicode.openRunDir",
"amicode.openInspector",
// ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's —
// the fork forwards it here so the editor's Command Palette (where every
// Amicode: command lives) opens as users expect.
"workbench.action.showCommands",
]);

/** Side channels the handler needs from its host panel. */
export interface BridgeIo {
/** Clipboard reads only answer while the user can see the chat. */
visible(): boolean;
/** Replies (clipboard text) go back to the host webview; `tab` echoes along. */
postToWebview(msg: unknown): void;
}

const isAmicode = (msg: unknown): msg is { source: "amicode"; kind: string; tab?: string } =>
!!msg && typeof msg === "object" && (msg as { source?: unknown }).source === "amicode";

/** Handle one envelope from a framed app. Returns true when the message was
* consumed (hosts log the rest). */
export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean {
if (!isAmicode(msg)) return false;

// target=_blank/window.open are dead inside the framed app — open https
// links via the editor (system browser). https-only; scheme is
// case-insensitive (RFC 3986).
if (
msg.kind === "open-external" &&
typeof (msg as { url?: unknown }).url === "string" &&
/^https:\/\//i.test((msg as unknown as { url: string }).url)
) {
void vscode.env.openExternal(vscode.Uri.parse((msg as unknown as { url: string }).url));
return true;
}

// Paste bridge: navigator.clipboard is unavailable to the framed app (the
// webview parent has no clipboard-read to delegate), so the app asks US —
// the extension host reads the OS clipboard and replies. Visibility gate:
// the app renders LLM-driven content, so a hidden panel must not be able to
// sample the clipboard in the background.
if (msg.kind === "clipboard-request") {
if (!io.visible()) return true;
void vscode.env.clipboard.readText().then((text) =>
io.postToWebview({
source: "amicode",
kind: "clipboard",
nonce: (msg as { nonce?: string }).nonce,
text,
tab: msg.tab,
}),
);
return true;
}

// Copy bridge (mirror of clipboard-request): the framed app's native copy
// can't reach the OS clipboard, so an in-chat ⌘C posts the text here and we
// write it via vscode.env.clipboard — otherwise the paste bridge above reads
// back stale content. Same visibility gate; payload is untrusted, so bound it.
if (msg.kind === "clipboard-write" && typeof (msg as { text?: unknown }).text === "string") {
if (!io.visible()) return true;
const text = (msg as unknown as { text: string }).text;
if (text.length > 5_000_000) return true;
void vscode.env.clipboard.writeText(text);
return true;
}

// Save bridge (run-card PNG export): downloads are dead inside the framed
// app — the extension shows a save dialog and writes the file. PNG-only,
// basename-only, bounded size: the payload is untrusted.
if (
msg.kind === "save-file" &&
typeof (msg as { filename?: unknown }).filename === "string" &&
typeof (msg as { dataUrl?: unknown }).dataUrl === "string"
) {
const raw = msg as unknown as { filename: string; dataUrl: string };
const prefix = "data:image/png;base64,";
const base64 = raw.dataUrl.startsWith(prefix) ? raw.dataUrl.slice(prefix.length) : undefined;
const name = path.basename(raw.filename).replace(/[^\w.-]+/g, "-");
if (!base64 || base64.length > 24_000_000 || !name.endsWith(".png")) return true;
void (async () => {
const target = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)),
filters: { Images: ["png"] },
});
if (!target) return;
await vscode.workspace.fs.writeFile(target, Buffer.from(base64, "base64"));
const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal");
if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target);
})();
return true;
}

// The "Amico" palette group — allowlisted commands only.
if (msg.kind === "command") {
const command = (msg as unknown as { command?: unknown }).command;
if (typeof command === "string" && BRIDGE_ALLOWED_COMMANDS.has(command)) {
void vscode.commands.executeCommand(command);
return true;
}
return false;
}

// Dashboard "Default model" control mirrors its choice into the
// amicode.defaultModel setting, so the config pin (headless / first turn)
// tracks the UI. "provider/model-id" only, bounded — untrusted.
if (msg.kind === "set-default-model" && typeof (msg as { model?: unknown }).model === "string") {
const model = (msg as unknown as { model: string }).model.trim();
if (model.length > 0 && model.length <= 200 && /^[\w.-]+\/[\w.:-]+$/.test(model)) {
void vscode.workspace.getConfiguration("amicode").update("defaultModel", model, vscode.ConfigurationTarget.Global);
}
return true;
}

return false;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
16 changes: 14 additions & 2 deletions packages/amico-run/src/agent_spawn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,8 +31,12 @@ import type { Finding } from "./lenses.js";

export const DEFAULT_CRITIC_MODEL = "anthropic/claude-opus-5";
export const DEFAULT_CRITIC_VARIANT = "high";
/** Per-child ceiling (§3.7). A critic that has not answered in two minutes has failed. */
export const CRITIC_TIMEOUT_MS = 120_000;
/** Per-child ceiling (§3.7). A critic that has not answered in four minutes has failed.
* 240s, not 120s: a frontier-class review of a full spec on a free-tier model takes
* ~3 minutes measured (178s for a 13k-token decomposition pass, 2026-07-31) — the
* two-minute ceiling read legitimate slow answers as failures and silently degraded
* every review to approved-mechanical/degraded. */
export const CRITIC_TIMEOUT_MS = 240_000;

/** Why a lens has no findings, which is NOT the same question as whether it ran.
*
Expand DownExpand Up@@ -99,6 +103,13 @@ export function resolveAgentBin(env: NodeJS.ProcessEnv = process.env): string |
* already authenticated against. */
const ENV_ALLOWLIST = ["HOME", "PATH", "TMPDIR", "SHELL", "LANG", "LC_ALL", "TERM"];
const ENV_ALLOW_PREFIXES = ["XDG_", "OPENCODE_"];
/** Live-session pointers: when amico runs INSIDE a live Amicode session, these
* ride the OPENCODE_ prefix allowance into the child, and the child's headless
* `run` tries to resolve the PARENT's session — failing with "Session not
* found" before the critic ever starts. The child spawns its own runtime; the
* parent's session pointers are never valid for it. Config vars
* (OPENCODE_CONFIG_CONTENT/DIR) stay — those are the legitimate prefix users. */
const ENV_DENYLIST = new Set(["OPENCODE", "OPENCODE_PID", "OPENCODE_SERVER_PASSWORD"]);

export function buildChildEnv(
parent: NodeJS.ProcessEnv = process.env,
Expand All@@ -107,6 +118,7 @@ export function buildChildEnv(
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(parent)) {
if (v === undefined) continue;
if (ENV_DENYLIST.has(k)) continue;
if (ENV_ALLOWLIST.includes(k) || ENV_ALLOW_PREFIXES.some((p) => k.startsWith(p))) out[k] = v;
}
return { ...out, ...extra };
Expand Down
8 changes: 8 additions & 0 deletions packages/amico-run/src/plan_compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -469,8 +469,16 @@ function defaultPlanner(specPath: string, env?: NodeJS.ProcessEnv): ((specText:
agent: "planner",
model: criticModel(e),
env: e,
// Plan generation is a bigger output than a critic pass (a full gated
// plan JSON, not findings) — measured beyond the 240s critic ceiling on
// a free-tier model, so the planner gets its own ceiling (2026-07-31).
timeoutMs: PLANNER_TIMEOUT_MS,
prompt: "Compile the spec in your working directory into a plan. Reply with the JSON object only.",
specText,
specFilename: specPath.split("/").pop() ?? "spec.md",
});
}

/** Planner-only spawn ceiling: ~2.7× the measured 178s critic pass at the same
* tier (2026-07-31). Anything longer is a hung child, not a slow answer. */
export const PLANNER_TIMEOUT_MS = 480_000;
13 changes: 13 additions & 0 deletions packages/amico-run/test/agent_spawn.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -348,4 +348,17 @@ describe("buildChildEnv", () => {
it("lets explicit extras through", () => {
expect(buildChildEnv({ HOME: "/h" }, { OPENCODE_CONFIG_CONTENT: "{}" }).OPENCODE_CONFIG_CONTENT).toBe("{}");
});
it("strips live-session pointers even though they carry the OPENCODE_ prefix", () => {
// Regression: spawned from inside a live Amicode session, these vars made
// the child's headless `run` resolve the PARENT's session → "Session not
// found", killing every critic before it started. Config vars must stay.
const env = buildChildEnv({
HOME: "/h",
OPENCODE: "1",
OPENCODE_PID: "3434",
OPENCODE_SERVER_PASSWORD: "live-pw",
OPENCODE_CONFIG_CONTENT: "{}",
});
expect(env).toEqual({ HOME: "/h", OPENCODE_CONFIG_CONTENT: "{}" });
});
});
12 changes: 12 additions & 0 deletions packages/extension/esbuild.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,18 @@ const targets = [
logLevel: "info",
loader: { ".svg": "text" },
},
// Chat Deck webview bundle — pane-manager shell (src/deck/shell.ts + model)
{
entryPoints: ["src/deck/shell.ts"],
bundle: true,
platform: "browser",
target: "es2022",
format: "iife",
outfile: "dist/deck_shell.js",
sourcemap: true,
minify: false,
logLevel: "info",
},
];

if (watch) {
Expand Down
12 changes: 12 additions & 0 deletions packages/extension/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@
],
"activationEvents": [
"onCommand:amicode.openChat",
"onCommand:amicode.newChat",
"onCommand:amicode.chatDeck",
"onCommand:amicode.openInspector",
"onView:amicode.runInspector",
"onView:amicode.deviceInspector",
Expand DownExpand Up@@ -92,6 +94,16 @@
"title": "Amicode: Open Chat",
"icon": "$(comment-discussion)"
},
{
"command": "amicode.newChat",
"title": "Amicode: New Chat (Side by Side)",
"icon": "$(add)"
},
{
"command": "amicode.chatDeck",
"title": "Amicode: Open Chat Deck (Panes in One Tab)",
"icon": "$(layout)"
},
{
"command": "amicode.setupVault",
"title": "Amicode: Set up a personal vault"
Expand Down
138 changes: 138 additions & 0 deletions packages/extension/src/chat_bridge.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as vscode from "vscode";
import * as path from "node:path";
import * as os from "node:os";

// ============================================================================
// The amicode iframe⇄extension command bridge, shared by ChatPanel (one
// iframe) and DeckPanel (N panes). The framed app renders LLM output, so the
// handler is paranoid by construction: strict command allowlist, https-only
// externals, visibility-gated clipboard reads, bounded payloads. Panes tag
// their messages with an opaque `tab` id; replies ECHO it so the shell can
// route the answer back to the asking pane. Single-iframe panels leave `tab`
// undefined and their relay simply forwards to the one iframe.
// ============================================================================

// Commands the in-app palette (opencode "Amico" command group) may trigger via
// the iframe→parent→extension postMessage bridge. STRICT allowlist: the framed
// app renders LLM output, so we never executeCommand anything outside this set.
export const BRIDGE_ALLOWED_COMMANDS: ReadonlySet<string> = new Set([
"amicode.restartServer",
"amicode.distillNow",
"amicode.stopRun",
"amicode.savePulse",
"amicode.openRunDir",
"amicode.openInspector",
// ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's —
// the fork forwards it here so the editor's Command Palette (where every
// Amicode: command lives) opens as users expect.
"workbench.action.showCommands",
]);

/** Side channels the handler needs from its host panel. */
export interface BridgeIo {
/** Clipboard reads only answer while the user can see the chat. */
visible(): boolean;
/** Replies (clipboard text) go back to the host webview; `tab` echoes along. */
postToWebview(msg: unknown): void;
}

const isAmicode = (msg: unknown): msg is { source: "amicode"; kind: string; tab?: string } =>
!!msg && typeof msg === "object" && (msg as { source?: unknown }).source === "amicode";

/** Handle one envelope from a framed app. Returns true when the message was
* consumed (hosts log the rest). */
export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean {
if (!isAmicode(msg)) return false;

// target=_blank/window.open are dead inside the framed app — open https
// links via the editor (system browser). https-only; scheme is
// case-insensitive (RFC 3986).
if (
msg.kind === "open-external" &&
typeof (msg as { url?: unknown }).url === "string" &&
/^https:\/\//i.test((msg as unknown as { url: string }).url)
) {
void vscode.env.openExternal(vscode.Uri.parse((msg as unknown as { url: string }).url));
return true;
}

// Paste bridge: navigator.clipboard is unavailable to the framed app (the
// webview parent has no clipboard-read to delegate), so the app asks US —
// the extension host reads the OS clipboard and replies. Visibility gate:
// the app renders LLM-driven content, so a hidden panel must not be able to
// sample the clipboard in the background.
if (msg.kind === "clipboard-request") {
if (!io.visible()) return true;
void vscode.env.clipboard.readText().then((text) =>
io.postToWebview({
source: "amicode",
kind: "clipboard",
nonce: (msg as { nonce?: string }).nonce,
text,
tab: msg.tab,
}),
);
return true;
}

// Copy bridge (mirror of clipboard-request): the framed app's native copy
// can't reach the OS clipboard, so an in-chat ⌘C posts the text here and we
// write it via vscode.env.clipboard — otherwise the paste bridge above reads
// back stale content. Same visibility gate; payload is untrusted, so bound it.
if (msg.kind === "clipboard-write" && typeof (msg as { text?: unknown }).text === "string") {
if (!io.visible()) return true;
const text = (msg as unknown as { text: string }).text;
if (text.length > 5_000_000) return true;
void vscode.env.clipboard.writeText(text);
return true;
}

// Save bridge (run-card PNG export): downloads are dead inside the framed
// app — the extension shows a save dialog and writes the file. PNG-only,
// basename-only, bounded size: the payload is untrusted.
if (
msg.kind === "save-file" &&
typeof (msg as { filename?: unknown }).filename === "string" &&
typeof (msg as { dataUrl?: unknown }).dataUrl === "string"
) {
const raw = msg as unknown as { filename: string; dataUrl: string };
const prefix = "data:image/png;base64,";
const base64 = raw.dataUrl.startsWith(prefix) ? raw.dataUrl.slice(prefix.length) : undefined;
const name = path.basename(raw.filename).replace(/[^\w.-]+/g, "-");
if (!base64 || base64.length > 24_000_000 || !name.endsWith(".png")) return true;
void (async () => {
const target = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)),
filters: { Images: ["png"] },
});
if (!target) return;
await vscode.workspace.fs.writeFile(target, Buffer.from(base64, "base64"));
const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal");
if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target);
})();
return true;
}

// The "Amico" palette group — allowlisted commands only.
if (msg.kind === "command") {
const command = (msg as unknown as { command?: unknown }).command;
if (typeof command === "string" && BRIDGE_ALLOWED_COMMANDS.has(command)) {
void vscode.commands.executeCommand(command);
return true;
}
return false;
}

// Dashboard "Default model" control mirrors its choice into the
// amicode.defaultModel setting, so the config pin (headless / first turn)
// tracks the UI. "provider/model-id" only, bounded — untrusted.
if (msg.kind === "set-default-model" && typeof (msg as { model?: unknown }).model === "string") {
const model = (msg as unknown as { model: string }).model.trim();
if (model.length > 0 && model.length <= 200 && /^[\w.-]+\/[\w.:-]+$/.test(model)) {
void vscode.workspace.getConfiguration("amicode").update("defaultModel", model, vscode.ConfigurationTarget.Global);
}
return true;
}

return false;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
16 changes: 14 additions & 2 deletions packages/amico-run/src/agent_spawn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,8 +31,12 @@ import type { Finding } from "./lenses.js";

export const DEFAULT_CRITIC_MODEL = "anthropic/claude-opus-5";
export const DEFAULT_CRITIC_VARIANT = "high";
/** Per-child ceiling (§3.7). A critic that has not answered in two minutes has failed. */
export const CRITIC_TIMEOUT_MS = 120_000;
/** Per-child ceiling (§3.7). A critic that has not answered in four minutes has failed.
* 240s, not 120s: a frontier-class review of a full spec on a free-tier model takes
* ~3 minutes measured (178s for a 13k-token decomposition pass, 2026-07-31) — the
* two-minute ceiling read legitimate slow answers as failures and silently degraded
* every review to approved-mechanical/degraded. */
export const CRITIC_TIMEOUT_MS = 240_000;

/** Why a lens has no findings, which is NOT the same question as whether it ran.
*
Expand DownExpand Up@@ -99,6 +103,13 @@ export function resolveAgentBin(env: NodeJS.ProcessEnv = process.env): string |
* already authenticated against. */
const ENV_ALLOWLIST = ["HOME", "PATH", "TMPDIR", "SHELL", "LANG", "LC_ALL", "TERM"];
const ENV_ALLOW_PREFIXES = ["XDG_", "OPENCODE_"];
/** Live-session pointers: when amico runs INSIDE a live Amicode session, these
* ride the OPENCODE_ prefix allowance into the child, and the child's headless
* `run` tries to resolve the PARENT's session — failing with "Session not
* found" before the critic ever starts. The child spawns its own runtime; the
* parent's session pointers are never valid for it. Config vars
* (OPENCODE_CONFIG_CONTENT/DIR) stay — those are the legitimate prefix users. */
const ENV_DENYLIST = new Set(["OPENCODE", "OPENCODE_PID", "OPENCODE_SERVER_PASSWORD"]);

export function buildChildEnv(
parent: NodeJS.ProcessEnv = process.env,
Expand All@@ -107,6 +118,7 @@ export function buildChildEnv(
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(parent)) {
if (v === undefined) continue;
if (ENV_DENYLIST.has(k)) continue;
if (ENV_ALLOWLIST.includes(k) || ENV_ALLOW_PREFIXES.some((p) => k.startsWith(p))) out[k] = v;
}
return { ...out, ...extra };
Expand Down
8 changes: 8 additions & 0 deletions packages/amico-run/src/plan_compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -469,8 +469,16 @@ function defaultPlanner(specPath: string, env?: NodeJS.ProcessEnv): ((specText:
agent: "planner",
model: criticModel(e),
env: e,
// Plan generation is a bigger output than a critic pass (a full gated
// plan JSON, not findings) — measured beyond the 240s critic ceiling on
// a free-tier model, so the planner gets its own ceiling (2026-07-31).
timeoutMs: PLANNER_TIMEOUT_MS,
prompt: "Compile the spec in your working directory into a plan. Reply with the JSON object only.",
specText,
specFilename: specPath.split("/").pop() ?? "spec.md",
});
}

/** Planner-only spawn ceiling: ~2.7× the measured 178s critic pass at the same
* tier (2026-07-31). Anything longer is a hung child, not a slow answer. */
export const PLANNER_TIMEOUT_MS = 480_000;
13 changes: 13 additions & 0 deletions packages/amico-run/test/agent_spawn.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -348,4 +348,17 @@ describe("buildChildEnv", () => {
it("lets explicit extras through", () => {
expect(buildChildEnv({ HOME: "/h" }, { OPENCODE_CONFIG_CONTENT: "{}" }).OPENCODE_CONFIG_CONTENT).toBe("{}");
});
it("strips live-session pointers even though they carry the OPENCODE_ prefix", () => {
// Regression: spawned from inside a live Amicode session, these vars made
// the child's headless `run` resolve the PARENT's session → "Session not
// found", killing every critic before it started. Config vars must stay.
const env = buildChildEnv({
HOME: "/h",
OPENCODE: "1",
OPENCODE_PID: "3434",
OPENCODE_SERVER_PASSWORD: "live-pw",
OPENCODE_CONFIG_CONTENT: "{}",
});
expect(env).toEqual({ HOME: "/h", OPENCODE_CONFIG_CONTENT: "{}" });
});
});
12 changes: 12 additions & 0 deletions packages/extension/esbuild.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,18 @@ const targets = [
logLevel: "info",
loader: { ".svg": "text" },
},
// Chat Deck webview bundle — pane-manager shell (src/deck/shell.ts + model)
{
entryPoints: ["src/deck/shell.ts"],
bundle: true,
platform: "browser",
target: "es2022",
format: "iife",
outfile: "dist/deck_shell.js",
sourcemap: true,
minify: false,
logLevel: "info",
},
];

if (watch) {
Expand Down
12 changes: 12 additions & 0 deletions packages/extension/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@
],
"activationEvents": [
"onCommand:amicode.openChat",
"onCommand:amicode.newChat",
"onCommand:amicode.chatDeck",
"onCommand:amicode.openInspector",
"onView:amicode.runInspector",
"onView:amicode.deviceInspector",
Expand DownExpand Up@@ -92,6 +94,16 @@
"title": "Amicode: Open Chat",
"icon": "$(comment-discussion)"
},
{
"command": "amicode.newChat",
"title": "Amicode: New Chat (Side by Side)",
"icon": "$(add)"
},
{
"command": "amicode.chatDeck",
"title": "Amicode: Open Chat Deck (Panes in One Tab)",
"icon": "$(layout)"
},
{
"command": "amicode.setupVault",
"title": "Amicode: Set up a personal vault"
Expand Down
138 changes: 138 additions & 0 deletions packages/extension/src/chat_bridge.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as vscode from "vscode";
import * as path from "node:path";
import * as os from "node:os";

// ============================================================================
// The amicode iframe⇄extension command bridge, shared by ChatPanel (one
// iframe) and DeckPanel (N panes). The framed app renders LLM output, so the
// handler is paranoid by construction: strict command allowlist, https-only
// externals, visibility-gated clipboard reads, bounded payloads. Panes tag
// their messages with an opaque `tab` id; replies ECHO it so the shell can
// route the answer back to the asking pane. Single-iframe panels leave `tab`
// undefined and their relay simply forwards to the one iframe.
// ============================================================================

// Commands the in-app palette (opencode "Amico" command group) may trigger via
// the iframe→parent→extension postMessage bridge. STRICT allowlist: the framed
// app renders LLM output, so we never executeCommand anything outside this set.
export const BRIDGE_ALLOWED_COMMANDS: ReadonlySet<string> = new Set([
"amicode.restartServer",
"amicode.distillNow",
"amicode.stopRun",
"amicode.savePulse",
"amicode.openRunDir",
"amicode.openInspector",
// ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's —
// the fork forwards it here so the editor's Command Palette (where every
// Amicode: command lives) opens as users expect.
"workbench.action.showCommands",
]);

/** Side channels the handler needs from its host panel. */
export interface BridgeIo {
/** Clipboard reads only answer while the user can see the chat. */
visible(): boolean;
/** Replies (clipboard text) go back to the host webview; `tab` echoes along. */
postToWebview(msg: unknown): void;
}

const isAmicode = (msg: unknown): msg is { source: "amicode"; kind: string; tab?: string } =>
!!msg && typeof msg === "object" && (msg as { source?: unknown }).source === "amicode";

/** Handle one envelope from a framed app. Returns true when the message was
* consumed (hosts log the rest). */
export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean {
if (!isAmicode(msg)) return false;

// target=_blank/window.open are dead inside the framed app — open https
// links via the editor (system browser). https-only; scheme is
// case-insensitive (RFC 3986).
if (
msg.kind === "open-external" &&
typeof (msg as { url?: unknown }).url === "string" &&
/^https:\/\//i.test((msg as unknown as { url: string }).url)
) {
void vscode.env.openExternal(vscode.Uri.parse((msg as unknown as { url: string }).url));
return true;
}

// Paste bridge: navigator.clipboard is unavailable to the framed app (the
// webview parent has no clipboard-read to delegate), so the app asks US —
// the extension host reads the OS clipboard and replies. Visibility gate:
// the app renders LLM-driven content, so a hidden panel must not be able to
// sample the clipboard in the background.
if (msg.kind === "clipboard-request") {
if (!io.visible()) return true;
void vscode.env.clipboard.readText().then((text) =>
io.postToWebview({
source: "amicode",
kind: "clipboard",
nonce: (msg as { nonce?: string }).nonce,
text,
tab: msg.tab,
}),
);
return true;
}

// Copy bridge (mirror of clipboard-request): the framed app's native copy
// can't reach the OS clipboard, so an in-chat ⌘C posts the text here and we
// write it via vscode.env.clipboard — otherwise the paste bridge above reads
// back stale content. Same visibility gate; payload is untrusted, so bound it.
if (msg.kind === "clipboard-write" && typeof (msg as { text?: unknown }).text === "string") {
if (!io.visible()) return true;
const text = (msg as unknown as { text: string }).text;
if (text.length > 5_000_000) return true;
void vscode.env.clipboard.writeText(text);
return true;
}

// Save bridge (run-card PNG export): downloads are dead inside the framed
// app — the extension shows a save dialog and writes the file. PNG-only,
// basename-only, bounded size: the payload is untrusted.
if (
msg.kind === "save-file" &&
typeof (msg as { filename?: unknown }).filename === "string" &&
typeof (msg as { dataUrl?: unknown }).dataUrl === "string"
) {
const raw = msg as unknown as { filename: string; dataUrl: string };
const prefix = "data:image/png;base64,";
const base64 = raw.dataUrl.startsWith(prefix) ? raw.dataUrl.slice(prefix.length) : undefined;
const name = path.basename(raw.filename).replace(/[^\w.-]+/g, "-");
if (!base64 || base64.length > 24_000_000 || !name.endsWith(".png")) return true;
void (async () => {
const target = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)),
filters: { Images: ["png"] },
});
if (!target) return;
await vscode.workspace.fs.writeFile(target, Buffer.from(base64, "base64"));
const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal");
if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target);
})();
return true;
}

// The "Amico" palette group — allowlisted commands only.
if (msg.kind === "command") {
const command = (msg as unknown as { command?: unknown }).command;
if (typeof command === "string" && BRIDGE_ALLOWED_COMMANDS.has(command)) {
void vscode.commands.executeCommand(command);
return true;
}
return false;
}

// Dashboard "Default model" control mirrors its choice into the
// amicode.defaultModel setting, so the config pin (headless / first turn)
// tracks the UI. "provider/model-id" only, bounded — untrusted.
if (msg.kind === "set-default-model" && typeof (msg as { model?: unknown }).model === "string") {
const model = (msg as unknown as { model: string }).model.trim();
if (model.length > 0 && model.length <= 200 && /^[\w.-]+\/[\w.:-]+$/.test(model)) {
void vscode.workspace.getConfiguration("amicode").update("defaultModel", model, vscode.ConfigurationTarget.Global);
}
return true;
}

return false;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
16 changes: 14 additions & 2 deletions packages/amico-run/src/agent_spawn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,8 +31,12 @@ import type { Finding } from "./lenses.js";

export const DEFAULT_CRITIC_MODEL = "anthropic/claude-opus-5";
export const DEFAULT_CRITIC_VARIANT = "high";
/** Per-child ceiling (§3.7). A critic that has not answered in two minutes has failed. */
export const CRITIC_TIMEOUT_MS = 120_000;
/** Per-child ceiling (§3.7). A critic that has not answered in four minutes has failed.
* 240s, not 120s: a frontier-class review of a full spec on a free-tier model takes
* ~3 minutes measured (178s for a 13k-token decomposition pass, 2026-07-31) — the
* two-minute ceiling read legitimate slow answers as failures and silently degraded
* every review to approved-mechanical/degraded. */
export const CRITIC_TIMEOUT_MS = 240_000;

/** Why a lens has no findings, which is NOT the same question as whether it ran.
*
Expand DownExpand Up@@ -99,6 +103,13 @@ export function resolveAgentBin(env: NodeJS.ProcessEnv = process.env): string |
* already authenticated against. */
const ENV_ALLOWLIST = ["HOME", "PATH", "TMPDIR", "SHELL", "LANG", "LC_ALL", "TERM"];
const ENV_ALLOW_PREFIXES = ["XDG_", "OPENCODE_"];
/** Live-session pointers: when amico runs INSIDE a live Amicode session, these
* ride the OPENCODE_ prefix allowance into the child, and the child's headless
* `run` tries to resolve the PARENT's session — failing with "Session not
* found" before the critic ever starts. The child spawns its own runtime; the
* parent's session pointers are never valid for it. Config vars
* (OPENCODE_CONFIG_CONTENT/DIR) stay — those are the legitimate prefix users. */
const ENV_DENYLIST = new Set(["OPENCODE", "OPENCODE_PID", "OPENCODE_SERVER_PASSWORD"]);

export function buildChildEnv(
parent: NodeJS.ProcessEnv = process.env,
Expand All@@ -107,6 +118,7 @@ export function buildChildEnv(
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(parent)) {
if (v === undefined) continue;
if (ENV_DENYLIST.has(k)) continue;
if (ENV_ALLOWLIST.includes(k) || ENV_ALLOW_PREFIXES.some((p) => k.startsWith(p))) out[k] = v;
}
return { ...out, ...extra };
Expand Down
8 changes: 8 additions & 0 deletions packages/amico-run/src/plan_compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -469,8 +469,16 @@ function defaultPlanner(specPath: string, env?: NodeJS.ProcessEnv): ((specText:
agent: "planner",
model: criticModel(e),
env: e,
// Plan generation is a bigger output than a critic pass (a full gated
// plan JSON, not findings) — measured beyond the 240s critic ceiling on
// a free-tier model, so the planner gets its own ceiling (2026-07-31).
timeoutMs: PLANNER_TIMEOUT_MS,
prompt: "Compile the spec in your working directory into a plan. Reply with the JSON object only.",
specText,
specFilename: specPath.split("/").pop() ?? "spec.md",
});
}

/** Planner-only spawn ceiling: ~2.7× the measured 178s critic pass at the same
* tier (2026-07-31). Anything longer is a hung child, not a slow answer. */
export const PLANNER_TIMEOUT_MS = 480_000;
13 changes: 13 additions & 0 deletions packages/amico-run/test/agent_spawn.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -348,4 +348,17 @@ describe("buildChildEnv", () => {
it("lets explicit extras through", () => {
expect(buildChildEnv({ HOME: "/h" }, { OPENCODE_CONFIG_CONTENT: "{}" }).OPENCODE_CONFIG_CONTENT).toBe("{}");
});
it("strips live-session pointers even though they carry the OPENCODE_ prefix", () => {
// Regression: spawned from inside a live Amicode session, these vars made
// the child's headless `run` resolve the PARENT's session → "Session not
// found", killing every critic before it started. Config vars must stay.
const env = buildChildEnv({
HOME: "/h",
OPENCODE: "1",
OPENCODE_PID: "3434",
OPENCODE_SERVER_PASSWORD: "live-pw",
OPENCODE_CONFIG_CONTENT: "{}",
});
expect(env).toEqual({ HOME: "/h", OPENCODE_CONFIG_CONTENT: "{}" });
});
});
12 changes: 12 additions & 0 deletions packages/extension/esbuild.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,18 @@ const targets = [
logLevel: "info",
loader: { ".svg": "text" },
},
// Chat Deck webview bundle — pane-manager shell (src/deck/shell.ts + model)
{
entryPoints: ["src/deck/shell.ts"],
bundle: true,
platform: "browser",
target: "es2022",
format: "iife",
outfile: "dist/deck_shell.js",
sourcemap: true,
minify: false,
logLevel: "info",
},
];

if (watch) {
Expand Down
12 changes: 12 additions & 0 deletions packages/extension/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@
],
"activationEvents": [
"onCommand:amicode.openChat",
"onCommand:amicode.newChat",
"onCommand:amicode.chatDeck",
"onCommand:amicode.openInspector",
"onView:amicode.runInspector",
"onView:amicode.deviceInspector",
Expand DownExpand Up@@ -92,6 +94,16 @@
"title": "Amicode: Open Chat",
"icon": "$(comment-discussion)"
},
{
"command": "amicode.newChat",
"title": "Amicode: New Chat (Side by Side)",
"icon": "$(add)"
},
{
"command": "amicode.chatDeck",
"title": "Amicode: Open Chat Deck (Panes in One Tab)",
"icon": "$(layout)"
},
{
"command": "amicode.setupVault",
"title": "Amicode: Set up a personal vault"
Expand Down
138 changes: 138 additions & 0 deletions packages/extension/src/chat_bridge.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as vscode from "vscode";
import * as path from "node:path";
import * as os from "node:os";

// ============================================================================
// The amicode iframe⇄extension command bridge, shared by ChatPanel (one
// iframe) and DeckPanel (N panes). The framed app renders LLM output, so the
// handler is paranoid by construction: strict command allowlist, https-only
// externals, visibility-gated clipboard reads, bounded payloads. Panes tag
// their messages with an opaque `tab` id; replies ECHO it so the shell can
// route the answer back to the asking pane. Single-iframe panels leave `tab`
// undefined and their relay simply forwards to the one iframe.
// ============================================================================

// Commands the in-app palette (opencode "Amico" command group) may trigger via
// the iframe→parent→extension postMessage bridge. STRICT allowlist: the framed
// app renders LLM output, so we never executeCommand anything outside this set.
export const BRIDGE_ALLOWED_COMMANDS: ReadonlySet<string> = new Set([
"amicode.restartServer",
"amicode.distillNow",
"amicode.stopRun",
"amicode.savePulse",
"amicode.openRunDir",
"amicode.openInspector",
// ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's —
// the fork forwards it here so the editor's Command Palette (where every
// Amicode: command lives) opens as users expect.
"workbench.action.showCommands",
]);

/** Side channels the handler needs from its host panel. */
export interface BridgeIo {
/** Clipboard reads only answer while the user can see the chat. */
visible(): boolean;
/** Replies (clipboard text) go back to the host webview; `tab` echoes along. */
postToWebview(msg: unknown): void;
}

const isAmicode = (msg: unknown): msg is { source: "amicode"; kind: string; tab?: string } =>
!!msg && typeof msg === "object" && (msg as { source?: unknown }).source === "amicode";

/** Handle one envelope from a framed app. Returns true when the message was
* consumed (hosts log the rest). */
export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean {
if (!isAmicode(msg)) return false;

// target=_blank/window.open are dead inside the framed app — open https
// links via the editor (system browser). https-only; scheme is
// case-insensitive (RFC 3986).
if (
msg.kind === "open-external" &&
typeof (msg as { url?: unknown }).url === "string" &&
/^https:\/\//i.test((msg as unknown as { url: string }).url)
) {
void vscode.env.openExternal(vscode.Uri.parse((msg as unknown as { url: string }).url));
return true;
}

// Paste bridge: navigator.clipboard is unavailable to the framed app (the
// webview parent has no clipboard-read to delegate), so the app asks US —
// the extension host reads the OS clipboard and replies. Visibility gate:
// the app renders LLM-driven content, so a hidden panel must not be able to
// sample the clipboard in the background.
if (msg.kind === "clipboard-request") {
if (!io.visible()) return true;
void vscode.env.clipboard.readText().then((text) =>
io.postToWebview({
source: "amicode",
kind: "clipboard",
nonce: (msg as { nonce?: string }).nonce,
text,
tab: msg.tab,
}),
);
return true;
}

// Copy bridge (mirror of clipboard-request): the framed app's native copy
// can't reach the OS clipboard, so an in-chat ⌘C posts the text here and we
// write it via vscode.env.clipboard — otherwise the paste bridge above reads
// back stale content. Same visibility gate; payload is untrusted, so bound it.
if (msg.kind === "clipboard-write" && typeof (msg as { text?: unknown }).text === "string") {
if (!io.visible()) return true;
const text = (msg as unknown as { text: string }).text;
if (text.length > 5_000_000) return true;
void vscode.env.clipboard.writeText(text);
return true;
}

// Save bridge (run-card PNG export): downloads are dead inside the framed
// app — the extension shows a save dialog and writes the file. PNG-only,
// basename-only, bounded size: the payload is untrusted.
if (
msg.kind === "save-file" &&
typeof (msg as { filename?: unknown }).filename === "string" &&
typeof (msg as { dataUrl?: unknown }).dataUrl === "string"
) {
const raw = msg as unknown as { filename: string; dataUrl: string };
const prefix = "data:image/png;base64,";
const base64 = raw.dataUrl.startsWith(prefix) ? raw.dataUrl.slice(prefix.length) : undefined;
const name = path.basename(raw.filename).replace(/[^\w.-]+/g, "-");
if (!base64 || base64.length > 24_000_000 || !name.endsWith(".png")) return true;
void (async () => {
const target = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)),
filters: { Images: ["png"] },
});
if (!target) return;
await vscode.workspace.fs.writeFile(target, Buffer.from(base64, "base64"));
const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal");
if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target);
})();
return true;
}

// The "Amico" palette group — allowlisted commands only.
if (msg.kind === "command") {
const command = (msg as unknown as { command?: unknown }).command;
if (typeof command === "string" && BRIDGE_ALLOWED_COMMANDS.has(command)) {
void vscode.commands.executeCommand(command);
return true;
}
return false;
}

// Dashboard "Default model" control mirrors its choice into the
// amicode.defaultModel setting, so the config pin (headless / first turn)
// tracks the UI. "provider/model-id" only, bounded — untrusted.
if (msg.kind === "set-default-model" && typeof (msg as { model?: unknown }).model === "string") {
const model = (msg as unknown as { model: string }).model.trim();
if (model.length > 0 && model.length <= 200 && /^[\w.-]+\/[\w.:-]+$/.test(model)) {
void vscode.workspace.getConfiguration("amicode").update("defaultModel", model, vscode.ConfigurationTarget.Global);
}
return true;
}

return false;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
16 changes: 14 additions & 2 deletions packages/amico-run/src/agent_spawn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,8 +31,12 @@ import type { Finding } from "./lenses.js";

export const DEFAULT_CRITIC_MODEL = "anthropic/claude-opus-5";
export const DEFAULT_CRITIC_VARIANT = "high";
/** Per-child ceiling (§3.7). A critic that has not answered in two minutes has failed. */
export const CRITIC_TIMEOUT_MS = 120_000;
/** Per-child ceiling (§3.7). A critic that has not answered in four minutes has failed.
* 240s, not 120s: a frontier-class review of a full spec on a free-tier model takes
* ~3 minutes measured (178s for a 13k-token decomposition pass, 2026-07-31) — the
* two-minute ceiling read legitimate slow answers as failures and silently degraded
* every review to approved-mechanical/degraded. */
export const CRITIC_TIMEOUT_MS = 240_000;

/** Why a lens has no findings, which is NOT the same question as whether it ran.
*
Expand DownExpand Up@@ -99,6 +103,13 @@ export function resolveAgentBin(env: NodeJS.ProcessEnv = process.env): string |
* already authenticated against. */
const ENV_ALLOWLIST = ["HOME", "PATH", "TMPDIR", "SHELL", "LANG", "LC_ALL", "TERM"];
const ENV_ALLOW_PREFIXES = ["XDG_", "OPENCODE_"];
/** Live-session pointers: when amico runs INSIDE a live Amicode session, these
* ride the OPENCODE_ prefix allowance into the child, and the child's headless
* `run` tries to resolve the PARENT's session — failing with "Session not
* found" before the critic ever starts. The child spawns its own runtime; the
* parent's session pointers are never valid for it. Config vars
* (OPENCODE_CONFIG_CONTENT/DIR) stay — those are the legitimate prefix users. */
const ENV_DENYLIST = new Set(["OPENCODE", "OPENCODE_PID", "OPENCODE_SERVER_PASSWORD"]);

export function buildChildEnv(
parent: NodeJS.ProcessEnv = process.env,
Expand All@@ -107,6 +118,7 @@ export function buildChildEnv(
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(parent)) {
if (v === undefined) continue;
if (ENV_DENYLIST.has(k)) continue;
if (ENV_ALLOWLIST.includes(k) || ENV_ALLOW_PREFIXES.some((p) => k.startsWith(p))) out[k] = v;
}
return { ...out, ...extra };
Expand Down
8 changes: 8 additions & 0 deletions packages/amico-run/src/plan_compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -469,8 +469,16 @@ function defaultPlanner(specPath: string, env?: NodeJS.ProcessEnv): ((specText:
agent: "planner",
model: criticModel(e),
env: e,
// Plan generation is a bigger output than a critic pass (a full gated
// plan JSON, not findings) — measured beyond the 240s critic ceiling on
// a free-tier model, so the planner gets its own ceiling (2026-07-31).
timeoutMs: PLANNER_TIMEOUT_MS,
prompt: "Compile the spec in your working directory into a plan. Reply with the JSON object only.",
specText,
specFilename: specPath.split("/").pop() ?? "spec.md",
});
}

/** Planner-only spawn ceiling: ~2.7× the measured 178s critic pass at the same
* tier (2026-07-31). Anything longer is a hung child, not a slow answer. */
export const PLANNER_TIMEOUT_MS = 480_000;
13 changes: 13 additions & 0 deletions packages/amico-run/test/agent_spawn.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -348,4 +348,17 @@ describe("buildChildEnv", () => {
it("lets explicit extras through", () => {
expect(buildChildEnv({ HOME: "/h" }, { OPENCODE_CONFIG_CONTENT: "{}" }).OPENCODE_CONFIG_CONTENT).toBe("{}");
});
it("strips live-session pointers even though they carry the OPENCODE_ prefix", () => {
// Regression: spawned from inside a live Amicode session, these vars made
// the child's headless `run` resolve the PARENT's session → "Session not
// found", killing every critic before it started. Config vars must stay.
const env = buildChildEnv({
HOME: "/h",
OPENCODE: "1",
OPENCODE_PID: "3434",
OPENCODE_SERVER_PASSWORD: "live-pw",
OPENCODE_CONFIG_CONTENT: "{}",
});
expect(env).toEqual({ HOME: "/h", OPENCODE_CONFIG_CONTENT: "{}" });
});
});
12 changes: 12 additions & 0 deletions packages/extension/esbuild.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,18 @@ const targets = [
logLevel: "info",
loader: { ".svg": "text" },
},
// Chat Deck webview bundle — pane-manager shell (src/deck/shell.ts + model)
{
entryPoints: ["src/deck/shell.ts"],
bundle: true,
platform: "browser",
target: "es2022",
format: "iife",
outfile: "dist/deck_shell.js",
sourcemap: true,
minify: false,
logLevel: "info",
},
];

if (watch) {
Expand Down
12 changes: 12 additions & 0 deletions packages/extension/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@
],
"activationEvents": [
"onCommand:amicode.openChat",
"onCommand:amicode.newChat",
"onCommand:amicode.chatDeck",
"onCommand:amicode.openInspector",
"onView:amicode.runInspector",
"onView:amicode.deviceInspector",
Expand DownExpand Up@@ -92,6 +94,16 @@
"title": "Amicode: Open Chat",
"icon": "$(comment-discussion)"
},
{
"command": "amicode.newChat",
"title": "Amicode: New Chat (Side by Side)",
"icon": "$(add)"
},
{
"command": "amicode.chatDeck",
"title": "Amicode: Open Chat Deck (Panes in One Tab)",
"icon": "$(layout)"
},
{
"command": "amicode.setupVault",
"title": "Amicode: Set up a personal vault"
Expand Down
138 changes: 138 additions & 0 deletions packages/extension/src/chat_bridge.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as vscode from "vscode";
import * as path from "node:path";
import * as os from "node:os";

// ============================================================================
// The amicode iframe⇄extension command bridge, shared by ChatPanel (one
// iframe) and DeckPanel (N panes). The framed app renders LLM output, so the
// handler is paranoid by construction: strict command allowlist, https-only
// externals, visibility-gated clipboard reads, bounded payloads. Panes tag
// their messages with an opaque `tab` id; replies ECHO it so the shell can
// route the answer back to the asking pane. Single-iframe panels leave `tab`
// undefined and their relay simply forwards to the one iframe.
// ============================================================================

// Commands the in-app palette (opencode "Amico" command group) may trigger via
// the iframe→parent→extension postMessage bridge. STRICT allowlist: the framed
// app renders LLM output, so we never executeCommand anything outside this set.
export const BRIDGE_ALLOWED_COMMANDS: ReadonlySet<string> = new Set([
"amicode.restartServer",
"amicode.distillNow",
"amicode.stopRun",
"amicode.savePulse",
"amicode.openRunDir",
"amicode.openInspector",
// ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's —
// the fork forwards it here so the editor's Command Palette (where every
// Amicode: command lives) opens as users expect.
"workbench.action.showCommands",
]);

/** Side channels the handler needs from its host panel. */
export interface BridgeIo {
/** Clipboard reads only answer while the user can see the chat. */
visible(): boolean;
/** Replies (clipboard text) go back to the host webview; `tab` echoes along. */
postToWebview(msg: unknown): void;
}

const isAmicode = (msg: unknown): msg is { source: "amicode"; kind: string; tab?: string } =>
!!msg && typeof msg === "object" && (msg as { source?: unknown }).source === "amicode";

/** Handle one envelope from a framed app. Returns true when the message was
* consumed (hosts log the rest). */
export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean {
if (!isAmicode(msg)) return false;

// target=_blank/window.open are dead inside the framed app — open https
// links via the editor (system browser). https-only; scheme is
// case-insensitive (RFC 3986).
if (
msg.kind === "open-external" &&
typeof (msg as { url?: unknown }).url === "string" &&
/^https:\/\//i.test((msg as unknown as { url: string }).url)
) {
void vscode.env.openExternal(vscode.Uri.parse((msg as unknown as { url: string }).url));
return true;
}

// Paste bridge: navigator.clipboard is unavailable to the framed app (the
// webview parent has no clipboard-read to delegate), so the app asks US —
// the extension host reads the OS clipboard and replies. Visibility gate:
// the app renders LLM-driven content, so a hidden panel must not be able to
// sample the clipboard in the background.
if (msg.kind === "clipboard-request") {
if (!io.visible()) return true;
void vscode.env.clipboard.readText().then((text) =>
io.postToWebview({
source: "amicode",
kind: "clipboard",
nonce: (msg as { nonce?: string }).nonce,
text,
tab: msg.tab,
}),
);
return true;
}

// Copy bridge (mirror of clipboard-request): the framed app's native copy
// can't reach the OS clipboard, so an in-chat ⌘C posts the text here and we
// write it via vscode.env.clipboard — otherwise the paste bridge above reads
// back stale content. Same visibility gate; payload is untrusted, so bound it.
if (msg.kind === "clipboard-write" && typeof (msg as { text?: unknown }).text === "string") {
if (!io.visible()) return true;
const text = (msg as unknown as { text: string }).text;
if (text.length > 5_000_000) return true;
void vscode.env.clipboard.writeText(text);
return true;
}

// Save bridge (run-card PNG export): downloads are dead inside the framed
// app — the extension shows a save dialog and writes the file. PNG-only,
// basename-only, bounded size: the payload is untrusted.
if (
msg.kind === "save-file" &&
typeof (msg as { filename?: unknown }).filename === "string" &&
typeof (msg as { dataUrl?: unknown }).dataUrl === "string"
) {
const raw = msg as unknown as { filename: string; dataUrl: string };
const prefix = "data:image/png;base64,";
const base64 = raw.dataUrl.startsWith(prefix) ? raw.dataUrl.slice(prefix.length) : undefined;
const name = path.basename(raw.filename).replace(/[^\w.-]+/g, "-");
if (!base64 || base64.length > 24_000_000 || !name.endsWith(".png")) return true;
void (async () => {
const target = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)),
filters: { Images: ["png"] },
});
if (!target) return;
await vscode.workspace.fs.writeFile(target, Buffer.from(base64, "base64"));
const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal");
if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target);
})();
return true;
}

// The "Amico" palette group — allowlisted commands only.
if (msg.kind === "command") {
const command = (msg as unknown as { command?: unknown }).command;
if (typeof command === "string" && BRIDGE_ALLOWED_COMMANDS.has(command)) {
void vscode.commands.executeCommand(command);
return true;
}
return false;
}

// Dashboard "Default model" control mirrors its choice into the
// amicode.defaultModel setting, so the config pin (headless / first turn)
// tracks the UI. "provider/model-id" only, bounded — untrusted.
if (msg.kind === "set-default-model" && typeof (msg as { model?: unknown }).model === "string") {
const model = (msg as unknown as { model: string }).model.trim();
if (model.length > 0 && model.length <= 200 && /^[\w.-]+\/[\w.:-]+$/.test(model)) {
void vscode.workspace.getConfiguration("amicode").update("defaultModel", model, vscode.ConfigurationTarget.Global);
}
return true;
}

return false;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
16 changes: 14 additions & 2 deletions packages/amico-run/src/agent_spawn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,8 +31,12 @@ import type { Finding } from "./lenses.js";

export const DEFAULT_CRITIC_MODEL = "anthropic/claude-opus-5";
export const DEFAULT_CRITIC_VARIANT = "high";
/** Per-child ceiling (§3.7). A critic that has not answered in two minutes has failed. */
export const CRITIC_TIMEOUT_MS = 120_000;
/** Per-child ceiling (§3.7). A critic that has not answered in four minutes has failed.
* 240s, not 120s: a frontier-class review of a full spec on a free-tier model takes
* ~3 minutes measured (178s for a 13k-token decomposition pass, 2026-07-31) — the
* two-minute ceiling read legitimate slow answers as failures and silently degraded
* every review to approved-mechanical/degraded. */
export const CRITIC_TIMEOUT_MS = 240_000;

/** Why a lens has no findings, which is NOT the same question as whether it ran.
*
Expand DownExpand Up@@ -99,6 +103,13 @@ export function resolveAgentBin(env: NodeJS.ProcessEnv = process.env): string |
* already authenticated against. */
const ENV_ALLOWLIST = ["HOME", "PATH", "TMPDIR", "SHELL", "LANG", "LC_ALL", "TERM"];
const ENV_ALLOW_PREFIXES = ["XDG_", "OPENCODE_"];
/** Live-session pointers: when amico runs INSIDE a live Amicode session, these
* ride the OPENCODE_ prefix allowance into the child, and the child's headless
* `run` tries to resolve the PARENT's session — failing with "Session not
* found" before the critic ever starts. The child spawns its own runtime; the
* parent's session pointers are never valid for it. Config vars
* (OPENCODE_CONFIG_CONTENT/DIR) stay — those are the legitimate prefix users. */
const ENV_DENYLIST = new Set(["OPENCODE", "OPENCODE_PID", "OPENCODE_SERVER_PASSWORD"]);

export function buildChildEnv(
parent: NodeJS.ProcessEnv = process.env,
Expand All@@ -107,6 +118,7 @@ export function buildChildEnv(
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(parent)) {
if (v === undefined) continue;
if (ENV_DENYLIST.has(k)) continue;
if (ENV_ALLOWLIST.includes(k) || ENV_ALLOW_PREFIXES.some((p) => k.startsWith(p))) out[k] = v;
}
return { ...out, ...extra };
Expand Down
8 changes: 8 additions & 0 deletions packages/amico-run/src/plan_compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -469,8 +469,16 @@ function defaultPlanner(specPath: string, env?: NodeJS.ProcessEnv): ((specText:
agent: "planner",
model: criticModel(e),
env: e,
// Plan generation is a bigger output than a critic pass (a full gated
// plan JSON, not findings) — measured beyond the 240s critic ceiling on
// a free-tier model, so the planner gets its own ceiling (2026-07-31).
timeoutMs: PLANNER_TIMEOUT_MS,
prompt: "Compile the spec in your working directory into a plan. Reply with the JSON object only.",
specText,
specFilename: specPath.split("/").pop() ?? "spec.md",
});
}

/** Planner-only spawn ceiling: ~2.7× the measured 178s critic pass at the same
* tier (2026-07-31). Anything longer is a hung child, not a slow answer. */
export const PLANNER_TIMEOUT_MS = 480_000;
13 changes: 13 additions & 0 deletions packages/amico-run/test/agent_spawn.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -348,4 +348,17 @@ describe("buildChildEnv", () => {
it("lets explicit extras through", () => {
expect(buildChildEnv({ HOME: "/h" }, { OPENCODE_CONFIG_CONTENT: "{}" }).OPENCODE_CONFIG_CONTENT).toBe("{}");
});
it("strips live-session pointers even though they carry the OPENCODE_ prefix", () => {
// Regression: spawned from inside a live Amicode session, these vars made
// the child's headless `run` resolve the PARENT's session → "Session not
// found", killing every critic before it started. Config vars must stay.
const env = buildChildEnv({
HOME: "/h",
OPENCODE: "1",
OPENCODE_PID: "3434",
OPENCODE_SERVER_PASSWORD: "live-pw",
OPENCODE_CONFIG_CONTENT: "{}",
});
expect(env).toEqual({ HOME: "/h", OPENCODE_CONFIG_CONTENT: "{}" });
});
});
12 changes: 12 additions & 0 deletions packages/extension/esbuild.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,18 @@ const targets = [
logLevel: "info",
loader: { ".svg": "text" },
},
// Chat Deck webview bundle — pane-manager shell (src/deck/shell.ts + model)
{
entryPoints: ["src/deck/shell.ts"],
bundle: true,
platform: "browser",
target: "es2022",
format: "iife",
outfile: "dist/deck_shell.js",
sourcemap: true,
minify: false,
logLevel: "info",
},
];

if (watch) {
Expand Down
12 changes: 12 additions & 0 deletions packages/extension/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@
],
"activationEvents": [
"onCommand:amicode.openChat",
"onCommand:amicode.newChat",
"onCommand:amicode.chatDeck",
"onCommand:amicode.openInspector",
"onView:amicode.runInspector",
"onView:amicode.deviceInspector",
Expand DownExpand Up@@ -92,6 +94,16 @@
"title": "Amicode: Open Chat",
"icon": "$(comment-discussion)"
},
{
"command": "amicode.newChat",
"title": "Amicode: New Chat (Side by Side)",
"icon": "$(add)"
},
{
"command": "amicode.chatDeck",
"title": "Amicode: Open Chat Deck (Panes in One Tab)",
"icon": "$(layout)"
},
{
"command": "amicode.setupVault",
"title": "Amicode: Set up a personal vault"
Expand Down
138 changes: 138 additions & 0 deletions packages/extension/src/chat_bridge.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as vscode from "vscode";
import * as path from "node:path";
import * as os from "node:os";

// ============================================================================
// The amicode iframe⇄extension command bridge, shared by ChatPanel (one
// iframe) and DeckPanel (N panes). The framed app renders LLM output, so the
// handler is paranoid by construction: strict command allowlist, https-only
// externals, visibility-gated clipboard reads, bounded payloads. Panes tag
// their messages with an opaque `tab` id; replies ECHO it so the shell can
// route the answer back to the asking pane. Single-iframe panels leave `tab`
// undefined and their relay simply forwards to the one iframe.
// ============================================================================

// Commands the in-app palette (opencode "Amico" command group) may trigger via
// the iframe→parent→extension postMessage bridge. STRICT allowlist: the framed
// app renders LLM output, so we never executeCommand anything outside this set.
export const BRIDGE_ALLOWED_COMMANDS: ReadonlySet<string> = new Set([
"amicode.restartServer",
"amicode.distillNow",
"amicode.stopRun",
"amicode.savePulse",
"amicode.openRunDir",
"amicode.openInspector",
// ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's —
// the fork forwards it here so the editor's Command Palette (where every
// Amicode: command lives) opens as users expect.
"workbench.action.showCommands",
]);

/** Side channels the handler needs from its host panel. */
export interface BridgeIo {
/** Clipboard reads only answer while the user can see the chat. */
visible(): boolean;
/** Replies (clipboard text) go back to the host webview; `tab` echoes along. */
postToWebview(msg: unknown): void;
}

const isAmicode = (msg: unknown): msg is { source: "amicode"; kind: string; tab?: string } =>
!!msg && typeof msg === "object" && (msg as { source?: unknown }).source === "amicode";

/** Handle one envelope from a framed app. Returns true when the message was
* consumed (hosts log the rest). */
export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean {
if (!isAmicode(msg)) return false;

// target=_blank/window.open are dead inside the framed app — open https
// links via the editor (system browser). https-only; scheme is
// case-insensitive (RFC 3986).
if (
msg.kind === "open-external" &&
typeof (msg as { url?: unknown }).url === "string" &&
/^https:\/\//i.test((msg as unknown as { url: string }).url)
) {
void vscode.env.openExternal(vscode.Uri.parse((msg as unknown as { url: string }).url));
return true;
}

// Paste bridge: navigator.clipboard is unavailable to the framed app (the
// webview parent has no clipboard-read to delegate), so the app asks US —
// the extension host reads the OS clipboard and replies. Visibility gate:
// the app renders LLM-driven content, so a hidden panel must not be able to
// sample the clipboard in the background.
if (msg.kind === "clipboard-request") {
if (!io.visible()) return true;
void vscode.env.clipboard.readText().then((text) =>
io.postToWebview({
source: "amicode",
kind: "clipboard",
nonce: (msg as { nonce?: string }).nonce,
text,
tab: msg.tab,
}),
);
return true;
}

// Copy bridge (mirror of clipboard-request): the framed app's native copy
// can't reach the OS clipboard, so an in-chat ⌘C posts the text here and we
// write it via vscode.env.clipboard — otherwise the paste bridge above reads
// back stale content. Same visibility gate; payload is untrusted, so bound it.
if (msg.kind === "clipboard-write" && typeof (msg as { text?: unknown }).text === "string") {
if (!io.visible()) return true;
const text = (msg as unknown as { text: string }).text;
if (text.length > 5_000_000) return true;
void vscode.env.clipboard.writeText(text);
return true;
}

// Save bridge (run-card PNG export): downloads are dead inside the framed
// app — the extension shows a save dialog and writes the file. PNG-only,
// basename-only, bounded size: the payload is untrusted.
if (
msg.kind === "save-file" &&
typeof (msg as { filename?: unknown }).filename === "string" &&
typeof (msg as { dataUrl?: unknown }).dataUrl === "string"
) {
const raw = msg as unknown as { filename: string; dataUrl: string };
const prefix = "data:image/png;base64,";
const base64 = raw.dataUrl.startsWith(prefix) ? raw.dataUrl.slice(prefix.length) : undefined;
const name = path.basename(raw.filename).replace(/[^\w.-]+/g, "-");
if (!base64 || base64.length > 24_000_000 || !name.endsWith(".png")) return true;
void (async () => {
const target = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)),
filters: { Images: ["png"] },
});
if (!target) return;
await vscode.workspace.fs.writeFile(target, Buffer.from(base64, "base64"));
const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal");
if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target);
})();
return true;
}

// The "Amico" palette group — allowlisted commands only.
if (msg.kind === "command") {
const command = (msg as unknown as { command?: unknown }).command;
if (typeof command === "string" && BRIDGE_ALLOWED_COMMANDS.has(command)) {
void vscode.commands.executeCommand(command);
return true;
}
return false;
}

// Dashboard "Default model" control mirrors its choice into the
// amicode.defaultModel setting, so the config pin (headless / first turn)
// tracks the UI. "provider/model-id" only, bounded — untrusted.
if (msg.kind === "set-default-model" && typeof (msg as { model?: unknown }).model === "string") {
const model = (msg as unknown as { model: string }).model.trim();
if (model.length > 0 && model.length <= 200 && /^[\w.-]+\/[\w.:-]+$/.test(model)) {
void vscode.workspace.getConfiguration("amicode").update("defaultModel", model, vscode.ConfigurationTarget.Global);
}
return true;
}

return false;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
16 changes: 14 additions & 2 deletions packages/amico-run/src/agent_spawn.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,8 +31,12 @@ import type { Finding } from "./lenses.js";

export const DEFAULT_CRITIC_MODEL = "anthropic/claude-opus-5";
export const DEFAULT_CRITIC_VARIANT = "high";
/** Per-child ceiling (§3.7). A critic that has not answered in two minutes has failed. */
export const CRITIC_TIMEOUT_MS = 120_000;
/** Per-child ceiling (§3.7). A critic that has not answered in four minutes has failed.
* 240s, not 120s: a frontier-class review of a full spec on a free-tier model takes
* ~3 minutes measured (178s for a 13k-token decomposition pass, 2026-07-31) — the
* two-minute ceiling read legitimate slow answers as failures and silently degraded
* every review to approved-mechanical/degraded. */
export const CRITIC_TIMEOUT_MS = 240_000;

/** Why a lens has no findings, which is NOT the same question as whether it ran.
*
Expand DownExpand Up@@ -99,6 +103,13 @@ export function resolveAgentBin(env: NodeJS.ProcessEnv = process.env): string |
* already authenticated against. */
const ENV_ALLOWLIST = ["HOME", "PATH", "TMPDIR", "SHELL", "LANG", "LC_ALL", "TERM"];
const ENV_ALLOW_PREFIXES = ["XDG_", "OPENCODE_"];
/** Live-session pointers: when amico runs INSIDE a live Amicode session, these
* ride the OPENCODE_ prefix allowance into the child, and the child's headless
* `run` tries to resolve the PARENT's session — failing with "Session not
* found" before the critic ever starts. The child spawns its own runtime; the
* parent's session pointers are never valid for it. Config vars
* (OPENCODE_CONFIG_CONTENT/DIR) stay — those are the legitimate prefix users. */
const ENV_DENYLIST = new Set(["OPENCODE", "OPENCODE_PID", "OPENCODE_SERVER_PASSWORD"]);

export function buildChildEnv(
parent: NodeJS.ProcessEnv = process.env,
Expand All@@ -107,6 +118,7 @@ export function buildChildEnv(
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(parent)) {
if (v === undefined) continue;
if (ENV_DENYLIST.has(k)) continue;
if (ENV_ALLOWLIST.includes(k) || ENV_ALLOW_PREFIXES.some((p) => k.startsWith(p))) out[k] = v;
}
return { ...out, ...extra };
Expand Down
8 changes: 8 additions & 0 deletions packages/amico-run/src/plan_compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -469,8 +469,16 @@ function defaultPlanner(specPath: string, env?: NodeJS.ProcessEnv): ((specText:
agent: "planner",
model: criticModel(e),
env: e,
// Plan generation is a bigger output than a critic pass (a full gated
// plan JSON, not findings) — measured beyond the 240s critic ceiling on
// a free-tier model, so the planner gets its own ceiling (2026-07-31).
timeoutMs: PLANNER_TIMEOUT_MS,
prompt: "Compile the spec in your working directory into a plan. Reply with the JSON object only.",
specText,
specFilename: specPath.split("/").pop() ?? "spec.md",
});
}

/** Planner-only spawn ceiling: ~2.7× the measured 178s critic pass at the same
* tier (2026-07-31). Anything longer is a hung child, not a slow answer. */
export const PLANNER_TIMEOUT_MS = 480_000;
13 changes: 13 additions & 0 deletions packages/amico-run/test/agent_spawn.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -348,4 +348,17 @@ describe("buildChildEnv", () => {
it("lets explicit extras through", () => {
expect(buildChildEnv({ HOME: "/h" }, { OPENCODE_CONFIG_CONTENT: "{}" }).OPENCODE_CONFIG_CONTENT).toBe("{}");
});
it("strips live-session pointers even though they carry the OPENCODE_ prefix", () => {
// Regression: spawned from inside a live Amicode session, these vars made
// the child's headless `run` resolve the PARENT's session → "Session not
// found", killing every critic before it started. Config vars must stay.
const env = buildChildEnv({
HOME: "/h",
OPENCODE: "1",
OPENCODE_PID: "3434",
OPENCODE_SERVER_PASSWORD: "live-pw",
OPENCODE_CONFIG_CONTENT: "{}",
});
expect(env).toEqual({ HOME: "/h", OPENCODE_CONFIG_CONTENT: "{}" });
});
});
12 changes: 12 additions & 0 deletions packages/extension/esbuild.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,18 @@ const targets = [
logLevel: "info",
loader: { ".svg": "text" },
},
// Chat Deck webview bundle — pane-manager shell (src/deck/shell.ts + model)
{
entryPoints: ["src/deck/shell.ts"],
bundle: true,
platform: "browser",
target: "es2022",
format: "iife",
outfile: "dist/deck_shell.js",
sourcemap: true,
minify: false,
logLevel: "info",
},
];

if (watch) {
Expand Down
12 changes: 12 additions & 0 deletions packages/extension/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@
],
"activationEvents": [
"onCommand:amicode.openChat",
"onCommand:amicode.newChat",
"onCommand:amicode.chatDeck",
"onCommand:amicode.openInspector",
"onView:amicode.runInspector",
"onView:amicode.deviceInspector",
Expand DownExpand Up@@ -92,6 +94,16 @@
"title": "Amicode: Open Chat",
"icon": "$(comment-discussion)"
},
{
"command": "amicode.newChat",
"title": "Amicode: New Chat (Side by Side)",
"icon": "$(add)"
},
{
"command": "amicode.chatDeck",
"title": "Amicode: Open Chat Deck (Panes in One Tab)",
"icon": "$(layout)"
},
{
"command": "amicode.setupVault",
"title": "Amicode: Set up a personal vault"
Expand Down
138 changes: 138 additions & 0 deletions packages/extension/src/chat_bridge.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as vscode from "vscode";
import * as path from "node:path";
import * as os from "node:os";

// ============================================================================
// The amicode iframe⇄extension command bridge, shared by ChatPanel (one
// iframe) and DeckPanel (N panes). The framed app renders LLM output, so the
// handler is paranoid by construction: strict command allowlist, https-only
// externals, visibility-gated clipboard reads, bounded payloads. Panes tag
// their messages with an opaque `tab` id; replies ECHO it so the shell can
// route the answer back to the asking pane. Single-iframe panels leave `tab`
// undefined and their relay simply forwards to the one iframe.
// ============================================================================

// Commands the in-app palette (opencode "Amico" command group) may trigger via
// the iframe→parent→extension postMessage bridge. STRICT allowlist: the framed
// app renders LLM output, so we never executeCommand anything outside this set.
export const BRIDGE_ALLOWED_COMMANDS: ReadonlySet<string> = new Set([
"amicode.restartServer",
"amicode.distillNow",
"amicode.stopRun",
"amicode.savePulse",
"amicode.openRunDir",
"amicode.openInspector",
// ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's —
// the fork forwards it here so the editor's Command Palette (where every
// Amicode: command lives) opens as users expect.
"workbench.action.showCommands",
]);

/** Side channels the handler needs from its host panel. */
export interface BridgeIo {
/** Clipboard reads only answer while the user can see the chat. */
visible(): boolean;
/** Replies (clipboard text) go back to the host webview; `tab` echoes along. */
postToWebview(msg: unknown): void;
}

const isAmicode = (msg: unknown): msg is { source: "amicode"; kind: string; tab?: string } =>
!!msg && typeof msg === "object" && (msg as { source?: unknown }).source === "amicode";

/** Handle one envelope from a framed app. Returns true when the message was
* consumed (hosts log the rest). */
export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean {
if (!isAmicode(msg)) return false;

// target=_blank/window.open are dead inside the framed app — open https
// links via the editor (system browser). https-only; scheme is
// case-insensitive (RFC 3986).
if (
msg.kind === "open-external" &&
typeof (msg as { url?: unknown }).url === "string" &&
/^https:\/\//i.test((msg as unknown as { url: string }).url)
) {
void vscode.env.openExternal(vscode.Uri.parse((msg as unknown as { url: string }).url));
return true;
}

// Paste bridge: navigator.clipboard is unavailable to the framed app (the
// webview parent has no clipboard-read to delegate), so the app asks US —
// the extension host reads the OS clipboard and replies. Visibility gate:
// the app renders LLM-driven content, so a hidden panel must not be able to
// sample the clipboard in the background.
if (msg.kind === "clipboard-request") {
if (!io.visible()) return true;
void vscode.env.clipboard.readText().then((text) =>
io.postToWebview({
source: "amicode",
kind: "clipboard",
nonce: (msg as { nonce?: string }).nonce,
text,
tab: msg.tab,
}),
);
return true;
}

// Copy bridge (mirror of clipboard-request): the framed app's native copy
// can't reach the OS clipboard, so an in-chat ⌘C posts the text here and we
// write it via vscode.env.clipboard — otherwise the paste bridge above reads
// back stale content. Same visibility gate; payload is untrusted, so bound it.
if (msg.kind === "clipboard-write" && typeof (msg as { text?: unknown }).text === "string") {
if (!io.visible()) return true;
const text = (msg as unknown as { text: string }).text;
if (text.length > 5_000_000) return true;
void vscode.env.clipboard.writeText(text);
return true;
}

// Save bridge (run-card PNG export): downloads are dead inside the framed
// app — the extension shows a save dialog and writes the file. PNG-only,
// basename-only, bounded size: the payload is untrusted.
if (
msg.kind === "save-file" &&
typeof (msg as { filename?: unknown }).filename === "string" &&
typeof (msg as { dataUrl?: unknown }).dataUrl === "string"
) {
const raw = msg as unknown as { filename: string; dataUrl: string };
const prefix = "data:image/png;base64,";
const base64 = raw.dataUrl.startsWith(prefix) ? raw.dataUrl.slice(prefix.length) : undefined;
const name = path.basename(raw.filename).replace(/[^\w.-]+/g, "-");
if (!base64 || base64.length > 24_000_000 || !name.endsWith(".png")) return true;
void (async () => {
const target = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)),
filters: { Images: ["png"] },
});
if (!target) return;
await vscode.workspace.fs.writeFile(target, Buffer.from(base64, "base64"));
const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal");
if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target);
})();
return true;
}

// The "Amico" palette group — allowlisted commands only.
if (msg.kind === "command") {
const command = (msg as unknown as { command?: unknown }).command;
if (typeof command === "string" && BRIDGE_ALLOWED_COMMANDS.has(command)) {
void vscode.commands.executeCommand(command);
return true;
}
return false;
}

// Dashboard "Default model" control mirrors its choice into the
// amicode.defaultModel setting, so the config pin (headless / first turn)
// tracks the UI. "provider/model-id" only, bounded — untrusted.
if (msg.kind === "set-default-model" && typeof (msg as { model?: unknown }).model === "string") {
const model = (msg as unknown as { model: string }).model.trim();
if (model.length > 0 && model.length <= 200 && /^[\w.-]+\/[\w.:-]+$/.test(model)) {
void vscode.workspace.getConfiguration("amicode").update("defaultModel", model, vscode.ConfigurationTarget.Global);
}
return true;
}

return false;
}
Loading
Loading