@roarpeng/graphflow 1.9.16 → 1.12.2

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 (90) hide show
  1. package/CHANGELOG.md +49 -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/learning/canary-gate.d.ts +5 -1
  25. package/dist/learning/canary-gate.d.ts.map +1 -1
  26. package/dist/learning/canary-gate.js +5 -2
  27. package/dist/learning/canary-gate.js.map +1 -1
  28. package/dist/learning/episodic-memory.d.ts.map +1 -1
  29. package/dist/learning/episodic-memory.js +19 -3
  30. package/dist/learning/episodic-memory.js.map +1 -1
  31. package/dist/learning/skill-admission.d.ts +55 -10
  32. package/dist/learning/skill-admission.d.ts.map +1 -1
  33. package/dist/learning/skill-admission.js +177 -35
  34. package/dist/learning/skill-admission.js.map +1 -1
  35. package/dist/learning/skill-flywheel.d.ts +24 -6
  36. package/dist/learning/skill-flywheel.d.ts.map +1 -1
  37. package/dist/learning/skill-flywheel.js +99 -19
  38. package/dist/learning/skill-flywheel.js.map +1 -1
  39. package/dist/learning/skill-markdown.d.ts +12 -0
  40. package/dist/learning/skill-markdown.d.ts.map +1 -0
  41. package/dist/learning/skill-markdown.js +226 -0
  42. package/dist/learning/skill-markdown.js.map +1 -0
  43. package/dist/learning/skill-store.d.ts.map +1 -1
  44. package/dist/learning/skill-store.js +23 -4
  45. package/dist/learning/skill-store.js.map +1 -1
  46. package/dist/learning/skill-types.d.ts +10 -0
  47. package/dist/learning/skill-types.d.ts.map +1 -1
  48. package/dist/learning/skill-types.js.map +1 -1
  49. package/dist/learning/workflow-skill.d.ts +2 -0
  50. package/dist/learning/workflow-skill.d.ts.map +1 -1
  51. package/dist/learning/workflow-skill.js +15 -0
  52. package/dist/learning/workflow-skill.js.map +1 -1
  53. package/dist/surfaces/cli/index.js +69 -0
  54. package/dist/surfaces/cli/index.js.map +1 -1
  55. package/dist/surfaces/cli/output.d.ts.map +1 -1
  56. package/dist/surfaces/cli/output.js +3 -0
  57. package/dist/surfaces/cli/output.js.map +1 -1
  58. package/dist/surfaces/cli/runtime/dialogue.js +3 -3
  59. package/dist/surfaces/cli/runtime/dialogue.js.map +1 -1
  60. package/dist/surfaces/cli/runtime/graph.d.ts.map +1 -1
  61. package/dist/surfaces/cli/runtime/graph.js +14 -10
  62. package/dist/surfaces/cli/runtime/graph.js.map +1 -1
  63. package/dist/surfaces/cli/runtime/knowledge.d.ts +37 -0
  64. package/dist/surfaces/cli/runtime/knowledge.d.ts.map +1 -0
  65. package/dist/surfaces/cli/runtime/knowledge.js +162 -0
  66. package/dist/surfaces/cli/runtime/knowledge.js.map +1 -0
  67. package/dist/surfaces/cli/runtime/routing.js +3 -3
  68. package/dist/surfaces/cli/runtime/routing.js.map +1 -1
  69. package/dist/surfaces/cli/runtime/types.d.ts +6 -0
  70. package/dist/surfaces/cli/runtime/types.d.ts.map +1 -1
  71. package/dist/surfaces/cli/runtime.d.ts +1 -0
  72. package/dist/surfaces/cli/runtime.d.ts.map +1 -1
  73. package/dist/surfaces/cli/runtime.js +5 -1
  74. package/dist/surfaces/cli/runtime.js.map +1 -1
  75. package/dist/surfaces/mcp/server.d.ts +24 -0
  76. package/dist/surfaces/mcp/server.d.ts.map +1 -1
  77. package/dist/surfaces/mcp/server.js +265 -1
  78. package/dist/surfaces/mcp/server.js.map +1 -1
  79. package/dist/surfaces/mcp/tool-definitions.d.ts +1 -0
  80. package/dist/surfaces/mcp/tool-definitions.d.ts.map +1 -1
  81. package/dist/surfaces/mcp/tool-definitions.js +9 -1
  82. package/dist/surfaces/mcp/tool-definitions.js.map +1 -1
  83. package/dist/surfaces/mcp/tool-handlers.d.ts +4 -1
  84. package/dist/surfaces/mcp/tool-handlers.d.ts.map +1 -1
  85. package/dist/surfaces/mcp/tool-handlers.js +69 -19
  86. package/dist/surfaces/mcp/tool-handlers.js.map +1 -1
  87. package/dsh/client.js +431 -0
  88. package/dsh/plugin.mjs +766 -40
  89. package/package.json +12 -3
  90. package/plugin.json +1 -1
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.
@@ -109,12 +183,48 @@ export function resolveCliCommand(packageRoot = PACKAGE_ROOT) {
109
183
  return existsSync(distCli) ? distCli : "graphflow";
110
184
  }
111
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
+
112
219
  /**
113
220
  * Whether a message originates from the human user rather than the harness.
114
- * Only `source.kind === "user"` qualifies; harness/system injections
115
- * (`plugin`/`tool`/`model`) must not become dialogue turns (job finished
116
- * notices, subagent reports, tool results, …). Unknown/missing source falls
117
- * back to the role check so nothing legitimate is dropped.
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).
118
228
  * @param {object|undefined} message
119
229
  * @returns {boolean}
120
230
  */
