@shendeguize/dsh-agent-sidecar 0.1.0
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/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- package/src/supervisor.ts +463 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,4166 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import z from "@deepseek-ai/schemastery";
|
|
5
|
+
import { createConnection } from "node:net";
|
|
6
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
7
|
+
/** Title chars kept in prompts and logs (titles are untrusted input too). */
|
|
8
|
+
const MAX_TITLE_CHARS = 200;
|
|
9
|
+
/** Appended to the input text when it was cut at `maxInputChars`. */
|
|
10
|
+
const TRUNCATION_MARKER = "\n…[输入已截断 / input truncated]";
|
|
11
|
+
/** Honesty banner attached to every result (design §7-B / risk 12). */
|
|
12
|
+
const ANALYSIS_DISCLAIMER = "AI 分析仅供参考,由模型基于有界摘要推断生成,可能不完整或有误 / AI-generated analysis for reference only; inferred from a bounded summary and may be incomplete or wrong.";
|
|
13
|
+
/**
|
|
14
|
+
* Read-only-analyst guidance. `CreateAgentOptions` has no system-prompt field
|
|
15
|
+
* (d.ts fact above), so this rides the first user message.
|
|
16
|
+
*/
|
|
17
|
+
const ANALYSIS_GUIDANCE = [
|
|
18
|
+
"你是只读分析助手:基于下面提供的 agent 会话摘要给出洞察(状态判断、异常与风险、可能的下一步建议)。",
|
|
19
|
+
"不执行任何操作、不调用任何工具、不修改任何东西;不要假设摘要之外的事实,摘要可能不完整或被截断,不确定处请如实说明。",
|
|
20
|
+
"You are a read-only analysis assistant: provide insights (state assessment, anomalies/risks, possible next steps) based solely on the agent-session summary below.",
|
|
21
|
+
"Take no actions, call no tools, change nothing; do not assume facts beyond the summary — it may be incomplete or truncated, so state uncertainty honestly."
|
|
22
|
+
].join("\n");
|
|
23
|
+
function describeError$4(error) {
|
|
24
|
+
return error instanceof Error ? error.message : String(error);
|
|
25
|
+
}
|
|
26
|
+
function boundText(text, maxChars) {
|
|
27
|
+
if (text.length <= maxChars) return {
|
|
28
|
+
text,
|
|
29
|
+
truncated: false
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
text: text.slice(0, maxChars) + TRUNCATION_MARKER,
|
|
33
|
+
truncated: true
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function boundTitle(title) {
|
|
37
|
+
return title.length <= MAX_TITLE_CHARS ? title : title.slice(0, MAX_TITLE_CHARS) + "…";
|
|
38
|
+
}
|
|
39
|
+
/** Race a promise against a bounded timer; the timer is always cleared. */
|
|
40
|
+
async function withTimeout(promise, ms) {
|
|
41
|
+
let timer;
|
|
42
|
+
try {
|
|
43
|
+
return await Promise.race([promise.then((value) => ({
|
|
44
|
+
timedOut: false,
|
|
45
|
+
value
|
|
46
|
+
})), new Promise((resolve) => {
|
|
47
|
+
timer = setTimeout(() => resolve({ timedOut: true }), Math.max(1, ms));
|
|
48
|
+
})]);
|
|
49
|
+
} finally {
|
|
50
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Join the text blocks of assistant messages appended after `baseline`. */
|
|
54
|
+
function extractNewAssistantText(messages, baseline) {
|
|
55
|
+
const parts = [];
|
|
56
|
+
for (const message of messages.slice(baseline)) {
|
|
57
|
+
if (message.role !== "assistant") continue;
|
|
58
|
+
const text = message.content.filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n");
|
|
59
|
+
if (text.length > 0) parts.push(text);
|
|
60
|
+
}
|
|
61
|
+
return parts.join("\n\n");
|
|
62
|
+
}
|
|
63
|
+
/** Sum reported input+output tokens on new `assistant/message` events. */
|
|
64
|
+
function extractTokensHint(events, baseline) {
|
|
65
|
+
let total = 0;
|
|
66
|
+
let reported = false;
|
|
67
|
+
for (const event of events.slice(baseline)) {
|
|
68
|
+
if (event.type !== "assistant/message") continue;
|
|
69
|
+
const usage = event.data?.usage;
|
|
70
|
+
if (usage === void 0) continue;
|
|
71
|
+
reported = true;
|
|
72
|
+
if (typeof usage.inputTokens === "number") total += usage.inputTokens;
|
|
73
|
+
if (typeof usage.outputTokens === "number") total += usage.outputTokens;
|
|
74
|
+
}
|
|
75
|
+
return reported ? total : void 0;
|
|
76
|
+
}
|
|
77
|
+
var AnalysisEngine = class {
|
|
78
|
+
deps;
|
|
79
|
+
now;
|
|
80
|
+
maxInputChars;
|
|
81
|
+
analysisTimeoutMs;
|
|
82
|
+
maxActiveSessions;
|
|
83
|
+
pluginName;
|
|
84
|
+
active = /* @__PURE__ */ new Map();
|
|
85
|
+
mintCounter = 0;
|
|
86
|
+
constructor(deps) {
|
|
87
|
+
this.deps = deps;
|
|
88
|
+
this.now = deps.now ?? Date.now;
|
|
89
|
+
this.maxInputChars = deps.maxInputChars ?? 8e3;
|
|
90
|
+
this.analysisTimeoutMs = deps.analysisTimeoutMs ?? 6e4;
|
|
91
|
+
this.maxActiveSessions = deps.maxActiveSessions ?? 4;
|
|
92
|
+
this.pluginName = deps.pluginName ?? "agent-sidecar";
|
|
93
|
+
}
|
|
94
|
+
/** Number of live (or being-created) analysis sessions. */
|
|
95
|
+
get activeCount() {
|
|
96
|
+
return this.active.size;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Start a dedicated analysis session and return its first insight.
|
|
100
|
+
* Establishment (create + priming prompt + first response) shares one
|
|
101
|
+
* `analysisTimeoutMs` budget; on timeout the turn is cancelled and the
|
|
102
|
+
* session disposed, so a timed-out request leaves nothing running.
|
|
103
|
+
*/
|
|
104
|
+
async request(input) {
|
|
105
|
+
const startedAt = this.now();
|
|
106
|
+
const title = boundTitle(input.title);
|
|
107
|
+
if (!this.deps.allowAnalysis()) return this.failResult("request", {
|
|
108
|
+
kind: input.kind,
|
|
109
|
+
title
|
|
110
|
+
}, "analysis_disabled", {
|
|
111
|
+
truncated: false,
|
|
112
|
+
startedAt
|
|
113
|
+
});
|
|
114
|
+
if (this.active.size >= this.maxActiveSessions) return this.failResult("request", {
|
|
115
|
+
kind: input.kind,
|
|
116
|
+
title
|
|
117
|
+
}, "too_many_active", {
|
|
118
|
+
truncated: false,
|
|
119
|
+
startedAt,
|
|
120
|
+
detail: `active analyses at cap (${this.maxActiveSessions})`
|
|
121
|
+
});
|
|
122
|
+
const bounded = boundText(input.summaryText, this.maxInputChars);
|
|
123
|
+
const analysisSessionId = this.mintSessionId();
|
|
124
|
+
const entry = {
|
|
125
|
+
analysisSessionId,
|
|
126
|
+
kind: input.kind,
|
|
127
|
+
title,
|
|
128
|
+
busy: true,
|
|
129
|
+
messageBaseline: 0,
|
|
130
|
+
eventBaseline: 0,
|
|
131
|
+
messageSeq: 0
|
|
132
|
+
};
|
|
133
|
+
this.active.set(analysisSessionId, entry);
|
|
134
|
+
const deadline = startedAt + this.analysisTimeoutMs;
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
let createTimedOut = false;
|
|
137
|
+
const createPromise = this.deps.createAgent({
|
|
138
|
+
sessionId: analysisSessionId,
|
|
139
|
+
signal: controller.signal
|
|
140
|
+
});
|
|
141
|
+
createPromise.then((late) => {
|
|
142
|
+
if (createTimedOut) late.dispose().catch(() => {});
|
|
143
|
+
}, () => {});
|
|
144
|
+
let handle;
|
|
145
|
+
try {
|
|
146
|
+
const created = await withTimeout(createPromise, deadline - this.now());
|
|
147
|
+
if (created.timedOut) {
|
|
148
|
+
createTimedOut = true;
|
|
149
|
+
this.active.delete(analysisSessionId);
|
|
150
|
+
controller.abort();
|
|
151
|
+
return this.timeoutResult("request", entry, bounded.truncated, startedAt, void 0);
|
|
152
|
+
}
|
|
153
|
+
handle = created.value;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
this.active.delete(analysisSessionId);
|
|
156
|
+
return this.failResult("request", entry, "create_failed", {
|
|
157
|
+
truncated: bounded.truncated,
|
|
158
|
+
startedAt,
|
|
159
|
+
detail: describeError$4(error)
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
entry.handle = handle;
|
|
163
|
+
this.deps.log({
|
|
164
|
+
op: "create",
|
|
165
|
+
analysisSessionId,
|
|
166
|
+
kind: input.kind,
|
|
167
|
+
title,
|
|
168
|
+
truncated: bounded.truncated,
|
|
169
|
+
inputChars: bounded.text.length
|
|
170
|
+
});
|
|
171
|
+
const prompt = this.buildInitialPrompt(input.kind, title, bounded);
|
|
172
|
+
const turn = await this.runTurn(entry, prompt, deadline);
|
|
173
|
+
entry.busy = false;
|
|
174
|
+
if (turn.status === "threw") {
|
|
175
|
+
this.active.delete(analysisSessionId);
|
|
176
|
+
await this.disposeQuietly(entry);
|
|
177
|
+
return this.failResult("request", entry, "create_failed", {
|
|
178
|
+
truncated: bounded.truncated,
|
|
179
|
+
startedAt,
|
|
180
|
+
detail: turn.detail
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (turn.status === "timeout") {
|
|
184
|
+
this.cancelQuietly(entry);
|
|
185
|
+
this.active.delete(analysisSessionId);
|
|
186
|
+
await this.disposeQuietly(entry);
|
|
187
|
+
return this.timeoutResult("request", entry, bounded.truncated, startedAt, void 0);
|
|
188
|
+
}
|
|
189
|
+
const result = {
|
|
190
|
+
outcome: "completed",
|
|
191
|
+
analysisSessionId,
|
|
192
|
+
summary: turn.summary,
|
|
193
|
+
truncated: bounded.truncated,
|
|
194
|
+
...turn.tokensHint !== void 0 ? { tokensHint: turn.tokensHint } : {},
|
|
195
|
+
disclaimer: ANALYSIS_DISCLAIMER
|
|
196
|
+
};
|
|
197
|
+
this.logResult("request", entry, result, startedAt);
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Ask an incremental follow-up question on an established analysis session.
|
|
202
|
+
* A timeout cancels the in-flight turn but KEEPS the session (its prior
|
|
203
|
+
* context stays valuable; the UI may retry or cancel).
|
|
204
|
+
*/
|
|
205
|
+
async followup(analysisSessionId, question) {
|
|
206
|
+
const startedAt = this.now();
|
|
207
|
+
const entry = this.active.get(analysisSessionId);
|
|
208
|
+
if (!this.deps.allowAnalysis()) return this.failResult("followup", entry ?? { analysisSessionId }, "analysis_disabled", {
|
|
209
|
+
truncated: false,
|
|
210
|
+
startedAt
|
|
211
|
+
});
|
|
212
|
+
if (entry === void 0 || entry.handle === void 0) return this.failResult("followup", { analysisSessionId }, "cancelled", {
|
|
213
|
+
truncated: false,
|
|
214
|
+
startedAt,
|
|
215
|
+
detail: "unknown or already-cancelled analysis session"
|
|
216
|
+
});
|
|
217
|
+
if (entry.busy) return this.failResult("followup", entry, "too_many_active", {
|
|
218
|
+
truncated: false,
|
|
219
|
+
startedAt,
|
|
220
|
+
detail: "a turn is already in flight on this analysis session"
|
|
221
|
+
});
|
|
222
|
+
const bounded = boundText(question, this.maxInputChars);
|
|
223
|
+
this.deps.log({
|
|
224
|
+
op: "followup",
|
|
225
|
+
analysisSessionId,
|
|
226
|
+
kind: entry.kind,
|
|
227
|
+
title: entry.title,
|
|
228
|
+
truncated: bounded.truncated,
|
|
229
|
+
inputChars: bounded.text.length
|
|
230
|
+
});
|
|
231
|
+
entry.busy = true;
|
|
232
|
+
try {
|
|
233
|
+
const turn = await this.runTurn(entry, bounded.text, startedAt + this.analysisTimeoutMs);
|
|
234
|
+
if (turn.status === "threw") {
|
|
235
|
+
this.active.delete(analysisSessionId);
|
|
236
|
+
await this.disposeQuietly(entry);
|
|
237
|
+
return this.failResult("followup", entry, "cancelled", {
|
|
238
|
+
truncated: bounded.truncated,
|
|
239
|
+
startedAt,
|
|
240
|
+
detail: turn.detail
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (turn.status === "timeout") {
|
|
244
|
+
this.cancelQuietly(entry);
|
|
245
|
+
return this.timeoutResult("followup", entry, bounded.truncated, startedAt, analysisSessionId);
|
|
246
|
+
}
|
|
247
|
+
const result = {
|
|
248
|
+
outcome: "completed",
|
|
249
|
+
analysisSessionId,
|
|
250
|
+
summary: turn.summary,
|
|
251
|
+
truncated: bounded.truncated,
|
|
252
|
+
...turn.tokensHint !== void 0 ? { tokensHint: turn.tokensHint } : {},
|
|
253
|
+
disclaimer: ANALYSIS_DISCLAIMER
|
|
254
|
+
};
|
|
255
|
+
this.logResult("followup", entry, result, startedAt);
|
|
256
|
+
return result;
|
|
257
|
+
} finally {
|
|
258
|
+
entry.busy = false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Stop and dispose one analysis session (UI stop button). Idempotent: an
|
|
263
|
+
* unknown id resolves as a logged no-op.
|
|
264
|
+
*/
|
|
265
|
+
async cancel(analysisSessionId) {
|
|
266
|
+
const entry = this.active.get(analysisSessionId);
|
|
267
|
+
if (entry === void 0) {
|
|
268
|
+
this.deps.log({
|
|
269
|
+
op: "cancel",
|
|
270
|
+
analysisSessionId,
|
|
271
|
+
found: false
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.active.delete(analysisSessionId);
|
|
276
|
+
this.cancelQuietly(entry);
|
|
277
|
+
await this.disposeQuietly(entry);
|
|
278
|
+
this.deps.log({
|
|
279
|
+
op: "cancel",
|
|
280
|
+
analysisSessionId,
|
|
281
|
+
kind: entry.kind,
|
|
282
|
+
title: entry.title,
|
|
283
|
+
found: true
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
async runTurn(entry, text, deadline) {
|
|
287
|
+
const handle = entry.handle;
|
|
288
|
+
const session = handle.agent.session;
|
|
289
|
+
entry.messageBaseline = session.deriveMessages().length;
|
|
290
|
+
entry.eventBaseline = session.events.length;
|
|
291
|
+
const message = {
|
|
292
|
+
id: `${entry.analysisSessionId}-msg-${++entry.messageSeq}`,
|
|
293
|
+
role: "user",
|
|
294
|
+
content: [{
|
|
295
|
+
type: "text",
|
|
296
|
+
text
|
|
297
|
+
}],
|
|
298
|
+
source: {
|
|
299
|
+
kind: "plugin",
|
|
300
|
+
plugin: this.pluginName
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
try {
|
|
304
|
+
handle.agent.followup(message);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return {
|
|
307
|
+
status: "threw",
|
|
308
|
+
detail: describeError$4(error)
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
let idle;
|
|
312
|
+
try {
|
|
313
|
+
idle = await withTimeout(handle.agent.whenIdle(), deadline - this.now());
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return {
|
|
316
|
+
status: "threw",
|
|
317
|
+
detail: describeError$4(error)
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (idle.timedOut) return { status: "timeout" };
|
|
321
|
+
const summary = extractNewAssistantText(session.deriveMessages(), entry.messageBaseline);
|
|
322
|
+
const tokensHint = extractTokensHint(session.events, entry.eventBaseline);
|
|
323
|
+
entry.messageBaseline = session.deriveMessages().length;
|
|
324
|
+
entry.eventBaseline = session.events.length;
|
|
325
|
+
return {
|
|
326
|
+
status: "completed",
|
|
327
|
+
summary,
|
|
328
|
+
...tokensHint !== void 0 ? { tokensHint } : {}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
buildInitialPrompt(kind, title, bounded) {
|
|
332
|
+
return [
|
|
333
|
+
ANALYSIS_GUIDANCE,
|
|
334
|
+
"",
|
|
335
|
+
`[分析对象 / subject] kind=${kind} title=${title}`,
|
|
336
|
+
"",
|
|
337
|
+
`--- 会话摘要开始 / summary begin (有界输入${bounded.truncated ? ",已截断 / truncated" : ""}) ---`,
|
|
338
|
+
bounded.text,
|
|
339
|
+
"--- 会话摘要结束 / summary end ---"
|
|
340
|
+
].join("\n");
|
|
341
|
+
}
|
|
342
|
+
cancelQuietly(entry) {
|
|
343
|
+
try {
|
|
344
|
+
entry.handle?.agent.cancel({ kind: "user" });
|
|
345
|
+
} catch (error) {
|
|
346
|
+
this.deps.log({
|
|
347
|
+
op: "cancel",
|
|
348
|
+
analysisSessionId: entry.analysisSessionId,
|
|
349
|
+
found: true,
|
|
350
|
+
detail: `cancel threw: ${describeError$4(error)}`
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async disposeQuietly(entry) {
|
|
355
|
+
if (entry.handle === void 0) return;
|
|
356
|
+
try {
|
|
357
|
+
await entry.handle.dispose();
|
|
358
|
+
} catch (error) {
|
|
359
|
+
this.deps.log({
|
|
360
|
+
op: "cancel",
|
|
361
|
+
analysisSessionId: entry.analysisSessionId,
|
|
362
|
+
found: true,
|
|
363
|
+
detail: `dispose threw: ${describeError$4(error)}`
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
mintSessionId() {
|
|
368
|
+
return `${this.pluginName}-analysis-${this.now().toString(36)}-${++this.mintCounter}`;
|
|
369
|
+
}
|
|
370
|
+
failResult(phase, ident, errorCode, opts) {
|
|
371
|
+
const result = {
|
|
372
|
+
outcome: "failed",
|
|
373
|
+
truncated: opts.truncated,
|
|
374
|
+
errorCode,
|
|
375
|
+
...opts.detail !== void 0 ? { detail: opts.detail } : {},
|
|
376
|
+
disclaimer: ANALYSIS_DISCLAIMER
|
|
377
|
+
};
|
|
378
|
+
this.deps.log({
|
|
379
|
+
op: "result",
|
|
380
|
+
phase,
|
|
381
|
+
...ident.analysisSessionId !== void 0 ? { analysisSessionId: ident.analysisSessionId } : {},
|
|
382
|
+
...ident.kind !== void 0 ? { kind: ident.kind } : {},
|
|
383
|
+
...ident.title !== void 0 ? { title: ident.title } : {},
|
|
384
|
+
outcome: "failed",
|
|
385
|
+
errorCode,
|
|
386
|
+
...opts.detail !== void 0 ? { detail: opts.detail } : {},
|
|
387
|
+
elapsedMs: this.now() - opts.startedAt
|
|
388
|
+
});
|
|
389
|
+
return result;
|
|
390
|
+
}
|
|
391
|
+
timeoutResult(phase, entry, truncated, startedAt, analysisSessionId) {
|
|
392
|
+
const result = {
|
|
393
|
+
outcome: "timeout",
|
|
394
|
+
...analysisSessionId !== void 0 ? { analysisSessionId } : {},
|
|
395
|
+
truncated,
|
|
396
|
+
errorCode: "timeout",
|
|
397
|
+
disclaimer: ANALYSIS_DISCLAIMER
|
|
398
|
+
};
|
|
399
|
+
this.deps.log({
|
|
400
|
+
op: "result",
|
|
401
|
+
phase,
|
|
402
|
+
analysisSessionId: entry.analysisSessionId,
|
|
403
|
+
kind: entry.kind,
|
|
404
|
+
title: entry.title,
|
|
405
|
+
outcome: "timeout",
|
|
406
|
+
errorCode: "timeout",
|
|
407
|
+
elapsedMs: this.now() - startedAt
|
|
408
|
+
});
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
logResult(phase, entry, result, startedAt) {
|
|
412
|
+
this.deps.log({
|
|
413
|
+
op: "result",
|
|
414
|
+
phase,
|
|
415
|
+
analysisSessionId: entry.analysisSessionId,
|
|
416
|
+
kind: entry.kind,
|
|
417
|
+
title: entry.title,
|
|
418
|
+
outcome: result.outcome,
|
|
419
|
+
...result.tokensHint !== void 0 ? { tokensHint: result.tokensHint } : {},
|
|
420
|
+
truncated: result.truncated,
|
|
421
|
+
elapsedMs: this.now() - startedAt
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
//#endregion
|
|
426
|
+
//#region src/config.ts
|
|
427
|
+
/**
|
|
428
|
+
* Composition config for the dsh-agent-sidecar host half (design §6, scoped
|
|
429
|
+
* to the task-approved field set). schemastery-validated by the cordis
|
|
430
|
+
* Loader; every field carries a default so a bare patch row (no `config:`
|
|
431
|
+
* block) mounts with zero configuration, and `.description()` strings feed
|
|
432
|
+
* the dsh settings pane renderer.
|
|
433
|
+
*
|
|
434
|
+
* Grouping mirrors the runtime module it feeds:
|
|
435
|
+
* - `daemon` → DaemonSupervisor (src/supervisor.ts)
|
|
436
|
+
* - `sidecar` → CLI/daemon invocation (command + runtime dir redirect)
|
|
437
|
+
* - `stream` → Reconciler cadences (src/bridge.ts)
|
|
438
|
+
* - `inject` → the guard's write gate (src/guard.ts); M2 consumes the rest
|
|
439
|
+
* - `analysis` / `ui` / `skill` → M3/M4 surfaces, contractual today so the
|
|
440
|
+
* config face does not churn per milestone.
|
|
441
|
+
*
|
|
442
|
+
* @module
|
|
443
|
+
*/
|
|
444
|
+
const Config = z.object({
|
|
445
|
+
daemon: z.object({
|
|
446
|
+
policy: z.union([
|
|
447
|
+
z.const("adopt-or-host"),
|
|
448
|
+
z.const("adopt-only"),
|
|
449
|
+
z.const("off")
|
|
450
|
+
]).default("adopt-or-host").description("daemon 托管策略:adopt-or-host=探测并领养既有 daemon,否则自行拉起;adopt-only=只领养绝不拉起;off=不管理 daemon 生命周期(仍只读对账既有 daemon 的数据)"),
|
|
451
|
+
backoffLimit: z.natural().min(1).default(5).description("托管失败熔断阈值:连续失败达到该次数后停止重启并进入 FAILED")
|
|
452
|
+
}).description("daemon 生命周期治理"),
|
|
453
|
+
sidecar: z.object({
|
|
454
|
+
command: z.array(String).default(["agent-sidecar"]).description("sidecar 可执行命令(argv 前缀):PATH 名、绝对路径,或多段命令(如 [\"python3\", \"/path/to/agent-sidecar.pyz\"]);插件绝不代装"),
|
|
455
|
+
runtimeDir: z.string().default("").description("运行时目录:留空用默认 ~/.agent_sidecar(尊重 AGENT_SIDECAR_RUNTIME_DIR 环境变量);非空时经环境变量传给受托管的 daemon")
|
|
456
|
+
}).description("sidecar 调用方式"),
|
|
457
|
+
stream: z.object({
|
|
458
|
+
reconcileActiveMs: z.natural().min(100).default(2e3).description("对账快照周期(有会话工作中,毫秒)"),
|
|
459
|
+
reconcileIdleMs: z.natural().min(100).default(1e4).description("对账快照周期(空闲,毫秒)")
|
|
460
|
+
}).description("数据流对账节奏"),
|
|
461
|
+
inject: z.object({
|
|
462
|
+
enabled: z.boolean().default(false).description("注入总开关:关闭时看板隐藏全部注入入口,写接口在服务端同步拒绝(默认关闭;多用户主机不建议开启)"),
|
|
463
|
+
defaultMode: z.union([z.const("queue"), z.const("steer")]).default("queue").description("注入面板默认模式:queue=排队下一轮,steer=中途注入")
|
|
464
|
+
}).description("消息注入"),
|
|
465
|
+
analysis: z.object({
|
|
466
|
+
enabled: z.boolean().default(false).description("AI 旁路分析开关(M3;消耗模型 token,默认关闭)"),
|
|
467
|
+
provider: z.string().default("").description("分析代理的 provider 路由:留空(默认)复用宿主默认模型(agentDefaultModel 服务);与 model 同时非空才生效"),
|
|
468
|
+
model: z.string().default("").description("分析代理的模型 id:留空(默认)复用宿主默认模型;与 provider 同时非空才生效")
|
|
469
|
+
}).description("旁路分析"),
|
|
470
|
+
ui: z.object({
|
|
471
|
+
timeWindowHours: z.natural().min(1).default(24).description("看板会话时间窗(小时)"),
|
|
472
|
+
showDead: z.boolean().default(false).description("是否显示 dead 会话")
|
|
473
|
+
}).description("看板界面"),
|
|
474
|
+
skill: z.object({ provide: z.boolean().default(true).description("是否经 registerProvider 内嵌提供 agent-sidecar skill(设计 §6 默认开;文件系统已装的同名 skill 自动优先;改动需重载插件生效)") }).description("skill 模式")
|
|
475
|
+
});
|
|
476
|
+
//#endregion
|
|
477
|
+
//#region src/bridge.ts
|
|
478
|
+
/**
|
|
479
|
+
* Sidecar Unix-socket bridge (host half, transport layer only).
|
|
480
|
+
*
|
|
481
|
+
* Pure `node:net`; deliberately free of any cordis/dsh import so the
|
|
482
|
+
* protocol client stays testable in isolation and reusable outside the
|
|
483
|
+
* plugin context.
|
|
484
|
+
*
|
|
485
|
+
* Protocol source of truth (verified against sidecar source, not docs):
|
|
486
|
+
* - Requests are single-line JSON
|
|
487
|
+
* `{"op":"ping"|"status"|"replay"|"subscribe"}` terminated by `\n`
|
|
488
|
+
* (`sidecar/daemon.py` `_handle_client`).
|
|
489
|
+
* - `ping`/`status`/`replay` answer with exactly one JSON line. The official
|
|
490
|
+
* client (`sidecar/client.py`) opens one fresh connection per op and
|
|
491
|
+
* closes it after the response; we mirror that semantic.
|
|
492
|
+
* - `replay {session_id, after_seq, limit}` (T5.2) answers one bounded page
|
|
493
|
+
* `{events, last_seq, truncated, count, agent, ...}` sourced from the
|
|
494
|
+
* session adapter's own transcript replay (daemon `_replay_response`;
|
|
495
|
+
* today only dsh sessions provide one). Unlike ping/status, {@link
|
|
496
|
+
* SidecarSocketClient.replay} REJECTS with a coded
|
|
497
|
+
* {@link SidecarDaemonError} instead of resolving null: the daemon error
|
|
498
|
+
* vocabulary (`unknown_session` / `replay_unsupported` / `replay_failed`
|
|
499
|
+
* / `invalid_request`) must reach the caller verbatim so the fusion
|
|
500
|
+
* layer can degrade honestly (design §4.b.2).
|
|
501
|
+
* - `subscribe` answers with an ack line `{"ok":true,"op":"subscribe"}`
|
|
502
|
+
* and then streams JSONL event objects until either side disconnects
|
|
503
|
+
* (`sidecar/daemon.py` `_serve_subscription`). An optional
|
|
504
|
+
* `{"agents":[...]}` allowlist asks the daemon to stream only those
|
|
505
|
+
* agents' events (server-side filter, daemon `_parse_subscribe_agents`);
|
|
506
|
+
* the ack then echoes the sorted list. The per-subscriber queue is
|
|
507
|
+
* bounded (256, drop-oldest) and drops are NOT signalled on the wire
|
|
508
|
+
* (`sidecar/bus.py`), which is why the stream is a trigger signal only;
|
|
509
|
+
* `status` snapshots remain the source of truth (design §4.b / ADR-2).
|
|
510
|
+
* - Daemon-declared errors arrive as `{"ok":false,"error":{code,message}}`.
|
|
511
|
+
*
|
|
512
|
+
* @module
|
|
513
|
+
*/
|
|
514
|
+
/**
|
|
515
|
+
* Coded failure of a request/response op. `code` carries the daemon error
|
|
516
|
+
* vocabulary verbatim (`invalid_request`, `unknown_session`,
|
|
517
|
+
* `replay_unsupported`, `replay_failed`, ...) or one of the client-side
|
|
518
|
+
* transport codes: `timeout`, `connection_failed`, `connection_closed`,
|
|
519
|
+
* `invalid_response`.
|
|
520
|
+
*/
|
|
521
|
+
var SidecarDaemonError = class extends Error {
|
|
522
|
+
code;
|
|
523
|
+
constructor(code, detail) {
|
|
524
|
+
super(`${code}: ${detail}`);
|
|
525
|
+
this.name = "SidecarDaemonError";
|
|
526
|
+
this.code = code;
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
function isRecord(value) {
|
|
530
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
531
|
+
}
|
|
532
|
+
function parseHttpPingInfo(value) {
|
|
533
|
+
if (value === null || value === void 0) return { enabled: false };
|
|
534
|
+
if (!isRecord(value) || typeof value["enabled"] !== "boolean") return null;
|
|
535
|
+
if (value["enabled"] === false) return { enabled: false };
|
|
536
|
+
const host = value["host"];
|
|
537
|
+
const port = value["port"];
|
|
538
|
+
if (typeof host !== "string" || host === "") return null;
|
|
539
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
540
|
+
return {
|
|
541
|
+
enabled: true,
|
|
542
|
+
host,
|
|
543
|
+
port
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
function parsePingInfo(value) {
|
|
547
|
+
if (!isRecord(value) || value["ok"] !== true || value["op"] !== "ping") return null;
|
|
548
|
+
const pid = value["pid"];
|
|
549
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return null;
|
|
550
|
+
const rawVersion = value["version"];
|
|
551
|
+
let version;
|
|
552
|
+
if (rawVersion === null || rawVersion === void 0) version = "";
|
|
553
|
+
else if (typeof rawVersion === "string") version = rawVersion;
|
|
554
|
+
else return null;
|
|
555
|
+
const http = parseHttpPingInfo(value["http"]);
|
|
556
|
+
if (http === null) return null;
|
|
557
|
+
return {
|
|
558
|
+
pid,
|
|
559
|
+
version,
|
|
560
|
+
http
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Normalize one raw status row. Rows without a usable `session_id` are
|
|
565
|
+
* skipped by the caller (the daemon model guarantees the field, so this
|
|
566
|
+
* only defends against wire corruption).
|
|
567
|
+
*/
|
|
568
|
+
function parseSessionRow(value) {
|
|
569
|
+
if (!isRecord(value)) return null;
|
|
570
|
+
const sessionId = value["session_id"];
|
|
571
|
+
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
572
|
+
const updatedAt = value["updated_at"];
|
|
573
|
+
return {
|
|
574
|
+
agent: typeof value["agent"] === "string" ? value["agent"] : "",
|
|
575
|
+
session_id: sessionId,
|
|
576
|
+
project: typeof value["project"] === "string" ? value["project"] : "",
|
|
577
|
+
transcript: typeof value["transcript"] === "string" ? value["transcript"] : "",
|
|
578
|
+
updated_at: typeof updatedAt === "number" && Number.isFinite(updatedAt) ? updatedAt : 0,
|
|
579
|
+
title: typeof value["title"] === "string" ? value["title"] : "",
|
|
580
|
+
status: typeof value["status"] === "string" ? value["status"] : "idle",
|
|
581
|
+
extra: isRecord(value["extra"]) ? value["extra"] : {},
|
|
582
|
+
parent_id: typeof value["parent_id"] === "string" ? value["parent_id"] : null
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
function parseRecordList(value) {
|
|
586
|
+
if (value === void 0) return [];
|
|
587
|
+
if (!Array.isArray(value)) return null;
|
|
588
|
+
const out = [];
|
|
589
|
+
for (const item of value) {
|
|
590
|
+
if (!isRecord(item)) return null;
|
|
591
|
+
out.push(item);
|
|
592
|
+
}
|
|
593
|
+
return out;
|
|
594
|
+
}
|
|
595
|
+
function parseStatusSnapshot(value) {
|
|
596
|
+
if (!isRecord(value) || value["ok"] !== true) return null;
|
|
597
|
+
const rawSessions = value["sessions"];
|
|
598
|
+
if (!Array.isArray(rawSessions)) return null;
|
|
599
|
+
const sessions = [];
|
|
600
|
+
for (const raw of rawSessions) {
|
|
601
|
+
if (!isRecord(raw)) return null;
|
|
602
|
+
const row = parseSessionRow(raw);
|
|
603
|
+
if (row !== null) sessions.push(row);
|
|
604
|
+
}
|
|
605
|
+
const scanErrors = parseRecordList(value["scan_errors"]);
|
|
606
|
+
const tailErrors = parseRecordList(value["tail_errors"]);
|
|
607
|
+
if (scanErrors === null || tailErrors === null) return null;
|
|
608
|
+
return {
|
|
609
|
+
sessions,
|
|
610
|
+
scanErrors,
|
|
611
|
+
tailErrors,
|
|
612
|
+
diagnostics: parseRecordList(value["diagnostics"]) ?? []
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
function parseEvent(value) {
|
|
616
|
+
const ts = value["ts"];
|
|
617
|
+
const agent = value["agent"];
|
|
618
|
+
const sessionId = value["session_id"];
|
|
619
|
+
const kind = value["kind"];
|
|
620
|
+
const text = value["text"];
|
|
621
|
+
if (typeof ts !== "string" || typeof agent !== "string" || typeof sessionId !== "string" || typeof kind !== "string" || typeof text !== "string") return null;
|
|
622
|
+
return {
|
|
623
|
+
ts,
|
|
624
|
+
agent,
|
|
625
|
+
session_id: sessionId,
|
|
626
|
+
kind,
|
|
627
|
+
text,
|
|
628
|
+
extra: isRecord(value["extra"]) ? value["extra"] : {}
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function daemonError(value) {
|
|
632
|
+
const error = value["error"];
|
|
633
|
+
if (isRecord(error)) {
|
|
634
|
+
const code = String(error["code"] ?? "daemon_error");
|
|
635
|
+
return new SidecarDaemonError(code, String(error["message"] ?? code));
|
|
636
|
+
}
|
|
637
|
+
return new SidecarDaemonError("daemon_error", String(error ?? "daemon_error"));
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Parse one `replay` response page. Mirrors sidecar/client.py strictness:
|
|
641
|
+
* a non-object entry in `events` invalidates the whole response, while an
|
|
642
|
+
* object entry missing normalized fields is skipped defensively (the
|
|
643
|
+
* daemon model guarantees them).
|
|
644
|
+
*/
|
|
645
|
+
function parseReplayPage(value) {
|
|
646
|
+
if (!isRecord(value) || value["ok"] !== true || value["op"] !== "replay") return null;
|
|
647
|
+
const sessionId = value["session_id"];
|
|
648
|
+
const agent = value["agent"];
|
|
649
|
+
const rawEvents = value["events"];
|
|
650
|
+
if (typeof sessionId !== "string" || typeof agent !== "string") return null;
|
|
651
|
+
if (!Array.isArray(rawEvents)) return null;
|
|
652
|
+
const events = [];
|
|
653
|
+
for (const raw of rawEvents) {
|
|
654
|
+
if (!isRecord(raw)) return null;
|
|
655
|
+
const event = parseEvent(raw);
|
|
656
|
+
if (event !== null) events.push(event);
|
|
657
|
+
}
|
|
658
|
+
const afterSeq = value["after_seq"];
|
|
659
|
+
const count = value["count"];
|
|
660
|
+
const lastSeq = value["last_seq"];
|
|
661
|
+
return {
|
|
662
|
+
sessionId,
|
|
663
|
+
agent,
|
|
664
|
+
afterSeq: typeof afterSeq === "number" && Number.isInteger(afterSeq) && afterSeq >= 0 ? afterSeq : 0,
|
|
665
|
+
events,
|
|
666
|
+
count: typeof count === "number" && Number.isInteger(count) ? count : events.length,
|
|
667
|
+
lastSeq: typeof lastSeq === "number" && Number.isInteger(lastSeq) ? lastSeq : null,
|
|
668
|
+
truncated: value["truncated"] === true
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Build the subscribe request line, validating an optional agents filter
|
|
673
|
+
* up front (before any socket exists) so misuse throws synchronously.
|
|
674
|
+
*/
|
|
675
|
+
function buildSubscribeRequest(agents) {
|
|
676
|
+
if (agents === void 0) return "{\"op\":\"subscribe\"}\n";
|
|
677
|
+
if (agents.length === 0 || agents.some((name) => typeof name !== "string" || name === "")) throw new RangeError("agents must be a nonempty list of nonempty agent names");
|
|
678
|
+
return `${JSON.stringify({
|
|
679
|
+
op: "subscribe",
|
|
680
|
+
agents
|
|
681
|
+
})}\n`;
|
|
682
|
+
}
|
|
683
|
+
const NEWLINE = 10;
|
|
684
|
+
const EMPTY = Buffer.alloc(0);
|
|
685
|
+
/**
|
|
686
|
+
* Splits a byte stream into newline-terminated lines with a hard size
|
|
687
|
+
* bound. An over-long line is discarded (signalled once via `onOverflow`)
|
|
688
|
+
* and the splitter resynchronizes at the next newline, so one oversized
|
|
689
|
+
* record cannot take down the whole stream or balloon memory.
|
|
690
|
+
*/
|
|
691
|
+
var LineBuffer = class {
|
|
692
|
+
maxBytes;
|
|
693
|
+
onLine;
|
|
694
|
+
onOverflow;
|
|
695
|
+
pending = EMPTY;
|
|
696
|
+
dropping = false;
|
|
697
|
+
constructor(maxBytes, onLine, onOverflow) {
|
|
698
|
+
this.maxBytes = maxBytes;
|
|
699
|
+
this.onLine = onLine;
|
|
700
|
+
this.onOverflow = onOverflow;
|
|
701
|
+
}
|
|
702
|
+
push(chunk) {
|
|
703
|
+
this.pending = this.pending.length === 0 ? chunk : Buffer.concat([this.pending, chunk]);
|
|
704
|
+
for (;;) {
|
|
705
|
+
const idx = this.pending.indexOf(NEWLINE);
|
|
706
|
+
if (idx < 0) {
|
|
707
|
+
if (this.pending.length > this.maxBytes) {
|
|
708
|
+
this.pending = EMPTY;
|
|
709
|
+
if (!this.dropping) {
|
|
710
|
+
this.dropping = true;
|
|
711
|
+
this.onOverflow();
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const line = this.pending.subarray(0, idx);
|
|
717
|
+
this.pending = this.pending.subarray(idx + 1);
|
|
718
|
+
if (this.dropping) {
|
|
719
|
+
this.dropping = false;
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (line.length > this.maxBytes) {
|
|
723
|
+
this.onOverflow();
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
this.onLine(line);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
/**
|
|
731
|
+
* Minimal daemon client: one fresh connection per op (matching the
|
|
732
|
+
* semantics of `sidecar/client.py`), single-line JSON requests, JSONL
|
|
733
|
+
* responses, bounded reads. All request/response failures resolve to
|
|
734
|
+
* `null` instead of throwing — the caller (Reconciler/Supervisor) owns
|
|
735
|
+
* the health policy.
|
|
736
|
+
*/
|
|
737
|
+
var SidecarSocketClient = class {
|
|
738
|
+
socketPath;
|
|
739
|
+
timeoutMs;
|
|
740
|
+
replayTimeoutMs;
|
|
741
|
+
maxLineBytes;
|
|
742
|
+
constructor(opts) {
|
|
743
|
+
this.socketPath = opts.socketPath;
|
|
744
|
+
this.timeoutMs = opts.timeoutMs ?? 1e3;
|
|
745
|
+
this.replayTimeoutMs = opts.replayTimeoutMs ?? 15e3;
|
|
746
|
+
this.maxLineBytes = opts.maxLineBytes ?? 33554432;
|
|
747
|
+
if (this.timeoutMs <= 0 || this.replayTimeoutMs <= 0 || this.maxLineBytes <= 0) throw new RangeError("client bounds are invalid");
|
|
748
|
+
}
|
|
749
|
+
/** `ping` op; `null` on refusal, timeout, or an invalid/error response. */
|
|
750
|
+
async ping() {
|
|
751
|
+
return parsePingInfo(await this.requestLine("ping"));
|
|
752
|
+
}
|
|
753
|
+
/** `status` op; `null` on refusal, timeout, or an invalid/error response. */
|
|
754
|
+
async status() {
|
|
755
|
+
return parseStatusSnapshot(await this.requestLine("status"));
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* `replay` op (T5.2): one bounded page of normalized historical events
|
|
759
|
+
* after `afterSeq`. Unlike ping/status this REJECTS with a coded
|
|
760
|
+
* {@link SidecarDaemonError} — daemon codes pass through verbatim and
|
|
761
|
+
* transport failures get client codes — because the caller (FusionQuery
|
|
762
|
+
* seam) distinguishes degradation reasons instead of polling health.
|
|
763
|
+
* Local misuse throws a RangeError (mirrors sidecar/client.py's
|
|
764
|
+
* ValueError). `limit` is forwarded as-is; the daemon enforces its own
|
|
765
|
+
* 1..1024 bound and answers `invalid_request` beyond it.
|
|
766
|
+
*/
|
|
767
|
+
async replay(sessionId, afterSeq = 0, limit) {
|
|
768
|
+
if (typeof sessionId !== "string" || sessionId === "") throw new RangeError("sessionId must be a nonempty string");
|
|
769
|
+
if (!Number.isInteger(afterSeq) || afterSeq < 0) throw new RangeError("afterSeq must be a nonnegative integer");
|
|
770
|
+
if (limit !== void 0 && (!Number.isInteger(limit) || limit <= 0)) throw new RangeError("limit must be a positive integer");
|
|
771
|
+
const payload = {
|
|
772
|
+
op: "replay",
|
|
773
|
+
session_id: sessionId,
|
|
774
|
+
after_seq: afterSeq
|
|
775
|
+
};
|
|
776
|
+
if (limit !== void 0) payload["limit"] = limit;
|
|
777
|
+
const value = await this.requestObject(payload, this.replayTimeoutMs);
|
|
778
|
+
if (isRecord(value) && value["ok"] === false) throw daemonError(value);
|
|
779
|
+
const page = parseReplayPage(value);
|
|
780
|
+
if (page === null) throw new SidecarDaemonError("invalid_response", "daemon replay response has no valid events list");
|
|
781
|
+
return page;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Open a subscribe stream: write the op, validate the ack, then deliver
|
|
785
|
+
* each JSONL event through `handlers.onEvent`. After the ack the
|
|
786
|
+
* connection may idle indefinitely (no timeout), matching
|
|
787
|
+
* sidecar/client.py which disables its socket timeout post-handshake.
|
|
788
|
+
* An `opts.agents` allowlist becomes the daemon-side stream filter.
|
|
789
|
+
*/
|
|
790
|
+
subscribe(handlers, opts = {}) {
|
|
791
|
+
const request = buildSubscribeRequest(opts.agents);
|
|
792
|
+
let closed = false;
|
|
793
|
+
let ready = false;
|
|
794
|
+
const socket = createConnection({ path: this.socketPath });
|
|
795
|
+
const finish = (err) => {
|
|
796
|
+
if (closed) return;
|
|
797
|
+
closed = true;
|
|
798
|
+
socket.destroy();
|
|
799
|
+
queueMicrotask(() => handlers.onClose?.(err));
|
|
800
|
+
};
|
|
801
|
+
const lines = new LineBuffer(this.maxLineBytes, (line) => {
|
|
802
|
+
if (closed) return;
|
|
803
|
+
let value;
|
|
804
|
+
try {
|
|
805
|
+
value = JSON.parse(line.toString("utf8"));
|
|
806
|
+
} catch {
|
|
807
|
+
if (!ready) {
|
|
808
|
+
finish(/* @__PURE__ */ new Error("daemon returned an invalid subscribe acknowledgement"));
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
handlers.onDrop?.("invalid_json");
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (!ready) {
|
|
815
|
+
if (isRecord(value) && value["ok"] === true && value["op"] === "subscribe" && !("error" in value)) {
|
|
816
|
+
ready = true;
|
|
817
|
+
socket.setTimeout(0);
|
|
818
|
+
handlers.onReady?.();
|
|
819
|
+
} else if (isRecord(value) && value["ok"] === false) finish(daemonError(value));
|
|
820
|
+
else finish(/* @__PURE__ */ new Error("daemon returned an invalid subscribe acknowledgement"));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (!isRecord(value)) {
|
|
824
|
+
handlers.onDrop?.("invalid_event");
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (value["ok"] === false) {
|
|
828
|
+
finish(daemonError(value));
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const event = parseEvent(value);
|
|
832
|
+
if (event === null) {
|
|
833
|
+
handlers.onDrop?.("invalid_event");
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
handlers.onEvent(event);
|
|
837
|
+
}, () => {
|
|
838
|
+
if (!closed) handlers.onDrop?.("line_too_long");
|
|
839
|
+
});
|
|
840
|
+
socket.setTimeout(this.timeoutMs);
|
|
841
|
+
socket.once("timeout", () => {
|
|
842
|
+
if (!ready) finish(/* @__PURE__ */ new Error("subscribe handshake timed out"));
|
|
843
|
+
});
|
|
844
|
+
socket.once("error", (err) => finish(err));
|
|
845
|
+
socket.once("close", () => finish());
|
|
846
|
+
socket.once("connect", () => {
|
|
847
|
+
socket.write(request);
|
|
848
|
+
});
|
|
849
|
+
socket.on("data", (chunk) => lines.push(chunk));
|
|
850
|
+
return { close: () => finish() };
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Send one single-line JSON request and read one JSONL response line,
|
|
854
|
+
* REJECTING with a coded {@link SidecarDaemonError} on every transport
|
|
855
|
+
* failure (the replay path needs error provenance, not just null).
|
|
856
|
+
*/
|
|
857
|
+
requestObject(payload, timeoutMs) {
|
|
858
|
+
return new Promise((resolve, reject) => {
|
|
859
|
+
let settled = false;
|
|
860
|
+
const socket = createConnection({ path: this.socketPath });
|
|
861
|
+
const finish = (settle) => {
|
|
862
|
+
if (settled) return;
|
|
863
|
+
settled = true;
|
|
864
|
+
socket.destroy();
|
|
865
|
+
settle();
|
|
866
|
+
};
|
|
867
|
+
const fail = (code, detail) => {
|
|
868
|
+
finish(() => reject(new SidecarDaemonError(code, detail)));
|
|
869
|
+
};
|
|
870
|
+
const lines = new LineBuffer(this.maxLineBytes, (line) => {
|
|
871
|
+
let value;
|
|
872
|
+
try {
|
|
873
|
+
value = JSON.parse(line.toString("utf8"));
|
|
874
|
+
} catch {
|
|
875
|
+
fail("invalid_response", "daemon returned an unparsable response line");
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
finish(() => resolve(value));
|
|
879
|
+
}, () => fail("invalid_response", "daemon response line exceeded the size bound"));
|
|
880
|
+
socket.setTimeout(timeoutMs);
|
|
881
|
+
socket.once("timeout", () => fail("timeout", "daemon did not answer within the bound"));
|
|
882
|
+
socket.once("error", (err) => fail("connection_failed", err.message));
|
|
883
|
+
socket.once("close", () => fail("connection_closed", "connection closed before a response line"));
|
|
884
|
+
socket.once("connect", () => {
|
|
885
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
886
|
+
});
|
|
887
|
+
socket.on("data", (chunk) => lines.push(chunk));
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
/** Send one single-line JSON request and read one JSONL response line. */
|
|
891
|
+
requestLine(op) {
|
|
892
|
+
return new Promise((resolve) => {
|
|
893
|
+
let settled = false;
|
|
894
|
+
const socket = createConnection({ path: this.socketPath });
|
|
895
|
+
const finish = (value) => {
|
|
896
|
+
if (settled) return;
|
|
897
|
+
settled = true;
|
|
898
|
+
socket.destroy();
|
|
899
|
+
resolve(value);
|
|
900
|
+
};
|
|
901
|
+
const lines = new LineBuffer(this.maxLineBytes, (line) => {
|
|
902
|
+
let value;
|
|
903
|
+
try {
|
|
904
|
+
value = JSON.parse(line.toString("utf8"));
|
|
905
|
+
} catch {
|
|
906
|
+
value = null;
|
|
907
|
+
}
|
|
908
|
+
finish(value);
|
|
909
|
+
}, () => finish(null));
|
|
910
|
+
socket.setTimeout(this.timeoutMs);
|
|
911
|
+
socket.once("timeout", () => finish(null));
|
|
912
|
+
socket.once("error", () => finish(null));
|
|
913
|
+
socket.once("close", () => finish(null));
|
|
914
|
+
socket.once("connect", () => {
|
|
915
|
+
socket.write(JSON.stringify({ op }) + "\n");
|
|
916
|
+
});
|
|
917
|
+
socket.on("data", (chunk) => lines.push(chunk));
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
/**
|
|
922
|
+
* Dual-cadence status reconciliation plus subscribe-stream supervision:
|
|
923
|
+
* - `status` snapshots run on an active (any working session) or idle
|
|
924
|
+
* cadence and are applied as the authoritative full state.
|
|
925
|
+
* - each subscribe event is folded into the store as a hint and schedules
|
|
926
|
+
* one debounced early reconcile.
|
|
927
|
+
* - a FAILED snapshot (daemon absent or not yet ready) retries on a short
|
|
928
|
+
* backoff (250ms doubling, capped at the current cadence) instead of
|
|
929
|
+
* sleeping a whole cadence period — a cold start where the very first
|
|
930
|
+
* `status` races the daemon socket must not cost a full `idleMs`
|
|
931
|
+
* (M1 acceptance ②). A success resets the streak to the steady cadence.
|
|
932
|
+
* - `reconcileNow()` is public so the supervisor can hand off "daemon just
|
|
933
|
+
* became reachable" (ADOPTED/HOSTED are ping-gated) as one immediate
|
|
934
|
+
* reconcile.
|
|
935
|
+
* - a dropped stream marks `streamHealth=degraded` and reconnects with
|
|
936
|
+
* bounded exponential backoff (1s doubling to a 30s cap, retrying
|
|
937
|
+
* forever); a validated ack restores `streamHealth=ok` and resets the
|
|
938
|
+
* backoff.
|
|
939
|
+
*/
|
|
940
|
+
var Reconciler = class {
|
|
941
|
+
client;
|
|
942
|
+
store;
|
|
943
|
+
activeMs;
|
|
944
|
+
idleMs;
|
|
945
|
+
debounceMs;
|
|
946
|
+
reconnectMinMs;
|
|
947
|
+
reconnectMaxMs;
|
|
948
|
+
failureBackoffMs;
|
|
949
|
+
running = false;
|
|
950
|
+
backoffMs;
|
|
951
|
+
/** Consecutive failed reconciles; drives the short retry backoff. */
|
|
952
|
+
failStreak = 0;
|
|
953
|
+
pollTimer = null;
|
|
954
|
+
kickTimer = null;
|
|
955
|
+
reconnectTimer = null;
|
|
956
|
+
subscription = null;
|
|
957
|
+
reconcileInFlight = false;
|
|
958
|
+
reconcileQueued = false;
|
|
959
|
+
constructor(client, store, opts = {}) {
|
|
960
|
+
this.client = client;
|
|
961
|
+
this.store = store;
|
|
962
|
+
this.activeMs = opts.activeMs ?? 2e3;
|
|
963
|
+
this.idleMs = opts.idleMs ?? 1e4;
|
|
964
|
+
this.debounceMs = opts.debounceMs ?? 200;
|
|
965
|
+
this.reconnectMinMs = opts.reconnectMinMs ?? 1e3;
|
|
966
|
+
this.reconnectMaxMs = opts.reconnectMaxMs ?? 3e4;
|
|
967
|
+
this.failureBackoffMs = opts.failureBackoffMs ?? 250;
|
|
968
|
+
this.backoffMs = this.reconnectMinMs;
|
|
969
|
+
}
|
|
970
|
+
start() {
|
|
971
|
+
if (this.running) return;
|
|
972
|
+
this.running = true;
|
|
973
|
+
this.backoffMs = this.reconnectMinMs;
|
|
974
|
+
this.failStreak = 0;
|
|
975
|
+
this.openSubscription();
|
|
976
|
+
this.reconcileNow();
|
|
977
|
+
}
|
|
978
|
+
stop() {
|
|
979
|
+
if (!this.running) return;
|
|
980
|
+
this.running = false;
|
|
981
|
+
if (this.pollTimer !== null) clearTimeout(this.pollTimer);
|
|
982
|
+
if (this.kickTimer !== null) clearTimeout(this.kickTimer);
|
|
983
|
+
if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer);
|
|
984
|
+
this.pollTimer = null;
|
|
985
|
+
this.kickTimer = null;
|
|
986
|
+
this.reconnectTimer = null;
|
|
987
|
+
const subscription = this.subscription;
|
|
988
|
+
this.subscription = null;
|
|
989
|
+
subscription?.close();
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Run one immediate `status` reconcile and reschedule the next poll from
|
|
993
|
+
* its outcome. Public as the supervisor hand-off seam: the plugin entry
|
|
994
|
+
* calls this on the ADOPTED/HOSTED transition (both are gated on a
|
|
995
|
+
* successful ping, so the socket is known-reachable at that moment).
|
|
996
|
+
* Coalesces with an in-flight reconcile; a no-op when stopped.
|
|
997
|
+
*/
|
|
998
|
+
async reconcileNow() {
|
|
999
|
+
if (!this.running) return;
|
|
1000
|
+
if (this.reconcileInFlight) {
|
|
1001
|
+
this.reconcileQueued = true;
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
this.reconcileInFlight = true;
|
|
1005
|
+
try {
|
|
1006
|
+
const snapshot = await this.client.status();
|
|
1007
|
+
if (snapshot === null) this.failStreak += 1;
|
|
1008
|
+
else {
|
|
1009
|
+
this.failStreak = 0;
|
|
1010
|
+
if (this.running) this.store.applySnapshot(snapshot.sessions);
|
|
1011
|
+
}
|
|
1012
|
+
} finally {
|
|
1013
|
+
this.reconcileInFlight = false;
|
|
1014
|
+
}
|
|
1015
|
+
if (!this.running) return;
|
|
1016
|
+
if (this.reconcileQueued) {
|
|
1017
|
+
this.reconcileQueued = false;
|
|
1018
|
+
this.reconcileNow();
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
this.scheduleNext();
|
|
1022
|
+
}
|
|
1023
|
+
scheduleNext() {
|
|
1024
|
+
if (this.pollTimer !== null) clearTimeout(this.pollTimer);
|
|
1025
|
+
const cadence = this.store.hasWorkingSessions() ? this.activeMs : this.idleMs;
|
|
1026
|
+
const delay = this.failStreak > 0 ? Math.min(this.failureBackoffMs * 2 ** (this.failStreak - 1), cadence) : cadence;
|
|
1027
|
+
this.pollTimer = setTimeout(() => {
|
|
1028
|
+
this.pollTimer = null;
|
|
1029
|
+
this.reconcileNow();
|
|
1030
|
+
}, delay);
|
|
1031
|
+
}
|
|
1032
|
+
/** Schedule one debounced early reconcile (subscribe events are hints). */
|
|
1033
|
+
kick() {
|
|
1034
|
+
if (!this.running || this.kickTimer !== null) return;
|
|
1035
|
+
this.kickTimer = setTimeout(() => {
|
|
1036
|
+
this.kickTimer = null;
|
|
1037
|
+
this.reconcileNow();
|
|
1038
|
+
}, this.debounceMs);
|
|
1039
|
+
}
|
|
1040
|
+
openSubscription() {
|
|
1041
|
+
if (!this.running) return;
|
|
1042
|
+
this.subscription = this.client.subscribe({
|
|
1043
|
+
onReady: () => {
|
|
1044
|
+
if (!this.running) return;
|
|
1045
|
+
this.backoffMs = this.reconnectMinMs;
|
|
1046
|
+
this.store.setStreamHealth("ok");
|
|
1047
|
+
},
|
|
1048
|
+
onEvent: (ev) => {
|
|
1049
|
+
if (!this.running) return;
|
|
1050
|
+
this.store.applyEvent(ev);
|
|
1051
|
+
this.kick();
|
|
1052
|
+
},
|
|
1053
|
+
onDrop: () => {
|
|
1054
|
+
this.kick();
|
|
1055
|
+
},
|
|
1056
|
+
onClose: () => {
|
|
1057
|
+
this.subscription = null;
|
|
1058
|
+
if (!this.running) return;
|
|
1059
|
+
this.store.setStreamHealth("degraded");
|
|
1060
|
+
const delay = this.backoffMs;
|
|
1061
|
+
this.backoffMs = Math.min(this.backoffMs * 2, this.reconnectMaxMs);
|
|
1062
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1063
|
+
this.reconnectTimer = null;
|
|
1064
|
+
this.openSubscription();
|
|
1065
|
+
}, delay);
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
/** Sha256 hex prefix length recorded in logs (matches inject-gateway). */
|
|
1071
|
+
const SHA_LOG_CHARS$1 = 12;
|
|
1072
|
+
function describeError$3(error) {
|
|
1073
|
+
return error instanceof Error ? error.message : String(error);
|
|
1074
|
+
}
|
|
1075
|
+
/** Race a resume against the bounded wait; null = timed out (still pending). */
|
|
1076
|
+
async function resumeWithin(resume, timeoutMs) {
|
|
1077
|
+
let timer;
|
|
1078
|
+
try {
|
|
1079
|
+
return await Promise.race([resume, new Promise((resolve) => {
|
|
1080
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
1081
|
+
})]);
|
|
1082
|
+
} finally {
|
|
1083
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
function createDshInjectExecutor(deps) {
|
|
1087
|
+
const pluginName = deps.pluginName ?? "agent-sidecar";
|
|
1088
|
+
const log = deps.log ?? (() => {});
|
|
1089
|
+
const resumeTimeoutMs = deps.resumeTimeoutMs ?? 3e4;
|
|
1090
|
+
return {
|
|
1091
|
+
kind: "dsh",
|
|
1092
|
+
async execute(req) {
|
|
1093
|
+
const sessionId = req.target.sessionId;
|
|
1094
|
+
const messageBytes = Buffer.byteLength(req.message, "utf8");
|
|
1095
|
+
const messageSha12 = createHash("sha256").update(req.message, "utf8").digest("hex").slice(0, SHA_LOG_CHARS$1);
|
|
1096
|
+
const baseMeta = {
|
|
1097
|
+
requestId: req.requestId,
|
|
1098
|
+
sessionId,
|
|
1099
|
+
mode: req.mode,
|
|
1100
|
+
messageBytes,
|
|
1101
|
+
messageSha12
|
|
1102
|
+
};
|
|
1103
|
+
let agent = deps.agents.get(sessionId);
|
|
1104
|
+
const resumed = agent === void 0;
|
|
1105
|
+
if (agent === void 0) {
|
|
1106
|
+
log("debug", "dsh session not loaded; resuming", baseMeta);
|
|
1107
|
+
let handle;
|
|
1108
|
+
try {
|
|
1109
|
+
handle = await resumeWithin(deps.agents.resume({ resumeSessionId: sessionId }), resumeTimeoutMs);
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
const detail = describeError$3(error);
|
|
1112
|
+
log("warn", "dsh resume failed", {
|
|
1113
|
+
...baseMeta,
|
|
1114
|
+
error: detail
|
|
1115
|
+
});
|
|
1116
|
+
return {
|
|
1117
|
+
outcome: "failed",
|
|
1118
|
+
errorCode: "session_not_found",
|
|
1119
|
+
detail
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
if (handle === null) {
|
|
1123
|
+
log("warn", "dsh resume timed out", {
|
|
1124
|
+
...baseMeta,
|
|
1125
|
+
resumeTimeoutMs
|
|
1126
|
+
});
|
|
1127
|
+
return {
|
|
1128
|
+
outcome: "failed",
|
|
1129
|
+
errorCode: "timeout",
|
|
1130
|
+
detail: `resume did not settle within ${resumeTimeoutMs}ms`
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
agent = handle.agent;
|
|
1134
|
+
}
|
|
1135
|
+
const message = {
|
|
1136
|
+
id: `${pluginName}-${req.requestId}`,
|
|
1137
|
+
role: "user",
|
|
1138
|
+
content: [{
|
|
1139
|
+
type: "text",
|
|
1140
|
+
text: req.message
|
|
1141
|
+
}],
|
|
1142
|
+
source: {
|
|
1143
|
+
kind: "plugin",
|
|
1144
|
+
plugin: pluginName
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
try {
|
|
1148
|
+
if (req.mode === "steer") agent.steer(message);
|
|
1149
|
+
else agent.followup(message);
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
const detail = describeError$3(error);
|
|
1152
|
+
log("warn", "dsh injection call threw", {
|
|
1153
|
+
...baseMeta,
|
|
1154
|
+
resumed,
|
|
1155
|
+
error: detail
|
|
1156
|
+
});
|
|
1157
|
+
return {
|
|
1158
|
+
outcome: "failed",
|
|
1159
|
+
errorCode: "executor_error",
|
|
1160
|
+
detail
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
log("info", "dsh injection delivered", {
|
|
1164
|
+
...baseMeta,
|
|
1165
|
+
resumed
|
|
1166
|
+
});
|
|
1167
|
+
return { outcome: "delivered" };
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
const DSH_AGENT = "dsh";
|
|
1172
|
+
const KEY_SEP = "\0";
|
|
1173
|
+
function integerOrNull(value) {
|
|
1174
|
+
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
|
1175
|
+
}
|
|
1176
|
+
function secondsToMs(seconds) {
|
|
1177
|
+
return typeof seconds === "number" && Number.isFinite(seconds) ? Math.round(seconds * 1e3) : 0;
|
|
1178
|
+
}
|
|
1179
|
+
function parseTs(ts) {
|
|
1180
|
+
const ms = Date.parse(ts);
|
|
1181
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
1182
|
+
}
|
|
1183
|
+
/** Latest-wins `session/title` payload fold (`{title: string}`). */
|
|
1184
|
+
function extractTitle(data) {
|
|
1185
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
|
|
1186
|
+
const title = data["title"];
|
|
1187
|
+
return typeof title === "string" && title !== "" ? title : null;
|
|
1188
|
+
}
|
|
1189
|
+
/** Correlation-key normalization: strip trailing slashes (keep root `/`). */
|
|
1190
|
+
function normalizeProject(project) {
|
|
1191
|
+
if (project.length > 1 && project.endsWith("/")) {
|
|
1192
|
+
const stripped = project.replace(/\/+$/, "");
|
|
1193
|
+
return stripped === "" ? "/" : stripped;
|
|
1194
|
+
}
|
|
1195
|
+
return project;
|
|
1196
|
+
}
|
|
1197
|
+
function describeError$2(error) {
|
|
1198
|
+
return error instanceof Error ? error.message : String(error);
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Dedup identity of one sidecar event within a single session's timeline.
|
|
1202
|
+
* The dsh adapter legally normalizes ONE dsh record into SEVERAL events
|
|
1203
|
+
* sharing the same `extra.seq` (reasoning+text blocks of an assistant
|
|
1204
|
+
* message, multi-block user messages, spliced inbox inserts —
|
|
1205
|
+
* sidecar/adapters/dsh.py content_block_events), and no per-block ordinal
|
|
1206
|
+
* exists on the wire — so seq alone would silently drop sibling events.
|
|
1207
|
+
* The identity is therefore `seq+kind+text`: the same underlying event
|
|
1208
|
+
* seen through both replay and the ring still collapses (identical
|
|
1209
|
+
* normalized kind/text), while same-seq siblings stay distinct. Must stay
|
|
1210
|
+
* in sync with the client mirror (client/detail/logic.ts `entryKey`).
|
|
1211
|
+
*/
|
|
1212
|
+
function sidecarEventKey(ev) {
|
|
1213
|
+
const seq = integerOrNull(ev.extra?.["seq"]);
|
|
1214
|
+
return seq !== null ? `s:${seq}${KEY_SEP}${ev.kind}${KEY_SEP}${ev.text}` : `t:${ev.ts}${KEY_SEP}${ev.kind}${KEY_SEP}${ev.text}`;
|
|
1215
|
+
}
|
|
1216
|
+
/** Strictly-older-than-cursor predicate over the merged ascending order. */
|
|
1217
|
+
function isBeforeCursor(entry, cursor) {
|
|
1218
|
+
if (cursor.seq !== null && entry.seq !== null) return entry.seq < cursor.seq;
|
|
1219
|
+
return entry.ts < cursor.ts;
|
|
1220
|
+
}
|
|
1221
|
+
/** One seq-carrying sidecar event as its own timeline entry. */
|
|
1222
|
+
function sidecarSeqEntry(seq, ev) {
|
|
1223
|
+
return {
|
|
1224
|
+
origin: "sidecar",
|
|
1225
|
+
seq,
|
|
1226
|
+
ts: parseTs(ev.ts),
|
|
1227
|
+
kind: ev.kind,
|
|
1228
|
+
text: ev.text,
|
|
1229
|
+
data: void 0,
|
|
1230
|
+
extra: ev.extra ?? null
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Merge dsh events (authoritative seq domain) with sidecar events.
|
|
1235
|
+
* One dsh record can normalize into several sidecar events sharing the
|
|
1236
|
+
* same `extra.seq` (multi-block messages), so twins are grouped per seq:
|
|
1237
|
+
* the FIRST twin folds into the matching dsh entry (normalized text +
|
|
1238
|
+
* extra supplement, dsh primary) and every further sibling stays its own
|
|
1239
|
+
* entry — dropping siblings would silently lose blocks (F1). Seq-carrying
|
|
1240
|
+
* entries keep exact seq order (same-seq groups keep dsh-then-block
|
|
1241
|
+
* arrival order via the stable sort); seq-less entries interleave by
|
|
1242
|
+
* timestamp.
|
|
1243
|
+
*/
|
|
1244
|
+
function mergeTimeline(dshEvents, sidecarEvents) {
|
|
1245
|
+
const twinsBySeq = /* @__PURE__ */ new Map();
|
|
1246
|
+
const unseqed = [];
|
|
1247
|
+
for (const ev of sidecarEvents) {
|
|
1248
|
+
const seq = integerOrNull(ev.extra?.["seq"]);
|
|
1249
|
+
if (seq !== null) {
|
|
1250
|
+
const group = twinsBySeq.get(seq);
|
|
1251
|
+
if (group === void 0) twinsBySeq.set(seq, [ev]);
|
|
1252
|
+
else group.push(ev);
|
|
1253
|
+
} else unseqed.push({
|
|
1254
|
+
origin: "sidecar",
|
|
1255
|
+
seq: null,
|
|
1256
|
+
ts: parseTs(ev.ts),
|
|
1257
|
+
kind: ev.kind,
|
|
1258
|
+
text: ev.text,
|
|
1259
|
+
data: void 0,
|
|
1260
|
+
extra: ev.extra ?? null
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
const seqDomain = [];
|
|
1264
|
+
const dshSeqs = /* @__PURE__ */ new Set();
|
|
1265
|
+
for (const ev of dshEvents) {
|
|
1266
|
+
const twins = dshSeqs.has(ev.seq) ? void 0 : twinsBySeq.get(ev.seq);
|
|
1267
|
+
dshSeqs.add(ev.seq);
|
|
1268
|
+
const first = twins?.[0];
|
|
1269
|
+
seqDomain.push({
|
|
1270
|
+
origin: "dsh",
|
|
1271
|
+
seq: ev.seq,
|
|
1272
|
+
ts: ev.time,
|
|
1273
|
+
kind: ev.type,
|
|
1274
|
+
text: first?.text ?? "",
|
|
1275
|
+
data: ev.data,
|
|
1276
|
+
extra: first?.extra ?? null
|
|
1277
|
+
});
|
|
1278
|
+
if (twins !== void 0) for (let i = 1; i < twins.length; i += 1) {
|
|
1279
|
+
const sibling = twins[i];
|
|
1280
|
+
if (sibling !== void 0) seqDomain.push(sidecarSeqEntry(ev.seq, sibling));
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
for (const [seq, twins] of twinsBySeq) {
|
|
1284
|
+
if (dshSeqs.has(seq)) continue;
|
|
1285
|
+
for (const ev of twins) seqDomain.push(sidecarSeqEntry(seq, ev));
|
|
1286
|
+
}
|
|
1287
|
+
seqDomain.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
1288
|
+
unseqed.sort((a, b) => a.ts - b.ts);
|
|
1289
|
+
const out = [];
|
|
1290
|
+
let i = 0;
|
|
1291
|
+
let j = 0;
|
|
1292
|
+
for (;;) {
|
|
1293
|
+
const a = seqDomain[i];
|
|
1294
|
+
const b = unseqed[j];
|
|
1295
|
+
if (a === void 0 && b === void 0) break;
|
|
1296
|
+
if (b === void 0 || a !== void 0 && a.ts <= b.ts) {
|
|
1297
|
+
if (a !== void 0) {
|
|
1298
|
+
out.push(a);
|
|
1299
|
+
i += 1;
|
|
1300
|
+
}
|
|
1301
|
+
} else {
|
|
1302
|
+
out.push(b);
|
|
1303
|
+
j += 1;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
return out;
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* The fused query surface. Lifecycle: `start()` subscribes to the dsh
|
|
1310
|
+
* feed, `stop()` disposes subscriptions and drops all cached state; the
|
|
1311
|
+
* wiring feeds the sidecar subscribe stream through
|
|
1312
|
+
* {@link ingestSidecarEvent}. All query methods are on-demand pulls.
|
|
1313
|
+
*/
|
|
1314
|
+
var FusionQuery = class {
|
|
1315
|
+
store;
|
|
1316
|
+
dshEvents;
|
|
1317
|
+
getSessionQueryThunk;
|
|
1318
|
+
replaySource;
|
|
1319
|
+
now;
|
|
1320
|
+
maxEventsPerSession;
|
|
1321
|
+
maxSessions;
|
|
1322
|
+
/** Live in-process dsh sessions keyed by session id. */
|
|
1323
|
+
live = /* @__PURE__ */ new Map();
|
|
1324
|
+
/** Bounded per-session sidecar event rings; insertion order = feed recency. */
|
|
1325
|
+
buffers = /* @__PURE__ */ new Map();
|
|
1326
|
+
disposers = [];
|
|
1327
|
+
started = false;
|
|
1328
|
+
constructor(opts) {
|
|
1329
|
+
this.store = opts.store;
|
|
1330
|
+
this.dshEvents = opts.dshEvents ?? null;
|
|
1331
|
+
this.getSessionQueryThunk = opts.getSessionQuery ?? null;
|
|
1332
|
+
this.replaySource = opts.replay ?? null;
|
|
1333
|
+
this.now = opts.now ?? Date.now;
|
|
1334
|
+
this.maxEventsPerSession = opts.maxBufferedEventsPerSession ?? 200;
|
|
1335
|
+
this.maxSessions = opts.maxBufferedSessions ?? 256;
|
|
1336
|
+
if (this.maxEventsPerSession <= 0 || this.maxSessions <= 0) throw new RangeError("fusion buffer bounds are invalid");
|
|
1337
|
+
}
|
|
1338
|
+
/** Subscribe to the in-process feed (idempotent). */
|
|
1339
|
+
start() {
|
|
1340
|
+
if (this.started) return;
|
|
1341
|
+
this.started = true;
|
|
1342
|
+
if (this.dshEvents === null) return;
|
|
1343
|
+
this.disposers.push(this.dshEvents.on("session/created", (session) => {
|
|
1344
|
+
this.ensureLive(session);
|
|
1345
|
+
}), this.dshEvents.on("session/event", (session, ev) => {
|
|
1346
|
+
this.handleDshEvent(session, ev);
|
|
1347
|
+
}), this.dshEvents.on("session/disposed", (session) => {
|
|
1348
|
+
this.live.delete(session.id);
|
|
1349
|
+
}));
|
|
1350
|
+
}
|
|
1351
|
+
/** Dispose subscriptions and drop all cached state (idempotent). */
|
|
1352
|
+
stop() {
|
|
1353
|
+
if (!this.started) return;
|
|
1354
|
+
this.started = false;
|
|
1355
|
+
const disposers = this.disposers;
|
|
1356
|
+
this.disposers = [];
|
|
1357
|
+
for (const dispose of disposers) dispose();
|
|
1358
|
+
this.live.clear();
|
|
1359
|
+
this.buffers.clear();
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Feed one sidecar subscribe-stream event into the bounded ring
|
|
1363
|
+
* (timeline hints only; the stream stays a trigger signal, ADR-2).
|
|
1364
|
+
*/
|
|
1365
|
+
ingestSidecarEvent(ev) {
|
|
1366
|
+
if (typeof ev.session_id !== "string" || ev.session_id === "") return;
|
|
1367
|
+
let ring = this.buffers.get(ev.session_id);
|
|
1368
|
+
if (ring === void 0) ring = [];
|
|
1369
|
+
else this.buffers.delete(ev.session_id);
|
|
1370
|
+
ring.push(ev);
|
|
1371
|
+
if (ring.length > this.maxEventsPerSession) ring.splice(0, ring.length - this.maxEventsPerSession);
|
|
1372
|
+
this.buffers.set(ev.session_id, ring);
|
|
1373
|
+
while (this.buffers.size > this.maxSessions) {
|
|
1374
|
+
const oldest = this.buffers.keys().next();
|
|
1375
|
+
if (oldest.done === true) break;
|
|
1376
|
+
this.buffers.delete(oldest.value);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Deduplicated cross-agent session list, most recently active first.
|
|
1381
|
+
* dsh sessions live in this process win over their sidecar rows
|
|
1382
|
+
* (which then only supplement); cold dsh sessions and non-dsh agents
|
|
1383
|
+
* come from the sidecar alone.
|
|
1384
|
+
*/
|
|
1385
|
+
getUnifiedSessions() {
|
|
1386
|
+
const board = this.store.getBoardState();
|
|
1387
|
+
const out = /* @__PURE__ */ new Map();
|
|
1388
|
+
const mergedIds = /* @__PURE__ */ new Set();
|
|
1389
|
+
for (const row of board.sessions) {
|
|
1390
|
+
const liveEntry = row.agent === DSH_AGENT ? this.live.get(row.session_id) : void 0;
|
|
1391
|
+
if (liveEntry !== void 0) {
|
|
1392
|
+
mergedIds.add(row.session_id);
|
|
1393
|
+
out.set(`${row.agent}${KEY_SEP}${row.session_id}`, this.mergeRow(liveEntry, row));
|
|
1394
|
+
} else out.set(`${row.agent}${KEY_SEP}${row.session_id}`, fromSidecarRow(row));
|
|
1395
|
+
}
|
|
1396
|
+
for (const [id, entry] of this.live) {
|
|
1397
|
+
if (mergedIds.has(id)) continue;
|
|
1398
|
+
out.set(`${DSH_AGENT}${KEY_SEP}${id}`, fromDshLive(entry));
|
|
1399
|
+
}
|
|
1400
|
+
const sessions = [...out.values()];
|
|
1401
|
+
sessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.sessionId.localeCompare(b.sessionId));
|
|
1402
|
+
return sessions;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Cross-agent project correlation groups within a time window
|
|
1406
|
+
* (project path + window is the correlation key, design §4.e.2).
|
|
1407
|
+
*/
|
|
1408
|
+
getProjectGroups(opts = {}) {
|
|
1409
|
+
const windowMs = opts.windowMs ?? 864e5;
|
|
1410
|
+
const cutoff = (opts.now ?? this.now()) - windowMs;
|
|
1411
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1412
|
+
for (const session of this.getUnifiedSessions()) {
|
|
1413
|
+
if (session.lastActivityAt < cutoff) continue;
|
|
1414
|
+
const project = normalizeProject(session.project);
|
|
1415
|
+
let group = groups.get(project);
|
|
1416
|
+
if (group === void 0) {
|
|
1417
|
+
group = {
|
|
1418
|
+
project,
|
|
1419
|
+
agents: [],
|
|
1420
|
+
sessions: [],
|
|
1421
|
+
lastActivityAt: 0
|
|
1422
|
+
};
|
|
1423
|
+
groups.set(project, group);
|
|
1424
|
+
}
|
|
1425
|
+
group.sessions.push(session);
|
|
1426
|
+
if (!group.agents.includes(session.agent)) group.agents.push(session.agent);
|
|
1427
|
+
if (session.lastActivityAt > group.lastActivityAt) group.lastActivityAt = session.lastActivityAt;
|
|
1428
|
+
}
|
|
1429
|
+
const out = [...groups.values()];
|
|
1430
|
+
for (const group of out) group.agents.sort();
|
|
1431
|
+
out.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.project.localeCompare(b.project));
|
|
1432
|
+
return out;
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* One merged timeline page for a session, ascending, deduplicated by
|
|
1436
|
+
* event identity (seq+kind+text for seq-carrying events — same-seq
|
|
1437
|
+
* sibling events from multi-block records all survive), newest window
|
|
1438
|
+
* first with a backward cursor. Sources are pulled on demand; a
|
|
1439
|
+
* missing/failing source silently narrows the page (provenance is
|
|
1440
|
+
* reported in `sources`).
|
|
1441
|
+
*/
|
|
1442
|
+
async getSessionTimeline(sessionId, opts = {}) {
|
|
1443
|
+
const limit = Math.max(1, Math.floor(opts.limit ?? 100));
|
|
1444
|
+
const sources = {
|
|
1445
|
+
dshLive: false,
|
|
1446
|
+
dshCold: false,
|
|
1447
|
+
sidecarReplay: false,
|
|
1448
|
+
sidecarBuffer: false
|
|
1449
|
+
};
|
|
1450
|
+
let dshEvents = [];
|
|
1451
|
+
const liveEntry = this.live.get(sessionId);
|
|
1452
|
+
if (liveEntry !== void 0) {
|
|
1453
|
+
dshEvents = liveEntry.session.events;
|
|
1454
|
+
sources.dshLive = true;
|
|
1455
|
+
} else {
|
|
1456
|
+
const engine = this.resolveSessionQuery();
|
|
1457
|
+
if (engine !== null) try {
|
|
1458
|
+
dshEvents = (await engine.readSession(sessionId)).events;
|
|
1459
|
+
sources.dshCold = true;
|
|
1460
|
+
} catch {}
|
|
1461
|
+
}
|
|
1462
|
+
const sidecarEvents = [];
|
|
1463
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1464
|
+
const addSidecar = (ev) => {
|
|
1465
|
+
const key = sidecarEventKey(ev);
|
|
1466
|
+
if (seen.has(key)) return false;
|
|
1467
|
+
seen.add(key);
|
|
1468
|
+
sidecarEvents.push(ev);
|
|
1469
|
+
return true;
|
|
1470
|
+
};
|
|
1471
|
+
if (this.replaySource !== null) try {
|
|
1472
|
+
const replayed = await this.replaySource.replay({ sessionId });
|
|
1473
|
+
for (const ev of replayed) addSidecar(ev);
|
|
1474
|
+
sources.sidecarReplay = true;
|
|
1475
|
+
} catch {}
|
|
1476
|
+
const ring = this.buffers.get(sessionId);
|
|
1477
|
+
if (ring !== void 0 && ring.length > 0) {
|
|
1478
|
+
sources.sidecarBuffer = true;
|
|
1479
|
+
for (const ev of ring) addSidecar(ev);
|
|
1480
|
+
}
|
|
1481
|
+
const entries = mergeTimeline(dshEvents, sidecarEvents);
|
|
1482
|
+
let endIdx = entries.length;
|
|
1483
|
+
const before = opts.before ?? null;
|
|
1484
|
+
if (before !== null) {
|
|
1485
|
+
endIdx = 0;
|
|
1486
|
+
while (endIdx < entries.length) {
|
|
1487
|
+
const entry = entries[endIdx];
|
|
1488
|
+
if (entry === void 0 || !isBeforeCursor(entry, before)) break;
|
|
1489
|
+
endIdx += 1;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
let startIdx = Math.max(0, endIdx - limit);
|
|
1493
|
+
const boundary = entries[startIdx];
|
|
1494
|
+
if (boundary !== void 0 && boundary.seq !== null) while (startIdx > 0 && entries[startIdx - 1]?.seq === boundary.seq) startIdx -= 1;
|
|
1495
|
+
const window = entries.slice(startIdx, endIdx);
|
|
1496
|
+
const first = window[0];
|
|
1497
|
+
return {
|
|
1498
|
+
sessionId,
|
|
1499
|
+
entries: window,
|
|
1500
|
+
cursor: startIdx > 0 && first !== void 0 ? {
|
|
1501
|
+
seq: first.seq,
|
|
1502
|
+
ts: first.ts
|
|
1503
|
+
} : null,
|
|
1504
|
+
sources
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* dsh lineage via `sessionQuery.traceSession`; degrades to
|
|
1509
|
+
* `trace: null` + reason when the service is absent or the trace
|
|
1510
|
+
* fails (never throws).
|
|
1511
|
+
*/
|
|
1512
|
+
async getLineage(sessionId) {
|
|
1513
|
+
const engine = this.resolveSessionQuery();
|
|
1514
|
+
if (engine === null) return {
|
|
1515
|
+
available: false,
|
|
1516
|
+
trace: null,
|
|
1517
|
+
reason: "session_query_unavailable"
|
|
1518
|
+
};
|
|
1519
|
+
try {
|
|
1520
|
+
return {
|
|
1521
|
+
available: true,
|
|
1522
|
+
trace: await engine.traceSession(sessionId),
|
|
1523
|
+
reason: null
|
|
1524
|
+
};
|
|
1525
|
+
} catch (error) {
|
|
1526
|
+
return {
|
|
1527
|
+
available: false,
|
|
1528
|
+
trace: null,
|
|
1529
|
+
reason: "trace_failed",
|
|
1530
|
+
detail: describeError$2(error)
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Cross-agent search. With sessionQuery mounted, dsh sessions get
|
|
1536
|
+
* full-text ranking (hits first, engine order); without it — or when
|
|
1537
|
+
* the engine call fails — the deep query degrades to title/project
|
|
1538
|
+
* substring filtering over the unified view (`filter-only`), without
|
|
1539
|
+
* error. Non-dsh agents always use the filter path (the sidecar has
|
|
1540
|
+
* no search API).
|
|
1541
|
+
*/
|
|
1542
|
+
async searchSessions(query, opts = {}) {
|
|
1543
|
+
const limit = Math.max(1, Math.floor(opts.limit ?? 50));
|
|
1544
|
+
const needle = query.trim().toLowerCase();
|
|
1545
|
+
const engine = this.resolveSessionQuery();
|
|
1546
|
+
let mode = engine !== null ? "full-text" : "filter-only";
|
|
1547
|
+
if (needle === "") return {
|
|
1548
|
+
mode,
|
|
1549
|
+
items: []
|
|
1550
|
+
};
|
|
1551
|
+
const unified = this.getUnifiedSessions();
|
|
1552
|
+
const items = [];
|
|
1553
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1554
|
+
if (engine !== null) try {
|
|
1555
|
+
const page = await engine.searchSessions({
|
|
1556
|
+
query,
|
|
1557
|
+
limit
|
|
1558
|
+
});
|
|
1559
|
+
const dshById = /* @__PURE__ */ new Map();
|
|
1560
|
+
for (const session of unified) if (session.agent === DSH_AGENT) dshById.set(session.sessionId, session);
|
|
1561
|
+
for (const hit of page.items) {
|
|
1562
|
+
const session = dshById.get(hit.header.id);
|
|
1563
|
+
if (session === void 0) continue;
|
|
1564
|
+
const key = `${session.agent}${KEY_SEP}${session.sessionId}`;
|
|
1565
|
+
if (seen.has(key)) continue;
|
|
1566
|
+
seen.add(key);
|
|
1567
|
+
items.push({
|
|
1568
|
+
session,
|
|
1569
|
+
matchedBy: "full-text",
|
|
1570
|
+
snippet: hit.bestMatch.snippet
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
} catch {
|
|
1574
|
+
mode = "filter-only";
|
|
1575
|
+
}
|
|
1576
|
+
for (const session of unified) {
|
|
1577
|
+
const key = `${session.agent}${KEY_SEP}${session.sessionId}`;
|
|
1578
|
+
if (seen.has(key)) continue;
|
|
1579
|
+
if (session.title.toLowerCase().includes(needle)) {
|
|
1580
|
+
seen.add(key);
|
|
1581
|
+
items.push({
|
|
1582
|
+
session,
|
|
1583
|
+
matchedBy: "title",
|
|
1584
|
+
snippet: null
|
|
1585
|
+
});
|
|
1586
|
+
} else if (session.project.toLowerCase().includes(needle)) {
|
|
1587
|
+
seen.add(key);
|
|
1588
|
+
items.push({
|
|
1589
|
+
session,
|
|
1590
|
+
matchedBy: "project",
|
|
1591
|
+
snippet: null
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
return {
|
|
1596
|
+
mode,
|
|
1597
|
+
items: items.slice(0, limit)
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
/** Current capability face (sessionQuery re-resolved on every call). */
|
|
1601
|
+
getCapabilities() {
|
|
1602
|
+
const engineAvailable = this.resolveSessionQuery() !== null;
|
|
1603
|
+
return {
|
|
1604
|
+
dshEvents: {
|
|
1605
|
+
available: this.dshEvents !== null,
|
|
1606
|
+
liveSessions: this.live.size
|
|
1607
|
+
},
|
|
1608
|
+
sessionQuery: {
|
|
1609
|
+
available: engineAvailable,
|
|
1610
|
+
reason: engineAvailable ? null : "session_query_unavailable"
|
|
1611
|
+
},
|
|
1612
|
+
search: { mode: engineAvailable ? "full-text" : "filter-only" }
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
resolveSessionQuery() {
|
|
1616
|
+
if (this.getSessionQueryThunk === null) return null;
|
|
1617
|
+
try {
|
|
1618
|
+
return this.getSessionQueryThunk() ?? null;
|
|
1619
|
+
} catch {
|
|
1620
|
+
return null;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Register a live session (first `session/created` or, when the feed
|
|
1625
|
+
* attached late, first `session/event`), folding title/seq facts from
|
|
1626
|
+
* the existing log tail without copying it.
|
|
1627
|
+
*/
|
|
1628
|
+
ensureLive(session) {
|
|
1629
|
+
let entry = this.live.get(session.id);
|
|
1630
|
+
if (entry !== void 0) return entry;
|
|
1631
|
+
const events = session.events;
|
|
1632
|
+
const tail = events.length > 0 ? events[events.length - 1] : void 0;
|
|
1633
|
+
let title = null;
|
|
1634
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
1635
|
+
const ev = events[i];
|
|
1636
|
+
if (ev !== void 0 && ev.type === "session/title") {
|
|
1637
|
+
const candidate = extractTitle(ev.data);
|
|
1638
|
+
if (candidate !== null) {
|
|
1639
|
+
title = candidate;
|
|
1640
|
+
break;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
entry = {
|
|
1645
|
+
session,
|
|
1646
|
+
title,
|
|
1647
|
+
lastSeq: tail !== void 0 ? tail.seq : null,
|
|
1648
|
+
lastEventAt: tail !== void 0 ? tail.time : null
|
|
1649
|
+
};
|
|
1650
|
+
this.live.set(session.id, entry);
|
|
1651
|
+
return entry;
|
|
1652
|
+
}
|
|
1653
|
+
handleDshEvent(session, ev) {
|
|
1654
|
+
const entry = this.ensureLive(session);
|
|
1655
|
+
if (entry.lastSeq === null || ev.seq > entry.lastSeq) entry.lastSeq = ev.seq;
|
|
1656
|
+
if (entry.lastEventAt === null || ev.time > entry.lastEventAt) entry.lastEventAt = ev.time;
|
|
1657
|
+
if (ev.type === "session/title") {
|
|
1658
|
+
const title = extractTitle(ev.data);
|
|
1659
|
+
if (title !== null) entry.title = title;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
/** Merge one live dsh entry with its sidecar row (dsh primary). */
|
|
1663
|
+
mergeRow(liveEntry, row) {
|
|
1664
|
+
const header = liveEntry.session.header;
|
|
1665
|
+
const dshActivityMs = liveEntry.lastEventAt ?? header.createdAt;
|
|
1666
|
+
return {
|
|
1667
|
+
agent: DSH_AGENT,
|
|
1668
|
+
sessionId: liveEntry.session.id,
|
|
1669
|
+
origin: "merged",
|
|
1670
|
+
live: true,
|
|
1671
|
+
status: row.status,
|
|
1672
|
+
title: liveEntry.title ?? row.title,
|
|
1673
|
+
project: header.cwd ?? row.project,
|
|
1674
|
+
lastActivityAt: Math.max(dshActivityMs, secondsToMs(row.updated_at)),
|
|
1675
|
+
lastEvent: row.last_event ?? null,
|
|
1676
|
+
lastSeq: liveEntry.lastSeq ?? integerOrNull(row.extra?.["seq"]),
|
|
1677
|
+
gap: row.gap === true,
|
|
1678
|
+
parentId: header.parentSession ?? (typeof row.parent_id === "string" ? row.parent_id : null),
|
|
1679
|
+
extra: row.extra ?? {}
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
/** Cold fallback / non-dsh row: sidecar is the only source. */
|
|
1684
|
+
function fromSidecarRow(row) {
|
|
1685
|
+
return {
|
|
1686
|
+
agent: row.agent,
|
|
1687
|
+
sessionId: row.session_id,
|
|
1688
|
+
origin: "sidecar",
|
|
1689
|
+
live: false,
|
|
1690
|
+
status: row.status,
|
|
1691
|
+
title: row.title,
|
|
1692
|
+
project: row.project,
|
|
1693
|
+
lastActivityAt: secondsToMs(row.updated_at),
|
|
1694
|
+
lastEvent: row.last_event ?? null,
|
|
1695
|
+
lastSeq: integerOrNull(row.extra?.["seq"]),
|
|
1696
|
+
gap: row.gap === true,
|
|
1697
|
+
parentId: typeof row.parent_id === "string" ? row.parent_id : null,
|
|
1698
|
+
extra: row.extra ?? {}
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
/** Live dsh session the sidecar has not (yet) observed on disk. */
|
|
1702
|
+
function fromDshLive(entry) {
|
|
1703
|
+
const header = entry.session.header;
|
|
1704
|
+
return {
|
|
1705
|
+
agent: DSH_AGENT,
|
|
1706
|
+
sessionId: entry.session.id,
|
|
1707
|
+
origin: "dsh-live",
|
|
1708
|
+
live: true,
|
|
1709
|
+
status: "unknown",
|
|
1710
|
+
title: entry.title ?? "",
|
|
1711
|
+
project: header.cwd ?? "",
|
|
1712
|
+
lastActivityAt: entry.lastEventAt ?? header.createdAt,
|
|
1713
|
+
lastEvent: null,
|
|
1714
|
+
lastSeq: entry.lastSeq,
|
|
1715
|
+
gap: false,
|
|
1716
|
+
parentId: header.parentSession ?? null,
|
|
1717
|
+
extra: {}
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
//#endregion
|
|
1721
|
+
//#region src/inject-gateway.ts
|
|
1722
|
+
/**
|
|
1723
|
+
* InjectGateway — the single entry point of the injection write path
|
|
1724
|
+
* (design §4.d dual-path injection, §4.f.5 / §5.3 two-phase confirm,
|
|
1725
|
+
* §8 threat model: server-issued one-time confirmToken).
|
|
1726
|
+
*
|
|
1727
|
+
* Unifies the three planes both injection paths must share (ADR-4):
|
|
1728
|
+
*
|
|
1729
|
+
* - **Confirmation**: two-phase `prepare` → `execute`. `prepare` re-verifies
|
|
1730
|
+
* the target live and issues a crypto-random one-time confirmToken
|
|
1731
|
+
* (≥128 bit, 60s TTL) bound to requestId + target + mode + message sha256.
|
|
1732
|
+
* `execute` refuses missing / expired / reused tokens, and refuses a
|
|
1733
|
+
* message whose hash differs from the prepare-time binding (anti-swap).
|
|
1734
|
+
* Any execute attempt against a live token voids it, success or not
|
|
1735
|
+
* (consume-on-attempt).
|
|
1736
|
+
* - **Idempotency**: the first execute result per requestId is cached
|
|
1737
|
+
* (5 min TTL) and replayed on repeats. `outcome: 'unknown'` is terminal:
|
|
1738
|
+
* a repeated execute returns the cached unknown and NEVER re-fires the
|
|
1739
|
+
* executor (S6 — no retry through the gateway).
|
|
1740
|
+
* - **Logging**: exactly one entry per prepare/execute carrying ts /
|
|
1741
|
+
* requestId / target / mode / phase / result / errorCode / message byte
|
|
1742
|
+
* size and sha256 prefix — never the message body or head plaintext
|
|
1743
|
+
* (the head preview only travels in the prepare response for the UI).
|
|
1744
|
+
*
|
|
1745
|
+
* The token gate defends against browser-mediated attackers only; it does
|
|
1746
|
+
* not claim to stop a local process that can drive both phases itself
|
|
1747
|
+
* (ADR-8 trust posture — same as guard.ts).
|
|
1748
|
+
*
|
|
1749
|
+
* Pure DI: no cordis/dsh imports. Path executors (dsh in-process,
|
|
1750
|
+
* sidecar send CLI) are injected; this module owns the contract, not the
|
|
1751
|
+
* transport.
|
|
1752
|
+
*
|
|
1753
|
+
* @module
|
|
1754
|
+
*/
|
|
1755
|
+
/** Message byte cap, aligned with the sidecar send 16 KiB limit (§4.d). */
|
|
1756
|
+
const MAX_MESSAGE_BYTES = 16 * 1024;
|
|
1757
|
+
/** confirmToken lifetime (§4.f.5). */
|
|
1758
|
+
const TOKEN_TTL_MS = 6e4;
|
|
1759
|
+
/** Idempotency window: how long a first execute result is replayable. */
|
|
1760
|
+
const RESULT_CACHE_TTL_MS = 5 * 6e4;
|
|
1761
|
+
/** 16 random bytes = 128 bits, the spec floor for the confirmToken. */
|
|
1762
|
+
const TOKEN_BYTES = 16;
|
|
1763
|
+
/** Head preview cap (chars) for the confirm dialog; UI-only, never logged. */
|
|
1764
|
+
const HEAD_PREVIEW_CHARS = 120;
|
|
1765
|
+
/** Bound of the internal audit ring served by {@link InjectGateway.getRecentLog}. */
|
|
1766
|
+
const LOG_RING_LIMIT = 256;
|
|
1767
|
+
const DEFAULT_LOG_QUERY_LIMIT = 50;
|
|
1768
|
+
/** Sha256 hex prefix length recorded in logs. */
|
|
1769
|
+
const SHA_LOG_CHARS = 12;
|
|
1770
|
+
/** External agents reachable through the sidecar `send` CLI path (§4.d). */
|
|
1771
|
+
const SEND_CLI_AGENTS = /* @__PURE__ */ new Set([
|
|
1772
|
+
"claude",
|
|
1773
|
+
"codex",
|
|
1774
|
+
"cursor-cli"
|
|
1775
|
+
]);
|
|
1776
|
+
function digestMessage(message) {
|
|
1777
|
+
const sha256 = createHash("sha256").update(message, "utf8").digest("hex");
|
|
1778
|
+
return {
|
|
1779
|
+
bytes: Buffer.byteLength(message, "utf8"),
|
|
1780
|
+
sha256,
|
|
1781
|
+
sha12: sha256.slice(0, SHA_LOG_CHARS)
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
/** Returns a rejection detail, or null when the message is acceptable. */
|
|
1785
|
+
function validateMessage(message, bytes) {
|
|
1786
|
+
if (bytes === 0) return "message is empty";
|
|
1787
|
+
if (message.includes("\0")) return "message contains a NUL byte";
|
|
1788
|
+
if (bytes > 16384) return `message is ${bytes} bytes; limit is ${MAX_MESSAGE_BYTES}`;
|
|
1789
|
+
return null;
|
|
1790
|
+
}
|
|
1791
|
+
/** Constant-time token comparison (length leak is inherent and harmless). */
|
|
1792
|
+
function tokenEquals(expected, provided) {
|
|
1793
|
+
const a = Buffer.from(expected, "utf8");
|
|
1794
|
+
const b = Buffer.from(provided, "utf8");
|
|
1795
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
1796
|
+
}
|
|
1797
|
+
/** The confirmation / idempotency / logging hub for both injection paths. */
|
|
1798
|
+
var InjectGateway = class {
|
|
1799
|
+
deps;
|
|
1800
|
+
now;
|
|
1801
|
+
randomId;
|
|
1802
|
+
pending = /* @__PURE__ */ new Map();
|
|
1803
|
+
results = /* @__PURE__ */ new Map();
|
|
1804
|
+
logRing = [];
|
|
1805
|
+
constructor(deps) {
|
|
1806
|
+
this.deps = deps;
|
|
1807
|
+
this.now = deps.now ?? Date.now;
|
|
1808
|
+
this.randomId = deps.randomId ?? randomUUID;
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Phase one: gate, validate, re-verify, then issue a one-time
|
|
1812
|
+
* confirmation bound to this exact target + mode + message.
|
|
1813
|
+
*
|
|
1814
|
+
* Pipeline (order per spec): allowWrite gate → message pre-validation
|
|
1815
|
+
* (≤16 KiB by bytes, non-empty, no NUL) → live target re-check →
|
|
1816
|
+
* injectable-agent whitelist → capacity check → token issuance.
|
|
1817
|
+
*/
|
|
1818
|
+
async prepare(req) {
|
|
1819
|
+
this.prune(this.now());
|
|
1820
|
+
const digest = digestMessage(req.message);
|
|
1821
|
+
if (!this.deps.allowWrite()) return this.rejectPrepare(req, digest, "inject_disabled");
|
|
1822
|
+
const invalid = validateMessage(req.message, digest.bytes);
|
|
1823
|
+
if (invalid !== null) return this.rejectPrepare(req, digest, "invalid_message", invalid);
|
|
1824
|
+
const target = {
|
|
1825
|
+
agent: req.target.agent,
|
|
1826
|
+
sessionId: req.target.sessionId
|
|
1827
|
+
};
|
|
1828
|
+
const status = await this.deps.verifyTarget(target);
|
|
1829
|
+
if (status === null) return this.rejectPrepare(req, digest, "target_not_found");
|
|
1830
|
+
if (status.status === "dead") return this.rejectPrepare(req, digest, "target_dead");
|
|
1831
|
+
if (this.executorFor(target.agent) === null) return this.rejectPrepare(req, digest, "unsupported_agent");
|
|
1832
|
+
const issuedAt = this.now();
|
|
1833
|
+
if (this.inFlightCount(issuedAt) >= 32) return this.rejectPrepare(req, digest, "too_many_pending");
|
|
1834
|
+
const requestId = this.randomId();
|
|
1835
|
+
const confirmToken = randomBytes(TOKEN_BYTES).toString("hex");
|
|
1836
|
+
const expiresAt = issuedAt + TOKEN_TTL_MS;
|
|
1837
|
+
this.pending.set(requestId, {
|
|
1838
|
+
token: confirmToken,
|
|
1839
|
+
target,
|
|
1840
|
+
mode: req.mode,
|
|
1841
|
+
messageSha256: digest.sha256,
|
|
1842
|
+
expiresAt,
|
|
1843
|
+
consumed: false
|
|
1844
|
+
});
|
|
1845
|
+
this.record({
|
|
1846
|
+
ts: issuedAt,
|
|
1847
|
+
phase: "prepare",
|
|
1848
|
+
requestId,
|
|
1849
|
+
target: { ...target },
|
|
1850
|
+
mode: req.mode,
|
|
1851
|
+
ok: true,
|
|
1852
|
+
messageBytes: digest.bytes,
|
|
1853
|
+
messageSha12: digest.sha12
|
|
1854
|
+
});
|
|
1855
|
+
return {
|
|
1856
|
+
ok: true,
|
|
1857
|
+
requestId,
|
|
1858
|
+
confirmToken,
|
|
1859
|
+
plan: {
|
|
1860
|
+
target: { ...target },
|
|
1861
|
+
mode: req.mode,
|
|
1862
|
+
targetStatus: { ...status },
|
|
1863
|
+
messagePreview: {
|
|
1864
|
+
bytes: digest.bytes,
|
|
1865
|
+
head: req.message.slice(0, HEAD_PREVIEW_CHARS)
|
|
1866
|
+
}
|
|
1867
|
+
},
|
|
1868
|
+
expiresAt
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Phase two: validate the confirmation, dispatch to the path executor,
|
|
1873
|
+
* and cache the first result per requestId.
|
|
1874
|
+
*
|
|
1875
|
+
* Rejection order: cached replay (idempotency wins) → token missing →
|
|
1876
|
+
* token reused → token expired → token/message binding mismatch →
|
|
1877
|
+
* unsupported agent. Every attempt against a live token consumes it,
|
|
1878
|
+
* whatever happens afterwards.
|
|
1879
|
+
*/
|
|
1880
|
+
async execute(req) {
|
|
1881
|
+
const now = this.now();
|
|
1882
|
+
this.prune(now);
|
|
1883
|
+
const digest = digestMessage(req.message);
|
|
1884
|
+
const record = this.pending.get(req.requestId) ?? null;
|
|
1885
|
+
const cached = this.results.get(req.requestId);
|
|
1886
|
+
if (cached !== void 0) {
|
|
1887
|
+
if (record === null) return this.rejectExecute(req.requestId, null, digest, "token_missing");
|
|
1888
|
+
if (!tokenEquals(record.token, req.confirmToken) || record.messageSha256 !== digest.sha256) return this.rejectExecute(req.requestId, record, digest, "token_mismatch");
|
|
1889
|
+
const replay = {
|
|
1890
|
+
...cached.result,
|
|
1891
|
+
replayed: true
|
|
1892
|
+
};
|
|
1893
|
+
this.logExecuteResult(req.requestId, record, digest, replay);
|
|
1894
|
+
return replay;
|
|
1895
|
+
}
|
|
1896
|
+
if (!req.confirmToken) return this.rejectExecute(req.requestId, record, digest, "token_missing");
|
|
1897
|
+
if (record === null) return this.rejectExecute(req.requestId, null, digest, "token_missing");
|
|
1898
|
+
if (record.consumed) return this.rejectExecute(req.requestId, record, digest, "token_reused");
|
|
1899
|
+
if (record.expiresAt <= now) {
|
|
1900
|
+
this.pending.delete(req.requestId);
|
|
1901
|
+
return this.rejectExecute(req.requestId, record, digest, "token_expired");
|
|
1902
|
+
}
|
|
1903
|
+
record.consumed = true;
|
|
1904
|
+
if (!tokenEquals(record.token, req.confirmToken)) return this.rejectExecute(req.requestId, record, digest, "token_mismatch");
|
|
1905
|
+
if (record.messageSha256 !== digest.sha256) return this.rejectExecute(req.requestId, record, digest, "token_mismatch");
|
|
1906
|
+
const executor = this.executorFor(record.target.agent);
|
|
1907
|
+
if (executor === null) return this.rejectExecute(req.requestId, record, digest, "unsupported_agent");
|
|
1908
|
+
let result;
|
|
1909
|
+
try {
|
|
1910
|
+
result = await executor.execute({
|
|
1911
|
+
target: { ...record.target },
|
|
1912
|
+
mode: record.mode,
|
|
1913
|
+
message: req.message,
|
|
1914
|
+
requestId: req.requestId
|
|
1915
|
+
});
|
|
1916
|
+
} catch (err) {
|
|
1917
|
+
result = {
|
|
1918
|
+
outcome: "failed",
|
|
1919
|
+
errorCode: "executor_error",
|
|
1920
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
this.results.set(req.requestId, {
|
|
1924
|
+
result: { ...result },
|
|
1925
|
+
expiresAt: this.now() + RESULT_CACHE_TTL_MS
|
|
1926
|
+
});
|
|
1927
|
+
this.logExecuteResult(req.requestId, record, digest, result);
|
|
1928
|
+
return result;
|
|
1929
|
+
}
|
|
1930
|
+
/** Read-only audit view (newest first), for the M3 detail page. */
|
|
1931
|
+
getRecentLog(limit = DEFAULT_LOG_QUERY_LIMIT) {
|
|
1932
|
+
const bounded = Math.min(Math.max(Math.floor(limit), 0), this.logRing.length);
|
|
1933
|
+
return this.logRing.slice(this.logRing.length - bounded).reverse();
|
|
1934
|
+
}
|
|
1935
|
+
executorFor(agent) {
|
|
1936
|
+
if (agent === "dsh") return this.deps.executors.dsh;
|
|
1937
|
+
if (SEND_CLI_AGENTS.has(agent)) return this.deps.executors.sendCli;
|
|
1938
|
+
return null;
|
|
1939
|
+
}
|
|
1940
|
+
/** Issued-but-unconsumed-and-unexpired tokens count toward the cap. */
|
|
1941
|
+
inFlightCount(now) {
|
|
1942
|
+
let count = 0;
|
|
1943
|
+
for (const record of this.pending.values()) if (!record.consumed && record.expiresAt > now) count += 1;
|
|
1944
|
+
return count;
|
|
1945
|
+
}
|
|
1946
|
+
/**
|
|
1947
|
+
* Housekeeping. Pending records outlive their token TTL by the result
|
|
1948
|
+
* cache window so that late replays keep their binding check and late
|
|
1949
|
+
* reuse attempts still answer `token_reused` (not `token_missing`).
|
|
1950
|
+
*/
|
|
1951
|
+
prune(now) {
|
|
1952
|
+
for (const [id, record] of this.pending) if (record.expiresAt + 3e5 <= now) this.pending.delete(id);
|
|
1953
|
+
for (const [id, cached] of this.results) if (cached.expiresAt <= now) this.results.delete(id);
|
|
1954
|
+
}
|
|
1955
|
+
rejectPrepare(req, digest, errorCode, detail) {
|
|
1956
|
+
this.record({
|
|
1957
|
+
ts: this.now(),
|
|
1958
|
+
phase: "prepare",
|
|
1959
|
+
requestId: null,
|
|
1960
|
+
target: {
|
|
1961
|
+
agent: req.target.agent,
|
|
1962
|
+
sessionId: req.target.sessionId
|
|
1963
|
+
},
|
|
1964
|
+
mode: req.mode,
|
|
1965
|
+
ok: false,
|
|
1966
|
+
errorCode,
|
|
1967
|
+
messageBytes: digest.bytes,
|
|
1968
|
+
messageSha12: digest.sha12
|
|
1969
|
+
});
|
|
1970
|
+
return detail === void 0 ? {
|
|
1971
|
+
ok: false,
|
|
1972
|
+
errorCode
|
|
1973
|
+
} : {
|
|
1974
|
+
ok: false,
|
|
1975
|
+
errorCode,
|
|
1976
|
+
detail
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
rejectExecute(requestId, record, digest, errorCode) {
|
|
1980
|
+
this.record({
|
|
1981
|
+
ts: this.now(),
|
|
1982
|
+
phase: "execute",
|
|
1983
|
+
requestId,
|
|
1984
|
+
target: record === null ? null : { ...record.target },
|
|
1985
|
+
mode: record === null ? null : record.mode,
|
|
1986
|
+
ok: false,
|
|
1987
|
+
outcome: "failed",
|
|
1988
|
+
errorCode,
|
|
1989
|
+
messageBytes: digest.bytes,
|
|
1990
|
+
messageSha12: digest.sha12
|
|
1991
|
+
});
|
|
1992
|
+
return {
|
|
1993
|
+
outcome: "failed",
|
|
1994
|
+
errorCode
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
logExecuteResult(requestId, record, digest, result) {
|
|
1998
|
+
this.record({
|
|
1999
|
+
ts: this.now(),
|
|
2000
|
+
phase: "execute",
|
|
2001
|
+
requestId,
|
|
2002
|
+
target: record === null ? null : { ...record.target },
|
|
2003
|
+
mode: record === null ? null : record.mode,
|
|
2004
|
+
ok: result.outcome === "delivered",
|
|
2005
|
+
outcome: result.outcome,
|
|
2006
|
+
...result.errorCode !== void 0 ? { errorCode: result.errorCode } : {},
|
|
2007
|
+
...result.replayed !== void 0 ? { replayed: result.replayed } : {},
|
|
2008
|
+
messageBytes: digest.bytes,
|
|
2009
|
+
messageSha12: digest.sha12
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
record(entry) {
|
|
2013
|
+
const frozen = Object.freeze({
|
|
2014
|
+
...entry,
|
|
2015
|
+
target: entry.target === null ? null : Object.freeze({ ...entry.target })
|
|
2016
|
+
});
|
|
2017
|
+
this.logRing.push(frozen);
|
|
2018
|
+
if (this.logRing.length > LOG_RING_LIMIT) this.logRing.splice(0, this.logRing.length - LOG_RING_LIMIT);
|
|
2019
|
+
this.deps.log(frozen);
|
|
2020
|
+
}
|
|
2021
|
+
};
|
|
2022
|
+
//#endregion
|
|
2023
|
+
//#region src/guard.ts
|
|
2024
|
+
const OK = { ok: true };
|
|
2025
|
+
const forbid = (reason) => ({
|
|
2026
|
+
ok: false,
|
|
2027
|
+
status: 403,
|
|
2028
|
+
reason
|
|
2029
|
+
});
|
|
2030
|
+
/** Methods whose body is a state-changing payload (layer 4 media-type gate). */
|
|
2031
|
+
const BODY_METHODS = /* @__PURE__ */ new Set([
|
|
2032
|
+
"POST",
|
|
2033
|
+
"PUT",
|
|
2034
|
+
"PATCH"
|
|
2035
|
+
]);
|
|
2036
|
+
/**
|
|
2037
|
+
* True when `addr` (a `socket.remoteAddress` value) is a loopback address:
|
|
2038
|
+
* IPv4 `127.0.0.0/8`, IPv6 `::1`, or the IPv4-mapped form `::ffff:127.x.y.z`
|
|
2039
|
+
* that Node reports on dual-stack listeners. Anything unparsable is `false`
|
|
2040
|
+
* (fail closed).
|
|
2041
|
+
*/
|
|
2042
|
+
function isLoopbackAddress(addr) {
|
|
2043
|
+
if (!addr) return false;
|
|
2044
|
+
let candidate = addr.trim().toLowerCase();
|
|
2045
|
+
if (candidate.startsWith("::ffff:")) candidate = candidate.slice(7);
|
|
2046
|
+
if (candidate === "::1") return true;
|
|
2047
|
+
return isLoopbackIpv4(candidate);
|
|
2048
|
+
}
|
|
2049
|
+
/** Strict dotted-quad check for `127.0.0.0/8`. */
|
|
2050
|
+
function isLoopbackIpv4(candidate) {
|
|
2051
|
+
const parts = candidate.split(".");
|
|
2052
|
+
if (parts.length !== 4) return false;
|
|
2053
|
+
for (const part of parts) if (!/^\d{1,3}$/.test(part) || Number(part) > 255) return false;
|
|
2054
|
+
return parts[0] === "127";
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Parse an authority string (`Host` header shape). Returns undefined for
|
|
2058
|
+
* anything malformed: empty, bad brackets, non-numeric or out-of-range port,
|
|
2059
|
+
* stray colons. Node keeps only the first `Host` header on duplicates, so a
|
|
2060
|
+
* single string is the full input space here.
|
|
2061
|
+
*/
|
|
2062
|
+
function parseAuthority(raw) {
|
|
2063
|
+
if (typeof raw !== "string") return void 0;
|
|
2064
|
+
const value = raw.trim().toLowerCase();
|
|
2065
|
+
if (!value) return void 0;
|
|
2066
|
+
let host;
|
|
2067
|
+
let portPart;
|
|
2068
|
+
if (value.startsWith("[")) {
|
|
2069
|
+
const close = value.indexOf("]");
|
|
2070
|
+
if (close <= 1) return void 0;
|
|
2071
|
+
host = value.slice(0, close + 1);
|
|
2072
|
+
const rest = value.slice(close + 1);
|
|
2073
|
+
if (rest) {
|
|
2074
|
+
if (!rest.startsWith(":")) return void 0;
|
|
2075
|
+
portPart = rest.slice(1);
|
|
2076
|
+
}
|
|
2077
|
+
} else {
|
|
2078
|
+
const colon = value.indexOf(":");
|
|
2079
|
+
if (colon === -1) host = value;
|
|
2080
|
+
else {
|
|
2081
|
+
host = value.slice(0, colon);
|
|
2082
|
+
portPart = value.slice(colon + 1);
|
|
2083
|
+
if (portPart.includes(":")) return void 0;
|
|
2084
|
+
}
|
|
2085
|
+
if (!host || /[\s/@#?\\]/.test(host)) return void 0;
|
|
2086
|
+
}
|
|
2087
|
+
if (portPart !== void 0) {
|
|
2088
|
+
if (!/^\d{1,5}$/.test(portPart)) return void 0;
|
|
2089
|
+
const num = Number(portPart);
|
|
2090
|
+
if (num < 1 || num > 65535) return void 0;
|
|
2091
|
+
}
|
|
2092
|
+
return {
|
|
2093
|
+
host,
|
|
2094
|
+
port: portPart
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
/** True when a parsed authority host names loopback. */
|
|
2098
|
+
function authorityIsLoopback(host) {
|
|
2099
|
+
if (host === "localhost") return true;
|
|
2100
|
+
if (host.startsWith("[") && host.endsWith("]")) return isLoopbackAddress(host.slice(1, -1));
|
|
2101
|
+
return isLoopbackIpv4(host);
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* Same-origin check between an `Origin` header value and the request's
|
|
2105
|
+
* `Host` authority. Scheme may be http or https; host must match exactly
|
|
2106
|
+
* (WHATWG-normalized: lowercase, IPv6 canonical bracketed form) and the
|
|
2107
|
+
* effective ports must agree. A `Host` without a port accepts either
|
|
2108
|
+
* scheme-default origin port (80/443), covering default-port elision.
|
|
2109
|
+
*/
|
|
2110
|
+
function originMatchesAuthority(origin, authority) {
|
|
2111
|
+
let url;
|
|
2112
|
+
try {
|
|
2113
|
+
url = new URL(origin);
|
|
2114
|
+
} catch {
|
|
2115
|
+
return false;
|
|
2116
|
+
}
|
|
2117
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
2118
|
+
if (url.hostname.toLowerCase() !== authority.host) return false;
|
|
2119
|
+
const originPort = url.port || (url.protocol === "https:" ? "443" : "80");
|
|
2120
|
+
if (authority.port !== void 0) return originPort === authority.port;
|
|
2121
|
+
return originPort === "80" || originPort === "443";
|
|
2122
|
+
}
|
|
2123
|
+
/** Reject when any (possibly `, `-joined multi-value) entry is `cross-site`. */
|
|
2124
|
+
function declaresCrossSite(secFetchSite) {
|
|
2125
|
+
if (secFetchSite === void 0) return false;
|
|
2126
|
+
return (Array.isArray(secFetchSite) ? secFetchSite : [secFetchSite]).some((value) => value.split(",").some((entry) => entry.trim().toLowerCase() === "cross-site"));
|
|
2127
|
+
}
|
|
2128
|
+
/** Layers 1-3: remote loopback, Host authority, Origin/sec-fetch-site. */
|
|
2129
|
+
function guardReachability(req) {
|
|
2130
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress ?? void 0)) return forbid("remote_not_loopback");
|
|
2131
|
+
const hostHeader = req.headers.host;
|
|
2132
|
+
const authority = typeof hostHeader === "string" ? parseAuthority(hostHeader) : void 0;
|
|
2133
|
+
if (!authority || !authorityIsLoopback(authority.host)) return forbid("host_not_loopback");
|
|
2134
|
+
const origin = req.headers.origin;
|
|
2135
|
+
if (origin !== void 0) {
|
|
2136
|
+
if (Array.isArray(origin) || !originMatchesAuthority(origin, authority)) return forbid("origin_mismatch");
|
|
2137
|
+
}
|
|
2138
|
+
if (declaresCrossSite(req.headers["sec-fetch-site"])) return forbid("cross_site");
|
|
2139
|
+
return OK;
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Full HTTP-route guard, layers 1-4 in order:
|
|
2143
|
+
*
|
|
2144
|
+
* 1. `socket.remoteAddress` must be loopback → else 403;
|
|
2145
|
+
* 2. `Host` must be a loopback authority → else 403;
|
|
2146
|
+
* 3. `Origin` (when present) must be same-origin with Host, and
|
|
2147
|
+
* `sec-fetch-site: cross-site` is explicitly refused → else 403;
|
|
2148
|
+
* 4. POST/PUT/PATCH must carry `content-type: application/json` (charset
|
|
2149
|
+
* parameter allowed) → else 415.
|
|
2150
|
+
*
|
|
2151
|
+
* Layer 5 (the write-action gate) is {@link guardWriteAction}: routes call
|
|
2152
|
+
* it only for state-changing actions, chaining this verdict through.
|
|
2153
|
+
*
|
|
2154
|
+
* @param req - the incoming request (or a structural mock in tests).
|
|
2155
|
+
* @param _opts - reserved; layers 1-4 need no dynamic settings today.
|
|
2156
|
+
*/
|
|
2157
|
+
function guardRequest(req, _opts) {
|
|
2158
|
+
const reachability = guardReachability(req);
|
|
2159
|
+
if (!reachability.ok) return reachability;
|
|
2160
|
+
const method = (req.method ?? "").toUpperCase();
|
|
2161
|
+
if (BODY_METHODS.has(method)) {
|
|
2162
|
+
const contentType = req.headers["content-type"];
|
|
2163
|
+
if ((typeof contentType === "string" ? contentType.split(";", 1)[0]?.trim().toLowerCase() : void 0) !== "application/json") return {
|
|
2164
|
+
ok: false,
|
|
2165
|
+
status: 415,
|
|
2166
|
+
reason: "unsupported_media_type"
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
return OK;
|
|
2170
|
+
}
|
|
2171
|
+
/**
|
|
2172
|
+
* Layer 5 — write-action gate. Chains an earlier verdict (typically from
|
|
2173
|
+
* {@link guardRequest}) and then requires `inject.enabled` to be on, read
|
|
2174
|
+
* live via {@link GuardOptions.allowWriteActions}. The one-time confirmToken
|
|
2175
|
+
* check is the M2 inject gateway's job, not this layer's.
|
|
2176
|
+
*
|
|
2177
|
+
* @param verdictCtx - verdict from the preceding layers; failures pass through.
|
|
2178
|
+
* @param opts - dynamic settings source; gate is closed when it says so.
|
|
2179
|
+
*/
|
|
2180
|
+
function guardWriteAction(verdictCtx, opts) {
|
|
2181
|
+
if (!verdictCtx.ok) return verdictCtx;
|
|
2182
|
+
if (!opts.allowWriteActions()) return forbid("inject_disabled");
|
|
2183
|
+
return OK;
|
|
2184
|
+
}
|
|
2185
|
+
//#endregion
|
|
2186
|
+
//#region src/routes.ts
|
|
2187
|
+
/** Route namespace, per the `/plugins/<package>/` convention (design §4.f). */
|
|
2188
|
+
const API_PREFIX = "/plugins/agent-sidecar/api";
|
|
2189
|
+
const DEFAULT_MAX_SSE_CLIENTS = 8;
|
|
2190
|
+
const DEFAULT_SSE_HEARTBEAT_MS = 15e3;
|
|
2191
|
+
const DEFAULT_SSE_BUFFER_LIMIT = 256;
|
|
2192
|
+
const HEARTBEAT_FRAME = ": hb\n\n";
|
|
2193
|
+
/** Bound on the `POST action` JSON body (message cap is 16 KiB + envelope). */
|
|
2194
|
+
const MAX_ACTION_BODY_BYTES = 64 * 1024;
|
|
2195
|
+
/** `inject.prepare` rejection code → HTTP status (task spec mapping). */
|
|
2196
|
+
const PREPARE_ERROR_STATUS = {
|
|
2197
|
+
inject_disabled: 403,
|
|
2198
|
+
invalid_message: 422,
|
|
2199
|
+
target_not_found: 404,
|
|
2200
|
+
target_dead: 409,
|
|
2201
|
+
too_many_pending: 429,
|
|
2202
|
+
unsupported_agent: 422
|
|
2203
|
+
};
|
|
2204
|
+
/**
|
|
2205
|
+
* `inject.execute` failed-outcome code → HTTP status. Unlisted codes
|
|
2206
|
+
* (executor-native vocab) and codeless failures fall back to 502.
|
|
2207
|
+
*/
|
|
2208
|
+
const EXECUTE_ERROR_STATUS = {
|
|
2209
|
+
token_missing: 401,
|
|
2210
|
+
token_expired: 401,
|
|
2211
|
+
token_reused: 409,
|
|
2212
|
+
token_mismatch: 409,
|
|
2213
|
+
unsupported_agent: 422,
|
|
2214
|
+
executor_error: 502
|
|
2215
|
+
};
|
|
2216
|
+
/**
|
|
2217
|
+
* Analysis-engine error code → HTTP status (task spec mapping). `cancelled`
|
|
2218
|
+
* stays 200: it is a terminal fact about the analysis session carried in
|
|
2219
|
+
* the result outcome, not a transport failure. Unknown codes fall back to
|
|
2220
|
+
* 502 like the execute map does.
|
|
2221
|
+
*/
|
|
2222
|
+
const ANALYSIS_ERROR_STATUS = {
|
|
2223
|
+
analysis_disabled: 403,
|
|
2224
|
+
too_many_active: 429,
|
|
2225
|
+
timeout: 504,
|
|
2226
|
+
create_failed: 502,
|
|
2227
|
+
cancelled: 200
|
|
2228
|
+
};
|
|
2229
|
+
const ANALYSIS_ACTION_TYPES = /* @__PURE__ */ new Set([
|
|
2230
|
+
"analysis.request",
|
|
2231
|
+
"analysis.followup",
|
|
2232
|
+
"analysis.cancel"
|
|
2233
|
+
]);
|
|
2234
|
+
function writeJson(res, status, body) {
|
|
2235
|
+
res.writeHead(status, {
|
|
2236
|
+
"content-type": "application/json; charset=utf-8",
|
|
2237
|
+
"cache-control": "no-store"
|
|
2238
|
+
});
|
|
2239
|
+
res.end(JSON.stringify(body));
|
|
2240
|
+
}
|
|
2241
|
+
function writeMethodNotAllowed(res, allow) {
|
|
2242
|
+
res.writeHead(405, {
|
|
2243
|
+
allow,
|
|
2244
|
+
"content-type": "application/json; charset=utf-8"
|
|
2245
|
+
});
|
|
2246
|
+
res.end(JSON.stringify({ reason: "method_not_allowed" }));
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Path inside the namespace ('' for the bare prefix), or null when the
|
|
2250
|
+
* request is outside {@link API_PREFIX} or the URL is unparsable. The
|
|
2251
|
+
* carrier already parsed the same string to match the route, so the null
|
|
2252
|
+
* arms only matter when `handle` is exercised directly.
|
|
2253
|
+
*/
|
|
2254
|
+
function subpathOf(rawUrl) {
|
|
2255
|
+
let pathname;
|
|
2256
|
+
try {
|
|
2257
|
+
pathname = new URL(rawUrl ?? "/", "http://dsh.internal").pathname;
|
|
2258
|
+
} catch {
|
|
2259
|
+
return null;
|
|
2260
|
+
}
|
|
2261
|
+
if (pathname === "/plugins/agent-sidecar/api") return "";
|
|
2262
|
+
if (pathname.startsWith(`/plugins/agent-sidecar/api/`)) return pathname.slice(27);
|
|
2263
|
+
return null;
|
|
2264
|
+
}
|
|
2265
|
+
/** Query string of the request (empty params when the URL is unparsable). */
|
|
2266
|
+
function queryOf(rawUrl) {
|
|
2267
|
+
try {
|
|
2268
|
+
return new URL(rawUrl ?? "/", "http://dsh.internal").searchParams;
|
|
2269
|
+
} catch {
|
|
2270
|
+
return new URLSearchParams();
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
/**
|
|
2274
|
+
* Timeline pagination token: `<seq|'-'>~<epoch-ms>`. Deliberately not the
|
|
2275
|
+
* raw JSON cursor object so the query-string round-trip stays trivial and
|
|
2276
|
+
* the wire shape is decoupled from fusion's internal cursor type.
|
|
2277
|
+
*/
|
|
2278
|
+
function encodeCursor(cursor) {
|
|
2279
|
+
return `${cursor.seq === null ? "-" : cursor.seq}~${cursor.ts}`;
|
|
2280
|
+
}
|
|
2281
|
+
function decodeCursor(raw) {
|
|
2282
|
+
const sep = raw.indexOf("~");
|
|
2283
|
+
if (sep <= 0 || sep === raw.length - 1) return null;
|
|
2284
|
+
const seqPart = raw.slice(0, sep);
|
|
2285
|
+
const tsPart = raw.slice(sep + 1);
|
|
2286
|
+
const ts = Number(tsPart);
|
|
2287
|
+
if (!Number.isInteger(ts) || ts < 0) return null;
|
|
2288
|
+
if (seqPart === "-") return {
|
|
2289
|
+
seq: null,
|
|
2290
|
+
ts
|
|
2291
|
+
};
|
|
2292
|
+
const seq = Number(seqPart);
|
|
2293
|
+
if (!Number.isInteger(seq) || seq < 0) return null;
|
|
2294
|
+
return {
|
|
2295
|
+
seq,
|
|
2296
|
+
ts
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
/** Bound on caller-supplied page sizes (timeline entries / search hits). */
|
|
2300
|
+
const MAX_PAGE_LIMIT = 500;
|
|
2301
|
+
/** Search result bound when the caller supplies no limit (fusion default). */
|
|
2302
|
+
const DEFAULT_SEARCH_ROUTE_LIMIT = 50;
|
|
2303
|
+
/** Match fusion's project correlation key: strip trailing slashes (keep `/`). */
|
|
2304
|
+
function normalizeProjectKey(project) {
|
|
2305
|
+
if (project.length > 1 && project.endsWith("/")) {
|
|
2306
|
+
const stripped = project.replace(/\/+$/, "");
|
|
2307
|
+
return stripped === "" ? "/" : stripped;
|
|
2308
|
+
}
|
|
2309
|
+
return project;
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Parse an optional positive-integer query param bounded by
|
|
2313
|
+
* {@link MAX_PAGE_LIMIT}. `undefined` when absent, `null` when invalid.
|
|
2314
|
+
*/
|
|
2315
|
+
function parseLimit(params, name) {
|
|
2316
|
+
const raw = params.get(name);
|
|
2317
|
+
if (raw === null || raw === "") return void 0;
|
|
2318
|
+
const value = Number(raw);
|
|
2319
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_PAGE_LIMIT) return null;
|
|
2320
|
+
return value;
|
|
2321
|
+
}
|
|
2322
|
+
/** JSON wire shape of one timeline page (adds the encoded `nextCursor`). */
|
|
2323
|
+
function timelineBody(page) {
|
|
2324
|
+
return {
|
|
2325
|
+
sessionId: page.sessionId,
|
|
2326
|
+
entries: page.entries,
|
|
2327
|
+
cursor: page.cursor,
|
|
2328
|
+
nextCursor: page.cursor === null ? null : encodeCursor(page.cursor),
|
|
2329
|
+
sources: page.sources
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
/** `event: <name>` + single-line JSON data (JSON.stringify never emits raw newlines). */
|
|
2333
|
+
function sseFrame(event, data) {
|
|
2334
|
+
return `event: ${event}\ndata: ${data}\n\n`;
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* Read the request body up to {@link MAX_ACTION_BODY_BYTES}. On overflow the
|
|
2338
|
+
* promise settles immediately ('too_large') while the rest of the stream
|
|
2339
|
+
* keeps draining, so the keep-alive connection is left in a clean state.
|
|
2340
|
+
*/
|
|
2341
|
+
function readActionBody(req) {
|
|
2342
|
+
return new Promise((resolve) => {
|
|
2343
|
+
const chunks = [];
|
|
2344
|
+
let size = 0;
|
|
2345
|
+
let settled = false;
|
|
2346
|
+
const settle = (result) => {
|
|
2347
|
+
if (settled) return;
|
|
2348
|
+
settled = true;
|
|
2349
|
+
resolve(result);
|
|
2350
|
+
};
|
|
2351
|
+
req.on("data", (chunk) => {
|
|
2352
|
+
if (settled) return;
|
|
2353
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
2354
|
+
size += buf.length;
|
|
2355
|
+
if (size > 65536) {
|
|
2356
|
+
settle({ kind: "too_large" });
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
chunks.push(buf);
|
|
2360
|
+
});
|
|
2361
|
+
req.on("end", () => settle({
|
|
2362
|
+
kind: "ok",
|
|
2363
|
+
text: Buffer.concat(chunks).toString("utf8")
|
|
2364
|
+
}));
|
|
2365
|
+
req.on("error", () => settle({ kind: "error" }));
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Build the M1 route surface. All state lives in the returned closure;
|
|
2370
|
+
* multiple instances never share anything.
|
|
2371
|
+
*/
|
|
2372
|
+
function createRoutes(deps, opts = {}) {
|
|
2373
|
+
const maxSseClients = opts.maxSseClients ?? DEFAULT_MAX_SSE_CLIENTS;
|
|
2374
|
+
const sseHeartbeatMs = opts.sseHeartbeatMs ?? DEFAULT_SSE_HEARTBEAT_MS;
|
|
2375
|
+
const sseBufferLimit = opts.sseBufferLimit ?? DEFAULT_SSE_BUFFER_LIMIT;
|
|
2376
|
+
const clients = /* @__PURE__ */ new Set();
|
|
2377
|
+
let disposed = false;
|
|
2378
|
+
const buildSnapshot = () => ({
|
|
2379
|
+
daemon: {
|
|
2380
|
+
state: deps.supervisor.state,
|
|
2381
|
+
lastPing: deps.supervisor.lastPing
|
|
2382
|
+
},
|
|
2383
|
+
board: deps.store.getBoardState(),
|
|
2384
|
+
capabilities: { inject: deps.guardOptions.allowWriteActions() }
|
|
2385
|
+
});
|
|
2386
|
+
const cleanupClient = (client) => {
|
|
2387
|
+
if (client.closed) return;
|
|
2388
|
+
client.closed = true;
|
|
2389
|
+
if (client.heartbeat !== null) clearInterval(client.heartbeat);
|
|
2390
|
+
client.heartbeat = null;
|
|
2391
|
+
client.pending.length = 0;
|
|
2392
|
+
clients.delete(client);
|
|
2393
|
+
deps.log("info", "sse client disconnected", { clients: clients.size });
|
|
2394
|
+
};
|
|
2395
|
+
const dropClient = (client, reason) => {
|
|
2396
|
+
deps.log("warn", "sse client dropped", {
|
|
2397
|
+
reason,
|
|
2398
|
+
pending: client.pending.length,
|
|
2399
|
+
limit: sseBufferLimit
|
|
2400
|
+
});
|
|
2401
|
+
cleanupClient(client);
|
|
2402
|
+
client.res.destroy();
|
|
2403
|
+
};
|
|
2404
|
+
const push = (client, frame) => {
|
|
2405
|
+
if (client.closed) return;
|
|
2406
|
+
if (client.blocked) {
|
|
2407
|
+
client.pending.push(frame);
|
|
2408
|
+
if (client.pending.length > sseBufferLimit) dropClient(client, "buffer_overflow");
|
|
2409
|
+
return;
|
|
2410
|
+
}
|
|
2411
|
+
if (!client.res.write(frame)) client.blocked = true;
|
|
2412
|
+
};
|
|
2413
|
+
const flush = (client) => {
|
|
2414
|
+
if (client.closed) return;
|
|
2415
|
+
client.blocked = false;
|
|
2416
|
+
while (!client.blocked) {
|
|
2417
|
+
const frame = client.pending.shift();
|
|
2418
|
+
if (frame === void 0) return;
|
|
2419
|
+
if (!client.res.write(frame)) client.blocked = true;
|
|
2420
|
+
}
|
|
2421
|
+
};
|
|
2422
|
+
const acceptStream = (res) => {
|
|
2423
|
+
if (clients.size >= maxSseClients) {
|
|
2424
|
+
deps.log("warn", "sse connection rejected: client limit reached", { max: maxSseClients });
|
|
2425
|
+
writeJson(res, 503, { reason: "too_many_stream_clients" });
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
res.writeHead(200, {
|
|
2429
|
+
"content-type": "text/event-stream",
|
|
2430
|
+
"cache-control": "no-cache",
|
|
2431
|
+
connection: "keep-alive"
|
|
2432
|
+
});
|
|
2433
|
+
const client = {
|
|
2434
|
+
res,
|
|
2435
|
+
pending: [],
|
|
2436
|
+
blocked: false,
|
|
2437
|
+
closed: false,
|
|
2438
|
+
heartbeat: null
|
|
2439
|
+
};
|
|
2440
|
+
clients.add(client);
|
|
2441
|
+
res.on("close", () => cleanupClient(client));
|
|
2442
|
+
res.on("drain", () => flush(client));
|
|
2443
|
+
client.heartbeat = setInterval(() => push(client, HEARTBEAT_FRAME), sseHeartbeatMs);
|
|
2444
|
+
deps.log("info", "sse client connected", { clients: clients.size });
|
|
2445
|
+
push(client, sseFrame("state", JSON.stringify(buildSnapshot())));
|
|
2446
|
+
};
|
|
2447
|
+
/** One change → one full snapshot frame to every client (M1 granularity). */
|
|
2448
|
+
const onMutation = () => {
|
|
2449
|
+
if (disposed || clients.size === 0) return;
|
|
2450
|
+
const frame = sseFrame("state", JSON.stringify(buildSnapshot()));
|
|
2451
|
+
for (const client of [...clients]) push(client, frame);
|
|
2452
|
+
};
|
|
2453
|
+
const unsubscribes = [deps.store.onChange(onMutation), deps.supervisor.onStateChange(onMutation)];
|
|
2454
|
+
/** Decoded session id, or null (already answered 404) on a bad escape. */
|
|
2455
|
+
const decodeId = (res, rawId) => {
|
|
2456
|
+
try {
|
|
2457
|
+
const id = decodeURIComponent(rawId);
|
|
2458
|
+
if (id !== "") return id;
|
|
2459
|
+
} catch {}
|
|
2460
|
+
writeJson(res, 404, { reason: "session_not_found" });
|
|
2461
|
+
return null;
|
|
2462
|
+
};
|
|
2463
|
+
const handleSession = async (res, rawId) => {
|
|
2464
|
+
const id = decodeId(res, rawId);
|
|
2465
|
+
if (id === null) return;
|
|
2466
|
+
const view = deps.store.getBoardState().sessions.find((s) => s.session_id === id);
|
|
2467
|
+
const fusion = deps.fusion;
|
|
2468
|
+
if (fusion === void 0) {
|
|
2469
|
+
if (view === void 0) {
|
|
2470
|
+
writeJson(res, 404, { reason: "session_not_found" });
|
|
2471
|
+
return;
|
|
2472
|
+
}
|
|
2473
|
+
writeJson(res, 200, {
|
|
2474
|
+
session: view,
|
|
2475
|
+
timeline: null,
|
|
2476
|
+
timelineNote: "timeline_not_available_until_m3"
|
|
2477
|
+
});
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
const unified = fusion.getUnifiedSessions().find((s) => s.sessionId === id) ?? null;
|
|
2481
|
+
if (view === void 0 && unified === null) {
|
|
2482
|
+
writeJson(res, 404, { reason: "session_not_found" });
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
const page = await fusion.getSessionTimeline(id);
|
|
2486
|
+
writeJson(res, 200, {
|
|
2487
|
+
session: view ?? null,
|
|
2488
|
+
unified,
|
|
2489
|
+
timeline: timelineBody(page)
|
|
2490
|
+
});
|
|
2491
|
+
};
|
|
2492
|
+
const handleTimeline = async (res, rawId, params) => {
|
|
2493
|
+
const fusion = deps.fusion;
|
|
2494
|
+
if (fusion === void 0) {
|
|
2495
|
+
writeJson(res, 501, { reason: "fusion_not_wired" });
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
const id = decodeId(res, rawId);
|
|
2499
|
+
if (id === null) return;
|
|
2500
|
+
const rawCursor = params.get("cursor");
|
|
2501
|
+
let before = null;
|
|
2502
|
+
if (rawCursor !== null && rawCursor !== "") {
|
|
2503
|
+
before = decodeCursor(rawCursor);
|
|
2504
|
+
if (before === null) {
|
|
2505
|
+
writeJson(res, 400, { reason: "invalid_cursor" });
|
|
2506
|
+
return;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
const limit = parseLimit(params, "limit");
|
|
2510
|
+
if (limit === null) {
|
|
2511
|
+
writeJson(res, 400, { reason: "invalid_limit" });
|
|
2512
|
+
return;
|
|
2513
|
+
}
|
|
2514
|
+
const page = await fusion.getSessionTimeline(id, {
|
|
2515
|
+
before,
|
|
2516
|
+
limit
|
|
2517
|
+
});
|
|
2518
|
+
if (!(page.sources.dshLive || page.sources.dshCold || page.sources.sidecarReplay || page.sources.sidecarBuffer) && page.entries.length === 0) {
|
|
2519
|
+
if (!(deps.store.getBoardState().sessions.some((s) => s.session_id === id) || fusion.getUnifiedSessions().some((s) => s.sessionId === id))) {
|
|
2520
|
+
writeJson(res, 404, { reason: "session_not_found" });
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
writeJson(res, 200, timelineBody(page));
|
|
2525
|
+
};
|
|
2526
|
+
const handleLineage = async (res, rawId) => {
|
|
2527
|
+
const fusion = deps.fusion;
|
|
2528
|
+
if (fusion === void 0) {
|
|
2529
|
+
writeJson(res, 501, { reason: "fusion_not_wired" });
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
const id = decodeId(res, rawId);
|
|
2533
|
+
if (id === null) return;
|
|
2534
|
+
writeJson(res, 200, await fusion.getLineage(id));
|
|
2535
|
+
};
|
|
2536
|
+
const handleSearch = async (res, params) => {
|
|
2537
|
+
const fusion = deps.fusion;
|
|
2538
|
+
if (fusion === void 0) {
|
|
2539
|
+
writeJson(res, 501, { reason: "fusion_not_wired" });
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2542
|
+
const query = (params.get("q") ?? "").trim();
|
|
2543
|
+
const project = (params.get("project") ?? "").trim();
|
|
2544
|
+
if (query === "" && project === "") {
|
|
2545
|
+
writeJson(res, 400, {
|
|
2546
|
+
reason: "invalid_request",
|
|
2547
|
+
detail: "search needs q= (text query) and/or project= (project filter)"
|
|
2548
|
+
});
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
const limit = parseLimit(params, "limit");
|
|
2552
|
+
if (limit === null) {
|
|
2553
|
+
writeJson(res, 400, { reason: "invalid_limit" });
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
let mode;
|
|
2557
|
+
let items;
|
|
2558
|
+
if (query !== "") {
|
|
2559
|
+
const result = await fusion.searchSessions(query, limit === void 0 ? {} : { limit });
|
|
2560
|
+
mode = result.mode;
|
|
2561
|
+
items = result.items;
|
|
2562
|
+
} else {
|
|
2563
|
+
mode = "filter-only";
|
|
2564
|
+
items = fusion.getUnifiedSessions().map((session) => ({
|
|
2565
|
+
session,
|
|
2566
|
+
matchedBy: "project",
|
|
2567
|
+
snippet: null
|
|
2568
|
+
}));
|
|
2569
|
+
}
|
|
2570
|
+
if (project !== "") {
|
|
2571
|
+
const wanted = normalizeProjectKey(project);
|
|
2572
|
+
items = items.filter((item) => normalizeProjectKey(item.session.project) === wanted);
|
|
2573
|
+
}
|
|
2574
|
+
items = items.slice(0, limit ?? DEFAULT_SEARCH_ROUTE_LIMIT);
|
|
2575
|
+
writeJson(res, 200, {
|
|
2576
|
+
mode,
|
|
2577
|
+
query,
|
|
2578
|
+
project: project === "" ? null : project,
|
|
2579
|
+
items
|
|
2580
|
+
});
|
|
2581
|
+
};
|
|
2582
|
+
const handleProjects = (res) => {
|
|
2583
|
+
const fusion = deps.fusion;
|
|
2584
|
+
if (fusion === void 0) {
|
|
2585
|
+
writeJson(res, 501, { reason: "fusion_not_wired" });
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
writeJson(res, 200, { groups: fusion.getProjectGroups() });
|
|
2589
|
+
};
|
|
2590
|
+
/**
|
|
2591
|
+
* Route-log discipline (S8): only the action type, status and vocabulary
|
|
2592
|
+
* codes — never the message body, preview, or gateway detail text.
|
|
2593
|
+
*/
|
|
2594
|
+
const logAction = (type, status, meta = {}) => {
|
|
2595
|
+
deps.log("info", "action handled", {
|
|
2596
|
+
type,
|
|
2597
|
+
status,
|
|
2598
|
+
...meta
|
|
2599
|
+
});
|
|
2600
|
+
};
|
|
2601
|
+
const handlePrepare = async (gateway, envelope, res) => {
|
|
2602
|
+
const rawTarget = envelope.target;
|
|
2603
|
+
const targetObj = typeof rawTarget === "object" && rawTarget !== null ? rawTarget : void 0;
|
|
2604
|
+
const agent = targetObj?.agent;
|
|
2605
|
+
const sessionId = targetObj?.sessionId;
|
|
2606
|
+
const mode = envelope.mode;
|
|
2607
|
+
const message = envelope.message;
|
|
2608
|
+
if (typeof agent !== "string" || typeof sessionId !== "string" || mode !== "queue" && mode !== "steer" || typeof message !== "string") {
|
|
2609
|
+
logAction("inject.prepare", 400, { reason: "invalid_request" });
|
|
2610
|
+
writeJson(res, 400, {
|
|
2611
|
+
reason: "invalid_request",
|
|
2612
|
+
detail: "inject.prepare needs target{agent,sessionId}, mode queue|steer, and a string message"
|
|
2613
|
+
});
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
const result = await gateway.prepare({
|
|
2617
|
+
target: {
|
|
2618
|
+
agent,
|
|
2619
|
+
sessionId
|
|
2620
|
+
},
|
|
2621
|
+
mode,
|
|
2622
|
+
message
|
|
2623
|
+
});
|
|
2624
|
+
if (result.ok) {
|
|
2625
|
+
logAction("inject.prepare", 200, { requestId: result.requestId });
|
|
2626
|
+
writeJson(res, 200, {
|
|
2627
|
+
requestId: result.requestId,
|
|
2628
|
+
confirmToken: result.confirmToken,
|
|
2629
|
+
plan: result.plan,
|
|
2630
|
+
expiresAt: result.expiresAt
|
|
2631
|
+
});
|
|
2632
|
+
return;
|
|
2633
|
+
}
|
|
2634
|
+
const status = PREPARE_ERROR_STATUS[result.errorCode] ?? 400;
|
|
2635
|
+
logAction("inject.prepare", status, { errorCode: result.errorCode });
|
|
2636
|
+
writeJson(res, status, {
|
|
2637
|
+
reason: result.errorCode,
|
|
2638
|
+
...result.detail !== void 0 ? { detail: result.detail } : {}
|
|
2639
|
+
});
|
|
2640
|
+
};
|
|
2641
|
+
const handleExecute = async (gateway, envelope, res) => {
|
|
2642
|
+
const { requestId, confirmToken, message } = envelope;
|
|
2643
|
+
if (typeof requestId !== "string" || typeof confirmToken !== "string" || typeof message !== "string") {
|
|
2644
|
+
logAction("inject.execute", 400, { reason: "invalid_request" });
|
|
2645
|
+
writeJson(res, 400, {
|
|
2646
|
+
reason: "invalid_request",
|
|
2647
|
+
detail: "inject.execute needs string requestId, confirmToken and message"
|
|
2648
|
+
});
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
const result = await gateway.execute({
|
|
2652
|
+
requestId,
|
|
2653
|
+
confirmToken,
|
|
2654
|
+
message
|
|
2655
|
+
});
|
|
2656
|
+
const status = result.outcome === "failed" ? EXECUTE_ERROR_STATUS[result.errorCode ?? ""] ?? 502 : 200;
|
|
2657
|
+
logAction("inject.execute", status, {
|
|
2658
|
+
outcome: result.outcome,
|
|
2659
|
+
...result.errorCode !== void 0 ? { errorCode: result.errorCode } : {},
|
|
2660
|
+
...result.replayed !== void 0 ? { replayed: result.replayed } : {}
|
|
2661
|
+
});
|
|
2662
|
+
writeJson(res, status, result);
|
|
2663
|
+
};
|
|
2664
|
+
/**
|
|
2665
|
+
* Answer one engine result: status per {@link ANALYSIS_ERROR_STATUS},
|
|
2666
|
+
* body is the result verbatim (it already carries outcome /
|
|
2667
|
+
* analysisSessionId / summary / truncated / disclaimer). The log line
|
|
2668
|
+
* keeps only outcome/codes/ids — never summaries or questions (S8).
|
|
2669
|
+
*/
|
|
2670
|
+
const respondAnalysisResult = (type, res, result) => {
|
|
2671
|
+
const status = result.errorCode !== void 0 ? ANALYSIS_ERROR_STATUS[result.errorCode] ?? 502 : 200;
|
|
2672
|
+
logAction(type, status, {
|
|
2673
|
+
outcome: result.outcome,
|
|
2674
|
+
...result.errorCode !== void 0 ? { errorCode: result.errorCode } : {},
|
|
2675
|
+
...result.analysisSessionId !== void 0 ? { analysisSessionId: result.analysisSessionId } : {},
|
|
2676
|
+
...result.truncated ? { truncated: true } : {}
|
|
2677
|
+
});
|
|
2678
|
+
writeJson(res, status, result);
|
|
2679
|
+
};
|
|
2680
|
+
const rejectInvalidAnalysis = (type, res, detail) => {
|
|
2681
|
+
logAction(type, 400, { reason: "invalid_request" });
|
|
2682
|
+
writeJson(res, 400, {
|
|
2683
|
+
reason: "invalid_request",
|
|
2684
|
+
detail
|
|
2685
|
+
});
|
|
2686
|
+
};
|
|
2687
|
+
const handleAnalysisRequest = async (analysis, envelope, res) => {
|
|
2688
|
+
const { targetKind, targetId, question } = envelope;
|
|
2689
|
+
if (targetKind !== "session" && targetKind !== "project" && targetKind !== "cross-agent" || targetId !== void 0 && typeof targetId !== "string" || question !== void 0 && typeof question !== "string") {
|
|
2690
|
+
rejectInvalidAnalysis("analysis.request", res, "analysis.request needs targetKind session|project|cross-agent, optional string targetId and question");
|
|
2691
|
+
return;
|
|
2692
|
+
}
|
|
2693
|
+
if ((targetKind === "session" || targetKind === "project") && (targetId === void 0 || targetId === "")) {
|
|
2694
|
+
rejectInvalidAnalysis("analysis.request", res, `analysis.request with targetKind ${targetKind} needs a non-empty targetId`);
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
const input = await analysis.buildInput({
|
|
2698
|
+
targetKind,
|
|
2699
|
+
...targetId !== void 0 ? { targetId } : {},
|
|
2700
|
+
...question !== void 0 ? { question } : {}
|
|
2701
|
+
});
|
|
2702
|
+
if (input === null) {
|
|
2703
|
+
logAction("analysis.request", 404, {
|
|
2704
|
+
reason: "target_not_found",
|
|
2705
|
+
targetKind
|
|
2706
|
+
});
|
|
2707
|
+
writeJson(res, 404, { reason: "target_not_found" });
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
respondAnalysisResult("analysis.request", res, await analysis.engine.request(input));
|
|
2711
|
+
};
|
|
2712
|
+
const handleAnalysisFollowup = async (analysis, envelope, res) => {
|
|
2713
|
+
const { analysisSessionId, question } = envelope;
|
|
2714
|
+
if (typeof analysisSessionId !== "string" || analysisSessionId === "" || typeof question !== "string" || question === "") {
|
|
2715
|
+
rejectInvalidAnalysis("analysis.followup", res, "analysis.followup needs non-empty string analysisSessionId and question");
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
respondAnalysisResult("analysis.followup", res, await analysis.engine.followup(analysisSessionId, question));
|
|
2719
|
+
};
|
|
2720
|
+
const handleAnalysisCancel = async (analysis, envelope, res) => {
|
|
2721
|
+
const { analysisSessionId } = envelope;
|
|
2722
|
+
if (typeof analysisSessionId !== "string" || analysisSessionId === "") {
|
|
2723
|
+
rejectInvalidAnalysis("analysis.cancel", res, "analysis.cancel needs a non-empty string analysisSessionId");
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
await analysis.engine.cancel(analysisSessionId);
|
|
2727
|
+
logAction("analysis.cancel", 200, { analysisSessionId });
|
|
2728
|
+
writeJson(res, 200, {
|
|
2729
|
+
ok: true,
|
|
2730
|
+
analysisSessionId
|
|
2731
|
+
});
|
|
2732
|
+
};
|
|
2733
|
+
/** M2 dispatcher over the action envelope (gateway present, guard 1-4 passed). */
|
|
2734
|
+
const handleAction = async (gateway, verdict, req, res) => {
|
|
2735
|
+
const body = await readActionBody(req);
|
|
2736
|
+
if (body.kind === "too_large") {
|
|
2737
|
+
deps.log("warn", "action rejected", {
|
|
2738
|
+
reason: "body_too_large",
|
|
2739
|
+
limit: MAX_ACTION_BODY_BYTES
|
|
2740
|
+
});
|
|
2741
|
+
writeJson(res, 400, { reason: "body_too_large" });
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
if (body.kind === "error") {
|
|
2745
|
+
deps.log("warn", "action rejected", { reason: "body_read_error" });
|
|
2746
|
+
writeJson(res, 400, { reason: "body_read_error" });
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
let parsed;
|
|
2750
|
+
try {
|
|
2751
|
+
parsed = JSON.parse(body.text);
|
|
2752
|
+
} catch {
|
|
2753
|
+
deps.log("warn", "action rejected", { reason: "invalid_json" });
|
|
2754
|
+
writeJson(res, 400, { reason: "invalid_json" });
|
|
2755
|
+
return;
|
|
2756
|
+
}
|
|
2757
|
+
const envelope = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
2758
|
+
if (envelope !== null) {
|
|
2759
|
+
const type = typeof envelope.type === "string" ? envelope.type : null;
|
|
2760
|
+
if (type === "daemon.retry") {
|
|
2761
|
+
deps.supervisor.retry();
|
|
2762
|
+
const state = deps.supervisor.state;
|
|
2763
|
+
logAction("daemon.retry", 200, { state });
|
|
2764
|
+
writeJson(res, 200, { state });
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2767
|
+
if (type === "inject.prepare" || type === "inject.execute") {
|
|
2768
|
+
const writeVerdict = guardWriteAction(verdict, deps.guardOptions);
|
|
2769
|
+
if (!writeVerdict.ok) {
|
|
2770
|
+
logAction(type, writeVerdict.status, { reason: writeVerdict.reason });
|
|
2771
|
+
writeJson(res, writeVerdict.status, { reason: writeVerdict.reason });
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
if (type === "inject.prepare") await handlePrepare(gateway, envelope, res);
|
|
2775
|
+
else await handleExecute(gateway, envelope, res);
|
|
2776
|
+
return;
|
|
2777
|
+
}
|
|
2778
|
+
if (type !== null && ANALYSIS_ACTION_TYPES.has(type)) {
|
|
2779
|
+
if (type !== "analysis.cancel" && (deps.analysisEnabled === void 0 || !deps.analysisEnabled())) {
|
|
2780
|
+
logAction(type, 403, { reason: "analysis_disabled" });
|
|
2781
|
+
writeJson(res, 403, { reason: "analysis_disabled" });
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2784
|
+
const analysis = deps.analysis;
|
|
2785
|
+
if (analysis === void 0 || !analysis.available()) {
|
|
2786
|
+
logAction(type, 501, { reason: "analysis_unavailable" });
|
|
2787
|
+
writeJson(res, 501, { reason: "analysis_unavailable" });
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
if (type === "analysis.request" && analysis.modelConfigured !== void 0 && !analysis.modelConfigured()) {
|
|
2791
|
+
logAction(type, 403, { reason: "analysis_model_unconfigured" });
|
|
2792
|
+
writeJson(res, 403, { reason: "analysis_model_unconfigured" });
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
if (type === "analysis.request") await handleAnalysisRequest(analysis, envelope, res);
|
|
2796
|
+
else if (type === "analysis.followup") await handleAnalysisFollowup(analysis, envelope, res);
|
|
2797
|
+
else await handleAnalysisCancel(analysis, envelope, res);
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
deps.log("warn", "action rejected", { reason: "unknown_action" });
|
|
2802
|
+
writeJson(res, 400, { reason: "unknown_action" });
|
|
2803
|
+
};
|
|
2804
|
+
const handle = async (req, res) => {
|
|
2805
|
+
if (disposed) {
|
|
2806
|
+
writeJson(res, 503, { reason: "shutting_down" });
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
const verdict = guardRequest(req, deps.guardOptions);
|
|
2810
|
+
const method = (req.method ?? "").toUpperCase();
|
|
2811
|
+
const subpath = subpathOf(req.url);
|
|
2812
|
+
if (!(verdict.ok && subpath === "action" && method === "POST" && deps.injectGateway !== void 0) && (method === "POST" || method === "PUT" || method === "PATCH")) req.resume();
|
|
2813
|
+
if (!verdict.ok) {
|
|
2814
|
+
writeJson(res, verdict.status, { reason: verdict.reason });
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
if (subpath === null || subpath === "") {
|
|
2818
|
+
writeJson(res, 404, { reason: "not_found" });
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
2821
|
+
if (subpath === "state") {
|
|
2822
|
+
if (method !== "GET") return writeMethodNotAllowed(res, "GET");
|
|
2823
|
+
writeJson(res, 200, buildSnapshot());
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
if (subpath === "stream") {
|
|
2827
|
+
if (method !== "GET") return writeMethodNotAllowed(res, "GET");
|
|
2828
|
+
acceptStream(res);
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2831
|
+
if (subpath === "action") {
|
|
2832
|
+
if (method !== "POST") return writeMethodNotAllowed(res, "POST");
|
|
2833
|
+
const gateway = deps.injectGateway;
|
|
2834
|
+
if (gateway === void 0) {
|
|
2835
|
+
const writeVerdict = guardWriteAction(verdict, deps.guardOptions);
|
|
2836
|
+
if (!writeVerdict.ok) {
|
|
2837
|
+
writeJson(res, writeVerdict.status, { reason: writeVerdict.reason });
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2840
|
+
writeJson(res, 501, { reason: "not_implemented_until_m2" });
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
await handleAction(gateway, verdict, req, res);
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
if (subpath === "projects") {
|
|
2847
|
+
if (method !== "GET") return writeMethodNotAllowed(res, "GET");
|
|
2848
|
+
handleProjects(res);
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
if (subpath === "search") {
|
|
2852
|
+
if (method !== "GET") return writeMethodNotAllowed(res, "GET");
|
|
2853
|
+
await handleSearch(res, queryOf(req.url));
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
if (subpath.startsWith("lineage/")) {
|
|
2857
|
+
if (method !== "GET") return writeMethodNotAllowed(res, "GET");
|
|
2858
|
+
await handleLineage(res, subpath.slice(8));
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
if (subpath.startsWith("session/")) {
|
|
2862
|
+
if (method !== "GET") return writeMethodNotAllowed(res, "GET");
|
|
2863
|
+
const rest = subpath.slice(8);
|
|
2864
|
+
if (rest.endsWith("/timeline")) {
|
|
2865
|
+
await handleTimeline(res, rest.slice(0, -9), queryOf(req.url));
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
await handleSession(res, rest);
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
writeJson(res, 404, { reason: "not_found" });
|
|
2872
|
+
};
|
|
2873
|
+
const dispose = () => {
|
|
2874
|
+
if (disposed) return;
|
|
2875
|
+
disposed = true;
|
|
2876
|
+
for (const unsubscribe of unsubscribes) unsubscribe();
|
|
2877
|
+
for (const client of [...clients]) {
|
|
2878
|
+
cleanupClient(client);
|
|
2879
|
+
client.res.end();
|
|
2880
|
+
}
|
|
2881
|
+
deps.log("info", "routes disposed");
|
|
2882
|
+
};
|
|
2883
|
+
return {
|
|
2884
|
+
handle,
|
|
2885
|
+
dispose
|
|
2886
|
+
};
|
|
2887
|
+
}
|
|
2888
|
+
//#endregion
|
|
2889
|
+
//#region src/send-cli.ts
|
|
2890
|
+
const DEFAULT_SEND_CLI_COMMAND = Object.freeze(["agent-sidecar"]);
|
|
2891
|
+
/** Detail cap for collected stderr (2 KiB). */
|
|
2892
|
+
const STDERR_DETAIL_BYTES = 2 * 1024;
|
|
2893
|
+
/** send --json responses are ≤4 MiB; anything past this is garbage. */
|
|
2894
|
+
const MAX_STDOUT_BYTES = 8 * 1024 * 1024;
|
|
2895
|
+
/** sidecar/inject.py MAX_SEND_TIMEOUT_SECONDS. */
|
|
2896
|
+
const MAX_CLI_TIMEOUT_SECONDS = 900;
|
|
2897
|
+
/** Byte-bounded chunk accumulator; excess input is dropped, not buffered. */
|
|
2898
|
+
var BoundedCollector = class {
|
|
2899
|
+
limit;
|
|
2900
|
+
chunks = [];
|
|
2901
|
+
size = 0;
|
|
2902
|
+
constructor(limit) {
|
|
2903
|
+
this.limit = limit;
|
|
2904
|
+
}
|
|
2905
|
+
append(chunk) {
|
|
2906
|
+
if (this.size >= this.limit) return;
|
|
2907
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
|
|
2908
|
+
const room = this.limit - this.size;
|
|
2909
|
+
const kept = buf.byteLength > room ? buf.subarray(0, room) : buf;
|
|
2910
|
+
this.chunks.push(Buffer.from(kept));
|
|
2911
|
+
this.size += kept.byteLength;
|
|
2912
|
+
}
|
|
2913
|
+
get bytes() {
|
|
2914
|
+
return this.size;
|
|
2915
|
+
}
|
|
2916
|
+
text() {
|
|
2917
|
+
return Buffer.concat(this.chunks).toString("utf8");
|
|
2918
|
+
}
|
|
2919
|
+
};
|
|
2920
|
+
/**
|
|
2921
|
+
* Parse stdout as one `send --json` receipt. Anything that is not a JSON
|
|
2922
|
+
* object carrying a valid `delivery` field yields null (exit-code fallback).
|
|
2923
|
+
*/
|
|
2924
|
+
function parseReceipt(stdoutText) {
|
|
2925
|
+
const trimmed = stdoutText.trim();
|
|
2926
|
+
if (!trimmed) return null;
|
|
2927
|
+
let value;
|
|
2928
|
+
try {
|
|
2929
|
+
value = JSON.parse(trimmed);
|
|
2930
|
+
} catch {
|
|
2931
|
+
return null;
|
|
2932
|
+
}
|
|
2933
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
2934
|
+
const record = value;
|
|
2935
|
+
const delivery = record["delivery"];
|
|
2936
|
+
if (delivery !== "delivered" && delivery !== "unknown") return null;
|
|
2937
|
+
const rawErrorCode = record["error_code"];
|
|
2938
|
+
const errorCode = typeof rawErrorCode === "string" && rawErrorCode !== "" ? rawErrorCode : void 0;
|
|
2939
|
+
return {
|
|
2940
|
+
delivery,
|
|
2941
|
+
...errorCode !== void 0 ? { errorCode } : {},
|
|
2942
|
+
replayed: record["replayed"] === true
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
function describeError$1(error) {
|
|
2946
|
+
return error instanceof Error ? error.message : String(error);
|
|
2947
|
+
}
|
|
2948
|
+
function createSendCliExecutor(deps) {
|
|
2949
|
+
const command = deps.opts?.command ?? DEFAULT_SEND_CLI_COMMAND;
|
|
2950
|
+
const timeoutMs = deps.opts?.timeoutMs ?? 3e4;
|
|
2951
|
+
const bufferMs = deps.opts?.hardTimeoutBufferMs ?? 5e3;
|
|
2952
|
+
const log = deps.log ?? (() => {});
|
|
2953
|
+
const cliTimeoutSecs = Math.min(MAX_CLI_TIMEOUT_SECONDS, Math.max(1, Math.floor(timeoutMs / 1e3)));
|
|
2954
|
+
const hardTimeoutMs = timeoutMs + bufferMs;
|
|
2955
|
+
return {
|
|
2956
|
+
kind: "send-cli",
|
|
2957
|
+
async execute(req) {
|
|
2958
|
+
const argv = [
|
|
2959
|
+
...command,
|
|
2960
|
+
"send",
|
|
2961
|
+
req.target.sessionId,
|
|
2962
|
+
"--message-stdin",
|
|
2963
|
+
"--allow-write",
|
|
2964
|
+
"--json",
|
|
2965
|
+
"--request-id",
|
|
2966
|
+
req.requestId,
|
|
2967
|
+
"--timeout",
|
|
2968
|
+
String(cliTimeoutSecs)
|
|
2969
|
+
];
|
|
2970
|
+
log("debug", "spawning sidecar send CLI", {
|
|
2971
|
+
requestId: req.requestId,
|
|
2972
|
+
agent: req.target.agent,
|
|
2973
|
+
sessionId: req.target.sessionId,
|
|
2974
|
+
mode: req.mode,
|
|
2975
|
+
timeoutSecs: cliTimeoutSecs
|
|
2976
|
+
});
|
|
2977
|
+
let proc;
|
|
2978
|
+
try {
|
|
2979
|
+
proc = deps.spawn(argv);
|
|
2980
|
+
} catch (error) {
|
|
2981
|
+
log("warn", "send CLI spawn failed", {
|
|
2982
|
+
requestId: req.requestId,
|
|
2983
|
+
error: describeError$1(error)
|
|
2984
|
+
});
|
|
2985
|
+
return {
|
|
2986
|
+
outcome: "failed",
|
|
2987
|
+
errorCode: "cli_not_found",
|
|
2988
|
+
detail: describeError$1(error)
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
const stdout = new BoundedCollector(MAX_STDOUT_BYTES);
|
|
2992
|
+
const stderr = new BoundedCollector(STDERR_DETAIL_BYTES);
|
|
2993
|
+
proc.onStdout((chunk) => stdout.append(chunk));
|
|
2994
|
+
proc.onStderr((chunk) => stderr.append(chunk));
|
|
2995
|
+
try {
|
|
2996
|
+
proc.stdin.write(Buffer.from(req.message, "utf8"));
|
|
2997
|
+
proc.stdin.end();
|
|
2998
|
+
} catch {}
|
|
2999
|
+
let timer;
|
|
3000
|
+
const settled = await Promise.race([proc.exited.then((code) => ({
|
|
3001
|
+
kind: "exit",
|
|
3002
|
+
code
|
|
3003
|
+
}), (error) => ({
|
|
3004
|
+
kind: "spawn-error",
|
|
3005
|
+
error
|
|
3006
|
+
})), new Promise((resolve) => {
|
|
3007
|
+
timer = setTimeout(() => resolve({ kind: "timeout" }), hardTimeoutMs);
|
|
3008
|
+
})]);
|
|
3009
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
3010
|
+
const stderrText = stderr.text();
|
|
3011
|
+
if (settled.kind === "timeout") {
|
|
3012
|
+
try {
|
|
3013
|
+
proc.kill();
|
|
3014
|
+
} catch {}
|
|
3015
|
+
log("warn", "send CLI hard timeout; process killed", {
|
|
3016
|
+
requestId: req.requestId,
|
|
3017
|
+
hardTimeoutMs
|
|
3018
|
+
});
|
|
3019
|
+
return {
|
|
3020
|
+
outcome: "unknown",
|
|
3021
|
+
errorCode: "timeout",
|
|
3022
|
+
detail: stderrText ? `no exit within ${hardTimeoutMs}ms; killed; stderr: ${stderrText}` : `no exit within ${hardTimeoutMs}ms; killed`
|
|
3023
|
+
};
|
|
3024
|
+
}
|
|
3025
|
+
if (settled.kind === "spawn-error") {
|
|
3026
|
+
log("warn", "send CLI could not be started", {
|
|
3027
|
+
requestId: req.requestId,
|
|
3028
|
+
error: describeError$1(settled.error)
|
|
3029
|
+
});
|
|
3030
|
+
return {
|
|
3031
|
+
outcome: "failed",
|
|
3032
|
+
errorCode: "cli_not_found",
|
|
3033
|
+
detail: describeError$1(settled.error)
|
|
3034
|
+
};
|
|
3035
|
+
}
|
|
3036
|
+
const receipt = parseReceipt(stdout.text());
|
|
3037
|
+
log("info", "send CLI exited", {
|
|
3038
|
+
requestId: req.requestId,
|
|
3039
|
+
agent: req.target.agent,
|
|
3040
|
+
sessionId: req.target.sessionId,
|
|
3041
|
+
exitCode: settled.code,
|
|
3042
|
+
parsedReceipt: receipt !== null,
|
|
3043
|
+
...receipt !== null ? {
|
|
3044
|
+
delivery: receipt.delivery,
|
|
3045
|
+
...receipt.errorCode !== void 0 ? { errorCode: receipt.errorCode } : {},
|
|
3046
|
+
replayed: receipt.replayed
|
|
3047
|
+
} : {},
|
|
3048
|
+
stdoutBytes: stdout.bytes,
|
|
3049
|
+
stderrBytes: stderr.bytes
|
|
3050
|
+
});
|
|
3051
|
+
if (receipt !== null) return {
|
|
3052
|
+
outcome: receipt.delivery === "delivered" ? "delivered" : "unknown",
|
|
3053
|
+
...receipt.errorCode !== void 0 ? { errorCode: receipt.errorCode } : {},
|
|
3054
|
+
...receipt.replayed ? { replayed: true } : {},
|
|
3055
|
+
...stderrText ? { detail: stderrText } : {}
|
|
3056
|
+
};
|
|
3057
|
+
const code = settled.code;
|
|
3058
|
+
if (code === 0) return {
|
|
3059
|
+
outcome: "delivered",
|
|
3060
|
+
detail: stderrText ? `parse_warning: exit 0 but stdout was not a send --json receipt; stderr: ${stderrText}` : "parse_warning: exit 0 but stdout was not a send --json receipt"
|
|
3061
|
+
};
|
|
3062
|
+
const base = { outcome: "failed" };
|
|
3063
|
+
if (code === 2) base.errorCode = "usage_error";
|
|
3064
|
+
else if (code === 130) base.errorCode = "interrupted";
|
|
3065
|
+
else if (code !== 1) base.errorCode = `exit_${code ?? "signal"}`;
|
|
3066
|
+
if (stderrText) base.detail = stderrText;
|
|
3067
|
+
return base;
|
|
3068
|
+
}
|
|
3069
|
+
};
|
|
3070
|
+
}
|
|
3071
|
+
//#endregion
|
|
3072
|
+
//#region src/session-store.ts
|
|
3073
|
+
/** Bound for the last-event text summary kept per session. */
|
|
3074
|
+
const EVENT_TEXT_LIMIT = 160;
|
|
3075
|
+
function sessionKey(agent, sessionId) {
|
|
3076
|
+
return `${agent}\u0000${sessionId}`;
|
|
3077
|
+
}
|
|
3078
|
+
function truncate(text, limit) {
|
|
3079
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
|
|
3080
|
+
}
|
|
3081
|
+
/**
|
|
3082
|
+
* Extract a usable seq cursor. Mirrors `_sequence` in
|
|
3083
|
+
* `sidecar/adapters/dsh.py` (integer only); JSON cannot distinguish
|
|
3084
|
+
* `3.0` from `3` on the JS side, so `Number.isInteger` is the closest
|
|
3085
|
+
* faithful check.
|
|
3086
|
+
*/
|
|
3087
|
+
function extractSeq(extra) {
|
|
3088
|
+
const value = extra["seq"];
|
|
3089
|
+
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
|
3090
|
+
}
|
|
3091
|
+
/** In-memory session cache reconciled by snapshots, hinted by events. */
|
|
3092
|
+
var SessionStore = class {
|
|
3093
|
+
rows = /* @__PURE__ */ new Map();
|
|
3094
|
+
eventState = /* @__PURE__ */ new Map();
|
|
3095
|
+
streamHealth = "unknown";
|
|
3096
|
+
lastReconcileAt = null;
|
|
3097
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3098
|
+
/** Replace the full session set with an authoritative snapshot. */
|
|
3099
|
+
applySnapshot(rows) {
|
|
3100
|
+
const next = /* @__PURE__ */ new Map();
|
|
3101
|
+
for (const row of rows) {
|
|
3102
|
+
if (typeof row.session_id !== "string" || row.session_id === "") continue;
|
|
3103
|
+
next.set(sessionKey(row.agent, row.session_id), row);
|
|
3104
|
+
}
|
|
3105
|
+
this.rows = next;
|
|
3106
|
+
const staleKeys = [];
|
|
3107
|
+
for (const [key, state] of this.eventState) if (next.has(key)) state.gap = false;
|
|
3108
|
+
else staleKeys.push(key);
|
|
3109
|
+
for (const key of staleKeys) this.eventState.delete(key);
|
|
3110
|
+
this.lastReconcileAt = Date.now();
|
|
3111
|
+
this.notify();
|
|
3112
|
+
}
|
|
3113
|
+
/** Fold one stream event in as a hint (summary + seq continuity only). */
|
|
3114
|
+
applyEvent(ev) {
|
|
3115
|
+
const key = sessionKey(ev.agent, ev.session_id);
|
|
3116
|
+
let state = this.eventState.get(key);
|
|
3117
|
+
if (state === void 0) {
|
|
3118
|
+
state = {
|
|
3119
|
+
lastEvent: null,
|
|
3120
|
+
lastSeq: null,
|
|
3121
|
+
gap: false
|
|
3122
|
+
};
|
|
3123
|
+
this.eventState.set(key, state);
|
|
3124
|
+
}
|
|
3125
|
+
state.lastEvent = {
|
|
3126
|
+
ts: ev.ts,
|
|
3127
|
+
kind: ev.kind,
|
|
3128
|
+
text: truncate(ev.text, EVENT_TEXT_LIMIT)
|
|
3129
|
+
};
|
|
3130
|
+
const seq = extractSeq(ev.extra);
|
|
3131
|
+
if (seq !== null) {
|
|
3132
|
+
if (state.lastSeq !== null && seq > state.lastSeq + 1) state.gap = true;
|
|
3133
|
+
state.lastSeq = seq;
|
|
3134
|
+
}
|
|
3135
|
+
this.notify();
|
|
3136
|
+
}
|
|
3137
|
+
/** Stream health is owned by the Reconciler; the store just exposes it. */
|
|
3138
|
+
setStreamHealth(health) {
|
|
3139
|
+
if (this.streamHealth === health) return;
|
|
3140
|
+
this.streamHealth = health;
|
|
3141
|
+
this.notify();
|
|
3142
|
+
}
|
|
3143
|
+
/** True when any snapshot session is currently `working` (active cadence). */
|
|
3144
|
+
hasWorkingSessions() {
|
|
3145
|
+
for (const row of this.rows.values()) if (row.status === "working") return true;
|
|
3146
|
+
return false;
|
|
3147
|
+
}
|
|
3148
|
+
getBoardState() {
|
|
3149
|
+
const sessions = [];
|
|
3150
|
+
for (const [key, row] of this.rows) {
|
|
3151
|
+
const state = this.eventState.get(key);
|
|
3152
|
+
sessions.push({
|
|
3153
|
+
agent: row.agent,
|
|
3154
|
+
session_id: row.session_id,
|
|
3155
|
+
status: row.status,
|
|
3156
|
+
title: row.title,
|
|
3157
|
+
project: row.project,
|
|
3158
|
+
updated_at: row.updated_at,
|
|
3159
|
+
last_event: state?.lastEvent ?? null,
|
|
3160
|
+
gap: state?.gap ?? false
|
|
3161
|
+
});
|
|
3162
|
+
}
|
|
3163
|
+
sessions.sort((a, b) => b.updated_at - a.updated_at || a.session_id.localeCompare(b.session_id));
|
|
3164
|
+
return {
|
|
3165
|
+
sessions,
|
|
3166
|
+
streamHealth: this.streamHealth,
|
|
3167
|
+
lastReconcileAt: this.lastReconcileAt
|
|
3168
|
+
};
|
|
3169
|
+
}
|
|
3170
|
+
/** Subscribe to store mutations; returns the unsubscribe function. */
|
|
3171
|
+
onChange(cb) {
|
|
3172
|
+
this.listeners.add(cb);
|
|
3173
|
+
return () => {
|
|
3174
|
+
this.listeners.delete(cb);
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
notify() {
|
|
3178
|
+
for (const listener of this.listeners) listener();
|
|
3179
|
+
}
|
|
3180
|
+
};
|
|
3181
|
+
//#endregion
|
|
3182
|
+
//#region src/skills-provider.ts
|
|
3183
|
+
/** Registry name of this provider (distinct from the skill it serves). */
|
|
3184
|
+
const SKILL_PROVIDER_NAME = "agent-sidecar-plugin";
|
|
3185
|
+
/** The one skill this provider serves (same name as the filesystem copy). */
|
|
3186
|
+
const SIDECAR_SKILL_NAME = "agent-sidecar";
|
|
3187
|
+
/** Routing description; aligned with skills/agent-sidecar/SKILL.md frontmatter. */
|
|
3188
|
+
const SIDECAR_SKILL_DESCRIPTION = "Monitors readonly local AI agent sessions (claude/codex/cursor/dsh/kimi/copilot) and reports their state and progress via the agent-sidecar CLI and the Sidecar board in dsh web. Use when the user asks for agent status, session progress, to monitor agents, which agent is waiting or working, or explicitly asks to send a message or feedback to an agent.";
|
|
3189
|
+
/**
|
|
3190
|
+
* dsh-scene skill body: semantically consistent with the canonical
|
|
3191
|
+
* `skills/agent-sidecar/SKILL.md`, condensed for the plugin context —
|
|
3192
|
+
* observation goes CLI/board, injection goes the plugin panel (design §7
|
|
3193
|
+
* path two: "dsh 会话注入应引导走插件通路而非 send,因 unsupported_dsh").
|
|
3194
|
+
*/
|
|
3195
|
+
const SIDECAR_SKILL_CONTENT = `# Agent Sidecar (dsh plugin edition)
|
|
3196
|
+
|
|
3197
|
+
This dsh composition runs the \`dsh-agent-sidecar\` plugin. Observation is
|
|
3198
|
+
the default; every mutation needs an explicit user request in the same turn.
|
|
3199
|
+
|
|
3200
|
+
## Observe
|
|
3201
|
+
|
|
3202
|
+
1. Check \`command -v agent-sidecar\`. If missing, do not install anything
|
|
3203
|
+
unless the user explicitly asks; point them at the agent_sidecar repo
|
|
3204
|
+
install options instead.
|
|
3205
|
+
2. Run \`agent-sidecar status --json\` first; summarize sessions by agent,
|
|
3206
|
+
status, title, project, and age from \`updated_at\`.
|
|
3207
|
+
3. Other observation commands, only when they match the request:
|
|
3208
|
+
\`list --json\` (48h window), \`list --all --json\`, \`ps --json\`,
|
|
3209
|
+
\`watch <session-prefix> --json\`, \`watch --all --json\`, \`tui\`.
|
|
3210
|
+
4. The plugin also serves a live multi-agent board in dsh web (the
|
|
3211
|
+
"Sidecar" conversation tab). Prefer pointing the user there for
|
|
3212
|
+
continuous monitoring instead of polling the CLI yourself.
|
|
3213
|
+
5. Treat \`working\`/\`waiting\` as inferred observations from persisted
|
|
3214
|
+
data, not control-plane guarantees; Cursor IDE can report \`waiting\`
|
|
3215
|
+
several minutes late.
|
|
3216
|
+
|
|
3217
|
+
## Inject (explicit request only)
|
|
3218
|
+
|
|
3219
|
+
- For **dsh sessions**, \`agent-sidecar send\` is unsupported
|
|
3220
|
+
(\`unsupported_dsh\`: DSH has neither session resume nor stdin prompt
|
|
3221
|
+
transport). Route the user to the plugin's inject panel on the Sidecar
|
|
3222
|
+
board, which injects in-process (queue/steer) behind the plugin's
|
|
3223
|
+
\`inject.enabled\` gate and confirmation dialog.
|
|
3224
|
+
- For **claude / codex / cursor-cli** sessions in \`waiting\`/\`idle\`, use
|
|
3225
|
+
the plugin panel, or run \`send\` only when the user explicitly requests
|
|
3226
|
+
the exact message or action in the same turn. Never infer consent from a
|
|
3227
|
+
request to observe, watch, report, or wait. That explicit same-turn
|
|
3228
|
+
request is the permission required to use \`--allow-write\`; never add it
|
|
3229
|
+
otherwise:
|
|
3230
|
+
|
|
3231
|
+
\`\`\`sh
|
|
3232
|
+
agent-sidecar send <session-prefix> "<exact-message>" --allow-write --request-id "<stable-unique-id>" --json
|
|
3233
|
+
\`\`\`
|
|
3234
|
+
|
|
3235
|
+
- Preserve the returned \`request_id\` and \`replayed\` fields. Never send
|
|
3236
|
+
to remote, \`working\`, \`dead\`, child, or unsupported-agent sessions
|
|
3237
|
+
(\`cursor-ide\`, \`copilot\`, \`kimi\`, \`dsh\`).
|
|
3238
|
+
- Never retry \`failed\`, \`timed_out\`, \`request_pending\`,
|
|
3239
|
+
\`audit_error\`, \`cleanup_incomplete\`, or any result with
|
|
3240
|
+
\`delivery: "unknown"\` — the agent may already have received the
|
|
3241
|
+
message. Report the unknown state plainly and ask the user what to do.
|
|
3242
|
+
- The audit store is fail-closed; never run \`agent-sidecar audit reset\`
|
|
3243
|
+
automatically.
|
|
3244
|
+
|
|
3245
|
+
## Reference
|
|
3246
|
+
|
|
3247
|
+
Full schemas, exit codes, and boundaries: \`skills/agent-sidecar/SKILL.md\`
|
|
3248
|
+
and \`reference.md\` in the agent_sidecar repository (also installable as a
|
|
3249
|
+
filesystem skill via \`scripts/install-skill.sh\`; a filesystem copy under
|
|
3250
|
+
\`~/.dsh/skills/\` automatically shadows this plugin-provided one).`;
|
|
3251
|
+
const RESOURCE_BASE = {
|
|
3252
|
+
kind: "opaque",
|
|
3253
|
+
description: "Self-contained skill provided by the dsh-agent-sidecar plugin; the canonical long-form reference (SKILL.md + reference.md) lives in the agent_sidecar repository under skills/agent-sidecar/."
|
|
3254
|
+
};
|
|
3255
|
+
const INVOCATION = {
|
|
3256
|
+
modelInvocable: true,
|
|
3257
|
+
userInvocable: true
|
|
3258
|
+
};
|
|
3259
|
+
/** The single catalog candidate this provider lists (skill-badge template). */
|
|
3260
|
+
const SIDECAR_SKILL_CANDIDATE = {
|
|
3261
|
+
name: SIDECAR_SKILL_NAME,
|
|
3262
|
+
description: SIDECAR_SKILL_DESCRIPTION,
|
|
3263
|
+
invocation: INVOCATION,
|
|
3264
|
+
source: "bundled",
|
|
3265
|
+
provider: SKILL_PROVIDER_NAME,
|
|
3266
|
+
resourceBase: RESOURCE_BASE,
|
|
3267
|
+
rank: 600,
|
|
3268
|
+
locator: SIDECAR_SKILL_NAME
|
|
3269
|
+
};
|
|
3270
|
+
/** The provider instance: one static candidate, embedded body. */
|
|
3271
|
+
const provider = {
|
|
3272
|
+
name: SKILL_PROVIDER_NAME,
|
|
3273
|
+
list: () => Promise.resolve([SIDECAR_SKILL_CANDIDATE]),
|
|
3274
|
+
get: (candidate) => Promise.resolve(candidate.name === "agent-sidecar" ? {
|
|
3275
|
+
name: SIDECAR_SKILL_NAME,
|
|
3276
|
+
description: SIDECAR_SKILL_DESCRIPTION,
|
|
3277
|
+
invocation: INVOCATION,
|
|
3278
|
+
source: "bundled",
|
|
3279
|
+
provider: SKILL_PROVIDER_NAME,
|
|
3280
|
+
resourceBase: RESOURCE_BASE,
|
|
3281
|
+
content: SIDECAR_SKILL_CONTENT
|
|
3282
|
+
} : void 0)
|
|
3283
|
+
};
|
|
3284
|
+
/**
|
|
3285
|
+
* Register the agent-sidecar skill provider on `ctx.skills`.
|
|
3286
|
+
*
|
|
3287
|
+
* Yield rule (per live test, env_facts.md): none needed beyond the rank —
|
|
3288
|
+
* dsh's registry dedupes same-name skills natively, and this provider's
|
|
3289
|
+
* BUNDLED rank (600) loses to every filesystem root, so a filesystem copy
|
|
3290
|
+
* always shadows the plugin copy and the catalog shows exactly one entry
|
|
3291
|
+
* either way. `provide=false` skips registration entirely.
|
|
3292
|
+
*
|
|
3293
|
+
* @param deps - registry face, config gate, and log sink.
|
|
3294
|
+
* @returns the registry's unregister disposer, or `null` when the gate is
|
|
3295
|
+
* off or registration failed (duplicate provider name in this layer —
|
|
3296
|
+
* only reachable if the plugin is mounted twice in one scope).
|
|
3297
|
+
*/
|
|
3298
|
+
function registerSidecarSkillProvider(deps) {
|
|
3299
|
+
if (!deps.provide) {
|
|
3300
|
+
deps.log("debug", "skill provider disabled (skill.provide=false)");
|
|
3301
|
+
return null;
|
|
3302
|
+
}
|
|
3303
|
+
try {
|
|
3304
|
+
const dispose = deps.skills.registerProvider(() => provider);
|
|
3305
|
+
deps.log("debug", "skill provider registered", {
|
|
3306
|
+
provider: SKILL_PROVIDER_NAME,
|
|
3307
|
+
skill: SIDECAR_SKILL_NAME,
|
|
3308
|
+
rank: 600
|
|
3309
|
+
});
|
|
3310
|
+
return dispose;
|
|
3311
|
+
} catch (err) {
|
|
3312
|
+
deps.log("warn", `skill provider registration failed: ${String(err)}`);
|
|
3313
|
+
return null;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
//#endregion
|
|
3317
|
+
//#region src/supervisor.ts
|
|
3318
|
+
const defaultSetTimeout = (fn, ms) => globalThis.setTimeout(fn, ms);
|
|
3319
|
+
const defaultClearTimeout = (handle) => {
|
|
3320
|
+
globalThis.clearTimeout(handle);
|
|
3321
|
+
};
|
|
3322
|
+
const describeError = (error) => error instanceof Error ? error.message : String(error);
|
|
3323
|
+
var DaemonSupervisor = class {
|
|
3324
|
+
deps;
|
|
3325
|
+
opts;
|
|
3326
|
+
_state = "probe";
|
|
3327
|
+
_lastPing = null;
|
|
3328
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3329
|
+
timers = /* @__PURE__ */ new Set();
|
|
3330
|
+
/** Only ever non-null for a process this supervisor spawned itself. */
|
|
3331
|
+
proc = null;
|
|
3332
|
+
/**
|
|
3333
|
+
* Invalidation token for async continuations (ping/detect results, process
|
|
3334
|
+
* exit watchers): each macro transition bumps it, so continuations started
|
|
3335
|
+
* under an older epoch abandon instead of acting on a stale world.
|
|
3336
|
+
*/
|
|
3337
|
+
epoch = 0;
|
|
3338
|
+
started = false;
|
|
3339
|
+
stopped = false;
|
|
3340
|
+
/** Consecutive hosting failures (readiness timeout or early exit). */
|
|
3341
|
+
hostFailures = 0;
|
|
3342
|
+
/** Consecutive ADOPTED re-ping misses. */
|
|
3343
|
+
pingFailures = 0;
|
|
3344
|
+
constructor(deps, options) {
|
|
3345
|
+
this.deps = deps;
|
|
3346
|
+
this.opts = {
|
|
3347
|
+
policy: options.policy,
|
|
3348
|
+
backoffLimit: options.backoffLimit ?? 5,
|
|
3349
|
+
backoffBaseMs: options.backoffBaseMs ?? 1e3,
|
|
3350
|
+
backoffCapMs: options.backoffCapMs ?? 3e4,
|
|
3351
|
+
probeIntervalMs: options.probeIntervalMs ?? 5e3,
|
|
3352
|
+
adoptedRepingMs: options.adoptedRepingMs ?? 5e3,
|
|
3353
|
+
adoptedFailureLimit: options.adoptedFailureLimit ?? 3,
|
|
3354
|
+
hostReadyTimeoutMs: options.hostReadyTimeoutMs ?? 5e3,
|
|
3355
|
+
hostReadyPingIntervalMs: options.hostReadyPingIntervalMs ?? 500
|
|
3356
|
+
};
|
|
3357
|
+
}
|
|
3358
|
+
get state() {
|
|
3359
|
+
return this._state;
|
|
3360
|
+
}
|
|
3361
|
+
/** Last successful ping payload; null until the daemon answered once. */
|
|
3362
|
+
get lastPing() {
|
|
3363
|
+
return this._lastPing;
|
|
3364
|
+
}
|
|
3365
|
+
/** Subscribe to state transitions; returns an unsubscribe function. */
|
|
3366
|
+
onStateChange(listener) {
|
|
3367
|
+
this.listeners.add(listener);
|
|
3368
|
+
return () => {
|
|
3369
|
+
this.listeners.delete(listener);
|
|
3370
|
+
};
|
|
3371
|
+
}
|
|
3372
|
+
start() {
|
|
3373
|
+
if (this.started || this.stopped) return;
|
|
3374
|
+
this.started = true;
|
|
3375
|
+
if (this.opts.policy === "off") {
|
|
3376
|
+
this.deps.log("info", "daemon management policy is off; supervisor standing down");
|
|
3377
|
+
this.setState("defer");
|
|
3378
|
+
return;
|
|
3379
|
+
}
|
|
3380
|
+
this.runDetermination("probe");
|
|
3381
|
+
}
|
|
3382
|
+
/**
|
|
3383
|
+
* Tear everything down for the ctx.effect disposer: probe/backoff timers
|
|
3384
|
+
* first, then terminate a self-spawned daemon. Adopted or launchd-managed
|
|
3385
|
+
* daemons are never touched. Idempotent.
|
|
3386
|
+
*/
|
|
3387
|
+
async stop() {
|
|
3388
|
+
if (this.stopped) return;
|
|
3389
|
+
this.stopped = true;
|
|
3390
|
+
this.epoch += 1;
|
|
3391
|
+
this.clearAllTimers();
|
|
3392
|
+
this.listeners.clear();
|
|
3393
|
+
const proc = this.proc;
|
|
3394
|
+
this.proc = null;
|
|
3395
|
+
if (proc) {
|
|
3396
|
+
this.deps.log("info", "stopping supervisor: terminating self-hosted daemon");
|
|
3397
|
+
try {
|
|
3398
|
+
await proc.terminate();
|
|
3399
|
+
} catch (error) {
|
|
3400
|
+
this.deps.log("warn", "terminate during stop failed", { error: describeError(error) });
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
/** From FAILED only: reset the failure budget and re-run the determination. */
|
|
3405
|
+
retry() {
|
|
3406
|
+
if (this.stopped) return;
|
|
3407
|
+
if (this._state !== "failed") {
|
|
3408
|
+
this.deps.log("debug", "retry ignored outside FAILED state", { state: this._state });
|
|
3409
|
+
return;
|
|
3410
|
+
}
|
|
3411
|
+
this.hostFailures = 0;
|
|
3412
|
+
this.pingFailures = 0;
|
|
3413
|
+
this.deps.log("info", "retry requested: failure budget reset, re-probing");
|
|
3414
|
+
this.runDetermination("probe");
|
|
3415
|
+
}
|
|
3416
|
+
/** Shared PROBE/REPROBE determination: ping → adopt, else LaunchAgent → defer, else host. */
|
|
3417
|
+
async runDetermination(entry) {
|
|
3418
|
+
const ep = ++this.epoch;
|
|
3419
|
+
this.clearAllTimers();
|
|
3420
|
+
this.setState(entry);
|
|
3421
|
+
const info = await this.safePing();
|
|
3422
|
+
if (this.invalidated(ep)) return;
|
|
3423
|
+
if (info) {
|
|
3424
|
+
this.enterAdopted(info);
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
3427
|
+
let managed = false;
|
|
3428
|
+
try {
|
|
3429
|
+
managed = await this.deps.detectLaunchAgent();
|
|
3430
|
+
} catch (error) {
|
|
3431
|
+
this.deps.log("warn", "LaunchAgent detection failed; assuming absent", { error: describeError(error) });
|
|
3432
|
+
}
|
|
3433
|
+
if (this.invalidated(ep)) return;
|
|
3434
|
+
if (managed) {
|
|
3435
|
+
this.enterDefer("LaunchAgent installed; launchd owns daemon liveness");
|
|
3436
|
+
return;
|
|
3437
|
+
}
|
|
3438
|
+
if (this.opts.policy === "adopt-only") {
|
|
3439
|
+
this.enterDefer("policy adopt-only forbids spawning");
|
|
3440
|
+
return;
|
|
3441
|
+
}
|
|
3442
|
+
this.enterHosting();
|
|
3443
|
+
}
|
|
3444
|
+
enterAdopted(info) {
|
|
3445
|
+
this.clearAllTimers();
|
|
3446
|
+
this._lastPing = info;
|
|
3447
|
+
this.pingFailures = 0;
|
|
3448
|
+
this.hostFailures = 0;
|
|
3449
|
+
this.deps.log("info", "adopted existing daemon", {
|
|
3450
|
+
pid: info.pid,
|
|
3451
|
+
version: info.version
|
|
3452
|
+
});
|
|
3453
|
+
this.setState("adopted");
|
|
3454
|
+
this.scheduleAdoptedPing();
|
|
3455
|
+
}
|
|
3456
|
+
scheduleAdoptedPing() {
|
|
3457
|
+
this.schedule(this.opts.adoptedRepingMs, () => {
|
|
3458
|
+
this.adoptedPing();
|
|
3459
|
+
});
|
|
3460
|
+
}
|
|
3461
|
+
async adoptedPing() {
|
|
3462
|
+
const ep = this.epoch;
|
|
3463
|
+
const info = await this.safePing();
|
|
3464
|
+
if (this.invalidated(ep) || this._state !== "adopted") return;
|
|
3465
|
+
if (info) {
|
|
3466
|
+
this._lastPing = info;
|
|
3467
|
+
this.pingFailures = 0;
|
|
3468
|
+
this.scheduleAdoptedPing();
|
|
3469
|
+
return;
|
|
3470
|
+
}
|
|
3471
|
+
this.pingFailures += 1;
|
|
3472
|
+
this.deps.log("warn", "adopted daemon missed ping", {
|
|
3473
|
+
misses: this.pingFailures,
|
|
3474
|
+
limit: this.opts.adoptedFailureLimit
|
|
3475
|
+
});
|
|
3476
|
+
if (this.pingFailures >= this.opts.adoptedFailureLimit) {
|
|
3477
|
+
this.pingFailures = 0;
|
|
3478
|
+
this.runDetermination("reprobe");
|
|
3479
|
+
return;
|
|
3480
|
+
}
|
|
3481
|
+
this.scheduleAdoptedPing();
|
|
3482
|
+
}
|
|
3483
|
+
/** External management (launchd, or adopt-only policy): re-probe periodically, never spawn. */
|
|
3484
|
+
enterDefer(reason) {
|
|
3485
|
+
this.clearAllTimers();
|
|
3486
|
+
this.deps.log("info", "deferring daemon management", { reason });
|
|
3487
|
+
this.setState("defer");
|
|
3488
|
+
this.scheduleDeferProbe();
|
|
3489
|
+
}
|
|
3490
|
+
scheduleDeferProbe() {
|
|
3491
|
+
this.schedule(this.opts.probeIntervalMs, () => {
|
|
3492
|
+
this.deferProbe();
|
|
3493
|
+
});
|
|
3494
|
+
}
|
|
3495
|
+
async deferProbe() {
|
|
3496
|
+
const ep = this.epoch;
|
|
3497
|
+
const info = await this.safePing();
|
|
3498
|
+
if (this.invalidated(ep) || this._state !== "defer") return;
|
|
3499
|
+
if (info) {
|
|
3500
|
+
this.enterAdopted(info);
|
|
3501
|
+
return;
|
|
3502
|
+
}
|
|
3503
|
+
this.scheduleDeferProbe();
|
|
3504
|
+
}
|
|
3505
|
+
enterHosting() {
|
|
3506
|
+
const ep = ++this.epoch;
|
|
3507
|
+
this.clearAllTimers();
|
|
3508
|
+
this.setState("hosting");
|
|
3509
|
+
let proc;
|
|
3510
|
+
try {
|
|
3511
|
+
proc = this.deps.spawnDaemon();
|
|
3512
|
+
} catch (error) {
|
|
3513
|
+
this.deps.log("error", "failed to spawn daemon", { error: describeError(error) });
|
|
3514
|
+
this.enterBackoff("spawn-error", null);
|
|
3515
|
+
return;
|
|
3516
|
+
}
|
|
3517
|
+
this.proc = proc;
|
|
3518
|
+
proc.exited.then((code) => {
|
|
3519
|
+
if (this.invalidated(ep)) return;
|
|
3520
|
+
this.proc = null;
|
|
3521
|
+
this.deps.log("warn", "hosted daemon exited", {
|
|
3522
|
+
code,
|
|
3523
|
+
state: this._state
|
|
3524
|
+
});
|
|
3525
|
+
this.enterBackoff("daemon-exit", code);
|
|
3526
|
+
}, (error) => {
|
|
3527
|
+
if (this.invalidated(ep)) return;
|
|
3528
|
+
this.proc = null;
|
|
3529
|
+
this.deps.log("warn", "hosted daemon exit watch failed", { error: describeError(error) });
|
|
3530
|
+
this.enterBackoff("daemon-exit", null);
|
|
3531
|
+
});
|
|
3532
|
+
this.schedule(this.opts.hostReadyTimeoutMs, () => {
|
|
3533
|
+
if (this.invalidated(ep)) return;
|
|
3534
|
+
this.deps.log("warn", "hosted daemon readiness timeout", { timeoutMs: this.opts.hostReadyTimeoutMs });
|
|
3535
|
+
this.disposeProc();
|
|
3536
|
+
this.enterBackoff("ready-timeout", null);
|
|
3537
|
+
});
|
|
3538
|
+
this.scheduleReadyPoll(ep);
|
|
3539
|
+
}
|
|
3540
|
+
scheduleReadyPoll(ep) {
|
|
3541
|
+
this.schedule(this.opts.hostReadyPingIntervalMs, () => {
|
|
3542
|
+
this.readyPoll(ep);
|
|
3543
|
+
});
|
|
3544
|
+
}
|
|
3545
|
+
async readyPoll(ep) {
|
|
3546
|
+
if (this.invalidated(ep)) return;
|
|
3547
|
+
const info = await this.safePing();
|
|
3548
|
+
if (this.invalidated(ep) || this._state !== "hosting") return;
|
|
3549
|
+
if (info) {
|
|
3550
|
+
this.enterHosted(info);
|
|
3551
|
+
return;
|
|
3552
|
+
}
|
|
3553
|
+
this.scheduleReadyPoll(ep);
|
|
3554
|
+
}
|
|
3555
|
+
enterHosted(info) {
|
|
3556
|
+
this.clearAllTimers();
|
|
3557
|
+
this._lastPing = info;
|
|
3558
|
+
this.hostFailures = 0;
|
|
3559
|
+
this.pingFailures = 0;
|
|
3560
|
+
this.deps.log("info", "hosted daemon ready", {
|
|
3561
|
+
pid: info.pid,
|
|
3562
|
+
version: info.version
|
|
3563
|
+
});
|
|
3564
|
+
this.setState("hosted");
|
|
3565
|
+
}
|
|
3566
|
+
enterBackoff(reason, code) {
|
|
3567
|
+
this.epoch += 1;
|
|
3568
|
+
this.clearAllTimers();
|
|
3569
|
+
this.hostFailures += 1;
|
|
3570
|
+
this.setState("backoff");
|
|
3571
|
+
if (this.hostFailures >= this.opts.backoffLimit) {
|
|
3572
|
+
this.deps.log("error", "hosting failure budget exhausted; giving up", {
|
|
3573
|
+
failures: this.hostFailures,
|
|
3574
|
+
reason
|
|
3575
|
+
});
|
|
3576
|
+
this.setState("failed");
|
|
3577
|
+
return;
|
|
3578
|
+
}
|
|
3579
|
+
const delayMs = Math.min(this.opts.backoffBaseMs * 2 ** (this.hostFailures - 1), this.opts.backoffCapMs);
|
|
3580
|
+
this.deps.log("warn", "hosting failed; backing off", {
|
|
3581
|
+
reason,
|
|
3582
|
+
code,
|
|
3583
|
+
failures: this.hostFailures,
|
|
3584
|
+
delayMs
|
|
3585
|
+
});
|
|
3586
|
+
this.schedule(delayMs, () => {
|
|
3587
|
+
this.runDetermination("probe");
|
|
3588
|
+
});
|
|
3589
|
+
}
|
|
3590
|
+
invalidated(ep) {
|
|
3591
|
+
return this.stopped || this.epoch !== ep;
|
|
3592
|
+
}
|
|
3593
|
+
async safePing() {
|
|
3594
|
+
try {
|
|
3595
|
+
return await this.deps.ping();
|
|
3596
|
+
} catch (error) {
|
|
3597
|
+
this.deps.log("debug", "ping threw; treating as unreachable", { error: describeError(error) });
|
|
3598
|
+
return null;
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
/** Fire-and-forget terminate of the self-spawned process (readiness timeout path). */
|
|
3602
|
+
disposeProc() {
|
|
3603
|
+
const proc = this.proc;
|
|
3604
|
+
this.proc = null;
|
|
3605
|
+
if (!proc) return;
|
|
3606
|
+
proc.terminate().catch((error) => {
|
|
3607
|
+
this.deps.log("warn", "terminate failed", { error: describeError(error) });
|
|
3608
|
+
});
|
|
3609
|
+
}
|
|
3610
|
+
setState(next) {
|
|
3611
|
+
if (this._state === next) return;
|
|
3612
|
+
const previous = this._state;
|
|
3613
|
+
this._state = next;
|
|
3614
|
+
this.deps.log("debug", "supervisor state transition", {
|
|
3615
|
+
from: previous,
|
|
3616
|
+
to: next
|
|
3617
|
+
});
|
|
3618
|
+
for (const listener of [...this.listeners]) try {
|
|
3619
|
+
listener(next, previous);
|
|
3620
|
+
} catch (error) {
|
|
3621
|
+
this.deps.log("warn", "state listener threw", { error: describeError(error) });
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
schedule(ms, fn) {
|
|
3625
|
+
const set = this.deps.setTimeout ?? defaultSetTimeout;
|
|
3626
|
+
let handle;
|
|
3627
|
+
handle = set(() => {
|
|
3628
|
+
this.timers.delete(handle);
|
|
3629
|
+
if (this.stopped) return;
|
|
3630
|
+
fn();
|
|
3631
|
+
}, ms);
|
|
3632
|
+
this.timers.add(handle);
|
|
3633
|
+
return handle;
|
|
3634
|
+
}
|
|
3635
|
+
clearAllTimers() {
|
|
3636
|
+
const clear = this.deps.clearTimeout ?? defaultClearTimeout;
|
|
3637
|
+
for (const handle of this.timers) clear(handle);
|
|
3638
|
+
this.timers.clear();
|
|
3639
|
+
}
|
|
3640
|
+
};
|
|
3641
|
+
//#endregion
|
|
3642
|
+
//#region src/index.ts
|
|
3643
|
+
const name = "agent-sidecar";
|
|
3644
|
+
/** Required services; see the module doc for why `agents` is lazy instead. */
|
|
3645
|
+
const inject = ["webServer", "subprocess"];
|
|
3646
|
+
/** `SOCKET_NAME` in sidecar/daemon.py. */
|
|
3647
|
+
const SOCKET_NAME = "daemon.sock";
|
|
3648
|
+
/** `RUNTIME_ENV` / `LEGACY_RUNTIME_ENV` in sidecar/daemon.py. */
|
|
3649
|
+
const RUNTIME_ENV = "AGENT_SIDECAR_RUNTIME_DIR";
|
|
3650
|
+
const LEGACY_RUNTIME_ENV = "AGENT_SIDECAR_HOME";
|
|
3651
|
+
/** SIGTERM → grace → SIGKILL window for a hosted daemon (design §4.a: 5s). */
|
|
3652
|
+
const DAEMON_GRACE_MS = 5e3;
|
|
3653
|
+
/** Whole-run bound for one `service status` detection probe. */
|
|
3654
|
+
const DETECT_TIMEOUT_MS = 1e4;
|
|
3655
|
+
/** SIGTERM → grace → SIGKILL window when the send-cli hard timeout kills. */
|
|
3656
|
+
const SEND_CLI_GRACE_MS = 2e3;
|
|
3657
|
+
/** Output cap for the detection probe (one sanitized message line). */
|
|
3658
|
+
const DETECT_OUTPUT_BYTES = 4096;
|
|
3659
|
+
/** Per-line clamp when forwarding daemon output into ctx.logger (S8). */
|
|
3660
|
+
const LOG_LINE_LIMIT = 400;
|
|
3661
|
+
/** Per-page `replay` limit forwarded to the daemon (its own cap is 1024). */
|
|
3662
|
+
const REPLAY_PAGE_LIMIT = 512;
|
|
3663
|
+
/** Page cap per fusion replay pull: bounds one timeline fan-out to ≤2048 events. */
|
|
3664
|
+
const REPLAY_MAX_PAGES = 4;
|
|
3665
|
+
/** Timeline entries pulled into one session-analysis summary. */
|
|
3666
|
+
const ANALYSIS_TIMELINE_LIMIT = 120;
|
|
3667
|
+
/** Sessions listed per project-analysis overview. */
|
|
3668
|
+
const ANALYSIS_MAX_SESSIONS = 30;
|
|
3669
|
+
/** Project groups listed in a cross-agent analysis overview. */
|
|
3670
|
+
const ANALYSIS_MAX_GROUPS = 12;
|
|
3671
|
+
/** Sessions listed per group in a cross-agent analysis overview. */
|
|
3672
|
+
const ANALYSIS_CROSS_SESSIONS = 5;
|
|
3673
|
+
/** Clamp on one line of untrusted text (titles, event text). */
|
|
3674
|
+
const ANALYSIS_LINE_CLAMP = 200;
|
|
3675
|
+
/** Clamp on the user question (placed at the head, so it survives truncation). */
|
|
3676
|
+
const ANALYSIS_QUESTION_CLAMP = 2e3;
|
|
3677
|
+
/**
|
|
3678
|
+
* `service status` messages that mean "a LaunchAgent owns daemon liveness"
|
|
3679
|
+
* (sidecar/launchd.py `_status`): exit 0 is `service is running (pid N)`;
|
|
3680
|
+
* exit 1 covers `service is loaded but daemon is not running` and
|
|
3681
|
+
* `service is degraded; ...` (both installed) as well as
|
|
3682
|
+
* `service is unloaded...` (not installed). There is no `--json` face —
|
|
3683
|
+
* the single sanitized message line IS the contract.
|
|
3684
|
+
*/
|
|
3685
|
+
const SERVICE_PRESENT = /^service is (?:running|loaded|degraded)/m;
|
|
3686
|
+
/**
|
|
3687
|
+
* Resolve the effective runtime directory the way sidecar/daemon.py
|
|
3688
|
+
* `default_runtime_dir()` does: explicit config wins, then the
|
|
3689
|
+
* AGENT_SIDECAR_RUNTIME_DIR / legacy AGENT_SIDECAR_HOME environment of the
|
|
3690
|
+
* dsh host process, then `~/.agent_sidecar`.
|
|
3691
|
+
*/
|
|
3692
|
+
function resolveRuntimeDir(configured, env) {
|
|
3693
|
+
const raw = configured.trim() !== "" ? configured.trim() : (env[RUNTIME_ENV] ?? env[LEGACY_RUNTIME_ENV] ?? "").trim();
|
|
3694
|
+
if (raw === "") return join(homedir(), ".agent_sidecar");
|
|
3695
|
+
const expanded = raw === "~" ? homedir() : raw.startsWith("~/") ? join(homedir(), raw.slice(2)) : raw;
|
|
3696
|
+
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
3697
|
+
}
|
|
3698
|
+
/** Flatten and clamp one line of untrusted text for an analysis summary. */
|
|
3699
|
+
function clampAnalysisText(text, max = ANALYSIS_LINE_CLAMP) {
|
|
3700
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
3701
|
+
return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
|
|
3702
|
+
}
|
|
3703
|
+
/** Same trailing-slash normalization fusion uses for project group keys. */
|
|
3704
|
+
function normalizeAnalysisProject(project) {
|
|
3705
|
+
if (project.length > 1 && project.endsWith("/")) {
|
|
3706
|
+
const stripped = project.replace(/\/+$/, "");
|
|
3707
|
+
return stripped === "" ? "/" : stripped;
|
|
3708
|
+
}
|
|
3709
|
+
return project;
|
|
3710
|
+
}
|
|
3711
|
+
/** One unified-session line in a project / cross-agent overview. */
|
|
3712
|
+
function describeUnifiedSession(session) {
|
|
3713
|
+
const title = session.title !== "" ? clampAnalysisText(session.title) : "(untitled)";
|
|
3714
|
+
const live = session.live ? "|live" : "";
|
|
3715
|
+
const updated = new Date(session.lastActivityAt).toISOString();
|
|
3716
|
+
return `- [${session.agent}|${session.status}${live}] ${title} (updated ${updated})`;
|
|
3717
|
+
}
|
|
3718
|
+
/**
|
|
3719
|
+
* Assemble the M1 host half.
|
|
3720
|
+
*
|
|
3721
|
+
* Teardown is order-sensitive, so the whole assembly lives in ONE
|
|
3722
|
+
* `ctx.effect` disposer (design §4.a: "顺序敏感拆除放同一 disposer"):
|
|
3723
|
+
* supervisor first (terminates a self-hosted daemon, never an adopted one),
|
|
3724
|
+
* then the reconciler (closes the subscribe stream and timers), then
|
|
3725
|
+
* `routes.dispose()` (ends SSE clients, unsubscribes), and the webServer
|
|
3726
|
+
* route disposer last.
|
|
3727
|
+
*
|
|
3728
|
+
* @param ctx - plugin context handed by the cordis loader.
|
|
3729
|
+
* @param config - schema-validated composition config (defaults filled).
|
|
3730
|
+
*/
|
|
3731
|
+
function apply(ctx, config) {
|
|
3732
|
+
const runtimeDir = resolveRuntimeDir(config.sidecar.runtimeDir, process.env);
|
|
3733
|
+
const socketPath = join(runtimeDir, SOCKET_NAME);
|
|
3734
|
+
const command = config.sidecar.command;
|
|
3735
|
+
/** Explicit redirect only when configured; the ambient env already flows. */
|
|
3736
|
+
const childEnv = config.sidecar.runtimeDir.trim() !== "" ? { [RUNTIME_ENV]: runtimeDir } : void 0;
|
|
3737
|
+
const log = (level, msg, meta) => {
|
|
3738
|
+
ctx.logger[level](meta === void 0 ? `agent-sidecar: ${msg}` : `agent-sidecar: ${msg} ${JSON.stringify(meta)}`);
|
|
3739
|
+
};
|
|
3740
|
+
/** Clamped per-line forwarding of daemon output (design §4.c, S8-safe). */
|
|
3741
|
+
const forwardLines = (stream, level) => {
|
|
3742
|
+
if (stream === void 0) return;
|
|
3743
|
+
stream.on("error", () => {});
|
|
3744
|
+
createInterface({ input: stream }).on("line", (line) => {
|
|
3745
|
+
const text = line.length > LOG_LINE_LIMIT ? `${line.slice(0, LOG_LINE_LIMIT)}…` : line;
|
|
3746
|
+
if (text.trim() !== "") ctx.logger[level](`agent-sidecar daemon: ${text}`);
|
|
3747
|
+
});
|
|
3748
|
+
};
|
|
3749
|
+
/** Spawn `<command> daemon run` as a supervised foreground child. */
|
|
3750
|
+
const spawnDaemon = () => {
|
|
3751
|
+
const handle = ctx.subprocess.spawn({
|
|
3752
|
+
argv: [
|
|
3753
|
+
...command,
|
|
3754
|
+
"daemon",
|
|
3755
|
+
"run"
|
|
3756
|
+
],
|
|
3757
|
+
cwd: homedir(),
|
|
3758
|
+
stdio: {
|
|
3759
|
+
stdin: "ignore",
|
|
3760
|
+
stdout: "pipe",
|
|
3761
|
+
stderr: "pipe"
|
|
3762
|
+
},
|
|
3763
|
+
graceMs: DAEMON_GRACE_MS,
|
|
3764
|
+
env: childEnv
|
|
3765
|
+
});
|
|
3766
|
+
forwardLines(handle.stdout, "debug");
|
|
3767
|
+
forwardLines(handle.stderr, "warn");
|
|
3768
|
+
return {
|
|
3769
|
+
exited: handle.done.then((outcome) => outcome.exitCode),
|
|
3770
|
+
terminate: async () => {
|
|
3771
|
+
handle.terminate();
|
|
3772
|
+
await handle.waitForExit();
|
|
3773
|
+
}
|
|
3774
|
+
};
|
|
3775
|
+
};
|
|
3776
|
+
/**
|
|
3777
|
+
* Read-only LaunchAgent detection: darwin-only, one bounded
|
|
3778
|
+
* `service status` run, parsed per {@link SERVICE_PRESENT}. Any failure
|
|
3779
|
+
* (non-zero control exit, timeout, unspawnable CLI) reads as "absent" —
|
|
3780
|
+
* the supervisor already treats detection errors that way.
|
|
3781
|
+
*/
|
|
3782
|
+
const detectLaunchAgent = async () => {
|
|
3783
|
+
if (process.platform !== "darwin") return false;
|
|
3784
|
+
const handle = ctx.subprocess.spawn({
|
|
3785
|
+
argv: [
|
|
3786
|
+
...command,
|
|
3787
|
+
"service",
|
|
3788
|
+
"status"
|
|
3789
|
+
],
|
|
3790
|
+
cwd: homedir(),
|
|
3791
|
+
stdio: {
|
|
3792
|
+
stdin: "ignore",
|
|
3793
|
+
stdout: { maxBytes: DETECT_OUTPUT_BYTES },
|
|
3794
|
+
stderr: { maxBytes: DETECT_OUTPUT_BYTES }
|
|
3795
|
+
},
|
|
3796
|
+
graceMs: 2e3,
|
|
3797
|
+
signal: AbortSignal.timeout(DETECT_TIMEOUT_MS),
|
|
3798
|
+
env: childEnv
|
|
3799
|
+
});
|
|
3800
|
+
const outcome = await handle.done;
|
|
3801
|
+
if (outcome.exitCode === 0) return true;
|
|
3802
|
+
if (outcome.exitCode !== 1) return false;
|
|
3803
|
+
const text = handle.collected.stdout?.readFrom(0).text ?? "";
|
|
3804
|
+
return SERVICE_PRESENT.test(text);
|
|
3805
|
+
};
|
|
3806
|
+
const store = new SessionStore();
|
|
3807
|
+
const client = new SidecarSocketClient({ socketPath });
|
|
3808
|
+
const replayFace = { replay: async ({ sessionId, afterSeq }) => {
|
|
3809
|
+
const events = [];
|
|
3810
|
+
let cursor = afterSeq ?? 0;
|
|
3811
|
+
for (let page = 0; page < REPLAY_MAX_PAGES; page += 1) {
|
|
3812
|
+
const result = await client.replay(sessionId, cursor, REPLAY_PAGE_LIMIT);
|
|
3813
|
+
events.push(...result.events);
|
|
3814
|
+
if (!result.truncated || result.lastSeq === null || result.lastSeq <= cursor) break;
|
|
3815
|
+
cursor = result.lastSeq;
|
|
3816
|
+
}
|
|
3817
|
+
return events;
|
|
3818
|
+
} };
|
|
3819
|
+
const getSessionQuery = () => {
|
|
3820
|
+
const getter = ctx.get;
|
|
3821
|
+
if (typeof getter !== "function") return null;
|
|
3822
|
+
const engine = getter.call(ctx, "sessionQuery");
|
|
3823
|
+
return engine === void 0 || engine === null ? null : engine;
|
|
3824
|
+
};
|
|
3825
|
+
const buildFusion = (dshEvents) => new FusionQuery({
|
|
3826
|
+
store,
|
|
3827
|
+
dshEvents,
|
|
3828
|
+
getSessionQuery,
|
|
3829
|
+
replay: replayFace
|
|
3830
|
+
});
|
|
3831
|
+
const fusionHolder = { current: buildFusion(null) };
|
|
3832
|
+
const fusion = {
|
|
3833
|
+
getUnifiedSessions: () => fusionHolder.current.getUnifiedSessions(),
|
|
3834
|
+
getSessionTimeline: (sessionId, opts) => fusionHolder.current.getSessionTimeline(sessionId, opts),
|
|
3835
|
+
getProjectGroups: (opts) => fusionHolder.current.getProjectGroups(opts),
|
|
3836
|
+
getLineage: (sessionId) => fusionHolder.current.getLineage(sessionId),
|
|
3837
|
+
searchSessions: (query, opts) => fusionHolder.current.searchSessions(query, opts),
|
|
3838
|
+
getCapabilities: () => fusionHolder.current.getCapabilities()
|
|
3839
|
+
};
|
|
3840
|
+
const reconciler = new Reconciler(client, {
|
|
3841
|
+
applySnapshot: (rows) => {
|
|
3842
|
+
store.applySnapshot(rows);
|
|
3843
|
+
},
|
|
3844
|
+
applyEvent: (ev) => {
|
|
3845
|
+
store.applyEvent(ev);
|
|
3846
|
+
fusionHolder.current.ingestSidecarEvent(ev);
|
|
3847
|
+
},
|
|
3848
|
+
setStreamHealth: (health) => {
|
|
3849
|
+
store.setStreamHealth(health);
|
|
3850
|
+
},
|
|
3851
|
+
hasWorkingSessions: () => store.hasWorkingSessions()
|
|
3852
|
+
}, {
|
|
3853
|
+
activeMs: config.stream.reconcileActiveMs,
|
|
3854
|
+
idleMs: config.stream.reconcileIdleMs
|
|
3855
|
+
});
|
|
3856
|
+
const supervisor = new DaemonSupervisor({
|
|
3857
|
+
ping: () => client.ping(),
|
|
3858
|
+
spawnDaemon,
|
|
3859
|
+
detectLaunchAgent,
|
|
3860
|
+
log
|
|
3861
|
+
}, {
|
|
3862
|
+
policy: config.daemon.policy,
|
|
3863
|
+
backoffLimit: config.daemon.backoffLimit
|
|
3864
|
+
});
|
|
3865
|
+
let effective = config;
|
|
3866
|
+
const guardOptions = { allowWriteActions: () => effective.inject.enabled };
|
|
3867
|
+
let liveAgents = null;
|
|
3868
|
+
const dshExecutor = createDshInjectExecutor({
|
|
3869
|
+
agents: {
|
|
3870
|
+
get: (sessionId) => liveAgents?.get(sessionId),
|
|
3871
|
+
resume: (options) => liveAgents === null ? Promise.reject(/* @__PURE__ */ new Error("dsh agents service is not available in this composition")) : liveAgents.resume(options)
|
|
3872
|
+
},
|
|
3873
|
+
log,
|
|
3874
|
+
pluginName: name
|
|
3875
|
+
});
|
|
3876
|
+
const spawnSendCli = (argv) => {
|
|
3877
|
+
const handle = ctx.subprocess.spawn({
|
|
3878
|
+
argv,
|
|
3879
|
+
cwd: homedir(),
|
|
3880
|
+
stdio: {
|
|
3881
|
+
stdin: "pipe",
|
|
3882
|
+
stdout: "pipe",
|
|
3883
|
+
stderr: "pipe"
|
|
3884
|
+
},
|
|
3885
|
+
graceMs: SEND_CLI_GRACE_MS,
|
|
3886
|
+
env: childEnv
|
|
3887
|
+
});
|
|
3888
|
+
handle.stdin?.on("error", () => {});
|
|
3889
|
+
return {
|
|
3890
|
+
stdin: {
|
|
3891
|
+
write: (chunk) => {
|
|
3892
|
+
handle.stdin?.write(chunk);
|
|
3893
|
+
},
|
|
3894
|
+
end: () => {
|
|
3895
|
+
handle.stdin?.end();
|
|
3896
|
+
}
|
|
3897
|
+
},
|
|
3898
|
+
onStdout: (listener) => {
|
|
3899
|
+
handle.stdout?.on("data", listener);
|
|
3900
|
+
},
|
|
3901
|
+
onStderr: (listener) => {
|
|
3902
|
+
handle.stderr?.on("data", listener);
|
|
3903
|
+
},
|
|
3904
|
+
exited: handle.done.then((outcome) => outcome.exitCode),
|
|
3905
|
+
kill: () => {
|
|
3906
|
+
handle.terminate();
|
|
3907
|
+
}
|
|
3908
|
+
};
|
|
3909
|
+
};
|
|
3910
|
+
const sendCliExecutor = createSendCliExecutor({
|
|
3911
|
+
spawn: spawnSendCli,
|
|
3912
|
+
log,
|
|
3913
|
+
opts: { command }
|
|
3914
|
+
});
|
|
3915
|
+
/** Live target re-check against the reconciled store (§4.f.5 prepare). */
|
|
3916
|
+
const verifyTarget = async (target) => {
|
|
3917
|
+
const view = store.getBoardState().sessions.find((s) => s.agent === target.agent && s.session_id === target.sessionId);
|
|
3918
|
+
if (view === void 0) return null;
|
|
3919
|
+
return {
|
|
3920
|
+
agent: view.agent,
|
|
3921
|
+
sessionId: view.session_id,
|
|
3922
|
+
status: view.status,
|
|
3923
|
+
title: view.title,
|
|
3924
|
+
project: view.project
|
|
3925
|
+
};
|
|
3926
|
+
};
|
|
3927
|
+
const injectGateway = new InjectGateway({
|
|
3928
|
+
executors: {
|
|
3929
|
+
dsh: dshExecutor,
|
|
3930
|
+
sendCli: sendCliExecutor
|
|
3931
|
+
},
|
|
3932
|
+
verifyTarget,
|
|
3933
|
+
allowWrite: () => effective.inject.enabled,
|
|
3934
|
+
log: (entry) => log(entry.ok ? "info" : "warn", `inject ${entry.phase}`, entry)
|
|
3935
|
+
});
|
|
3936
|
+
const liveAnalysisSessions = /* @__PURE__ */ new Set();
|
|
3937
|
+
/**
|
|
3938
|
+
* Resolve the provider/model the analysis agent runs on (A-1 fix: an
|
|
3939
|
+
* agent created without agentOptions has no model — `{{model}}` prompt
|
|
3940
|
+
* assembly and `buildRequest` both fail, yielding an empty summary).
|
|
3941
|
+
* Explicit `analysis.provider`+`analysis.model` config wins (both
|
|
3942
|
+
* non-empty, read live); otherwise the host's default model selection is
|
|
3943
|
+
* reused via `ctx.agentDefaultModel` — the same source dsh's own entry
|
|
3944
|
+
* points (headless/apiproxy) read. `null` = no model anywhere: routes
|
|
3945
|
+
* pre-reject `analysis.request` as `analysis_model_unconfigured`.
|
|
3946
|
+
*/
|
|
3947
|
+
const resolveAnalysisModel = () => {
|
|
3948
|
+
const provider = effective.analysis.provider.trim();
|
|
3949
|
+
const model = effective.analysis.model.trim();
|
|
3950
|
+
if (provider !== "" && model !== "") return {
|
|
3951
|
+
provider,
|
|
3952
|
+
model
|
|
3953
|
+
};
|
|
3954
|
+
const getter = ctx.get;
|
|
3955
|
+
if (typeof getter !== "function") return null;
|
|
3956
|
+
const service = getter.call(ctx, "agentDefaultModel");
|
|
3957
|
+
if (service === void 0 || service === null) return null;
|
|
3958
|
+
try {
|
|
3959
|
+
const selection = service.currentSelection();
|
|
3960
|
+
if (typeof selection?.provider === "string" && selection.provider !== "" && typeof selection.model === "string" && selection.model !== "") return {
|
|
3961
|
+
provider: selection.provider,
|
|
3962
|
+
model: selection.model
|
|
3963
|
+
};
|
|
3964
|
+
} catch {}
|
|
3965
|
+
return null;
|
|
3966
|
+
};
|
|
3967
|
+
const createAnalysisAgent = async (options) => {
|
|
3968
|
+
const agents = liveAgents;
|
|
3969
|
+
if (agents === null) throw new Error("dsh agents service is not available in this composition");
|
|
3970
|
+
const selection = resolveAnalysisModel();
|
|
3971
|
+
if (selection === null) throw new Error("no analysis model available: set analysis.provider/analysis.model or mount agentDefaultModel");
|
|
3972
|
+
const handle = await agents.create({
|
|
3973
|
+
...options,
|
|
3974
|
+
agentOptions: {
|
|
3975
|
+
provider: selection.provider,
|
|
3976
|
+
model: selection.model
|
|
3977
|
+
},
|
|
3978
|
+
meta: { cwd: process.cwd() }
|
|
3979
|
+
});
|
|
3980
|
+
const tracked = {
|
|
3981
|
+
agent: handle.agent,
|
|
3982
|
+
dispose: async () => {
|
|
3983
|
+
liveAnalysisSessions.delete(tracked);
|
|
3984
|
+
await handle.dispose();
|
|
3985
|
+
}
|
|
3986
|
+
};
|
|
3987
|
+
liveAnalysisSessions.add(tracked);
|
|
3988
|
+
return tracked;
|
|
3989
|
+
};
|
|
3990
|
+
const analysisEngine = new AnalysisEngine({
|
|
3991
|
+
createAgent: createAnalysisAgent,
|
|
3992
|
+
allowAnalysis: () => effective.analysis.enabled,
|
|
3993
|
+
log: (entry) => log(entry.errorCode !== void 0 ? "warn" : "info", `analysis ${entry.op}`, entry)
|
|
3994
|
+
});
|
|
3995
|
+
/**
|
|
3996
|
+
* Assemble the bounded AnalysisInput for one target from fusion data
|
|
3997
|
+
* (design §4.e.3: summaries come from the fused timelines/overviews).
|
|
3998
|
+
* `null` = target unknown to fusion → the routes answer 404. The user
|
|
3999
|
+
* question rides the HEAD of the text so it survives the engine's
|
|
4000
|
+
* tail truncation, and the session timeline lists NEWEST events first
|
|
4001
|
+
* for the same reason: when the engine's head-keep truncation bites,
|
|
4002
|
+
* it should shed the oldest — least informative — events (F5).
|
|
4003
|
+
*/
|
|
4004
|
+
const buildAnalysisInput = async (req) => {
|
|
4005
|
+
const questionLines = req.question !== void 0 && req.question.trim() !== "" ? [
|
|
4006
|
+
"[用户问题 / question]",
|
|
4007
|
+
clampAnalysisText(req.question, ANALYSIS_QUESTION_CLAMP),
|
|
4008
|
+
""
|
|
4009
|
+
] : [];
|
|
4010
|
+
if (req.targetKind === "session") {
|
|
4011
|
+
const targetId = req.targetId ?? "";
|
|
4012
|
+
const session = fusion.getUnifiedSessions().find((s) => s.sessionId === targetId) ?? null;
|
|
4013
|
+
if (session === null) return null;
|
|
4014
|
+
const page = await fusion.getSessionTimeline(targetId, { limit: ANALYSIS_TIMELINE_LIMIT });
|
|
4015
|
+
const sources = page.sources;
|
|
4016
|
+
const summaryText = [
|
|
4017
|
+
...questionLines,
|
|
4018
|
+
`[会话概览 / session] agent=${session.agent} status=${session.status} live=${session.live}`,
|
|
4019
|
+
`title: ${session.title !== "" ? clampAnalysisText(session.title) : "(untitled)"}`,
|
|
4020
|
+
`project: ${session.project}`,
|
|
4021
|
+
`last activity: ${new Date(session.lastActivityAt).toISOString()}`,
|
|
4022
|
+
"",
|
|
4023
|
+
`[时间线 / timeline,最新在前 / newest first] ${page.entries.length} events (sources: dshLive=${sources.dshLive} dshCold=${sources.dshCold} replay=${sources.sidecarReplay} buffer=${sources.sidecarBuffer})`,
|
|
4024
|
+
...[...page.entries].reverse().map((entry) => `- [${new Date(entry.ts).toISOString()}] ${entry.kind}${entry.seq !== null ? ` seq=${entry.seq}` : ""}${entry.text !== "" ? ` ${clampAnalysisText(entry.text)}` : ""}`)
|
|
4025
|
+
].join("\n");
|
|
4026
|
+
return {
|
|
4027
|
+
kind: "session",
|
|
4028
|
+
title: session.title !== "" ? session.title : `${session.agent} ${session.sessionId}`,
|
|
4029
|
+
summaryText,
|
|
4030
|
+
meta: {
|
|
4031
|
+
targetId,
|
|
4032
|
+
agent: session.agent
|
|
4033
|
+
}
|
|
4034
|
+
};
|
|
4035
|
+
}
|
|
4036
|
+
if (req.targetKind === "project") {
|
|
4037
|
+
const wanted = normalizeAnalysisProject(req.targetId ?? "");
|
|
4038
|
+
const group = fusion.getProjectGroups().find((g) => normalizeAnalysisProject(g.project) === wanted) ?? null;
|
|
4039
|
+
if (group === null) return null;
|
|
4040
|
+
const omitted = group.sessions.length - ANALYSIS_MAX_SESSIONS;
|
|
4041
|
+
const summaryText = [
|
|
4042
|
+
...questionLines,
|
|
4043
|
+
`[项目概览 / project] ${group.project}`,
|
|
4044
|
+
`agents: ${group.agents.join(", ")} | sessions: ${group.sessions.length} | last activity: ${new Date(group.lastActivityAt).toISOString()}`,
|
|
4045
|
+
"",
|
|
4046
|
+
...group.sessions.slice(0, ANALYSIS_MAX_SESSIONS).map(describeUnifiedSession),
|
|
4047
|
+
...omitted > 0 ? [`… ${omitted} more sessions omitted`] : []
|
|
4048
|
+
].join("\n");
|
|
4049
|
+
return {
|
|
4050
|
+
kind: "project",
|
|
4051
|
+
title: `project ${group.project}`,
|
|
4052
|
+
summaryText,
|
|
4053
|
+
meta: { targetId: group.project }
|
|
4054
|
+
};
|
|
4055
|
+
}
|
|
4056
|
+
const groups = fusion.getProjectGroups();
|
|
4057
|
+
const sessionsTotal = groups.reduce((n, g) => n + g.sessions.length, 0);
|
|
4058
|
+
const omittedGroups = groups.length - ANALYSIS_MAX_GROUPS;
|
|
4059
|
+
return {
|
|
4060
|
+
kind: "cross-agent",
|
|
4061
|
+
title: "cross-agent overview",
|
|
4062
|
+
summaryText: [
|
|
4063
|
+
...questionLines,
|
|
4064
|
+
`[跨 agent 概览 / cross-agent overview] ${groups.length} projects, ${sessionsTotal} sessions in the correlation window`,
|
|
4065
|
+
"",
|
|
4066
|
+
...groups.slice(0, ANALYSIS_MAX_GROUPS).flatMap((group) => [
|
|
4067
|
+
`[${group.project}] agents: ${group.agents.join(", ")} | sessions: ${group.sessions.length}`,
|
|
4068
|
+
...group.sessions.slice(0, ANALYSIS_CROSS_SESSIONS).map(describeUnifiedSession),
|
|
4069
|
+
""
|
|
4070
|
+
]),
|
|
4071
|
+
...omittedGroups > 0 ? [`… ${omittedGroups} more projects omitted`] : []
|
|
4072
|
+
].join("\n")
|
|
4073
|
+
};
|
|
4074
|
+
};
|
|
4075
|
+
const routes = createRoutes({
|
|
4076
|
+
store,
|
|
4077
|
+
supervisor,
|
|
4078
|
+
guardOptions,
|
|
4079
|
+
injectGateway,
|
|
4080
|
+
fusion,
|
|
4081
|
+
analysisEnabled: () => effective.analysis.enabled,
|
|
4082
|
+
analysis: {
|
|
4083
|
+
engine: analysisEngine,
|
|
4084
|
+
buildInput: buildAnalysisInput,
|
|
4085
|
+
available: () => liveAgents !== null,
|
|
4086
|
+
modelConfigured: () => resolveAnalysisModel() !== null
|
|
4087
|
+
},
|
|
4088
|
+
log
|
|
4089
|
+
});
|
|
4090
|
+
ctx.effect(() => {
|
|
4091
|
+
const removeRoute = ctx.webServer.register({
|
|
4092
|
+
kind: "prefix",
|
|
4093
|
+
path: API_PREFIX,
|
|
4094
|
+
handler: routes.handle
|
|
4095
|
+
});
|
|
4096
|
+
const offStateChange = supervisor.onStateChange((state) => {
|
|
4097
|
+
if (state === "adopted" || state === "hosted") reconciler.reconcileNow();
|
|
4098
|
+
});
|
|
4099
|
+
fusionHolder.current.start();
|
|
4100
|
+
reconciler.start();
|
|
4101
|
+
supervisor.start();
|
|
4102
|
+
return async () => {
|
|
4103
|
+
offStateChange();
|
|
4104
|
+
await supervisor.stop();
|
|
4105
|
+
reconciler.stop();
|
|
4106
|
+
await Promise.all([...liveAnalysisSessions].map((handle) => handle.dispose().catch(() => {})));
|
|
4107
|
+
routes.dispose();
|
|
4108
|
+
removeRoute();
|
|
4109
|
+
fusionHolder.current.stop();
|
|
4110
|
+
};
|
|
4111
|
+
}, "agent-sidecar: host assembly (route + reconciler + supervisor + fusion + analysis)");
|
|
4112
|
+
ctx.inject(["sessions"], (injected) => {
|
|
4113
|
+
const sctx = injected;
|
|
4114
|
+
const bus = sctx;
|
|
4115
|
+
const withFeed = buildFusion({ on: (event, handler) => bus.on(event, handler) });
|
|
4116
|
+
withFeed.start();
|
|
4117
|
+
const previous = fusionHolder.current;
|
|
4118
|
+
fusionHolder.current = withFeed;
|
|
4119
|
+
previous.stop();
|
|
4120
|
+
sctx.effect(() => () => {
|
|
4121
|
+
const downgraded = buildFusion(null);
|
|
4122
|
+
downgraded.start();
|
|
4123
|
+
fusionHolder.current = downgraded;
|
|
4124
|
+
withFeed.stop();
|
|
4125
|
+
}, "agent-sidecar: fusion dsh feed release");
|
|
4126
|
+
log("debug", "fusion dsh event feed online (sessions service bound)");
|
|
4127
|
+
});
|
|
4128
|
+
ctx.inject(["agents"], (injected) => {
|
|
4129
|
+
const actx = injected;
|
|
4130
|
+
liveAgents = actx.agents;
|
|
4131
|
+
actx.effect(() => () => {
|
|
4132
|
+
liveAgents = null;
|
|
4133
|
+
}, "agent-sidecar: agents binding release");
|
|
4134
|
+
log("debug", "dsh inject + analysis paths online (agents service bound)");
|
|
4135
|
+
});
|
|
4136
|
+
ctx.inject(["skills"], (injected) => {
|
|
4137
|
+
registerSidecarSkillProvider({
|
|
4138
|
+
skills: injected.skills,
|
|
4139
|
+
provide: config.skill.provide,
|
|
4140
|
+
log
|
|
4141
|
+
});
|
|
4142
|
+
});
|
|
4143
|
+
ctx.inject(["settings"], (injected) => {
|
|
4144
|
+
try {
|
|
4145
|
+
const sctx = injected;
|
|
4146
|
+
const scope = sctx.settings.register(name, Config, {
|
|
4147
|
+
base: config,
|
|
4148
|
+
applies: "live"
|
|
4149
|
+
});
|
|
4150
|
+
effective = scope.get();
|
|
4151
|
+
const unwatch = scope.watch((next) => {
|
|
4152
|
+
effective = next;
|
|
4153
|
+
});
|
|
4154
|
+
sctx.effect(() => () => {
|
|
4155
|
+
unwatch();
|
|
4156
|
+
effective = config;
|
|
4157
|
+
}, "agent-sidecar: settings scope release");
|
|
4158
|
+
log("debug", "settings namespace registered", { applies: "live" });
|
|
4159
|
+
} catch (err) {
|
|
4160
|
+
log("warn", `settings namespace registration failed: ${String(err)}`);
|
|
4161
|
+
}
|
|
4162
|
+
});
|
|
4163
|
+
ctx.logger.info(`agent-sidecar: host half assembled (policy=${config.daemon.policy}, socket=${socketPath}, route=${API_PREFIX})`);
|
|
4164
|
+
}
|
|
4165
|
+
//#endregion
|
|
4166
|
+
export { Config, apply, inject, name };
|