@harness-nexus/cli 0.1.0-alpha.2 → 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.
Files changed (68) hide show
  1. package/dist/daemon/acp/adapters.d.ts.map +1 -1
  2. package/dist/daemon/acp/adapters.js +9 -3
  3. package/dist/daemon/acp/adapters.js.map +1 -1
  4. package/dist/daemon/acp/agent-connection.d.ts +67 -4
  5. package/dist/daemon/acp/agent-connection.d.ts.map +1 -1
  6. package/dist/daemon/acp/agent-connection.js +113 -9
  7. package/dist/daemon/acp/agent-connection.js.map +1 -1
  8. package/dist/daemon/adapter-ledger.d.ts +62 -0
  9. package/dist/daemon/adapter-ledger.d.ts.map +1 -0
  10. package/dist/daemon/adapter-ledger.js +207 -0
  11. package/dist/daemon/adapter-ledger.js.map +1 -0
  12. package/dist/daemon/chat.d.ts +33 -2
  13. package/dist/daemon/chat.d.ts.map +1 -1
  14. package/dist/daemon/chat.js +956 -42
  15. package/dist/daemon/chat.js.map +1 -1
  16. package/dist/daemon/client.d.ts +11 -2
  17. package/dist/daemon/client.d.ts.map +1 -1
  18. package/dist/daemon/client.js +122 -24
  19. package/dist/daemon/client.js.map +1 -1
  20. package/dist/daemon/config-view.d.ts +42 -0
  21. package/dist/daemon/config-view.d.ts.map +1 -0
  22. package/dist/daemon/config-view.js +159 -0
  23. package/dist/daemon/config-view.js.map +1 -0
  24. package/dist/daemon/dsh-sessions.d.ts +161 -0
  25. package/dist/daemon/dsh-sessions.d.ts.map +1 -0
  26. package/dist/daemon/dsh-sessions.js +674 -0
  27. package/dist/daemon/dsh-sessions.js.map +1 -0
  28. package/dist/daemon/dsh-tap/index.mjs +99 -0
  29. package/dist/daemon/dsh-tap-listener.d.ts +62 -0
  30. package/dist/daemon/dsh-tap-listener.d.ts.map +1 -0
  31. package/dist/daemon/dsh-tap-listener.js +196 -0
  32. package/dist/daemon/dsh-tap-listener.js.map +1 -0
  33. package/dist/daemon/jobs.d.ts +8 -5
  34. package/dist/daemon/jobs.d.ts.map +1 -1
  35. package/dist/daemon/jobs.js +28 -8
  36. package/dist/daemon/jobs.js.map +1 -1
  37. package/dist/daemon/runtime-config.d.ts +34 -0
  38. package/dist/daemon/runtime-config.d.ts.map +1 -0
  39. package/dist/daemon/runtime-config.js +347 -0
  40. package/dist/daemon/runtime-config.js.map +1 -0
  41. package/dist/daemon/runtime.d.ts +49 -0
  42. package/dist/daemon/runtime.d.ts.map +1 -0
  43. package/dist/daemon/runtime.js +218 -0
  44. package/dist/daemon/runtime.js.map +1 -0
  45. package/dist/daemon/sessions.d.ts +26 -0
  46. package/dist/daemon/sessions.d.ts.map +1 -0
  47. package/dist/daemon/sessions.js +169 -0
  48. package/dist/daemon/sessions.js.map +1 -0
  49. package/dist/daemon/workspace.d.ts +5 -0
  50. package/dist/daemon/workspace.d.ts.map +1 -0
  51. package/dist/daemon/workspace.js +44 -0
  52. package/dist/daemon/workspace.js.map +1 -0
  53. package/dist/install/adapters/deepseek.d.ts +4 -0
  54. package/dist/install/adapters/deepseek.d.ts.map +1 -1
  55. package/dist/install/adapters/deepseek.js +4 -2
  56. package/dist/install/adapters/deepseek.js.map +1 -1
  57. package/dist/inventory/common.d.ts +2 -0
  58. package/dist/inventory/common.d.ts.map +1 -1
  59. package/dist/inventory/common.js.map +1 -1
  60. package/dist/inventory/runtime.d.ts +40 -0
  61. package/dist/inventory/runtime.d.ts.map +1 -0
  62. package/dist/inventory/runtime.js +114 -0
  63. package/dist/inventory/runtime.js.map +1 -0
  64. package/dist/inventory/scanners/claude-code.d.ts +2 -0
  65. package/dist/inventory/scanners/claude-code.d.ts.map +1 -1
  66. package/dist/inventory/scanners/claude-code.js +103 -39
  67. package/dist/inventory/scanners/claude-code.js.map +1 -1
  68. package/package.json +6 -6
@@ -1,23 +1,230 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { acpPermissionOptionSchema, acpToolCallViewSchema, chatPermissionRespondEventSchema, chatPromptEventSchema, chatSessionCloseEventSchema, chatSessionStartEventSchema, chatTurnCancelEventSchema, } from '@harness-nexus/shared';
2
+ import { closeSync, fstatSync, openSync, readSync, readdirSync, readFileSync, statSync, } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { acpPermissionOptionSchema, acpToolCallViewSchema, chatConfigSetEventSchema, chatPermissionRespondEventSchema, chatPromptEventSchema, chatReconcileEventSchema, adaptersReportRequestSchema, chatSessionCloseEventSchema, chatSessionResyncEventSchema, chatSessionStartEventSchema, chatTurnCancelEventSchema, sessionConfigOptionSchema, sessionModeStateSchema, } from '@harness-nexus/shared';
3
6
  import { AcpAgentConnection } from './acp/agent-connection.js';
4
7
  import { resolveAcpCommand } from './acp/adapters.js';