@@ -123,7 +233,7 @@ export function isUserOriginatedMessage(message) {
123
233
  const kind =
124
234
  message.source && typeof message.source === "object" ? message.source.kind : undefined;
125
235
  if (kind === "user") return true;
126
- if (kind === "plugin" || kind === "tool" || kind === "model") return false;
236
+ if (typeof kind === "string" && SYSTEM_MESSAGE_SOURCE_KINDS.has(kind)) return false;
127
237
  return message.role !== "system";
128
238
  }
129
239
 
@@ -181,46 +291,618 @@ export function recordDialogueFromInbox(message, cwd, config = {}, sessionId) {
181
291
 
182
292
  /**
183
293
  * Best-effort fill of one assistant reply into the latest pending dialogue
184
- * turn for `sessionId`. Spawns `graphflow dialogue record --reply "<text>"
185
- * [--session <sessionId>]` (reply-only mode) in the workspace; the runtime
186
- * fills the latest pending turn's assistantReply and safely skips when none is
187
- * pending. Half-written (interrupted) replies are not filled. Never throws.
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.
188
298
  * @param {object|undefined} event - an `assistant/message` session event.
189
299
  * @param {string} [sessionId] - dialogue session name/id.
190
300
  * @param {string} [cwd]
191
301
  * @param {GraphFlowDshPluginConfig} [config]
192
- * @returns {{attempted: boolean, reason?: string, workspace?: string}}
302
+ * @returns {{attempted: boolean, reason?: string, workspace?: string, queued?: boolean}}
193
303
  */
194
304
  export function recordReplyFromTurn(event, sessionId, cwd, config = {}) {
195
305
  try {
196
- const text = extractMessageText(event?.message);
306
+ if (event?.interrupted || event?.data?.interrupted) {
307
+ return { attempted: false, reason: "interrupted" };
308
+ }
309
+ const text = extractMessageText(sessionEventMessage(event));
197
310
  if (!text) {
198
311
  return { attempted: false, reason: "no-text" };
199
312
  }
200
- if (event?.interrupted) {
201
- return { attempted: false, reason: "interrupted" };
202
- }
203
313
  const env = envOf(config);
204
314
  if (!isAutoCaptureEnabled(env)) {
205
315
  return { attempted: false, reason: "auto-capture-off" };
206
316
  }
207
- const workspace = typeof cwd === "string" && cwd.trim() ? cwd : process.cwd();
208
- const spawnFn = typeof config.spawn === "function" ? config.spawn : spawn;
209
- const cli = resolveCliCommand(config.packageRoot);
210
- const args = [cli, "dialogue", "record", "--reply", text];
211
- if (typeof sessionId === "string" && sessionId.trim()) {
212
- args.push("--session", sessionId);
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;
213
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);
214
504
  const bin = cli === "graphflow" ? (env.GRAPHFLOW_HOOK_BIN?.trim() || "npx") : process.execPath;
215
- const fullArgs = cli === "graphflow" ? ["-y", "--package=@roarpeng/graphflow", ...args] : args;
216
- spawnFn(bin, fullArgs, {
505
+ const fullArgs = cli === "graphflow" ? ["-y", "--package=@roarpeng/graphflow", cli, ...args] : [cli, ...args];
506
+ const child = spawnFn(bin, fullArgs, {
217
507
  cwd: workspace,
218
508
  env,
219
509
  stdio: "ignore",
220
- detached: true,
510
+ detached: false,
221
511
  windowsHide: true,
222
- })?.unref?.();
223
- return { attempted: true, workspace };
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 };
224
906
  } catch {
225
907
  return { attempted: false, reason: "error" };
226
908
  }
@@ -349,6 +1031,17 @@ export function apply(ctx, config = {}) {
349
1031
  // missing or duplicate skill registry — ignore
350
1032
  }
351
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
+
352
1045
  const hinted = new WeakSet();
353
1046
  const closed = new Set();
354
1047
 
@@ -407,23 +1100,51 @@ export function apply(ctx, config = {}) {
407
1100
  // event bus missing
408
1101
  }
409
1102
 
410
- const recordedReplies = new WeakSet();
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>
411
1109
 
412
1110
  try {
413
- // Auto fill every assistant reply into the latest pending dialogue turn of
414
- // the same dsh session: closes the question→answer loop so dialogue-turn
415
- // nodes record a complete exchange without the agent calling
416
- // graphflow_context({ assistantReply }) itself. Deduped per message object;
417
- // interrupted (half-written) replies are skipped.
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.
418
1119
  listen(ctx, "session/event", (session, event) => {
419
1120
  try {
420
- if (event?.type !== "assistant/message") return;
421
- if (event.interrupted) return;
422
- const message = event.message;
423
- if (!message || typeof message !== "object") return;
424
- if (recordedReplies.has(message)) return;
425
- recordedReplies.add(message);
426
- recordReplyFromTurn(event, session?.id, session?.header?.cwd, config);
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
+ }
427
1148
  } catch {
428
1149
  // reply fill is optional
429
1150
  }
@@ -451,4 +1172,9 @@ export function apply(ctx, config = {}) {
451
1172
  } catch {
452
1173
  // event bus missing
453
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;
454
1180
  }