@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.
- package/dist/daemon/acp/adapters.d.ts.map +1 -1
- package/dist/daemon/acp/adapters.js +9 -3
- package/dist/daemon/acp/adapters.js.map +1 -1
- package/dist/daemon/acp/agent-connection.d.ts +30 -1
- package/dist/daemon/acp/agent-connection.d.ts.map +1 -1
- package/dist/daemon/acp/agent-connection.js +59 -6
- package/dist/daemon/acp/agent-connection.js.map +1 -1
- package/dist/daemon/chat.d.ts +9 -1
- package/dist/daemon/chat.d.ts.map +1 -1
- package/dist/daemon/chat.js +642 -37
- package/dist/daemon/chat.js.map +1 -1
- package/dist/daemon/client.d.ts +10 -2
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +99 -23
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/config-view.d.ts +42 -0
- package/dist/daemon/config-view.d.ts.map +1 -0
- package/dist/daemon/config-view.js +159 -0
- package/dist/daemon/config-view.js.map +1 -0
- package/dist/daemon/dsh-sessions.d.ts +161 -0
- package/dist/daemon/dsh-sessions.d.ts.map +1 -0
- package/dist/daemon/dsh-sessions.js +674 -0
- package/dist/daemon/dsh-sessions.js.map +1 -0
- package/dist/daemon/dsh-tap/index.mjs +99 -0
- package/dist/daemon/dsh-tap-listener.d.ts +62 -0
- package/dist/daemon/dsh-tap-listener.d.ts.map +1 -0
- package/dist/daemon/dsh-tap-listener.js +196 -0
- package/dist/daemon/dsh-tap-listener.js.map +1 -0
- package/dist/daemon/jobs.d.ts +8 -5
- package/dist/daemon/jobs.d.ts.map +1 -1
- package/dist/daemon/jobs.js +28 -8
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/runtime-config.d.ts +34 -0
- package/dist/daemon/runtime-config.d.ts.map +1 -0
- package/dist/daemon/runtime-config.js +343 -0
- package/dist/daemon/runtime-config.js.map +1 -0
- package/dist/daemon/runtime.d.ts +49 -0
- package/dist/daemon/runtime.d.ts.map +1 -0
- package/dist/daemon/runtime.js +218 -0
- package/dist/daemon/runtime.js.map +1 -0
- package/dist/daemon/sessions.d.ts +21 -0
- package/dist/daemon/sessions.d.ts.map +1 -0
- package/dist/daemon/sessions.js +125 -0
- package/dist/daemon/sessions.js.map +1 -0
- package/dist/daemon/workspace.d.ts +3 -0
- package/dist/daemon/workspace.d.ts.map +1 -0
- package/dist/daemon/workspace.js +27 -0
- package/dist/daemon/workspace.js.map +1 -0
- package/dist/install/adapters/deepseek.d.ts +4 -0
- package/dist/install/adapters/deepseek.d.ts.map +1 -1
- package/dist/install/adapters/deepseek.js +4 -2
- package/dist/install/adapters/deepseek.js.map +1 -1
- package/dist/inventory/common.d.ts +2 -0
- package/dist/inventory/common.d.ts.map +1 -1
- package/dist/inventory/common.js.map +1 -1
- package/dist/inventory/runtime.d.ts +40 -0
- package/dist/inventory/runtime.d.ts.map +1 -0
- package/dist/inventory/runtime.js +114 -0
- package/dist/inventory/runtime.js.map +1 -0
- package/dist/inventory/scanners/claude-code.d.ts +2 -0
- package/dist/inventory/scanners/claude-code.d.ts.map +1 -1
- package/dist/inventory/scanners/claude-code.js +103 -39
- package/dist/inventory/scanners/claude-code.js.map +1 -1
- package/package.json +6 -6
package/dist/daemon/chat.js
CHANGED
|
@@ -1,23 +1,173 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
210
|
+
const spawnOpts = {
|
|
41
211
|
cwd,
|
|
42
|
-
...(
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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 /
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
|
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
|
|
608
|
+
async function runPrompt(session, prompt, emitEvent) {
|
|
609
|
+
const promptStartedAt = Date.now();
|
|
184
610
|
session.busy = true;
|
|
185
|
-
emitEvent(session
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
|
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:
|
|
689
|
+
return { kind: 'message_delta', delta: chunkText(update) };
|
|
217
690
|
case 'agent_thought_chunk':
|
|
218
|
-
return { kind: 'thought_delta', delta:
|
|
691
|
+
return { kind: 'thought_delta', delta: chunkText(update) };
|
|
219
692
|
case 'tool_call':
|
|
220
|
-
case 'tool_call_update':
|
|
221
|
-
|
|
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
|
|
232
|
-
//
|
|
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
|
|
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))
|