@roarpeng/graphflow 1.9.15 → 1.12.1

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 (105) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/README.md +9 -8
  3. package/README.zh.md +3 -2
  4. package/dist/config/resolve.d.ts +13 -1
  5. package/dist/config/resolve.d.ts.map +1 -1
  6. package/dist/config/resolve.js +29 -3
  7. package/dist/config/resolve.js.map +1 -1
  8. package/dist/config/workspace-root.d.ts +6 -0
  9. package/dist/config/workspace-root.d.ts.map +1 -1
  10. package/dist/config/workspace-root.js +3 -2
  11. package/dist/config/workspace-root.js.map +1 -1
  12. package/dist/graph/graphify-file-client.d.ts +49 -0
  13. package/dist/graph/graphify-file-client.d.ts.map +1 -1
  14. package/dist/graph/graphify-file-client.js +113 -15
  15. package/dist/graph/graphify-file-client.js.map +1 -1
  16. package/dist/graph/knowledge-extraction.d.ts +43 -0
  17. package/dist/graph/knowledge-extraction.d.ts.map +1 -0
  18. package/dist/graph/knowledge-extraction.js +274 -0
  19. package/dist/graph/knowledge-extraction.js.map +1 -0
  20. package/dist/graph/token-savings.d.ts +39 -0
  21. package/dist/graph/token-savings.d.ts.map +1 -1
  22. package/dist/graph/token-savings.js +138 -0
  23. package/dist/graph/token-savings.js.map +1 -1
  24. package/dist/integrations/skill-installer.d.ts.map +1 -1
  25. package/dist/integrations/skill-installer.js +9 -2
  26. package/dist/integrations/skill-installer.js.map +1 -1
  27. package/dist/learning/canary-gate.d.ts +5 -1
  28. package/dist/learning/canary-gate.d.ts.map +1 -1
  29. package/dist/learning/canary-gate.js +5 -2
  30. package/dist/learning/canary-gate.js.map +1 -1
  31. package/dist/learning/dialogue-thread.d.ts +15 -0
  32. package/dist/learning/dialogue-thread.d.ts.map +1 -1
  33. package/dist/learning/dialogue-thread.js +58 -1
  34. package/dist/learning/dialogue-thread.js.map +1 -1
  35. package/dist/learning/episodic-memory.d.ts.map +1 -1
  36. package/dist/learning/episodic-memory.js +19 -3
  37. package/dist/learning/episodic-memory.js.map +1 -1
  38. package/dist/learning/skill-admission.d.ts +55 -10
  39. package/dist/learning/skill-admission.d.ts.map +1 -1
  40. package/dist/learning/skill-admission.js +177 -35
  41. package/dist/learning/skill-admission.js.map +1 -1
  42. package/dist/learning/skill-flywheel.d.ts +24 -6
  43. package/dist/learning/skill-flywheel.d.ts.map +1 -1
  44. package/dist/learning/skill-flywheel.js +99 -19
  45. package/dist/learning/skill-flywheel.js.map +1 -1
  46. package/dist/learning/skill-markdown.d.ts +12 -0
  47. package/dist/learning/skill-markdown.d.ts.map +1 -0
  48. package/dist/learning/skill-markdown.js +226 -0
  49. package/dist/learning/skill-markdown.js.map +1 -0
  50. package/dist/learning/skill-store.d.ts.map +1 -1
  51. package/dist/learning/skill-store.js +23 -4
  52. package/dist/learning/skill-store.js.map +1 -1
  53. package/dist/learning/skill-types.d.ts +10 -0
  54. package/dist/learning/skill-types.d.ts.map +1 -1
  55. package/dist/learning/skill-types.js.map +1 -1
  56. package/dist/learning/turn-distillation.d.ts +16 -0
  57. package/dist/learning/turn-distillation.d.ts.map +1 -0
  58. package/dist/learning/turn-distillation.js +161 -0
  59. package/dist/learning/turn-distillation.js.map +1 -0
  60. package/dist/learning/workflow-skill.d.ts +2 -0
  61. package/dist/learning/workflow-skill.d.ts.map +1 -1
  62. package/dist/learning/workflow-skill.js +15 -0
  63. package/dist/learning/workflow-skill.js.map +1 -1
  64. package/dist/surfaces/cli/index.js +108 -31
  65. package/dist/surfaces/cli/index.js.map +1 -1
  66. package/dist/surfaces/cli/output.d.ts.map +1 -1
  67. package/dist/surfaces/cli/output.js +4 -0
  68. package/dist/surfaces/cli/output.js.map +1 -1
  69. package/dist/surfaces/cli/runtime/dialogue.d.ts +36 -0
  70. package/dist/surfaces/cli/runtime/dialogue.d.ts.map +1 -1
  71. package/dist/surfaces/cli/runtime/dialogue.js +80 -2
  72. package/dist/surfaces/cli/runtime/dialogue.js.map +1 -1
  73. package/dist/surfaces/cli/runtime/graph.d.ts.map +1 -1
  74. package/dist/surfaces/cli/runtime/graph.js +14 -10
  75. package/dist/surfaces/cli/runtime/graph.js.map +1 -1
  76. package/dist/surfaces/cli/runtime/knowledge.d.ts +37 -0
  77. package/dist/surfaces/cli/runtime/knowledge.d.ts.map +1 -0
  78. package/dist/surfaces/cli/runtime/knowledge.js +162 -0
  79. package/dist/surfaces/cli/runtime/knowledge.js.map +1 -0
  80. package/dist/surfaces/cli/runtime/routing.js +3 -3
  81. package/dist/surfaces/cli/runtime/routing.js.map +1 -1
  82. package/dist/surfaces/cli/runtime/types.d.ts +6 -0
  83. package/dist/surfaces/cli/runtime/types.d.ts.map +1 -1
  84. package/dist/surfaces/cli/runtime.d.ts +2 -1
  85. package/dist/surfaces/cli/runtime.d.ts.map +1 -1
  86. package/dist/surfaces/cli/runtime.js +7 -1
  87. package/dist/surfaces/cli/runtime.js.map +1 -1
  88. package/dist/surfaces/mcp/server.d.ts +24 -0
  89. package/dist/surfaces/mcp/server.d.ts.map +1 -1
  90. package/dist/surfaces/mcp/server.js +265 -1
  91. package/dist/surfaces/mcp/server.js.map +1 -1
  92. package/dist/surfaces/mcp/tool-definitions.d.ts +1 -0
  93. package/dist/surfaces/mcp/tool-definitions.d.ts.map +1 -1
  94. package/dist/surfaces/mcp/tool-definitions.js +9 -1
  95. package/dist/surfaces/mcp/tool-definitions.js.map +1 -1
  96. package/dist/surfaces/mcp/tool-handlers.d.ts +4 -1
  97. package/dist/surfaces/mcp/tool-handlers.d.ts.map +1 -1
  98. package/dist/surfaces/mcp/tool-handlers.js +69 -19
  99. package/dist/surfaces/mcp/tool-handlers.js.map +1 -1
  100. package/dsh/client.js +431 -0
  101. package/dsh/plugin.mjs +927 -2
  102. package/package.json +14 -3
  103. package/plugin.json +1 -1
  104. package/web/README.md +36 -0
  105. package/web/plugin.mjs +448 -0
