@harness-nexus/cli 0.1.0-alpha.2 → 0.1.0-alpha.3

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 (64) 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 +30 -1
  5. package/dist/daemon/acp/agent-connection.d.ts.map +1 -1
  6. package/dist/daemon/acp/agent-connection.js +59 -6
  7. package/dist/daemon/acp/agent-connection.js.map +1 -1
  8. package/dist/daemon/chat.d.ts +9 -1
  9. package/dist/daemon/chat.d.ts.map +1 -1
  10. package/dist/daemon/chat.js +642 -37
  11. package/dist/daemon/chat.js.map +1 -1
  12. package/dist/daemon/client.d.ts +10 -2
  13. package/dist/daemon/client.d.ts.map +1 -1
  14. package/dist/daemon/client.js +99 -23
  15. package/dist/daemon/client.js.map +1 -1
  16. package/dist/daemon/config-view.d.ts +42 -0
  17. package/dist/daemon/config-view.d.ts.map +1 -0
  18. package/dist/daemon/config-view.js +159 -0
  19. package/dist/daemon/config-view.js.map +1 -0
  20. package/dist/daemon/dsh-sessions.d.ts +161 -0
  21. package/dist/daemon/dsh-sessions.d.ts.map +1 -0
  22. package/dist/daemon/dsh-sessions.js +674 -0
  23. package/dist/daemon/dsh-sessions.js.map +1 -0
  24. package/dist/daemon/dsh-tap/index.mjs +99 -0
  25. package/dist/daemon/dsh-tap-listener.d.ts +62 -0
  26. package/dist/daemon/dsh-tap-listener.d.ts.map +1 -0
  27. package/dist/daemon/dsh-tap-listener.js +196 -0
  28. package/dist/daemon/dsh-tap-listener.js.map +1 -0
  29. package/dist/daemon/jobs.d.ts +8 -5
  30. package/dist/daemon/jobs.d.ts.map +1 -1
  31. package/dist/daemon/jobs.js +28 -8
  32. package/dist/daemon/jobs.js.map +1 -1
  33. package/dist/daemon/runtime-config.d.ts +34 -0
  34. package/dist/daemon/runtime-config.d.ts.map +1 -0
  35. package/dist/daemon/runtime-config.js +343 -0
  36. package/dist/daemon/runtime-config.js.map +1 -0
  37. package/dist/daemon/runtime.d.ts +49 -0
  38. package/dist/daemon/runtime.d.ts.map +1 -0
  39. package/dist/daemon/runtime.js +218 -0
  40. package/dist/daemon/runtime.js.map +1 -0
  41. package/dist/daemon/sessions.d.ts +21 -0
  42. package/dist/daemon/sessions.d.ts.map +1 -0
  43. package/dist/daemon/sessions.js +125 -0
  44. package/dist/daemon/sessions.js.map +1 -0
  45. package/dist/daemon/workspace.d.ts +3 -0
  46. package/dist/daemon/workspace.d.ts.map +1 -0
  47. package/dist/daemon/workspace.js +27 -0
  48. package/dist/daemon/workspace.js.map +1 -0
  49. package/dist/install/adapters/deepseek.d.ts +4 -0
  50. package/dist/install/adapters/deepseek.d.ts.map +1 -1
  51. package/dist/install/adapters/deepseek.js +4 -2
  52. package/dist/install/adapters/deepseek.js.map +1 -1
  53. package/dist/inventory/common.d.ts +2 -0
  54. package/dist/inventory/common.d.ts.map +1 -1
  55. package/dist/inventory/common.js.map +1 -1
  56. package/dist/inventory/runtime.d.ts +40 -0
  57. package/dist/inventory/runtime.d.ts.map +1 -0
  58. package/dist/inventory/runtime.js +114 -0
  59. package/dist/inventory/runtime.js.map +1 -0
  60. package/dist/inventory/scanners/claude-code.d.ts +2 -0
  61. package/dist/inventory/scanners/claude-code.d.ts.map +1 -1
  62. package/dist/inventory/scanners/claude-code.js +103 -39
  63. package/dist/inventory/scanners/claude-code.js.map +1 -1
  64. package/package.json +6 -6
@@ -1,23 +1,173 @@
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, chatPermissionRespondEventSchema, chatPromptEventSchema, chatSessionCloseEventSchema, chatSessionResyncEventSchema, chatSessionStartEventSchema, chatTurnCancelEventSchema, } from '@harness-nexus/shared';
3
6
  import { AcpAgentConnection } from './acp/agent-connection.js';
4
7
  import { resolveAcpCommand } from './acp/adapters.js';
