Skip to content
Draft
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
4 changes: 3 additions & 1 deletion packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1075,7 +1075,7 @@
"packages/app/src/pages/new-session/new-session-draft-controller.ts": "3607771a22b6855afd0b0d666de662c9070a2dcd56ae96ba51d89c9e11018e81",
"packages/app/src/pages/new-session/new-session-view.test.ts": "b27a1cc858897b6d146eecf3c50db4a17b2efc15421685efdca837dce91b4db8",
"packages/app/src/pages/new-session/new-session-view.tsx": "9b2688cb95aed672cbe5ddb0f7377a389b66abde7f5e84c5b9aa2f74b408eff7",
"packages/app/src/pages/session.tsx": "e85df7c6f3672de127421617c40e74be32b8bfd967291fcdd4583154e34d1c1c",
"packages/app/src/pages/session.tsx": "57fa072f802b662e3c039b0632caac39a229ccfc43480603974592949dfe23e1",
"packages/app/src/pages/session/composer/bug-dock-controller.test.ts": "fe3d68f2f2bb41b04ee5567cd95a9c01671a98fd21585788be9d4cbb01a4bc22",
"packages/app/src/pages/session/composer/bug-dock-controller.ts": "ab2c72a761e1992f300b6a0cecc9d0351b00a56e4be08567bcb22d3fcece78f7",
"packages/app/src/pages/session/composer/bug-dock.ts": "11eb1a1c911df8764162d47f07e5f135d94fcb622f8a2ad9b41a23233131ba58",
Expand All @@ -1098,6 +1098,8 @@
"packages/app/src/pages/session/helpers.test.ts": "a473d86117e3fddd25ba28a189d90fa35bcb80da7f2a152d3188e139f375e45d",
"packages/app/src/pages/session/helpers.ts": "8d0106a5ec3f01a666bd840e20b6bfb28d0e88b8c8c51fc1fdd7eaa33e9daafc",
"packages/app/src/pages/session/session-panel-width.test.ts": "2b9daf379be0b54142dd791bed6bae6d215b2574176b3b3e4b1273db3baf433c",
"packages/app/src/pages/session/spawn-tabs.test.ts": "b0eaeb976f3ae520fe38cd6b4cc1493b06c626923efaa2f57c22c70a638fb511",
"packages/app/src/pages/session/spawn-tabs.ts": "e5ae126808831944199244f67e4475aeb8f31a21c9eeff70eb3cd1ec05f6c5da",
"packages/app/src/pages/session/session-panel-width.ts": "8723cb2f980972ea9bf182240fdf154d131bf40e22fe563fd538a7005cff9192",
"packages/app/src/pages/session/session-side-panel-structure.test.ts": "c138fe905498c8326f459dccba61b146af6dfb12b78480303723b5a85046931a",
"packages/app/src/pages/session/session-side-panel.tsx": "e2346a1f9d81c4f8436051ebf92dcd747be28f1e6b610d98792569067f28a4c0",
Expand Down
18 changes: 18 additions & 0 deletions packages/app-bundle/overlay/packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import { serializeSession } from "@/utils/serialize-session"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
import { createSessionLineage } from "./session/session-lineage"
import { collectSpawnedChildren } from "./session/spawn-tabs"

type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
Expand Down Expand Up @@ -266,6 +267,23 @@ function ResolvedTargetSessionRoute() {
})
})

// amicode#639: children spawned by THIS session (the amicode_session tool
// stamps metadata.spawned_by) land here as background tabs. The server
// session store (sync().session) remembers every session.created with full
// metadata, so the spawn stamp arrives on the same stream the tab list
// already rides — no new subscription. addSessionTab never navigates, so
// spawning never moves focus; openedSpawns makes the effect idempotent.
const openedSpawns = new Set<string>()
createEffect(() => {
const parent = params.id
if (!parent) return
const fresh = collectSpawnedChildren(sync().session.data.info, parent, openedSpawns)
for (const id of fresh) {
openedSpawns.add(id)
tabs.addSessionTab({ server: serverKey(), sessionId: id })
}
})

