@adhdev/daemon-core 0.9.82-rc.142 → 0.9.82-rc.144

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.
Files changed (91) hide show
  1. package/dist/boot/process-hardening.d.ts +50 -0
  2. package/dist/cli-adapters/cli-script-runner.d.ts +73 -1
  3. package/dist/cli-adapters/provider-cli-adapter.d.ts +17 -0
  4. package/dist/cli-adapters/provider-cli-shared.d.ts +6 -0
  5. package/dist/commands/handler.d.ts +66 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +2876 -403
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2890 -424
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/ipc/local-ipc-server.d.ts +91 -0
  12. package/dist/providers/contracts.d.ts +8 -0
  13. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  14. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  15. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  16. package/dist/providers/native-history/index.d.ts +11 -0
  17. package/dist/providers/provider-loader.d.ts +19 -1
  18. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  19. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
  20. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  21. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +64 -0
  22. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +82 -0
  23. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  24. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  25. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  26. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  27. package/dist/providers/sdk/v1/index.d.ts +30 -0
  28. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  29. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  30. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  31. package/dist/providers/sdk/v1/types/cli/index.d.ts +268 -0
  32. package/dist/providers/sdk/v1/types/common/index.d.ts +169 -0
  33. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  34. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  35. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  36. package/package.json +4 -2
  37. package/src/boot/daemon-lifecycle.ts +14 -10
  38. package/src/boot/process-hardening.ts +89 -0
  39. package/src/cli-adapters/cli-script-runner.ts +289 -13
  40. package/src/cli-adapters/cli-state-engine.ts +8 -5
  41. package/src/cli-adapters/provider-cli-adapter.ts +36 -2
  42. package/src/cli-adapters/provider-cli-shared.ts +6 -0
  43. package/src/commands/chat-commands.ts +22 -1
  44. package/src/commands/cli-manager.ts +39 -0
  45. package/src/commands/handler.ts +539 -1
  46. package/src/commands/router.ts +1 -0
  47. package/src/index.ts +27 -0
  48. package/src/ipc/local-ipc-server.ts +278 -0
  49. package/src/providers/cli-provider-instance.ts +15 -0
  50. package/src/providers/contracts.ts +8 -0
  51. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  52. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  53. package/src/providers/native-history/codex-cli-transcript.ts +419 -0
  54. package/src/providers/native-history/index.ts +23 -0
  55. package/src/providers/provider-loader.ts +258 -17
  56. package/src/providers/provider-schema.ts +3 -0
  57. package/src/providers/sdk/README.md +49 -0
  58. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  59. package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
  60. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  61. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +245 -0
  62. package/src/providers/sdk/v1/builders/cli/parse-session.ts +247 -0
  63. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  64. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  65. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  66. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  67. package/src/providers/sdk/v1/index.ts +151 -0
  68. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  69. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  70. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  71. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +428 -0
  72. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  73. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  74. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  75. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  76. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  77. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  78. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  79. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  80. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +119 -0
  81. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  82. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  83. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  84. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  85. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  86. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  87. package/src/providers/sdk/v1/types/cli/index.ts +355 -0
  88. package/src/providers/sdk/v1/types/common/index.ts +210 -0
  89. package/src/providers/sdk/v1/validators/index.ts +19 -0
  90. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  91. package/src/providers/sdk/v1/validators/taint.ts +309 -0
@@ -38,6 +38,8 @@ import type { IdeProviderInstance } from '../providers/ide-provider-instance.js'
38
38
  import { createDefaultGitCommandServices } from '../git/git-commands.js';
39
39
  import { setupMeshEventForwarding } from '../mesh/mesh-events.js';
40
40
  import { loadMeshCoordinatorRegistry } from '../mesh/coordinator-registry.js';
41
+ import { applyProcessHardening } from './process-hardening.js';
42
+ import { installProviderProcessShim } from '../providers/sdk/v1/sandbox/require-whitelist.js';
41
43
 
42
44
  // ─── Init Config ───
43
45
 
@@ -130,6 +132,13 @@ export interface DaemonDevSupportOptions {
130
132
  * 8. Start instance ticking
131
133
  */
132
134
  export async function initDaemonComponents(config: DaemonInitConfig): Promise<DaemonComponents> {
135
+ // 0. Process-level hardening (must run before any provider script is loaded).
136
+ // Freezes built-in prototypes (Object/Array/Function/String/Number/Boolean/Promise)
137
+ // and shadows process.exit/kill/abort/binding/dlopen so provider-script callers
138
+ // throw instead of killing the daemon. See ./process-hardening.ts for details.
139
+ applyProcessHardening();
140
+ installProviderProcessShim();
141
+
133
142
  // 1. Global log interceptor
134
143
  installGlobalInterceptor();
135
144
  loadMeshCoordinatorRegistry();
@@ -144,16 +153,11 @@ export async function initDaemonComponents(config: DaemonInitConfig): Promise<Da
144
153
  userDir: appConfig.providerDir,
145
154
  });
