@harness-nexus/cli 0.1.0-alpha.3 → 0.1.0-alpha.4
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/daemon/acp/agent-connection.d.ts +37 -3
- package/dist/daemon/acp/agent-connection.d.ts.map +1 -1
- package/dist/daemon/acp/agent-connection.js +57 -6
- package/dist/daemon/acp/agent-connection.js.map +1 -1
- package/dist/daemon/adapter-ledger.d.ts +62 -0
- package/dist/daemon/adapter-ledger.d.ts.map +1 -0
- package/dist/daemon/adapter-ledger.js +207 -0
- package/dist/daemon/adapter-ledger.js.map +1 -0
- package/dist/daemon/chat.d.ts +24 -1
- package/dist/daemon/chat.d.ts.map +1 -1
- package/dist/daemon/chat.js +321 -12
- package/dist/daemon/chat.js.map +1 -1
- package/dist/daemon/client.d.ts +3 -2
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +27 -5
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/runtime-config.d.ts.map +1 -1
- package/dist/daemon/runtime-config.js +5 -1
- package/dist/daemon/runtime-config.js.map +1 -1
- package/dist/daemon/sessions.d.ts +5 -0
- package/dist/daemon/sessions.d.ts.map +1 -1
- package/dist/daemon/sessions.js +92 -48
- package/dist/daemon/sessions.js.map +1 -1
- package/dist/daemon/workspace.d.ts +3 -1
- package/dist/daemon/workspace.d.ts.map +1 -1
- package/dist/daemon/workspace.js +25 -8
- package/dist/daemon/workspace.js.map +1 -1
- package/package.json +5 -5
package/dist/daemon/chat.js
CHANGED
|
@@ -2,14 +2,20 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { closeSync, fstatSync, openSync, readSync, readdirSync, readFileSync, statSync, } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { acpPermissionOptionSchema, acpToolCallViewSchema, chatPermissionRespondEventSchema, chatPromptEventSchema, chatSessionCloseEventSchema, chatSessionResyncEventSchema, chatSessionStartEventSchema, chatTurnCancelEventSchema, } from '@harness-nexus/shared';
|
|
5
|
+
import { acpPermissionOptionSchema, acpToolCallViewSchema, chatConfigSetEventSchema, chatPermissionRespondEventSchema, chatPromptEventSchema, chatReconcileEventSchema, adaptersReportRequestSchema, chatSessionCloseEventSchema, chatSessionResyncEventSchema, chatSessionStartEventSchema, chatTurnCancelEventSchema, sessionConfigOptionSchema, sessionModeStateSchema, } from '@harness-nexus/shared';
|
|
6
6
|
import { AcpAgentConnection } from './acp/agent-connection.js';
|
|
7
7
|
import { resolveAcpCommand } from './acp/adapters.js';
|
|
8
|
+
import { auditAdapterLedger, writeAdapterLedgerEntry } from './adapter-ledger.js';
|
|
8
9
|
import { createDshLiveMapper, decodeTranscript, dshHistoryItems, findTranscript, nativeZstd, TranscriptTail, } from './dsh-sessions.js';
|
|
9
10
|
import { TapListener, tapPluginAvailable, tapPluginPath, writeTapPatch, } from './dsh-tap-listener.js';
|
|
10
11
|
const HISTORY_MAX = 2000;
|
|
11
12
|
/** How long after arming the tap plugin may take to say hello (design: 3s). */
|
|
12
13
|
const TAP_HANDSHAKE_MS = 3000;
|
|
14
|
+
/**
|
|
15
|
+
* codex-acp's unknown-model notice (see `mapAcpUpdate`'s agent_message_chunk
|
|
16
|
+
* arm). Anchored on the stable prefix; the model id varies.
|
|
17
|
+
*/
|
|
18
|
+
const CODEX_MODEL_METADATA_NOTICE = /^Model metadata for `[^`]*` not found\./;
|
|
13
19
|
/** Node fs surface for TranscriptTail. */
|
|
14
20
|
const nodeTailFs = {
|
|
15
21
|
size(path) {
|
|
@@ -49,6 +55,14 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
49
55
|
*/
|
|
50
56
|
const closedBeforeReady = new Set();
|
|
51
57
|
const CLOSED_BEFORE_READY_MAX = 64;
|
|
58
|
+
/**
|
|
59
|
+
* 9 W11 E — starts currently mid-establishment (spawn → registration).
|
|
60
|
+
* `chat:reconcile` reports these (when their id is still listed server-side)
|
|
61
|
+
* as held, and flags UNLISTED ones into `closedBeforeReady` so the
|
|
62
|
+
* establishment aborts — a row the server no longer knows must not finish
|
|
63
|
+
* into an unjoinable orphan.
|
|
64
|
+
*/
|
|
65
|
+
const inFlightStarts = new Set();
|
|
52
66
|
const emitEvent = (session, event) => {
|
|
53
67
|
pushHistory(session, { type: 'event', event });
|
|
54
68
|
socket.emit('chat:event', { sessionId: session.sessionId, event });
|
|
@@ -69,6 +83,19 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
69
83
|
items: session.history.slice(-HISTORY_MAX),
|
|
70
84
|
});
|
|
71
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* 9 W9 A — emit the session's FULL merged config snapshot as a
|
|
88
|
+
* `session_config` stream event (into the ring and the live room). The
|
|
89
|
+
* browser's selectors settle exclusively through these events — the
|
|
90
|
+
* daemon-side optimistic merge after `chat:config.set` reuses this path.
|
|
91
|
+
*/
|
|
92
|
+
const emitConfig = (session) => {
|
|
93
|
+
emitEvent(session, {
|
|
94
|
+
kind: 'session_config',
|
|
95
|
+
...(session.config.modes !== undefined ? { modes: { ...session.config.modes } } : {}),
|
|
96
|
+
...(session.config.options.length > 0 ? { configOptions: session.config.options } : {}),
|
|
97
|
+
});
|
|
98
|
+
};
|
|
72
99
|
const teardown = (session, reason) => {
|
|
73
100
|
if (sessions.get(session.sessionId) !== session)
|
|
74
101
|
return;
|
|
@@ -79,8 +106,38 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
79
106
|
clearTimeout(p.timer);
|
|
80
107
|
session.permissions.clear();
|
|
81
108
|
session.conn.kill();
|
|
109
|
+
// 9 W11 A — the kill path does NOT unlink the ledger file: if the daemon
|
|
110
|
+
// hard-dies inside the SIGTERM→SIGKILL grace, an unlinked file would
|
|
111
|
+
// lose accounting (the group survives with no record). The audit below
|
|
112
|
+
// removes it once the group is gone.
|
|
82
113
|
socket.emit('chat:session.closed', { sessionId: session.sessionId, reason });
|
|
83
114
|
};
|
|
115
|
+
// 9 W11 A — periodic audit: the only runtime remover of ledger files
|
|
116
|
+
// (entries whose process group is gone), and the backstop for a session
|
|
117
|
+
// whose group died without the exit event reaching teardown.
|
|
118
|
+
const auditMs = opts.auditIntervalMs ?? 60_000;
|
|
119
|
+
if (auditMs > 0) {
|
|
120
|
+
const auditTimer = setInterval(() => {
|
|
121
|
+
auditAdapterLedger(home);
|
|
122
|
+
for (const session of [...sessions.values()]) {
|
|
123
|
+
if (!session.conn.isGroupAlive())
|
|
124
|
+
teardown(session, 'agent-exited');
|
|
125
|
+
}
|
|
126
|
+
}, auditMs);
|
|
127
|
+
auditTimer.unref();
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 9 W11 E — disconnect grace window. `HN_TEARDOWN_GRACE_MS` (default 8000;
|
|
131
|
+
* 0 restores the pre-W11 immediate teardown). Socket.IO reconnects from a
|
|
132
|
+
* transport blip within ~1s, so holding channels (and in-flight turns) for
|
|
133
|
+
* the window survives the blip; the server delays its offline reap by the
|
|
134
|
+
* same window and reconciles on reconnect.
|
|
135
|
+
*/
|
|
136
|
+
const graceMs = (() => {
|
|
137
|
+
const raw = Number.parseInt(env.HN_TEARDOWN_GRACE_MS ?? '', 10);
|
|
138
|
+
return Number.isFinite(raw) ? Math.max(raw, 0) : 8000;
|
|
139
|
+
})();
|
|
140
|
+
let graceTimer = null;
|
|
84
141
|
// In-flight tail attachments by native session id (one per session).
|
|
85
142
|
const tailAttaches = new Map();
|
|
86
143
|
/**
|
|
@@ -178,6 +235,7 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
178
235
|
ack?.({ accepted: true });
|
|
179
236
|
const { sessionId, target, cwd, resume } = parsed.data;
|
|
180
237
|
void (async () => {
|
|
238
|
+
inFlightStarts.add(sessionId);
|
|
181
239
|
const cmd = resolveAcpCommand(target, env);
|
|
182
240
|
if (cmd === null) {
|
|
183
241
|
socket.emit('chat:session.ready', {
|
|
@@ -207,8 +265,26 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
207
265
|
if (abortIfClosed())
|
|
208
266
|
return;
|
|
209
267
|
try {
|
|
268
|
+
// 9 W11 A — ledger the process group the moment it exists (inside
|
|
269
|
+
// start, right after the detached spawn and before initialize): a
|
|
270
|
+
// hard death during establishment still leaves a boot-sweepable
|
|
271
|
+
// record. The entry is re-written after registration with the native
|
|
272
|
+
// session id; removal is the audit's job, never a kill path.
|
|
273
|
+
const ledgerStartedAt = Date.now();
|
|
274
|
+
let ledgerPgid = null;
|
|
275
|
+
const onSpawned = (pgid) => {
|
|
276
|
+
ledgerPgid = pgid;
|
|
277
|
+
writeAdapterLedgerEntry(home, {
|
|
278
|
+
pgid,
|
|
279
|
+
target,
|
|
280
|
+
command: cmd.command,
|
|
281
|
+
wireSessionId: sessionId,
|
|
282
|
+
startedAt: ledgerStartedAt,
|
|
283
|
+
});
|
|
284
|
+
};
|
|
210
285
|
const spawnOpts = {
|
|
211
286
|
cwd,
|
|
287
|
+
onSpawned,
|
|
212
288
|
...(tapArmed === null
|
|
213
289
|
? opts.spawnEnv !== undefined
|
|
214
290
|
? { env: opts.spawnEnv }
|
|
@@ -233,7 +309,7 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
233
309
|
tapArmed.hello,
|
|
234
310
|
]);
|
|
235
311
|
}
|
|
236
|
-
const { conn, agentInfo, sessionCaps } = started;
|
|
312
|
+
const { conn, agentInfo, sessionCaps, promptCaps } = started;
|
|
237
313
|
liveConn = conn;
|
|
238
314
|
// `mcpServers` is sent explicitly (spec: an array): ACP wrappers
|
|
239
315
|
// (zed 0.23.x AND the @agentclientprotocol one we ship for claude-code)
|
|
@@ -246,9 +322,14 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
246
322
|
// for provider …"). Retry that specific failure a few times.
|
|
247
323
|
let acpSessionId;
|
|
248
324
|
let history = [];
|
|
325
|
+
// 9 W9 A — the establishment response's session-config snapshot
|
|
326
|
+
// (modes + configOptions). Read from whichever arm established the
|
|
327
|
+
// session; null when the adapter advertised nothing (or only junk).
|
|
328
|
+
let establishedConfig = null;
|
|
249
329
|
if (resume === undefined) {
|
|
250
330
|
const created = (await establish(conn, 'session/new', { cwd, mcpServers: [] }));
|
|
251
331
|
acpSessionId = created?.sessionId ?? sessionId;
|
|
332
|
+
establishedConfig = takeSessionConfig(created);
|
|
252
333
|
}
|
|
253
334
|
else {
|
|
254
335
|
// 9 W7 — pick the method from the ADVERTISED capability: `load`
|
|
@@ -265,18 +346,20 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
265
346
|
}));
|
|
266
347
|
acpSessionId = loaded?.sessionId ?? resume.sessionId;
|
|
267
348
|
history = finishCaptured(captured);
|
|
349
|
+
establishedConfig = takeSessionConfig(loaded);
|
|
268
350
|
}
|
|
269
351
|
finally {
|
|
270
352
|
stopCapture();
|
|
271
353
|
}
|
|
272
354
|
}
|
|
273
355
|
else if (sessionCaps.resume) {
|
|
274
|
-
await establish(conn, 'session/resume', {
|
|
356
|
+
const resumed = (await establish(conn, 'session/resume', {
|
|
275
357
|
sessionId: resume.sessionId,
|
|
276
358
|
cwd,
|
|
277
359
|
mcpServers: [],
|
|
278
|
-
});
|
|
360
|
+
}));
|
|
279
361
|
acpSessionId = resume.sessionId;
|
|
362
|
+
establishedConfig = takeSessionConfig(resumed);
|
|
280
363
|
history =
|
|
281
364
|
target === 'deepseek' ? await dshTranscriptHistory(home, resume.sessionId) : [];
|
|
282
365
|
}
|
|
@@ -293,9 +376,12 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
293
376
|
sessionId,
|
|
294
377
|
acpSessionId,
|
|
295
378
|
target,
|
|
379
|
+
command: cmd.command,
|
|
380
|
+
startedAt: ledgerStartedAt,
|
|
296
381
|
conn,
|
|
297
382
|
busy: false,
|
|
298
383
|
permissions: new Map(),
|
|
384
|
+
config: establishedConfig ?? { options: [] },
|
|
299
385
|
history: [],
|
|
300
386
|
wireTextEmitted: false,
|
|
301
387
|
tailReplayEligible: resume === undefined,
|
|
@@ -307,6 +393,18 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
307
393
|
};
|
|
308
394
|
sessions.set(sessionId, session);
|
|
309
395
|
liveConn = null; // registered — teardown owns the connection from here
|
|
396
|
+
// 9 W11 A — enrich the ledger entry with the native session id (the
|
|
397
|
+
// adapter report in slice C and post-mortem forensics key off it).
|
|
398
|
+
if (ledgerPgid !== null) {
|
|
399
|
+
writeAdapterLedgerEntry(home, {
|
|
400
|
+
pgid: ledgerPgid,
|
|
401
|
+
target,
|
|
402
|
+
command: cmd.command,
|
|
403
|
+
wireSessionId: sessionId,
|
|
404
|
+
nativeSessionId: acpSessionId,
|
|
405
|
+
startedAt: ledgerStartedAt,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
310
408
|
wireSession(session, emitEvent);
|
|
311
409
|
conn.onExit(() => {
|
|
312
410
|
// Crash/quit outside our control — end the channel honestly.
|
|
@@ -348,11 +446,17 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
348
446
|
// pending attach, so it cannot be active without the tail.
|
|
349
447
|
ensureTail(session);
|
|
350
448
|
emitHistory(session, history);
|
|
449
|
+
// 9 W9 A — the authoritative config snapshot rides AFTER the replayed
|
|
450
|
+
// history (a load's replay patches settle first; last write wins) and
|
|
451
|
+
// enters the ring, so a resync restores the selectors.
|
|
452
|
+
if (establishedConfig !== null)
|
|
453
|
+
emitConfig(session);
|
|
351
454
|
socket.emit('chat:session.ready', {
|
|
352
455
|
sessionId,
|
|
353
456
|
nativeSessionId: acpSessionId,
|
|
354
457
|
...(agentInfo.name !== undefined ? { agentName: agentInfo.name } : {}),
|
|
355
458
|
...(agentInfo.version !== undefined ? { agentVersion: agentInfo.version } : {}),
|
|
459
|
+
promptCapabilities: promptCaps,
|
|
356
460
|
});
|
|
357
461
|
}
|
|
358
462
|
catch (e) {
|
|
@@ -363,7 +467,7 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
363
467
|
error: e instanceof Error ? e.message : String(e),
|
|
364
468
|
});
|
|
365
469
|
}
|
|
366
|
-
})();
|
|
470
|
+
})().finally(() => inFlightStarts.delete(sessionId));
|
|
367
471
|
});
|
|
368
472
|
// ---- server → daemon: prompt / cancel / permission / disconnect / resync ----
|
|
369
473
|
socket.on('chat:message.send', (payload, ack) => {
|
|
@@ -405,6 +509,44 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
405
509
|
// The pending session/prompt resolves as 'cancelled' → turn_result fires.
|
|
406
510
|
void session.conn.request('session/cancel', {}, 5000).catch(() => { });
|
|
407
511
|
});
|
|
512
|
+
// 9 W9 A — switch the live session's permission mode / one config option.
|
|
513
|
+
// NOT busy-gated: dsh pins the selection per PROMPT (a mid-turn set applies
|
|
514
|
+
// to the next turn) and the UI disables the selectors during a turn anyway.
|
|
515
|
+
socket.on('chat:config.set', (payload, ack) => {
|
|
516
|
+
const parsed = chatConfigSetEventSchema.safeParse(payload);
|
|
517
|
+
if (!parsed.success) {
|
|
518
|
+
ack?.({ error: 'proto:invalid' });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const session = sessions.get(parsed.data.sessionId);
|
|
522
|
+
if (session === undefined) {
|
|
523
|
+
ack?.({ error: 'unknown-session' });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const set = parsed.data;
|
|
527
|
+
const request = set.kind === 'mode' ? 'session/set_mode' : 'session/set_config_option';
|
|
528
|
+
const params = set.kind === 'mode'
|
|
529
|
+
? { sessionId: session.acpSessionId, modeId: set.modeId }
|
|
530
|
+
: { sessionId: session.acpSessionId, configId: set.configId, value: set.value };
|
|
531
|
+
void session.conn.request(request, params, 15000).then(() => {
|
|
532
|
+
// Optimistic daemon-side merge: adapters confirm/correct through
|
|
533
|
+
// config pushes, but codex does not reliably push after a set —
|
|
534
|
+
// without this the selector would sit on the stale value.
|
|
535
|
+
if (set.kind === 'mode') {
|
|
536
|
+
session.config.modes = {
|
|
537
|
+
currentModeId: set.modeId,
|
|
538
|
+
availableModes: session.config.modes?.availableModes ?? [],
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
session.config.options = session.config.options.map((o) => o.id === set.configId ? { ...o, currentValue: set.value } : o);
|
|
543
|
+
}
|
|
544
|
+
emitConfig(session);
|
|
545
|
+
ack?.({ accepted: true });
|
|
546
|
+
}, (e) => {
|
|
547
|
+
ack?.({ error: e instanceof Error ? e.message : String(e) });
|
|
548
|
+
});
|
|
549
|
+
});
|
|
408
550
|
socket.on('chat:permission.respond', (payload, ack) => {
|
|
409
551
|
const parsed = chatPermissionRespondEventSchema.safeParse(payload);
|
|
410
552
|
if (!parsed.success) {
|
|
@@ -468,10 +610,93 @@ export function attachChatHandlers(socket, opts = {}) {
|
|
|
468
610
|
}
|
|
469
611
|
ack?.({ accepted: true });
|
|
470
612
|
});
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
613
|
+
// 9 W11 C — the adapter report: present-tense process truth from the live
|
|
614
|
+
// sessions map (the LEDGER is crash accounting, not a status surface; a
|
|
615
|
+
// report built from it could list processes that already died). Instant by
|
|
616
|
+
// construction — no spawn, no round-trip beyond the socket.
|
|
617
|
+
socket.on('adapters:report', (payload, ack) => {
|
|
618
|
+
const parsed = adaptersReportRequestSchema.safeParse(payload);
|
|
619
|
+
if (!parsed.success) {
|
|
620
|
+
ack?.({ error: 'proto:invalid' });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
ack?.({ accepted: true });
|
|
624
|
+
socket.emit('adapters:report:result', {
|
|
625
|
+
requestId: parsed.data.requestId,
|
|
626
|
+
adapters: [...sessions.values()].map((s) => ({
|
|
627
|
+
wireSessionId: s.sessionId,
|
|
628
|
+
target: s.target,
|
|
629
|
+
pgid: s.conn.pgid ?? 0,
|
|
630
|
+
nativeSessionId: s.acpSessionId,
|
|
631
|
+
startedAt: s.startedAt,
|
|
632
|
+
command: s.command,
|
|
633
|
+
})),
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
// 9 W11 E — the server's live rows for this machine, sent on EVERY /ctl
|
|
637
|
+
// (re)connect. Drop what it disowned; report what we still hold.
|
|
638
|
+
socket.on('chat:reconcile', (payload, ack) => {
|
|
639
|
+
const parsed = chatReconcileEventSchema.safeParse(payload);
|
|
640
|
+
if (!parsed.success) {
|
|
641
|
+
ack?.({ error: 'proto:invalid' });
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const listed = new Set(parsed.data.sessionIds);
|
|
645
|
+
// A session whose row is gone server-side (reaped past the server's
|
|
646
|
+
// grace, or the server restarted) is unjoinable and invisible — no tab,
|
|
647
|
+
// no closer — tear it down now instead of leaking the adapter.
|
|
648
|
+
for (const session of [...sessions.values()]) {
|
|
649
|
+
if (!listed.has(session.sessionId))
|
|
650
|
+
teardown(session, 'reconciled');
|
|
651
|
+
}
|
|
652
|
+
// The same orphan risk exists MID-ESTABLISHMENT: flag unlisted starts so
|
|
653
|
+
// they abort at their checkpoints (the close-consume path).
|
|
654
|
+
for (const id of inFlightStarts) {
|
|
655
|
+
if (listed.has(id))
|
|
656
|
+
continue;
|
|
657
|
+
if (closedBeforeReady.size >= CLOSED_BEFORE_READY_MAX)
|
|
658
|
+
closedBeforeReady.clear();
|
|
659
|
+
closedBeforeReady.add(id);
|
|
660
|
+
}
|
|
661
|
+
// Held = registered sessions + establishments in flight for rows the
|
|
662
|
+
// server still knows (reporting an unlisted in-flight start as held
|
|
663
|
+
// would keep a ghost row alive).
|
|
664
|
+
const held = [...sessions.keys(), ...[...inFlightStarts].filter((id) => listed.has(id))];
|
|
665
|
+
ack?.({ held: held.slice(0, 64) });
|
|
666
|
+
});
|
|
667
|
+
// A reconnect within the grace window revives every channel: cancel the
|
|
668
|
+
// pending teardown BEFORE the server's reconcile lands (its list decides
|
|
669
|
+
// what survives; the grace only buys time for the reconnect itself).
|
|
670
|
+
socket.on('connect', () => {
|
|
671
|
+
if (graceTimer !== null) {
|
|
672
|
+
clearTimeout(graceTimer);
|
|
673
|
+
graceTimer = null;
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
socket.on('disconnect', (reason) => {
|
|
677
|
+
// 9 W11 E — a deliberate stop ('io client disconnect': SIGTERM → stop())
|
|
678
|
+
// or the kill-switch (HN_TEARDOWN_GRACE_MS=0) tears down immediately,
|
|
679
|
+
// exactly as before. A transport blip instead HOLDS every channel for
|
|
680
|
+
// the grace window: Socket.IO reconnects within ~1s, the server mirrors
|
|
681
|
+
// the grace on its reap, and a mid-grace turn keeps running against the
|
|
682
|
+
// local adapter (packets buffer; the viewer resyncs on rejoin).
|
|
683
|
+
if (graceMs <= 0 || reason === 'io client disconnect') {
|
|
684
|
+
if (graceTimer !== null) {
|
|
685
|
+
clearTimeout(graceTimer);
|
|
686
|
+
graceTimer = null;
|
|
687
|
+
}
|
|
688
|
+
for (const session of [...sessions.values()])
|
|
689
|
+
teardown(session, 'daemon-disconnected');
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (graceTimer !== null)
|
|
693
|
+
return; // already armed by an earlier blip cycle
|
|
694
|
+
graceTimer = setTimeout(() => {
|
|
695
|
+
graceTimer = null;
|
|
696
|
+
for (const session of [...sessions.values()])
|
|
697
|
+
teardown(session, 'daemon-disconnected');
|
|
698
|
+
}, graceMs);
|
|
699
|
+
graceTimer.unref();
|
|
475
700
|
});
|
|
476
701
|
}
|
|
477
702
|
/**
|
|
@@ -559,13 +784,37 @@ function wireSession(session, emitEvent) {
|
|
|
559
784
|
conn.setNotificationHandler((method, params) => {
|
|
560
785
|
if (method !== 'session/update')
|
|
561
786
|
return;
|
|
787
|
+
const update = (params.update ?? {});
|
|
788
|
+
// 9 W9 A — session-config pushes merge into the daemon's snapshot and
|
|
789
|
+
// re-emit it in full (needs the session state, so they are intercepted
|
|
790
|
+
// BEFORE the stateless mapping below).
|
|
791
|
+
if (update.sessionUpdate === 'current_mode_update') {
|
|
792
|
+
const modeId = typeof update.currentModeId === 'string' && update.currentModeId !== ''
|
|
793
|
+
? update.currentModeId
|
|
794
|
+
: null;
|
|
795
|
+
if (modeId !== null) {
|
|
796
|
+
session.config.modes = {
|
|
797
|
+
currentModeId: modeId,
|
|
798
|
+
availableModes: session.config.modes?.availableModes ?? [],
|
|
799
|
+
};
|
|
800
|
+
emitEvent(session, { kind: 'session_config', modes: { ...session.config.modes } });
|
|
801
|
+
}
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (update.sessionUpdate === 'config_option_update') {
|
|
805
|
+
const options = takeConfigOptions(update.configOptions);
|
|
806
|
+
if (options !== null) {
|
|
807
|
+
session.config.options = options;
|
|
808
|
+
emitEvent(session, { kind: 'session_config', configOptions: options });
|
|
809
|
+
}
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
562
812
|
// dsh commits block-level text at turn end — while a streaming source is
|
|
563
813
|
// live (the transcript tail OR the 9 W7.1 event tap), its deltas already
|
|
564
814
|
// streamed this content AND its mapper emits the complete blocks for
|
|
565
815
|
// steps whose deltas it never saw, so the wire's committed chunk is
|
|
566
816
|
// redundant in every case; letting it through would double-render the
|
|
567
817
|
// message. Tools/usage still flow (idempotent by callId / field-merged).
|
|
568
|
-
const update = (params.update ?? {});
|
|
569
818
|
if ((session.tail !== null || session.tap !== null) &&
|
|
570
819
|
(update.sessionUpdate === 'agent_message_chunk' ||
|
|
571
820
|
update.sessionUpdate === 'agent_thought_chunk')) {
|
|
@@ -681,12 +930,72 @@ async function attachTranscriptTail(home, acpSessionId, onEvent, onFatal) {
|
|
|
681
930
|
}
|
|
682
931
|
return null;
|
|
683
932
|
}
|
|
933
|
+
/**
|
|
934
|
+
* 9 W9 A — validate an adapter's `configOptions` array down to the
|
|
935
|
+
* platform's bounded view: only `type:'select'` rows survive (the three
|
|
936
|
+
* shipped adapters expose mode/model/effort as selects; boolean options from
|
|
937
|
+
* future adapters are dropped rather than half-surfaced). Null = nothing
|
|
938
|
+
* usable in the payload.
|
|
939
|
+
*/
|
|
940
|
+
export function takeConfigOptions(raw) {
|
|
941
|
+
if (!Array.isArray(raw))
|
|
942
|
+
return null;
|
|
943
|
+
const out = [];
|
|
944
|
+
for (const row of raw) {
|
|
945
|
+
if (typeof row !== 'object' || row === null)
|
|
946
|
+
continue;
|
|
947
|
+
const r = row;
|
|
948
|
+
if (r['type'] !== undefined && r['type'] !== 'select')
|
|
949
|
+
continue;
|
|
950
|
+
const parsed = sessionConfigOptionSchema.safeParse(r);
|
|
951
|
+
if (parsed.success)
|
|
952
|
+
out.push(parsed.data);
|
|
953
|
+
}
|
|
954
|
+
return out.length > 0 ? out : null;
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* 9 W9 A — the session-config slice of an establishment response
|
|
958
|
+
* (`session/new` / `load` / `resume`): `modes` + `configOptions`, each
|
|
959
|
+
* independently validated. Null = the adapter advertised nothing usable.
|
|
960
|
+
*/
|
|
961
|
+
export function takeSessionConfig(result) {
|
|
962
|
+
const r = (result ?? {});
|
|
963
|
+
const modes = sessionModeStateSchema.safeParse(r['modes']);
|
|
964
|
+
const options = takeConfigOptions(r['configOptions']);
|
|
965
|
+
if (!modes.success && options === null)
|
|
966
|
+
return null;
|
|
967
|
+
return {
|
|
968
|
+
...(modes.success ? { modes: modes.data } : {}),
|
|
969
|
+
options: options ?? [],
|
|
970
|
+
};
|
|
971
|
+
}
|
|
684
972
|
/** Map one ACP `session/update` params object; null = drop (user echo). */
|
|
685
973
|
export function mapAcpUpdate(params) {
|
|
686
974
|
const update = (params.update ?? {});
|
|
687
975
|
switch (update.sessionUpdate) {
|
|
688
|
-
|
|
689
|
-
|
|
976
|
+
// 9 W9 A — config pushes on the CAPTURE path (session/load replay: no
|
|
977
|
+
// session state to merge into, so the adapter's patch maps verbatim).
|
|
978
|
+
case 'current_mode_update': {
|
|
979
|
+
const modeId = typeof update.currentModeId === 'string' && update.currentModeId !== ''
|
|
980
|
+
? update.currentModeId
|
|
981
|
+
: null;
|
|
982
|
+
return modeId === null ? null : { kind: 'session_config', modes: { currentModeId: modeId } };
|
|
983
|
+
}
|
|
984
|
+
case 'config_option_update': {
|
|
985
|
+
const options = takeConfigOptions(update.configOptions);
|
|
986
|
+
return options === null ? null : { kind: 'session_config', configOptions: options };
|
|
987
|
+
}
|
|
988
|
+
case 'agent_message_chunk': {
|
|
989
|
+
const delta = chunkText(update);
|
|
990
|
+
// codex-acp announces an unknown gateway model id by streaming a
|
|
991
|
+
// diagnostic AS AN ASSISTANT CHUNK, so it lands mid-transcript as if the
|
|
992
|
+
// model had said it. It is not model output; drop it. (The underlying
|
|
993
|
+
// condition — codex's built-in registry not knowing the custom model id —
|
|
994
|
+
// is what makes it fall back to a default context window; a root-level
|
|
995
|
+
// `model_context_window` in config.toml overrides that window, verified
|
|
996
|
+
// against codex-acp 0.16, but does not silence this notice.)
|
|
997
|
+
return CODEX_MODEL_METADATA_NOTICE.test(delta) ? null : { kind: 'message_delta', delta };
|
|
998
|
+
}
|
|
690
999
|
case 'agent_thought_chunk':
|
|
691
1000
|
return { kind: 'thought_delta', delta: chunkText(update) };
|
|
692
1001
|
case 'tool_call':
|