@agentproto/runtime 2.7.0 → 2.10.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.
@@ -122,6 +122,15 @@ declare function buildSessionPrFooter(session: FooterSession, options?: SessionF
122
122
  * a second footer.
123
123
  */
124
124
  declare function appendFooterOnce(body: string, footer: string): string;
125
+ /**
126
+ * Whether a PR/review body already carries a RENDERED provenance footer — the
127
+ * `<sub>…@agentproto-bot…</sub>` line {@link buildFooter} emits — as opposed
128
+ * to merely MENTIONING the marker in prose. The distinction matters: a PR
129
+ * whose description discusses the provenance machinery itself (they exist —
130
+ * #999 explains a footer bug and quotes the marker) would otherwise read as
131
+ * "already stamped" forever and never receive its real footer.
132
+ */
133
+ declare function hasProvenanceFooter(body: string): boolean;
125
134
  /**
126
135
  * Recognise a successful `gh pr create` from its argv + stdout and pull the
127
136
  * created PR's URL + number out. Returns null for anything that isn't a
@@ -135,6 +144,25 @@ declare function parseGhPrCreate(command: string, args: readonly string[], stdou
135
144
  url: string;
136
145
  number: number;
137
146
  } | null;
147
+ /**
148
+ * String-form counterpart of {@link parseGhPrCreate} for the in-agent tool
149
+ * lane: an ACP harness's Bash-style tool reports ONE shell string (possibly
150
+ * compound — `git push && gh pr create … | tail -1`), not an argv, so the
151
+ * argv parser above can't see it. Detects "this call created a PR" from the
152
+ * command string plus the call's recorded result text, and returns the
153
+ * created PR (LAST `…/pull/<n>` match in the result, same shadowing rule as
154
+ * {@link parseGhPrCreate} — `gh pr create` prints its URL after any notices).
155
+ *
156
+ * Quoted spans are stripped before matching so a command that merely
157
+ * MENTIONS the phrase — `grep "gh pr create" src/` over a result that quotes
158
+ * a PR url — can never read as a create. Best-effort by design: the caller
159
+ * additionally gates on the call's own `isError` (a failed create whose
160
+ * stderr cites an existing PR must not attribute that PR here).
161
+ */
162
+ declare function detectShellPrCreate(command: string | undefined, resultText: string | undefined): {
163
+ url: string;
164
+ number: number;
165
+ } | null;
138
166
  /**
139
167
  * Best-effort attribution of a `command_execute` run to the executor agent
140
168
  * session that most likely issued it: the newest agent-cli session whose cwd
@@ -145,4 +173,4 @@ declare function parseGhPrCreate(command: string, args: readonly string[], stdou
145
173
  */
146
174
  declare function pickExecutorSession<T extends FooterSession>(sessions: readonly T[], cwd: string): T | undefined;
147
175
 
148
- export { type BuildFooterInput, type FooterProvenance, type FooterSession, MARKER, type SessionFooterOptions, appendFooterOnce, buildFooter, buildSessionPrFooter, fmtTokens, parseGhPrCreate, pickExecutorSession, sessionFooterProvenance };
176
+ export { type BuildFooterInput, type FooterProvenance, type FooterSession, MARKER, type SessionFooterOptions, appendFooterOnce, buildFooter, buildSessionPrFooter, detectShellPrCreate, fmtTokens, hasProvenanceFooter, parseGhPrCreate, pickExecutorSession, sessionFooterProvenance };
@@ -46,7 +46,7 @@ var buildFooter = ({
46
46
  if (prov?.host) parts.push(`host \`${prov.host}\``);
47
47
  if (prov?.cwd) parts.push(`cwd \`${cwdLabel(prov.cwd, prov.workspaceSlug)}\``);
48
48
  }
49
- if (sha) parts.push(`sha \`${sha.slice(0, 7)}\``);
49
+ if (sha) parts.push(`sha \`${sha}\``);
50
50
  return `
51
51
 
52
52
  ---
@@ -75,9 +75,12 @@ function buildSessionPrFooter(session, options = {}) {
75
75
  return buildFooter({ prov, authMode, sha: options.sha, kind: "PR" });
76
76
  }
77
77
  function appendFooterOnce(body, footer) {
78
- if (body.includes(MARKER)) return body;
78
+ if (hasProvenanceFooter(body)) return body;
79
79
  return `${body}${footer}`;
80
80
  }
81
+ function hasProvenanceFooter(body) {
82
+ return new RegExp(`<sub>[^\\n]*${MARKER}`).test(body);
83
+ }
81
84
  function parseGhPrCreate(command, args, stdout) {
82
85
  if (basename(command) !== "gh") return null;
83
86
  const positionals = args.filter((a) => !a.startsWith("-"));
@@ -90,6 +93,18 @@ function parseGhPrCreate(command, args, stdout) {
90
93
  }
91
94
  return last;
92
95
  }
96
+ function detectShellPrCreate(command, resultText) {
97
+ if (!command || !resultText) return null;
98
+ const unquoted = command.replace(/'[^']*'/g, " ").replace(/"(?:[^"\\]|\\.)*"/g, " ");
99
+ if (!/(^|[\s;&|({])gh\s+pr\s+create(\s|$)/.test(unquoted)) return null;
100
+ const re = /https?:\/\/\S+?\/pull\/(\d+)/g;
101
+ let match;
102
+ let last = null;
103
+ while ((match = re.exec(resultText)) !== null) {
104
+ last = { url: match[0], number: Number(match[1]) };
105
+ }
106
+ return last;
107
+ }
93
108
  function cwdRelated(sessionCwd, cwd) {
94
109
  if (sessionCwd === cwd) return true;
95
110
  const sep = "/";
@@ -107,6 +122,6 @@ function pickExecutorSession(sessions, cwd) {
107
122
  return [...candidates].sort(byRecency)[0];
108
123
  }
109
124
 
110
- export { MARKER, appendFooterOnce, buildFooter, buildSessionPrFooter, fmtTokens, parseGhPrCreate, pickExecutorSession, sessionFooterProvenance };
125
+ export { MARKER, appendFooterOnce, buildFooter, buildSessionPrFooter, detectShellPrCreate, fmtTokens, hasProvenanceFooter, parseGhPrCreate, pickExecutorSession, sessionFooterProvenance };
111
126
  //# sourceMappingURL=pr-provenance.mjs.map
112
127
  //# sourceMappingURL=pr-provenance.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/pr-provenance.ts"],"names":[],"mappings":"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","file":"pr-provenance.mjs","sourcesContent":["/**\n * PR provenance footer — daemon/MCP lane.\n *\n * This is the SAME footer the runner-side agentflow scripts stamp on the\n * CI-PR, review, and local-`gh_open_pr` lanes; `scripts/lib/provenance-\n * footer.mjs` re-exports {@link buildFooter}/{@link MARKER}/{@link fmtTokens}\n * from HERE so there is exactly one format, one marker, one renderer. The\n * only reason this canonical copy lives in `@agentproto/runtime` rather than\n * in `scripts/` is direction of dependency: the daemon is a package and\n * cannot import a loose script, but a script can import a package's built\n * `dist/` (it already does for `computeProvenance`).\n *\n * What the DAEMON lane adds over the runner lanes: the footer is built from\n * a live {@link SessionDescriptor} (the executor agent-cli session that ran\n * `gh pr create`) plus its supervisor/parent session, rather than from a\n * `computeProvenance` join over the worktree. So this file also owns the\n * mapping from a session descriptor to the footer's provenance shape\n * ({@link sessionFooterProvenance}) and the small pure helpers the\n * command_execute stamp path needs ({@link parseGhPrCreate},\n * {@link pickExecutorSession}, {@link appendFooterOnce}).\n *\n * Pure by construction — only `node:path`, no daemon imports, no I/O — so it\n * bundles to a standalone `dist/pr-provenance.mjs` the scripts can import\n * without pulling the rest of the runtime in. Keep it that way. The\n * side-effecting orchestration (spawning `gh`, editing the PR body, recording\n * the opened PR) lives in `pr-provenance-stamp.ts`.\n */\n\nimport { basename } from \"node:path\"\n\n/** Deterministic marker the runner owns — a reliable native-vs-legacy\n * discriminator across every lane (CI-PR, review, local, daemon). */\nexport const MARKER = \"@agentproto-bot\"\n\nexport const fmtTokens = (n: unknown): string | null => {\n if (typeof n !== \"number\" || !Number.isFinite(n)) return null\n return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)\n}\n\n/** The footer's provenance shape — field names are the renderer's, not the\n * session registry's (see {@link sessionFooterProvenance} for the mapping). */\nexport interface FooterProvenance {\n sessionId?: string\n label?: string\n /** Adapter / harness slug that ran (\"claude-code\", \"codex\", …). */\n adapter?: string\n model?: string\n /** Bound auth-profile IDENTITY — a label or ref, NEVER the credential. */\n authProfile?: string\n sandboxId?: string\n /** Supervisor / parent session that spawned the executor. */\n parentSessionId?: string\n tokensIn?: number\n tokensOut?: number\n costUsd?: number\n /** \"adapter\" | \"local\" | \"daemon\" — drives host/cwd vs run-link rendering. */\n source?: string\n host?: string\n cwd?: string\n /** The session's registered workspace slug (`SessionDescriptor.workspaceSlug`,\n * e.g. \"ts\", \"default\"). Rendered alongside `cwd`'s leaf directory name so\n * the footer identifies WHICH workspace ran the command, not just an\n * arbitrary trailing path segment — a bare `basename(cwd)` reads as\n * meaningless (or actively misleading) when the session's cwd is a\n * workspace root rather than a per-branch worktree. */\n workspaceSlug?: string\n}\n\n/**\n * Render the footer's `cwd` value. A bare `basename(cwd)` collapses to a\n * meaningless (or actively misleading) fragment whenever the session's cwd IS\n * the workspace root rather than a per-branch worktree dir — e.g. a checkout\n * literally named `ts` renders as the opaque `cwd \\`ts\\`` with no indication\n * that's a workspace name, not a worktree/branch leaf. Prefixing the\n * registered workspace slug disambiguates it, and is dropped when it's\n * identical to the leaf (the common per-worktree case) so the common case\n * stays exactly as compact as before.\n */\nfunction cwdLabel(cwd: string, workspaceSlug?: string): string {\n const leaf = basename(cwd)\n if (!workspaceSlug || workspaceSlug === leaf) return workspaceSlug ?? leaf\n return `${workspaceSlug}/${leaf}`\n}\n\nexport interface BuildFooterInput {\n prov?: FooterProvenance\n authMode?: string\n runId?: string\n runUrl?: string\n sha?: string\n kind?: string\n}\n\nexport const buildFooter = ({\n prov,\n authMode,\n runId,\n runUrl,\n sha,\n kind = \"review\",\n}: BuildFooterInput): string => {\n const parts = [`🤖 **${MARKER}** — ${kind}`]\n if (prov?.sessionId) {\n parts.push(`session \\`${prov.sessionId}\\`${prov.label ? ` (\\`${prov.label}\\`)` : \"\"}`)\n }\n // Engine label: \"adapter / authMode\" when an adapter ran; \"legacy fallback\n // (authMode)\" when no agent session exists at all (the API-key fallback path);\n // bare authMode only if a session ran without a resolved adapter slug.\n if (prov?.adapter) parts.push([prov.adapter, authMode].filter(Boolean).join(\" / \"))\n else if (!prov?.sessionId) parts.push(`legacy fallback${authMode ? ` (${authMode})` : \"\"}`)\n else if (authMode) parts.push(authMode)\n // Bound auth profile identity (the \"wallet\") — daemon lane; NEVER the secret.\n if (prov?.authProfile) parts.push(`auth-profile \\`${prov.authProfile}\\``)\n if (prov?.model) parts.push(`model \\`${prov.model}\\``)\n if (prov?.sandboxId) parts.push(`e2b \\`${prov.sandboxId}\\``)\n if (prov?.parentSessionId) parts.push(`supervisor \\`${prov.parentSessionId}\\``)\n const tin = fmtTokens(prov?.tokensIn)\n const tout = fmtTokens(prov?.tokensOut)\n if (tin || tout) parts.push(`${tin ?? \"?\"} in / ${tout ?? \"?\"} out`)\n if (typeof prov?.costUsd === \"number\") {\n parts.push(`$${prov.costUsd.toFixed(4)}${prov.source && prov.source !== \"adapter\" ? ` (${prov.source})` : \"\"}`)\n }\n const showLocalHostCwd = prov?.source === \"local\" || prov?.source === \"daemon\" || !runId\n if (runId && !showLocalHostCwd) parts.push(`run [${runId}](${runUrl})`)\n if (showLocalHostCwd) {\n if (prov?.host) parts.push(`host \\`${prov.host}\\``)\n if (prov?.cwd) parts.push(`cwd \\`${cwdLabel(prov.cwd, prov.workspaceSlug)}\\``)\n }\n if (sha) parts.push(`sha \\`${sha.slice(0, 7)}\\``)\n return `\\n\\n---\\n<sub>${parts.join(\" · \")}</sub>`\n}\n\n/** Structural subset of `SessionDescriptor` the footer needs. The runtime's\n * full descriptor satisfies this; keeping it structural is what lets this\n * module stay free of a `./sessions.js` import (and thus standalone). */\nexport interface FooterSession {\n id: string\n kind?: string\n status?: string\n startedAt?: string\n label?: string\n cwd?: string\n workspaceSlug?: string\n adapterSlug?: string\n harness?: string\n model?: string\n parentSessionId?: string\n costUsd?: number\n tokensIn?: number\n tokensOut?: number\n auth?: { mode?: string }\n accessProfile?: { profileRef: string; label?: string }\n}\n\nexport interface SessionFooterOptions {\n /** The supervisor/parent session descriptor, when resolvable. */\n supervisor?: { id: string } | null\n host?: string\n /** Provenance source tag; defaults to \"daemon\". */\n source?: string\n}\n\n/**\n * Map a live executor session (+ its supervisor) onto the footer's provenance\n * shape and the `authMode` the renderer takes separately. The adapter is the\n * canonical harness slug, falling back to `adapterSlug`. The bound auth\n * profile is the profile's human label (or its ref), never the credential.\n */\nexport function sessionFooterProvenance(\n session: FooterSession,\n options: SessionFooterOptions = {},\n): { prov: FooterProvenance; authMode: string | undefined } {\n const prov: FooterProvenance = {\n sessionId: session.id,\n label: session.label,\n adapter: session.harness ?? session.adapterSlug,\n model: session.model,\n authProfile: session.accessProfile?.label ?? session.accessProfile?.profileRef,\n parentSessionId: options.supervisor?.id,\n costUsd: session.costUsd,\n tokensIn: session.tokensIn,\n tokensOut: session.tokensOut,\n source: options.source ?? \"daemon\",\n host: options.host,\n cwd: session.cwd,\n workspaceSlug: session.workspaceSlug,\n }\n return { prov, authMode: session.auth?.mode }\n}\n\n/** Build the daemon-lane PR footer for one executor session directly. */\nexport function buildSessionPrFooter(\n session: FooterSession,\n options: SessionFooterOptions & { sha?: string } = {},\n): string {\n const { prov, authMode } = sessionFooterProvenance(session, options)\n return buildFooter({ prov, authMode, sha: options.sha, kind: \"PR\" })\n}\n\n/**\n * Append the footer to a PR body exactly once. Idempotent by the marker: a\n * body that already carries a `@agentproto-bot` footer is returned unchanged,\n * so a retried stamp (network reply lost after the edit landed) never stacks\n * a second footer.\n */\nexport function appendFooterOnce(body: string, footer: string): string {\n if (body.includes(MARKER)) return body\n return `${body}${footer}`\n}\n\n/**\n * Recognise a successful `gh pr create` from its argv + stdout and pull the\n * created PR's URL + number out. Returns null for anything that isn't a\n * PR-creating `gh` invocation, or a create whose stdout carried no PR URL.\n *\n * `gh pr create` prints the PR URL on its own line (often the last line,\n * after any advisory notices) — we take the LAST `…/pull/<n>` match so a\n * \"Warning: …/pull/…\" style notice can't shadow the real one.\n */\nexport function parseGhPrCreate(\n command: string,\n args: readonly string[],\n stdout: string,\n): { url: string; number: number } | null {\n if (basename(command) !== \"gh\") return null\n // Skip global flags; the first two non-flag tokens must be `pr` then `create`.\n const positionals = args.filter(a => !a.startsWith(\"-\"))\n if (positionals[0] !== \"pr\" || positionals[1] !== \"create\") return null\n const re = /https?:\\/\\/\\S+?\\/pull\\/(\\d+)/g\n let match: RegExpExecArray | null\n let last: { url: string; number: number } | null = null\n while ((match = re.exec(stdout)) !== null) {\n last = { url: match[0], number: Number(match[1]) }\n }\n return last\n}\n\n/** `session.cwd == cwd || one contains the other` — the executor's cwd is\n * usually the worktree root while the command may run in a subdir (or vice\n * versa), so containment is checked in both directions. */\nfunction cwdRelated(sessionCwd: string, cwd: string): boolean {\n if (sessionCwd === cwd) return true\n const sep = \"/\"\n return cwd.startsWith(sessionCwd + sep) || sessionCwd.startsWith(cwd + sep)\n}\n\n/**\n * Best-effort attribution of a `command_execute` run to the executor agent\n * session that most likely issued it: the newest agent-cli session whose cwd\n * is related to the command's cwd, preferring a still-alive one. Returns\n * undefined when no agent-cli session matches (e.g. a bare shell opened the\n * PR) — the caller then skips stamping rather than emit a misleading \"legacy\n * fallback\" footer.\n */\nexport function pickExecutorSession<T extends FooterSession>(\n sessions: readonly T[],\n cwd: string,\n): T | undefined {\n const candidates = sessions.filter(\n s => s.kind === \"agent-cli\" && typeof s.cwd === \"string\" && cwdRelated(s.cwd, cwd),\n )\n if (candidates.length === 0) return undefined\n const alive = (s: T) => s.status === \"running\" || s.status === \"starting\"\n const byRecency = (a: T, b: T) => (b.startedAt ?? \"\").localeCompare(a.startedAt ?? \"\")\n const live = candidates.filter(alive).sort(byRecency)\n if (live.length > 0) return live[0]\n return [...candidates].sort(byRecency)[0]\n}\n"]}
1
+ {"version":3,"sources":["../src/pr-provenance.ts"],"names":[],"mappings":";;;;;;;AAgCO,IAAM,MAAA,GAAS;AAEf,IAAM,SAAA,GAAY,CAAC,CAAA,KAA8B;AACtD,EAAA,IAAI,OAAO,MAAM,QAAA,IAAY,CAAC,OAAO,QAAA,CAAS,CAAC,GAAG,OAAO,IAAA;AACzD,EAAA,OAAO,CAAA,IAAK,GAAA,GAAO,CAAA,EAAA,CAAI,CAAA,GAAI,GAAA,EAAM,QAAQ,CAAC,CAAC,CAAA,CAAA,CAAA,GAAM,MAAA,CAAO,CAAC,CAAA;AAC3D;AAyCA,SAAS,QAAA,CAAS,KAAa,aAAA,EAAgC;AAC7D,EAAA,MAAM,IAAA,GAAO,SAAS,GAAG,CAAA;AACzB,EAAA,IAAI,CAAC,aAAA,IAAiB,aAAA,KAAkB,IAAA,SAAa,aAAA,IAAiB,IAAA;AACtE,EAAA,OAAO,CAAA,EAAG,aAAa,CAAA,CAAA,EAAI,IAAI,CAAA,CAAA;AACjC;AAWO,IAAM,cAAc,CAAC;AAAA,EAC1B,IAAA;AAAA,EACA,QAAA;AAAA,EACA,KAAA;AAAA,EACA,MAAA;AAAA,EACA,GAAA;AAAA,EACA,IAAA,GAAO;AACT,CAAA,KAAgC;AAC9B,EAAA,MAAM,QAAQ,CAAC,CAAA,YAAA,EAAQ,MAAM,CAAA,UAAA,EAAQ,IAAI,CAAA,CAAE,CAAA;AAC3C,EAAA,IAAI,MAAM,SAAA,EAAW;AACnB,IAAA,KAAA,CAAM,IAAA,CAAK,CAAA,UAAA,EAAa,IAAA,CAAK,SAAS,CAAA,EAAA,EAAK,IAAA,CAAK,KAAA,GAAQ,CAAA,IAAA,EAAO,IAAA,CAAK,KAAK,CAAA,GAAA,CAAA,GAAQ,EAAE,CAAA,CAAE,CAAA;AAAA,EACvF;AAIA,EAAA,IAAI,IAAA,EAAM,OAAA,EAAS,KAAA,CAAM,IAAA,CAAK,CAAC,IAAA,CAAK,OAAA,EAAS,QAAQ,CAAA,CAAE,MAAA,CAAO,OAAO,CAAA,CAAE,IAAA,CAAK,KAAK,CAAC,CAAA;AAAA,OAAA,IACzE,CAAC,IAAA,EAAM,SAAA,EAAW,KAAA,CAAM,IAAA,CAAK,CAAA,eAAA,EAAkB,QAAA,GAAW,CAAA,EAAA,EAAK,QAAQ,CAAA,CAAA,CAAA,GAAM,EAAE,CAAA,CAAE,CAAA;AAAA,OAAA,IACjF,QAAA,EAAU,KAAA,CAAM,IAAA,CAAK,QAAQ,CAAA;AAEtC,EAAA,IAAI,MAAM,WAAA,EAAa,KAAA,CAAM,KAAK,CAAA,eAAA,EAAkB,IAAA,CAAK,WAAW,CAAA,EAAA,CAAI,CAAA;AACxE,EAAA,IAAI,MAAM,KAAA,EAAO,KAAA,CAAM,KAAK,CAAA,QAAA,EAAW,IAAA,CAAK,KAAK,CAAA,EAAA,CAAI,CAAA;AACrD,EAAA,IAAI,MAAM,SAAA,EAAW,KAAA,CAAM,KAAK,CAAA,MAAA,EAAS,IAAA,CAAK,SAAS,CAAA,EAAA,CAAI,CAAA;AAC3D,EAAA,IAAI,MAAM,eAAA,EAAiB,KAAA,CAAM,KAAK,CAAA,aAAA,EAAgB,IAAA,CAAK,eAAe,CAAA,EAAA,CAAI,CAAA;AAC9E,EAAA,MAAM,GAAA,GAAM,SAAA,CAAU,IAAA,EAAM,QAAQ,CAAA;AACpC,EAAA,MAAM,IAAA,GAAO,SAAA,CAAU,IAAA,EAAM,SAAS,CAAA;AACtC,EAAA,IAAI,GAAA,IAAO,IAAA,EAAM,KAAA,CAAM,IAAA,CAAK,CAAA,EAAG,OAAO,GAAG,CAAA,MAAA,EAAS,IAAA,IAAQ,GAAG,CAAA,IAAA,CAAM,CAAA;AACnE,EAAA,IAAI,OAAO,IAAA,EAAM,OAAA,KAAY,QAAA,EAAU;AACrC,IAAA,KAAA,CAAM,KAAK,CAAA,CAAA,EAAI,IAAA,CAAK,QAAQ,OAAA,CAAQ,CAAC,CAAC,CAAA,EAAG,IAAA,CAAK,MAAA,IAAU,IAAA,CAAK,WAAW,SAAA,GAAY,CAAA,EAAA,EAAK,KAAK,MAAM,CAAA,CAAA,CAAA,GAAM,EAAE,CAAA,CAAE,CAAA;AAAA,EAChH;AACA,EAAA,MAAM,mBAAmB,IAAA,EAAM,MAAA,KAAW,WAAW,IAAA,EAAM,MAAA,KAAW,YAAY,CAAC,KAAA;AACnF,EAAA,IAAI,KAAA,IAAS,CAAC,gBAAA,EAAkB,KAAA,CAAM,KAAK,CAAA,KAAA,EAAQ,KAAK,CAAA,EAAA,EAAK,MAAM,CAAA,CAAA,CAAG,CAAA;AACtE,EAAA,IAAI,gBAAA,EAAkB;AACpB,IAAA,IAAI,MAAM,IAAA,EAAM,KAAA,CAAM,KAAK,CAAA,OAAA,EAAU,IAAA,CAAK,IAAI,CAAA,EAAA,CAAI,CAAA;AAClD,IAAA,IAAI,IAAA,EAAM,GAAA,EAAK,KAAA,CAAM,IAAA,CAAK,CAAA,MAAA,EAAS,QAAA,CAAS,IAAA,CAAK,GAAA,EAAK,IAAA,CAAK,aAAa,CAAC,CAAA,EAAA,CAAI,CAAA;AAAA,EAC/E;AACA,EAAA,IAAI,GAAA,EAAK,KAAA,CAAM,IAAA,CAAK,CAAA,MAAA,EAAS,GAAG,CAAA,EAAA,CAAI,CAAA;AACpC,EAAA,OAAO;;AAAA;AAAA,KAAA,EAAiB,KAAA,CAAM,IAAA,CAAK,QAAK,CAAC,CAAA,MAAA,CAAA;AAC3C;AAsCO,SAAS,uBAAA,CACd,OAAA,EACA,OAAA,GAAgC,EAAC,EACyB;AAC1D,EAAA,MAAM,IAAA,GAAyB;AAAA,IAC7B,WAAW,OAAA,CAAQ,EAAA;AAAA,IACnB,OAAO,OAAA,CAAQ,KAAA;AAAA,IACf,OAAA,EAAS,OAAA,CAAQ,OAAA,IAAW,OAAA,CAAQ,WAAA;AAAA,IACpC,OAAO,OAAA,CAAQ,KAAA;AAAA,IACf,WAAA,EAAa,OAAA,CAAQ,aAAA,EAAe,KAAA,IAAS,QAAQ,aAAA,EAAe,UAAA;AAAA,IACpE,eAAA,EAAiB,QAAQ,UAAA,EAAY,EAAA;AAAA,IACrC,SAAS,OAAA,CAAQ,OAAA;AAAA,IACjB,UAAU,OAAA,CAAQ,QAAA;AAAA,IAClB,WAAW,OAAA,CAAQ,SAAA;AAAA,IACnB,MAAA,EAAQ,QAAQ,MAAA,IAAU,QAAA;AAAA,IAC1B,MAAM,OAAA,CAAQ,IAAA;AAAA,IACd,KAAK,OAAA,CAAQ,GAAA;AAAA,IACb,eAAe,OAAA,CAAQ;AAAA,GACzB;AACA,EAAA,OAAO,EAAE,IAAA,EAAM,QAAA,EAAU,OAAA,CAAQ,MAAM,IAAA,EAAK;AAC9C;AAGO,SAAS,oBAAA,CACd,OAAA,EACA,OAAA,GAAmD,EAAC,EAC5C;AACR,EAAA,MAAM,EAAE,IAAA,EAAM,QAAA,EAAS,GAAI,uBAAA,CAAwB,SAAS,OAAO,CAAA;AACnE,EAAA,OAAO,WAAA,CAAY,EAAE,IAAA,EAAM,QAAA,EAAU,KAAK,OAAA,CAAQ,GAAA,EAAK,IAAA,EAAM,IAAA,EAAM,CAAA;AACrE;AAQO,SAAS,gBAAA,CAAiB,MAAc,MAAA,EAAwB;AACrE,EAAA,IAAI,mBAAA,CAAoB,IAAI,CAAA,EAAG,OAAO,IAAA;AACtC,EAAA,OAAO,CAAA,EAAG,IAAI,CAAA,EAAG,MAAM,CAAA,CAAA;AACzB;AAUO,SAAS,oBAAoB,IAAA,EAAuB;AACzD,EAAA,OAAO,IAAI,MAAA,CAAO,CAAA,YAAA,EAAe,MAAM,CAAA,CAAE,CAAA,CAAE,KAAK,IAAI,CAAA;AACtD;AAWO,SAAS,eAAA,CACd,OAAA,EACA,IAAA,EACA,MAAA,EACwC;AACxC,EAAA,IAAI,QAAA,CAAS,OAAO,CAAA,KAAM,IAAA,EAAM,OAAO,IAAA;AAEvC,EAAA,MAAM,WAAA,GAAc,KAAK,MAAA,CAAO,CAAA,CAAA,KAAK,CAAC,CAAA,CAAE,UAAA,CAAW,GAAG,CAAC,CAAA;AACvD,EAAA,IAAI,WAAA,CAAY,CAAC,CAAA,KAAM,IAAA,IAAQ,YAAY,CAAC,CAAA,KAAM,UAAU,OAAO,IAAA;AACnE,EAAA,MAAM,EAAA,GAAK,+BAAA;AACX,EAAA,IAAI,KAAA;AACJ,EAAA,IAAI,IAAA,GAA+C,IAAA;AACnD,EAAA,OAAA,CAAQ,KAAA,GAAQ,EAAA,CAAG,IAAA,CAAK,MAAM,OAAO,IAAA,EAAM;AACzC,IAAA,IAAA,GAAO,EAAE,GAAA,EAAK,KAAA,CAAM,CAAC,CAAA,EAAG,QAAQ,MAAA,CAAO,KAAA,CAAM,CAAC,CAAC,CAAA,EAAE;AAAA,EACnD;AACA,EAAA,OAAO,IAAA;AACT;AAiBO,SAAS,mBAAA,CACd,SACA,UAAA,EACwC;AACxC,EAAA,IAAI,CAAC,OAAA,IAAW,CAAC,UAAA,EAAY,OAAO,IAAA;AACpC,EAAA,MAAM,QAAA,GAAW,QAAQ,OAAA,CAAQ,UAAA,EAAY,GAAG,CAAA,CAAE,OAAA,CAAQ,sBAAsB,GAAG,CAAA;AACnF,EAAA,IAAI,CAAC,qCAAA,CAAsC,IAAA,CAAK,QAAQ,GAAG,OAAO,IAAA;AAClE,EAAA,MAAM,EAAA,GAAK,+BAAA;AACX,EAAA,IAAI,KAAA;AACJ,EAAA,IAAI,IAAA,GAA+C,IAAA;AACnD,EAAA,OAAA,CAAQ,KAAA,GAAQ,EAAA,CAAG,IAAA,CAAK,UAAU,OAAO,IAAA,EAAM;AAC7C,IAAA,IAAA,GAAO,EAAE,GAAA,EAAK,KAAA,CAAM,CAAC,CAAA,EAAG,QAAQ,MAAA,CAAO,KAAA,CAAM,CAAC,CAAC,CAAA,EAAE;AAAA,EACnD;AACA,EAAA,OAAO,IAAA;AACT;AAKA,SAAS,UAAA,CAAW,YAAoB,GAAA,EAAsB;AAC5D,EAAA,IAAI,UAAA,KAAe,KAAK,OAAO,IAAA;AAC/B,EAAA,MAAM,GAAA,GAAM,GAAA;AACZ,EAAA,OAAO,GAAA,CAAI,WAAW,UAAA,GAAa,GAAG,KAAK,UAAA,CAAW,UAAA,CAAW,MAAM,GAAG,CAAA;AAC5E;AAUO,SAAS,mBAAA,CACd,UACA,GAAA,EACe;AACf,EAAA,MAAM,aAAa,QAAA,CAAS,MAAA;AAAA,IAC1B,CAAA,CAAA,KAAK,CAAA,CAAE,IAAA,KAAS,WAAA,IAAe,OAAO,CAAA,CAAE,GAAA,KAAQ,QAAA,IAAY,UAAA,CAAW,CAAA,CAAE,GAAA,EAAK,GAAG;AAAA,GACnF;AACA,EAAA,IAAI,UAAA,CAAW,MAAA,KAAW,CAAA,EAAG,OAAO,MAAA;AACpC,EAAA,MAAM,QAAQ,CAAC,CAAA,KAAS,EAAE,MAAA,KAAW,SAAA,IAAa,EAAE,MAAA,KAAW,UAAA;AAC/D,EAAA,MAAM,SAAA,GAAY,CAAC,CAAA,EAAM,CAAA,KAAA,CAAU,CAAA,CAAE,aAAa,EAAA,EAAI,aAAA,CAAc,CAAA,CAAE,SAAA,IAAa,EAAE,CAAA;AACrF,EAAA,MAAM,OAAO,UAAA,CAAW,MAAA,CAAO,KAAK,CAAA,CAAE,KAAK,SAAS,CAAA;AACpD,EAAA,IAAI,IAAA,CAAK,MAAA,GAAS,CAAA,EAAG,OAAO,KAAK,CAAC,CAAA;AAClC,EAAA,OAAO,CAAC,GAAG,UAAU,EAAE,IAAA,CAAK,SAAS,EAAE,CAAC,CAAA;AAC1C","file":"pr-provenance.mjs","sourcesContent":["/**\n * PR provenance footer — daemon/MCP lane.\n *\n * This is the SAME footer the runner-side agentflow scripts stamp on the\n * CI-PR, review, and local-`gh_open_pr` lanes; `scripts/lib/provenance-\n * footer.mjs` re-exports {@link buildFooter}/{@link MARKER}/{@link fmtTokens}\n * from HERE so there is exactly one format, one marker, one renderer. The\n * only reason this canonical copy lives in `@agentproto/runtime` rather than\n * in `scripts/` is direction of dependency: the daemon is a package and\n * cannot import a loose script, but a script can import a package's built\n * `dist/` (it already does for `computeProvenance`).\n *\n * What the DAEMON lane adds over the runner lanes: the footer is built from\n * a live {@link SessionDescriptor} (the executor agent-cli session that ran\n * `gh pr create`) plus its supervisor/parent session, rather than from a\n * `computeProvenance` join over the worktree. So this file also owns the\n * mapping from a session descriptor to the footer's provenance shape\n * ({@link sessionFooterProvenance}) and the small pure helpers the\n * command_execute stamp path needs ({@link parseGhPrCreate},\n * {@link pickExecutorSession}, {@link appendFooterOnce}).\n *\n * Pure by construction — only `node:path`, no daemon imports, no I/O — so it\n * bundles to a standalone `dist/pr-provenance.mjs` the scripts can import\n * without pulling the rest of the runtime in. Keep it that way. The\n * side-effecting orchestration (spawning `gh`, editing the PR body, recording\n * the opened PR) lives in `pr-provenance-stamp.ts`.\n */\n\nimport { basename } from \"node:path\"\n\n/** Deterministic marker the runner owns — a reliable native-vs-legacy\n * discriminator across every lane (CI-PR, review, local, daemon). */\nexport const MARKER = \"@agentproto-bot\"\n\nexport const fmtTokens = (n: unknown): string | null => {\n if (typeof n !== \"number\" || !Number.isFinite(n)) return null\n return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)\n}\n\n/** The footer's provenance shape — field names are the renderer's, not the\n * session registry's (see {@link sessionFooterProvenance} for the mapping). */\nexport interface FooterProvenance {\n sessionId?: string\n label?: string\n /** Adapter / harness slug that ran (\"claude-code\", \"codex\", …). */\n adapter?: string\n model?: string\n /** Bound auth-profile IDENTITY — a label or ref, NEVER the credential. */\n authProfile?: string\n sandboxId?: string\n /** Supervisor / parent session that spawned the executor. */\n parentSessionId?: string\n tokensIn?: number\n tokensOut?: number\n costUsd?: number\n /** \"adapter\" | \"local\" | \"daemon\" — drives host/cwd vs run-link rendering. */\n source?: string\n host?: string\n cwd?: string\n /** The session's registered workspace slug (`SessionDescriptor.workspaceSlug`,\n * e.g. \"ts\", \"default\"). Rendered alongside `cwd`'s leaf directory name so\n * the footer identifies WHICH workspace ran the command, not just an\n * arbitrary trailing path segment — a bare `basename(cwd)` reads as\n * meaningless (or actively misleading) when the session's cwd is a\n * workspace root rather than a per-branch worktree. */\n workspaceSlug?: string\n}\n\n/**\n * Render the footer's `cwd` value. A bare `basename(cwd)` collapses to a\n * meaningless (or actively misleading) fragment whenever the session's cwd IS\n * the workspace root rather than a per-branch worktree dir — e.g. a checkout\n * literally named `ts` renders as the opaque `cwd \\`ts\\`` with no indication\n * that's a workspace name, not a worktree/branch leaf. Prefixing the\n * registered workspace slug disambiguates it, and is dropped when it's\n * identical to the leaf (the common per-worktree case) so the common case\n * stays exactly as compact as before.\n */\nfunction cwdLabel(cwd: string, workspaceSlug?: string): string {\n const leaf = basename(cwd)\n if (!workspaceSlug || workspaceSlug === leaf) return workspaceSlug ?? leaf\n return `${workspaceSlug}/${leaf}`\n}\n\nexport interface BuildFooterInput {\n prov?: FooterProvenance\n authMode?: string\n runId?: string\n runUrl?: string\n sha?: string\n kind?: string\n}\n\nexport const buildFooter = ({\n prov,\n authMode,\n runId,\n runUrl,\n sha,\n kind = \"review\",\n}: BuildFooterInput): string => {\n const parts = [`🤖 **${MARKER}** — ${kind}`]\n if (prov?.sessionId) {\n parts.push(`session \\`${prov.sessionId}\\`${prov.label ? ` (\\`${prov.label}\\`)` : \"\"}`)\n }\n // Engine label: \"adapter / authMode\" when an adapter ran; \"legacy fallback\n // (authMode)\" when no agent session exists at all (the API-key fallback path);\n // bare authMode only if a session ran without a resolved adapter slug.\n if (prov?.adapter) parts.push([prov.adapter, authMode].filter(Boolean).join(\" / \"))\n else if (!prov?.sessionId) parts.push(`legacy fallback${authMode ? ` (${authMode})` : \"\"}`)\n else if (authMode) parts.push(authMode)\n // Bound auth profile identity (the \"wallet\") — daemon lane; NEVER the secret.\n if (prov?.authProfile) parts.push(`auth-profile \\`${prov.authProfile}\\``)\n if (prov?.model) parts.push(`model \\`${prov.model}\\``)\n if (prov?.sandboxId) parts.push(`e2b \\`${prov.sandboxId}\\``)\n if (prov?.parentSessionId) parts.push(`supervisor \\`${prov.parentSessionId}\\``)\n const tin = fmtTokens(prov?.tokensIn)\n const tout = fmtTokens(prov?.tokensOut)\n if (tin || tout) parts.push(`${tin ?? \"?\"} in / ${tout ?? \"?\"} out`)\n if (typeof prov?.costUsd === \"number\") {\n parts.push(`$${prov.costUsd.toFixed(4)}${prov.source && prov.source !== \"adapter\" ? ` (${prov.source})` : \"\"}`)\n }\n const showLocalHostCwd = prov?.source === \"local\" || prov?.source === \"daemon\" || !runId\n if (runId && !showLocalHostCwd) parts.push(`run [${runId}](${runUrl})`)\n if (showLocalHostCwd) {\n if (prov?.host) parts.push(`host \\`${prov.host}\\``)\n if (prov?.cwd) parts.push(`cwd \\`${cwdLabel(prov.cwd, prov.workspaceSlug)}\\``)\n }\n if (sha) parts.push(`sha \\`${sha}\\``)\n return `\\n\\n---\\n<sub>${parts.join(\" · \")}</sub>`\n}\n\n/** Structural subset of `SessionDescriptor` the footer needs. The runtime's\n * full descriptor satisfies this; keeping it structural is what lets this\n * module stay free of a `./sessions.js` import (and thus standalone). */\nexport interface FooterSession {\n id: string\n kind?: string\n status?: string\n startedAt?: string\n label?: string\n cwd?: string\n workspaceSlug?: string\n adapterSlug?: string\n harness?: string\n model?: string\n parentSessionId?: string\n costUsd?: number\n tokensIn?: number\n tokensOut?: number\n auth?: { mode?: string }\n accessProfile?: { profileRef: string; label?: string }\n}\n\nexport interface SessionFooterOptions {\n /** The supervisor/parent session descriptor, when resolvable. */\n supervisor?: { id: string } | null\n host?: string\n /** Provenance source tag; defaults to \"daemon\". */\n source?: string\n}\n\n/**\n * Map a live executor session (+ its supervisor) onto the footer's provenance\n * shape and the `authMode` the renderer takes separately. The adapter is the\n * canonical harness slug, falling back to `adapterSlug`. The bound auth\n * profile is the profile's human label (or its ref), never the credential.\n */\nexport function sessionFooterProvenance(\n session: FooterSession,\n options: SessionFooterOptions = {},\n): { prov: FooterProvenance; authMode: string | undefined } {\n const prov: FooterProvenance = {\n sessionId: session.id,\n label: session.label,\n adapter: session.harness ?? session.adapterSlug,\n model: session.model,\n authProfile: session.accessProfile?.label ?? session.accessProfile?.profileRef,\n parentSessionId: options.supervisor?.id,\n costUsd: session.costUsd,\n tokensIn: session.tokensIn,\n tokensOut: session.tokensOut,\n source: options.source ?? \"daemon\",\n host: options.host,\n cwd: session.cwd,\n workspaceSlug: session.workspaceSlug,\n }\n return { prov, authMode: session.auth?.mode }\n}\n\n/** Build the daemon-lane PR footer for one executor session directly. */\nexport function buildSessionPrFooter(\n session: FooterSession,\n options: SessionFooterOptions & { sha?: string } = {},\n): string {\n const { prov, authMode } = sessionFooterProvenance(session, options)\n return buildFooter({ prov, authMode, sha: options.sha, kind: \"PR\" })\n}\n\n/**\n * Append the footer to a PR body exactly once. Idempotent by the marker: a\n * body that already carries a `@agentproto-bot` footer is returned unchanged,\n * so a retried stamp (network reply lost after the edit landed) never stacks\n * a second footer.\n */\nexport function appendFooterOnce(body: string, footer: string): string {\n if (hasProvenanceFooter(body)) return body\n return `${body}${footer}`\n}\n\n/**\n * Whether a PR/review body already carries a RENDERED provenance footer — the\n * `<sub>…@agentproto-bot…</sub>` line {@link buildFooter} emits — as opposed\n * to merely MENTIONING the marker in prose. The distinction matters: a PR\n * whose description discusses the provenance machinery itself (they exist —\n * #999 explains a footer bug and quotes the marker) would otherwise read as\n * \"already stamped\" forever and never receive its real footer.\n */\nexport function hasProvenanceFooter(body: string): boolean {\n return new RegExp(`<sub>[^\\\\n]*${MARKER}`).test(body)\n}\n\n/**\n * Recognise a successful `gh pr create` from its argv + stdout and pull the\n * created PR's URL + number out. Returns null for anything that isn't a\n * PR-creating `gh` invocation, or a create whose stdout carried no PR URL.\n *\n * `gh pr create` prints the PR URL on its own line (often the last line,\n * after any advisory notices) — we take the LAST `…/pull/<n>` match so a\n * \"Warning: …/pull/…\" style notice can't shadow the real one.\n */\nexport function parseGhPrCreate(\n command: string,\n args: readonly string[],\n stdout: string,\n): { url: string; number: number } | null {\n if (basename(command) !== \"gh\") return null\n // Skip global flags; the first two non-flag tokens must be `pr` then `create`.\n const positionals = args.filter(a => !a.startsWith(\"-\"))\n if (positionals[0] !== \"pr\" || positionals[1] !== \"create\") return null\n const re = /https?:\\/\\/\\S+?\\/pull\\/(\\d+)/g\n let match: RegExpExecArray | null\n let last: { url: string; number: number } | null = null\n while ((match = re.exec(stdout)) !== null) {\n last = { url: match[0], number: Number(match[1]) }\n }\n return last\n}\n\n/**\n * String-form counterpart of {@link parseGhPrCreate} for the in-agent tool\n * lane: an ACP harness's Bash-style tool reports ONE shell string (possibly\n * compound — `git push && gh pr create … | tail -1`), not an argv, so the\n * argv parser above can't see it. Detects \"this call created a PR\" from the\n * command string plus the call's recorded result text, and returns the\n * created PR (LAST `…/pull/<n>` match in the result, same shadowing rule as\n * {@link parseGhPrCreate} — `gh pr create` prints its URL after any notices).\n *\n * Quoted spans are stripped before matching so a command that merely\n * MENTIONS the phrase — `grep \"gh pr create\" src/` over a result that quotes\n * a PR url — can never read as a create. Best-effort by design: the caller\n * additionally gates on the call's own `isError` (a failed create whose\n * stderr cites an existing PR must not attribute that PR here).\n */\nexport function detectShellPrCreate(\n command: string | undefined,\n resultText: string | undefined,\n): { url: string; number: number } | null {\n if (!command || !resultText) return null\n const unquoted = command.replace(/'[^']*'/g, \" \").replace(/\"(?:[^\"\\\\]|\\\\.)*\"/g, \" \")\n if (!/(^|[\\s;&|({])gh\\s+pr\\s+create(\\s|$)/.test(unquoted)) return null\n const re = /https?:\\/\\/\\S+?\\/pull\\/(\\d+)/g\n let match: RegExpExecArray | null\n let last: { url: string; number: number } | null = null\n while ((match = re.exec(resultText)) !== null) {\n last = { url: match[0], number: Number(match[1]) }\n }\n return last\n}\n\n/** `session.cwd == cwd || one contains the other` — the executor's cwd is\n * usually the worktree root while the command may run in a subdir (or vice\n * versa), so containment is checked in both directions. */\nfunction cwdRelated(sessionCwd: string, cwd: string): boolean {\n if (sessionCwd === cwd) return true\n const sep = \"/\"\n return cwd.startsWith(sessionCwd + sep) || sessionCwd.startsWith(cwd + sep)\n}\n\n/**\n * Best-effort attribution of a `command_execute` run to the executor agent\n * session that most likely issued it: the newest agent-cli session whose cwd\n * is related to the command's cwd, preferring a still-alive one. Returns\n * undefined when no agent-cli session matches (e.g. a bare shell opened the\n * PR) — the caller then skips stamping rather than emit a misleading \"legacy\n * fallback\" footer.\n */\nexport function pickExecutorSession<T extends FooterSession>(\n sessions: readonly T[],\n cwd: string,\n): T | undefined {\n const candidates = sessions.filter(\n s => s.kind === \"agent-cli\" && typeof s.cwd === \"string\" && cwdRelated(s.cwd, cwd),\n )\n if (candidates.length === 0) return undefined\n const alive = (s: T) => s.status === \"running\" || s.status === \"starting\"\n const byRecency = (a: T, b: T) => (b.startedAt ?? \"\").localeCompare(a.startedAt ?? \"\")\n const live = candidates.filter(alive).sort(byRecency)\n if (live.length > 0) return live[0]\n return [...candidates].sort(byRecency)[0]\n}\n"]}
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Release check for `@agentproto/cli` (and the daemon it carries) — shared
3
+ * between the CLI (`agentproto daemon status` fold) and the VS Code extension
4
+ * (the release status-bar indicator + update prompt). Both packages already
5
+ * depend on `@agentproto/runtime`, so it's the natural home for the ONE copy
6
+ * of the decision + IO, rather than a vscode-private module the CLI couldn't
7
+ * reach.
8
+ *
9
+ * Two halves:
10
+ * - the pure decision logic (`.`-version comparison, cache freshness, the
11
+ * offline-safe `resolveReleaseCheck`) — no import beyond the standard
12
+ * library, unit-testable without network/home/extension;
13
+ * - the IO half (npm registry fetch + the `~/.agentproto/release-check.json`
14
+ * versioned cache) — plain Node, no vscode import, runnable in tests.
15
+ *
16
+ * Contract (from .plans/release-update-indicator-PLAN.md, WP-A):
17
+ * - The signal "a new release exists" is the npm registry for
18
+ * `@agentproto/cli` (the CLI carries the daemon; `health.version` IS the
19
+ * CLI version, so the daemon inherits it).
20
+ * - Two comparison modes by `build.source` read on the daemon's /health:
21
+ * - `tarball` / absent → compare `health.version` against npm latest.
22
+ * - `workspace` → signal `workspace` distinctly: the real update for a
23
+ * workspace install is a local REBUILD, not an npm reinstall.
24
+ * - Offline-safe: never claim an update we didn't verify. When the npm fetch
25
+ * failed and the cache is stale, the answer is `unknown` — never a false
26
+ * "update dispo" off a stale number.
27
+ */
28
+ /** Cache file version — bump when the on-disk shape changes. */
29
+ declare const RELEASE_CHECK_CACHE_VERSION = 1;
30
+ /** Default npm poll TTL (ms). ~1 h, never below the 10 min floor. */
31
+ declare const RELEASE_CHECK_DEFAULT_TTL_MS: number;
32
+ declare const RELEASE_CHECK_MIN_TTL_MS: number;
33
+ /** Build provenance, from the daemon's health.build.source. */
34
+ type ReleaseBuildSource = "workspace" | "tarball" | null;
35
+ /**
36
+ * What the release indicator reports. `workspace` is deliberately distinct
37
+ * from `current`: both mean "nothing to install via npm", but `workspace`
38
+ * additionally tells the display to frame the update as a rebuild.
39
+ */
40
+ type ReleaseState = "current" | "behind" | "unknown" | "workspace";
41
+ /** Persistent cache — the offline fallback. Versioned like the other
42
+ * `~/.agentproto/*.json` stores (`{ version: N, ... }`). */
43
+ interface ReleaseCheckCache {
44
+ /** Cache-file shape version. Must equal RELEASE_CHECK_CACHE_VERSION to be
45
+ * trusted; a mismatch (older/newer on-disk shape) is treated as no cache. */
46
+ version: number;
47
+ /** Latest `@agentproto/cli` observed on npm. Null while unknown. */
48
+ latest: string | null;
49
+ /** Local CLI version at the time of the check that produced `latest`. */
50
+ localVersion: string | null;
51
+ /** Epoch ms of the last successful npm fetch. */
52
+ checkedAtMs: number;
53
+ }
54
+ /**
55
+ * Compare two dotted versions numerically. Returns a positive integer when
56
+ * `a > b`, negative when `a < b`, 0 when equal or unparseable. Accepts
57
+ * optional `v` prefix and trailing prerelease/build suffix.
58
+ */
59
+ declare function compareVersions(a: string, b: string): number;
60
+ /**
61
+ * The WP-A decision. Returns:
62
+ * - `unknown` — no usable `latestVersion` (network error / no cache → the
63
+ * offline-safe answer; never a fabricated "behind").
64
+ * - `workspace` — the daemon is served from a workspace build: the real
65
+ * update is a rebuild, so this is its own state, not a
66
+ * plain `behind`.
67
+ * - `behind` — `buildSource` is a published tarball and npm is ahead.
68
+ * - `current` — tarball and npm is not ahead.
69
+ */
70
+ declare function compareRelease(localVersion: string | null | undefined, latestVersion: string | null | undefined, buildSource: ReleaseBuildSource): ReleaseState;
71
+ /** TTL, clamped to the 10 min floor. */
72
+ declare function releaseTtlMs(intervalMin: number | undefined): number;
73
+ /** True when the cache is young enough to trust without a fresh fetch. */
74
+ declare function isCacheFresh(cache: ReleaseCheckCache | null, nowMs: number, ttlMs: number): boolean;
75
+ interface ResolveCheckInput {
76
+ localVersion: string | null;
77
+ buildSource: ReleaseBuildSource;
78
+ /** Npm result from a fresh fetch. Null when the network fetch failed or was
79
+ * skipped because the cache was already fresh. */
80
+ latestFromNpm: string | null;
81
+ cache: ReleaseCheckCache | null;
82
+ nowMs: number;
83
+ ttlMs: number;
84
+ }
85
+ interface ResolveCheckResult {
86
+ state: ReleaseState;
87
+ /** The `@agentproto/cli` latest we trust, if any (fresh npm or fresh cache). */
88
+ latest: string | null;
89
+ /** Whether `latest` came from the cache rather than a live fetch. */
90
+ fromCache: boolean;
91
+ /** Cached latest (may be stale) — informational only, never drives state. */
92
+ cachedLatest: string | null;
93
+ }
94
+ /**
95
+ * Resolve the full check from its inputs. This is the pure, offline-safe
96
+ * core: a fresh npm result wins; otherwise a fresh cache is trusted (this is
97
+ * the "return the cache if the TTL hasn't expired" fallback); otherwise the
98
+ * answer is `unknown` — a stale cache never fabricates a "behind".
99
+ */
100
+ declare function resolveReleaseCheck(input: ResolveCheckInput): ResolveCheckResult;
101
+ /** `~/.agentproto/release-check.json`. */
102
+ declare function releaseCheckCachePath(home?: string): string;
103
+ interface FetchLatestCli {
104
+ (opts?: {
105
+ timeoutMs?: number;
106
+ }): Promise<string | null>;
107
+ }
108
+ /** Fetch the latest published `@agentproto/cli` version from the npm
109
+ * registry. Returns null on any network error (so the caller can fall back to
110
+ * the cache / report `unknown`), never throws. */
111
+ declare function fetchLatestCliVersion(opts?: {
112
+ timeoutMs?: number;
113
+ }): Promise<string | null>;
114
+ /** Read + parse the cache. Returns null when missing, malformed, or of a
115
+ * version we don't understand. */
116
+ declare function readReleaseCache(path?: string): Promise<ReleaseCheckCache | null>;
117
+ /** Atomically write the cache (tmp + rename), like the other versioned
118
+ * `~/.agentproto` stores. */
119
+ declare function writeReleaseCache(next: ReleaseCheckCache, path?: string): Promise<void>;
120
+ interface ReleaseCheckView {
121
+ /** Decided state. */
122
+ state: "current" | "behind" | "unknown" | "workspace";
123
+ /** Latest `@agentproto/cli` version to trust (fresh npm or fresh cache). */
124
+ latest: string | null;
125
+ /** Whether this run used the cache instead of npm. */
126
+ fromCache: boolean;
127
+ /** Local CLI version that was compared. */
128
+ localVersion: string | null;
129
+ }
130
+ interface RunReleaseCheckOptions {
131
+ localVersion: string | null;
132
+ buildSource: ReleaseBuildSource;
133
+ /** Poll TTL (ms). Pass releaseTtlMs(config) typically. */
134
+ ttlMs: number;
135
+ nowMs?: number;
136
+ /** Injectable fetcher for tests; defaults to the real npm fetch. */
137
+ fetchLatest?: FetchLatestCli;
138
+ cachePath?: string;
139
+ }
140
+ /** Run one release check: consult the cache, fetch npm only when stale, and
141
+ * persist a successful live result. Offline-safe by construction. */
142
+ declare function runReleaseCheck(opts: RunReleaseCheckOptions): Promise<ReleaseCheckView>;
143
+
144
+ export { type FetchLatestCli, RELEASE_CHECK_CACHE_VERSION, RELEASE_CHECK_DEFAULT_TTL_MS, RELEASE_CHECK_MIN_TTL_MS, type ReleaseBuildSource, type ReleaseCheckCache, type ReleaseCheckView, type ReleaseState, type ResolveCheckInput, type ResolveCheckResult, type RunReleaseCheckOptions, compareRelease, compareVersions, fetchLatestCliVersion, isCacheFresh, readReleaseCache, releaseCheckCachePath, releaseTtlMs, resolveReleaseCheck, runReleaseCheck, writeReleaseCache };
@@ -0,0 +1,149 @@
1
+ import { promises } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join, dirname } from 'path';
4
+
5
+ /**
6
+ * @agentproto/runtime v0.1.0-alpha
7
+ * Long-running gateway: MCP server + HTTP transport + HEARTBEAT autonomy + conversation persistence over a workspace dir.
8
+ */
9
+
10
+ var RELEASE_CHECK_CACHE_VERSION = 1;
11
+ var RELEASE_CHECK_DEFAULT_TTL_MS = 60 * 60 * 1e3;
12
+ var RELEASE_CHECK_MIN_TTL_MS = 10 * 60 * 1e3;
13
+ function compareVersions(a, b) {
14
+ const parse = (v) => {
15
+ const parts = v.replace(/^v/i, "").split("-")[0]?.split(".").map(Number).filter((n) => Number.isFinite(n)) ?? [];
16
+ return { parts, ok: parts.length > 0 };
17
+ };
18
+ const pa = parse(a);
19
+ const pb = parse(b);
20
+ if (!pa.ok || !pb.ok) return 0;
21
+ const len = Math.max(pa.parts.length, pb.parts.length);
22
+ for (let i = 0; i < len; i++) {
23
+ const da = pa.parts[i] ?? 0;
24
+ const db = pb.parts[i] ?? 0;
25
+ if (da !== db) return da - db;
26
+ }
27
+ return 0;
28
+ }
29
+ function compareRelease(localVersion, latestVersion, buildSource) {
30
+ if (!localVersion || !latestVersion) return "unknown";
31
+ if (buildSource === "workspace") return "workspace";
32
+ if (compareVersions(latestVersion, localVersion) > 0) return "behind";
33
+ return "current";
34
+ }
35
+ function releaseTtlMs(intervalMin) {
36
+ const min = intervalMin && intervalMin > 0 ? intervalMin : RELEASE_CHECK_DEFAULT_TTL_MS / 6e4;
37
+ return Math.max(min * 6e4, RELEASE_CHECK_MIN_TTL_MS);
38
+ }
39
+ function isCacheFresh(cache, nowMs, ttlMs) {
40
+ if (!cache || cache.version !== RELEASE_CHECK_CACHE_VERSION) return false;
41
+ if (typeof cache.checkedAtMs !== "number" || Number.isNaN(cache.checkedAtMs)) return false;
42
+ return nowMs - cache.checkedAtMs <= ttlMs;
43
+ }
44
+ function resolveReleaseCheck(input) {
45
+ const freshCache = isCacheFresh(input.cache, input.nowMs, input.ttlMs);
46
+ if (input.latestFromNpm) {
47
+ return {
48
+ state: compareRelease(input.localVersion, input.latestFromNpm, input.buildSource),
49
+ latest: input.latestFromNpm,
50
+ fromCache: false,
51
+ cachedLatest: input.cache?.latest ?? null
52
+ };
53
+ }
54
+ if (freshCache && input.cache?.latest) {
55
+ return {
56
+ state: compareRelease(input.localVersion, input.cache.latest, input.buildSource),
57
+ latest: input.cache.latest,
58
+ fromCache: true,
59
+ cachedLatest: input.cache.latest
60
+ };
61
+ }
62
+ return {
63
+ state: "unknown",
64
+ latest: null,
65
+ fromCache: false,
66
+ cachedLatest: input.cache?.latest ?? null
67
+ };
68
+ }
69
+ var CLI_PACKAGE = "@agentproto/cli";
70
+ var NPM_LATEST_URL = `https://registry.npmjs.org/${CLI_PACKAGE.replace("/", "%2F")}/latest`;
71
+ function releaseCheckCachePath(home = homedir()) {
72
+ return join(home, ".agentproto", "release-check.json");
73
+ }
74
+ async function fetchLatestCliVersion(opts = {}) {
75
+ const timeoutMs = opts.timeoutMs ?? 5e3;
76
+ const controller = new AbortController();
77
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
78
+ try {
79
+ const res = await fetch(NPM_LATEST_URL, { signal: controller.signal });
80
+ if (!res.ok) return null;
81
+ const body = await res.json();
82
+ if (typeof body.version === "string" && body.version.length > 0) return body.version;
83
+ return null;
84
+ } catch {
85
+ return null;
86
+ } finally {
87
+ clearTimeout(timer);
88
+ }
89
+ }
90
+ async function readReleaseCache(path = releaseCheckCachePath()) {
91
+ try {
92
+ const raw = await promises.readFile(path, "utf8");
93
+ const parsed = JSON.parse(raw);
94
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
95
+ const c = parsed;
96
+ if (c.version !== RELEASE_CHECK_CACHE_VERSION) return null;
97
+ return {
98
+ version: RELEASE_CHECK_CACHE_VERSION,
99
+ latest: typeof c.latest === "string" ? c.latest : null,
100
+ localVersion: typeof c.localVersion === "string" ? c.localVersion : null,
101
+ checkedAtMs: typeof c.checkedAtMs === "number" ? c.checkedAtMs : Number.NaN
102
+ };
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+ async function writeReleaseCache(next, path = releaseCheckCachePath()) {
108
+ await promises.mkdir(dirname(path), { recursive: true });
109
+ const tmp = `${path}.tmp`;
110
+ await promises.writeFile(tmp, JSON.stringify(next, null, 2), "utf8");
111
+ await promises.rename(tmp, path);
112
+ }
113
+ async function runReleaseCheck(opts) {
114
+ const nowMs = opts.nowMs ?? Date.now();
115
+ const path = opts.cachePath ?? releaseCheckCachePath();
116
+ const fetchLatest = opts.fetchLatest ?? fetchLatestCliVersion;
117
+ const cache = await readReleaseCache(path);
118
+ const cacheFresh = cache && typeof cache.checkedAtMs === "number" && nowMs - cache.checkedAtMs <= opts.ttlMs;
119
+ const latestFromNpm = cacheFresh ? null : await fetchLatest();
120
+ const res = resolveReleaseCheck({
121
+ localVersion: opts.localVersion,
122
+ buildSource: opts.buildSource,
123
+ latestFromNpm,
124
+ cache,
125
+ nowMs,
126
+ ttlMs: opts.ttlMs
127
+ });
128
+ if (latestFromNpm) {
129
+ await writeReleaseCache(
130
+ {
131
+ version: RELEASE_CHECK_CACHE_VERSION,
132
+ latest: latestFromNpm,
133
+ localVersion: opts.localVersion,
134
+ checkedAtMs: nowMs
135
+ },
136
+ path
137
+ );
138
+ }
139
+ return {
140
+ state: res.state,
141
+ latest: res.latest,
142
+ fromCache: res.fromCache,
143
+ localVersion: opts.localVersion
144
+ };
145
+ }
146
+
147
+ export { RELEASE_CHECK_CACHE_VERSION, RELEASE_CHECK_DEFAULT_TTL_MS, RELEASE_CHECK_MIN_TTL_MS, compareRelease, compareVersions, fetchLatestCliVersion, isCacheFresh, readReleaseCache, releaseCheckCachePath, releaseTtlMs, resolveReleaseCheck, runReleaseCheck, writeReleaseCache };
148
+ //# sourceMappingURL=release-check.mjs.map
149
+ //# sourceMappingURL=release-check.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/release-check.ts"],"names":["fs"],"mappings":"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","file":"release-check.mjs","sourcesContent":["/**\n * Release check for `@agentproto/cli` (and the daemon it carries) — shared\n * between the CLI (`agentproto daemon status` fold) and the VS Code extension\n * (the release status-bar indicator + update prompt). Both packages already\n * depend on `@agentproto/runtime`, so it's the natural home for the ONE copy\n * of the decision + IO, rather than a vscode-private module the CLI couldn't\n * reach.\n *\n * Two halves:\n * - the pure decision logic (`.`-version comparison, cache freshness, the\n * offline-safe `resolveReleaseCheck`) — no import beyond the standard\n * library, unit-testable without network/home/extension;\n * - the IO half (npm registry fetch + the `~/.agentproto/release-check.json`\n * versioned cache) — plain Node, no vscode import, runnable in tests.\n *\n * Contract (from .plans/release-update-indicator-PLAN.md, WP-A):\n * - The signal \"a new release exists\" is the npm registry for\n * `@agentproto/cli` (the CLI carries the daemon; `health.version` IS the\n * CLI version, so the daemon inherits it).\n * - Two comparison modes by `build.source` read on the daemon's /health:\n * - `tarball` / absent → compare `health.version` against npm latest.\n * - `workspace` → signal `workspace` distinctly: the real update for a\n * workspace install is a local REBUILD, not an npm reinstall.\n * - Offline-safe: never claim an update we didn't verify. When the npm fetch\n * failed and the cache is stale, the answer is `unknown` — never a false\n * \"update dispo\" off a stale number.\n */\n\nimport { promises as fs } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { dirname, join } from \"node:path\"\n\n/** Cache file version — bump when the on-disk shape changes. */\nexport const RELEASE_CHECK_CACHE_VERSION = 1\n\n/** Default npm poll TTL (ms). ~1 h, never below the 10 min floor. */\nexport const RELEASE_CHECK_DEFAULT_TTL_MS = 60 * 60 * 1000\nexport const RELEASE_CHECK_MIN_TTL_MS = 10 * 60 * 1000\n\n/** Build provenance, from the daemon's health.build.source. */\nexport type ReleaseBuildSource = \"workspace\" | \"tarball\" | null\n\n/**\n * What the release indicator reports. `workspace` is deliberately distinct\n * from `current`: both mean \"nothing to install via npm\", but `workspace`\n * additionally tells the display to frame the update as a rebuild.\n */\nexport type ReleaseState = \"current\" | \"behind\" | \"unknown\" | \"workspace\"\n\n/** Persistent cache — the offline fallback. Versioned like the other\n * `~/.agentproto/*.json` stores (`{ version: N, ... }`). */\nexport interface ReleaseCheckCache {\n /** Cache-file shape version. Must equal RELEASE_CHECK_CACHE_VERSION to be\n * trusted; a mismatch (older/newer on-disk shape) is treated as no cache. */\n version: number\n /** Latest `@agentproto/cli` observed on npm. Null while unknown. */\n latest: string | null\n /** Local CLI version at the time of the check that produced `latest`. */\n localVersion: string | null\n /** Epoch ms of the last successful npm fetch. */\n checkedAtMs: number\n}\n\n/**\n * Compare two dotted versions numerically. Returns a positive integer when\n * `a > b`, negative when `a < b`, 0 when equal or unparseable. Accepts\n * optional `v` prefix and trailing prerelease/build suffix.\n */\nexport function compareVersions(a: string, b: string): number {\n const parse = (v: string): { parts: number[]; ok: boolean } => {\n const parts =\n v\n .replace(/^v/i, \"\")\n .split(\"-\")[0]\n ?.split(\".\")\n .map(Number)\n .filter(n => Number.isFinite(n)) ?? []\n // A version that yields no numeric component (e.g. \"abc\") can't be\n // compared — treat the whole comparison as inconclusive rather than guess.\n return { parts, ok: parts.length > 0 }\n }\n const pa = parse(a)\n const pb = parse(b)\n if (!pa.ok || !pb.ok) return 0\n const len = Math.max(pa.parts.length, pb.parts.length)\n for (let i = 0; i < len; i++) {\n const da = pa.parts[i] ?? 0\n const db = pb.parts[i] ?? 0\n if (da !== db) return da - db\n }\n return 0\n}\n\n/**\n * The WP-A decision. Returns:\n * - `unknown` — no usable `latestVersion` (network error / no cache → the\n * offline-safe answer; never a fabricated \"behind\").\n * - `workspace` — the daemon is served from a workspace build: the real\n * update is a rebuild, so this is its own state, not a\n * plain `behind`.\n * - `behind` — `buildSource` is a published tarball and npm is ahead.\n * - `current` — tarball and npm is not ahead.\n */\nexport function compareRelease(\n localVersion: string | null | undefined,\n latestVersion: string | null | undefined,\n buildSource: ReleaseBuildSource,\n): ReleaseState {\n if (!localVersion || !latestVersion) return \"unknown\"\n if (buildSource === \"workspace\") return \"workspace\"\n if (compareVersions(latestVersion, localVersion) > 0) return \"behind\"\n return \"current\"\n}\n\n/** TTL, clamped to the 10 min floor. */\nexport function releaseTtlMs(intervalMin: number | undefined): number {\n const min = intervalMin && intervalMin > 0 ? intervalMin : RELEASE_CHECK_DEFAULT_TTL_MS / 60_000\n return Math.max(min * 60_000, RELEASE_CHECK_MIN_TTL_MS)\n}\n\n/** True when the cache is young enough to trust without a fresh fetch. */\nexport function isCacheFresh(\n cache: ReleaseCheckCache | null,\n nowMs: number,\n ttlMs: number,\n): boolean {\n if (!cache || cache.version !== RELEASE_CHECK_CACHE_VERSION) return false\n if (typeof cache.checkedAtMs !== \"number\" || Number.isNaN(cache.checkedAtMs)) return false\n return nowMs - cache.checkedAtMs <= ttlMs\n}\n\nexport interface ResolveCheckInput {\n localVersion: string | null\n buildSource: ReleaseBuildSource\n /** Npm result from a fresh fetch. Null when the network fetch failed or was\n * skipped because the cache was already fresh. */\n latestFromNpm: string | null\n cache: ReleaseCheckCache | null\n nowMs: number\n ttlMs: number\n}\n\nexport interface ResolveCheckResult {\n state: ReleaseState\n /** The `@agentproto/cli` latest we trust, if any (fresh npm or fresh cache). */\n latest: string | null\n /** Whether `latest` came from the cache rather than a live fetch. */\n fromCache: boolean\n /** Cached latest (may be stale) — informational only, never drives state. */\n cachedLatest: string | null\n}\n\n/**\n * Resolve the full check from its inputs. This is the pure, offline-safe\n * core: a fresh npm result wins; otherwise a fresh cache is trusted (this is\n * the \"return the cache if the TTL hasn't expired\" fallback); otherwise the\n * answer is `unknown` — a stale cache never fabricates a \"behind\".\n */\nexport function resolveReleaseCheck(input: ResolveCheckInput): ResolveCheckResult {\n const freshCache = isCacheFresh(input.cache, input.nowMs, input.ttlMs)\n if (input.latestFromNpm) {\n return {\n state: compareRelease(input.localVersion, input.latestFromNpm, input.buildSource),\n latest: input.latestFromNpm,\n fromCache: false,\n cachedLatest: input.cache?.latest ?? null,\n }\n }\n if (freshCache && input.cache?.latest) {\n return {\n state: compareRelease(input.localVersion, input.cache.latest, input.buildSource),\n latest: input.cache.latest,\n fromCache: true,\n cachedLatest: input.cache.latest,\n }\n }\n // Network failed AND the cache is stale or absent → unknown, no false claim.\n return {\n state: \"unknown\",\n latest: null,\n fromCache: false,\n cachedLatest: input.cache?.latest ?? null,\n }\n}\n\n// ── IO half ───────────────────────────────────────────────────────────────\n\n/** `@agentproto/cli` npm package (scoped → `%2F` in the registry URL). */\nconst CLI_PACKAGE = \"@agentproto/cli\"\nconst NPM_LATEST_URL = `https://registry.npmjs.org/${CLI_PACKAGE.replace(\"/\", \"%2F\")}/latest`\n\n/** `~/.agentproto/release-check.json`. */\nexport function releaseCheckCachePath(home: string = homedir()): string {\n return join(home, \".agentproto\", \"release-check.json\")\n}\n\nexport interface FetchLatestCli {\n (opts?: { timeoutMs?: number }): Promise<string | null>\n}\n\n/** Fetch the latest published `@agentproto/cli` version from the npm\n * registry. Returns null on any network error (so the caller can fall back to\n * the cache / report `unknown`), never throws. */\nexport async function fetchLatestCliVersion(opts: { timeoutMs?: number } = {}): Promise<string | null> {\n const timeoutMs = opts.timeoutMs ?? 5_000\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n try {\n const res = await fetch(NPM_LATEST_URL, { signal: controller.signal })\n if (!res.ok) return null\n const body = (await res.json()) as { version?: unknown }\n if (typeof body.version === \"string\" && body.version.length > 0) return body.version\n return null\n } catch {\n return null\n } finally {\n clearTimeout(timer)\n }\n}\n\n/** Read + parse the cache. Returns null when missing, malformed, or of a\n * version we don't understand. */\nexport async function readReleaseCache(\n path: string = releaseCheckCachePath(),\n): Promise<ReleaseCheckCache | null> {\n try {\n const raw = await fs.readFile(path, \"utf8\")\n const parsed = JSON.parse(raw) as unknown\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) return null\n const c = parsed as Partial<ReleaseCheckCache>\n if (c.version !== RELEASE_CHECK_CACHE_VERSION) return null\n return {\n version: RELEASE_CHECK_CACHE_VERSION,\n latest: typeof c.latest === \"string\" ? c.latest : null,\n localVersion: typeof c.localVersion === \"string\" ? c.localVersion : null,\n checkedAtMs: typeof c.checkedAtMs === \"number\" ? c.checkedAtMs : Number.NaN,\n }\n } catch {\n return null\n }\n}\n\n/** Atomically write the cache (tmp + rename), like the other versioned\n * `~/.agentproto` stores. */\nexport async function writeReleaseCache(\n next: ReleaseCheckCache,\n path: string = releaseCheckCachePath(),\n): Promise<void> {\n await fs.mkdir(dirname(path), { recursive: true })\n const tmp = `${path}.tmp`\n await fs.writeFile(tmp, JSON.stringify(next, null, 2), \"utf8\")\n await fs.rename(tmp, path)\n}\n\nexport interface ReleaseCheckView {\n /** Decided state. */\n state: \"current\" | \"behind\" | \"unknown\" | \"workspace\"\n /** Latest `@agentproto/cli` version to trust (fresh npm or fresh cache). */\n latest: string | null\n /** Whether this run used the cache instead of npm. */\n fromCache: boolean\n /** Local CLI version that was compared. */\n localVersion: string | null\n}\n\nexport interface RunReleaseCheckOptions {\n localVersion: string | null\n buildSource: ReleaseBuildSource\n /** Poll TTL (ms). Pass releaseTtlMs(config) typically. */\n ttlMs: number\n nowMs?: number\n /** Injectable fetcher for tests; defaults to the real npm fetch. */\n fetchLatest?: FetchLatestCli\n cachePath?: string\n}\n\n/** Run one release check: consult the cache, fetch npm only when stale, and\n * persist a successful live result. Offline-safe by construction. */\nexport async function runReleaseCheck(opts: RunReleaseCheckOptions): Promise<ReleaseCheckView> {\n const nowMs = opts.nowMs ?? Date.now()\n const path = opts.cachePath ?? releaseCheckCachePath()\n const fetchLatest = opts.fetchLatest ?? fetchLatestCliVersion\n const cache = await readReleaseCache(path)\n\n // Fetch only when the cache can't answer fresh (no cache, wrong version, or\n // TTL expired). A fresh cache is the offline fallback — do not hit npm.\n const cacheFresh = cache && typeof cache.checkedAtMs === \"number\" && nowMs - cache.checkedAtMs <= opts.ttlMs\n const latestFromNpm = cacheFresh ? null : await fetchLatest()\n\n const res = resolveReleaseCheck({\n localVersion: opts.localVersion,\n buildSource: opts.buildSource,\n latestFromNpm,\n cache,\n nowMs,\n ttlMs: opts.ttlMs,\n })\n\n // Persist a live successful result for the next offline window.\n if (latestFromNpm) {\n await writeReleaseCache(\n {\n version: RELEASE_CHECK_CACHE_VERSION,\n latest: latestFromNpm,\n localVersion: opts.localVersion,\n checkedAtMs: nowMs,\n },\n path,\n )\n }\n\n return {\n state: res.state,\n latest: res.latest,\n fromCache: res.fromCache,\n localVersion: opts.localVersion,\n }\n}"]}
@@ -60,12 +60,24 @@ interface ResumeStrategy {
60
60
  * most-recently-modified, filtered to files at-or-after `prevStartedAt`
61
61
  * (avoids resuming an unrelated prior conversation).
62
62
  *
63
+ * `configDir` is the session's isolated provider config dir
64
+ * (`SessionDescriptor.adapterConfigDir`): a daemon-spawned claude-code
65
+ * session (#824) persists its transcripts under that dir, not the
66
+ * provider's global store — the probe must look there. Omit for a
67
+ * native PTY / pre-#824 session (global store applies).
68
+ *
63
69
  * Skip when the adapter doesn't persist sessions externally. */
64
- fsProbe?(cwd: string, prevStartedAt: string, expectedId?: string): Promise<string | null>;
70
+ fsProbe?(cwd: string, prevStartedAt: string, expectedId?: string, configDir?: string): Promise<string | null>;
65
71
  /** Return the argv to spawn a PTY that resumes into the given id.
66
72
  * When omitted, the daemon falls back to ACP-level resume via
67
73
  * the agent-cli protocol instead of the provider's native CLI. */
68
74
  spawnArgs?(id: string): string[];
75
+ /** Env var the resumed PTY needs pointed at the session's isolated
76
+ * provider config dir to find ITS OWN conversation store — see
77
+ * `ConversationStore.configDirEnvVar`'s doc (conversation-store.ts) for
78
+ * why this exists and what silently omitting it breaks. Undefined for
79
+ * a provider with no config-dir-isolated store. */
80
+ configDirEnvVar?: string;
69
81
  }
70
82
  declare const RESUME_STRATEGIES: Record<string, ResumeStrategy>;
71
83
  /**
@@ -98,18 +110,27 @@ interface RestartCandidate {
98
110
  interface FsProbeCandidate extends RestartCandidate {
99
111
  cwd?: string;
100
112
  startedAt: string;
113
+ /** The session's isolated provider config dir (`SessionDescriptor.
114
+ * adapterConfigDir`) — where a daemon-spawned claude-code session's
115
+ * transcripts actually live since #824. A full `SessionDescriptor`
116
+ * satisfies this structurally; absent on pre-#824/PTY rows, where the
117
+ * provider's global store is the right place to probe. */
118
+ adapterConfigDir?: string;
101
119
  }
102
120
  /**
103
121
  * Where a restart should land. Computed once so callers never diverge:
104
122
  *
105
123
  * 1. pty-native — the adapter has a captured resume id AND declares
106
124
  * `spawnArgs` (e.g. claude-code): respawn a PTY running the
107
- * provider's own resume command. Most reliable works whenever
108
- * the provider persisted the session, regardless of whether the
109
- * ACP wrapper did.
125
+ * provider's own resume command. Most reliable CONTINUITY mechanism
126
+ * when it applies but only actually reachable by default when the
127
+ * prior session was ITSELF a real PTY (`prev.pty === true`), or the
128
+ * caller explicitly opts in (`preferNativeTerminal`) — see the
129
+ * `mayPreferNative` doc below for why.
110
130
  * 2. pty-plain — the previous session was a real PTY with no
111
131
  * native strategy match: re-run the same argv, no continuity.
112
- * 3. agent — an agent-cli session with no native strategy:
132
+ * 3. agent — an agent-cli session with no native strategy (or an
133
+ * ACP-origin session where pty-native doesn't apply — see above):
113
134
  * resume at the ACP level via the adapter's own session id (may
114
135
  * still 404 if the adapter never persisted a turn — callers
115
136
  * should retry without `resumeSessionId` on a "not found" error).
@@ -129,7 +150,17 @@ type RestartStrategy = {
129
150
  kind: "unsupported";
130
151
  reason: string;
131
152
  };
132
- declare function decideRestartStrategy(prev: RestartCandidate): RestartStrategy;
153
+ interface DecideRestartStrategyOptions {
154
+ /**
155
+ * Explicit opt-in to provider-native terminal resume for a session whose
156
+ * ORIGIN was agent-cli/ACP (not itself a PTY) — a human who wants the raw
157
+ * provider TUI instead of ACP-level resume, understanding the tradeoffs
158
+ * (see `mayPreferNative`'s doc below). Default false/omitted: an
159
+ * ACP-origin session always resumes via ACP.
160
+ */
161
+ preferNativeTerminal?: boolean;
162
+ }
163
+ declare function decideRestartStrategy(prev: RestartCandidate, opts?: DecideRestartStrategyOptions): RestartStrategy;
133
164
  /**
134
165
  * Matches an adapter's rejection of a `resumeSessionId` it can't honour —
135
166
  * the trigger for the "retry as a fresh spawn" fallback in
@@ -176,8 +207,17 @@ declare function augmentWithFsResume<T extends FsProbeCandidate>(prev: T): Promi
176
207
  * declared capability backs that claim (`resumable !== false`). An adapter
177
208
  * that declared `resumable: false` but still carries an `adapterSessionId`
178
209
  * gets an honest degraded label instead of the lie — never silently "".
210
+ *
211
+ * Origin gate (mirrors `decideRestartStrategy`'s own — MUST stay in sync or
212
+ * this label lies about what actually happened): the native-resume phrasing
213
+ * is only reported when `decideRestartStrategy` itself would have picked
214
+ * pty-native for this `prev` — i.e. `prev.pty === true` or the caller passed
215
+ * the SAME `preferNativeTerminal` it decided the strategy with. Without this
216
+ * gate, a captured `resumeMetadata` alone used to make this function claim
217
+ * "resumed via claude --resume" even when the actual decision (now)
218
+ * downgrades an ACP-origin session to ACP-level resume instead.
179
219
  */
180
- declare function describeResumePath(prev: RestartCandidate): string;
220
+ declare function describeResumePath(prev: RestartCandidate, opts?: DecideRestartStrategyOptions): string;
181
221
  /**
182
222
  * Shell-style argv tokenizer for descriptors that don't carry `argv`
183
223
  * separately (legacy persisted rows predating that field). Same rules
@@ -186,4 +226,4 @@ declare function describeResumePath(prev: RestartCandidate): string;
186
226
  */
187
227
  declare function tokenizeCommand(s: string): string[];
188
228
 
189
- export { type FsProbeCandidate, RESUME_ID_REJECTED_RE, RESUME_STRATEGIES, type RestartCandidate, type RestartStrategy, type ResumeMetadataKey, type ResumeStrategy, augmentWithFsResume, decideRestartStrategy, describeResumePath, hasResumeStrategy, tokenizeCommand };
229
+ export { type DecideRestartStrategyOptions, type FsProbeCandidate, RESUME_ID_REJECTED_RE, RESUME_STRATEGIES, type RestartCandidate, type RestartStrategy, type ResumeMetadataKey, type ResumeStrategy, augmentWithFsResume, decideRestartStrategy, describeResumePath, hasResumeStrategy, tokenizeCommand };