ruvnet-brain 4.5.5 → 4.5.6
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/README.md +1 -1
- package/bin/install.mjs +24 -6
- package/config/model-router/claude-terminal-mod/.claude-plugin/plugin.json +1 -0
- package/config/model-router/claude-terminal-mod/README.md +22 -0
- package/config/model-router/claude-terminal-mod/hooks/hooks.json +1 -0
- package/config/model-router/claude-terminal-mod/hooks/policy.default.mjs +2 -0
- package/config/model-router/claude-terminal-mod/hooks/register.js +66 -0
- package/config/model-router/claude-terminal-mod/hooks/routing.js +55 -0
- package/config/model-router/claude-terminal-mod/hooks/runtime.js +2 -0
- package/config/model-router/claude-terminal-mod/tests/native.test.ts +86 -0
- package/config/model-router/policy.default.mjs +4 -1
- package/package.json +4 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +9 -4
- package/plugin/scripts/capability-claim-evidence.mjs +9 -2
- package/plugin/scripts/project-capture-queue.mjs +7 -1
- package/plugin/scripts/project-progression-hook.mjs +16 -7
- package/plugin/scripts/project-progression-outbox.mjs +96 -15
- package/plugin/scripts/project-progression-producer.mjs +12 -1
- package/plugin/scripts/project-progression-store.mjs +53 -1
- package/plugin/scripts/project-transition-hook.mjs +19 -8
- package/plugin/scripts/session-snapshot-hook.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +89 -0
- package/scripts/model-routing-gateway.mjs +47 -14
- package/scripts/model-routing-launchers.mjs +36 -1
- package/scripts/model-terminal-gateway.mjs +310 -0
- package/scripts/model-terminal-launchers.mjs +300 -0
- package/scripts/release-qualification-contract.mjs +10 -2
- package/scripts/release-qualification.mjs +9 -6
|
@@ -2,6 +2,66 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
const OUTBOX_NAME = 'project-progression-outbox.jsonl';
|
|
5
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
6
|
+
|
|
7
|
+
// Decode only complete records, so a multibyte character crossing reads stays intact.
|
|
8
|
+
function* readJsonl(file) {
|
|
9
|
+
let fd;
|
|
10
|
+
try { fd = fs.openSync(file, 'r'); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
11
|
+
const chunk = Buffer.alloc(READ_CHUNK_BYTES);
|
|
12
|
+
let parts = [];
|
|
13
|
+
let line = 0;
|
|
14
|
+
const parse = (bytes, terminated) => {
|
|
15
|
+
if (!bytes.length) return undefined;
|
|
16
|
+
const text = bytes.toString('utf8');
|
|
17
|
+
if (!text.trim()) return undefined;
|
|
18
|
+
try { return JSON.parse(text); } catch {
|
|
19
|
+
if (!terminated) return undefined;
|
|
20
|
+
throw new Error(`malformed outbox record at line ${line}`);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
try {
|
|
24
|
+
let length;
|
|
25
|
+
while ((length = fs.readSync(fd, chunk, 0, chunk.length, null))) {
|
|
26
|
+
let start = 0;
|
|
27
|
+
for (let end = 0; end < length; end += 1) {
|
|
28
|
+
if (chunk[end] !== 10) continue;
|
|
29
|
+
parts.push(Buffer.from(chunk.subarray(start, end)));
|
|
30
|
+
line += 1;
|
|
31
|
+
const record = parse(Buffer.concat(parts), true);
|
|
32
|
+
if (record !== undefined) yield record;
|
|
33
|
+
parts = []; start = end + 1;
|
|
34
|
+
}
|
|
35
|
+
if (start < length) parts.push(Buffer.from(chunk.subarray(start, length)));
|
|
36
|
+
}
|
|
37
|
+
if (parts.length) {
|
|
38
|
+
line += 1;
|
|
39
|
+
const record = parse(Buffer.concat(parts), false);
|
|
40
|
+
if (record !== undefined) yield record;
|
|
41
|
+
}
|
|
42
|
+
} finally { fs.closeSync(fd); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Inspect only the final record; preceding history is never decoded or copied.
|
|
46
|
+
function finalRecord(fd, size) {
|
|
47
|
+
const parts = [];
|
|
48
|
+
let position = size;
|
|
49
|
+
while (position > 0) {
|
|
50
|
+
const length = Math.min(READ_CHUNK_BYTES, position);
|
|
51
|
+
position -= length;
|
|
52
|
+
const chunk = Buffer.alloc(length);
|
|
53
|
+
let read = 0;
|
|
54
|
+
while (read < length) {
|
|
55
|
+
const count = fs.readSync(fd, chunk, read, length - read, position + read);
|
|
56
|
+
if (!count) throw new Error('outbox tail changed during inspection');
|
|
57
|
+
read += count;
|
|
58
|
+
}
|
|
59
|
+
const delimiter = chunk.lastIndexOf(10);
|
|
60
|
+
parts.unshift(delimiter < 0 ? chunk : chunk.subarray(delimiter + 1));
|
|
61
|
+
if (delimiter >= 0) break;
|
|
62
|
+
}
|
|
63
|
+
return Buffer.concat(parts).toString('utf8');
|
|
64
|
+
}
|
|
5
65
|
|
|
6
66
|
function requireIdentity(value, label) {
|
|
7
67
|
if (typeof value !== 'string' || !value) throw new TypeError(`${label} must be a non-empty string`);
|
|
@@ -31,8 +91,7 @@ export class ProgressionOutbox {
|
|
|
31
91
|
const last = Buffer.alloc(1);
|
|
32
92
|
fs.readSync(fd, last, 0, 1, size - 1);
|
|
33
93
|
if (last[0] !== 10) {
|
|
34
|
-
|
|
35
|
-
try { JSON.parse(content.slice(content.lastIndexOf('\n') + 1)); }
|
|
94
|
+
try { JSON.parse(finalRecord(fd, size)); }
|
|
36
95
|
catch { throw new Error('incomplete outbox final record; preserve and recover the torn tail before appending'); }
|
|
37
96
|
separator = '\n';
|
|
38
97
|
}
|
|
@@ -66,18 +125,23 @@ export class ProgressionOutbox {
|
|
|
66
125
|
return this.appendRecord({ type: 'commit', ...receipt });
|
|
67
126
|
}
|
|
68
127
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
128
|
+
/** Disposition of one frozen payload, never a commit for its conflicting original key. */
|
|
129
|
+
markRecovered(snapshot, receipt) {
|
|
130
|
+
for (const [label, value] of Object.entries({ eventKey: snapshot?.eventKey,
|
|
131
|
+
payloadDigest: snapshot?.payloadDigest, recoveryEventKey: receipt?.eventKey,
|
|
132
|
+
recoveryPayloadDigest: receipt?.payloadDigest, readbackDigest: receipt?.readbackDigest,
|
|
133
|
+
committedAt: receipt?.committedAt })) requireIdentity(value, label);
|
|
134
|
+
if (receipt.eventKey === snapshot.eventKey || receipt.readbackDigest !== receipt.payloadDigest) {
|
|
135
|
+
throw new Error('unverified recovery disposition');
|
|
77
136
|
}
|
|
78
|
-
return
|
|
79
|
-
|
|
80
|
-
|
|
137
|
+
return this.appendRecord({ type: 'recovery', eventKey: snapshot.eventKey,
|
|
138
|
+
payloadDigest: snapshot.payloadDigest, recoveryEventKey: receipt.eventKey,
|
|
139
|
+
recoveryPayloadDigest: receipt.payloadDigest, readbackDigest: receipt.readbackDigest,
|
|
140
|
+
committedAt: receipt.committedAt });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
records() {
|
|
144
|
+
return [...readJsonl(this.path)];
|
|
81
145
|
}
|
|
82
146
|
|
|
83
147
|
/**
|
|
@@ -92,7 +156,8 @@ export class ProgressionOutbox {
|
|
|
92
156
|
const snapshots = new Map();
|
|
93
157
|
const committed = new Map();
|
|
94
158
|
const quarantined = new Map();
|
|
95
|
-
|
|
159
|
+
const recoveries = [];
|
|
160
|
+
for (const record of readJsonl(this.path)) {
|
|
96
161
|
requireIdentity(record?.eventKey, 'outbox eventKey');
|
|
97
162
|
requireIdentity(record?.payloadDigest, 'outbox payloadDigest');
|
|
98
163
|
if (record.type === 'snapshot') {
|
|
@@ -103,6 +168,13 @@ export class ProgressionOutbox {
|
|
|
103
168
|
const prior = committed.get(record.eventKey);
|
|
104
169
|
if (prior && prior !== record.payloadDigest) quarantined.set(record.eventKey, 'outbox commit collision');
|
|
105
170
|
committed.set(record.eventKey, record.payloadDigest);
|
|
171
|
+
} else if (record.type === 'recovery') {
|
|
172
|
+
requireIdentity(record.recoveryEventKey, 'recovery event key');
|
|
173
|
+
requireIdentity(record.recoveryPayloadDigest, 'recovery payload digest');
|
|
174
|
+
if (record.eventKey === record.recoveryEventKey || record.readbackDigest !== record.recoveryPayloadDigest) {
|
|
175
|
+
throw new Error('unverified recovery disposition');
|
|
176
|
+
}
|
|
177
|
+
recoveries.push(record);
|
|
106
178
|
} else {
|
|
107
179
|
throw new Error('unsupported outbox record');
|
|
108
180
|
}
|
|
@@ -113,9 +185,18 @@ export class ProgressionOutbox {
|
|
|
113
185
|
quarantined.set(record.eventKey, 'outbox commit digest mismatch');
|
|
114
186
|
}
|
|
115
187
|
}
|
|
188
|
+
const recovered = new Set(recoveries.filter((record) => {
|
|
189
|
+
const target = snapshots.get(record.recoveryEventKey)?.snapshot;
|
|
190
|
+
return !quarantined.has(record.recoveryEventKey)
|
|
191
|
+
&& committed.get(record.recoveryEventKey) === record.recoveryPayloadDigest
|
|
192
|
+
&& target?.payloadDigest === record.recoveryPayloadDigest
|
|
193
|
+
&& target.recoveryDiagnostics?.originalEventKey === record.eventKey
|
|
194
|
+
&& target.recoveryDiagnostics?.frozenPayloadDigest === record.payloadDigest;
|
|
195
|
+
}).map((record) => `${record.eventKey}:${record.payloadDigest}`));
|
|
116
196
|
this.quarantine = [...quarantined].map(([eventKey, reason]) => ({ eventKey, reason }));
|
|
117
197
|
return [...snapshots.values()]
|
|
118
|
-
.filter((record) => !quarantined.has(record.eventKey) && !committed.has(record.eventKey)
|
|
198
|
+
.filter((record) => !quarantined.has(record.eventKey) && !committed.has(record.eventKey)
|
|
199
|
+
&& !recovered.has(`${record.eventKey}:${record.payloadDigest}`))
|
|
119
200
|
.sort((left, right) => left.eventKey.localeCompare(right.eventKey))
|
|
120
201
|
.map((record) => record.snapshot);
|
|
121
202
|
}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* within those bounds.
|
|
30
30
|
*/
|
|
31
31
|
import crypto from 'node:crypto';
|
|
32
|
+
import { enrichStateWithObservation } from './project-progression-hook.mjs';
|
|
32
33
|
import fs from 'node:fs';
|
|
33
34
|
import path from 'node:path';
|
|
34
35
|
import { digestCanonical, fieldAuthorityAllows, redactProgression, restoreProjectProgression } from './project-progression-contract.mjs';
|
|
@@ -250,6 +251,16 @@ export function buildProjectProgression({
|
|
|
250
251
|
completeProjectState,
|
|
251
252
|
});
|
|
252
253
|
const projectProgression = redactedProgression;
|
|
254
|
+
// Compare the same redacted observation the native writer will append, without returning it
|
|
255
|
+
// twice. Tool input and terminal evidence must participate before the no-op decision.
|
|
256
|
+
const observedState = redactProgression(enrichStateWithObservation(
|
|
257
|
+
projectProgression.completeProjectState, { ...payload, hook_event_name: trigger },
|
|
258
|
+
)).value;
|
|
259
|
+
// A committed sequence is not an event identity: several boundaries may freeze before replay.
|
|
260
|
+
// Bind all immutable captured fields (including the native observation and occurrence time).
|
|
261
|
+
projectProgression.dedupId += `:${digestCanonical({
|
|
262
|
+
...projectProgression, completeProjectState: observedState,
|
|
263
|
+
})}`;
|
|
253
264
|
|
|
254
265
|
// RETENTION (ADR-073). Three capture boundaries per session times every session is unbounded
|
|
255
266
|
// growth unless a capture that changes nothing writes nothing. Compare what a snapshot MEANS —
|
|
@@ -257,7 +268,7 @@ export function buildProjectProgression({
|
|
|
257
268
|
// differ (sequence, timestamp, dedup id, the trigger that happens to be firing, and the evidence
|
|
258
269
|
// block's own timestamps), because comparing those would make every capture look novel.
|
|
259
270
|
const meaning = digestCanonical({
|
|
260
|
-
state: { ...
|
|
271
|
+
state: { ...observedState, activeStep: null, evidence: null },
|
|
261
272
|
source: projectProgression.sourceIdentity,
|
|
262
273
|
});
|
|
263
274
|
const priorMeaning = heads.length === 1 ? digestCanonical({
|
|
@@ -5,6 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { ProgressionOutbox } from './project-progression-outbox.mjs';
|
|
7
7
|
import {
|
|
8
|
+
createProgressionSnapshot,
|
|
8
9
|
digestCanonical,
|
|
9
10
|
restoreProjectProgression,
|
|
10
11
|
validateProgressionSnapshot,
|
|
@@ -87,10 +88,26 @@ export function projectResumePayloadToBound(payload, maxOutputBytes) {
|
|
|
87
88
|
});
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
if (size() > maxOutputBytes && Array.isArray(summary.state?.resumeConflicts)) {
|
|
92
|
+
const groups = new Map();
|
|
93
|
+
for (const conflict of payload.state.resumeConflicts) {
|
|
94
|
+
if (!groups.has(conflict.field)) groups.set(conflict.field, []);
|
|
95
|
+
groups.get(conflict.field).push(conflict);
|
|
96
|
+
}
|
|
97
|
+
if (groups.size < payload.state.resumeConflicts.length) {
|
|
98
|
+
summary.state.resumeConflicts = [...groups].map(([field, conflicts]) => ({
|
|
99
|
+
field, conflictCount: conflicts.length,
|
|
100
|
+
valueCount: conflicts.reduce((count, conflict) => count + (conflict.values ?? []).length, 0),
|
|
101
|
+
conflictsDigest: digestCanonical(conflicts),
|
|
102
|
+
}));
|
|
103
|
+
omissions.push({ path: 'state.resumeConflicts', ...omissionSummary(payload.state.resumeConflicts) });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
if (size() > maxOutputBytes) recordOmission(summary.state, 'journalHeads', 'state.journalHeads');
|
|
91
108
|
// Least central detail first. currentGoal and nextAction are deliberately absent from this list.
|
|
92
109
|
const stateFields = [
|
|
93
|
-
'commands', 'proofArtifacts', 'changedFiles', 'completed', 'untested', 'decisions',
|
|
110
|
+
'observations', 'commands', 'proofArtifacts', 'changedFiles', 'completed', 'untested', 'decisions',
|
|
94
111
|
'plan', 'inProgress', 'blockers', 'failures', 'acceptanceContract', 'provenance',
|
|
95
112
|
'evidence', 'activeStep', 'activeProcess', 'sourceIdentity',
|
|
96
113
|
];
|
|
@@ -450,6 +467,41 @@ export class ProjectProgressionStore {
|
|
|
450
467
|
return receipt;
|
|
451
468
|
}
|
|
452
469
|
|
|
470
|
+
/** Frozen queue recovery never rewrites the conflicting row or marks its key committed. */
|
|
471
|
+
captureFrozen(snapshot, { canCommit } = {}) {
|
|
472
|
+
if (typeof canCommit !== 'function') throw new Error('frozen recovery requires replay fencing');
|
|
473
|
+
const fenced = () => { if (!canCommit()) throw new Error('progression recovery lost replay fencing'); };
|
|
474
|
+
fenced();
|
|
475
|
+
try { return { snapshot, receipt: this.capture(snapshot, { onPhase: fenced }) }; } catch (error) {
|
|
476
|
+
if (error.message !== 'progression readback digest mismatch') throw error;
|
|
477
|
+
}
|
|
478
|
+
// An independently retrieved exact canonical value must be valid for this project. A failed,
|
|
479
|
+
// absent, malformed or foreign read is never evidence that the immutable key collided.
|
|
480
|
+
const exact = this.retrieveSnapshots([snapshot.eventKey]);
|
|
481
|
+
if (exact.rejected.length || exact.snapshots.length !== 1) throw new Error('immutable collision is not verified');
|
|
482
|
+
const existing = exact.snapshots[0];
|
|
483
|
+
this.validateSnapshot(existing);
|
|
484
|
+
if (existing.eventKey !== snapshot.eventKey || existing.payloadDigest === snapshot.payloadDigest) {
|
|
485
|
+
throw new Error('immutable collision is not verified');
|
|
486
|
+
}
|
|
487
|
+
const identity = { originalEventKey: snapshot.eventKey,
|
|
488
|
+
frozenPayloadDigest: snapshot.payloadDigest, existingPayloadDigest: existing.payloadDigest };
|
|
489
|
+
const created = createProgressionSnapshot({ ...snapshot,
|
|
490
|
+
dedupId: `immutable-recovery:${digestCanonical(identity)}` });
|
|
491
|
+
// Preserve the frozen state and source exactly; diagnostics carry no instruction authority.
|
|
492
|
+
const { payloadDigest: _digest, ...body } = created;
|
|
493
|
+
body.redactions = snapshot.redactions;
|
|
494
|
+
body.recoveryDiagnostics = { kind: 'immutable-event-key-collision', authoritative: false, ...identity };
|
|
495
|
+
const recovered = Object.freeze({ ...body, payloadDigest: digestCanonical(body) });
|
|
496
|
+
this.validateSnapshot(recovered);
|
|
497
|
+
this.requireCaptureConsent(recovered);
|
|
498
|
+
fenced();
|
|
499
|
+
const receipt = this.capture(recovered, { onPhase: fenced });
|
|
500
|
+
fenced();
|
|
501
|
+
this.outbox.markRecovered(snapshot, receipt);
|
|
502
|
+
return { snapshot: recovered, receipt };
|
|
503
|
+
}
|
|
504
|
+
|
|
453
505
|
replay() {
|
|
454
506
|
const receipts = [];
|
|
455
507
|
for (const snapshot of this.outbox.pendingSnapshots()) {
|
|
@@ -75,20 +75,25 @@ export function normalizeTransition(payload, event, { now = () => new Date().toI
|
|
|
75
75
|
const toolInput = input.tool_input ?? {};
|
|
76
76
|
const response = input.tool_response && typeof input.tool_response === 'object' ? input.tool_response : {};
|
|
77
77
|
const exitCode = [response.exit_code, response.exitCode, response.status].find(Number.isSafeInteger);
|
|
78
|
-
const
|
|
79
|
-
const
|
|
78
|
+
const error = typeof response.error === 'string' && response.error ? redactText(response.error).slice(0, 4096) : undefined;
|
|
79
|
+
const signal = typeof response.signal === 'string' && response.signal ? redactText(response.signal).slice(0, 100) : undefined;
|
|
80
|
+
const failure = Boolean(error) || event === 'PostToolUseFailure' || response.isError === true || input.is_error === true || (Number.isSafeInteger(exitCode) && exitCode !== 0);
|
|
81
|
+
const interrupted = response.interrupted === true || Boolean(signal);
|
|
82
|
+
const declaredOutcome = ['success', 'failure', 'interrupted', 'unknown'].includes(response.outcome) ? response.outcome : null;
|
|
80
83
|
// Measured native Claude Bash PostToolUse has no exit code. Its completion envelope
|
|
81
84
|
// is host-reported success, never an inferred exit-code zero. Other hosts stay unknown.
|
|
82
85
|
const claudeBashCompletion = host === 'claude' && input.tool_name === 'Bash' && event === 'PostToolUse'
|
|
83
86
|
&& typeof response.stdout === 'string' && typeof response.stderr === 'string'
|
|
84
87
|
&& response.interrupted === false && typeof response.isImage === 'boolean'
|
|
85
88
|
&& typeof response.noOutputExpected === 'boolean';
|
|
86
|
-
const outcome = event === 'PreToolUse' ? 'pending' : interrupted ? 'interrupted' : failure ? 'failure'
|
|
87
|
-
: Number.isSafeInteger(exitCode) || response.success === true || response.ok === true || claudeBashCompletion ? 'success' : 'unknown';
|
|
89
|
+
const outcome = event === 'PreToolUse' ? 'pending' : error ? 'failure' : interrupted ? 'interrupted' : failure ? 'failure'
|
|
90
|
+
: declaredOutcome || (Number.isSafeInteger(exitCode) || response.success === true || response.ok === true || claudeBashCompletion ? 'success' : 'unknown');
|
|
88
91
|
return { ...common, kind: 'tool-observation', tool: String(input.tool_name).split('__').at(-1).slice(0, 100),
|
|
89
92
|
intent: semanticIntent(toolInput.description ?? toolInput.command ?? toolInput.cmd ?? toolInput.file_path), outcome,
|
|
90
93
|
...(outcome === 'success' && claudeBashCompletion ? { outcomeEvidence: 'claude-bash-completion' } : {}),
|
|
91
|
-
...(event !== 'PreToolUse' && Number.isSafeInteger(exitCode) ? { exitCode } : {}
|
|
94
|
+
...(event !== 'PreToolUse' && Number.isSafeInteger(exitCode) ? { exitCode } : event !== 'PreToolUse' && (response.exit_code === null || response.exitCode === null) ? { exitCode: null } : {}),
|
|
95
|
+
...(event !== 'PreToolUse' && error ? { error } : {}),
|
|
96
|
+
...(event !== 'PreToolUse' && signal ? { signal } : {}) };
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
export function readTransitionHistory(resolution, { deadlineAt = Infinity } = {}) {
|
|
@@ -107,6 +112,11 @@ export function readTransitionHistory(resolution, { deadlineAt = Infinity } = {}
|
|
|
107
112
|
export function buildTransitionProgression({ resolution, observation, snapshots = [], sessionIdentity, host, sourceIdentity: originalSourceIdentity }) {
|
|
108
113
|
const restored = restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity });
|
|
109
114
|
if (snapshots.length && !restored.ok) throw new Error('nonempty transition journal has no coherent ancestry');
|
|
115
|
+
return buildRestoredTransitionProgression({ resolution, observation, snapshots, sessionIdentity, host, sourceIdentity: originalSourceIdentity }, restored);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Only the validated result computed in this module can reach this private builder.
|
|
119
|
+
function buildRestoredTransitionProgression({ resolution, observation, snapshots, sessionIdentity, host, sourceIdentity: originalSourceIdentity }, restored) {
|
|
110
120
|
const heads = snapshots.filter((snapshot) => restored.heads.includes(snapshot.eventKey));
|
|
111
121
|
const prior = restored.state;
|
|
112
122
|
const empty = Object.fromEntries(['plan', 'completed', 'inProgress', 'blockers', 'failures', 'decisions', 'changedFiles', 'commands', 'proofArtifacts', 'untested', 'resumeConflicts'].map((key) => [key, []]));
|
|
@@ -145,7 +155,8 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
|
|
|
145
155
|
if (!normalized || normalized.observation?.authoritative !== false || !normalized.observation?.id
|
|
146
156
|
|| normalized.sourceIdentity?.checkoutPath !== resolution.checkoutRoot) throw new Error('invalid normalized transition binding');
|
|
147
157
|
const snapshots = readHistory(resolution, { deadlineAt });
|
|
148
|
-
|
|
158
|
+
const restored = restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity });
|
|
159
|
+
if (snapshots.length && !restored.ok) {
|
|
149
160
|
throw new Error('nonempty transition journal has no coherent ancestry');
|
|
150
161
|
}
|
|
151
162
|
// A failed first store already fsynced a snapshot to the outbox. If startup/replay committed
|
|
@@ -159,9 +170,9 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
|
|
|
159
170
|
if (committed) return { progressionCaptured: true, eventId: normalized.observation.id,
|
|
160
171
|
receipt: { eventKey: committed.eventKey, payloadDigest: committed.payloadDigest,
|
|
161
172
|
readbackDigest: committed.payloadDigest, readPath: 'canonical progression reader' } };
|
|
162
|
-
const progression =
|
|
173
|
+
const progression = buildRestoredTransitionProgression({ resolution, observation: normalized.observation,
|
|
163
174
|
snapshots, sessionIdentity: job.payload.session_id, host: job.host,
|
|
164
|
-
sourceIdentity: normalized.sourceIdentity });
|
|
175
|
+
sourceIdentity: normalized.sourceIdentity }, restored);
|
|
165
176
|
const result = capture(job.originProjectDir, job.event, { host: job.host, budgetMs: Math.max(0, deadlineAt - now()),
|
|
166
177
|
...(makeStoreFactory ? { makeStoreFactory } : {}), writeMetadata: false,
|
|
167
178
|
rawInput: JSON.stringify({ session_id: job.payload.session_id, hook_event_name: job.event, projectProgression: progression }),
|
|
@@ -163,7 +163,8 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
163
163
|
if (payload.hook_event_name !== event) {
|
|
164
164
|
throw new Error(`progression boundary mismatch: expected ${event}, received ${payload.hook_event_name}`);
|
|
165
165
|
}
|
|
166
|
-
const result = captureProgression({ host, payload, projectDir,
|
|
166
|
+
const result = captureProgression({ host, payload, projectDir, recoverFrozen: Boolean(ordered),
|
|
167
|
+
canCommit: () => Boolean(ordered) && refreshReplayLock(resolveProjectStore({ projectDir }).projectRoot, ordered), storeFactory: (options) => makeStoreFactory(now() + budgetMs)({ ...options, env }) });
|
|
167
168
|
return { ...idle, progressionCaptured: true, receipt: result.receipt };
|
|
168
169
|
}
|
|
169
170
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Materialize the sandboxed native mod and bridge it to the existing reviewed policy engine.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { classify } from '../config/model-router/policy.default.mjs';
|
|
9
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
export const MOD_SOURCE = path.join(ROOT, 'config/model-router/claude-terminal-mod');
|
|
11
|
+
export function terminalModDigest(pluginRoot) {
|
|
12
|
+
const hash = crypto.createHash('sha256');
|
|
13
|
+
for (const relative of ['.claude-plugin/plugin.json', 'hooks/hooks.json', 'hooks/register.js', 'hooks/routing.js', 'hooks/policy.default.mjs', 'hooks/runtime.js']) {
|
|
14
|
+
hash.update(relative); hash.update('\0'); hash.update(fs.readFileSync(path.join(pluginRoot, relative))); hash.update('\0');
|
|
15
|
+
}
|
|
16
|
+
return hash.digest('hex');
|
|
17
|
+
}
|
|
18
|
+
export function prepareClaudeTerminalMod({ destination, nodePath = process.execPath,
|
|
19
|
+
helperPath = fileURLToPath(import.meta.url), enginePath = path.join(ROOT, 'scripts/model-router-engine.mjs') } = {}) {
|
|
20
|
+
if (!destination || !path.isAbsolute(destination)) throw new Error('Absolute mod destination required');
|
|
21
|
+
for (const file of [nodePath, helperPath, enginePath]) if (!path.isAbsolute(file) || !fs.existsSync(file)) throw new Error('Native mod dependency unavailable');
|
|
22
|
+
fs.mkdirSync(destination, { recursive: true, mode: 0o700 });
|
|
23
|
+
for (const part of ['.claude-plugin/plugin.json', 'hooks/hooks.json', 'hooks/register.js', 'hooks/routing.js']) {
|
|
24
|
+
const dest = path.join(destination, part);
|
|
25
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
26
|
+
fs.copyFileSync(path.join(MOD_SOURCE, part), dest);
|
|
27
|
+
}
|
|
28
|
+
const policyPath = path.join(destination, 'hooks/policy.default.mjs');
|
|
29
|
+
fs.copyFileSync(path.join(ROOT, 'config/model-router/policy.default.mjs'), policyPath);
|
|
30
|
+
fs.writeFileSync(path.join(destination, 'hooks/runtime.js'), `export default ${JSON.stringify({ nodePath, helperPath, enginePath, policyPath })};\n`, { mode: 0o600 });
|
|
31
|
+
return { pluginDir: destination, policyPath, modDigest: terminalModDigest(destination) };
|
|
32
|
+
}
|
|
33
|
+
export function validateDecision(decision, now = Date.now()) {
|
|
34
|
+
const age = now - Date.parse(decision?.selectionReviewedAt);
|
|
35
|
+
const maxAge = decision?.selectionMaxAgeMs;
|
|
36
|
+
if (decision?.harness !== 'claude-code' || decision.provider !== 'anthropic' || decision.subscriptionCovered !== true ||
|
|
37
|
+
!/^claude-[a-z0-9][a-z0-9.-]*$/.test(decision.model || '') || !['fast', 'medium', 'hard'].includes(decision.taskClass) ||
|
|
38
|
+
!['low', 'medium', 'high', 'xhigh', 'max'].includes(decision.effort) ||
|
|
39
|
+
!Number.isSafeInteger(maxAge) || maxAge <= 0 || maxAge > 604800000 || !Number.isFinite(age) || age < 0 ||
|
|
40
|
+
!/^[a-f0-9]{64}$/.test(decision.selectionRouteDigest || '')) throw new Error('Native terminal routing decision invalid; no fallback');
|
|
41
|
+
return decision;
|
|
42
|
+
}
|
|
43
|
+
export function routeNativePrompt({ prompt, turnId, minimumClass, enginePath = path.join(ROOT, 'scripts/model-router-engine.mjs'),
|
|
44
|
+
policyPath = path.join(ROOT, 'config/model-router/policy.default.mjs'), env = process.env } = {}) {
|
|
45
|
+
if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 200000) throw new Error('Native routing requires a bounded text prompt');
|
|
46
|
+
if (minimumClass !== undefined && !['fast', 'medium', 'hard'].includes(minimumClass)) throw new Error('Invalid class floor');
|
|
47
|
+
const codeFences = Math.floor((prompt.match(/```/g) || []).length / 2);
|
|
48
|
+
const hasCode = codeFences > 0 || /\b(function|const|let|def|class|import|=>|SELECT|async)\b/.test(prompt) || /[{};]\s*$/m.test(prompt);
|
|
49
|
+
const rank = { fast: 0, medium: 1, hard: 2 };
|
|
50
|
+
const canonicalFloor = classify({ taskHints: prompt, hasCode }, 'claude-code');
|
|
51
|
+
const floor = rank[minimumClass] > rank[canonicalFloor] ? minimumClass : canonicalFloor;
|
|
52
|
+
const classificationPrompt = floor === 'hard' ? 'final substantive review\n' + prompt : prompt;
|
|
53
|
+
// Let the engine honor the user's policy.mjs; the bundled copy is only the
|
|
54
|
+
// deterministic classification floor used by the sandboxed hook.
|
|
55
|
+
const result = spawnSync(process.execPath, [enginePath, '--harness', 'claude-code', '--request-json', '--policy-only', '--json'],
|
|
56
|
+
{ input: JSON.stringify({ prompt: classificationPrompt }), encoding: 'utf8', env, timeout: 8000, maxBuffer: 262144 });
|
|
57
|
+
if (result.error || result.status !== 0) throw new Error('Reviewed native routing policy unavailable; no fallback');
|
|
58
|
+
const d = validateDecision(JSON.parse(result.stdout));
|
|
59
|
+
if (rank[d.taskClass] < rank[floor]) throw new Error('Reviewed native routing policy below canonical floor; no fallback');
|
|
60
|
+
// No prompt, reasoning, credentials, or arbitrary engine output crosses back to the sandbox.
|
|
61
|
+
return { schemaVersion: 1, turnId: typeof turnId === 'string' ? turnId : null, model: d.model, effort: d.effort,
|
|
62
|
+
taskClass: d.taskClass, expiresAt: Date.parse(d.selectionReviewedAt) + d.selectionMaxAgeMs,
|
|
63
|
+
subscriptionCovered: true, routeDigest: d.selectionRouteDigest };
|
|
64
|
+
}
|
|
65
|
+
export function writeReadinessReceipt({ nonce, receiptPath, pluginRoot, version, sessionId, status = 'ready' } = {}) {
|
|
66
|
+
if (!/^[a-f0-9]{64}$/.test(nonce || '') || !path.isAbsolute(receiptPath || '') ||
|
|
67
|
+
!path.isAbsolute(pluginRoot || '') || (!/^2\.1\.\d+$/.test(version || '') || Number(version.split('.')[2]) < 287) || typeof sessionId !== 'string' ||
|
|
68
|
+
!['ready', 'health'].includes(status)) throw new Error('Invalid native mod readiness request');
|
|
69
|
+
const receipt = { schemaVersion: 1, nonce, pluginRoot: fs.realpathSync(pluginRoot), nativeVersion: version,
|
|
70
|
+
sessionId, status, modDigest: terminalModDigest(pluginRoot), observedAt: new Date().toISOString(), pid: process.ppid,
|
|
71
|
+
scope: 'hook activation only; native load failure or worker crash can skip hooks' };
|
|
72
|
+
const parent = path.dirname(receiptPath);
|
|
73
|
+
if (!fs.existsSync(parent)) throw new Error('Readiness directory must be prepared by launcher');
|
|
74
|
+
const temporary = path.join(parent, `.rnb-ready-${crypto.randomBytes(16).toString('hex')}`);
|
|
75
|
+
fs.writeFileSync(temporary, JSON.stringify(receipt), { mode: 0o600, flag: 'wx' });
|
|
76
|
+
fs.renameSync(temporary, receiptPath);
|
|
77
|
+
return receipt;
|
|
78
|
+
}
|
|
79
|
+
async function main() {
|
|
80
|
+
const mode = process.argv[2];
|
|
81
|
+
const request = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
82
|
+
if (mode === '--decision') process.stdout.write(JSON.stringify(routeNativePrompt(request)) + '\n');
|
|
83
|
+
else if (mode === '--ready' || mode === '--health') process.stdout.write(JSON.stringify(writeReadinessReceipt({ ...request, status: mode === '--ready' ? 'ready' : 'health' })) + '\n');
|
|
84
|
+
else if (mode === '--prepare') process.stdout.write(JSON.stringify(prepareClaudeTerminalMod(request)) + '\n');
|
|
85
|
+
else throw new Error('Expected --prepare, --decision, --ready, or --health');
|
|
86
|
+
}
|
|
87
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch(() => {
|
|
88
|
+
process.stderr.write('claude-terminal-mod: routing or activation unavailable; no fallback\n'); process.exitCode = 1;
|
|
89
|
+
});
|
|
@@ -15,6 +15,21 @@ import { subscriptionEnvironment, assertSubscriptionAuth, validateDispatchDecisi
|
|
|
15
15
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
const REFUSED = 'Current reviewed native model/effort allocation unavailable; new turn was not forwarded.';
|
|
17
17
|
const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
18
|
+
// Native inference surfaces without qualified execution-time routing are denied.
|
|
19
|
+
const UNQUALIFIED = new Set(['review/start', 'thread/queue/add', 'thread/queue/update', 'thread/queue/start',
|
|
20
|
+
'thread/realtime/start', 'thread/realtime/appendAudio', 'thread/realtime/appendSpeech', 'thread/realtime/appendText',
|
|
21
|
+
'thread/goal/set', 'thread/goal/create', 'thread/goal/resume', 'thread/compact/start', 'turn/addUserMessage', 'thread/startAeon']);
|
|
22
|
+
|
|
23
|
+
function canonicalCodexConfig(config) {
|
|
24
|
+
if (!config || (config.model_provider ?? 'openai') !== 'openai') return false;
|
|
25
|
+
// Native 0.160 gives serviceTierForTurn precedence over daemon/thread Fast defaults.
|
|
26
|
+
// routeCodexTurn supplies both standard overrides; provider/auth checks remain mandatory.
|
|
27
|
+
// A provider with the built-in name can still be replaced by a custom endpoint.
|
|
28
|
+
if (config.model_providers && Object.hasOwn(config.model_providers, 'openai') && config.model_providers.openai != null) return false;
|
|
29
|
+
if (config.chatgpt_base_url != null && config.chatgpt_base_url !== 'https://chatgpt.com/backend-api/') return false;
|
|
30
|
+
// Native Config serializes openai_base_url:null when the override is unset.
|
|
31
|
+
return !Object.entries(config).some(([key, value]) => value != null && /^(model_providers\.openai|base_url|baseUrl|openai_base_url)(\.|$)/.test(key));
|
|
32
|
+
}
|
|
18
33
|
|
|
19
34
|
/** Fresh policy-only subprocess: prompt stays on stdin, never argv, receipts or diagnostics. */
|
|
20
35
|
export function decideNativeTurn(prompt, harness, { env = process.env, spawnEngine = spawn, timeoutMs = 4000, multimodal = false } = {}) {
|
|
@@ -107,9 +122,9 @@ function unsafeSettings(value, prefix = '', routing = false) {
|
|
|
107
122
|
if (!value || typeof value !== 'object') return false;
|
|
108
123
|
return Object.entries(value).some(([key, entry]) => {
|
|
109
124
|
const name = prefix ? `${prefix}.${key}` : key;
|
|
110
|
-
if (/^(model_providers|apiKeyHelper|api_key|apiKey|base_url|baseUrl|auth_token|authToken|customHeaders|env|modelSettings|alwaysThinkingEnabled|maxEffortLevel)(\.|$)/.test(name)) return true;
|
|
125
|
+
if (/^(model_providers|apiKeyHelper|api_key|apiKey|base_url|baseUrl|openai_base_url|chatgpt_base_url|auth_token|authToken|customHeaders|env|modelSettings|alwaysThinkingEnabled|maxEffortLevel)(\.|$)/.test(name)) return true;
|
|
111
126
|
if (/^(model_provider|modelProvider)$/.test(name) && entry !== 'openai') return true;
|
|
112
|
-
if (/^(service_tier|serviceTier)$/.test(name) && entry != null && entry !== 'default') return true;
|
|
127
|
+
if (/^(service_tier|serviceTier|service_tier_for_turn|serviceTierForTurn)$/.test(name) && entry != null && entry !== 'default') return true;
|
|
113
128
|
if (/^(features\.fast_mode|fastMode)$/.test(name) && entry !== false) return true;
|
|
114
129
|
if (routing && /^(model|effort|effortLevel|reasoning_effort|collaborationMode)(\.|$)/.test(name)) return true;
|
|
115
130
|
return unsafeSettings(entry, name, routing);
|
|
@@ -123,7 +138,7 @@ export function verifyNativeVision(decision, models = loadNativeCodexModels()) {
|
|
|
123
138
|
|
|
124
139
|
/** Pure native override: preserve every context, tool, approval and collaboration instruction. */
|
|
125
140
|
export function routeCodexTurn(message, decision) {
|
|
126
|
-
const params = { ...message.params, model: decision.model, effort: decision.effort, serviceTier: 'default' };
|
|
141
|
+
const params = { ...message.params, model: decision.model, effort: decision.effort, serviceTier: 'default', serviceTierForTurn: 'default' };
|
|
127
142
|
if (params.collaborationMode) params.collaborationMode = { ...params.collaborationMode,
|
|
128
143
|
settings: { ...params.collaborationMode.settings, model: decision.model, reasoning_effort: decision.effort } };
|
|
129
144
|
return { ...message, params };
|
|
@@ -135,7 +150,7 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
135
150
|
receipt = appendGatewayReceipt, timeoutMs = 5000, maxDeferredBytes = 64 * 1024 * 1024, tempRoot = os.tmpdir(), now = () => new Date().toISOString() } = {}) {
|
|
136
151
|
const nonce = crypto.randomUUID();
|
|
137
152
|
const pending = new Map(), initIds = new Set(), ownIds = new Set(), held = new Set();
|
|
138
|
-
const accepted = new Map(), starts = new Map(), inactiveWaiters = new Set();
|
|
153
|
+
const accepted = new Map(), starts = new Map(), inactiveWaiters = new Set(), boundedWaiters = new Set();
|
|
139
154
|
let serial = Promise.resolve(), ready = harness === 'codex', active = false, closed = false, serialNumber = 0;
|
|
140
155
|
let readiness = null, resolveReady;
|
|
141
156
|
let queuedBytes = 0, spoolDirectory = null, spoolSequence = 0, declineUsers = false;
|
|
@@ -218,9 +233,10 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
218
233
|
};
|
|
219
234
|
function bounded(work) {
|
|
220
235
|
return new Promise((resolve, reject) => {
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
236
|
+
const finish = (error, value) => { clearTimeout(timer); boundedWaiters.delete(cancel); error ? reject(error) : resolve(value); };
|
|
237
|
+
const cancel = () => finish(new Error(REFUSED));
|
|
238
|
+
const timer = setTimeout(cancel, timeoutMs); boundedWaiters.add(cancel);
|
|
239
|
+
Promise.resolve(work).then((value) => finish(null, value), (error) => finish(error));
|
|
224
240
|
});
|
|
225
241
|
}
|
|
226
242
|
const control = (request) => new Promise((resolve, reject) => {
|
|
@@ -262,7 +278,7 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
262
278
|
if (harness === 'codex') {
|
|
263
279
|
if (message.method !== 'turn/start') {
|
|
264
280
|
const pair = accepted.get(message.params?.threadId);
|
|
265
|
-
if (!pair || pair.model !== decision.model || pair.effort !== decision.effort) {
|
|
281
|
+
if (!pair || pair.turnId !== message.params?.expectedTurnId || pair.model !== decision.model || pair.effort !== decision.effort) {
|
|
266
282
|
fail(message, 'Input not submitted: requires a new turn with reviewed allocation; current work continues.'); return;
|
|
267
283
|
}
|
|
268
284
|
record('active-input-approved', decision, false, { evidence: 'native-turn-start.accepted-configured-pair' });
|
|
@@ -271,6 +287,14 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
271
287
|
const account = await control({ method: 'account/read', params: { refreshToken: false } });
|
|
272
288
|
assertLive();
|
|
273
289
|
if (account?.account?.type !== 'chatgpt') throw new Error(REFUSED);
|
|
290
|
+
const thread = await control({ method: 'thread/read', params: { threadId: message.params?.threadId, includeTurns: false } });
|
|
291
|
+
assertLive();
|
|
292
|
+
if (thread?.thread?.id !== message.params?.threadId || thread.thread.modelProvider !== 'openai') throw new Error(REFUSED);
|
|
293
|
+
const cwd = message.params?.cwd ?? thread.thread.cwd;
|
|
294
|
+
if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) throw new Error(REFUSED);
|
|
295
|
+
const effective = await control({ method: 'config/read', params: { includeLayers: false, cwd } });
|
|
296
|
+
assertLive();
|
|
297
|
+
if (!canonicalCodexConfig(effective?.config)) throw new Error(REFUSED);
|
|
274
298
|
const allowance = await control({ method: 'account/rateLimits/read', params: { excludeResetCreditDetails: true, supportsLunaReserve: false } });
|
|
275
299
|
assertLive();
|
|
276
300
|
if (allowance?.ordinaryUsageAllowed !== true) throw new Error(REFUSED);
|
|
@@ -310,10 +334,14 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
310
334
|
stream.on('end', () => { buffer += decoder.end(); if (buffer) handler(buffer); });
|
|
311
335
|
};
|
|
312
336
|
listenLines(input, (line) => {
|
|
337
|
+
if (closed) return;
|
|
313
338
|
let message;
|
|
314
339
|
try { message = JSON.parse(line); } catch { if (!closed) writeHost(line); return; }
|
|
315
340
|
if (harness === 'claude-code' && message.type === 'control_request' && message.request?.subtype === 'initialize') initIds.add(message.request_id);
|
|
316
|
-
|
|
341
|
+
if (harness === 'codex' && UNQUALIFIED.has(message.method)) {
|
|
342
|
+
fail(message, 'Native inference method is not qualified for gateway routing; request not forwarded.'); return;
|
|
343
|
+
}
|
|
344
|
+
const routable = harness === 'codex' ? ['turn/start', 'turn/steer'].includes(message.method) : message.type === 'user';
|
|
317
345
|
if (routable && harness === 'claude-code' && (declineUsers || queuedBytes + Buffer.byteLength(line) > maxDeferredBytes)) {
|
|
318
346
|
declineUsers = true;
|
|
319
347
|
const reason = 'Deferred input capacity (64 MiB) exceeded; new user packet not submitted; current work continues.';
|
|
@@ -334,9 +362,11 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
334
362
|
let unsafe = false;
|
|
335
363
|
if (harness === 'codex') {
|
|
336
364
|
const params = message.params || {};
|
|
337
|
-
unsafe = unsafeSettings(params.config) || unsafeSettings({ modelProvider: params.modelProvider ?? 'openai',
|
|
365
|
+
unsafe = unsafeSettings(params.config) || unsafeSettings({ modelProvider: params.modelProvider ?? 'openai',
|
|
366
|
+
serviceTier: params.serviceTier, service_tier: params.service_tier,
|
|
367
|
+
serviceTierForTurn: params.serviceTierForTurn, service_tier_for_turn: params.service_tier_for_turn });
|
|
338
368
|
if (message.method === 'account/login/start' && !['chatgpt', 'chatgptDeviceCode', 'chatgptAuthTokens'].includes(params.type)) unsafe = true;
|
|
339
|
-
if (
|
|
369
|
+
if (['thread/settings/update', 'turn/settings/update'].includes(message.method)) unsafe ||= unsafeSettings(params, '', true);
|
|
340
370
|
const edits = message.method === 'config/value/write' ? [params] : message.method === 'config/batchWrite' ? params.edits || [] : [];
|
|
341
371
|
if (message.method === 'config/batchWrite' && params.reloadUserConfig === true) unsafe = true;
|
|
342
372
|
unsafe ||= edits.some((edit) => unsafeSettings({ [edit.keyPath]: edit.value }, '', true));
|
|
@@ -366,9 +396,9 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
366
396
|
}
|
|
367
397
|
if (starts.has(id)) {
|
|
368
398
|
const pair = starts.get(id); starts.delete(id);
|
|
369
|
-
if (!message.error && message.result?.turn) accepted.set(pair.threadId, pair);
|
|
399
|
+
if (!message.error && typeof message.result?.turn?.id === 'string' && message.result.turn.id) accepted.set(pair.threadId, { ...pair, turnId: message.result.turn.id });
|
|
370
400
|
}
|
|
371
|
-
if (message.method === 'turn/completed') accepted.delete(message.params
|
|
401
|
+
if (message.method === 'turn/completed' && accepted.get(message.params?.threadId)?.turnId === message.params?.turn?.id) accepted.delete(message.params.threadId);
|
|
372
402
|
if (ownIds.has(id)) return; // late own-control replies never escape into the host client
|
|
373
403
|
if (initIds.has(id) && message.response?.subtype === 'success') {
|
|
374
404
|
ready = true; active = message.response.response?.session_state && message.response.response.session_state !== 'idle'; resolveReady?.();
|
|
@@ -379,7 +409,9 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
379
409
|
});
|
|
380
410
|
child.stderr?.on('data', (chunk) => diagnostics.write(chunk));
|
|
381
411
|
const close = () => {
|
|
412
|
+
if (closed) return;
|
|
382
413
|
closed = true; hostWriter.close(); inactiveWaiters.forEach((resolve) => resolve()); inactiveWaiters.clear();
|
|
414
|
+
boundedWaiters.forEach((cancel) => cancel()); boundedWaiters.clear(); accepted.clear(); starts.clear();
|
|
383
415
|
for (const token of [...held]) { token.cancelled = true; retire(token); }
|
|
384
416
|
if (spoolDirectory) fs.rmSync(spoolDirectory, { recursive: true, force: true });
|
|
385
417
|
spoolKey.fill(0);
|
|
@@ -387,7 +419,8 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
|
|
|
387
419
|
pending.clear();
|
|
388
420
|
};
|
|
389
421
|
child.once('error', close); child.once('exit', close); child.stdin.on('error', close); child.stdout.on('error', close);
|
|
390
|
-
|
|
422
|
+
const disconnect = () => { close(); if (!child.stdin.destroyed) child.stdin.end(); };
|
|
423
|
+
input.on('end', disconnect); input.on('error', disconnect); input.on('close', disconnect);
|
|
391
424
|
return { idle: () => serial, close, spoolState: () => ({ directory: spoolDirectory, queuedBytes }) };
|
|
392
425
|
}
|
|
393
426
|
|