@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.
- package/dist/connection-state.d.ts +35 -0
- package/dist/connection-state.d.ts.map +1 -0
- package/dist/connection-state.js +52 -0
- package/dist/connection-state.js.map +1 -0
- package/dist/control-handler.d.ts +73 -0
- package/dist/control-handler.d.ts.map +1 -0
- package/dist/control-handler.js +135 -0
- package/dist/control-handler.js.map +1 -0
- package/dist/daemon-client.d.ts +203 -0
- package/dist/daemon-client.d.ts.map +1 -0
- package/dist/daemon-client.js +469 -0
- package/dist/daemon-client.js.map +1 -0
- package/dist/daemon-rest-client.d.ts +86 -0
- package/dist/daemon-rest-client.d.ts.map +1 -0
- package/dist/daemon-rest-client.js +196 -0
- package/dist/daemon-rest-client.js.map +1 -0
- package/dist/event-router.d.ts +31 -6
- package/dist/event-router.d.ts.map +1 -1
- package/dist/event-router.js +58 -27
- package/dist/event-router.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +106 -7
- package/dist/index.js.map +1 -1
- package/dist/lineage.d.ts +44 -0
- package/dist/lineage.d.ts.map +1 -0
- package/dist/lineage.js +116 -0
- package/dist/lineage.js.map +1 -0
- package/dist/mcp-registration.d.ts.map +1 -1
- package/dist/mcp-registration.js +5 -4
- package/dist/mcp-registration.js.map +1 -1
- package/dist/sse-listener.d.ts +34 -0
- package/dist/sse-listener.d.ts.map +1 -1
- package/dist/sse-listener.js +78 -4
- package/dist/sse-listener.js.map +1 -1
- package/dist/wake.d.ts +20 -0
- package/dist/wake.d.ts.map +1 -1
- package/dist/wake.js +56 -0
- package/dist/wake.js.map +1 -1
- package/package.json +1 -1
- package/skills/brainstorm/SKILL.md +1 -1
- package/skills/chorus/SKILL.md +37 -6
- package/skills/develop/SKILL.md +1 -1
- package/skills/idea/SKILL.md +18 -3
- package/skills/openspec-aware/SKILL.md +1 -1
- package/skills/proposal/SKILL.md +1 -1
- package/skills/proposal-reviewer/SKILL.md +1 -1
- package/skills/quick-dev/SKILL.md +1 -1
- package/skills/review/SKILL.md +1 -1
- package/skills/task-reviewer/SKILL.md +1 -1
- package/skills/yolo/SKILL.md +1 -1
- package/src/connection-state.ts +66 -0
- package/src/control-handler.ts +219 -0
- package/src/daemon-client.ts +622 -0
- package/src/daemon-rest-client.ts +312 -0
- package/src/event-router.ts +103 -33
- package/src/index.ts +113 -8
- package/src/lineage.ts +157 -0
- package/src/mcp-registration.ts +6 -19
- package/src/openclaw-sdk.d.ts +232 -1
- package/src/sse-listener.ts +117 -5
- package/src/wake.ts +69 -26
package/skills/chorus/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
###
|
|
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
|
|
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 |
|
package/skills/develop/SKILL.md
CHANGED
package/skills/idea/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
package/skills/proposal/SKILL.md
CHANGED
package/skills/review/SKILL.md
CHANGED
package/skills/yolo/SKILL.md
CHANGED
|
@@ -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
|
+
}
|