return (
// Non-keyed: closes only while the target's directory is unknown (uncached
// lineage mid-resolution), which tears down the workspace subtree including
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test"
import { collectSpawnedChildren, isSpawnedBy, type SpawnedLike } from "./spawn-tabs"

const child = (id: string, parentId: string): SpawnedLike => ({
id,
metadata: { spawned_by: parentId, spawned_depth: 1 },
})

describe("isSpawnedBy", () => {
test("matches the exact spawned_by stamp", () => {
expect(isSpawnedBy(child("ses_a", "ses_parent"), "ses_parent")).toBe(true)
})

test("does not match other parents or absent stamps", () => {
expect(isSpawnedBy(child("ses_a", "ses_other"), "ses_parent")).toBe(false)
expect(isSpawnedBy({ id: "ses_a" }, "ses_parent")).toBe(false)
expect(isSpawnedBy({ id: "ses_a", metadata: null }, "ses_parent")).toBe(false)
expect(isSpawnedBy(undefined, "ses_parent")).toBe(false)
expect(isSpawnedBy(null, "ses_parent")).toBe(false)
})

test("never matches a junk stamp", () => {
expect(isSpawnedBy({ id: "ses_a", metadata: { spawned_by: 42 } }, "ses_parent")).toBe(false)
expect(isSpawnedBy({ id: "ses_a", metadata: { spawned_by: ["ses_parent"] } }, "ses_parent")).toBe(false)
})
})

describe("collectSpawnedChildren", () => {
const parent = "ses_parent"

test("selects only children spawned by the parent, sorted", () => {
const info = {
ses_z: child("ses_z", parent),
ses_a: child("ses_a", parent),
ses_other: child("ses_other", "ses_other_parent"),
ses_plain: { id: "ses_plain" },
}
expect(collectSpawnedChildren(info, parent, [])).toEqual(["ses_a", "ses_z"])
})

test("excludes already-opened ids", () => {
const info = {
ses_a: child("ses_a", parent),
ses_b: child("ses_b", parent),
}
expect(collectSpawnedChildren(info, parent, ["ses_a"])).toEqual(["ses_b"])
expect(collectSpawnedChildren(info, parent, ["ses_a", "ses_b"])).toEqual([])
})

test("empty parent id yields nothing (draft routes have no session)", () => {
expect(collectSpawnedChildren({ ses_a: child("ses_a", "") }, "", [])).toEqual([])
})

test("tolerates a missing info map", () => {
expect(collectSpawnedChildren(undefined as unknown as Record<string, SpawnedLike>, parent, [])).toEqual([])
})

test("re-running with the opened-set is idempotent", () => {
const info = { ses_a: child("ses_a", parent), ses_b: child("ses_b", parent) }
const opened = new Set<string>()
const first = collectSpawnedChildren(info, parent, opened)
for (const id of first) opened.add(id)
expect(collectSpawnedChildren(info, parent, opened)).toEqual([])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// amicode#639: sessions spawned by the `amicode_session` tool stamp metadata
// {spawned_by, spawned_depth} at create time. The session route showing the
// PARENT auto-opens each spawned child as a background tab (addSessionTab —
// never navigates, never steals focus). The pure selection logic lives here
// so it is unit-testable; the effect in pages/session.tsx applies it.

export type SpawnedLike = { id: string; metadata?: { [key: string]: unknown } | null }

export function isSpawnedBy(info: SpawnedLike | undefined | null, parentSessionID: string): boolean {
return !!info && info.metadata?.spawned_by === parentSessionID
}

// Returns the ids in `infoById` spawned by `parentSessionID` that are not in
// `alreadyOpened`, sorted for deterministic tab order. The caller owns the
// opened-set so a re-running effect never double-opens.
export function collectSpawnedChildren(
infoById: Record<string, SpawnedLike | undefined>,
parentSessionID: string,
alreadyOpened: Iterable<string>,
): string[] {
if (!parentSessionID) return []
const opened = new Set(alreadyOpened)
const out: string[] = []
for (const [id, info] of Object.entries(infoById ?? {})) {
if (opened.has(id)) continue
if (isSpawnedBy(info, parentSessionID)) out.push(id)
}
return out.sort()
}
9 changes: 9 additions & 0 deletions packages/extension/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ infrastructure.
rename them with `amicode_problem`. All design state, events, and entities live
there.

**`amicode_session`** spawns new chat sessions that appear as background tabs
beside the current one (the only server-mutating tool in the `amicode_*` pack —
everything else is local bookkeeping). Use it for parallel or branching work
the USER should see and steer; use the Task tool for subagent-style work they
need not watch. Children start their first turn immediately and run on the
user's model budget — fan out deliberately (max 4 per call). `mode: "fork"`
seeds a child from this session's history; the spawn-depth cap (2) is soft and
overridable with `force: true`.

**`amico-run`** is the gate + launch CLI. It validates specs, scans imports,
checks tiers, and launches scripts. `amico-run --help` prints usage.

Expand Down
186 changes: 182 additions & 4 deletions packages/extension/opencode-plugin/amicode_tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,20 @@ import {
lastEventSeq,
migrateLegacyEntities,
} from "./problems";
import { guardAndRecordStage, completeStage } from "./score_guard";
import {
guardAndRecordStage, completeStage } from "./score_guard";
import {
SPAWN_MAX_COUNT,
SPAWN_MAX_DEPTH,
parseSpawnArgs,
computeDepth,
depthRefusal,
defaultTitle,
childTitle,
unwrap,
summarizeSpawned,
type SpawnedChild,
} from "./session_spawn";
import {
onboardingStreamDir,
isOnboardingEntity,
Expand Down Expand Up @@ -242,8 +255,26 @@ const RYDBERG_SCOPE_NOTE =
"honest about the tier — and do NOT tell the user Rydberg is unsupported, because it isn't.";

// The plugin: exactly one export (see header). opencode calls it on session
// creation with PluginInput; we need nothing from it today.
export const AmicodeTools = async (_input: unknown) => ({
// creation with PluginInput. We need exactly one thing from it today: the
// server-bound SDK `client` the engine builds per plugin load (fork
// packages/opencode/src/plugin/index.ts — createOpencodeClient({baseUrl,
// directory, headers})). amicode_session is the first tool in this pack that
// talks to the server; every other tool below stays local bookkeeping. When
// a loader passes no input (legacy/odd paths), the tool degrades to an
// honest refusal rather than throwing at import time.
export const AmicodeTools = async (input: unknown) => {
const engineClient = (input as { client?: unknown } | undefined)?.client as
| {
session: {
get: (o: unknown) => Promise<unknown>;
create: (o: unknown) => Promise<unknown>;
update: (o: unknown) => Promise<unknown>;
fork: (o: unknown) => Promise<unknown>;
promptAsync: (o: unknown) => Promise<unknown>;
};
}
| undefined;
return {
tool: {
// Capability warrant request (spec-20260727-164748 §9.5 / G-9). The CARD is the
// point: this tool exists so a refusal from amico-run's --spec gate becomes a
Expand Down Expand Up @@ -1369,6 +1400,152 @@ export const AmicodeTools = async (_input: unknown) => ({
// The policy itself (auto-accept HIGH-confidence downstream params; resource
// gates always confirm; interrupt-off) is prompt-level in SCORE.md; this tool
// makes the mode durable + inspectable (⚡ badge) and returns current state.
// Session spawn (amicode#639) — the FIRST tool in this pack that mutates
// server state. Everything above is local bookkeeping; this one creates
// live sessions that immediately spend model budget, so the policy (caps,
// depth, force) lives in ./session_spawn.ts and is unit-tested there.
// Children stamp metadata {spawned_by, spawned_depth}: the app's session
// route watches for that stamp and opens each child as a background tab
// in the pane showing THIS session (addSessionTab — no focus steal).
amicode_session: {
description:
"Spawn one or a few NEW chat sessions that appear as background tabs beside this one. " +
"Each child starts working on `prompt` immediately (its first turn is posted at spawn); " +
"tabs open in this session's pane without stealing focus. This is the FIRST " +
"server-mutating tool in this pack — everything else here is local bookkeeping — and " +
"each spawned session runs on the user's model budget, so fan out deliberately (hard " +
"cap " + SPAWN_MAX_COUNT + " per call). mode='fork' seeds the child from THIS session's " +
"history instead of a blank start. A session that was itself spawned cannot spawn again " +
"past depth " + SPAWN_MAX_DEPTH + " unless force=true. Do NOT use this for subagent-style " +
"work the user need not steer (use the Task tool) — sessions are for parallel or " +
"branching work the USER should see and interact with.",
args: {
prompt: {
type: "string",
description: "The first message for each spawned session — what it should work on.",
},
count: {
type: ["integer", "null"],
description: "How many sessions to spawn (1-" + SPAWN_MAX_COUNT + "). Null = 1.",
},
title: {
type: ["string", "null"],
description: "Tab/session title. Null = derived from the prompt.",
},
agent: {
type: ["string", "null"],
description: "Agent for the child session (e.g. 'plan', 'build'). Null = server default.",
},
model: {
type: ["string", "null"],
description: "'providerID/modelID' for the child. Null = this session's model.",
},
mode: {
type: ["string", "null"],
enum: ["fresh", "fork"],
description: "fresh (blank session; default) | fork (seeded from this session's history).",
},
force: {
type: ["boolean", "null"],
description: "Overrule the spawn-depth cap. Null = false.",
},
},
async execute(
a: {
prompt: string;
count?: number | null;
title?: string | null;
agent?: string | null;
model?: string | null;
mode?: string | null;
force?: boolean | null;
},
ctx: { sessionID: string; directory: string },
) {
if (!engineClient) {
return "Cannot spawn: the engine did not hand this plugin a server client (legacy load path).";
}
const parsed = parseSpawnArgs(a);
if (!parsed.ok) return `Cannot spawn: ${parsed.error}.`;
const args = parsed.args;
// Depth comes from THIS session's own stamp — never from the caller's
// claim — so the cap is enforced by construction, not by politeness.
let own: { metadata?: unknown; model?: { providerID?: string; modelID?: string } } | undefined;
try {
own = unwrap<typeof own>(
await engineClient.session.get({ path: { id: ctx.sessionID }, query: { directory: ctx.directory } }),
);
} catch {
own = undefined;
}
const depth = computeDepth(own?.metadata);
if (depth >= SPAWN_MAX_DEPTH && !args.force) return depthRefusal(depth);
// Model precedence: explicit arg > this session's model > server default.
const model =
args.model ??
(own?.model?.providerID && own?.model?.modelID
? { providerID: own.model.providerID, modelID: own.model.modelID }
: null);
const base = args.title ?? defaultTitle(args.prompt);
const spawnMeta = { spawned_by: ctx.sessionID, spawned_depth: depth + 1 };
const children: SpawnedChild[] = [];
try {
for (let i = 0; i < args.count; i++) {
const title = childTitle(base, i, args.count);
let id: string | undefined;
if (args.mode === "fork") {
const forked = unwrap<{ id?: string }>(
await engineClient.session.fork({
path: { id: ctx.sessionID },
query: { directory: ctx.directory },
body: {},
}),
);
id = forked?.id;
if (id) {
// The fork endpoint carries history but not our stamp; PATCH
// metadata so the parent's route can auto-open the tab.
// Tolerated failure: the child still runs, it just won't
// auto-open — the summary below lists it either way.
await engineClient.session
.update({ path: { id }, query: { directory: ctx.directory }, body: { metadata: spawnMeta } })
.catch(() => undefined);
}
} else {
const created = unwrap<{ id?: string }>(
await engineClient.session.create({
query: { directory: ctx.directory },
body: {
title,
metadata: spawnMeta,
...(args.agent ? { agent: args.agent } : {}),
...(model ? { model } : {}),
},
}),
);
id = created?.id;
}
if (!id) throw new Error(`session ${args.mode === "fork" ? "fork" : "create"} returned no id`);
await engineClient.session.promptAsync({
path: { id },
query: { directory: ctx.directory },
body: {
parts: [{ type: "text", text: args.prompt }],
...(model ? { model } : {}),
...(args.agent ? { agent: args.agent } : {}),
},
});
children.push({ id, title });
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (children.length > 0) return `${summarizeSpawned(children, args.mode)}\nStopped early: ${msg}`;
return `Cannot spawn: ${msg}`;
}
return summarizeSpawned(children, args.mode);
},
},

amicode_veloce: {
description:
"Turn Amico Veloce on/off, or read its state. Veloce auto-accepts HIGH-confidence " +
Expand Down Expand Up @@ -1415,4 +1592,5 @@ export const AmicodeTools = async (_input: unknown) => ({
},
},
},
});
};
};
Loading
Loading