146
155
 
147
- // If no upstream providers exist, fetch them first (blocking — critical for new users)
148
- if (!disableUpstream && !providerLoader.hasUpstream()) {
149
- LOG.info('Provider', 'No upstream providers found downloading from GitHub...');
150
- try {
151
- await providerLoader.fetchLatest();
152
- } catch (e: any) {
153
- LOG.warn('Provider', `⚠ Failed to fetch providers: ${e?.message}`);
154
- }
155
- }
156
-
156
+ // Boot-time auto-sync is intentionally disabled. The user picks which
157
+ // providers to install via the dashboard onboarding / Providers tab; the
158
+ // daemon ships empty and only contains what the user explicitly installs.
159
+ // Manual sync is still available via the install / check_provider_updates
160
+ // commands (and the REST endpoint at /api/v1/providers/updates).
157
161
  providerLoader.loadAll();
158
162
  providerLoader.registerToDetector();
159
163
 
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Process-level hardening — boot-time mitigations against provider scripts.
3
+ *
4
+ * Provider scripts run in-process (see providers/sdk/v1/sandbox/README-design.ts).
5
+ * The require() whitelist (sandbox/require-whitelist.ts) blocks the obvious
6
+ * attack surfaces (no `child_process.exec`, no `fs.writeFile`, no `net`, etc.)
7
+ * but a malicious or buggy script can still:
8
+ *
9
+ * 1. Pollute Object.prototype to influence the daemon's downstream consumers
10
+ * (e.g. `Object.prototype.toString = () => '...'` makes every untagged
11
+ * object stringify to that value across the whole process).
12
+ * 2. Mutate Array/Function/String prototypes for similar effect.
13
+ * 3. Call `process.exit()` / `process.kill()` to kill the daemon.
14
+ *
15
+ * Sandbox isolation (isolated-vm) is the proper fix but has a real perf cost
16
+ * and a real marshalling-complexity cost. As a cheaper layer we just freeze
17
+ * the built-in prototypes at boot, which is irreversible and catches every
18
+ * prototype-pollution variant for the lifetime of the process.
19
+ *
20
+ * `applyProcessHardening()` is idempotent — calling it twice is a no-op the
21
+ * second time. The function intentionally swallows any "cannot redefine
22
+ * property" errors that would arise if some upstream code already froze
23
+ * the same prototype: the post-condition (prototype is frozen) is satisfied
24
+ * either way.
25
+ *
26
+ * Trade-offs:
27
+ * - Read-only access to prototype methods (`Object.prototype.hasOwnProperty.call`,
28
+ * `Array.prototype.slice.call`, etc.) is unaffected — only writes throw.
29
+ * - Some older npm libraries patch Array.prototype or Function.prototype at
30
+ * import time. If a daemon-core dep does this, the import will throw. The
31
+ * daemon-core vitest suite is the canonical regression check for this.
32
+ * - We deliberately do NOT freeze RegExp.prototype, Error.prototype, Map/Set
33
+ * prototypes, or Date.prototype because at least one of those tends to be
34
+ * mutated by mainstream test/runtime libs and the value of freezing them
35
+ * is low (no useful prototype-pollution attack surface).
36
+ */
37
+
38
+ let _hardened = false;
39
+
40
+ /** Prototypes that get frozen at boot. */
41
+ const HARDENED_PROTOS: Array<{ name: string; proto: object }> = [
42
+ { name: 'Object', proto: Object.prototype },
43
+ { name: 'Array', proto: Array.prototype },
44
+ { name: 'Function', proto: Function.prototype },
45
+ { name: 'String', proto: String.prototype },
46
+ { name: 'Number', proto: Number.prototype },
47
+ { name: 'Boolean', proto: Boolean.prototype },
48
+ { name: 'Promise', proto: Promise.prototype },
49
+ ];
50
+
51
+ /**
52
+ * Freeze the canonical built-in prototypes so provider scripts (and anything
53
+ * else running in this process) cannot mutate them. Idempotent.
54
+ *
55
+ * Returns the list of prototype names that were newly frozen — primarily for
56
+ * tests / observability. Already-frozen prototypes are not re-reported.
57
+ */
58
+ export function applyProcessHardening(): string[] {
59
+ if (_hardened) return [];
60
+ _hardened = true;
61
+
62
+ const newlyFrozen: string[] = [];
63
+ for (const entry of HARDENED_PROTOS) {
64
+ if (Object.isFrozen(entry.proto)) continue;
65
+ try {
66
+ Object.freeze(entry.proto);
67
+ newlyFrozen.push(entry.name);
68
+ } catch {
69
+ // Some host/runtime combinations refuse to freeze a built-in
70
+ // prototype (e.g. it has a non-configurable accessor). We treat
71
+ // this as best-effort — log nothing here (logger not guaranteed
72
+ // to be initialized this early) and move on. Subsequent reads
73
+ // of `Object.isFrozen(proto)` will reveal the gap.
74
+ }
75
+ }
76
+ return newlyFrozen;
77
+ }
78
+
79
+ /** For tests only — clears the internal "already hardened" flag. The
80
+ * prototypes themselves cannot be un-frozen, so this is mostly useful for
81
+ * testing the idempotency guard. */
82
+ export function _resetProcessHardeningForTest(): void {
83
+ _hardened = false;
84
+ }
85
+
86
+ /** For tests only — peek at the hardened state. */
87
+ export function _isProcessHardened(): boolean {
88
+ return _hardened;
89
+ }
@@ -20,30 +20,240 @@ import {
20
20
  type CliStatusInput,
21
21
  type ParsedSession,
22
22
  } from './provider-cli-shared.js';
