@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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +167 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +8062 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.d.ts +396 -0
  7. package/lib/index.js +4166 -0
  8. package/package.json +101 -0
  9. package/src/analysis.ts +782 -0
  10. package/src/bridge.ts +841 -0
  11. package/src/client/analysis/AnalysisPanel.tsx +191 -0
  12. package/src/client/analysis/analysis.module.css +183 -0
  13. package/src/client/analysis-glue.ts +331 -0
  14. package/src/client/api.ts +380 -0
  15. package/src/client/board/Board.tsx +214 -0
  16. package/src/client/board/board.module.css +302 -0
  17. package/src/client/board/logic.ts +556 -0
  18. package/src/client/board/project-view-logic.ts +361 -0
  19. package/src/client/board/project-view.module.css +307 -0
  20. package/src/client/board/project-view.tsx +189 -0
  21. package/src/client/board/strings.ts +112 -0
  22. package/src/client/commands.ts +484 -0
  23. package/src/client/controller.ts +360 -0
  24. package/src/client/css-modules.d.ts +11 -0
  25. package/src/client/detail/SessionDetail.tsx +270 -0
  26. package/src/client/detail/detail.module.css +433 -0
  27. package/src/client/detail/logic.ts +779 -0
  28. package/src/client/detail/strings.ts +98 -0
  29. package/src/client/detail/transport.ts +175 -0
  30. package/src/client/detail-glue.ts +397 -0
  31. package/src/client/detail-view.module.css +79 -0
  32. package/src/client/detail-view.tsx +233 -0
  33. package/src/client/dsh-tools/LineageTree.tsx +210 -0
  34. package/src/client/dsh-tools/SearchPanel.tsx +169 -0
  35. package/src/client/dsh-tools/dsh-tools.module.css +374 -0
  36. package/src/client/dsh-tools/logic.ts +596 -0
  37. package/src/client/dsh-tools/strings.ts +90 -0
  38. package/src/client/index.ts +315 -0
  39. package/src/client/inject/InjectPanel.tsx +482 -0
  40. package/src/client/inject/inject.module.css +446 -0
  41. package/src/client/inject/logic.ts +516 -0
  42. package/src/client/inject/overlay.module.css +22 -0
  43. package/src/client/inject-glue.ts +171 -0
  44. package/src/client/locales/command.ts +48 -0
  45. package/src/client/locales/en.ts +385 -0
  46. package/src/client/locales/index.ts +123 -0
  47. package/src/client/locales/zh.ts +402 -0
  48. package/src/client/m3-transport.ts +151 -0
  49. package/src/client/mount.tsx +307 -0
  50. package/src/client/project-glue.ts +134 -0
  51. package/src/client/search-glue.ts +143 -0
  52. package/src/client/settings-card.module.css +359 -0
  53. package/src/client/settings-card.tsx +565 -0
  54. package/src/client/settings-glue.ts +130 -0
  55. package/src/client/sidebar-tab.tsx +494 -0
  56. package/src/client/sse.ts +366 -0
  57. package/src/client/widget.tsx +80 -0
  58. package/src/config.ts +193 -0
  59. package/src/dsh-inject.ts +240 -0
  60. package/src/fusion.ts +988 -0
  61. package/src/guard.ts +274 -0
  62. package/src/index.ts +950 -0
  63. package/src/inject-gateway.ts +574 -0
  64. package/src/routes.ts +1133 -0
  65. package/src/send-cli.ts +340 -0
  66. package/src/session-store.ts +184 -0
  67. package/src/skills-provider.ts +293 -0
  68. 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 };