8
+ import { auditAdapterLedger, writeAdapterLedgerEntry } from './adapter-ledger.js';
9
+ import { createDshLiveMapper, decodeTranscript, dshHistoryItems, findTranscript, nativeZstd, TranscriptTail, } from './dsh-sessions.js';
10
+ import { TapListener, tapPluginAvailable, tapPluginPath, writeTapPatch, } from './dsh-tap-listener.js';
11
+ const HISTORY_MAX = 2000;
12
+ /** How long after arming the tap plugin may take to say hello (design: 3s). */
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\./;
19
+ /** Node fs surface for TranscriptTail. */
20
+ const nodeTailFs = {
21
+ size(path) {
22
+ try {
23
+ return statSync(path).size;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ },
29
+ readEnd(path, start) {
30
+ const fd = openSync(path, 'r');
31
+ try {
32
+ const len = fstatSync(fd).size - start;
33
+ if (len <= 0)
34
+ return Buffer.alloc(0);
35
+ const buf = Buffer.alloc(len);
36
+ readSync(fd, buf, 0, len, start);
37
+ return buf;
38
+ }
39
+ finally {
40
+ closeSync(fd);
41
+ }
42
+ },
43
+ };
5
44
  export function attachChatHandlers(socket, opts = {}) {
6
45
  const env = opts.env ?? process.env;
46
+ const home = opts.homeDir ?? homedir();
7
47
  const sessions = new Map();
8
- const emitEvent = (sessionId, event) => {
9
- socket.emit('chat:event', { sessionId, event });
48
+ /**
49
+ * Ids whose `chat:session.close` arrived BEFORE the session registered. A
50
+ * close can race the establishment: the channel dies server-side (a fast row
51
+ * hop, a page exit) while the adapter is still spawning, so the close finds
52
+ * no session to tear down and used to be dropped — the establishment then
53
+ * finished and registered an orphan process nobody could ever close. The
54
+ * start handler consumes the id at its checkpoints and aborts instead.
55
+ */
56
+ const closedBeforeReady = new Set();
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();
66
+ const emitEvent = (session, event) => {
67
+ pushHistory(session, { type: 'event', event });
68
+ socket.emit('chat:event', { sessionId: session.sessionId, event });
69
+ };
70
+ const pushHistory = (session, item) => {
71
+ session.history.push(item);
72
+ if (session.history.length > HISTORY_MAX) {
73
+ session.history.splice(0, session.history.length - HISTORY_MAX);
74
+ }
75
+ };
76
+ const emitHistory = (session, items) => {
77
+ if (items.length === 0)
78
+ return;
79
+ for (const item of items)
80
+ pushHistory(session, item);
81
+ socket.emit('chat:history', {
82
+ sessionId: session.sessionId,
83
+ items: session.history.slice(-HISTORY_MAX),
84
+ });
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
+ });
10
98
  };
11
99
  const teardown = (session, reason) => {
12
100
  if (sessions.get(session.sessionId) !== session)
13
101
  return;
14
102
  sessions.delete(session.sessionId);
103
+ session.tail?.stop();
104
+ session.tap?.close();
15
105
  for (const [, p] of session.permissions)
16
106
  clearTimeout(p.timer);
17
107
  session.permissions.clear();
18
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.
19
113
  socket.emit('chat:session.closed', { sessionId: session.sessionId, reason });
20
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;
141
+ // In-flight tail attachments by native session id (one per session).
142
+ const tailAttaches = new Map();
143
+ /**
144
+ * 9 W7.1 — arm the in-process dsh event tap BEFORE the spawn (the child
145
+ * needs the listener port/token in its env): render the spawn overlay into
146
+ * `~/.hnx/dsh-tap.patch.yml` and open the localhost listener the plugin
147
+ * dials. Null = not applicable (non-deepseek, `HN_DISABLE_DSH_TAP=1` A/B
148
+ * switch, assets missing, patch write or listen failure) — the transcript
149
+ * tail then streams exactly as before. The handshake races the spawn (the
150
+ * plugin loads during dsh's composition, i.e. typically before initialize
151
+ * resolves); no hello within the window → the caller closes the listener
152
+ * and falls through to the tail.
153
+ */
154
+ const armTap = (target) => {
155
+ if (target !== 'deepseek' || env.HN_DISABLE_DSH_TAP === '1')
156
+ return Promise.resolve(null);
157
+ if (!tapPluginAvailable())
158
+ return Promise.resolve(null);
159
+ const patchPath = writeTapPatch(home, tapPluginPath());
160
+ if (patchPath === null)
161
+ return Promise.resolve(null);
162
+ let sink = null;
163
+ let loss = null;
164
+ return TapListener.create({
165
+ onEvent: (sessionId, event) => sink?.(sessionId, event),
166
+ onLoss: () => loss?.(),
167
+ })
168
+ .then((listener) => ({
169
+ listener,
170
+ patchPath,
171
+ hello: listener.waitHello(Date.now() + TAP_HANDSHAKE_MS),
172
+ bind: (fnSink, fnLoss) => {
173
+ sink = fnSink;
174
+ loss = fnLoss;
175
+ },
176
+ }))
177
+ .catch(() => null);
178
+ };
179
+ /**
180
+ * dsh streaming — attach the transcript tail if it isn't live yet. Called
181
+ * at session ready AND at each prompt start: a NEW session's transcript is
182
+ * materialized lazily (the file appears only when the first prompt's user
183
+ * event flushes), so the ready-time attempt may legitimately find nothing.
184
+ * A late-attached tail misses the turn's first deltas — the mapper's
185
+ * committed fallback then emits the complete blocks, nothing is lost.
186
+ */
187
+ const ensureTail = (session) => {
188
+ // 9 W7.1 — the tap and the tail are mutually exclusive streaming
189
+ // sources: a live tap owns the session, and a DEAD one leaves it
190
+ // committed-only (a fresh tail mapper would double-render streamed steps).
191
+ if (session.target !== 'deepseek' || session.tail !== null)
192
+ return;
193
+ if (session.tap !== null || session.tapDead)
194
+ return;
195
+ if (tailAttaches.has(session.acpSessionId))
196
+ return;
197
+ const attach = (async () => {
198
+ let tailRef = null;
199
+ const tail = await attachTranscriptTail(home, session.acpSessionId, (event) => {
200
+ if (sessions.get(session.sessionId) === session)
201
+ emitEvent(session, event);
202
+ }, (reason) => {
203
+ // Mid-file corruption: stop and lift the wire suppression so the
204
+ // adapter's committed chunks carry the rest of the turn (a partially
205
+ // streamed message may render once more — rare, never silent loss).
206
+ console.warn(`[chat] dsh ${reason} — falling back to committed updates`);
207
+ if (session.tail === tailRef)
208
+ session.tail = null;
209
+ });
210
+ tailRef = tail;
211
+ if (tail !== null && session.tail === null && sessions.get(session.sessionId) === session) {
212
+ session.tail = tail;
213
+ // Byte-0 replay only for a NEW session whose file appeared mid-turn:
214
+ // it can hold nothing but the un-rendered in-flight turn (a resumed
215
+ // session's file pre-exists with rendered history; wire-rendered text
216
+ // likewise rules replay out — either way skip to EOF).
217
+ const replay = session.tailReplayEligible && !session.wireTextEmitted;
218
+ session.tailReplayEligible = false;
219
+ tail.start(replay);
220
+ }
221
+ else {
222
+ tail?.stop();
223
+ }
224
+ })().catch(() => { }); // attachment is best-effort; committed-only is the fallback
225
+ void attach.then(() => tailAttaches.delete(session.acpSessionId));
226
+ tailAttaches.set(session.acpSessionId, attach);
227
+ };
21
228
  // ---- server → daemon: spawn the channel ----
22
229
  socket.on('chat:session.start', (payload, ack) => {
23
230
  const parsed = chatSessionStartEventSchema.safeParse(payload);
@@ -26,8 +233,9 @@ export function attachChatHandlers(socket, opts = {}) {
26
233
  return;
27
234
  }
28
235
  ack?.({ accepted: true });
29
- const { sessionId, target, cwd } = parsed.data;
236
+ const { sessionId, target, cwd, resume } = parsed.data;
30
237
  void (async () => {
238
+ inFlightStarts.add(sessionId);
31
239
  const cmd = resolveAcpCommand(target, env);
32
240
  if (cmd === null) {
33
241
  socket.emit('chat:session.ready', {
@@ -36,41 +244,232 @@ export function attachChatHandlers(socket, opts = {}) {
36
244
  });
37
245
  return;
38
246
  }
247
+ // A failed establishment (resume model/cwd mismatch, "already active",
248
+ // the startup race giving up, initialize timeout) must NOT leave the
249
+ // spawned adapter running: the channel dies server-side, so nothing
250
+ // would ever kill it. Track the connection from spawn to outcome.
251
+ let liveConn = null;
252
+ // 9 W7.1 — arm the tap before the spawn (the child needs the port/token
253
+ // env); the spawn and the plugin's hello then race in parallel.
254
+ const tapArmed = await armTap(target);
255
+ // Consumes the id: true once the channel was closed while we were busy.
256
+ // Called at every checkpoint — a close that raced the establishment must
257
+ // not leave the spawned adapter behind with no channel to own it.
258
+ const abortIfClosed = () => {
259
+ if (!closedBeforeReady.delete(sessionId))
260
+ return false;
261
+ tapArmed?.listener.close();
262
+ liveConn?.kill();
263
+ return true;
264
+ };
265
+ if (abortIfClosed())
266
+ return;
39
267
  try {
40
- const { conn, agentInfo } = await AcpAgentConnection.start(cmd.command, cmd.args, {
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
+ };
285
+ const spawnOpts = {
41
286
  cwd,
42
- ...(opts.spawnEnv !== undefined ? { env: opts.spawnEnv } : {}),
43
- });
44
- const created = (await conn.request('session/new', { cwd }, 20000));
287
+ onSpawned,
288
+ ...(tapArmed === null
289
+ ? opts.spawnEnv !== undefined
290
+ ? { env: opts.spawnEnv }
291
+ : {}
292
+ : {
293
+ env: {
294
+ ...(opts.spawnEnv ?? {}),
295
+ HNX_TAP_PORT: String(tapArmed.listener.port),
296
+ HNX_TAP_TOKEN: tapArmed.listener.token,
297
+ },
298
+ }),
299
+ };
300
+ const args = tapArmed === null ? cmd.args : [...cmd.args, '--patch', tapArmed.patchPath];
301
+ let started;
302
+ let tapLive = false;
303
+ if (tapArmed === null) {
304
+ started = await AcpAgentConnection.start(cmd.command, args, spawnOpts);
305
+ }
306
+ else {
307
+ [started, tapLive] = await Promise.all([
308
+ AcpAgentConnection.start(cmd.command, args, spawnOpts),
309
+ tapArmed.hello,
310
+ ]);
311
+ }
312
+ const { conn, agentInfo, sessionCaps, promptCaps } = started;
313
+ liveConn = conn;
314
+ // `mcpServers` is sent explicitly (spec: an array): ACP wrappers
315
+ // (zed 0.23.x AND the @agentclientprotocol one we ship for claude-code)
316
+ // zod-validate session establishment and reject an absent field with
317
+ // `Invalid params` — adapters are
318
+ // pulled latest by `npx -y`, so the client must be maximally
319
+ // spec-shaped. Startup race (seen on real dsh 0.1.2-rc.1): an
320
+ // establishment fired the instant initialize resolves can beat the
321
+ // agent's model-adapter REGISTRATION ("-32605 no adapter registered
322
+ // for provider …"). Retry that specific failure a few times.
323
+ let acpSessionId;
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;
329
+ if (resume === undefined) {
330
+ const created = (await establish(conn, 'session/new', { cwd, mcpServers: [] }));
331
+ acpSessionId = created?.sessionId ?? sessionId;
332
+ establishedConfig = takeSessionConfig(created);
333
+ }
334
+ else {
335
+ // 9 W7 — pick the method from the ADVERTISED capability: `load`
336
+ // replays history (captured below), `resume` does not (dsh → we
337
+ // parse its transcript file instead).
338
+ if (sessionCaps.load) {
339
+ const captured = [];
340
+ const stopCapture = wireCapture(conn, captured);
341
+ try {
342
+ const loaded = (await establish(conn, 'session/load', {
343
+ sessionId: resume.sessionId,
344
+ cwd,
345
+ mcpServers: [],
346
+ }));
347
+ acpSessionId = loaded?.sessionId ?? resume.sessionId;
348
+ history = finishCaptured(captured);
349
+ establishedConfig = takeSessionConfig(loaded);
350
+ }
351
+ finally {
352
+ stopCapture();
353
+ }
354
+ }
355
+ else if (sessionCaps.resume) {
356
+ const resumed = (await establish(conn, 'session/resume', {
357
+ sessionId: resume.sessionId,
358
+ cwd,
359
+ mcpServers: [],
360
+ }));
361
+ acpSessionId = resume.sessionId;
362
+ establishedConfig = takeSessionConfig(resumed);
363
+ history =
364
+ target === 'deepseek' ? await dshTranscriptHistory(home, resume.sessionId) : [];
365
+ }
366
+ else {
367
+ throw new Error(`ACP adapter for '${target}' supports no session resume`);
368
+ }
369
+ }
370
+ // Registration is the point of no return: after it, `teardown` owns the
371
+ // connection. Re-check the close flag here — there is no await between
372
+ // this test and `sessions.set`, so no interleaving can slip past.
373
+ if (abortIfClosed())
374
+ return;
45
375
  const session = {
46
376
  sessionId,
47
- acpSessionId: created?.sessionId ?? sessionId,
377
+ acpSessionId,
378
+ target,
379
+ command: cmd.command,
380
+ startedAt: ledgerStartedAt,
48
381
  conn,
49
382
  busy: false,
50
383
  permissions: new Map(),
384
+ config: establishedConfig ?? { options: [] },
385
+ history: [],
386
+ wireTextEmitted: false,
387
+ tailReplayEligible: resume === undefined,
388
+ tail: null,
389
+ tap: null,
390
+ tapMapper: null,
391
+ tapTurnEndAt: 0,
392
+ tapDead: false,
51
393
  };
52
394
  sessions.set(sessionId, session);
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
+ }
53
408
  wireSession(session, emitEvent);
54
409
  conn.onExit(() => {
55
410
  // Crash/quit outside our control — end the channel honestly.
56
411
  if (sessions.get(sessionId) === session)
57
412
  teardown(session, 'agent-exited');
58
413
  });
414
+ // 9 W7.1 — the tap won the handshake race: it IS the streaming
415
+ // source and the transcript tail never attaches. A lost race closes
416
+ // the listener (a late plugin hello finds a dead port and goes
417
+ // dormant) and ensureTail streams exactly as in W7.
418
+ if (tapArmed !== null) {
419
+ if (tapLive) {
420
+ session.tap = tapArmed.listener;
421
+ session.tapMapper = createDshLiveMapper();
422
+ tapArmed.bind((sid, busEvent) => {
423
+ if (sessions.get(sessionId) !== session || sid !== session.acpSessionId)
424
+ return;
425
+ if (busEvent['type'] === 'turn/end')
426
+ session.tapTurnEndAt = Date.now();
427
+ for (const event of session.tapMapper?.(busEvent) ?? []) {
428
+ emitEvent(session, event);
429
+ }
430
+ }, () => {
431
+ if (sessions.get(sessionId) !== session)
432
+ return;
433
+ session.tap = null;
434
+ session.tapMapper = null;
435
+ session.tapDead = true;
436
+ console.warn('[chat] dsh event tap lost — committed-only streaming for this session');
437
+ });
438
+ }
439
+ else {
440
+ tapArmed.listener.close();
441
+ }
442
+ }
443
+ // dsh streaming: fire-and-forget the tail attach (a resume's file
444
+ // exists already; a new session's appears at first prompt — ensureTail
445
+ // re-runs then). Suppression is keyed off the live tail, never a
446
+ // pending attach, so it cannot be active without the tail.
447
+ ensureTail(session);
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);
59
454
  socket.emit('chat:session.ready', {
60
455
  sessionId,
456
+ nativeSessionId: acpSessionId,
61
457
  ...(agentInfo.name !== undefined ? { agentName: agentInfo.name } : {}),
62
458
  ...(agentInfo.version !== undefined ? { agentVersion: agentInfo.version } : {}),
459
+ promptCapabilities: promptCaps,
63
460
  });
64
461
  }
65
462
  catch (e) {
463
+ tapArmed?.listener.close();
464
+ liveConn?.kill();
66
465
  socket.emit('chat:session.ready', {
67
466
  sessionId,
68
467
  error: e instanceof Error ? e.message : String(e),
69
468
  });
70
469
  }
71
- })();
470
+ })().finally(() => inFlightStarts.delete(sessionId));
72
471
  });