package/dsh/plugin.mjs CHANGED
@@ -7,20 +7,52 @@
7
7
  *
8
8
  * Duck-types `ctx` — no hard dependency on `@deepseek-ai/cordis`.
9
9
  * Missing `ctx.skills` / events → no-op. Never throw into the harness loop.
10
+ *
11
+ * Reply auto-fill: each agent turn ends with a durable `turn/end` session
12
+ * event (`data.reason.kind`). The glue tracks the last non-interrupted
13
+ * `assistant/message` text of the turn (the event's message lives at
14
+ * `event.data.message` — session events are `{ type, seq, time, data }`), and
15
+ * on `turn/end` backfills the pending graph dialogue turn with that final
16
+ * reply. Backfill prefers calling the co-located GraphFlow runtime
17
+ * (`captureAssistantReply` + `recordDialogueTurnRuntime`) in-process, and
18
+ * falls back to spawning the local CLI (`context preview --reply` then
19
+ * `dialogue record --reply`, both idempotent tip fills). Env switch:
20
+ * `GRAPHFLOW_CAPTURE_REPLY=0` disables reply filling only.
21
+ *
22
+ * Static panel data channel: the /gf Connection RPC channel
23
+ * (`ctx.connection.rpc.handle("/gf", handler, { authority: "trusted-host" })`)
24
+ * serves the `nodes` method — it runs `workbench tree --json` and
25
+ * `dialogue list --json --limit 50` in the requested workspace and returns the
26
+ * parsed snapshots inside the mandatory RpcResult envelope for the static
27
+ * client panel (web/client.js).
10
28
  */
11
29
  import { spawn } from "node:child_process";
12
- import { existsSync, readFileSync } from "node:fs";
30
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
13
31
  import { dirname, join } from "node:path";
14
- import { fileURLToPath } from "node:url";
32
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
33
 
16
34
  export const name = "graphflow-dsh";
17
35
 
18
36
  const PLUGIN_ID = "graphflow-dsh";
19
37
  const AUTO_CAPTURE_ENV = "GRAPHFLOW_AUTO_CAPTURE";
38
+ const CAPTURE_REPLY_ENV = "GRAPHFLOW_CAPTURE_REPLY";
20
39
  const JOURNAL_RELATIVE = join(".graphflow", "session-journal.jsonl");
40
+ /** Reply text is clipped before it is passed as a function arg or argv element. */
41
+ const REPLY_CLIP_MAX = 4000;
42
+ /** CLI fill children are killed after this long (detached:false + timeout). */
43
+ const REPLY_FILL_TIMEOUT_MS = 20_000;
44
+ /** One bounded retry when the fill finds no pending turn yet (record race). */
45
+ const REPLY_RETRY_DELAY_MS = 1_500;
21
46
  const DEFAULT_SKILL_DESCRIPTION =
22
47
  "图谱上下文压缩、任务规划与知识图谱编排(10 个 MCP 工具)。任何读代码、改代码、排错、中文问题之前必须先调 graphflow_context。DeepSeek Harness 下工具名为 mcp__graphflow__graphflow_*。";
23
48
 
49
+ /** Static-panel data channel: Connection generic RPC channel for the panel. */
50
+ const NODES_CHANNEL = "/gf";
51
+ /** Panel-data CLI children are killed after this long (timeout guard). */
52
+ const CAPTURE_TIMEOUT_MS = 20_000;
53
+ /** Captured stdout/stderr per command are capped at ~4MB (runaway guard). */
54
+ const CAPTURE_OUTPUT_MAX_BYTES = 4_000_000;
55
+
24
56
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
25
57
 
26
58
  /** @typedef {{ spawn?: typeof spawn, env?: NodeJS.ProcessEnv, cwd?: string, readSkill?: () => { name: string, description: string, content: string, source: string, path?: string } }} GraphFlowDshPluginConfig */
@@ -34,6 +66,48 @@ export function isAutoCaptureEnabled(env = process.env) {
34
66
  return !(raw === "0" || raw === "false" || raw === "off" || raw === "no" || raw === "disabled");
35
67
  }
36
68
 
