@chorus-aidlc/chorus-openclaw-plugin 0.5.3 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/connection-state.d.ts +35 -0
  2. package/dist/connection-state.d.ts.map +1 -0
  3. package/dist/connection-state.js +52 -0
  4. package/dist/connection-state.js.map +1 -0
  5. package/dist/control-handler.d.ts +73 -0
  6. package/dist/control-handler.d.ts.map +1 -0
  7. package/dist/control-handler.js +135 -0
  8. package/dist/control-handler.js.map +1 -0
  9. package/dist/daemon-client.d.ts +203 -0
  10. package/dist/daemon-client.d.ts.map +1 -0
  11. package/dist/daemon-client.js +469 -0
  12. package/dist/daemon-client.js.map +1 -0
  13. package/dist/daemon-rest-client.d.ts +86 -0
  14. package/dist/daemon-rest-client.d.ts.map +1 -0
  15. package/dist/daemon-rest-client.js +196 -0
  16. package/dist/daemon-rest-client.js.map +1 -0
  17. package/dist/event-router.d.ts +31 -6
  18. package/dist/event-router.d.ts.map +1 -1
  19. package/dist/event-router.js +58 -27
  20. package/dist/event-router.js.map +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +106 -7
  23. package/dist/index.js.map +1 -1
  24. package/dist/lineage.d.ts +44 -0
  25. package/dist/lineage.d.ts.map +1 -0
  26. package/dist/lineage.js +116 -0
  27. package/dist/lineage.js.map +1 -0
  28. package/dist/mcp-registration.d.ts.map +1 -1
  29. package/dist/mcp-registration.js +5 -4
  30. package/dist/mcp-registration.js.map +1 -1
  31. package/dist/sse-listener.d.ts +34 -0
  32. package/dist/sse-listener.d.ts.map +1 -1
  33. package/dist/sse-listener.js +78 -4
  34. package/dist/sse-listener.js.map +1 -1
  35. package/dist/wake.d.ts +20 -0
  36. package/dist/wake.d.ts.map +1 -1
  37. package/dist/wake.js +56 -0
  38. package/dist/wake.js.map +1 -1
  39. package/package.json +1 -1
  40. package/skills/brainstorm/SKILL.md +1 -1
  41. package/skills/chorus/SKILL.md +37 -6
  42. package/skills/develop/SKILL.md +1 -1
  43. package/skills/idea/SKILL.md +18 -3
  44. package/skills/openspec-aware/SKILL.md +1 -1
  45. package/skills/proposal/SKILL.md +1 -1
  46. package/skills/proposal-reviewer/SKILL.md +1 -1
  47. package/skills/quick-dev/SKILL.md +1 -1
  48. package/skills/review/SKILL.md +1 -1
  49. package/skills/task-reviewer/SKILL.md +1 -1
  50. package/skills/yolo/SKILL.md +1 -1
  51. package/src/connection-state.ts +66 -0
  52. package/src/control-handler.ts +219 -0
  53. package/src/daemon-client.ts +622 -0
  54. package/src/daemon-rest-client.ts +312 -0
  55. package/src/event-router.ts +103 -33
  56. package/src/index.ts +113 -8
  57. package/src/lineage.ts +157 -0
  58. package/src/mcp-registration.ts +6 -19
  59. package/src/openclaw-sdk.d.ts +232 -1
  60. package/src/sse-listener.ts +117 -5
  61. package/src/wake.ts +69 -26
@@ -4,7 +4,7 @@ description: Chorus AI Agent collaboration platform — overview, common tools,
4
4
  license: AGPL-3.0
5
5
  metadata:
6
6
  author: chorus
7
- version: "0.9.4"
7
+ version: "0.11.0"
8
8
  category: project-management
9
9
  mcp_server: chorus
10
10
  ---
