@agentproto/runtime 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,12 @@ var PROVIDER_ENV_VARS = {
14
14
  google: "GOOGLE_GENERATIVE_AI_API_KEY",
15
15
  groq: "GROQ_API_KEY",
16
16
  mistral: "MISTRAL_API_KEY",
17
+ // Moonshot (Kimi) — an Anthropic-compatible gateway; its key is sent as a
18
+ // Bearer token (ANTHROPIC_AUTH_TOKEN) with a custom base_url by the claude-sdk
19
+ // gateway resolver. Native routers (Mastra/hermes) read this env name directly.
20
+ moonshot: "MOONSHOT_API_KEY",
21
+ // MiniMax — direct LLM provider (also serves voice/video under the same key).
22
+ minimax: "MINIMAX_API_KEY",
17
23
  // Vercel AI Gateway — a gateway provider, like openrouter, that fronts many
18
24
  // upstream model families behind one key.
19
25
  "vercel-ai-gateway": "AI_GATEWAY_API_KEY"
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/providers-store.ts"],"names":[],"mappings":"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","file":"providers-store.mjs","sourcesContent":["/**\n * `~/.agentproto/providers.json` — the provider API-key store (mode 0600).\n *\n * Distinct from `credentials.json` (host-binding OAuth tokens for\n * `serve --connect`) and from per-adapter `setup[]` tokens. This file holds\n * the LLM/model **provider** keys (anthropic, openrouter, openai, …) that the\n * model gateways in spawned agents read from the environment.\n *\n * Why a store at all: before this, the only way to give the daemon a provider\n * key was an ambient `export FOO_API_KEY=…` in whatever shell launched\n * `serve`. That's invisible, easy to forget, and per-shell. Storing the keys\n * here (0600, same-user-only) lets `serve` inject them at boot — set once,\n * works for every daemon. Explicit env still wins (see injectProviderKeysIntoEnv).\n *\n * Keys never leave this file except into the daemon's own process env; they\n * are never logged. A browser-loaded localhost page can't read a 0600 file —\n * the same defence the runtime.json bearer token relies on.\n */\n\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\"\nimport { homedir } from \"node:os\"\nimport { join, resolve } from \"node:path\"\n\n/**\n * Canonical provider → environment-variable name. The spawned model gateways\n * (Mastra in mastra-agent, hermes/opencode's routers) read the provider key\n * from these env names. Aligned with the adapter manifests' `models.env`\n * maps + the common SDK conventions.\n */\nexport const PROVIDER_ENV_VARS: Readonly<Record<string, string>> = {\n anthropic: \"ANTHROPIC_API_KEY\",\n openrouter: \"OPENROUTER_API_KEY\",\n openai: \"OPENAI_API_KEY\",\n google: \"GOOGLE_GENERATIVE_AI_API_KEY\",\n groq: \"GROQ_API_KEY\",\n mistral: \"MISTRAL_API_KEY\",\n // Vercel AI Gateway — a gateway provider, like openrouter, that fronts many\n // upstream model families behind one key.\n \"vercel-ai-gateway\": \"AI_GATEWAY_API_KEY\",\n}\n\nexport type KnownProvider = keyof typeof PROVIDER_ENV_VARS\n\nexport interface ProviderEntry {\n /** The provider API key (or gateway key). */\n apiKey: string\n /** Optional custom base URL (self-hosted / proxy). */\n baseUrl?: string\n /** Wall-clock ISO timestamp the key was last set. */\n updatedAt: string\n}\n\nexport interface ProvidersFile {\n version: 1\n providers: Record<string, ProviderEntry>\n}\n\n/** Fresh empty file each call — never share the `providers` object, or a\n * later `setProviderKey` mutation leaks into every other load in-process. */\nfunction emptyFile(): ProvidersFile {\n return { version: 1, providers: {} }\n}\n\nexport function providersPath(): string {\n return resolve(homedir(), \".agentproto\", \"providers.json\")\n}\n\n/** Resolve the env-var name for a provider (canonical map, else\n * `<PROVIDER>_API_KEY` upper-snake fallback so new providers still work). */\nexport function providerEnvVar(provider: string): string {\n return (\n PROVIDER_ENV_VARS[provider] ??\n `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, \"_\")}_API_KEY`\n )\n}\n\nexport async function loadProviders(): Promise<ProvidersFile> {\n try {\n const raw = await readFile(providersPath(), \"utf8\")\n const parsed = JSON.parse(raw) as unknown\n if (\n !parsed ||\n typeof parsed !== \"object\" ||\n !(\"providers\" in parsed) ||\n typeof (parsed as Record<string, unknown>).providers !== \"object\" ||\n (parsed as Record<string, unknown>).providers === null\n ) {\n return emptyFile()\n }\n return {\n version: 1,\n providers: (parsed as ProvidersFile).providers,\n }\n } catch {\n return emptyFile() // ENOENT / malformed → empty\n }\n}\n\nasync function writeProviders(file: ProvidersFile): Promise<void> {\n const dir = join(homedir(), \".agentproto\")\n await mkdir(dir, { recursive: true })\n // mode 0600 so other local users can't read the keys.\n await writeFile(providersPath(), JSON.stringify(file, null, 2) + \"\\n\", {\n encoding: \"utf8\",\n mode: 0o600,\n })\n}\n\n/** Set (or replace) a provider's key. Returns the env-var it maps to. */\nexport async function setProviderKey(\n provider: string,\n apiKey: string,\n baseUrl?: string,\n): Promise<string> {\n const file = await loadProviders()\n file.providers[provider] = {\n apiKey,\n ...(baseUrl ? { baseUrl } : {}),\n updatedAt: new Date().toISOString(),\n }\n await writeProviders(file)\n return providerEnvVar(provider)\n}\n\n/** Remove a provider's key. Returns true if it existed. */\nexport async function removeProviderKey(provider: string): Promise<boolean> {\n const file = await loadProviders()\n if (!(provider in file.providers)) return false\n delete file.providers[provider]\n await writeProviders(file)\n return true\n}\n\n/**\n * Inject stored provider keys into a target env (default `process.env`).\n * **Explicit env always wins** — a var already set is never overwritten, so a\n * one-off `FOO_API_KEY=… serve` or a CI secret takes precedence over the\n * store. Returns the list of provider names actually injected (for a boot log\n * that names providers, never values).\n */\nexport async function injectProviderKeysIntoEnv(\n env: NodeJS.ProcessEnv = process.env,\n): Promise<string[]> {\n const file = await loadProviders()\n const injected: string[] = []\n for (const [provider, entry] of Object.entries(file.providers)) {\n if (!entry?.apiKey) continue\n const name = providerEnvVar(provider)\n if (env[name]) continue // explicit env wins\n env[name] = entry.apiKey\n if (entry.baseUrl) env[`${provider.toUpperCase().replace(/[^A-Z0-9]+/g, \"_\")}_BASE_URL`] = entry.baseUrl\n injected.push(provider)\n }\n return injected\n}\n"]}
1
+ {"version":3,"sources":["../src/providers-store.ts"],"names":[],"mappings":"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","file":"providers-store.mjs","sourcesContent":["/**\n * `~/.agentproto/providers.json` — the provider API-key store (mode 0600).\n *\n * Distinct from `credentials.json` (host-binding OAuth tokens for\n * `serve --connect`) and from per-adapter `setup[]` tokens. This file holds\n * the LLM/model **provider** keys (anthropic, openrouter, openai, …) that the\n * model gateways in spawned agents read from the environment.\n *\n * Why a store at all: before this, the only way to give the daemon a provider\n * key was an ambient `export FOO_API_KEY=…` in whatever shell launched\n * `serve`. That's invisible, easy to forget, and per-shell. Storing the keys\n * here (0600, same-user-only) lets `serve` inject them at boot — set once,\n * works for every daemon. Explicit env still wins (see injectProviderKeysIntoEnv).\n *\n * Keys never leave this file except into the daemon's own process env; they\n * are never logged. A browser-loaded localhost page can't read a 0600 file —\n * the same defence the runtime.json bearer token relies on.\n */\n\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\"\nimport { homedir } from \"node:os\"\nimport { join, resolve } from \"node:path\"\n\n/**\n * Canonical provider → environment-variable name. The spawned model gateways\n * (Mastra in mastra-agent, hermes/opencode's routers) read the provider key\n * from these env names. Aligned with the adapter manifests' `models.env`\n * maps + the common SDK conventions.\n */\nexport const PROVIDER_ENV_VARS: Readonly<Record<string, string>> = {\n anthropic: \"ANTHROPIC_API_KEY\",\n openrouter: \"OPENROUTER_API_KEY\",\n openai: \"OPENAI_API_KEY\",\n google: \"GOOGLE_GENERATIVE_AI_API_KEY\",\n groq: \"GROQ_API_KEY\",\n mistral: \"MISTRAL_API_KEY\",\n // Moonshot (Kimi) — an Anthropic-compatible gateway; its key is sent as a\n // Bearer token (ANTHROPIC_AUTH_TOKEN) with a custom base_url by the claude-sdk\n // gateway resolver. Native routers (Mastra/hermes) read this env name directly.\n moonshot: \"MOONSHOT_API_KEY\",\n // MiniMax — direct LLM provider (also serves voice/video under the same key).\n minimax: \"MINIMAX_API_KEY\",\n // Vercel AI Gateway — a gateway provider, like openrouter, that fronts many\n // upstream model families behind one key.\n \"vercel-ai-gateway\": \"AI_GATEWAY_API_KEY\",\n}\n\nexport type KnownProvider = keyof typeof PROVIDER_ENV_VARS\n\nexport interface ProviderEntry {\n /** The provider API key (or gateway key). */\n apiKey: string\n /** Optional custom base URL (self-hosted / proxy). */\n baseUrl?: string\n /** Wall-clock ISO timestamp the key was last set. */\n updatedAt: string\n}\n\nexport interface ProvidersFile {\n version: 1\n providers: Record<string, ProviderEntry>\n}\n\n/** Fresh empty file each call — never share the `providers` object, or a\n * later `setProviderKey` mutation leaks into every other load in-process. */\nfunction emptyFile(): ProvidersFile {\n return { version: 1, providers: {} }\n}\n\nexport function providersPath(): string {\n return resolve(homedir(), \".agentproto\", \"providers.json\")\n}\n\n/** Resolve the env-var name for a provider (canonical map, else\n * `<PROVIDER>_API_KEY` upper-snake fallback so new providers still work). */\nexport function providerEnvVar(provider: string): string {\n return (\n PROVIDER_ENV_VARS[provider] ??\n `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, \"_\")}_API_KEY`\n )\n}\n\nexport async function loadProviders(): Promise<ProvidersFile> {\n try {\n const raw = await readFile(providersPath(), \"utf8\")\n const parsed = JSON.parse(raw) as unknown\n if (\n !parsed ||\n typeof parsed !== \"object\" ||\n !(\"providers\" in parsed) ||\n typeof (parsed as Record<string, unknown>).providers !== \"object\" ||\n (parsed as Record<string, unknown>).providers === null\n ) {\n return emptyFile()\n }\n return {\n version: 1,\n providers: (parsed as ProvidersFile).providers,\n }\n } catch {\n return emptyFile() // ENOENT / malformed → empty\n }\n}\n\nasync function writeProviders(file: ProvidersFile): Promise<void> {\n const dir = join(homedir(), \".agentproto\")\n await mkdir(dir, { recursive: true })\n // mode 0600 so other local users can't read the keys.\n await writeFile(providersPath(), JSON.stringify(file, null, 2) + \"\\n\", {\n encoding: \"utf8\",\n mode: 0o600,\n })\n}\n\n/** Set (or replace) a provider's key. Returns the env-var it maps to. */\nexport async function setProviderKey(\n provider: string,\n apiKey: string,\n baseUrl?: string,\n): Promise<string> {\n const file = await loadProviders()\n file.providers[provider] = {\n apiKey,\n ...(baseUrl ? { baseUrl } : {}),\n updatedAt: new Date().toISOString(),\n }\n await writeProviders(file)\n return providerEnvVar(provider)\n}\n\n/** Remove a provider's key. Returns true if it existed. */\nexport async function removeProviderKey(provider: string): Promise<boolean> {\n const file = await loadProviders()\n if (!(provider in file.providers)) return false\n delete file.providers[provider]\n await writeProviders(file)\n return true\n}\n\n/**\n * Inject stored provider keys into a target env (default `process.env`).\n * **Explicit env always wins** — a var already set is never overwritten, so a\n * one-off `FOO_API_KEY=… serve` or a CI secret takes precedence over the\n * store. Returns the list of provider names actually injected (for a boot log\n * that names providers, never values).\n */\nexport async function injectProviderKeysIntoEnv(\n env: NodeJS.ProcessEnv = process.env,\n): Promise<string[]> {\n const file = await loadProviders()\n const injected: string[] = []\n for (const [provider, entry] of Object.entries(file.providers)) {\n if (!entry?.apiKey) continue\n const name = providerEnvVar(provider)\n if (env[name]) continue // explicit env wins\n env[name] = entry.apiKey\n if (entry.baseUrl) env[`${provider.toUpperCase().replace(/[^A-Z0-9]+/g, \"_\")}_BASE_URL`] = entry.baseUrl\n injected.push(provider)\n }\n return injected\n}\n"]}
@@ -42,13 +42,26 @@ interface ResumeStrategy {
42
42
  outputHint?: RegExp;
43
43
  /** Where the matched id is recorded on the descriptor. */
44
44
  storeAs: ResumeMetadataKey;
45
- /** Probe the adapter's on-disk session store for the most-recently
46
- * modified session in the given workspace. Returns the resume id
47
- * or null when nothing eligible. Files older than `prevStartedAt`
48
- * are ignored (avoids resuming an unrelated prior conversation).
45
+ /** Probe the adapter's on-disk session store for the resume id of a
46
+ * session in the given workspace. Returns the id or null when nothing
47
+ * eligible.
48
+ *
49
+ * When `expectedId` is set the session's own conversation id is known
50
+ * (agent-cli descriptors carry it as `adapterSessionId`; for
51
+ * claude-code it IS the on-disk `.jsonl` uuid — see acp-host.ts) and
52
+ * the probe MUST bind to exactly that transcript: it confirms
53
+ * `<expectedId>` exists and returns it, else null. It never falls
54
+ * through to a mtime guess in that case — resuming *some other*
55
+ * session that merely shares the cwd is a silent cross-session
56
+ * correctness bug, strictly worse than not resuming at all.
57
+ *
58
+ * Only when `expectedId` is absent (e.g. a raw PTY claude session that
59
+ * never had an ACP session id) does the probe fall back to
60
+ * most-recently-modified, filtered to files at-or-after `prevStartedAt`
61
+ * (avoids resuming an unrelated prior conversation).
49
62
  *
50
63
  * Skip when the adapter doesn't persist sessions externally. */
51
- fsProbe?(cwd: string, prevStartedAt: string): Promise<string | null>;
64
+ fsProbe?(cwd: string, prevStartedAt: string, expectedId?: string): Promise<string | null>;
52
65
  /** Return the argv to spawn a PTY that resumes into the given id.
53
66
  * When omitted, the daemon falls back to ACP-level resume via
54
67
  * the agent-cli protocol instead of the provider's native CLI. */
@@ -26,9 +26,17 @@ var RESUME_STRATEGIES = {
26
26
  // opencode: { storeAs: "openCodeResumeId", ... }
27
27
  };
28
28
  function probeMtimeLatestJsonl(storeRel) {
29
- return async (cwd, prevStartedAt) => {
29
+ return async (cwd, prevStartedAt, expectedId) => {
30
30
  const encoded = cwd.replace(/\//g, "-");
31
31
  const dir = resolve(homedir(), storeRel, encoded);
32
+ if (expectedId) {
33
+ try {
34
+ await promises.stat(join(dir, `${expectedId}.jsonl`));
35
+ return expectedId;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
32
40
  let entries;
33
41
  try {
34
42
  entries = await promises.readdir(dir);
@@ -87,7 +95,11 @@ async function augmentWithFsResume(prev) {
87
95
  if (!strategy?.fsProbe) return prev;
88
96
  if (prev.resumeMetadata?.[strategy.storeAs]) return prev;
89
97
  if (!prev.cwd) return prev;
90
- const id = await strategy.fsProbe(prev.cwd, prev.startedAt);
98
+ const id = await strategy.fsProbe(
99
+ prev.cwd,
100
+ prev.startedAt,
101
+ prev.adapterSessionId
102
+ );
91
103
  if (!id) return prev;
92
104
  return {
93
105
  ...prev,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/resume-strategies.ts"],"names":["fs"],"mappings":"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","file":"resume-strategies.mjs","sourcesContent":["/**\n * Per-adapter resume strategies — the daemon's view of \"how do I\n * continue a previous session for THIS adapter?\".\n *\n * The driver already has an `AgentCliContinuation` declaration in\n * the adapter manifest (AIP-45). That covers strategy NAMES\n * (`native-resume`, `pinned-session`, `transcript`, `none`) but is\n * silent on the mechanics that live OUTSIDE the runtime — like\n * \"where on disk does this adapter store its conversations?\" or\n * \"what argv do I pass to resume into PTY mode?\".\n *\n * This table is that practical bridge. Each entry declares three\n * optional hooks:\n *\n * outputHint regex matched against each output line; group 1 is\n * the resume id. Stored as `desc.resumeMetadata[storeAs]`.\n * fsProbe async function that probes a per-adapter directory\n * for a stored session id, scoped to a workspace cwd.\n * Used when `outputHint` never matched.\n * spawnArgs given a captured id, returns the argv to spawn a\n * PTY that resumes the session. agentproto then\n * bypasses ACP/agent-cli and runs the provider\n * directly — the most reliable resume path because\n * it leans on the provider's native UX.\n *\n * Adapters that don't declare anything here fall back to:\n * 1. ACP-level resume via `resumeSessionId` on POST /sessions/agent\n * 2. Fresh spawn (same shape, no continuity)\n *\n * Eventually each adapter should ship its own strategy on\n * `AgentCliHandle.resumeStrategy` (driver-side). This central table\n * is the bootstrap: it lets a new adapter become resume-capable\n * without bumping its npm package; once that lands, entries\n * graduate into the adapter packages.\n */\n\nimport { promises as fs } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { join, resolve } from \"node:path\"\n\n/** Where on disk the resume id ends up on the session descriptor. */\nexport type ResumeMetadataKey =\n | \"claudeResumeId\"\n | \"hermesResumeId\"\n | \"codexResumeId\"\n | \"openClawResumeId\"\n | \"openCodeResumeId\"\n\nexport interface ResumeStrategy {\n /** When set, run on every output line for this adapter. Group 1\n * captures the resume id. The match is saved on the descriptor\n * as `resumeMetadata[storeAs]`. */\n outputHint?: RegExp\n /** Where the matched id is recorded on the descriptor. */\n storeAs: ResumeMetadataKey\n /** Probe the adapter's on-disk session store for the most-recently\n * modified session in the given workspace. Returns the resume id\n * or null when nothing eligible. Files older than `prevStartedAt`\n * are ignored (avoids resuming an unrelated prior conversation).\n *\n * Skip when the adapter doesn't persist sessions externally. */\n fsProbe?(cwd: string, prevStartedAt: string): Promise<string | null>\n /** Return the argv to spawn a PTY that resumes into the given id.\n * When omitted, the daemon falls back to ACP-level resume via\n * the agent-cli protocol instead of the provider's native CLI. */\n spawnArgs?(id: string): string[]\n}\n\nexport const RESUME_STRATEGIES: Record<string, ResumeStrategy> = {\n \"claude-code\": {\n // Printed by claude on graceful exit when session persistence\n // is on (default). Example: `claude --resume 0e483f81-1a44-4bec-9667-b37158450296`\n outputHint: /claude\\s+--resume\\s+([0-9a-f-]{8,})/i,\n storeAs: \"claudeResumeId\",\n fsProbe: probeMtimeLatestJsonl(\".claude/projects\"),\n spawnArgs: id => [\"claude\", \"--resume\", id],\n },\n\n // Stubs for other shipped adapters — fill in as we learn each\n // provider's resume mechanism. Today they fall back to ACP-level\n // resume (whatever the agent-cli runtime supports) or fresh spawn.\n //\n // hermes: { storeAs: \"hermesResumeId\", ... }\n // codex: { storeAs: \"codexResumeId\", ... }\n // openclaw: { storeAs: \"openClawResumeId\", ... }\n // opencode: { storeAs: \"openCodeResumeId\", ... }\n}\n\n/**\n * Build an `fsProbe` for adapters that store one `.jsonl` per session\n * inside `~/<storeRel>/<cwd-encoded>/`. The encoded cwd is the\n * absolute path with `/` → `-` (claude's convention; others tend to\n * use the same scheme). Returns the UUID of the most-recently\n * modified `.jsonl`, filtered to files at-or-after `prevStartedAt`.\n */\nfunction probeMtimeLatestJsonl(\n storeRel: string,\n): (cwd: string, prevStartedAt: string) => Promise<string | null> {\n return async (cwd, prevStartedAt) => {\n const encoded = cwd.replace(/\\//g, \"-\")\n const dir = resolve(homedir(), storeRel, encoded)\n let entries: string[]\n try {\n entries = await fs.readdir(dir)\n } catch {\n return null\n }\n const jsonl = entries.filter(e => e.endsWith(\".jsonl\"))\n if (jsonl.length === 0) return null\n const startedAtMs = Date.parse(prevStartedAt)\n const candidates: { name: string; mtime: number }[] = []\n for (const f of jsonl) {\n try {\n const st = await fs.stat(join(dir, f))\n if (!Number.isFinite(startedAtMs) || st.mtimeMs >= startedAtMs - 1000) {\n candidates.push({ name: f, mtime: st.mtimeMs })\n }\n } catch {\n // ignore unreadable entry\n }\n }\n if (candidates.length === 0) return null\n candidates.sort((a, b) => b.mtime - a.mtime)\n return candidates[0]!.name.replace(/\\.jsonl$/, \"\")\n }\n}\n\n/**\n * Helper: which adapters declare any resume capability? Used by\n * `agentproto sessions restart` to print a helpful \"this adapter\n * doesn't support resume; falling back to fresh spawn\" hint when\n * applicable.\n */\nexport function hasResumeStrategy(adapterSlug: string | undefined): boolean {\n if (!adapterSlug) return false\n const s = RESUME_STRATEGIES[adapterSlug]\n return !!(s && (s.outputHint || s.fsProbe || s.spawnArgs))\n}\n\n// ── Restart decision tree ────────────────────────────────────────\n//\n// Shared between the CLI (`agentproto sessions restart`, sessions.ts)\n// and the daemon's `session_restart` MCP tool (session-tools.ts) —\n// both need to answer \"given this dead/alive session, how do I bring\n// it back with the most continuity?\" the exact same way. Duck-typed\n// on a minimal shape rather than importing `SessionDescriptor` from\n// sessions.ts: this file is its own tsup entry (`splitting: false`),\n// so a value-level cross-entry import would inline the whole sessions\n// module here. A full `SessionDescriptor` satisfies these structurally.\n\nexport interface RestartCandidate {\n adapterSlug?: string\n resumeMetadata?: Record<string, string>\n pty?: boolean\n adapterSessionId?: string\n}\n\nexport interface FsProbeCandidate extends RestartCandidate {\n cwd?: string\n startedAt: string\n}\n\n/**\n * Where a restart should land. Computed once so callers never diverge:\n *\n * 1. pty-native — the adapter has a captured resume id AND declares\n * `spawnArgs` (e.g. claude-code): respawn a PTY running the\n * provider's own resume command. Most reliable — works whenever\n * the provider persisted the session, regardless of whether the\n * ACP wrapper did.\n * 2. pty-plain — the previous session was a real PTY with no\n * native strategy match: re-run the same argv, no continuity.\n * 3. agent — an agent-cli session with no native strategy:\n * resume at the ACP level via the adapter's own session id (may\n * still 404 if the adapter never persisted a turn — callers\n * should retry without `resumeSessionId` on a \"not found\" error).\n * 4. unsupported — a generic command session; no history to resume\n * and no shape to clone reliably.\n */\nexport type RestartStrategy =\n | { kind: \"pty-native\"; argv: string[] }\n | { kind: \"pty-plain\" }\n | { kind: \"agent\"; resumeSessionId?: string }\n | { kind: \"unsupported\"; reason: string }\n\nexport function decideRestartStrategy(prev: RestartCandidate): RestartStrategy {\n // Provider-native resume takes precedence over ACP-level resume —\n // strictly more reliable when available.\n if (prev.adapterSlug) {\n const strategy = RESUME_STRATEGIES[prev.adapterSlug]\n const id = strategy?.storeAs\n ? prev.resumeMetadata?.[strategy.storeAs]\n : undefined\n if (strategy?.spawnArgs && id) {\n return { kind: \"pty-native\", argv: strategy.spawnArgs(id) }\n }\n }\n if (prev.pty === true) {\n return { kind: \"pty-plain\" }\n }\n if (prev.adapterSlug) {\n return {\n kind: \"agent\",\n ...(prev.adapterSessionId\n ? { resumeSessionId: prev.adapterSessionId }\n : {}),\n }\n }\n return {\n kind: \"unsupported\",\n reason: \"generic command session — restart only supports pty + agent-cli\",\n }\n}\n\n/**\n * Generic filesystem-fallback: for each known adapter strategy that\n * defines an `fsProbe`, run it against this session's cwd. If a\n * resume id is found, attach it to the descriptor's resumeMetadata.\n *\n * Lets `restart` recover continuity even when our own output sniffer\n * missed the resume hint (session killed too quickly, output buffered\n * past the kill, …). Eligible only when the file is at-or-after the\n * session's `startedAt` (avoid resuming an unrelated prior session\n * in the same cwd — see ResumeStrategy.fsProbe comment).\n */\nexport async function augmentWithFsResume<T extends FsProbeCandidate>(\n prev: T,\n): Promise<T> {\n const slug = prev.adapterSlug\n if (!slug) return prev\n const strategy = RESUME_STRATEGIES[slug]\n if (!strategy?.fsProbe) return prev\n // If we already have a captured id for this strategy's storage key,\n // skip the FS probe — the sniffer found it during the session.\n if (prev.resumeMetadata?.[strategy.storeAs]) return prev\n if (!prev.cwd) return prev\n const id = await strategy.fsProbe(prev.cwd, prev.startedAt)\n if (!id) return prev\n return {\n ...prev,\n resumeMetadata: {\n ...(prev.resumeMetadata ?? {}),\n [strategy.storeAs]: id,\n },\n }\n}\n\n/**\n * Describe which resume path a restart used, for CLI banners / MCP\n * responses. Returns a short phrase like \"resumed via claude --resume\",\n * or \"resumed via ACP\", or \"\" when no resume was attempted.\n */\nexport function describeResumePath(prev: RestartCandidate): string {\n if (prev.adapterSlug) {\n const s = RESUME_STRATEGIES[prev.adapterSlug]\n if (s?.spawnArgs && s.storeAs && prev.resumeMetadata?.[s.storeAs]) {\n const sample = s.spawnArgs(\"…\")[0] ?? prev.adapterSlug\n return `resumed via ${sample} --resume`\n }\n }\n if (prev.adapterSlug && prev.adapterSessionId) {\n return \"resumed via ACP\"\n }\n return \"\"\n}\n\n/**\n * Shell-style argv tokenizer for descriptors that don't carry `argv`\n * separately (legacy persisted rows predating that field). Same rules\n * as the spawn-dialog tokenizer in the web app: whitespace splits,\n * single + double quotes group.\n */\nexport function tokenizeCommand(s: string): string[] {\n const out: string[] = []\n let buf = \"\"\n let inSingle = false\n let inDouble = false\n for (let i = 0; i < s.length; i++) {\n const ch = s[i]\n if (ch === \"'\" && !inDouble) {\n inSingle = !inSingle\n continue\n }\n if (ch === '\"' && !inSingle) {\n inDouble = !inDouble\n continue\n }\n if (!inSingle && !inDouble && /\\s/.test(ch ?? \"\")) {\n if (buf) {\n out.push(buf)\n buf = \"\"\n }\n continue\n }\n buf += ch\n }\n if (buf) out.push(buf)\n return out\n}\n"]}
1
+ {"version":3,"sources":["../src/resume-strategies.ts"],"names":["fs"],"mappings":";;;;;;;;;AAqFO,IAAM,iBAAA,GAAoD;AAAA,EAC/D,aAAA,EAAe;AAAA;AAAA;AAAA,IAGb,UAAA,EAAY,sCAAA;AAAA,IACZ,OAAA,EAAS,gBAAA;AAAA,IACT,OAAA,EAAS,sBAAsB,kBAAkB,CAAA;AAAA,IACjD,SAAA,EAAW,CAAA,EAAA,KAAM,CAAC,QAAA,EAAU,YAAY,EAAE;AAAA;AAC5C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAUF;AAkBA,SAAS,sBACP,QAAA,EAK0B;AAC1B,EAAA,OAAO,OAAO,GAAA,EAAK,aAAA,EAAe,UAAA,KAAe;AAC/C,IAAA,MAAM,OAAA,GAAU,GAAA,CAAI,OAAA,CAAQ,KAAA,EAAO,GAAG,CAAA;AACtC,IAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,OAAA,EAAQ,EAAG,UAAU,OAAO,CAAA;AAGhD,IAAA,IAAI,UAAA,EAAY;AACd,MAAA,IAAI;AACF,QAAA,MAAMA,SAAG,IAAA,CAAK,IAAA,CAAK,KAAK,CAAA,EAAG,UAAU,QAAQ,CAAC,CAAA;AAC9C,QAAA,OAAO,UAAA;AAAA,MACT,CAAA,CAAA,MAAQ;AACN,QAAA,OAAO,IAAA;AAAA,MACT;AAAA,IACF;AACA,IAAA,IAAI,OAAA;AACJ,IAAA,IAAI;AACF,MAAA,OAAA,GAAU,MAAMA,QAAA,CAAG,OAAA,CAAQ,GAAG,CAAA;AAAA,IAChC,CAAA,CAAA,MAAQ;AACN,MAAA,OAAO,IAAA;AAAA,IACT;AACA,IAAA,MAAM,QAAQ,OAAA,CAAQ,MAAA,CAAO,OAAK,CAAA,CAAE,QAAA,CAAS,QAAQ,CAAC,CAAA;AACtD,IAAA,IAAI,KAAA,CAAM,MAAA,KAAW,CAAA,EAAG,OAAO,IAAA;AAC/B,IAAA,MAAM,WAAA,GAAc,IAAA,CAAK,KAAA,CAAM,aAAa,CAAA;AAC5C,IAAA,MAAM,aAAgD,EAAC;AACvD,IAAA,KAAA,MAAW,KAAK,KAAA,EAAO;AACrB,MAAA,IAAI;AACF,QAAA,MAAM,KAAK,MAAMA,QAAA,CAAG,KAAK,IAAA,CAAK,GAAA,EAAK,CAAC,CAAC,CAAA;AACrC,QAAA,IAAI,CAAC,OAAO,QAAA,CAAS,WAAW,KAAK,EAAA,CAAG,OAAA,IAAW,cAAc,GAAA,EAAM;AACrE,UAAA,UAAA,CAAW,KAAK,EAAE,IAAA,EAAM,GAAG,KAAA,EAAO,EAAA,CAAG,SAAS,CAAA;AAAA,QAChD;AAAA,MACF,CAAA,CAAA,MAAQ;AAAA,MAER;AAAA,IACF;AACA,IAAA,IAAI,UAAA,CAAW,MAAA,KAAW,CAAA,EAAG,OAAO,IAAA;AACpC,IAAA,UAAA,CAAW,KAAK,CAAC,CAAA,EAAG,MAAM,CAAA,CAAE,KAAA,GAAQ,EAAE,KAAK,CAAA;AAC3C,IAAA,OAAO,WAAW,CAAC,CAAA,CAAG,IAAA,CAAK,OAAA,CAAQ,YAAY,EAAE,CAAA;AAAA,EACnD,CAAA;AACF;AAQO,SAAS,kBAAkB,WAAA,EAA0C;AAC1E,EAAA,IAAI,CAAC,aAAa,OAAO,KAAA;AACzB,EAAA,MAAM,CAAA,GAAI,kBAAkB,WAAW,CAAA;AACvC,EAAA,OAAO,CAAC,EAAE,CAAA,KAAM,EAAE,UAAA,IAAc,CAAA,CAAE,WAAW,CAAA,CAAE,SAAA,CAAA,CAAA;AACjD;AAgDO,SAAS,sBAAsB,IAAA,EAAyC;AAG7E,EAAA,IAAI,KAAK,WAAA,EAAa;AACpB,IAAA,MAAM,QAAA,GAAW,iBAAA,CAAkB,IAAA,CAAK,WAAW,CAAA;AACnD,IAAA,MAAM,KAAK,QAAA,EAAU,OAAA,GACjB,KAAK,cAAA,GAAiB,QAAA,CAAS,OAAO,CAAA,GACtC,MAAA;AACJ,IAAA,IAAI,QAAA,EAAU,aAAa,EAAA,EAAI;AAC7B,MAAA,OAAO,EAAE,IAAA,EAAM,YAAA,EAAc,MAAM,QAAA,CAAS,SAAA,CAAU,EAAE,CAAA,EAAE;AAAA,IAC5D;AAAA,EACF;AACA,EAAA,IAAI,IAAA,CAAK,QAAQ,IAAA,EAAM;AACrB,IAAA,OAAO,EAAE,MAAM,WAAA,EAAY;AAAA,EAC7B;AACA,EAAA,IAAI,KAAK,WAAA,EAAa;AACpB,IAAA,OAAO;AAAA,MACL,IAAA,EAAM,OAAA;AAAA,MACN,GAAI,KAAK,gBAAA,GACL,EAAE,iBAAiB,IAAA,CAAK,gBAAA,KACxB;AAAC,KACP;AAAA,EACF;AACA,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,aAAA;AAAA,IACN,MAAA,EAAQ;AAAA,GACV;AACF;AAaA,eAAsB,oBACpB,IAAA,EACY;AACZ,EAAA,MAAM,OAAO,IAAA,CAAK,WAAA;AAClB,EAAA,IAAI,CAAC,MAAM,OAAO,IAAA;AAClB,EAAA,MAAM,QAAA,GAAW,kBAAkB,IAAI,CAAA;AACvC,EAAA,IAAI,CAAC,QAAA,EAAU,OAAA,EAAS,OAAO,IAAA;AAG/B,EAAA,IAAI,IAAA,CAAK,cAAA,GAAiB,QAAA,CAAS,OAAO,GAAG,OAAO,IAAA;AACpD,EAAA,IAAI,CAAC,IAAA,CAAK,GAAA,EAAK,OAAO,IAAA;AAOtB,EAAA,MAAM,EAAA,GAAK,MAAM,QAAA,CAAS,OAAA;AAAA,IACxB,IAAA,CAAK,GAAA;AAAA,IACL,IAAA,CAAK,SAAA;AAAA,IACL,IAAA,CAAK;AAAA,GACP;AACA,EAAA,IAAI,CAAC,IAAI,OAAO,IAAA;AAChB,EAAA,OAAO;AAAA,IACL,GAAG,IAAA;AAAA,IACH,cAAA,EAAgB;AAAA,MACd,GAAI,IAAA,CAAK,cAAA,IAAkB,EAAC;AAAA,MAC5B,CAAC,QAAA,CAAS,OAAO,GAAG;AAAA;AACtB,GACF;AACF;AAOO,SAAS,mBAAmB,IAAA,EAAgC;AACjE,EAAA,IAAI,KAAK,WAAA,EAAa;AACpB,IAAA,MAAM,CAAA,GAAI,iBAAA,CAAkB,IAAA,CAAK,WAAW,CAAA;AAC5C,IAAA,IAAI,CAAA,EAAG,aAAa,CAAA,CAAE,OAAA,IAAW,KAAK,cAAA,GAAiB,CAAA,CAAE,OAAO,CAAA,EAAG;AACjE,MAAA,MAAM,SAAS,CAAA,CAAE,SAAA,CAAU,QAAG,CAAA,CAAE,CAAC,KAAK,IAAA,CAAK,WAAA;AAC3C,MAAA,OAAO,eAAe,MAAM,CAAA,SAAA,CAAA;AAAA,IAC9B;AAAA,EACF;AACA,EAAA,IAAI,IAAA,CAAK,WAAA,IAAe,IAAA,CAAK,gBAAA,EAAkB;AAC7C,IAAA,OAAO,iBAAA;AAAA,EACT;AACA,EAAA,OAAO,EAAA;AACT;AAQO,SAAS,gBAAgB,CAAA,EAAqB;AACnD,EAAA,MAAM,MAAgB,EAAC;AACvB,EAAA,IAAI,GAAA,GAAM,EAAA;AACV,EAAA,IAAI,QAAA,GAAW,KAAA;AACf,EAAA,IAAI,QAAA,GAAW,KAAA;AACf,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,CAAA,CAAE,QAAQ,CAAA,EAAA,EAAK;AACjC,IAAA,MAAM,EAAA,GAAK,EAAE,CAAC,CAAA;AACd,IAAA,IAAI,EAAA,KAAO,GAAA,IAAO,CAAC,QAAA,EAAU;AAC3B,MAAA,QAAA,GAAW,CAAC,QAAA;AACZ,MAAA;AAAA,IACF;AACA,IAAA,IAAI,EAAA,KAAO,GAAA,IAAO,CAAC,QAAA,EAAU;AAC3B,MAAA,QAAA,GAAW,CAAC,QAAA;AACZ,MAAA;AAAA,IACF;AACA,IAAA,IAAI,CAAC,YAAY,CAAC,QAAA,IAAY,KAAK,IAAA,CAAK,EAAA,IAAM,EAAE,CAAA,EAAG;AACjD,MAAA,IAAI,GAAA,EAAK;AACP,QAAA,GAAA,CAAI,KAAK,GAAG,CAAA;AACZ,QAAA,GAAA,GAAM,EAAA;AAAA,MACR;AACA,MAAA;AAAA,IACF;AACA,IAAA,GAAA,IAAO,EAAA;AAAA,EACT;AACA,EAAA,IAAI,GAAA,EAAK,GAAA,CAAI,IAAA,CAAK,GAAG,CAAA;AACrB,EAAA,OAAO,GAAA;AACT","file":"resume-strategies.mjs","sourcesContent":["/**\n * Per-adapter resume strategies — the daemon's view of \"how do I\n * continue a previous session for THIS adapter?\".\n *\n * The driver already has an `AgentCliContinuation` declaration in\n * the adapter manifest (AIP-45). That covers strategy NAMES\n * (`native-resume`, `pinned-session`, `transcript`, `none`) but is\n * silent on the mechanics that live OUTSIDE the runtime — like\n * \"where on disk does this adapter store its conversations?\" or\n * \"what argv do I pass to resume into PTY mode?\".\n *\n * This table is that practical bridge. Each entry declares three\n * optional hooks:\n *\n * outputHint regex matched against each output line; group 1 is\n * the resume id. Stored as `desc.resumeMetadata[storeAs]`.\n * fsProbe async function that probes a per-adapter directory\n * for a stored session id, scoped to a workspace cwd.\n * Used when `outputHint` never matched.\n * spawnArgs given a captured id, returns the argv to spawn a\n * PTY that resumes the session. agentproto then\n * bypasses ACP/agent-cli and runs the provider\n * directly — the most reliable resume path because\n * it leans on the provider's native UX.\n *\n * Adapters that don't declare anything here fall back to:\n * 1. ACP-level resume via `resumeSessionId` on POST /sessions/agent\n * 2. Fresh spawn (same shape, no continuity)\n *\n * Eventually each adapter should ship its own strategy on\n * `AgentCliHandle.resumeStrategy` (driver-side). This central table\n * is the bootstrap: it lets a new adapter become resume-capable\n * without bumping its npm package; once that lands, entries\n * graduate into the adapter packages.\n */\n\nimport { promises as fs } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { join, resolve } from \"node:path\"\n\n/** Where on disk the resume id ends up on the session descriptor. */\nexport type ResumeMetadataKey =\n | \"claudeResumeId\"\n | \"hermesResumeId\"\n | \"codexResumeId\"\n | \"openClawResumeId\"\n | \"openCodeResumeId\"\n\nexport interface ResumeStrategy {\n /** When set, run on every output line for this adapter. Group 1\n * captures the resume id. The match is saved on the descriptor\n * as `resumeMetadata[storeAs]`. */\n outputHint?: RegExp\n /** Where the matched id is recorded on the descriptor. */\n storeAs: ResumeMetadataKey\n /** Probe the adapter's on-disk session store for the resume id of a\n * session in the given workspace. Returns the id or null when nothing\n * eligible.\n *\n * When `expectedId` is set the session's own conversation id is known\n * (agent-cli descriptors carry it as `adapterSessionId`; for\n * claude-code it IS the on-disk `.jsonl` uuid — see acp-host.ts) and\n * the probe MUST bind to exactly that transcript: it confirms\n * `<expectedId>` exists and returns it, else null. It never falls\n * through to a mtime guess in that case — resuming *some other*\n * session that merely shares the cwd is a silent cross-session\n * correctness bug, strictly worse than not resuming at all.\n *\n * Only when `expectedId` is absent (e.g. a raw PTY claude session that\n * never had an ACP session id) does the probe fall back to\n * most-recently-modified, filtered to files at-or-after `prevStartedAt`\n * (avoids resuming an unrelated prior conversation).\n *\n * Skip when the adapter doesn't persist sessions externally. */\n fsProbe?(\n cwd: string,\n prevStartedAt: string,\n expectedId?: string,\n ): Promise<string | null>\n /** Return the argv to spawn a PTY that resumes into the given id.\n * When omitted, the daemon falls back to ACP-level resume via\n * the agent-cli protocol instead of the provider's native CLI. */\n spawnArgs?(id: string): string[]\n}\n\nexport const RESUME_STRATEGIES: Record<string, ResumeStrategy> = {\n \"claude-code\": {\n // Printed by claude on graceful exit when session persistence\n // is on (default). Example: `claude --resume 0e483f81-1a44-4bec-9667-b37158450296`\n outputHint: /claude\\s+--resume\\s+([0-9a-f-]{8,})/i,\n storeAs: \"claudeResumeId\",\n fsProbe: probeMtimeLatestJsonl(\".claude/projects\"),\n spawnArgs: id => [\"claude\", \"--resume\", id],\n },\n\n // Stubs for other shipped adapters — fill in as we learn each\n // provider's resume mechanism. Today they fall back to ACP-level\n // resume (whatever the agent-cli runtime supports) or fresh spawn.\n //\n // hermes: { storeAs: \"hermesResumeId\", ... }\n // codex: { storeAs: \"codexResumeId\", ... }\n // openclaw: { storeAs: \"openClawResumeId\", ... }\n // opencode: { storeAs: \"openCodeResumeId\", ... }\n}\n\n/**\n * Build an `fsProbe` for adapters that store one `.jsonl` per session\n * inside `~/<storeRel>/<cwd-encoded>/`. The encoded cwd is the\n * absolute path with `/` → `-` (claude's convention; others tend to\n * use the same scheme).\n *\n * When `expectedId` is supplied the caller already knows this session's\n * own conversation id, so the probe binds to exactly `<expectedId>.jsonl`\n * (returning the id iff that file exists) rather than guessing. This is\n * the fix for a silent cross-session resume: the old mtime-latest path\n * would, when several claude sessions share a cwd, hand back a DIFFERENT\n * (often concurrent, still-active) session's transcript — dropping the\n * caller into unrelated work with no error. Only when `expectedId` is\n * absent do we fall back to the UUID of the most-recently modified\n * `.jsonl`, filtered to files at-or-after `prevStartedAt`.\n */\nfunction probeMtimeLatestJsonl(\n storeRel: string,\n): (\n cwd: string,\n prevStartedAt: string,\n expectedId?: string,\n) => Promise<string | null> {\n return async (cwd, prevStartedAt, expectedId) => {\n const encoded = cwd.replace(/\\//g, \"-\")\n const dir = resolve(homedir(), storeRel, encoded)\n // Ground truth beats heuristic: resume the session we were actually\n // asked to, never whichever transcript happens to be newest.\n if (expectedId) {\n try {\n await fs.stat(join(dir, `${expectedId}.jsonl`))\n return expectedId\n } catch {\n return null\n }\n }\n let entries: string[]\n try {\n entries = await fs.readdir(dir)\n } catch {\n return null\n }\n const jsonl = entries.filter(e => e.endsWith(\".jsonl\"))\n if (jsonl.length === 0) return null\n const startedAtMs = Date.parse(prevStartedAt)\n const candidates: { name: string; mtime: number }[] = []\n for (const f of jsonl) {\n try {\n const st = await fs.stat(join(dir, f))\n if (!Number.isFinite(startedAtMs) || st.mtimeMs >= startedAtMs - 1000) {\n candidates.push({ name: f, mtime: st.mtimeMs })\n }\n } catch {\n // ignore unreadable entry\n }\n }\n if (candidates.length === 0) return null\n candidates.sort((a, b) => b.mtime - a.mtime)\n return candidates[0]!.name.replace(/\\.jsonl$/, \"\")\n }\n}\n\n/**\n * Helper: which adapters declare any resume capability? Used by\n * `agentproto sessions restart` to print a helpful \"this adapter\n * doesn't support resume; falling back to fresh spawn\" hint when\n * applicable.\n */\nexport function hasResumeStrategy(adapterSlug: string | undefined): boolean {\n if (!adapterSlug) return false\n const s = RESUME_STRATEGIES[adapterSlug]\n return !!(s && (s.outputHint || s.fsProbe || s.spawnArgs))\n}\n\n// ── Restart decision tree ────────────────────────────────────────\n//\n// Shared between the CLI (`agentproto sessions restart`, sessions.ts)\n// and the daemon's `session_restart` MCP tool (session-tools.ts) —\n// both need to answer \"given this dead/alive session, how do I bring\n// it back with the most continuity?\" the exact same way. Duck-typed\n// on a minimal shape rather than importing `SessionDescriptor` from\n// sessions.ts: this file is its own tsup entry (`splitting: false`),\n// so a value-level cross-entry import would inline the whole sessions\n// module here. A full `SessionDescriptor` satisfies these structurally.\n\nexport interface RestartCandidate {\n adapterSlug?: string\n resumeMetadata?: Record<string, string>\n pty?: boolean\n adapterSessionId?: string\n}\n\nexport interface FsProbeCandidate extends RestartCandidate {\n cwd?: string\n startedAt: string\n}\n\n/**\n * Where a restart should land. Computed once so callers never diverge:\n *\n * 1. pty-native — the adapter has a captured resume id AND declares\n * `spawnArgs` (e.g. claude-code): respawn a PTY running the\n * provider's own resume command. Most reliable — works whenever\n * the provider persisted the session, regardless of whether the\n * ACP wrapper did.\n * 2. pty-plain — the previous session was a real PTY with no\n * native strategy match: re-run the same argv, no continuity.\n * 3. agent — an agent-cli session with no native strategy:\n * resume at the ACP level via the adapter's own session id (may\n * still 404 if the adapter never persisted a turn — callers\n * should retry without `resumeSessionId` on a \"not found\" error).\n * 4. unsupported — a generic command session; no history to resume\n * and no shape to clone reliably.\n */\nexport type RestartStrategy =\n | { kind: \"pty-native\"; argv: string[] }\n | { kind: \"pty-plain\" }\n | { kind: \"agent\"; resumeSessionId?: string }\n | { kind: \"unsupported\"; reason: string }\n\nexport function decideRestartStrategy(prev: RestartCandidate): RestartStrategy {\n // Provider-native resume takes precedence over ACP-level resume —\n // strictly more reliable when available.\n if (prev.adapterSlug) {\n const strategy = RESUME_STRATEGIES[prev.adapterSlug]\n const id = strategy?.storeAs\n ? prev.resumeMetadata?.[strategy.storeAs]\n : undefined\n if (strategy?.spawnArgs && id) {\n return { kind: \"pty-native\", argv: strategy.spawnArgs(id) }\n }\n }\n if (prev.pty === true) {\n return { kind: \"pty-plain\" }\n }\n if (prev.adapterSlug) {\n return {\n kind: \"agent\",\n ...(prev.adapterSessionId\n ? { resumeSessionId: prev.adapterSessionId }\n : {}),\n }\n }\n return {\n kind: \"unsupported\",\n reason: \"generic command session — restart only supports pty + agent-cli\",\n }\n}\n\n/**\n * Generic filesystem-fallback: for each known adapter strategy that\n * defines an `fsProbe`, run it against this session's cwd. If a\n * resume id is found, attach it to the descriptor's resumeMetadata.\n *\n * Lets `restart` recover continuity even when our own output sniffer\n * missed the resume hint (session killed too quickly, output buffered\n * past the kill, …). Eligible only when the file is at-or-after the\n * session's `startedAt` (avoid resuming an unrelated prior session\n * in the same cwd — see ResumeStrategy.fsProbe comment).\n */\nexport async function augmentWithFsResume<T extends FsProbeCandidate>(\n prev: T,\n): Promise<T> {\n const slug = prev.adapterSlug\n if (!slug) return prev\n const strategy = RESUME_STRATEGIES[slug]\n if (!strategy?.fsProbe) return prev\n // If we already have a captured id for this strategy's storage key,\n // skip the FS probe — the sniffer found it during the session.\n if (prev.resumeMetadata?.[strategy.storeAs]) return prev\n if (!prev.cwd) return prev\n // Pass the session's own conversation id (agent-cli sets it as\n // `adapterSessionId`; for claude-code it equals the on-disk `.jsonl`\n // uuid — acp-host.ts pins them equal) so the probe binds to THIS\n // session's transcript instead of the newest one in the cwd. Without\n // it, a restart of a session that shares a cwd with a concurrent one\n // could silently resume the wrong conversation.\n const id = await strategy.fsProbe(\n prev.cwd,\n prev.startedAt,\n prev.adapterSessionId,\n )\n if (!id) return prev\n return {\n ...prev,\n resumeMetadata: {\n ...(prev.resumeMetadata ?? {}),\n [strategy.storeAs]: id,\n },\n }\n}\n\n/**\n * Describe which resume path a restart used, for CLI banners / MCP\n * responses. Returns a short phrase like \"resumed via claude --resume\",\n * or \"resumed via ACP\", or \"\" when no resume was attempted.\n */\nexport function describeResumePath(prev: RestartCandidate): string {\n if (prev.adapterSlug) {\n const s = RESUME_STRATEGIES[prev.adapterSlug]\n if (s?.spawnArgs && s.storeAs && prev.resumeMetadata?.[s.storeAs]) {\n const sample = s.spawnArgs(\"…\")[0] ?? prev.adapterSlug\n return `resumed via ${sample} --resume`\n }\n }\n if (prev.adapterSlug && prev.adapterSessionId) {\n return \"resumed via ACP\"\n }\n return \"\"\n}\n\n/**\n * Shell-style argv tokenizer for descriptors that don't carry `argv`\n * separately (legacy persisted rows predating that field). Same rules\n * as the spawn-dialog tokenizer in the web app: whitespace splits,\n * single + double quotes group.\n */\nexport function tokenizeCommand(s: string): string[] {\n const out: string[] = []\n let buf = \"\"\n let inSingle = false\n let inDouble = false\n for (let i = 0; i < s.length; i++) {\n const ch = s[i]\n if (ch === \"'\" && !inDouble) {\n inSingle = !inSingle\n continue\n }\n if (ch === '\"' && !inSingle) {\n inDouble = !inDouble\n continue\n }\n if (!inSingle && !inDouble && /\\s/.test(ch ?? \"\")) {\n if (buf) {\n out.push(buf)\n buf = \"\"\n }\n continue\n }\n buf += ch\n }\n if (buf) out.push(buf)\n return out\n}\n"]}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Pure resolver for `~/.agentproto/config.json`'s `defaults` block —
3
+ * computes the effective `skills` + `options` for an `agent_start` spawn
4
+ * before adapter-specific normalization. No fs, no adapter I/O, so it's
5
+ * unit-testable in isolation from `session-spawn.ts` (which owns the fs
6
+ * read + the adapter-manifest lookup).
7
+ *
8
+ * Precedence (lowest → highest): global `defaults` < `defaults.adapters.
9
+ * <slug>` < the explicit `agent_start` call.
10
+ * - `options`: shallow-merged maps, later (higher-precedence) keys win.
11
+ * - `skills`: global ∪ per-adapter when the caller didn't pass `skills`
12
+ * at all; an explicit `skills` (even `[]`) REPLACES the union rather
13
+ * than merging into it — a deliberate exact set, mirroring how an
14
+ * explicit `mcpServers: []` opts out of the hermes default in
15
+ * `session-spawn.ts`.
16
+ */
17
+ interface DefaultsAdapterConfig {
18
+ skills?: string[];
19
+ options?: Record<string, boolean | number | string>;
20
+ }
21
+ /** Shape of `config.json`'s top-level `defaults` block. */
22
+ interface SpawnDefaultsConfig {
23
+ skills?: string[];
24
+ options?: Record<string, boolean | number | string>;
25
+ adapters?: Record<string, DefaultsAdapterConfig>;
26
+ /** Depth cutoff for the role-derived default (see `resolveRole` in
27
+ * `role.ts`) applied when an `agent_start` call omits `role`:
28
+ * `depth < cutoff` → supervisor, `depth >= cutoff` → executor.
29
+ * Default 1 (root spawns keep today's unrestricted behaviour; any
30
+ * spawn made THROUGH an orchestrator defaults to executor). Tune
31
+ * this up (e.g. to a large number) to restore the old permissive
32
+ * behaviour for existing deep spawns wholesale. */
33
+ defaultRoleDepthCutoff?: number;
34
+ /** Trust-boundary cap on pack-carried roles (see `role-registry.ts`'s
35
+ * `loadRoleRegistry`): a role pack whose `toolPolicy.delegation` is
36
+ * `"allow"` at a level ABOVE this cap has it forced to `"deny"` —
37
+ * the pack can still declare the intent, the daemon just refuses to
38
+ * grant it. Lets an operator install third-party role packs without
39
+ * trusting every one of them to self-grant delegation. Undefined
40
+ * (default) ⇒ no cap, any pack-declared level may carry
41
+ * `delegation: "allow"` (back-compat: #214 had no such knob). */
42
+ maxGrantableDelegation?: number;
43
+ /** Default per-session Langfuse tracing opt-in when an `agent_start` call
44
+ * omits `trace`. Default false — sessions trace only when they opt in or
45
+ * this is on. See `filterSessionObserver` / `SpawnAgentInput.trace`. */
46
+ langfuseTracing?: boolean;
47
+ /** Redactor slug applied to traced session content before it's sent to
48
+ * Langfuse (see `@agentproto/redaction`'s registry). Default "secrets"
49
+ * (deny-list by key + value-scan for secret shapes). */
50
+ traceRedactor?: string;
51
+ }
52
+ /** Manifest-declared AIP-45 option id + type, the minimum an adapter
53
+ * resolver needs to expose for `normalizeSkillsOption` below. Mirrors
54
+ * `AgentCliOption`'s `id`/`type` fields without importing
55
+ * `@agentproto/driver-agent-cli` into the runtime package. */
56
+ interface DeclaredAdapterOption {
57
+ id: string;
58
+ type: "boolean" | "integer" | "string" | "enum";
59
+ }
60
+
61
+ export type { DeclaredAdapterOption as D, SpawnDefaultsConfig as S };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentproto/runtime",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "@agentproto/runtime — long-running gateway that turns an agentproto workspace into a live runtime. Composes @agentproto/mcp-server (CRUD verbs) with HTTP transport, HEARTBEAT.md autonomy loop, and append-only conversation persistence. Drop a workspace dir, point your MCP client at it, and the agent ticks on its own.",
5
5
  "keywords": [
6
6
  "agentproto",
@@ -86,12 +86,23 @@
86
86
  "gray-matter": "^4.0.3",
87
87
  "ws": "^8.18.0",
88
88
  "zod": "^4.4.3",
89
- "@agentproto/acp": "0.3.0",
90
- "@agentproto/workflow-runtime": "0.2.0",
91
- "@agentproto/adapter-kit": "0.1.0",
89
+ "@agentproto/acp": "0.4.0",
90
+ "@agentproto/workflow": "0.1.0-alpha.1",
91
+ "@agentproto/workflow-loader": "0.1.0-alpha.0",
92
+ "@agentproto/workflow-runtime": "0.3.0",
93
+ "@agentproto/provider-kit": "0.2.0",
94
+ "@agentproto/provider-presets": "0.2.0",
95
+ "@agentproto/eval-reporters": "0.2.0",
92
96
  "@agentproto/agent": "0.2.0",
93
97
  "@agentproto/manifest": "0.2.0",
94
- "@agentproto/mcp-server": "0.2.1"
98
+ "@agentproto/mcp-server": "0.2.1",
99
+ "@agentproto/model-catalog": "0.2.0",
100
+ "@agentproto/redaction": "0.2.0",
101
+ "@agentproto/sandbox": "0.1.0",
102
+ "@agentproto/telemetry-langfuse": "0.2.0"
103
+ },
104
+ "optionalDependencies": {
105
+ "@agentproto/sandbox-e2b": "0.1.0"
95
106
  },
96
107
  "devDependencies": {
97
108
  "@types/node": "^25.6.2",
@@ -99,6 +110,8 @@
99
110
  "tsup": "^8.5.1",
100
111
  "typescript": "^5.9.3",
101
112
  "vitest": "^3.2.4",
113
+ "@agentproto/driver": "0.1.2",
114
+ "@agentproto/tool": "0.2.0",
102
115
  "@agentproto/tooling": "0.1.0-alpha.0"
103
116
  },
104
117
  "scripts": {