@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.
- 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 +67 -4
- package/dist/daemon/acp/agent-connection.d.ts.map +1 -1
- package/dist/daemon/acp/agent-connection.js +113 -9
- package/dist/daemon/acp/agent-connection.js.map +1 -1
- package/dist/daemon/adapter-ledger.d.ts +62 -0
- package/dist/daemon/adapter-ledger.d.ts.map +1 -0
- package/dist/daemon/adapter-ledger.js +207 -0
- package/dist/daemon/adapter-ledger.js.map +1 -0
- package/dist/daemon/chat.d.ts +33 -2
- package/dist/daemon/chat.d.ts.map +1 -1
- package/dist/daemon/chat.js +956 -42
- package/dist/daemon/chat.js.map +1 -1
- package/dist/daemon/client.d.ts +11 -2
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +122 -24
- 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 +347 -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 +26 -0
- package/dist/daemon/sessions.d.ts.map +1 -0
- package/dist/daemon/sessions.js +169 -0
- package/dist/daemon/sessions.js.map +1 -0
- package/dist/daemon/workspace.d.ts +5 -0
- package/dist/daemon/workspace.d.ts.map +1 -0
- package/dist/daemon/workspace.js +44 -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,230 @@
|
|
|
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, 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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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 /
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
|
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
|
|
857
|
+
async function runPrompt(session, prompt, emitEvent) {
|
|
858
|
+
const promptStartedAt = Date.now();
|
|
184
859
|
session.busy = true;
|
|
185
|
-
emitEvent(session
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
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:
|
|
1000
|
+
return { kind: 'thought_delta', delta: chunkText(update) };
|
|
219
1001
|
case 'tool_call':
|
|
220
|
-
case 'tool_call_update':
|
|
221
|
-
|
|
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
|
|
232
|
-
//
|
|
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
|
|
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))
|