8
+ import { createDshLiveMapper, decodeTranscript, dshHistoryItems, findTranscript, nativeZstd, TranscriptTail, } from './dsh-sessions.js';
9
+ import { TapListener, tapPluginAvailable, tapPluginPath, writeTapPatch, } from './dsh-tap-listener.js';
10
+ const HISTORY_MAX = 2000;
11
+ /** How long after arming the tap plugin may take to say hello (design: 3s). */
12
+ const TAP_HANDSHAKE_MS = 3000;
13
+ /** Node fs surface for TranscriptTail. */
14
+ const nodeTailFs = {
15
+ size(path) {
16
+ try {
17
+ return statSync(path).size;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ },
23
+ readEnd(path, start) {
24
+ const fd = openSync(path, 'r');
25
+ try {
26
+ const len = fstatSync(fd).size - start;
27
+ if (len <= 0)
28
+ return Buffer.alloc(0);
29
+ const buf = Buffer.alloc(len);
30
+ readSync(fd, buf, 0, len, start);
31
+ return buf;
32
+ }
33
+ finally {
34
+ closeSync(fd);
35
+ }
36
+ },
37
+ };
5
38
  export function attachChatHandlers(socket, opts = {}) {
6
39
  const env = opts.env ?? process.env;
40
+ const home = opts.homeDir ?? homedir();
7
41
  const sessions = new Map();
8
- const emitEvent = (sessionId, event) => {
9
- socket.emit('chat:event', { sessionId, event });
42
+ /**
43
+ * Ids whose `chat:session.close` arrived BEFORE the session registered. A
44
+ * close can race the establishment: the channel dies server-side (a fast row
45
+ * hop, a page exit) while the adapter is still spawning, so the close finds
46
+ * no session to tear down and used to be dropped — the establishment then
47
+ * finished and registered an orphan process nobody could ever close. The
48
+ * start handler consumes the id at its checkpoints and aborts instead.
49
+ */
50
+ const closedBeforeReady = new Set();
51
+ const CLOSED_BEFORE_READY_MAX = 64;
52
+ const emitEvent = (session, event) => {
53
+ pushHistory(session, { type: 'event', event });
54
+ socket.emit('chat:event', { sessionId: session.sessionId, event });
55
+ };
56
+ const pushHistory = (session, item) => {
57
+ session.history.push(item);
58
+ if (session.history.length > HISTORY_MAX) {
59
+ session.history.splice(0, session.history.length - HISTORY_MAX);
60
+ }
61
+ };
62
+ const emitHistory = (session, items) => {
63
+ if (items.length === 0)
64
+ return;
65
+ for (const item of items)
66
+ pushHistory(session, item);
67
+ socket.emit('chat:history', {
68
+ sessionId: session.sessionId,
69
+ items: session.history.slice(-HISTORY_MAX),
70
+ });
10
71
  };
11
72
  const teardown = (session, reason) => {
12
73
  if (sessions.get(session.sessionId) !== session)
13
74
  return;
14
75
  sessions.delete(session.sessionId);
76
+ session.tail?.stop();
77
+ session.tap?.close();
15
78
  for (const [, p] of session.permissions)
16
79
  clearTimeout(p.timer);
17
80
  session.permissions.clear();
18
81
  session.conn.kill();
19
82
  socket.emit('chat:session.closed', { sessionId: session.sessionId, reason });
20
83
  };
84
+ // In-flight tail attachments by native session id (one per session).
85
+ const tailAttaches = new Map();
86
+ /**
87
+ * 9 W7.1 — arm the in-process dsh event tap BEFORE the spawn (the child
88
+ * needs the listener port/token in its env): render the spawn overlay into
89
+ * `~/.hnx/dsh-tap.patch.yml` and open the localhost listener the plugin
90
+ * dials. Null = not applicable (non-deepseek, `HN_DISABLE_DSH_TAP=1` A/B
91
+ * switch, assets missing, patch write or listen failure) — the transcript
92
+ * tail then streams exactly as before. The handshake races the spawn (the
93
+ * plugin loads during dsh's composition, i.e. typically before initialize
94
+ * resolves); no hello within the window → the caller closes the listener
95
+ * and falls through to the tail.
96
+ */
97
+ const armTap = (target) => {
98
+ if (target !== 'deepseek' || env.HN_DISABLE_DSH_TAP === '1')
99
+ return Promise.resolve(null);
100
+ if (!tapPluginAvailable())
101
+ return Promise.resolve(null);
102
+ const patchPath = writeTapPatch(home, tapPluginPath());
103
+ if (patchPath === null)
104
+ return Promise.resolve(null);
105
+ let sink = null;
106
+ let loss = null;
107
+ return TapListener.create({
108
+ onEvent: (sessionId, event) => sink?.(sessionId, event),
109
+ onLoss: () => loss?.(),
110
+ })
111
+ .then((listener) => ({
112
+ listener,
113
+ patchPath,
114
+ hello: listener.waitHello(Date.now() + TAP_HANDSHAKE_MS),
115
+ bind: (fnSink, fnLoss) => {
116
+ sink = fnSink;
117
+ loss = fnLoss;
118
+ },
119
+ }))
120
+ .catch(() => null);
121
+ };
122
+ /**
123
+ * dsh streaming — attach the transcript tail if it isn't live yet. Called
124
+ * at session ready AND at each prompt start: a NEW session's transcript is
125
+ * materialized lazily (the file appears only when the first prompt's user
126
+ * event flushes), so the ready-time attempt may legitimately find nothing.
127
+ * A late-attached tail misses the turn's first deltas — the mapper's
128
+ * committed fallback then emits the complete blocks, nothing is lost.
129
+ */
130
+ const ensureTail = (session) => {
131
+ // 9 W7.1 — the tap and the tail are mutually exclusive streaming
132
+ // sources: a live tap owns the session, and a DEAD one leaves it
133
+ // committed-only (a fresh tail mapper would double-render streamed steps).
134
+ if (session.target !== 'deepseek' || session.tail !== null)
135
+ return;
136
+ if (session.tap !== null || session.tapDead)
137
+ return;
138
+ if (tailAttaches.has(session.acpSessionId))
139
+ return;
140
+ const attach = (async () => {
141
+ let tailRef = null;
142
+ const tail = await attachTranscriptTail(home, session.acpSessionId, (event) => {
143
+ if (sessions.get(session.sessionId) === session)
144
+ emitEvent(session, event);
145
+ }, (reason) => {
146
+ // Mid-file corruption: stop and lift the wire suppression so the
147
+ // adapter's committed chunks carry the rest of the turn (a partially
148
+ // streamed message may render once more — rare, never silent loss).
149
+ console.warn(`[chat] dsh ${reason} — falling back to committed updates`);
150
+ if (session.tail === tailRef)
151
+ session.tail = null;
152
+ });
153
+ tailRef = tail;
154
+ if (tail !== null && session.tail === null && sessions.get(session.sessionId) === session) {
155
+ session.tail = tail;
156
+ // Byte-0 replay only for a NEW session whose file appeared mid-turn:
157
+ // it can hold nothing but the un-rendered in-flight turn (a resumed
158
+ // session's file pre-exists with rendered history; wire-rendered text
159
+ // likewise rules replay out — either way skip to EOF).
160
+ const replay = session.tailReplayEligible && !session.wireTextEmitted;
161
+ session.tailReplayEligible = false;
162
+ tail.start(replay);
163
+ }
164
+ else {
165
+ tail?.stop();
166
+ }
167
+ })().catch(() => { }); // attachment is best-effort; committed-only is the fallback
168
+ void attach.then(() => tailAttaches.delete(session.acpSessionId));
169
+ tailAttaches.set(session.acpSessionId, attach);
170
+ };
21
171
  // ---- server → daemon: spawn the channel ----
22
172
  socket.on('chat:session.start', (payload, ack) => {
23
173
  const parsed = chatSessionStartEventSchema.safeParse(payload);
@@ -26,7 +176,7 @@ export function attachChatHandlers(socket, opts = {}) {
26
176
  return;
27
177
  }
28
178
  ack?.({ accepted: true });
29
- const { sessionId, target, cwd } = parsed.data;
179
+ const { sessionId, target, cwd, resume } = parsed.data;
30
180
  void (async () => {
31
181
  const cmd = resolveAcpCommand(target, env);
32
182
  if (cmd === null) {
@@ -36,33 +186,178 @@ export function attachChatHandlers(socket, opts = {}) {
36
186
  });
37
187
  return;
38
188
  }
189
+ // A failed establishment (resume model/cwd mismatch, "already active",
190
+ // the startup race giving up, initialize timeout) must NOT leave the
191
+ // spawned adapter running: the channel dies server-side, so nothing
192
+ // would ever kill it. Track the connection from spawn to outcome.
193
+ let liveConn = null;
194
+ // 9 W7.1 — arm the tap before the spawn (the child needs the port/token
195
+ // env); the spawn and the plugin's hello then race in parallel.
196
+ const tapArmed = await armTap(target);
197
+ // Consumes the id: true once the channel was closed while we were busy.
198
+ // Called at every checkpoint — a close that raced the establishment must
199
+ // not leave the spawned adapter behind with no channel to own it.
200
+ const abortIfClosed = () => {
201
+ if (!closedBeforeReady.delete(sessionId))
202
+ return false;
203
+ tapArmed?.listener.close();
204
+ liveConn?.kill();
205
+ return true;
206
+ };
207
+ if (abortIfClosed())
208
+ return;
39
209
  try {
40
- const { conn, agentInfo } = await AcpAgentConnection.start(cmd.command, cmd.args, {
210
+ const spawnOpts = {
41
211
  cwd,
42
- ...(opts.spawnEnv !== undefined ? { env: opts.spawnEnv } : {}),
43
- });
44
- const created = (await conn.request('session/new', { cwd }, 20000));
212
+ ...(tapArmed === null
213
+ ? opts.spawnEnv !== undefined
214
+ ? { env: opts.spawnEnv }
215
+ : {}
216
+ : {
217
+ env: {
218
+ ...(opts.spawnEnv ?? {}),
219
+ HNX_TAP_PORT: String(tapArmed.listener.port),
220
+ HNX_TAP_TOKEN: tapArmed.listener.token,
221
+ },
222
+ }),
223
+ };
224
+ const args = tapArmed === null ? cmd.args : [...cmd.args, '--patch', tapArmed.patchPath];
225
+ let started;
226
+ let tapLive = false;
227
+ if (tapArmed === null) {
228
+ started = await AcpAgentConnection.start(cmd.command, args, spawnOpts);
229
+ }
230
+ else {
231
+ [started, tapLive] = await Promise.all([
232
+ AcpAgentConnection.start(cmd.command, args, spawnOpts),
233
+ tapArmed.hello,
234
+ ]);
235
+ }
236
+ const { conn, agentInfo, sessionCaps } = started;
237
+ liveConn = conn;
238
+ // `mcpServers` is sent explicitly (spec: an array): ACP wrappers
239
+ // (zed 0.23.x AND the @agentclientprotocol one we ship for claude-code)
240
+ // zod-validate session establishment and reject an absent field with
241
+ // `Invalid params` — adapters are
242
+ // pulled latest by `npx -y`, so the client must be maximally
243
+ // spec-shaped. Startup race (seen on real dsh 0.1.2-rc.1): an
244
+ // establishment fired the instant initialize resolves can beat the
245
+ // agent's model-adapter REGISTRATION ("-32605 no adapter registered
246
+ // for provider …"). Retry that specific failure a few times.
247
+ let acpSessionId;
248
+ let history = [];
249
+ if (resume === undefined) {
250
+ const created = (await establish(conn, 'session/new', { cwd, mcpServers: [] }));
251
+ acpSessionId = created?.sessionId ?? sessionId;
252
+ }
253
+ else {
254
+ // 9 W7 — pick the method from the ADVERTISED capability: `load`
255
+ // replays history (captured below), `resume` does not (dsh → we
256
+ // parse its transcript file instead).
257
+ if (sessionCaps.load) {
258
+ const captured = [];
259
+ const stopCapture = wireCapture(conn, captured);
260
+ try {
261
+ const loaded = (await establish(conn, 'session/load', {
262
+ sessionId: resume.sessionId,
263
+ cwd,
264
+ mcpServers: [],
265
+ }));
266
+ acpSessionId = loaded?.sessionId ?? resume.sessionId;
267
+ history = finishCaptured(captured);
268
+ }
269
+ finally {
270
+ stopCapture();
271
+ }
272
+ }
273
+ else if (sessionCaps.resume) {
274
+ await establish(conn, 'session/resume', {
275
+ sessionId: resume.sessionId,
276
+ cwd,
277
+ mcpServers: [],
278
+ });
279
+ acpSessionId = resume.sessionId;
280
+ history =
281
+ target === 'deepseek' ? await dshTranscriptHistory(home, resume.sessionId) : [];
282
+ }
283
+ else {
284
+ throw new Error(`ACP adapter for '${target}' supports no session resume`);
285
+ }
286
+ }
287
+ // Registration is the point of no return: after it, `teardown` owns the
288
+ // connection. Re-check the close flag here — there is no await between
289
+ // this test and `sessions.set`, so no interleaving can slip past.
290
+ if (abortIfClosed())
291
+ return;
45
292
  const session = {
46
293
  sessionId,
47
- acpSessionId: created?.sessionId ?? sessionId,
294
+ acpSessionId,
295
+ target,
48
296
  conn,
49
297
  busy: false,
50
298
  permissions: new Map(),
299
+ history: [],
300
+ wireTextEmitted: false,
301
+ tailReplayEligible: resume === undefined,
302
+ tail: null,
303
+ tap: null,
304
+ tapMapper: null,
305
+ tapTurnEndAt: 0,
306
+ tapDead: false,
51
307
  };
52
308
  sessions.set(sessionId, session);
309
+ liveConn = null; // registered — teardown owns the connection from here
53
310
  wireSession(session, emitEvent);
54
311
  conn.onExit(() => {
55
312
  // Crash/quit outside our control — end the channel honestly.
56
313
  if (sessions.get(sessionId) === session)
57
314
  teardown(session, 'agent-exited');
58
315
  });
316
+ // 9 W7.1 — the tap won the handshake race: it IS the streaming
317
+ // source and the transcript tail never attaches. A lost race closes
318
+ // the listener (a late plugin hello finds a dead port and goes
319
+ // dormant) and ensureTail streams exactly as in W7.
320
+ if (tapArmed !== null) {
321
+ if (tapLive) {
322
+ session.tap = tapArmed.listener;
323
+ session.tapMapper = createDshLiveMapper();
324
+ tapArmed.bind((sid, busEvent) => {
325
+ if (sessions.get(sessionId) !== session || sid !== session.acpSessionId)
326
+ return;
327
+ if (busEvent['type'] === 'turn/end')
328
+ session.tapTurnEndAt = Date.now();
329
+ for (const event of session.tapMapper?.(busEvent) ?? []) {
330
+ emitEvent(session, event);
331
+ }
332
+ }, () => {
333
+ if (sessions.get(sessionId) !== session)
334
+ return;
335
+ session.tap = null;
336
+ session.tapMapper = null;
337
+ session.tapDead = true;
338
+ console.warn('[chat] dsh event tap lost — committed-only streaming for this session');
339
+ });
340
+ }
341
+ else {
342
+ tapArmed.listener.close();
343
+ }
344
+ }
345
+ // dsh streaming: fire-and-forget the tail attach (a resume's file
346
+ // exists already; a new session's appears at first prompt — ensureTail
347
+ // re-runs then). Suppression is keyed off the live tail, never a
348
+ // pending attach, so it cannot be active without the tail.
349
+ ensureTail(session);
350
+ emitHistory(session, history);
59
351
  socket.emit('chat:session.ready', {
60
352
  sessionId,
353
+ nativeSessionId: acpSessionId,
61
354
  ...(agentInfo.name !== undefined ? { agentName: agentInfo.name } : {}),
62
355
  ...(agentInfo.version !== undefined ? { agentVersion: agentInfo.version } : {}),
63
356
  });
64
357
  }
65
358
  catch (e) {
359
+ tapArmed?.listener.close();
360
+ liveConn?.kill();
66
361
  socket.emit('chat:session.ready', {
67
362
  sessionId,
68
363
  error: e instanceof Error ? e.message : String(e),
@@ -70,7 +365,7 @@ export function attachChatHandlers(socket, opts = {}) {
70
365
  }
71
366
  })();
72
367
  });
73
- // ---- server → daemon: prompt / cancel / permission / close ----
368
+ // ---- server → daemon: prompt / cancel / permission / disconnect / resync ----
74
369
  socket.on('chat:message.send', (payload, ack) => {
75
370
  const parsed = chatPromptEventSchema.safeParse(payload);
76
371
  if (!parsed.success) {
@@ -84,12 +379,16 @@ export function attachChatHandlers(socket, opts = {}) {
84
379
  }
85
380
  if (session.busy) {
86
381
  // Lost the race against the server's busy gate — resync it.
87
- emitEvent(session.sessionId, { kind: 'session_status', state: 'active' });
382
+ emitEvent(session, { kind: 'session_status', state: 'active' });
88
383
  ack?.({ error: 'session-busy' });
89
384
  return;
90
385
  }
91
386
  ack?.({ accepted: true });
92
- void runPrompt(session, parsed.data.prompt, emitEvent, teardown);
387
+ // The history ring must know the user turn too — a resync rebuilds the
388
+ // fold from items alone (no optimistic browser echo on that path).
389
+ pushHistory(session, { type: 'user', blocks: parsed.data.prompt });
390
+ ensureTail(session); // lazy materialization: a new dsh file appears now
391
+ void runPrompt(session, parsed.data.prompt, emitEvent);
93
392
  });
94
393
  socket.on('chat:turn.cancel', (payload, ack) => {
95
394
  const parsed = chatTurnCancelEventSchema.safeParse(payload);
@@ -138,25 +437,151 @@ export function attachChatHandlers(socket, opts = {}) {
138
437
  }
139
438
  const session = sessions.get(parsed.data.sessionId);
140
439
  if (session !== undefined) {
141
- // Best-effort session/close, then SIGTERM (kill is on a 3s grace).
440
+ // Best-effort session/close, then SIGTERM (kill is on a 3s grace). The
441
+ // AGENT's session survives this (9 W7) — only the subprocess ends.
142
442
  void session.conn.request('session/close', {}, 3000).catch(() => { });
143
443
  teardown(session, parsed.data.reason ?? 'user');
144
444
  }
445
+ else {
446
+ // Possibly mid-establishment — let the start handler abort. The reply is
447
+ // `{closed:true}` either way: the channel IS gone from the caller's view.
448
+ if (closedBeforeReady.size >= CLOSED_BEFORE_READY_MAX)
449
+ closedBeforeReady.clear();
450
+ closedBeforeReady.add(parsed.data.sessionId);
451
+ }
145
452
  ack?.({ closed: true });
146
453
  });
454
+ // 9 W7 — a viewer (re)joined a live channel (page refresh): re-emit the
455
+ // history ring so the rebuilt fold shows what already happened.
456
+ socket.on('chat:session.resync', (payload, ack) => {
457
+ const parsed = chatSessionResyncEventSchema.safeParse(payload);
458
+ if (!parsed.success) {
459
+ ack?.({ error: 'proto:invalid' });
460
+ return;
461
+ }
462
+ const session = sessions.get(parsed.data.sessionId);
463
+ if (session !== undefined && session.history.length > 0) {
464
+ socket.emit('chat:history', {
465
+ sessionId: session.sessionId,
466
+ items: session.history.slice(-HISTORY_MAX),
467
+ });
468
+ }
469
+ ack?.({ accepted: true });
470
+ });
147
471
  socket.on('disconnect', () => {
148
- // No resume in v1 — every channel dies with the daemon's connection.
472
+ // Channels die with the daemon's connection; the native sessions survive.
149
473
  for (const session of [...sessions.values()])
150
474
  teardown(session, 'daemon-disconnected');
151
475
  });
152
476
  }
477
+ /**
478
+ * Session establishment (`session/new` / `session/load` / `session/resume`)
479
+ * with a retry for the agent-startup registration race: a rejection
480
+ * mentioning "no adapter registered" is retried with a short backoff — the
481
+ * adapter finishes registering moments later.
482
+ */
483
+ async function establish(conn, method, params, attempt = 1) {
484
+ try {
485
+ return await conn.request(method, params, 20000);
486
+ }
487
+ catch (e) {
488
+ const msg = e instanceof Error ? e.message : String(e);
489
+ if (attempt < 4 && /no adapter registered/i.test(msg)) {
490
+ await new Promise((r) => setTimeout(r, 600 * attempt));
491
+ return establish(conn, method, params, attempt + 1);
492
+ }
493
+ throw e;
494
+ }
495
+ }
496
+ /**
497
+ * Capture `session/update` notifications into `items` instead of emitting —
498
+ * used while `session/load` replays an agent's history (claude/codex replay
499
+ * BEFORE the load response resolves). `user_message_chunk` becomes a USER
500
+ * item (on the live path it is dropped as a browser echo); everything else
501
+ * maps through the ordinary update mapping. Returns the deactivation.
502
+ */
503
+ function wireCapture(conn, items) {
504
+ const handler = (method, params) => {
505
+ if (method !== 'session/update')
506
+ return;
507
+ const update = (params.update ?? {});
508
+ if (update.sessionUpdate === 'user_message_chunk') {
509
+ const text = textOf(update.contentBlock) || textOf(update.content);
510
+ if (text !== '')
511
+ items.push({ type: 'user', blocks: [{ type: 'text', text }] });
512
+ return;
513
+ }
514
+ const mapped = mapAcpUpdate(params);
515
+ if (mapped !== null)
516
+ items.push({ type: 'event', event: mapped });
517
+ };
518
+ conn.setNotificationHandler(handler);
519
+ return () => conn.setNotificationHandler(() => { });
520
+ }
521
+ /** Close the captured batch: guarantee a trailing turn_result so the fold settles. */
522
+ function finishCaptured(items) {
523
+ const last = items[items.length - 1];
524
+ if (last !== undefined && last.type === 'event' && last.event.kind !== 'turn_result') {
525
+ items.push({ type: 'event', event: { kind: 'turn_result', stopReason: 'end_turn' } });
526
+ }
527
+ return items;
528
+ }
529
+ /**
530
+ * dsh resume history — the adapter restores the log WITHOUT replaying, so the
531
+ * transcript file is the source. Best-effort by design: a missing/unreadable
532
+ * transcript (or no zstd on this Node) resumes WITHOUT history rather than
533
+ * failing the channel.
534
+ */
535
+ async function dshTranscriptHistory(home, sessionId) {
536
+ try {
537
+ const root = join(home, '.dsh', 'sessions');
538
+ const zstd = nativeZstd();
539
+ if (zstd === null)
540
+ return [];
541
+ for (const slug of readdirSync(root)) {
542
+ const candidate = join(root, slug, sessionId, 'session.jsonl.zstd');
543
+ try {
544
+ return dshHistoryItems(decodeTranscript(readFileSync(candidate), zstd));
545
+ }
546
+ catch {
547
+ // not under this slug (or undecodable) — try the next
548
+ }
549
+ }
550
+ return [];
551
+ }
552
+ catch {
553
+ return [];
554
+ }
555
+ }
153
556
  /** Hook ACP frames for one live session onto the semantic stream. */
154
557
  function wireSession(session, emitEvent) {
155
558
  const { conn } = session;
156
559
  conn.setNotificationHandler((method, params) => {
157
- const mapped = method === 'session/update' ? mapAcpUpdate(params) : null;
158
- if (mapped !== null)
159
- emitEvent(session.sessionId, mapped);
560
+ if (method !== 'session/update')
561
+ return;
562
+ // dsh commits block-level text at turn end — while a streaming source is
563
+ // live (the transcript tail OR the 9 W7.1 event tap), its deltas already
564
+ // streamed this content AND its mapper emits the complete blocks for
565
+ // steps whose deltas it never saw, so the wire's committed chunk is
566
+ // redundant in every case; letting it through would double-render the
567
+ // message. Tools/usage still flow (idempotent by callId / field-merged).
568
+ const update = (params.update ?? {});
569
+ if ((session.tail !== null || session.tap !== null) &&
570
+ (update.sessionUpdate === 'agent_message_chunk' ||
571
+ update.sessionUpdate === 'agent_thought_chunk')) {
572
+ return;
573
+ }
574
+ const mapped = mapAcpUpdate(params);
575
+ if (mapped !== null) {
576
+ if ((mapped.kind === 'message_delta' || mapped.kind === 'thought_delta') &&
577
+ session.tail === null &&
578
+ session.tap === null) {
579
+ // Committed text rendered through the wire — a FUTURE tail attach
580
+ // must skip the file's existing bytes (replaying would duplicate).
581
+ session.wireTextEmitted = true;
582
+ }
583
+ emitEvent(session, mapped);
584
+ }
160
585
  });
161
586
  conn.setPermissionHandler((jsonrpcId, params) => {
162
587
  const requestId = randomUUID();
@@ -165,14 +590,14 @@ function wireSession(session, emitEvent) {
165
590
  // guarantees the agent never waits forever even if the server is gone.
166
591
  session.permissions.delete(requestId);
167
592
  conn.respondPermission(jsonrpcId, { outcome: 'cancelled' });
168
- emitEvent(session.sessionId, {
593
+ emitEvent(session, {
169
594
  kind: 'permission_resolved',
170
595
  requestId,
171
596
  outcome: 'timeout',
172
597
  });
173
598
  }, 75000);
174
599
  session.permissions.set(requestId, { jsonrpcId, timer });
175
- emitEvent(session.sessionId, {
600
+ emitEvent(session, {
176
601
  kind: 'permission_request',
177
602
  requestId,
178
603
  toolCall: toolCallView(params.toolCall),
@@ -180,9 +605,10 @@ function wireSession(session, emitEvent) {
180
605
  });
181
606
  });
182
607
  }
183
- async function runPrompt(session, prompt, emitEvent, teardown) {
608
+ async function runPrompt(session, prompt, emitEvent) {
609
+ const promptStartedAt = Date.now();
184
610
  session.busy = true;
185
- emitEvent(session.sessionId, { kind: 'session_status', state: 'active' });
611
+ emitEvent(session, { kind: 'session_status', state: 'active' });
186
612
  try {
187
613
  // No client-side timeout: a turn can legitimately run for minutes; the
188
614
  // recovery story is cancel or channel close, not a timer.
@@ -190,46 +616,111 @@ async function runPrompt(session, prompt, emitEvent, teardown) {
190
616
  const stopReason = ['end_turn', 'cancelled', 'max_tokens', 'refusal'].includes(result?.stopReason)
191
617
  ? result.stopReason
192
618
  : 'end_turn';
193
- emitEvent(session.sessionId, { kind: 'turn_result', stopReason });
619
+ // dsh: the wire settles when the agent idles, but the streaming source's
620
+ // final bytes can land a beat LATER — the transcript's write-behind
621
+ // batch (tail) or the bus `turn/end` (tap — typically already there,
622
+ // the adapter derives its updates from committed session events).
623
+ // Emitting turn_result before them would render the message tail as a
624
+ // post-turn bubble (the fold opens a new step after turn_result). So
625
+ // drain, then wait briefly for the turn/end signal of whichever source
626
+ // is live.
627
+ if (session.tail !== null) {
628
+ session.tail.flush();
629
+ const deadline = Date.now() + 600;
630
+ while (session.tail !== null &&
631
+ !session.tail.turnEndSeenSince(promptStartedAt) &&
632
+ Date.now() < deadline) {
633
+ await new Promise((r) => setTimeout(r, 50));
634
+ session.tail?.flush();
635
+ }
636
+ }
637
+ else if (session.tap !== null) {
638
+ const deadline = Date.now() + 600;
639
+ while (session.tap !== null &&
640
+ session.tapTurnEndAt < promptStartedAt &&
641
+ Date.now() < deadline) {
642
+ await new Promise((r) => setTimeout(r, 25));
643
+ }
644
+ }
645
+ emitEvent(session, { kind: 'turn_result', stopReason });
194
646
  }
195
647
  catch (e) {
196
- emitEvent(session.sessionId, {
648
+ // A rejected prompt is a TURN error (adapters answer protocol failures —
649
+ // "Authentication required", upstream API errors — through JSON-RPC
650
+ // errors while staying alive), not a dead subprocess. Real process death
651
+ // is conn.onExit's job. Surface the error, end the turn, keep the channel.
652
+ emitEvent(session, {
197
653
  kind: 'raw',
198
654
  method: 'hnx/prompt-error',
199
655
  params: { message: e instanceof Error ? e.message : String(e) },
200
656
  });
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;
657
+ session.tail?.flush();
658
+ emitEvent(session, { kind: 'turn_result', stopReason: 'end_turn' });
205
659
  }
206
660
  finally {
207
661
  session.busy = false;
208
- emitEvent(session.sessionId, { kind: 'session_status', state: 'idle' });
662
+ emitEvent(session, { kind: 'session_status', state: 'idle' });
663
+ }
664
+ }
665
+ /**
666
+ * Attach a dsh transcript tail (short retry — the file materializes with the
667
+ * session header at creation; write lag is the only window). Null = no tail
668
+ * (committed-only streaming — today's behavior).
669
+ */
670
+ async function attachTranscriptTail(home, acpSessionId, onEvent, onFatal) {
671
+ const zstd = nativeZstd();
672
+ if (zstd === null)
673
+ return null;
674
+ const root = join(home, '.dsh', 'sessions');
675
+ for (let attempt = 0; attempt < 6; attempt++) {
676
+ const file = findTranscript(root, acpSessionId, (p) => readdirSync(p));
677
+ if (file !== null) {
678
+ return new TranscriptTail(file, nodeTailFs, zstd, onEvent, { onFatal });
679
+ }
680
+ await new Promise((r) => setTimeout(r, 200));
209
681
  }
682
+ return null;
210
683
  }
211
684
  /** Map one ACP `session/update` params object; null = drop (user echo). */
212
685
  export function mapAcpUpdate(params) {
213
686
  const update = (params.update ?? {});
214
687
  switch (update.sessionUpdate) {
215
688
  case 'agent_message_chunk':
216
- return { kind: 'message_delta', delta: textOf(update.contentBlock) };
689
+ return { kind: 'message_delta', delta: chunkText(update) };
217
690
  case 'agent_thought_chunk':
218
- return { kind: 'thought_delta', delta: textOf(update.contentBlock) };
691
+ return { kind: 'thought_delta', delta: chunkText(update) };
219
692
  case 'tool_call':
220
- case 'tool_call_update':
221
- return { kind: 'tool_call', call: toolCallView(update.toolCallUpdate) };
693
+ case 'tool_call_update': {
694
+ // Claude adapters ride the registry key on the envelope's `_meta`.
695
+ const meta = (params._meta ?? null);
696
+ const cc = meta !== null && meta.claudeCode !== null && typeof meta.claudeCode === 'object'
697
+ ? meta.claudeCode
698
+ : null;
699
+ const metaToolName = cc !== null && typeof cc.toolName === 'string' && cc.toolName !== ''
700
+ ? cc.toolName
701
+ : undefined;
702
+ // Two dialects: Zed adapters nest under `toolCallUpdate`; dsh's native
703
+ // adapter spreads the fields FLAT on the update object.
704
+ return {
705
+ kind: 'tool_call',
706
+ call: toolCallView(update.toolCallUpdate ?? update, metaToolName),
707
+ };
708
+ }
222
709
  case 'usage_update': {
223
710
  const usage = (update.usage ?? {});
224
711
  return {
225
712
  kind: 'usage',
226
713
  ...(typeof usage.inputTokens === 'number' ? { inputTokens: usage.inputTokens } : {}),
227
714
  ...(typeof usage.outputTokens === 'number' ? { outputTokens: usage.outputTokens } : {}),
715
+ // dsh reports context occupancy (`used` of `size`) instead of
716
+ // per-turn token counts.
717
+ ...(typeof update.used === 'number' ? { contextUsed: update.used } : {}),
718
+ ...(typeof update.size === 'number' ? { contextSize: update.size } : {}),
228
719
  };
229
720
  }
230
721
  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.
722
+ // The browser echoes the user's message optimistically on the live
723
+ // path; history batches (capture mode) turn these into USER items.
233
724
  return null;
234
725
  default:
235
726
  return { kind: 'raw', method: 'session/update', params: update };
@@ -253,18 +744,132 @@ export function textOf(contentBlock) {
253
744
  return '';
254
745
  }
255
746
  }
747
+ /**
748
+ * Text of one message/thought chunk across the two adapter dialects: Zed
749
+ * adapters carry `contentBlock`; dsh's native ACP adapter carries `content`
750
+ * (a ContentBlock-shaped object without the wrapper name).
751
+ */
752
+ function chunkText(update) {
753
+ const fromBlock = textOf(update.contentBlock);
754
+ if (fromBlock !== '')
755
+ return fromBlock;
756
+ return textOf(update.content);
757
+ }
256
758
  /**
257
759
  * Defensive view of an ACP ToolCallUpdate: shared-schema-validated, falling
258
760
  * back to the bare id when an adapter sends something malformed (the server
259
761
  * re-validates everything crossing the wire).
762
+ *
763
+ * 9 W6 enrichment: carries `toolName` (the card registry key — from the
764
+ * update itself or the Claude `_meta.claudeCode.toolName` on the envelope),
765
+ * `rawInput` (dropped when oversized — Write-style file bodies), structured
766
+ * `content` (diff/text/terminal items), and the `rawOutput` text — the rich
767
+ * tool cards' rendering inputs.
260
768
  */
261
- export function toolCallView(toolCallUpdate) {
262
- const parsed = acpToolCallViewSchema.safeParse(toolCallUpdate);
769
+ export function toolCallView(toolCallUpdate, metaToolName) {
770
+ const t = (toolCallUpdate ?? {});
771
+ const parsed = acpToolCallViewSchema.safeParse(buildView(t, metaToolName));
263
772
  if (parsed.success)
264
773
  return parsed.data;
265
- const t = (toolCallUpdate ?? {});
266
774
  return { toolCallId: String(t.toolCallId ?? 'unknown') };
267
775
  }
776
+ const TOOL_KINDS = new Set([
777
+ 'read',
778
+ 'edit',
779
+ 'delete',
780
+ 'move',
781
+ 'search',
782
+ 'execute',
783
+ 'think',
784
+ 'fetch',
785
+ 'switch_mode',
786
+ 'other',
787
+ ]);
788
+ /** Spec form is the short kind; some adapters send `readTool`-style variants. */
789
+ function normalizeKind(raw) {
790
+ if (typeof raw !== 'string' || raw === '')
791
+ return undefined;
792
+ const k = raw.toLowerCase().replace(/tool$/, '');
793
+ return TOOL_KINDS.has(k) ? k : undefined;
794
+ }
795
+ const TOOL_STATUSES = new Set(['pending', 'in_progress', 'completed', 'failed']);
796
+ function boundedString(raw, max) {
797
+ if (typeof raw !== 'string' || raw === '')
798
+ return undefined;
799
+ return raw.length <= max ? raw : raw.slice(0, max);
800
+ }
801
+ const RAW_INPUT_MAX = 32 * 1024;
802
+ /** Field-by-field extraction with every bound enforced before the parse. */
803
+ function buildView(t, metaToolName) {
804
+ const toolName = typeof t.toolName === 'string' && t.toolName !== ''
805
+ ? t.toolName
806
+ : typeof metaToolName === 'string' && metaToolName !== ''
807
+ ? metaToolName
808
+ : undefined;
809
+ let rawInput;
810
+ if (t.rawInput !== null && typeof t.rawInput === 'object' && !Array.isArray(t.rawInput)) {
811
+ const entries = Object.entries(t.rawInput).filter(([key]) => key.length <= 128);
812
+ try {
813
+ if (JSON.stringify(Object.fromEntries(entries)).length <= RAW_INPUT_MAX) {
814
+ rawInput = Object.fromEntries(entries);
815
+ }
816
+ }
817
+ catch {
818
+ rawInput = undefined; // unserializable values — drop rather than fail
819
+ }
820
+ }
821
+ let content;
822
+ if (Array.isArray(t.content)) {
823
+ content = t.content
824
+ .filter((c) => c !== null && typeof c === 'object')
825
+ .slice(0, 16)
826
+ .map((c) => ({
827
+ type: c.type,
828
+ ...(c.content !== null && typeof c.content === 'object' && !Array.isArray(c.content)
829
+ ? {
830
+ content: {
831
+ type: String(c.content.type ?? ''),
832
+ ...('text' in c.content
833
+ ? { text: boundedString(c.content.text, 100000) }
834
+ : {}),
835
+ },
836
+ }
837
+ : {}),
838
+ ...(typeof c.path === 'string' ? { path: boundedString(c.path, 1024) } : {}),
839
+ ...(typeof c.oldText === 'string' ? { oldText: boundedString(c.oldText, 100000) } : {}),
840
+ ...(typeof c.newText === 'string' ? { newText: boundedString(c.newText, 100000) } : {}),
841
+ ...(typeof c.terminalId === 'string'
842
+ ? { terminalId: boundedString(c.terminalId, 128) }
843
+ : {}),
844
+ }));
845
+ }
846
+ const locations = Array.isArray(t.locations)
847
+ ? t.locations
848
+ .filter((l) => l !== null && typeof l === 'object')
849
+ .slice(0, 16)
850
+ .map((l) => ({
851
+ path: String(l.path ?? ''),
852
+ ...(typeof l.line === 'number' ? { line: l.line } : {}),
853
+ ...(typeof l.lineEnd === 'number' ? { lineEnd: l.lineEnd } : {}),
854
+ }))
855
+ .filter((l) => l.path !== '')
856
+ : undefined;
857
+ return {
858
+ toolCallId: String(t.toolCallId ?? ''),
859
+ ...(typeof t.title === 'string' && t.title !== ''
860
+ ? { title: boundedString(t.title, 512) }
861
+ : {}),
862
+ ...(toolName !== undefined ? { toolName: boundedString(toolName, 128) } : {}),
863
+ ...(normalizeKind(t.kind) !== undefined ? { kind: normalizeKind(t.kind) } : {}),
864
+ ...(typeof t.status === 'string' && TOOL_STATUSES.has(t.status) ? { status: t.status } : {}),
865
+ ...(locations !== undefined && locations.length > 0 ? { locations } : {}),
866
+ ...(rawInput !== undefined ? { rawInput } : {}),
867
+ ...(content !== undefined ? { content } : {}),
868
+ ...(typeof t.rawOutput === 'string' && t.rawOutput !== ''
869
+ ? { output: boundedString(t.rawOutput, 100000) }
870
+ : {}),
871
+ };
872
+ }
268
873
  /** Permission options, shared-schema-validated; malformed entries dropped. */
269
874
  function permissionOptions(options) {
270
875
  if (!Array.isArray(options))