@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.
- package/dist/boot/process-hardening.d.ts +50 -0
- package/dist/cli-adapters/cli-script-runner.d.ts +73 -1
- package/dist/cli-adapters/provider-cli-adapter.d.ts +17 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +6 -0
- package/dist/commands/handler.d.ts +66 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2876 -403
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2890 -424
- package/dist/index.mjs.map +1 -1
- package/dist/ipc/local-ipc-server.d.ts +91 -0
- package/dist/providers/contracts.d.ts +8 -0
- package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
- package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
- package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
- package/dist/providers/native-history/index.d.ts +11 -0
- package/dist/providers/provider-loader.d.ts +19 -1
- package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
- package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
- package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
- package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +64 -0
- package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +82 -0
- package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
- package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
- package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
- package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
- package/dist/providers/sdk/v1/index.d.ts +30 -0
- package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
- package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
- package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
- package/dist/providers/sdk/v1/types/cli/index.d.ts +268 -0
- package/dist/providers/sdk/v1/types/common/index.d.ts +169 -0
- package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
- package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
- package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
- package/package.json +4 -2
- package/src/boot/daemon-lifecycle.ts +14 -10
- package/src/boot/process-hardening.ts +89 -0
- package/src/cli-adapters/cli-script-runner.ts +289 -13
- package/src/cli-adapters/cli-state-engine.ts +8 -5
- package/src/cli-adapters/provider-cli-adapter.ts +36 -2
- package/src/cli-adapters/provider-cli-shared.ts +6 -0
- package/src/commands/chat-commands.ts +22 -1
- package/src/commands/cli-manager.ts +39 -0
- package/src/commands/handler.ts +539 -1
- package/src/commands/router.ts +1 -0
- package/src/index.ts +27 -0
- package/src/ipc/local-ipc-server.ts +278 -0
- package/src/providers/cli-provider-instance.ts +15 -0
- package/src/providers/contracts.ts +8 -0
- package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
- package/src/providers/native-history/claude-cli-transcript.ts +396 -0
- package/src/providers/native-history/codex-cli-transcript.ts +419 -0
- package/src/providers/native-history/index.ts +23 -0
- package/src/providers/provider-loader.ts +258 -17
- package/src/providers/provider-schema.ts +3 -0
- package/src/providers/sdk/README.md +49 -0
- package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
- package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval.ts +245 -0
- package/src/providers/sdk/v1/builders/cli/parse-session.ts +247 -0
- package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
- package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
- package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
- package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
- package/src/providers/sdk/v1/index.ts +151 -0
- package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
- package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
- package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
- package/src/providers/sdk/v1/schemas/cli/provider.schema.json +428 -0
- package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
- package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +119 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
- package/src/providers/sdk/v1/types/cli/index.ts +355 -0
- package/src/providers/sdk/v1/types/common/index.ts +210 -0
- package/src/providers/sdk/v1/validators/index.ts +19 -0
- package/src/providers/sdk/v1/validators/manifest.ts +110 -0
- 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
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
40
|
-
? (
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
//
|
|
990
|
-
//
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|