@@ -295,7 +295,7 @@ The table below shows default tool availability for each preset (no custom permi
295
295
  | `chorus_add_comment` / `chorus_get_comments` | (public) | Yes | Yes | Yes |
296
296
  | `chorus_update_task` (field edits + status) | (public; assignee required for status) | Yes | Yes | Yes |
297
297
  | `chorus_claim_task` / `chorus_release_task` / `chorus_submit_for_verify` / `chorus_report_work` / `chorus_report_criteria_self_check` | `task:write` | Yes | **Yes** (0.7.0+) | Yes |
298
- | `chorus_claim_idea` / `chorus_release_idea` / `chorus_move_idea` / `chorus_pm_create_idea` / `chorus_pm_*_elaboration` | `idea:write` | No | Yes | Yes |
298
+ | `chorus_claim_idea` / `chorus_release_idea` / `chorus_move_idea` / `chorus_pm_create_idea` / `chorus_edit_idea` / `chorus_pm_*_elaboration` | `idea:write` | No | Yes | Yes |
299
299
  | `chorus_pm_create_proposal` / `chorus_pm_*_proposal` / `chorus_pm_*_draft` / `chorus_create_tasks` / `chorus_pm_assign_task` / `chorus_update_task` (dependency edits via `addDependsOn`/`removeDependsOn`) | `proposal:write` | No | Yes | Yes |
300
300
  | `chorus_pm_create_document` / `chorus_pm_update_document` / `chorus_create_report` | `document:write` | No | Yes | Yes |
301
301
  | `chorus_admin_create_project` / `chorus_admin_*_project_group` / `chorus_admin_move_project_to_group` | `project:write` | No | **Yes** (0.7.0+) | Yes |
@@ -314,18 +314,49 @@ Results are advisory — they do not hard-block approval or verification, but yo
314
314
 
315
315
  ---
316
316
 
317
- ## SSE Event-Driven Model
317
+ ## SSE Event-Driven Model (Bidirectional Daemon Host)
318
318
 
319
- The OpenClaw Chorus plugin runs a background service that holds a **Server-Sent Events (SSE)** connection to the Chorus server and wakes the agent (via the plugin's in-process system-event bridge) when relevant events arrive. Instead of polling, the agent is notified the moment something needs its attention.
319
+ The OpenClaw Chorus plugin runs a background service that holds a **Server-Sent Events (SSE)** connection to the Chorus server. It is a **fully bidirectional daemon host**, on a par with the Chorus CLI daemon: it is not just a receiver of notifications that wakes the agent it also **registers a connection identity, reports its execution lifecycle back to the server, and accepts reverse control commands** (interrupt / resume / deliver an instruction turn) so a human in the Chorus UI can observe and steer an in-flight run. Instead of polling, the agent is notified the moment something needs its attention, and the server always has a live, controllable view of what this host is running.
320
320
 
321
- ### How It Works
321
+ ### Inbound: notifications wake the agent
322
322
 
323
323
  1. The plugin connects to the Chorus SSE endpoint using the configured API Key
324
324
  2. When a notification event arrives, the plugin fetches the full notification details
325
325
  3. If `projectUuids` is configured, events from other projects are filtered out
326
- 4. The plugin routes the event to the agent with context-rich instructions
326
+ 4. The plugin resolves the event's lineage and routes it to the agent (an in-process embedded-agent run) with context-rich instructions
327
327
  5. If `autoStart` is enabled, certain events (like `task_assigned`) auto-claim before waking the agent
328
328
 
329
+ ### Connection identity (`connection_registered`)
330
+
331
+ Right after the SSE handshake, the server assigns this stream a **DaemonConnection** and reports its `connectionUuid` via a `connection_registered` data event. The plugin captures that uuid (refreshing it on every reconnect, so a recycled stream never carries a stale identity) and uses it as the single source of truth for "which connection am I." This identity is what makes the connection addressable: it appears in the Chorus **Agent Connections** UI, scopes the reverse control channel, and stamps every outbound report below.
332
+
333
+ ### Outbound: the daemon reports its lifecycle to the server
334
+
335
+ While a woken run executes, the plugin reports back over the `/api/daemon/*` REST surface (host-agnostic, the same payload shapes the CLI daemon sends) so the connection's activity is observable in the UI in real time:
336
+
337
+ | Report | What it conveys |
338
+ |--------|-----------------|
339
+ | **turn-advance** | Advances the wake's turn lifecycle (`running` → `ended`) so the UI knows when a turn starts and finishes |
340
+ | **execution-state** | Publishes this connection's running/queued execution snapshot (entity, root idea, status, start time) — the live "what is this host doing" view |
341
+ | **transcript** | Streams the finalized user/assistant text of the turn (only `{ role, text }` — no internals) so the run's transcript is visible in the UI |
342
+ | **report-interrupt** | Records that a run ended as `interrupted` (reason `user` or `crash`) rather than completing |
343
+
344
+ Reports are fire-and-forget and never crash the run: a network/non-2xx failure is logged with its cause and surfaced as a structured failure result, never silently swallowed.
345
+
346
+ ### Reverse control channel (interrupt / resume / deliver_turn)
347
+
348
+ The server publishes control commands on a `control:{connectionUuid}` channel and forwards them as `type:"control"` SSE events. These are **forked away from the wake path** — a control event can never spawn a new agent run for itself. The plugin acts on a command only after a double-check: (1) the command's `targetConnectionUuid` matches this connection's own uuid (a stale/recycled uuid or another connection's command is ignored and logged), and (2) for `interrupt`, this host actually holds a running embedded-agent run for that entity.
349
+
350
+ | Command | Effect |
351
+ |---------|--------|
352
+ | **interrupt** | Aborts the matching in-flight run (true mid-run stop via its AbortController), then reports `report-interrupt` |
353
+ | **resume** | Re-dispatches the entity's wake to continue the **same** session (the synthetic "resume" wake; resolves lineage so it anchors on the same direct idea) |
354
+ | **deliver_turn** | Runs a human instruction turn delivered into the conversation — precisely the one turn when a `turnUuid` is present (origin-only live delivery), or a full pending-turns sweep as a fallback for an older server |
355
+
356
+ ### Backfill on reconnect
357
+
358
+ After an SSE gap, the plugin re-pulls (1) unread **notifications** missed during the gap and (2) this connection's unstarted **pending turns** from the turn table (the safety net for a `deliver_turn` ping lost while disconnected). The two sources share a seen-set, so a turn is run **at most once** across live delivery and backfill.
359
+
329
360
  ### Event Types
330
361
 
331
362
  | Event | Trigger | Agent Action |
@@ -4,7 +4,7 @@ description: Chorus Development workflow — claim tasks, report work, manage se
4
4
  license: AGPL-3.0
5
5
  metadata:
6
6
  author: chorus
7
- version: "0.9.4"
7
+ version: "0.11.0"
8
8
  category: project-management
9
9
  mcp_server: chorus
10
10
  ---
@@ -4,7 +4,7 @@ description: Chorus Idea workflow — claim ideas, run elaboration rounds, and p
4
4
  license: AGPL-3.0
5
5
  metadata:
6
6
  author: chorus
7
- version: "0.9.4"
7
+ version: "0.11.0"
8
8
  category: project-management
9
9
  mcp_server: chorus
10
10
  ---
@@ -37,10 +37,11 @@ All post-elaboration progress (planning, building, verifying, done) is **derived
37
37
 
38
38
  | Tool | Purpose |
39
39
  |------|---------|
40
- | `chorus_pm_create_idea` | Create a new idea in a project (on behalf of humans) |
40
+ | `chorus_pm_create_idea` | Create a new idea in a project (on behalf of humans). Optional `parentUuid` derives a child idea from an existing same-project idea (single-parent lineage). |
41
+ | `chorus_edit_idea` | Edit an existing idea's title, description, and/or lineage parent. `parentUuid`: another same-project idea to reparent under, `null` to detach to top-level, omit to leave unchanged (cycle-checked + same-project). Single-parent **weak** lineage — a parent shows a read-only `+N derived` rollup but never blocks either idea's flow. Records an "edited" activity and signals presence. |
41
42
  | `chorus_claim_idea` | Claim an open idea (open -> elaborating) |
42
43
  | `chorus_release_idea` | Release a claimed idea (elaborating -> open) |
43
- | `chorus_move_idea` | Move an Idea to a different Project. Cascade-migrates the Idea, all linked Proposals (any status), all materialized Documents and Tasks, and all related Activities atomically. Comments, TaskDependency, AcceptanceCriterion, AgentSession, SessionTaskCheckin, Notification history, and Task assignees are NOT modified. Returns `moved: { proposals, documents, tasks, activities }` counts. Requires `idea:write` only — no project-level checks. |
44
+ | `chorus_move_idea` | Move an Idea to a different Project. Cascade-migrates the Idea **and its full lineage subtree** (all descendant Ideas; the moved root is detached from any parent left behind), all linked Proposals (any status), all materialized Documents and Tasks, and all related Activities atomically. Comments, TaskDependency, AcceptanceCriterion, AgentSession, SessionTaskCheckin, Notification history, and Task assignees are NOT modified. Returns `moved: { ideas, proposals, documents, tasks, activities }` counts. Requires `idea:write` only — no project-level checks. |
44
45
 
45
46
  **Requirements Elaboration:**
46
47
 
@@ -276,6 +277,20 @@ chorus_pm_skip_elaboration({
276
277
 
277
278
  ---
278
279
 
280
+ ## Idea Lineage (derive vs. task)
281
+
282
+ Ideas can form a **single-parent forest**: an idea may have one parent (`parentUuid`), establishing a weak lineage. "Weak" means the parent only shows a read-only `+N derived` rollup of its **direct** children — it never blocks or alters either idea's elaboration/proposal/task flow, and a parent is always a full first-class idea (it can have its own content, proposals, and tasks).
283
+
284
+ When a new direction surfaces (during elaboration, brainstorm, or review), decide where it belongs:
285
+
286
+ - **Derive a child idea** (`chorus_pm_create_idea` with `parentUuid`, or `chorus_edit_idea` with `parentUuid` to reparent an existing idea) when the new direction needs **its own elaboration/proposal lifecycle** — it is an independent AI-DLC pass.
287
+ - **Add a task** to the current idea's proposal when the new work is just *how to implement the current idea*.
288
+ - **Create a plain top-level idea** (no `parentUuid`) when there is no lineage to the current idea.
289
+
290
+ This is a soft heuristic, not a rule — use judgment. Cycle prevention is automatic: you cannot set a parent that is the idea itself or one of its descendants. Parent and child must be in the same project (cross-project lineage is not supported yet). Deleting a parent re-parents its children to top-level (it never cascades). (Reminder: invoke these as `chorus__chorus_pm_create_idea` / `chorus__chorus_edit_idea` — see the namespace note at the top of the `chorus` skill.)
291
+
292
+ ---
293
+
279
294
  ## Tips
280
295
 
281
296
  - When combining multiple ideas, explain how they relate in the proposal description
@@ -4,7 +4,7 @@ description: Opt-in OpenSpec-mode authoring for Chorus PM workflows on OpenClaw.
4
4
  license: AGPL-3.0
5
5
  metadata:
6
6
  author: chorus
7
- version: "0.9.4"
7
+ version: "0.11.0"
8
8
  category: project-management
9
9
  mcp_server: chorus
10
10
  ---
@@ -4,7 +4,7 @@ description: Chorus Proposal workflow — create proposals with document and tas
4
4
  license: AGPL-3.0
5
5
  metadata:
6
6
  author: chorus
7
- version: "0.9.4"
7
+ version: "0.11.0"
8
8
  category: project-management
9
9
  mcp_server: chorus
10
10
  ---
@@ -4,7 +4,7 @@ description: Adversarial read-only review of a submitted Chorus proposal — doc
4
4
  license: AGPL-3.0
5
5
  metadata:
6
6
  author: chorus
7
- version: "0.9.4"
7
+ version: "0.11.0"
8
8
  category: project-management
9
9
  mcp_server: chorus
10
10
  ---
@@ -4,7 +4,7 @@ description: Quick Task workflow — skip Idea→Proposal, create tasks directly
4
4
  license: AGPL-3.0
5
5
  metadata:
6
6
  author: chorus
7
- version: "0.9.4"
7
+ version: "0.11.0"
8
8
  category: project-management
9
9
  mcp_server: chorus
10
10
  ---
@@ -4,7 +4,7 @@ description: Chorus Review workflow — approve/reject proposals, verify tasks,
4
4
  license: AGPL-3.0
5
5
  metadata:
6
6
  author: chorus
7
- version: "0.9.4"
7
+ version: "0.11.0"
8
8
  category: project-management
9
9
  mcp_server: chorus
10
10
  ---
@@ -4,7 +4,7 @@ description: Adversarial verification of a submitted Chorus task against its AC
4
4
  license: AGPL-3.0
5
5
  metadata:
6
6
  author: chorus
7
- version: "0.9.4"
7
+ version: "0.11.0"
8
8
  category: project-management
9
9
  mcp_server: chorus
10
10
  ---
@@ -4,7 +4,7 @@ description: Full-auto AI-DLC pipeline — from prompt to done. Automates the en
4
4
  license: AGPL-3.0
5
5
  metadata:
6
6
  author: chorus
7
- version: "0.9.4"
7
+ version: "0.11.0"
8
8
  category: project-management
9
9
  mcp_server: chorus
10
10
  ---
@@ -0,0 +1,66 @@
1
+ // packages/openclaw-plugin/src/connection-state.ts
2
+ // Holds the live DaemonConnection identity for this OpenClaw plugin process —
3
+ // the `connectionUuid` the server assigns post-handshake and reports via the
4
+ // `connection_registered` SSE data event (see api/events/notifications/route.ts).
5
+ //
6
+ // WHY A DEDICATED MODULE: the connectionUuid is captured in ONE place (the SSE
7
+ // listener, via onConnectionId) but READ in several (the daemon REST client to
8
+ // attribute execution-state / turn-advance, and the control handler to do its
9
+ // `targetConnectionUuid === my uuid` double-check). Threading it through every
10
+ // constructor would couple those modules to the listener's lifecycle; instead
11
+ // the listener writes it here and the consumers read it through a stable
12
+ // `getConnectionUuid()` accessor — the same accessor shape the shared
13
+ // `daemon-rest-client` already expects (`getConnectionUuid?: () => string|null`).
14
+ //
15
+ // The value is refreshed on every `connection_registered` (so a reconnect that
16
+ // registers a NEW DaemonConnection overwrites the stale uuid), and is the single
17
+ // source of truth for "which connection am I" across the plugin.
18
+ //
19
+ // This mirrors the CLI host's `SseListener.connectionUuid` field
20
+ // (cli/sse-listener.mjs), lifted into a module so the OpenClaw plugin's separate
21
+ // modules can share one identity without a circular import on the listener.
22
+
23
+ /**
24
+ * A read accessor for the live connection identity. This is the exact shape the
25
+ * shared `daemon-rest-client` (`getConnectionUuid?: () => string|null`) and the
26
+ * control handler consume, so a single `ConnectionState` instance can be passed
27
+ * to both.
28
+ */
29
+ export interface ConnectionStateReader {
30
+ /** The connection uuid this stream registered as, or null before handshake. */
31
+ getConnectionUuid: () => string | null;
32
+ }
33
+
34
+ /**
35
+ * Mutable connection identity. One instance per plugin process; the SSE
36
+ * listener's `onConnectionId` writes it, the rest client + control handler read
37
+ * it. Not a singleton export — the entry owns the instance and injects it — so
38
+ * tests can construct an isolated state per case.
39
+ */
40
+ export class ConnectionState implements ConnectionStateReader {
41
+ private connectionUuid: string | null = null;
42
+
43
+ /** The current connection identity, or null before the first handshake. */
44
+ getConnectionUuid(): string | null {
45
+ return this.connectionUuid;
46
+ }
47
+
48
+ /**
49
+ * Record (or refresh) the connection identity. Called from the SSE listener's
50
+ * `onConnectionId` on every `connection_registered` event, so a reconnect that
51
+ * registers a new DaemonConnection overwrites the previous uuid rather than
52
+ * leaving a stale one that could mis-route a control command.
53
+ */
54
+ setConnectionUuid(connectionUuid: string): void {
55
+ this.connectionUuid = connectionUuid;
56
+ }
57
+
58
+ /**
59
+ * Forget the connection identity (e.g. on a clean disconnect). After this,
60
+ * `getConnectionUuid()` returns null so the control handler's double-check
61
+ * treats every command as "not ours" until a fresh handshake re-registers.
62
+ */
63
+ clear(): void {
64
+ this.connectionUuid = null;
65
+ }
66
+ }
@@ -0,0 +1,219 @@
1
+ // packages/openclaw-plugin/src/control-handler.ts
2
+ // OpenClaw-host handler for reverse (server→daemon) control commands — the
3
+ // in-process analog of cli/control-handler.mjs. The SSE listener forks a
4
+ // `type:"control"` SSE event here (NOT to the wake router — see sse-listener.ts),
5
+ // so a control command can NEVER spawn a new embedded-agent run for the control
6
+ // event itself. This module is the ROUTING + DOUBLE-CHECK layer only; the actual
7
+ // abort / re-dispatch / pending-turns-sweep BEHAVIORS are injected (filled by the
8
+ // openclaw-daemon-client task, T4).
9
+ //
10
+ // The single safety property this module enforces is the DOUBLE-CHECK, identical
11
+ // to the CLI host (cli/control-handler.mjs): act ONLY when —
12
+ // Check 1 (every command): event.targetConnectionUuid === this plugin's OWN
13
+ // registered connectionUuid (from connection-state). On mismatch — a
14
+ // stale/recycled uuid, another connection's command, or a handshake not
15
+ // yet complete — IGNORE + LOG. This is what stops a recycled connection
16
+ // uuid from aborting the wrong run (Tech Design "Risks": mis-kill after
17
+ // a reconnect).
18
+ // Check 2 (interrupt only): the injected `isEntityRunning(entityType, entityUuid)`
19
+ // predicate returns true — i.e. this plugin currently holds a running
20
+ // embedded-agent run for that entity. (In the CLI host this is "the
21
+ // execution registry holds a running child"; here it is an injected
22
+ // predicate the daemon client backs with its AbortController registry.)
23
+ // On mismatch — only queued, never ran, or already finished — IGNORE + LOG.
24
+ //
25
+ // `resume` and `deliver_turn` carry NO running-entity requirement (mirroring the
26
+ // CLI host): the run for a resumed entity is already gone, and a `deliver_turn`
27
+ // resolves its turn by uuid from the persisted turn table. They only require
28
+ // Check 1.
29
+ //
30
+ // The three verbs route to injected behavior hooks (the seams T4 plugs into):
31
+ // - interrupt → onInterrupt(entityType, entityUuid)
32
+ // - resume → onResume(entityType, entityUuid)
33
+ // - deliver_turn → onDeliverTurn(turnUuid?) (turnUuid present = precise single
34
+ // turn; absent = older server, full connection sweep fallback)
35
+ //
36
+ // Non-throwing: a control event must NEVER crash the SSE loop, so the whole body
37
+ // is wrapped in a try/catch backstop and the hooks' own throws are caught + logged
38
+ // (memory: no-silent-errors — every ignore/failure is logged, never swallowed).
39
+
40
+ import type { ConnectionStateReader } from "./connection-state.js";
41
+
42
+ /**
43
+ * The control event shape the server publishes on `control:{connectionUuid}` and
44
+ * forwards verbatim as a `type:"control"` SSE data event. Mirrors the server's
45
+ * `ControlEvent` (src/lib/event-bus.ts). `entityType`/`entityUuid` are present for
46
+ * `interrupt`/`resume`; `turnUuid` is present only for `deliver_turn`.
47
+ */
48
+ export interface ControlEvent {
49
+ // `string` (not the `"control"` literal) because this is the raw forked SSE
50
+ // event — the handler re-validates `type === "control"` and the command enum at
51
+ // runtime before acting. Matches the listener's `SseControlEvent` shape so the
52
+ // listener can pass its parsed event straight through.
53
+ type: string;
54
+ command?: string;
55
+ targetConnectionUuid?: string;
56
+ entityType?: string;
57
+ entityUuid?: string;
58
+ turnUuid?: string;
59
+ }
60
+
61
+ /**
62
+ * Injected behavior hooks. These are the seams the openclaw-daemon-client task
63
+ * (T4) fills with the real abort / re-dispatch / pending-turns-sweep. This task
64
+ * (T3) only ROUTES verified commands to them — it does not implement the
65
+ * behaviors. All hooks are optional so a partially-wired host (or a test) can
66
+ * verify routing without every behavior present.
67
+ */
68
+ export interface ControlBehaviorHooks {
69
+ /**
70
+ * Whether this plugin currently holds a RUNNING embedded-agent run for the
71
+ * entity — the OpenClaw analog of the CLI host's "execution registry holds a
72
+ * running child" check. Gates `interrupt` (Check 2). Defaults to "nothing is
73
+ * running" (always false) when absent, so an interrupt with no registry is a
74
+ * safe no-op rather than a blind abort.
75
+ */
76
+ isEntityRunning?: (entityType: string, entityUuid: string) => boolean;
77
+
78
+ /**
79
+ * Abort the matching in-flight run for the entity (true mid-run stop via the
80
+ * run's AbortController). Invoked only after BOTH checks pass. (T4)
81
+ */
82
+ onInterrupt?: (entityType: string, entityUuid: string) => void;
83
+
84
+ /**
85
+ * Re-dispatch the entity's wake to continue the same session — the synthetic
86
+ * "resume" wake. Invoked after Check 1 only (no running-entity requirement —
87
+ * the run is gone). (T4)
88
+ */
89
+ onResume?: (entityType: string, entityUuid: string) => void;
90
+
91
+ /**
92
+ * Run the connection-scoped pending turn(s). With a `turnUuid` (origin-only
93
+ * live delivery) run PRECISELY that one turn; without one (older server /
94
+ * reconnect) sweep all pending turns. Invoked after Check 1 only. (T4)
95
+ */
96
+ onDeliverTurn?: (turnUuid?: string) => void;
97
+ }
98
+
99
+ export interface ControlHandlerOptions {
100
+ /** Live connection identity (connection-state). Provides Check 1's "my uuid". */
101
+ connectionState: ConnectionStateReader;
102
+ /** Injected behavior hooks (filled by T4). */
103
+ hooks: ControlBehaviorHooks;
104
+ logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
105
+ }
106
+
107
+ /**
108
+ * Build the `onControl(event)` callback the SSE listener invokes for a
109
+ * `type:"control"` event. The returned function is synchronous and non-throwing:
110
+ * it performs the double-check and routes to the (injected) behavior hook,
111
+ * returning immediately so the SSE consumer never blocks.
112
+ *
113
+ * A control event NEVER enqueues a wake or spawns a run for the control event
114
+ * itself — that structural guarantee is owned by the SSE listener fork (the
115
+ * control event never reaches `onEvent` / the wake router) and reinforced here
116
+ * by routing ONLY to the abort/resume/deliver hooks, never to the wake path.
117
+ */
118
+ export function createControlHandler(opts: ControlHandlerOptions): (event: ControlEvent) => void {
119
+ const { connectionState, hooks, logger } = opts;
120
+ const isEntityRunning = hooks.isEntityRunning ?? (() => false);
121
+
122
+ return function onControl(event: ControlEvent): void {
123
+ try {
124
+ if (!event || event.type !== "control") {
125
+ logger.warn(`[Chorus] control-handler received non-control event; ignoring`);
126
+ return;
127
+ }
128
+
129
+ if (
130
+ event.command !== "interrupt" &&
131
+ event.command !== "resume" &&
132
+ event.command !== "deliver_turn"
133
+ ) {
134
+ // Forward-compatible: the wire enum may grow. Unknown command → ignore + log.
135
+ logger.warn(`[Chorus] control command "${event.command}" not supported; ignoring`);
136
+ return;
137
+ }
138
+
139
+ const { command, targetConnectionUuid, entityType, entityUuid } = event;
140
+
141
+ // --- Check 1: connection-uuid match (applies to EVERY command) ---
142
+ const myConnectionUuid = connectionState.getConnectionUuid();
143
+ if (!myConnectionUuid || targetConnectionUuid !== myConnectionUuid) {
144
+ // Not ours (stale/recycled uuid, another connection, or handshake not yet
145
+ // complete). Ignore — never abort/resume/deliver for a command that isn't ours.
146
+ logger.info(
147
+ `[Chorus] control: ignoring ${command} for connection ${targetConnectionUuid} ` +
148
+ `(this plugin is ${myConnectionUuid ?? "<unregistered>"})`,
149
+ );
150
+ return;
151
+ }
152
+
153
+ // --- deliver_turn: origin-only live delivery. No entity on the wire (the
154
+ // turn is read by uuid) and NO running-entity requirement (mirrors
155
+ // resume). With a turnUuid, run PRECISELY that turn; without one (older
156
+ // server), the hook falls back to a full connection sweep. ---
157
+ if (command === "deliver_turn") {
158
+ const turnUuid = typeof event.turnUuid === "string" ? event.turnUuid : undefined;
159
+ logger.info(
160
+ `[Chorus] control: deliver_turn for connection ${targetConnectionUuid} ` +
161
+ (turnUuid ? `(turn ${turnUuid})` : "(no turnUuid — full sweep fallback)"),
162
+ );
163
+ try {
164
+ hooks.onDeliverTurn?.(turnUuid);
165
+ } catch (err) {
166
+ logger.warn(`[Chorus] control: deliver_turn hook failed: ${err}`);
167
+ }
168
+ return;
169
+ }
170
+
171
+ // interrupt / resume both target a specific entity — require both fields.
172
+ if (typeof entityType !== "string" || typeof entityUuid !== "string") {
173
+ logger.warn(`[Chorus] control: ${command} missing entityType/entityUuid; ignoring`);
174
+ return;
175
+ }
176
+
177
+ // --- resume: re-dispatch the wake for this entity. No running-entity check —
178
+ // the run is gone (it was interrupted); the wake path re-enters the same
179
+ // session. Check 1 already passed. ---
180
+ if (command === "resume") {
181
+ logger.info(`[Chorus] control: resuming ${entityType}:${entityUuid} (re-dispatch wake)`);
182
+ try {
183
+ hooks.onResume?.(entityType, entityUuid);
184
+ } catch (err) {
185
+ logger.warn(
186
+ `[Chorus] control: resume re-dispatch failed for ${entityType}:${entityUuid}: ${err}`,
187
+ );
188
+ }
189
+ return;
190
+ }
191
+
192
+ // --- interrupt path: Check 2 — this plugin must hold a RUNNING run for the
193
+ // entity. The injected predicate is backed by the daemon client's
194
+ // AbortController registry (T4). ---
195
+ if (!isEntityRunning(entityType, entityUuid)) {
196
+ // Either we never ran this entity, it's only queued, or the run already
197
+ // finished (race: interrupt arrived after completion). Safe no-op.
198
+ logger.info(
199
+ `[Chorus] control: no running embedded-agent run for ${entityType}:${entityUuid} ` +
200
+ `on this plugin; ignoring interrupt`,
201
+ );
202
+ return;
203
+ }
204
+
205
+ // --- Both checks passed: route to the abort hook (true mid-run stop). ---
206
+ logger.info(`[Chorus] control: interrupting running run for ${entityType}:${entityUuid}`);
207
+ try {
208
+ hooks.onInterrupt?.(entityType, entityUuid);
209
+ } catch (err) {
210
+ logger.warn(
211
+ `[Chorus] control: interrupt hook failed for ${entityType}:${entityUuid}: ${err}`,
212
+ );
213
+ }
214
+ } catch (err) {
215
+ // Absolute backstop — a control event must never crash the SSE loop.
216
+ logger.error(`[Chorus] control-handler unexpected error: ${err}`);
217
+ }
218
+ };
219
+ }