23
+ import { buildDetectStatusFromTui } from '../providers/sdk/v1/builders/cli/detect-status.js';
24
+ import { buildParseApprovalFromTui } from '../providers/sdk/v1/builders/cli/parse-approval.js';
25
+ import { buildParseSessionFromTui, normalizeMessageIdentity } from '../providers/sdk/v1/builders/cli/parse-session.js';
26
+
27
+ /**
28
+ * Capability bag injected into provider scripts as the third argument.
29
+ *
30
+ * v1 manifests declare line-shape recognition in their `tui` block — for
31
+ * those providers, the daemon builds the canonical (input → verdict)
32
+ * functions from the manifest and hands them to the script via `sdk`. The
33
+ * script's job is then only to wrap that verdict with stateful logic
34
+ * (idle-hold timers, frame counting, etc.) that can't be expressed
35
+ * declaratively.
36
+ *
37
+ * Without these, the extended-tier overrides bail with `return 'idle'`
38
+ * and the session sticks in generating forever. That's the regression
39
+ * we hit on codex-cli during the registry-install path.
40
+ */
41
+ interface CliScriptSdk {
42
+ declarativeDetectStatus?: (input: CliStatusInput) => string | null;
43
+ declarativeParseApproval?: (input: CliApprovalInput) => { message: string; buttons: string[] } | null;
44
+ }
45
+
46
+ /**
47
+ * One entry per script invocation, kept in a ring buffer so debug
48
+ * tooling can answer "what did the script see, and what did it return?"
49
+ * without having to re-run the daemon with custom logs. This is the
50
+ * trace that would have answered the codex-cli #102 regression in one
51
+ * pass instead of a dozen guesses.
52
+ *
53
+ * Body fields are bounded: input is reduced to a small summary (sizes
54
+ * + a salted hash + the first few normalized chars of screenText), and
55
+ * result is JSON-serialized then capped. Full PTY frames are NOT
56
+ * captured here — they live in CliBufferSnapshot and are exposed by
57
+ * the chat debug bundle separately.
58
+ */
59
+ export interface CliScriptInvocationTrace {
60
+ at: number; // Date.now()
61
+ scriptName: string;
62
+ arity: number;
63
+ inputSummary: {
64
+ screenTextLen: number;
65
+ rawBufferLen: number;
66
+ tailLen: number;
67
+ isWaitingForResponse?: boolean;
68
+ screenTextHead?: string; // first 200 chars of screenText (post-strip)
69
+ };
70
+ ok: boolean;
71
+ elapsedUs: number;
72
+ resultSummary?: string; // JSON.stringify(result).slice(0, 400)
73
+ error?: string;
74
+ /**
75
+ * True when the invocation's elapsed time exceeded the runner's
76
+ * `scriptCallBudgetMs`. The script is NOT aborted (Node CJS can't
77
+ * interrupt sync code without a worker thread) — this flag plus the
78
+ * trace ring lets an operator identify which provider is hanging the
79
+ * settle loop. A throttled WARN is emitted alongside.
80
+ */
81
+ timedOut?: boolean;
82
+ }
83
+
84
+ const TRACE_RING_CAPACITY = 64;
85
+
86
+ /** Default per-invocation wall-clock budget (ms) when the manifest omits one. */
87
+ const DEFAULT_SCRIPT_CALL_BUDGET_MS = 50;
88
+
89
+ /** Minimum interval between repeated budget-violation WARNs per script. */
90
+ const BUDGET_WARN_THROTTLE_MS = 30_000;
91
+
92
+ function summarizeInput(input: any): CliScriptInvocationTrace['inputSummary'] {
93
+ const screenText = typeof input?.screenText === 'string' ? input.screenText : '';
94
+ const rawBuffer = typeof input?.rawBuffer === 'string' ? input.rawBuffer : '';
95
+ const tail = typeof input?.tail === 'string' ? input.tail : '';
96
+ return {
97
+ screenTextLen: screenText.length,
98
+ rawBufferLen: rawBuffer.length,
99
+ tailLen: tail.length,
100
+ isWaitingForResponse: typeof input?.isWaitingForResponse === 'boolean' ? input.isWaitingForResponse : undefined,
101
+ screenTextHead: screenText ? screenText.slice(0, 200) : undefined,
102
+ };
103
+ }
104
+
105
+ function summarizeResult(result: unknown): string {
106
+ try {
107
+ const json = JSON.stringify(result);
108
+ return json && json.length > 400 ? `${json.slice(0, 400)}…[truncated ${json.length - 400}]` : (json ?? 'undefined');
109
+ } catch (e: any) {
110
+ return `<unserializable: ${e?.message || e}>`;
111
+ }
112
+ }
23
113
 