73
- // ---- server → daemon: prompt / cancel / permission / close ----
472
+ // ---- server → daemon: prompt / cancel / permission / disconnect / resync ----
74
473
  socket.on('chat:message.send', (payload, ack) => {
75
474
  const parsed = chatPromptEventSchema.safeParse(payload);
76
475
  if (!parsed.success) {
@@ -84,12 +483,16 @@ export function attachChatHandlers(socket, opts = {}) {
84
483
  }
85
484
  if (session.busy) {
86
485
  // Lost the race against the server's busy gate — resync it.
87
- emitEvent(session.sessionId, { kind: 'session_status', state: 'active' });
486
+ emitEvent(session, { kind: 'session_status', state: 'active' });
88
487
  ack?.({ error: 'session-busy' });
89
488
  return;
90
489
  }
91
490
  ack?.({ accepted: true });
92
- void runPrompt(session, parsed.data.prompt, emitEvent, teardown);
491
+ // The history ring must know the user turn too — a resync rebuilds the
492
+ // fold from items alone (no optimistic browser echo on that path).
493
+ pushHistory(session, { type: 'user', blocks: parsed.data.prompt });
494
+ ensureTail(session); // lazy materialization: a new dsh file appears now
495
+ void runPrompt(session, parsed.data.prompt, emitEvent);
93
496
  });
94
497
  socket.on('chat:turn.cancel', (payload, ack) => {
95
498
  const parsed = chatTurnCancelEventSchema.safeParse(payload);
@@ -106,6 +509,44 @@ export function attachChatHandlers(socket, opts = {}) {
106
509
  // The pending session/prompt resolves as 'cancelled' → turn_result fires.
107
510
  void session.conn.request('session/cancel', {}, 5000).catch(() => { });
108
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
+ });
109
550
  socket.on('chat:permission.respond', (payload, ack) => {
110
551
  const parsed = chatPermissionRespondEventSchema.safeParse(payload);
111
552
  if (!parsed.success) {
@@ -138,25 +579,258 @@ export function attachChatHandlers(socket, opts = {}) {
138
579
  }
139
580
  const session = sessions.get(parsed.data.sessionId);
140
581
  if (session !== undefined) {
141
- // Best-effort session/close, then SIGTERM (kill is on a 3s grace).
582
+ // Best-effort session/close, then SIGTERM (kill is on a 3s grace). The
583
+ // AGENT's session survives this (9 W7) — only the subprocess ends.
142
584
  void session.conn.request('session/close', {}, 3000).catch(() => { });
143
585
  teardown(session, parsed.data.reason ?? 'user');
144
586
  }
587
+ else {
588
+ // Possibly mid-establishment — let the start handler abort. The reply is
589
+ // `{closed:true}` either way: the channel IS gone from the caller's view.
590
+ if (closedBeforeReady.size >= CLOSED_BEFORE_READY_MAX)
591
+ closedBeforeReady.clear();
592
+ closedBeforeReady.add(parsed.data.sessionId);
593
+ }
145
594
  ack?.({ closed: true });
146
595
  });
147
- socket.on('disconnect', () => {
148
- // No resume in v1 every channel dies with the daemon's connection.
149
- for (const session of [...sessions.values()])
150
- teardown(session, 'daemon-disconnected');
596
+ // 9 W7 — a viewer (re)joined a live channel (page refresh): re-emit the
597
+ // history ring so the rebuilt fold shows what already happened.
598
+ socket.on('chat:session.resync', (payload, ack) => {
599
+ const parsed = chatSessionResyncEventSchema.safeParse(payload);
600
+ if (!parsed.success) {
601
+ ack?.({ error: 'proto:invalid' });
602
+ return;
603
+ }
604
+ const session = sessions.get(parsed.data.sessionId);
605
+ if (session !== undefined && session.history.length > 0) {
606
+ socket.emit('chat:history', {
607
+ sessionId: session.sessionId,
608
+ items: session.history.slice(-HISTORY_MAX),
609
+ });
610
+ }
611
+ ack?.({ accepted: true });
612
+ });
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();
151
700
  });
152
701
  }
702
+ /**
703
+ * Session establishment (`session/new` / `session/load` / `session/resume`)
704
+ * with a retry for the agent-startup registration race: a rejection
705
+ * mentioning "no adapter registered" is retried with a short backoff — the
706
+ * adapter finishes registering moments later.
707
+ */
708
+ async function establish(conn, method, params, attempt = 1) {
709
+ try {
710
+ return await conn.request(method, params, 20000);
711
+ }
712
+ catch (e) {
713
+ const msg = e instanceof Error ? e.message : String(e);
714
+ if (attempt < 4 && /no adapter registered/i.test(msg)) {
715
+ await new Promise((r) => setTimeout(r, 600 * attempt));
716
+ return establish(conn, method, params, attempt + 1);
717
+ }
718
+ throw e;
719
+ }
720
+ }
721
+ /**
722
+ * Capture `session/update` notifications into `items` instead of emitting —
723
+ * used while `session/load` replays an agent's history (claude/codex replay
724
+ * BEFORE the load response resolves). `user_message_chunk` becomes a USER
725
+ * item (on the live path it is dropped as a browser echo); everything else
726
+ * maps through the ordinary update mapping. Returns the deactivation.
727
+ */
728
+ function wireCapture(conn, items) {
729
+ const handler = (method, params) => {
730
+ if (method !== 'session/update')
731
+ return;
732
+ const update = (params.update ?? {});
733
+ if (update.sessionUpdate === 'user_message_chunk') {
734
+ const text = textOf(update.contentBlock) || textOf(update.content);
735
+ if (text !== '')
736
+ items.push({ type: 'user', blocks: [{ type: 'text', text }] });
737
+ return;
738
+ }
739
+ const mapped = mapAcpUpdate(params);
740
+ if (mapped !== null)
741
+ items.push({ type: 'event', event: mapped });
742
+ };
743
+ conn.setNotificationHandler(handler);
744
+ return () => conn.setNotificationHandler(() => { });
745
+ }
746
+ /** Close the captured batch: guarantee a trailing turn_result so the fold settles. */
747
+ function finishCaptured(items) {
748
+ const last = items[items.length - 1];
749
+ if (last !== undefined && last.type === 'event' && last.event.kind !== 'turn_result') {
750
+ items.push({ type: 'event', event: { kind: 'turn_result', stopReason: 'end_turn' } });
751
+ }
752
+ return items;
753
+ }
754
+ /**
755
+ * dsh resume history — the adapter restores the log WITHOUT replaying, so the
756
+ * transcript file is the source. Best-effort by design: a missing/unreadable
757
+ * transcript (or no zstd on this Node) resumes WITHOUT history rather than
758
+ * failing the channel.
759
+ */
760
+ async function dshTranscriptHistory(home, sessionId) {
761
+ try {
762
+ const root = join(home, '.dsh', 'sessions');
763
+ const zstd = nativeZstd();
764
+ if (zstd === null)
765
+ return [];
766
+ for (const slug of readdirSync(root)) {
767
+ const candidate = join(root, slug, sessionId, 'session.jsonl.zstd');
768
+ try {
769
+ return dshHistoryItems(decodeTranscript(readFileSync(candidate), zstd));
770
+ }
771
+ catch {
772
+ // not under this slug (or undecodable) — try the next
773
+ }
774
+ }
775
+ return [];
776
+ }
777
+ catch {
778
+ return [];
779
+ }
780
+ }
153
781
  /** Hook ACP frames for one live session onto the semantic stream. */
154
782
  function wireSession(session, emitEvent) {
155
783
  const { conn } = session;
156
784
  conn.setNotificationHandler((method, params) => {
157
- const mapped = method === 'session/update' ? mapAcpUpdate(params) : null;
158
- if (mapped !== null)
159
- emitEvent(session.sessionId, mapped);
785
+ if (method !== 'session/update')
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
+ }
812
+ // dsh commits block-level text at turn end — while a streaming source is
813
+ // live (the transcript tail OR the 9 W7.1 event tap), its deltas already
814
+ // streamed this content AND its mapper emits the complete blocks for
815
+ // steps whose deltas it never saw, so the wire's committed chunk is
816
+ // redundant in every case; letting it through would double-render the
817
+ // message. Tools/usage still flow (idempotent by callId / field-merged).
818
+ if ((session.tail !== null || session.tap !== null) &&
819
+ (update.sessionUpdate === 'agent_message_chunk' ||
820
+ update.sessionUpdate === 'agent_thought_chunk')) {
821
+ return;
822
+ }
823
+ const mapped = mapAcpUpdate(params);
824
+ if (mapped !== null) {
825
+ if ((mapped.kind === 'message_delta' || mapped.kind === 'thought_delta') &&
826
+ session.tail === null &&
827
+ session.tap === null) {
828
+ // Committed text rendered through the wire — a FUTURE tail attach
829
+ // must skip the file's existing bytes (replaying would duplicate).
830
+ session.wireTextEmitted = true;
831
+ }
832
+ emitEvent(session, mapped);
833
+ }
160
834
  });
161
835
  conn.setPermissionHandler((jsonrpcId, params) => {
162
836
  const requestId = randomUUID();
@@ -165,14 +839,14 @@ function wireSession(session, emitEvent) {
165
839
  // guarantees the agent never waits forever even if the server is gone.
166
840
  session.permissions.delete(requestId);
167
841
  conn.respondPermission(jsonrpcId, { outcome: 'cancelled' });
168
- emitEvent(session.sessionId, {
842
+ emitEvent(session, {
169
843
  kind: 'permission_resolved',
170
844
  requestId,
171
845
  outcome: 'timeout',
172
846
  });
173
847
  }, 75000);
174
848
  session.permissions.set(requestId, { jsonrpcId, timer });
175
- emitEvent(session.sessionId, {
849
+ emitEvent(session, {
176
850
  kind: 'permission_request',
177
851
  requestId,
178
852
  toolCall: toolCallView(params.toolCall),
@@ -180,9 +854,10 @@ function wireSession(session, emitEvent) {
180
854
  });
181
855
  });
182
856
  }
183
- async function runPrompt(session, prompt, emitEvent, teardown) {
857
+ async function runPrompt(session, prompt, emitEvent) {
858
+ const promptStartedAt = Date.now();
184
859
  session.busy = true;
185
- emitEvent(session.sessionId, { kind: 'session_status', state: 'active' });
860
+ emitEvent(session, { kind: 'session_status', state: 'active' });
186
861
  try {
187
862
  // No client-side timeout: a turn can legitimately run for minutes; the
188
863
  // recovery story is cancel or channel close, not a timer.
@@ -190,46 +865,171 @@ async function runPrompt(session, prompt, emitEvent, teardown) {
190
865
  const stopReason = ['end_turn', 'cancelled', 'max_tokens', 'refusal'].includes(result?.stopReason)
191
866
  ? result.stopReason
192
867
  : 'end_turn';
193
- emitEvent(session.sessionId, { kind: 'turn_result', stopReason });
868
+ // dsh: the wire settles when the agent idles, but the streaming source's
869
+ // final bytes can land a beat LATER — the transcript's write-behind
870
+ // batch (tail) or the bus `turn/end` (tap — typically already there,
871
+ // the adapter derives its updates from committed session events).
872
+ // Emitting turn_result before them would render the message tail as a
873
+ // post-turn bubble (the fold opens a new step after turn_result). So
874
+ // drain, then wait briefly for the turn/end signal of whichever source
875
+ // is live.
876
+ if (session.tail !== null) {
877
+ session.tail.flush();
878
+ const deadline = Date.now() + 600;
879
+ while (session.tail !== null &&
880
+ !session.tail.turnEndSeenSince(promptStartedAt) &&
881
+ Date.now() < deadline) {
882
+ await new Promise((r) => setTimeout(r, 50));
883
+ session.tail?.flush();
884
+ }
885
+ }
886
+ else if (session.tap !== null) {
887
+ const deadline = Date.now() + 600;
888
+ while (session.tap !== null &&
889
+ session.tapTurnEndAt < promptStartedAt &&
890
+ Date.now() < deadline) {
891
+ await new Promise((r) => setTimeout(r, 25));
892
+ }
893
+ }
894
+ emitEvent(session, { kind: 'turn_result', stopReason });
194
895
  }
195
896
  catch (e) {
196
- emitEvent(session.sessionId, {
897
+ // A rejected prompt is a TURN error (adapters answer protocol failures —
898
+ // "Authentication required", upstream API errors — through JSON-RPC
899
+ // errors while staying alive), not a dead subprocess. Real process death
900
+ // is conn.onExit's job. Surface the error, end the turn, keep the channel.
901
+ emitEvent(session, {
197
902
  kind: 'raw',
198
903
  method: 'hnx/prompt-error',
199
904
  params: { message: e instanceof Error ? e.message : String(e) },
200
905
  });
201
- emitEvent(session.sessionId, { kind: 'turn_result', stopReason: 'end_turn' });
202
- // The subprocess is unusable end the channel honestly.
203
- teardown(session, 'agent-exited');
204
- return;
906
+ session.tail?.flush();
907
+ emitEvent(session, { kind: 'turn_result', stopReason: 'end_turn' });
205
908
  }
206
909
  finally {
207
910
  session.busy = false;
208
- emitEvent(session.sessionId, { kind: 'session_status', state: 'idle' });
911
+ emitEvent(session, { kind: 'session_status', state: 'idle' });
912
+ }
913
+ }
914
+ /**
915
+ * Attach a dsh transcript tail (short retry — the file materializes with the
916
+ * session header at creation; write lag is the only window). Null = no tail
917
+ * (committed-only streaming — today's behavior).
918
+ */
919
+ async function attachTranscriptTail(home, acpSessionId, onEvent, onFatal) {
920
+ const zstd = nativeZstd();
921
+ if (zstd === null)
922
+ return null;
923
+ const root = join(home, '.dsh', 'sessions');
924
+ for (let attempt = 0; attempt < 6; attempt++) {
925
+ const file = findTranscript(root, acpSessionId, (p) => readdirSync(p));
926
+ if (file !== null) {
927
+ return new TranscriptTail(file, nodeTailFs, zstd, onEvent, { onFatal });
928
+ }
929
+ await new Promise((r) => setTimeout(r, 200));
209
930
  }
931
+ return null;
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
+ };
210
971
  }
211
972
  /** Map one ACP `session/update` params object; null = drop (user echo). */
212
973
  export function mapAcpUpdate(params) {
213
974
  const update = (params.update ?? {});
214
975
  switch (update.sessionUpdate) {
215
- case 'agent_message_chunk':
216
- return { kind: 'message_delta', delta: textOf(update.contentBlock) };
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
+ }
217
999
  case 'agent_thought_chunk':
218
- return { kind: 'thought_delta', delta: textOf(update.contentBlock) };
1000
+ return { kind: 'thought_delta', delta: chunkText(update) };
219
1001
  case 'tool_call':
220
- case 'tool_call_update':
221
- return { kind: 'tool_call', call: toolCallView(update.toolCallUpdate) };
1002
+ case 'tool_call_update': {
1003
+ // Claude adapters ride the registry key on the envelope's `_meta`.
1004
+ const meta = (params._meta ?? null);
1005
+ const cc = meta !== null && meta.claudeCode !== null && typeof meta.claudeCode === 'object'
1006
+ ? meta.claudeCode
1007
+ : null;
1008
+ const metaToolName = cc !== null && typeof cc.toolName === 'string' && cc.toolName !== ''
1009
+ ? cc.toolName
1010
+ : undefined;
1011
+ // Two dialects: Zed adapters nest under `toolCallUpdate`; dsh's native
1012
+ // adapter spreads the fields FLAT on the update object.
1013
+ return {
1014
+ kind: 'tool_call',
1015
+ call: toolCallView(update.toolCallUpdate ?? update, metaToolName),
1016
+ };
1017
+ }
222
1018
  case 'usage_update': {
223
1019
  const usage = (update.usage ?? {});
224
1020
  return {
225
1021
  kind: 'usage',
226
1022
  ...(typeof usage.inputTokens === 'number' ? { inputTokens: usage.inputTokens } : {}),
227
1023
  ...(typeof usage.outputTokens === 'number' ? { outputTokens: usage.outputTokens } : {}),
1024
+ // dsh reports context occupancy (`used` of `size`) instead of
1025
+ // per-turn token counts.
1026
+ ...(typeof update.used === 'number' ? { contextUsed: update.used } : {}),
1027
+ ...(typeof update.size === 'number' ? { contextSize: update.size } : {}),
228
1028
  };
229
1029
  }
230
1030
  case 'user_message_chunk':
231
- // The browser echoes the user's message optimistically; there is no
232
- // replay in v1, so a live echo would double-render.
1031
+ // The browser echoes the user's message optimistically on the live
1032
+ // path; history batches (capture mode) turn these into USER items.
233
1033
  return null;
234
1034
  default:
235
1035
  return { kind: 'raw', method: 'session/update', params: update };
@@ -253,18 +1053,132 @@ export function textOf(contentBlock) {
253
1053
  return '';
254
1054
  }
255
1055
  }
1056
+ /**
1057
+ * Text of one message/thought chunk across the two adapter dialects: Zed
1058
+ * adapters carry `contentBlock`; dsh's native ACP adapter carries `content`
1059
+ * (a ContentBlock-shaped object without the wrapper name).
1060
+ */
1061
+ function chunkText(update) {
1062
+ const fromBlock = textOf(update.contentBlock);
1063
+ if (fromBlock !== '')
1064
+ return fromBlock;
1065
+ return textOf(update.content);
1066
+ }
256
1067
  /**
257
1068
  * Defensive view of an ACP ToolCallUpdate: shared-schema-validated, falling
258
1069
  * back to the bare id when an adapter sends something malformed (the server
259
1070
  * re-validates everything crossing the wire).
1071
+ *
1072
+ * 9 W6 enrichment: carries `toolName` (the card registry key — from the
1073
+ * update itself or the Claude `_meta.claudeCode.toolName` on the envelope),
1074
+ * `rawInput` (dropped when oversized — Write-style file bodies), structured
1075
+ * `content` (diff/text/terminal items), and the `rawOutput` text — the rich
1076
+ * tool cards' rendering inputs.
260
1077
  */
261
- export function toolCallView(toolCallUpdate) {
262
- const parsed = acpToolCallViewSchema.safeParse(toolCallUpdate);
1078
+ export function toolCallView(toolCallUpdate, metaToolName) {
1079
+ const t = (toolCallUpdate ?? {});
1080
+ const parsed = acpToolCallViewSchema.safeParse(buildView(t, metaToolName));
263
1081
  if (parsed.success)
264
1082
  return parsed.data;
265
- const t = (toolCallUpdate ?? {});
266
1083
  return { toolCallId: String(t.toolCallId ?? 'unknown') };
267
1084
  }
1085
+ const TOOL_KINDS = new Set([
1086
+ 'read',
1087
+ 'edit',
1088
+ 'delete',
1089
+ 'move',
1090
+ 'search',
1091
+ 'execute',
1092
+ 'think',
1093
+ 'fetch',
1094
+ 'switch_mode',
1095
+ 'other',
1096
+ ]);
1097
+ /** Spec form is the short kind; some adapters send `readTool`-style variants. */
1098
+ function normalizeKind(raw) {
1099
+ if (typeof raw !== 'string' || raw === '')
1100
+ return undefined;
1101
+ const k = raw.toLowerCase().replace(/tool$/, '');
1102
+ return TOOL_KINDS.has(k) ? k : undefined;
1103
+ }
1104
+ const TOOL_STATUSES = new Set(['pending', 'in_progress', 'completed', 'failed']);
1105
+ function boundedString(raw, max) {
1106
+ if (typeof raw !== 'string' || raw === '')
1107
+ return undefined;
1108
+ return raw.length <= max ? raw : raw.slice(0, max);
1109
+ }
1110
+ const RAW_INPUT_MAX = 32 * 1024;
1111
+ /** Field-by-field extraction with every bound enforced before the parse. */
1112
+ function buildView(t, metaToolName) {
1113
+ const toolName = typeof t.toolName === 'string' && t.toolName !== ''
1114
+ ? t.toolName
1115
+ : typeof metaToolName === 'string' && metaToolName !== ''
1116
+ ? metaToolName
1117
+ : undefined;
1118
+ let rawInput;
1119
+ if (t.rawInput !== null && typeof t.rawInput === 'object' && !Array.isArray(t.rawInput)) {
1120
+ const entries = Object.entries(t.rawInput).filter(([key]) => key.length <= 128);
1121
+ try {
1122
+ if (JSON.stringify(Object.fromEntries(entries)).length <= RAW_INPUT_MAX) {
1123
+ rawInput = Object.fromEntries(entries);
1124
+ }
1125
+ }
1126
+ catch {
1127
+ rawInput = undefined; // unserializable values — drop rather than fail
1128
+ }
1129
+ }
1130
+ let content;
1131
+ if (Array.isArray(t.content)) {
1132
+ content = t.content
1133
+ .filter((c) => c !== null && typeof c === 'object')
1134
+ .slice(0, 16)
1135
+ .map((c) => ({
1136
+ type: c.type,
1137
+ ...(c.content !== null && typeof c.content === 'object' && !Array.isArray(c.content)
1138
+ ? {
1139
+ content: {
1140
+ type: String(c.content.type ?? ''),
1141
+ ...('text' in c.content
1142
+ ? { text: boundedString(c.content.text, 100000) }
1143
+ : {}),
1144
+ },
1145
+ }
1146
+ : {}),
1147
+ ...(typeof c.path === 'string' ? { path: boundedString(c.path, 1024) } : {}),
1148
+ ...(typeof c.oldText === 'string' ? { oldText: boundedString(c.oldText, 100000) } : {}),
1149
+ ...(typeof c.newText === 'string' ? { newText: boundedString(c.newText, 100000) } : {}),
1150
+ ...(typeof c.terminalId === 'string'
1151
+ ? { terminalId: boundedString(c.terminalId, 128) }
1152
+ : {}),
1153
+ }));
1154
+ }
1155
+ const locations = Array.isArray(t.locations)
1156
+ ? t.locations
1157
+ .filter((l) => l !== null && typeof l === 'object')
1158
+ .slice(0, 16)
1159
+ .map((l) => ({
1160
+ path: String(l.path ?? ''),
1161
+ ...(typeof l.line === 'number' ? { line: l.line } : {}),
1162
+ ...(typeof l.lineEnd === 'number' ? { lineEnd: l.lineEnd } : {}),
1163
+ }))
1164
+ .filter((l) => l.path !== '')
1165
+ : undefined;
1166
+ return {
1167
+ toolCallId: String(t.toolCallId ?? ''),
1168
+ ...(typeof t.title === 'string' && t.title !== ''
1169
+ ? { title: boundedString(t.title, 512) }
1170
+ : {}),
1171
+ ...(toolName !== undefined ? { toolName: boundedString(toolName, 128) } : {}),
1172
+ ...(normalizeKind(t.kind) !== undefined ? { kind: normalizeKind(t.kind) } : {}),
1173
+ ...(typeof t.status === 'string' && TOOL_STATUSES.has(t.status) ? { status: t.status } : {}),
1174
+ ...(locations !== undefined && locations.length > 0 ? { locations } : {}),
1175
+ ...(rawInput !== undefined ? { rawInput } : {}),
1176
+ ...(content !== undefined ? { content } : {}),
1177
+ ...(typeof t.rawOutput === 'string' && t.rawOutput !== ''
1178
+ ? { output: boundedString(t.rawOutput, 100000) }
1179
+ : {}),
1180
+ };
1181
+ }
268
1182
  /** Permission options, shared-schema-validated; malformed entries dropped. */
269
1183
  function permissionOptions(options) {
270
1184
  if (!Array.isArray(options))