69
+ /**
70
+ * One-switch kill for the assistant-reply backfill only (the question
71
+ * recording keeps obeying `GRAPHFLOW_AUTO_CAPTURE`). `GRAPHFLOW_CAPTURE_REPLY`
72
+ * in {0,false,off,no,disabled} (case-insensitive) disables reply filling.
73
+ * @param {NodeJS.ProcessEnv} [env]
74
+ * @returns {boolean}
75
+ */
76
+ export function isReplyCaptureEnabled(env = process.env) {
77
+ const raw = env[CAPTURE_REPLY_ENV]?.trim().toLowerCase();
78
+ return !(raw === "0" || raw === "false" || raw === "off" || raw === "no" || raw === "disabled");
79
+ }
80
+
81
+ /**
82
+ * Normalize and clip reply text before it crosses a process boundary (in-process
83
+ * call arg or spawn argv). Collapses whitespace, strips NUL (illegal in argv),
84
+ * and truncates to `max` chars with an ellipsis — mirroring the runtime's own
85
+ * `clip` so glue and store agree. Never throws.
86
+ * @param {unknown} text
87
+ * @param {number} [max]
88
+ * @returns {string}
89
+ */
90
+ export function clipReplyText(text, max = REPLY_CLIP_MAX) {
91
+ if (typeof text !== "string") return "";
92
+ const normalized = text.replace(/\s+/g, " ").replace(/\u0000/g, "").trim();
93
+ if (normalized.length <= max) return normalized;
94
+ return `${normalized.slice(0, max - 1)}…`;
95
+ }
96
+
97
+ /**
98
+ * The assistant message carried by a `session/event` payload. Live events are
99
+ * frozen envelopes `{ type, seq, time, data }` so the message lives at
100
+ * `event.data.message`; a legacy/tolerant shape with `event.message` is also
101
+ * accepted. Never throws.
102
+ * @param {object|undefined} event
103
+ * @returns {object|undefined}
104
+ */
105
+ export function sessionEventMessage(event) {
106
+ if (!event || typeof event !== "object") return undefined;
107
+ const data = event.data;
108
+ return data && typeof data === "object" ? data.message ?? event.message : event.message;
109
+ }
110
+
37
111
  /**
38
112
  * Explicit success only — same contract as Claude Code SessionEnd `$2`.
39
113
  * Missing GRAPHFLOW_HOOK_SUCCESS leaves the episode pending.
@@ -80,6 +154,760 @@ export function buildContextHint(cwd = process.cwd()) {
80
154
  return `GraphFlow: before large code reads, call mcp__graphflow__graphflow_context with rootDir=${cwd}. Do not dump SKILL.md.`;
81
155
  }
82
156
 
157
+ /**
158
+ * Extract the plain text of a user message (its text ContentBlocks joined).
159
+ * Returns "" when the message carries no text. Never throws.
160
+ * @param {object|undefined} message - a UserMessage (agent/inbox/inserted payload).
161
+ * @returns {string}
162
+ */
163
+ export function extractMessageText(message) {
164
+ if (!message || typeof message !== "object") return "";
165
+ const content = message.content;
166
+ if (!Array.isArray(content)) return "";
167
+ const parts = [];
168
+ for (const block of content) {
169
+ if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
170
+ parts.push(block.text);
171
+ }
172
+ }
173
+ return parts.join("\n").trim();
174
+ }
175
+
176
+ /**
177
+ * The local GraphFlow CLI entry (this package's own dist build). Falls back to
178
+ * `graphflow` from PATH when dist is absent (source checkout without build).
179
+ * @returns {string}
180
+ */
181
+ export function resolveCliCommand(packageRoot = PACKAGE_ROOT) {
182
+ const distCli = join(packageRoot, "dist", "surfaces", "cli", "index.js");
183
+ return existsSync(distCli) ? distCli : "graphflow";
184
+ }
185
+
186
+ /**
187
+ * Confirmed harness/system message-source kinds that must never become
188
+ * dialogue turns. `MessageSource.kind` (dsh-llm) is merge-extensible: the
189
+ * base map defines `user`/`plugin`/`tool`/`model`, and harness plugins add
190
+ * their own kinds (verified against the running dsh 1.9.16 packages):
191
+ * - `subagent-settled`: "Background subagent … finished/stopped/failed"
192
+ * settlement notices (dsh-subagent, form: notice);
193
+ * - `subagent-report`: "Background subagent … reported:" framed reports
194
+ * (dsh-subagent, form: relay);
195
+ * - `agent-instructions`: AGENTS.md/workspace instructions system-reminders
196
+ * (dsh-agent-instructions, form: instructions);
197
+ * - `session-reference`: recalled content lifted from another session
198
+ * (dsh-session-reference, form: recall);
199
+ * - `goal`: goal-round driver context prompts (dsh-goal-round-driver);
200
+ * - `skill-catalog`: the `<system-reminder><available_skills>` catalog
201
+ * (dsh-tool-skill, form: catalog).
202
+ * `plugin` also covers runtime-context snapshots (`@deepseek-ai/dsh-system-prompt`,
203
+ * form: snapshot), approval-policy changes (`user-approval`),
204
+ * Cordis run failures (`cordis-host-runner`), and this glue's own hint
205
+ * (`graphflow-dsh`, form: instructions).
206
+ */
207
+ const SYSTEM_MESSAGE_SOURCE_KINDS = new Set([
208
+ "plugin",
209
+ "tool",
210
+ "model",
211
+ "subagent-settled",
212
+ "subagent-report",
213
+ "agent-instructions",
214
+ "session-reference",
215
+ "goal",
216
+ "skill-catalog",
217
+ ]);
218
+
219
+ /**
220
+ * Whether a message originates from the human user rather than the harness.
221
+ * Whitelist: only `source.kind === "user"` (web chat, headless, slash
222
+ * commands) qualifies. Blacklist: every confirmed harness/system kind above is
223
+ * rejected — subagent notices/reports, AGENTS.md instructions, session recall,
224
+ * goal-round context, skill catalogs, tool results, plugin injections and
225
+ * runtime-context snapshots must not become dialogue turns. Unknown/missing
226
+ * source falls back to the role check so nothing legitimate is dropped
227
+ * (a future harness kind with role "user" would still leak — see report).
228
+ * @param {object|undefined} message
229
+ * @returns {boolean}
230
+ */
231
+ export function isUserOriginatedMessage(message) {
232
+ if (!message || typeof message !== "object") return false;
233
+ const kind =
234
+ message.source && typeof message.source === "object" ? message.source.kind : undefined;
235
+ if (kind === "user") return true;
236
+ if (typeof kind === "string" && SYSTEM_MESSAGE_SOURCE_KINDS.has(kind)) return false;
237
+ return message.role !== "system";
238
+ }
239
+
240
+ /**
241
+ * Best-effort auto-record of one user turn as a dialogue node.
242
+ * Spawns `graphflow dialogue record --query "<text>" [--session <sessionId>]`
243
+ * in the agent's workspace (cwd = session header cwd) so every web/agent
244
+ * question becomes a dialogue-turn graph node without relying on the agent
245
+ * calling graphflow_context itself. Only human-originated messages are
246
+ * recorded (see isUserOriginatedMessage) — harness/system injections must not
247
+ * pollute the dialogue graph. `sessionId` scopes the turn to one dsh session
248
+ * (omitted → default "main" dialogue session) so question recording and reply
249
+ * filling land in the same dialogue session. Never throws; the spawned child
250
+ * is detached.
251
+ * @param {object|undefined} message - a UserMessage (agent/inbox/inserted payload).
252
+ * @param {string} [cwd]
253
+ * @param {GraphFlowDshPluginConfig} [config]
254
+ * @param {string} [sessionId] - dialogue session name/id; omitted → "main".
255
+ * @returns {{attempted: boolean, reason?: string, workspace?: string}}
256
+ */
257
+ export function recordDialogueFromInbox(message, cwd, config = {}, sessionId) {
258
+ try {
259
+ if (!isUserOriginatedMessage(message)) {
260
+ return { attempted: false, reason: "not-user-message" };
261
+ }
262
+ const env = envOf(config);
263
+ if (!isAutoCaptureEnabled(env)) {
264
+ return { attempted: false, reason: "auto-capture-off" };
265
+ }
266
+ const text = extractMessageText(message);
267
+ if (text.length < 4) {
268
+ return { attempted: false, reason: "query-too-short" };
269
+ }
270
+ const workspace = typeof cwd === "string" && cwd.trim() ? cwd : process.cwd();
271
+ const spawnFn = typeof config.spawn === "function" ? config.spawn : spawn;
272
+ const cli = resolveCliCommand(config.packageRoot);
273
+ const args = [cli, "dialogue", "record", "--query", text];
274
+ if (typeof sessionId === "string" && sessionId.trim()) {
275
+ args.push("--session", sessionId);
276
+ }
277
+ const bin = cli === "graphflow" ? (env.GRAPHFLOW_HOOK_BIN?.trim() || "npx") : process.execPath;
278
+ const fullArgs = cli === "graphflow" ? ["-y", "--package=@roarpeng/graphflow", ...args] : args;
279
+ spawnFn(bin, fullArgs, {
280
+ cwd: workspace,
281
+ env,
282
+ stdio: "ignore",
283
+ detached: true,
284
+ windowsHide: true,
285
+ })?.unref?.();
286
+ return { attempted: true, workspace };
287
+ } catch {
288
+ return { attempted: false, reason: "error" };
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Best-effort fill of one assistant reply into the latest pending dialogue
294
+ * turn for `sessionId`. Backfill pipeline (see fillPendingReply): co-located
295
+ * GraphFlow runtime first, CLI spawn fallback, deduped per session+turn,
296
+ * latest-wins coalescing queue, one bounded retry for the record race. Never
297
+ * throws.
298
+ * @param {object|undefined} event - an `assistant/message` session event.
299
+ * @param {string} [sessionId] - dialogue session name/id.
300
+ * @param {string} [cwd]
301
+ * @param {GraphFlowDshPluginConfig} [config]
302
+ * @returns {{attempted: boolean, reason?: string, workspace?: string, queued?: boolean}}
303
+ */
304
+ export function recordReplyFromTurn(event, sessionId, cwd, config = {}) {
305
+ try {
306
+ if (event?.interrupted || event?.data?.interrupted) {
307
+ return { attempted: false, reason: "interrupted" };
308
+ }
309
+ const text = extractMessageText(sessionEventMessage(event));
310
+ if (!text) {
311
+ return { attempted: false, reason: "no-text" };
312
+ }
313
+ const env = envOf(config);
314
+ if (!isAutoCaptureEnabled(env)) {
315
+ return { attempted: false, reason: "auto-capture-off" };
316
+ }
317
+ return fillPendingReply(text, sessionId, cwd, config);
318
+ } catch {
319
+ return { attempted: false, reason: "error" };
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Absolute path of the co-located CLI runtime module (this package's own dist
325
+ * build) when present, else undefined. The runtime is CJS; the glue imports it
326
+ * lazily and only ever in try/catch — any load failure falls back to the CLI.
327
+ * @param {string} [packageRoot]
328
+ * @returns {string|undefined}
329
+ */
330
+ export function resolveRuntimeGraphPath(packageRoot = PACKAGE_ROOT) {
331
+ const candidate = join(packageRoot, "dist", "surfaces", "cli", "runtime", "graph.js");
332
+ return existsSync(candidate) ? candidate : undefined;
333
+ }
334
+
335
+ /**
336
+ * First existing CLI binary for reply filling, in order of preference:
337
+ * 1. the co-located package's own dist CLI (same build the glue runs from);
338
+ * 2. `$HOME/.dsh/profiles/<profile>/node_modules/@roarpeng/graphflow/dist/…`
339
+ * CLI (first hit — covers a source checkout whose own dist is absent);
340
+ * 3. bare `graphflow` (spawned through npx/npm exec).
341
+ * @param {string} [packageRoot]
342
+ * @param {string} [home]
343
+ * @returns {string}
344
+ */
345
+ export function resolveCliForCapture(packageRoot = PACKAGE_ROOT, home = process.env.HOME || process.env.USERPROFILE) {
346
+ const packageCli = join(packageRoot, "dist", "surfaces", "cli", "index.js");
347
+ if (existsSync(packageCli)) return packageCli;
348
+ const profileCli = findProfileCli(home);
349
+ if (profileCli) return profileCli;
350
+ return "graphflow";
351
+ }
352
+
353
+ /** @param {string|undefined} home */
354
+ export function findProfileCli(home) {
355
+ try {
356
+ if (!home) return undefined;
357
+ const profilesDir = join(home, ".dsh", "profiles");
358
+ if (!existsSync(profilesDir)) return undefined;
359
+ for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
360
+ if (!entry.isDirectory()) continue;
361
+ const candidate = join(
362
+ profilesDir,
363
+ entry.name,
364
+ "node_modules",
365
+ "@roarpeng",
366
+ "graphflow",
367
+ "dist",
368
+ "surfaces",
369
+ "cli",
370
+ "index.js"
371
+ );
372
+ if (existsSync(candidate)) return candidate;
373
+ }
374
+ } catch {
375
+ // discovery is best-effort
376
+ }
377
+ return undefined;
378
+ }
379
+
380
+ /**
381
+ * Parse one captured CLI result into JSON — mirrors the dynamic panel's
382
+ * parseCliOut (web/plugin.mjs:48-58): exitCode must be 0, stdout must
383
+ * JSON.parse, and a wrapping `{ data }` object is unwrapped. Returns null on
384
+ * any failure so the panel renders an empty state instead of an error.
385
+ * @param {{exitCode: number|null, stdout?: string}|undefined} result
386
+ * @returns {unknown|null}
387
+ */
388
+ export function parseCliOut(result) {
389
+ if (!result || result.exitCode !== 0) return null;
390
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
391
+ if (!stdout.trim()) return null;
392
+ try {
393
+ const parsed = JSON.parse(stdout);
394
+ return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed;
395
+ } catch {
396
+ return null;
397
+ }
398
+ }
399
+
400
+ /** The workspace's own config path when it exists; passing it keeps config
401
+ * discovery (graphStorePath, enableDialogueThread, …) aligned with the CLI
402
+ * run from that workspace. The workspace root itself is always overridden by
403
+ * the explicit rootDir the runtime functions receive. */
404
+ function resolveWorkspaceConfigPath(workspace) {
405
+ try {
406
+ if (typeof workspace !== "string" || !workspace.trim()) return undefined;
407
+ const candidate = join(workspace, "graphflow.config.json");
408
+ return existsSync(candidate) ? candidate : undefined;
409
+ } catch {
410
+ return undefined;
411
+ }
412
+ }
413
+
414
+ let captureReplyRuntimePromise;
415
+ let dialogueRecordRuntimePromise;
416
+
417
+ function loadCaptureAssistantReply(packageRoot = PACKAGE_ROOT) {
418
+ const runtimePath = resolveRuntimeGraphPath(packageRoot);
419
+ if (!runtimePath) return Promise.resolve(undefined);
420
+ if (captureReplyRuntimePromise === undefined) {
421
+ captureReplyRuntimePromise = import(pathToFileURL(runtimePath).href)
422
+ .then((mod) => mod?.captureAssistantReply ?? mod?.default?.captureAssistantReply)
423
+ .catch(() => undefined);
424
+ }
425
+ return captureReplyRuntimePromise;
426
+ }
427
+
428
+ function loadDialogueRecordRuntime(packageRoot = PACKAGE_ROOT) {
429
+ const runtimePath = join(packageRoot, "dist", "surfaces", "cli", "runtime", "dialogue.js");
430
+ if (!existsSync(runtimePath)) return Promise.resolve(undefined);
431
+ if (dialogueRecordRuntimePromise === undefined) {
432
+ dialogueRecordRuntimePromise = import(pathToFileURL(runtimePath).href)
433
+ .then((mod) => mod?.recordDialogueTurnRuntime ?? mod?.default?.recordDialogueTurnRuntime)
434
+ .catch(() => undefined);
435
+ }
436
+ return dialogueRecordRuntimePromise;
437
+ }
438
+
439
+ /**
440
+ * In-process backfill: `captureAssistantReply` (the `context preview --reply`
441
+ * semantics — fills a pending workbench topic when one is active) plus, when it
442
+ * only landed on a workbench topic, a direct dialogue-turn fill via
443
+ * `recordDialogueTurnRuntime("", { assistantReply })` so the pending DIALOGUE
444
+ * turn is guaranteed to close. Any failure returns a structured non-ok result
445
+ * so the caller can fall back to the CLI. Never throws.
446
+ * @param {string} text
447
+ * @param {string} [sessionId]
448
+ * @param {string} workspace
449
+ * @param {GraphFlowDshPluginConfig} [config]
450
+ * @returns {Promise<{attempted: boolean, ok?: boolean, filled?: boolean, reason?: string, capture?: object}>}
451
+ */
452
+ export async function captureReplyInProcess(text, sessionId, workspace, config = {}) {
453
+ const fn = await loadCaptureAssistantReply(config.packageRoot);
454
+ if (typeof fn !== "function") {
455
+ return { attempted: false, reason: "runtime-unavailable" };
456
+ }
457
+ const reply = clipReplyText(text);
458
+ if (!reply) return { attempted: false, reason: "no-text" };
459
+ const sessionOpts =
460
+ typeof sessionId === "string" && sessionId.trim() ? { sessionId } : undefined;
461
+ let result;
462
+ try {
463
+ result = await fn(reply, resolveWorkspaceConfigPath(workspace), workspace, sessionOpts);
464
+ } catch (error) {
465
+ return { attempted: true, ok: false, reason: "capture-failed", error: String(error) };
466
+ }
467
+ const ok = result?.ok === true;
468
+ const kind = result?.capture?.kind;
469
+ if (ok && kind === "workbench") {
470
+ // The reply went to an active workbench topic; the pending dialogue turn
471
+ // may still be empty — close it too so `A:` is never left "(待回复)".
472
+ try {
473
+ const dialogueFn = await loadDialogueRecordRuntime(config.packageRoot);
474
+ if (typeof dialogueFn === "function") {
475
+ await dialogueFn("", {
476
+ rootDir: workspace,
477
+ assistantReply: reply,
478
+ ...(sessionOpts ? { sessionId } : {}),
479
+ });
480
+ } else {
481
+ spawnCliFillArgs(["dialogue", "record", "--reply", reply, ...(sessionOpts ? ["--session", sessionId] : [])], workspace, config);
482
+ }
483
+ } catch {
484
+ // dialogue follow-up is best-effort
485
+ }
486
+ }
487
+ return {
488
+ attempted: true,
489
+ ok,
490
+ filled: result?.filled === true,
491
+ reason: result?.reason,
492
+ ...(result?.capture
493
+ ? { capture: { kind, id: result.capture.id, filled: result.capture.filled === true } }
494
+ : {}),
495
+ };
496
+ }
497
+
498
+ /** Spawn one CLI fill command: non-detached, timeout-killed, stderr swallowed. */
499
+ function spawnCliFillArgs(args, workspace, config) {
500
+ try {
501
+ const env = envOf(config);
502
+ const spawnFn = typeof config.spawn === "function" ? config.spawn : spawn;
503
+ const cli = resolveCliForCapture(config.packageRoot);
504
+ const bin = cli === "graphflow" ? (env.GRAPHFLOW_HOOK_BIN?.trim() || "npx") : process.execPath;
505
+ const fullArgs = cli === "graphflow" ? ["-y", "--package=@roarpeng/graphflow", cli, ...args] : [cli, ...args];
506
+ const child = spawnFn(bin, fullArgs, {
507
+ cwd: workspace,
508
+ env,
509
+ stdio: "ignore",
510
+ detached: false,
511
+ windowsHide: true,
512
+ });
513
+ child?.on?.("error", () => {});
514
+ const timer = setTimeout(() => {
515
+ try {
516
+ child?.kill?.();
517
+ } catch {
518
+ // already gone
519
+ }
520
+ }, REPLY_FILL_TIMEOUT_MS);
521
+ timer.unref?.();
522
+ child?.once?.("exit", () => clearTimeout(timer));
523
+ } catch {
524
+ // fill is optional — never throw into the harness
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Spawn one CLI command and capture stdout/stderr (the static panel data
530
+ * channel needs the JSON output, unlike the fire-and-forget reply fills).
531
+ * Resolution order matches resolveCliForCapture: co-located dist CLI → first
532
+ * profile install → npx `-y --package=@roarpeng/graphflow graphflow`
533
+ * (`config.packageRoot` / `config.home` are injectable for tests). The
534
+ * child is timeout-killed (20s) and each stream is capped (~4MB) so a runaway
535
+ * CLI can never hang or exhaust the host; an external abort (the Connection
536
+ * RPC request signal) kills the child too. Resolves
537
+ * `{ exitCode, signal, stdout, stderr }` — CLI failures never reject; only
538
+ * infrastructure errors (spawn throwing, child "error" event) reject. Never
539
+ * throws synchronously.
540
+ * @param {string[]} args - command words after the graphflow binary.
541
+ * @param {string} workspace - cwd for the child.
542
+ * @param {GraphFlowDshPluginConfig} [config]
543
+ * @param {AbortSignal} [signal]
544
+ * @returns {Promise<{exitCode: number|null, signal: string|null, stdout: string, stderr: string}>}
545
+ */
546
+ function spawnCliCapture(args, workspace, config, signal) {
547
+ return new Promise((resolve, reject) => {
548
+ const env = envOf(config);
549
+ const spawnFn = typeof config.spawn === "function" ? config.spawn : spawn;
550
+ let bin;
551
+ let fullArgs;
552
+ try {
553
+ const cli = resolveCliForCapture(config.packageRoot, config.home);
554
+ bin = cli === "graphflow" ? (env.GRAPHFLOW_HOOK_BIN?.trim() || "npx") : process.execPath;
555
+ fullArgs = cli === "graphflow" ? ["-y", "--package=@roarpeng/graphflow", cli, ...args] : [cli, ...args];
556
+ } catch (error) {
557
+ reject(error);
558
+ return;
559
+ }
560
+ let child;
561
+ try {
562
+ child = spawnFn(bin, fullArgs, {
563
+ cwd: workspace,
564
+ env,
565
+ stdio: ["ignore", "pipe", "pipe"],
566
+ windowsHide: true,
567
+ });
568
+ } catch (error) {
569
+ reject(error);
570
+ return;
571
+ }
572
+ if (!child || typeof child !== "object") {
573
+ reject(new Error(`spawn of ${JSON.stringify(bin)} returned no child process`));
574
+ return;
575
+ }
576
+
577
+ const output = { stdout: "", stderr: "" };
578
+ const sizes = { stdout: 0, stderr: 0 };
579
+ let settled = false;
580
+ let exited = false;
581
+ let exitCode = null;
582
+ let exitSignal = null;
583
+ let stdoutEnded = child.stdout === undefined;
584
+ let stderrEnded = child.stderr === undefined;
585
+
586
+ const cleanup = () => {
587
+ clearTimeout(timer);
588
+ if (signal && typeof signal.removeEventListener === "function") {
589
+ try {
590
+ signal.removeEventListener("abort", onAbort);
591
+ } catch {
592
+ // ignore
593
+ }
594
+ }
595
+ };
596
+ const finish = () => {
597
+ if (settled) return;
598
+ settled = true;
599
+ cleanup();
600
+ resolve({ exitCode, signal: exitSignal, stdout: output.stdout, stderr: output.stderr });
601
+ };
602
+ const fail = (error) => {
603
+ if (settled) return;
604
+ settled = true;
605
+ cleanup();
606
+ reject(error);
607
+ };
608
+ const maybeFinish = () => {
609
+ if (exited && stdoutEnded && stderrEnded) finish();
610
+ };
611
+ const timer = setTimeout(() => {
612
+ exitSignal = "timeout";
613
+ try {
614
+ child.kill?.();
615
+ } catch {
616
+ // already gone
617
+ }
618
+ finish();
619
+ }, CAPTURE_TIMEOUT_MS);
620
+ timer.unref?.();
621
+ const onAbort = () => {
622
+ exitSignal = "abort";
623
+ try {
624
+ child.kill?.();
625
+ } catch {
626
+ // already gone
627
+ }
628
+ finish();
629
+ };
630
+ if (signal) {
631
+ if (signal.aborted) {
632
+ onAbort();
633
+ } else if (typeof signal.addEventListener === "function") {
634
+ signal.addEventListener("abort", onAbort, { once: true });
635
+ }
636
+ }
637
+
638
+ const capture = (name) => (chunk) => {
639
+ const text = typeof chunk === "string" ? chunk : String(chunk);
640
+ if (sizes[name] >= CAPTURE_OUTPUT_MAX_BYTES) return;
641
+ const room = CAPTURE_OUTPUT_MAX_BYTES - sizes[name];
642
+ const kept = text.length > room ? text.slice(0, room) : text;
643
+ output[name] += kept;
644
+ sizes[name] += kept.length;
645
+ };
646
+ child.stdout?.on?.("data", capture("stdout"));
647
+ child.stdout?.on?.("end", () => {
648
+ stdoutEnded = true;
649
+ maybeFinish();
650
+ });
651
+ child.stderr?.on?.("data", capture("stderr"));
652
+ child.stderr?.on?.("end", () => {
653
+ stderrEnded = true;
654
+ maybeFinish();
655
+ });
656
+ child.on?.("error", fail);
657
+ child.once?.("exit", (code, sig) => {
658
+ exited = true;
659
+ exitCode = code ?? null;
660
+ exitSignal = sig ?? null;
661
+ maybeFinish();
662
+ });
663
+ child.once?.("close", (code, sig) => {
664
+ exited = true;
665
+ exitCode = code ?? null;
666
+ exitSignal = sig ?? null;
667
+ maybeFinish();
668
+ });
669
+ });
670
+ }
671
+
672
+ /**
673
+ * Static-panel data fetch: run the two read-only CLI snapshots
674
+ * (`workbench tree --json`, `dialogue list --json --limit 50`) in the given
675
+ * workspace and return structured JSON. Pure function — `config.spawn`,
676
+ * `config.env` and `config.packageRoot` are injectable for tests (the spawn
677
+ * convention matches the rest of the glue); an optional `config.signal`
678
+ * aborts the children. CLI/parse failures map to null fields with `ok: true`
679
+ * (the panel shows an empty state); only infrastructure errors (spawn
680
+ * throwing) yield `ok: false`.
681
+ * @param {string} workspaceRoot
682
+ * @param {GraphFlowDshPluginConfig & {signal?: AbortSignal}} [config]
683
+ * @returns {Promise<{ok: boolean, workbench?: unknown, dialogues?: unknown, error?: string}>}
684
+ */
685
+ export async function collectNodesData(workspaceRoot, config = {}) {
686
+ if (typeof workspaceRoot !== "string" || !workspaceRoot.trim()) {
687
+ return { ok: false, error: "no-workspace" };
688
+ }
689
+ try {
690
+ const [wb, dl] = await Promise.all([
691
+ spawnCliCapture(["workbench", "tree", "--json"], workspaceRoot, config, config.signal),
692
+ spawnCliCapture(["dialogue", "list", "--json", "--limit", "50"], workspaceRoot, config, config.signal),
693
+ ]);
694
+ return {
695
+ ok: true,
696
+ workbench: parseCliOut(wb),
697
+ dialogues: parseCliOut(dl),
698
+ };
699
+ } catch (error) {
700
+ return { ok: false, error: String(error && error.message ? error.message : error) };
701
+ }
702
+ }
703
+
704
+ /** Connection instances already wired for the /gf channel (re-apply guard). */
705
+ const wiredRpcConnections = new WeakSet();
706
+
707
+ /**
708
+ * Thin Connection RPC handler for the static panel: wraps collectNodesData
709
+ * into the mandatory RpcResult envelope. The envelope is validated by the
710
+ * client `serverResponseSchema` (dsh-host-apiproxy rpc.schema.js): success is
711
+ * `{ ok: true, value }`; failure is `{ ok: false, error: { code, message,
712
+ * details } }` with `bad-request` requiring `details.issues` and `internal`
713
+ * requiring `details: {}`.
714
+ * @param {string} endpoint - path segment after /gf (e.g. "nodes").
715
+ * @param {unknown} payload - client envelope payload (e.g. { workspaceRoot }).
716
+ * @param {AbortSignal} [signal]
717
+ * @param {GraphFlowDshPluginConfig} [config]
718
+ * @returns {Promise<{ok: boolean, value?: object, error?: object}>}
719
+ */
720
+ async function rpcNodesHandler(endpoint, payload, signal, config) {
721
+ try {
722
+ if (endpoint !== "nodes") {
723
+ return {
724
+ ok: false,
725
+ error: { code: "bad-request", message: `unknown endpoint ${String(endpoint)}`, details: { issues: [] } },
726
+ };
727
+ }
728
+ const result = await collectNodesData(
729
+ payload && typeof payload === "object" ? payload.workspaceRoot : undefined,
730
+ { ...config, signal }
731
+ );
732
+ if (result.ok !== true) {
733
+ const code = result.error === "no-workspace" ? "bad-request" : "internal";
734
+ const message = String(result.error ?? (code === "bad-request" ? "no-workspace" : "internal error"));
735
+ return {
736
+ ok: false,
737
+ error:
738
+ code === "bad-request"
739
+ ? { code, message, details: { issues: [] } }
740
+ : { code, message, details: {} },
741
+ };
742
+ }
743
+ return {
744
+ ok: true,
745
+ value: { workbench: result.workbench ?? null, dialogues: result.dialogues ?? null },
746
+ };
747
+ } catch (error) {
748
+ return {
749
+ ok: false,
750
+ error: { code: "internal", message: String(error && error.message ? error.message : error), details: {} },
751
+ };
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Best-effort registration of the /gf Connection RPC channel
757
+ * (`ctx.connection.rpc.handle("/gf", handler, { authority: "trusted-host" })`
758
+ * — see dsh-client-connection lib/index.js:219-258: the handler is
759
+ * `(endpoint, payload, signal)` and `handle` returns a disposer). Duck-typed,
760
+ * idempotent (one channel per connection instance), and the returned disposer
761
+ * both unregisters via the handle disposer and is attached to `ctx.effect`
762
+ * when available so the harness tears it down on unload. Never throws.
763
+ * @param {object} ctx
764
+ * @param {GraphFlowDshPluginConfig} [config]
765
+ * @returns {(() => void)|undefined}
766
+ */
767
+ function registerNodesRpcChannel(ctx, config) {
768
+ const connection = typeof ctx.get === "function" ? ctx.get("connection") : ctx.connection;
769
+ if (!connection || typeof connection !== "object") return undefined;
770
+ const rpc = connection.rpc;
771
+ if (!rpc || typeof rpc.handle !== "function") return undefined;
772
+ if (wiredRpcConnections.has(connection)) return undefined;
773
+ wiredRpcConnections.add(connection);
774
+ let disposer;
775
+ try {
776
+ disposer = rpc.handle(NODES_CHANNEL, (endpoint, payload, signal) => rpcNodesHandler(endpoint, payload, signal, config), {
777
+ authority: "trusted-host",
778
+ });
779
+ } catch {
780
+ wiredRpcConnections.delete(connection);
781
+ return undefined;
782
+ }
783
+ const dispose = () => {
784
+ if (!wiredRpcConnections.has(connection)) return;
785
+ wiredRpcConnections.delete(connection);
786
+ if (typeof disposer === "function") {
787
+ try {
788
+ disposer();
789
+ } catch {
790
+ // teardown is best-effort
791
+ }
792
+ }
793
+ };
794
+ if (typeof ctx.effect === "function") {
795
+ try {
796
+ ctx.effect(() => dispose, "graphflow-dsh: /gf nodes rpc channel");
797
+ } catch {
798
+ // effect bus missing — the apply return value still cleans up
799
+ }
800
+ }
801
+ return dispose;
802
+ }
803
+
804
+ /**
805
+ * CLI fallback backfill: `context preview --reply` (task-preferred "fill
806
+ * pending" semantics) followed by `dialogue record --reply`, so the pending
807
+ * DIALOGUE turn is closed even when the preview path only lands on a workbench
808
+ * topic. Both are idempotent tip fills (reply-only mode never records a new
809
+ * turn). Never throws.
810
+ * @returns {{attempted: boolean, reason?: string, workspace?: string}}
811
+ */
812
+ export function captureReplyViaCli(text, sessionId, workspace, config = {}) {
813
+ try {
814
+ const reply = clipReplyText(text);
815
+ if (!reply) return { attempted: false, reason: "no-text" };
816
+ const sessionArgs =
817
+ typeof sessionId === "string" && sessionId.trim() ? ["--session", sessionId] : [];
818
+ spawnCliFillArgs(["context", "preview", "--reply", reply, ...sessionArgs], workspace, config);
819
+ spawnCliFillArgs(["dialogue", "record", "--reply", reply, ...sessionArgs], workspace, config);
820
+ return { attempted: true, mode: "cli", workspace };
821
+ } catch {
822
+ return { attempted: false, reason: "error" };
823
+ }
824
+ }
825
+
826
+ /**
827
+ * Latest-wins per-session fill queue: only the most recent reply per dialogue
828
+ * session is kept, one fill runs per session at a time, and a just-finished
829
+ * turn's fill is never lost to a newer one. Module-level so every `apply`
830
+ * shares the same throttle.
831
+ */
832
+ const pendingFills = new Map();
833
+ const inflightFills = new Set();
834
+
835
+ function delay(ms) {
836
+ return new Promise((resolve) => {
837
+ const timer = setTimeout(resolve, ms);
838
+ timer.unref?.();
839
+ });
840
+ }
841
+
842
+ async function runReplyFillEntry(key, entry) {
843
+ try {
844
+ let direct;
845
+ try {
846
+ direct = await captureReplyInProcess(entry.reply, entry.sessionId, entry.workspace, entry.config);
847
+ } catch {
848
+ direct = undefined;
849
+ }
850
+ if (direct?.ok === true) return;
851
+ if (direct?.reason === "no-pending-turn" && !entry.retried) {
852
+ // The question record (detached spawn) may not have landed yet — retry
853
+ // once, unless a newer fill superseded this one meanwhile.
854
+ entry.retried = true;
855
+ await delay(REPLY_RETRY_DELAY_MS);
856
+ if (!pendingFills.has(key)) await runReplyFillEntry(key, entry);
857
+ return;
858
+ }
859
+ captureReplyViaCli(entry.reply, entry.sessionId, entry.workspace, entry.config);
860
+ } catch {
861
+ // never throw into the harness
862
+ }
863
+ }
864
+
865
+ function drainReplyFills() {
866
+ for (const [key, entry] of pendingFills) {
867
+ if (inflightFills.has(key)) continue;
868
+ pendingFills.delete(key);
869
+ inflightFills.add(key);
870
+ runReplyFillEntry(key, entry).finally(() => {
871
+ inflightFills.delete(key);
872
+ drainReplyFills();
873
+ });
874
+ }
875
+ }
876
+
877
+ /**
878
+ * Queue one reply fill. Checks the master switch (`GRAPHFLOW_AUTO_CAPTURE`)
879
+ * and the reply-only switch (`GRAPHFLOW_CAPTURE_REPLY`), clips the text, then
880
+ * enqueues per dialogue session (omitted session id → "main"). Returns
881
+ * synchronously; the fill itself runs async in the background.
882
+ * @param {string} text
883
+ * @param {string} [sessionId]
884
+ * @param {string} [workspace]
885
+ * @param {GraphFlowDshPluginConfig} [config]
886
+ * @returns {{attempted: boolean, reason?: string, queued?: boolean, key?: string}}
887
+ */
888
+ export function fillPendingReply(text, sessionId, workspace, config = {}) {
889
+ try {
890
+ const env = envOf(config);
891
+ if (!isAutoCaptureEnabled(env)) return { attempted: false, reason: "auto-capture-off" };
892
+ if (!isReplyCaptureEnabled(env)) return { attempted: false, reason: "reply-capture-off" };
893
+ const reply = clipReplyText(text);
894
+ if (!reply) return { attempted: false, reason: "no-text" };
895
+ const key = typeof sessionId === "string" && sessionId.trim() ? sessionId : "main";
896
+ const ws = typeof workspace === "string" && workspace.trim() ? workspace : process.cwd();
897
+ pendingFills.set(key, {
898
+ reply,
899
+ sessionId: key === "main" ? undefined : key,
900
+ workspace: ws,
901
+ config,
902
+ retried: false,
903
+ });
904
+ drainReplyFills();
905
+ return { attempted: true, queued: true, key, workspace: ws };
906
+ } catch {
907
+ return { attempted: false, reason: "error" };
908
+ }
909
+ }
910
+
83
911
  export function loadGraphFlowSkillRegistration(packageRoot = PACKAGE_ROOT) {
84
912
  const skillPath = join(packageRoot, "skills", "graphflow", "SKILL.md");
85
913
  let content = "";
@@ -203,6 +1031,17 @@ export function apply(ctx, config = {}) {
203
1031
  // missing or duplicate skill registry — ignore
204
1032
  }
205
1033
 
1034
+ // Static-panel data channel: /gf Connection RPC (registerNodesRpcChannel).
1035
+ // Best-effort — a missing connection service or a failed registration must
1036
+ // never affect the rest of the glue. The disposer is returned from apply so
1037
+ // Cordis tears the channel down on unload.
1038
+ let disposeNodesRpc;
1039
+ try {
1040
+ disposeNodesRpc = registerNodesRpcChannel(ctx, config);
1041
+ } catch {
1042
+ // the /gf data channel is optional
1043
+ }
1044
+
206
1045
  const hinted = new WeakSet();
207
1046
  const closed = new Set();
208
1047
 
@@ -233,6 +1072,87 @@ export function apply(ctx, config = {}) {
233
1072
  // event bus missing
234
1073
  }
235
1074
 
1075
+ const recordedInboxMessages = new WeakSet();
1076
+
1077
+ try {
1078
+ // Auto record every user question as a dialogue-turn node (the dsh analog
1079
+ // of Claude Code's PostToolUse capture): each web/agent question becomes a
1080
+ // graph node with session/workspace lineage, without relying on the agent
1081
+ // calling graphflow_context itself. Turns are scoped to the dsh session id
1082
+ // so question recording and reply filling stay in the same dialogue
1083
+ // session (never crossing same-workspace sessions).
1084
+ listen(ctx, "agent/inbox/inserted", (payload) => {
1085
+ try {
1086
+ const message = payload?.message;
1087
+ if (!message || typeof message !== "object") return;
1088
+ if (recordedInboxMessages.has(message)) return;
1089
+ recordedInboxMessages.add(message);
1090
+ const agent = payloadAgent(payload);
1091
+ const cwd =
1092
+ agent?.session?.header?.cwd ??
1093
+ resolveWorkspaceCwd(payload, config.cwd);
1094
+ recordDialogueFromInbox(message, cwd, config, agent?.session?.id);
1095
+ } catch {
1096
+ // recording is optional
1097
+ }
1098
+ });
1099
+ } catch {
1100
+ // event bus missing
1101
+ }
1102
+
1103
+ // Per-session tracking for the reply backfill. Session events are durable
1104
+ // envelopes `{ type, seq, time, data }`; the assistant message lives at
1105
+ // `event.data.message` (NOT `event.message` — the earlier wiring read the
1106
+ // wrong field, so every fill was a silent "no-text" no-op).
1107
+ const lastReplyBySession = new WeakMap(); // session -> { turn, text }
1108
+ const filledTurnsBySession = new WeakMap(); // session -> Set<turn>
1109
+
1110
+ try {
1111
+ // Auto fill every COMPLETED turn's final assistant reply into the pending
1112
+ // dialogue turn of the same dsh session. `assistant/message` fires per
1113
+ // STEP (intermediate tool-call steps included), so it only feeds a
1114
+ // per-turn "last text" buffer; the durable `turn/end` event is the commit
1115
+ // point — one fill per (session, turn), skipped for interrupted/aborted
1116
+ // turns and for turns whose final message carried no text. Fills are
1117
+ // scoped by the subject session id, so a subagent's reply closes its own
1118
+ // dialogue session and can never overwrite the parent's pending turn.
1119
+ listen(ctx, "session/event", (session, event) => {
1120
+ try {
1121
+ if (!session || typeof session !== "object") return;
1122
+ const type = event?.type;
1123
+ if (type === "assistant/message") {
1124
+ if (event?.data?.interrupted || event?.interrupted) return;
1125
+ const text = extractMessageText(sessionEventMessage(event));
1126
+ if (!text) return;
1127
+ const turn = event?.data?.turn ?? event?.turn;
1128
+ if (typeof turn !== "number") return;
1129
+ lastReplyBySession.set(session, { turn, text });
1130
+ return;
1131
+ }
1132
+ if (type === "turn/end") {
1133
+ const reason = event?.data?.reason ?? event?.reason;
1134
+ const kind = reason && typeof reason === "object" ? reason.kind : undefined;
1135
+ if (kind === "interrupted" || kind === "aborted") return;
1136
+ const turn = event?.data?.turn ?? event?.turn;
1137
+ if (typeof turn !== "number") return;
1138
+ const entry = lastReplyBySession.get(session);
1139
+ if (!entry || entry.turn !== turn) return;
1140
+ let filled = filledTurnsBySession.get(session);
1141
+ if (filled?.has(turn)) return; // already committed
1142
+ filled ??= new Set();
1143
+ filled.add(turn);
1144
+ filledTurnsBySession.set(session, filled);
1145
+ lastReplyBySession.delete(session);
1146
+ fillPendingReply(entry.text, session?.id, session?.header?.cwd ?? config.cwd, config);
1147
+ }
1148
+ } catch {
1149
+ // reply fill is optional
1150
+ }
1151
+ });
1152
+ } catch {
1153
+ // event bus missing
1154
+ }
1155
+
236
1156
  try {
237
1157
  listen(ctx, "agent/pre-step", (payload, next) => {
238
1158
  try {
@@ -252,4 +1172,9 @@ export function apply(ctx, config = {}) {
252
1172
  } catch {
253
1173
  // event bus missing
254
1174
  }
1175
+
1176
+ // Cordis uses apply's return value as the plugin disposer: tear down the
1177
+ // /gf channel on unload (idempotent; the harness may also dispose it via
1178
+ // ctx.effect when available).
1179
+ return disposeNodesRpc;
255
1180
  }