24
114
  export class CliScriptRunner {
25
115
  private scripts: CliScripts = {};
26
116
  private scriptState: unknown = null;
27
117
  private _parseErrorMessage: string | null = null;
28
118
  private readonly cliType: string;
119
+ private sdk: CliScriptSdk = {};
120
+ private invocationTrace: CliScriptInvocationTrace[] = [];
121
+ /** Per-invocation wall-clock budget (ms). Configurable via setScriptCallBudget. */
122
+ private scriptCallBudgetMs = DEFAULT_SCRIPT_CALL_BUDGET_MS;
123
+ /** Last WARN emit time per scriptName, used to throttle repeated budget violations. */
124
+ private lastBudgetWarnAt = new Map<string, number>();
29
125
 
30
126
  constructor(cliType: string) {
31
127
  this.cliType = cliType;
32
128
  }
33
129
 
130
+ /** Returns the most-recent script invocation traces (oldest → newest). */
131
+ getInvocationTrace(): CliScriptInvocationTrace[] {
132
+ return this.invocationTrace.slice();
133
+ }
134
+
135
+ /** Clear the trace ring — used by tests and after PTY reset. */
136
+ clearInvocationTrace(): void {
137
+ this.invocationTrace = [];
138
+ }
139
+
140
+ /**
141
+ * Configure the wall-clock budget (ms) applied to every script invocation.
142
+ *
143
+ * Out-of-range or non-finite values are clamped to [1, 5000] and the
144
+ * default (50ms) is used as a fallback. The budget is enforced per-call,
145
+ * not aggregated — it does not abort a runaway script (Node CJS cannot
146
+ * interrupt synchronous code without a worker thread). Instead, an
147
+ * exceeded budget flags the trace entry with `timedOut: true` and emits
148
+ * a throttled WARN naming the script and elapsed time so an operator
149
+ * can identify which provider is hanging the settle loop.
150
+ */
151
+ setScriptCallBudget(ms: number): void {
152
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) {
153
+ this.scriptCallBudgetMs = DEFAULT_SCRIPT_CALL_BUDGET_MS;
154
+ return;
155
+ }
156
+ const clamped = Math.max(1, Math.min(5000, Math.floor(ms)));
157
+ this.scriptCallBudgetMs = clamped;
158
+ }
159
+
160
+ /** Test/debug accessor — current effective budget in ms. */
161
+ getScriptCallBudgetMs(): number {
162
+ return this.scriptCallBudgetMs;
163
+ }
164
+
165
+ private recordTrace(entry: CliScriptInvocationTrace): void {
166
+ this.invocationTrace.push(entry);
167
+ if (this.invocationTrace.length > TRACE_RING_CAPACITY) {
168
+ this.invocationTrace.splice(0, this.invocationTrace.length - TRACE_RING_CAPACITY);
169
+ }
170
+ }
171
+
34
172
  // ─── Script lifecycle ─────────────────────────────
35
173
 
36
- setScripts(scripts: CliScripts): void {
37
- this.scripts = scripts;
174
+ setScripts(scripts: CliScripts, providerTui?: Record<string, unknown> | undefined): void {
175
+ // SDK is built first so the synth functions below have access to it.
176
+ this.sdk = this.buildSdk(providerTui);
177
+
178
+ // Fill missing scripts from the SDK synth. This is the heart of the
179
+ // declarative model: a v1 manifest with a complete tui block (spinner
180
+ // + modal + settledPrompt + transcriptPty) gets working detectStatus
181
+ // / parseApproval / parseSession functions for free, no provider .js
182
+ // required. Providers that supply their own override always win
183
+ // because we don't overwrite when scripts[name] is already a function.
184
+ const tui = providerTui as Record<string, unknown> | undefined;
185
+ const enriched: CliScripts = { ...scripts };
186
+
187
+ if (typeof enriched.detectStatus !== 'function' && this.sdk.declarativeDetectStatus) {
188
+ enriched.detectStatus = this.sdk.declarativeDetectStatus as any;
189
+ }
190
+ if (typeof enriched.parseApproval !== 'function' && this.sdk.declarativeParseApproval) {
191
+ enriched.parseApproval = this.sdk.declarativeParseApproval as any;
192
+ }
193
+ if (typeof enriched.parseSession !== 'function' && tui?.transcriptPty) {
194
+ try {
195
+ const synth = buildParseSessionFromTui({
196
+ spinner: tui.spinner,
197
+ settledPrompt: tui.settledPrompt,
198
+ modal: tui.modal as any,
199
+ dispatchOrder: tui.dispatchOrder,
200
+ transcriptPty: tui.transcriptPty as any,
201
+ });
202
+ // Wrap to apply identity stamps the daemon downstream expects.
203
+ enriched.parseSession = ((input: any) => {
204
+ const out = synth(input);
205
+ return {
206
+ ...out,
207
+ messages: normalizeMessageIdentity(out.messages, out.status ?? 'idle'),
208
+ };
209
+ }) as any;
210
+ } catch (e: any) {
211
+ LOG.warn('CLI', `[${this.cliType}] buildParseSessionFromTui failed: ${e?.message || e}`);
212
+ }
213
+ }
214
+
215
+ this.scripts = enriched;
38
216
  this._parseErrorMessage = null;
39
- this.scriptState = typeof scripts.createState === 'function'
40
- ? (scripts.createState() ?? null)
217
+ this.scriptState = typeof enriched.createState === 'function'
218
+ ? (enriched.createState() ?? null)
41
219
  : null;
42
220
  }
43
221
 
222
+ private buildSdk(providerTui: Record<string, unknown> | undefined): CliScriptSdk {
223
+ const tui = providerTui as Record<string, unknown> | undefined;
224
+ if (!tui) return {};
225
+ const sdk: CliScriptSdk = {};
226
+ // Build declarativeDetectStatus from the manifest tui block. The
227
+ // builder requires at least spinner OR settledPrompt OR modal; if
228
+ // none are present we skip silently so a non-tui manifest doesn't
229
+ // throw at boot time.
230
+ if (tui.spinner || tui.settledPrompt || tui.modal || tui.dispatchOrder) {
231
+ try {
232
+ sdk.declarativeDetectStatus = buildDetectStatusFromTui({
233
+ spinner: tui.spinner as any,
234
+ settledPrompt: tui.settledPrompt as any,
235
+ modal: tui.modal as any,
236
+ dispatchOrder: tui.dispatchOrder as any,
237
+ }) as unknown as (input: CliStatusInput) => string | null;
238
+ } catch (e: any) {
239
+ LOG.warn('CLI', `[${this.cliType}] buildDetectStatusFromTui failed: ${e?.message || e}`);
240
+ }
241
+ }
242
+ if (tui.modal) {
243
+ try {
244
+ sdk.declarativeParseApproval = buildParseApprovalFromTui(tui.modal as any) as unknown as (input: CliApprovalInput) => { message: string; buttons: string[] } | null;
245
+ } catch (e: any) {
246
+ LOG.warn('CLI', `[${this.cliType}] buildParseApprovalFromTui failed: ${e?.message || e}`);
247
+ }
248
+ }
249
+ return sdk;
250
+ }
251
+
44
252
  /** Reset per-session state — called when the PTY process exits. */
45
253
  resetSessionState(): void {
46
254
  this.scriptState = null;
255
+ this.invocationTrace = [];
256
+ this.lastBudgetWarnAt.clear();
47
257
  }
48
258
 
49
259
  // ─── Script access (for reflection and test patching) ────────────────────
@@ -81,7 +291,7 @@ export class CliScriptRunner {
81
291
  detectStatus(input: CliStatusInput): string | null {
82
292
  if (!this.scripts.detectStatus) return null;
83
293
  try {
84
- return this.invoke<string | null>(this.scripts.detectStatus, input);
294
+ return this.invoke<string | null>('detectStatus', this.scripts.detectStatus, input);
85
295
  } catch (e: any) {
86
296
  LOG.warn('CLI', `[${this.cliType}] detectStatus error: ${e?.message || e}`);
87
297
  return null;
@@ -94,6 +304,7 @@ export class CliScriptRunner {
94
304
  if (!this.scripts.parseApproval) return null;
95
305
  try {
96
306
  return this.invoke<{ message: string; buttons: string[] } | null>(
307
+ 'parseApproval',
97
308
  this.scripts.parseApproval,
98
309
  input,
99
310
  );
@@ -111,7 +322,7 @@ export class CliScriptRunner {
111
322
  return null;
112
323
  }
113
324
  try {
114
- const result = this.invoke<ParsedSession | null>(this.scripts.parseSession, input);
325
+ const result = this.invoke<ParsedSession | null>('parseSession', this.scripts.parseSession, input);
115
326
  this._parseErrorMessage = null;
116
327
  return result && typeof result === 'object' ? result : null;
117
328
  } catch (e: any) {
@@ -130,16 +341,81 @@ export class CliScriptRunner {
130
341
  if (typeof fn !== 'function') {
131
342
  throw new Error(`CLI script '${name}' not available`);
132
343
  }
133
- return this.invoke(fn, input);
344
+ return this.invoke(name, fn, input);
134
345
  }
135
346
 
136
347
  // ─── Internal ─────────────────────────────────────
137
348
 
138
- private invoke<T>(fn: Function, input: any): T {
139
- const hasStateFactory = typeof this.scripts.createState === 'function';
140
- const expectsState = hasStateFactory || this.scriptState !== null || fn.length >= 2;
141
- return expectsState
142
- ? (fn as (state: unknown, input: any) => T)(this.scriptState, input)
143
- : (fn as (input: any) => T)(input);
349
+ private invoke<T>(scriptName: string, fn: Function, input: any): T {
350
+ // Pick the call shape from fn.length so each script gets exactly the
351
+ // args its signature declares:
352
+ // (input) — v0 single-arg scripts
353
+ // (state, input) v0 stateful scripts that opt in via createState()
354
+ // (state, input, sdk) v1 extended-tier overrides that consume the SDK
355
+ const arity = fn.length;
356
+ const startedAt = Date.now();
357
+ const startedHr = typeof process !== 'undefined' && typeof process.hrtime === 'function'
358
+ ? process.hrtime.bigint()
359
+ : null;
360
+ let result: T;
361
+ try {
362
+ if (arity >= 3) {
363
+ result = (fn as (state: unknown, input: any, sdk: CliScriptSdk) => T)(this.scriptState, input, this.sdk);
364
+ } else if (arity === 2) {
365
+ result = (fn as (state: unknown, input: any) => T)(this.scriptState, input);
366
+ } else {
367
+ result = (fn as (input: any) => T)(input);
368
+ }
369
+ const elapsedUs = startedHr ? Number((process.hrtime.bigint() - startedHr) / 1000n) : 0;
370
+ const timedOut = this.checkBudget(scriptName, elapsedUs);
371
+ this.recordTrace({
372
+ at: startedAt,
373
+ scriptName,
374
+ arity,
375
+ inputSummary: summarizeInput(input),
376
+ ok: true,
377
+ elapsedUs,
378
+ resultSummary: summarizeResult(result),
379
+ ...(timedOut ? { timedOut: true } : {}),
380
+ });
381
+ return result;
382
+ } catch (e: any) {
383
+ const elapsedUs = startedHr ? Number((process.hrtime.bigint() - startedHr) / 1000n) : 0;
384
+ const timedOut = this.checkBudget(scriptName, elapsedUs);
385
+ this.recordTrace({
386
+ at: startedAt,
387
+ scriptName,
388
+ arity,
389
+ inputSummary: summarizeInput(input),
390
+ ok: false,
391
+ elapsedUs,
392
+ error: e?.message ? String(e.message).slice(0, 400) : String(e).slice(0, 400),
393
+ ...(timedOut ? { timedOut: true } : {}),
394
+ });
395
+ throw e;
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Returns true when `elapsedUs` exceeded the configured budget. On the
401
+ * first violation per script (or after the throttle window expires) we
402
+ * emit a single WARN so the operator learns which provider is slow.
403
+ *
404
+ * We deliberately throttle per-scriptName so a chronically-slow
405
+ * detectStatus doesn't spam the log on every PTY frame settle.
406
+ */
407
+ private checkBudget(scriptName: string, elapsedUs: number): boolean {
408
+ const budgetUs = this.scriptCallBudgetMs * 1000;
409
+ if (elapsedUs <= budgetUs) return false;
410
+ const now = Date.now();
411
+ const last = this.lastBudgetWarnAt.get(scriptName) ?? 0;
412
+ if (now - last >= BUDGET_WARN_THROTTLE_MS) {
413
+ this.lastBudgetWarnAt.set(scriptName, now);
414
+ LOG.warn(
415
+ 'CLI',
416
+ `[${this.cliType}] script ${scriptName} took ${elapsedUs}us, budget ${budgetUs}us`,
417
+ );
418
+ }
419
+ return true;
144
420
  }
145
421
  }
@@ -986,11 +986,14 @@ export class CliStateEngine {
986
986
  }
987
987
 
988
988
  private shouldDeferFinishForTranscript(parsed: any): boolean {
989
- // Support both explicit flag and legacy codex-cli type check
990
- // Also check transport.cliType to support tests that patch adapter.cliType directly
991
- const effectiveType = this.transport.cliType ?? this.provider.type;
992
- const requiresFinalAssistant = !!this.provider.requiresFinalAssistantBeforeIdle
993
- || effectiveType === 'codex-cli';
989
+ // Honor only the explicit manifest opt-in. We used to also hard-code
990
+ // codex-cli here, but that left codex sessions wedged in `generating`
991
+ // whenever the PTY parser or native transcript missed the final
992
+ // assistant line — a much more common failure mode than the original
993
+ // background-tool race this was meant to guard against. If a provider
994
+ // really needs the gate, the manifest can set
995
+ // `requiresFinalAssistantBeforeIdle: true`.
996
+ const requiresFinalAssistant = !!this.provider.requiresFinalAssistantBeforeIdle;
994
997
  if (!requiresFinalAssistant) return false;
995
998
  if (!this.isWaitingForResponse || !this.currentTurnScope || this.hasActionableApproval()) return false;
996
999
  const parsedStatus = typeof parsed?.status === 'string' ? parsed.status.trim() : '';
@@ -179,6 +179,29 @@ export class ProviderCliAdapter implements CliAdapter {
179
179
  private readonly runner: CliScriptRunner;
180
180
  /** @deprecated use runner.cliScripts for direct script access */
181
181
  get cliScripts(): CliScripts { return this.runner.cliScripts; }
182
+
183
+ /**
184
+ * Recent script invocations (oldest → newest). Exposed via the
185
+ * `get_chat_debug_bundle` daemon command so anyone debugging a
186
+ * stuck-status regression can read what each detectStatus /
187
+ * parseSession call actually saw and returned, instead of having
188
+ * to instrument the daemon.
189
+ */
190
+ getScriptInvocationTrace() {
191
+ return this.runner.getInvocationTrace();
192
+ }
193
+
194
+ /**
195
+ * Returns the full raw PTY byte stream captured since adapter start.
196
+ * Used by the `record_provider_pty` IPC command to produce fixtures.
197
+ * Bounded by MAX_ACCUMULATED_BUFFER; older bytes may have been dropped.
198
+ */
199
+ getAccumulatedRawBuffer(): { text: string; droppedChars: number } {
200
+ return {
201
+ text: this.accumulatedRawBuffer,
202
+ droppedChars: this.accumulatedRawBufferDroppedChars,
203
+ };
204
+ }
182
205
  set cliScripts(scripts: CliScripts) { this.setCliScripts(scripts); }
183
206
  private runtimeSettings: Record<string, any> = {};
184
207
  /** Full accumulated rendered PTY transcript for parser/readback use */
@@ -367,8 +390,19 @@ export class ProviderCliAdapter implements CliAdapter {
367
390
  resolvedConfig.timeouts,
368
391
  );
369
392
 
370
- // Scripts delegated to CliScriptRunner — adapter stays as transport
371
- this.runner.setScripts(provider.scripts || {});
393
+ // Scripts delegated to CliScriptRunner — adapter stays as transport.
394
+ // Pass the manifest tui block so the runner can build the
395
+ // declarativeDetectStatus / declarativeParseApproval SDK functions
396
+ // that v1 extended-tier overrides depend on (without these, scripts
397
+ // like codex-cli's detect_status v1 fail-closed with return 'idle'
398
+ // and the session sticks in `generating` forever).
399
+ this.runner.setScripts(provider.scripts || {}, provider.tui);
400
+ // Per-invocation wall-clock budget for provider scripts. Manifests may
401
+ // raise this for genuinely slow parsers, but the default (50ms) is the
402
+ // settle-loop safety net — any script that exceeds it gets flagged in
403
+ // the invocation trace and surfaced via the debug bundle so we can
404
+ // identify the offender instead of guessing why the settle loop hangs.
405
+ this.runner.setScriptCallBudget(provider.scriptCallBudgetMs ?? 50);
372
406
  const scriptNames = this.runner.getScriptNames();
373
407
  if (scriptNames.length > 0) {
374
408
  LOG.info('CLI', `[${this.cliType}] CLI scripts: [${scriptNames.join(', ')}]`);
@@ -158,6 +158,10 @@ export interface CliProviderModule {
158
158
  binary: string;
159
159
  approvalKeys?: Record<number, string>;
160
160
  sendDelayMs?: number;
161
+ /** Wall-clock budget (ms) for a single provider script invocation. Default 50. Range 1..5000.
162
+ * Exceeding the budget records `timedOut: true` on the invocation trace and emits a
163
+ * throttled WARN — it does NOT abort the script (Node CJS can't interrupt sync code). */
164
+ scriptCallBudgetMs?: number;
161
165
  sendKey?: string;
162
166
  submitStrategy?: 'wait_for_echo' | 'immediate';
163
167
  /** Require the typed prompt to be visible on the PTY screen before sending Enter. */
@@ -172,6 +176,8 @@ export interface CliProviderModule {
172
176
  transcriptAuthority?: 'provider' | 'daemon';
173
177
  /** Full context lets provider-owned parsers canonicalize retained history instead of daemon prefix stitching. */
174
178
  transcriptContext?: 'full' | 'tail';
179
+ /** v1 declarative tui block — used by CliScriptRunner to synthesize SDK helpers (declarativeDetectStatus, declarativeParseApproval). */
180
+ tui?: Record<string, unknown>;
175
181
  scripts?: CliScripts;
176
182
  spawn: {
177
183
  command: string;
@@ -1677,6 +1677,9 @@ export async function handleGetChatDebugBundle(h: CommandHelpers, args: any): Pr
1677
1677
  ready: typeof adapter.isReady === 'function' ? adapter.isReady() : undefined,
1678
1678
  processing: typeof adapter.isProcessing === 'function' ? adapter.isProcessing() : undefined,
1679
1679
  debugSnapshot: adapterDebugSnapshot,
1680
+ scriptInvocationTrace: typeof (adapter as any).getScriptInvocationTrace === 'function'
1681
+ ? (adapter as any).getScriptInvocationTrace()
1682
+ : undefined,
1680
1683
  } : null,
1681
1684
  readChat,
1682
1685
  frontend: args?.frontendSnapshot && typeof args.frontendSnapshot === 'object' ? args.frontendSnapshot : null,
@@ -1858,7 +1861,25 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
1858
1861
  }
1859
1862
 
1860
1863
  export async function handleReadChat(h: CommandHelpers, args: any): Promise<CommandResult> {
1861
- const provider = h.getProvider(args?.agentType || args?.providerType);
1864
+ // Resolve provider in order: explicit agentType/providerType > registered session.
1865
+ // Without this fallback, callers that only have a sessionId (e.g. a chat tail
1866
+ // controller that just got handed a session ID over WS) get an empty result
1867
+ // because getProvider(undefined) returns undefined and the rest of the pipeline
1868
+ // bails. This makes the UI look like the session "disappeared".
1869
+ let providerHint: string | undefined = args?.agentType || args?.providerType;
1870
+ if (!providerHint) {
1871
+ const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
1872
+ if (targetSessionId) {
1873
+ const session = (h.ctx as any)?.sessionRegistry?.get?.(targetSessionId);
1874
+ if (session && typeof session.providerType === 'string') {
1875
+ providerHint = session.providerType;
1876
+ }
1877
+ }
1878
+ if (!providerHint && h.currentSession?.providerType) {
1879
+ providerHint = h.currentSession.providerType;
1880
+ }
1881
+ }
1882
+ const provider = h.getProvider(providerHint);
1862
1883
  const transport = getTargetTransport(h, provider);
1863
1884
  const historySessionId = getHistorySessionId(h, args);
1864
1885