@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
@@ -0,0 +1,622 @@
1
+ // packages/openclaw-plugin/src/daemon-client.ts
2
+ // The OpenClaw in-process host's bidirectional daemon behavior — the in-process
3
+ // analog of the chorus CLI host (cli/waker.mjs + cli/daemon.mjs), re-mapped from a
4
+ // spawned `claude` subprocess to a single `runtime.agent.runEmbeddedAgent(...)` call.
5
+ //
6
+ // Re-mapping table (verified against ../openclaw, 2026-06-20):
7
+ // | Concern | CLI host | this client |
8
+ // | run an agent | spawn `claude` subprocess | runEmbeddedAgent(params) in-process |
9
+ // | observe messages | parse stream-json lines | onAssistantMessageStart/onBlockReply |
10
+ // | | | /onToolResult callbacks (params.ts) |
11
+ // | mid-run interrupt | SIGINT→SIGKILL the proc group | AbortController.abort() → abortSignal |
12
+ // | crash vs user-abort | non-zero exit code | promise rejects / result.meta.aborted|
13
+ // | resume same session | `claude --resume <directIdea>` | sessionKey from business key → |
14
+ // | | | getSessionEntry resolves sessionId |
15
+ // The server-facing payloads are IDENTICAL across hosts (that is why they live in the
16
+ // shared daemon-rest-client). This module owns only the host-side concerns: the
17
+ // run wrapper, the abort/execution registries, the transcript content filter, the
18
+ // session mapping, and the at-most-once turn dispatch.
19
+ //
20
+ // VERIFIED runEmbeddedAgent surface (../openclaw):
21
+ // - abortSignal?: AbortSignal params.ts:167
22
+ // - onAssistantMessageStart?: () => void|Promise<void> params.ts:190 (ZERO-arg)
23
+ // - onBlockReply?: (BlockReplyPayload{text?,isReasoning?}) params.ts:191 / payloads.ts:1-11
24
+ // - onToolResult?: (ReplyPayload) params.ts:201 (tool internals — NOT transcript)
25
+ // - onReasoningStream?: (...) params.ts:195 (thinking — NOT transcript)
26
+ // - result.meta.aborted?: boolean types.ts:140 (user-abort marker)
27
+ // - getSessionEntry({sessionKey,agentId}) → SessionEntry? store.ts:210
28
+ // - resolveSessionFilePath(sessionId, entry?, opts?) paths.ts:267
29
+
30
+ import type {
31
+ OpenClawRuntimeAgent,
32
+ OpenClawBlockReplyPayload,
33
+ } from "openclaw/plugin-sdk/plugin-entry";
34
+ import type {
35
+ DaemonRestClient,
36
+ DaemonExecutionRow,
37
+ DaemonTranscriptMessage,
38
+ DaemonPendingTurn,
39
+ } from "./daemon-rest-client.js";
40
+
41
+ export interface DaemonClientLogger {
42
+ info: (msg: string) => void;
43
+ warn: (msg: string) => void;
44
+ error: (msg: string) => void;
45
+ }
46
+
47
+ const NOOP_LOGGER: DaemonClientLogger = { info() {}, warn() {}, error() {} };
48
+
49
+ /** Resource kinds the server's DaemonExecution accepts (mirrors waker.mjs). */
50
+ const EXECUTION_ENTITY_TYPES = new Set(["task", "idea", "proposal", "document", "daemon_session"]);
51
+
52
+ /**
53
+ * The host knobs the run needs, resolved per-wake by the entry/wake layer. These are
54
+ * the OpenClaw runtime-derived values (workspace dir, timeout, model, …) that wake.ts
55
+ * already knows how to resolve from `api.config`. The daemon client takes them as a
56
+ * resolver so it never reaches into `api` directly (keeps it host-API-agnostic + testable).
57
+ */
58
+ export interface WakeRunContext {
59
+ /** The `runtime.agent` surface to run the turn with. */
60
+ agent: OpenClawRuntimeAgent;
61
+ /** The OpenClaw session key for this wake's main agent (e.g. `agent:main:main`). */
62
+ sessionKey: string;
63
+ /** The resolved default agent id (session/workspace resolvers are agent-scoped). */
64
+ agentId: string;
65
+ /** The opaque `api.config` snapshot passed through to runEmbeddedAgent. */
66
+ config: unknown;
67
+ /** Resolved agent workspace dir. */
68
+ workspaceDir: string;
69
+ /** Resolved agent dir (optional — omitted when the host has none). */
70
+ agentDir?: string;
71
+ /** Resolved per-agent timeout. */
72
+ timeoutMs: number;
73
+ /** Resolved `{ provider, model }` override, or null to use the host default. */
74
+ modelRef: { provider: string; model: string } | null;
75
+ }
76
+
77
+ /**
78
+ * Per-wake attribution + prompt. `entityType`/`entityUuid` identify the resource the
79
+ * execution row keys on; `directIdeaUuid`/`rootIdeaUuid` come from the lineage resolve
80
+ * (the two-id contract: directIdea = session anchor, rootIdea = snapshot attribution).
81
+ * `contextKey` is the router's dedupe/log key. `turnUuid` is set for a delivered turn.
82
+ */
83
+ export interface WakeRequest {
84
+ prompt: string;
85
+ contextKey: string;
86
+ entityType?: string | null;
87
+ entityUuid?: string | null;
88
+ directIdeaUuid?: string | null;
89
+ rootIdeaUuid?: string | null;
90
+ /** When this wake runs a specific pending turn (deliver_turn / backfill). */
91
+ turnUuid?: string | null;
92
+ }
93
+
94
+ interface ExecutionEntry {
95
+ entityType: string;
96
+ entityUuid: string;
97
+ rootIdeaUuid: string | null;
98
+ status: "running" | "queued";
99
+ startedAt: string | null;
100
+ }
101
+
102
+ interface AbortEntry {
103
+ controller: AbortController;
104
+ /** Set when an authorized interrupt fired before the run settled → reason=user. */
105
+ interrupting: boolean;
106
+ }
107
+
108
+ export interface OpenClawDaemonClientOptions {
109
+ /** The shared REST client (turnAdvance/transcript/executionState/reportInterrupt/readPendingTurns). */
110
+ restClient: DaemonRestClient;
111
+ /**
112
+ * Resolve the host run context for a wake (workspace/timeout/model/agent/session
113
+ * key). Returns null when the wake must be DROPPED (no resolvable session/agent on
114
+ * this host) — mirrors wake.ts's graceful-drop. Errors thrown here are caught.
115
+ */
116
+ resolveRunContext: () => WakeRunContext | null;
117
+ /**
118
+ * Re-dispatch a wake for an entity (the synthetic resume / pending-turn path). Built
119
+ * by the entry from the router so a resume / delivered turn rides the SAME wake path
120
+ * (continuing the same session). Receives the full WakeRequest.
121
+ */
122
+ redispatch: (req: WakeRequest) => void;
123
+ /**
124
+ * Read this connection's unstarted (pending) turns and feed each to `redispatch`.
125
+ * The client owns the seen-set dedup + the optional single-turn filter; the entry
126
+ * just provides the prompt builder via `buildTurnPrompt`.
127
+ */
128
+ buildTurnPrompt: (turn: DaemonPendingTurn) => string;
129
+ logger?: DaemonClientLogger;
130
+ }
131
+
132
+ /**
133
+ * Extract finalized assistant VISIBLE text from an `onBlockReply` payload, or null to
134
+ * skip. Mirrors the CLI host's stream-json filter (upload-hooks.mjs
135
+ * `extractTranscriptText`): keep only finalized assistant text; DROP reasoning/thinking
136
+ * (`isReasoning`) — verified the flag exists at ../openclaw/src/agents/
137
+ * embedded-agent-payloads.ts:7 and is set true for reasoning blocks
138
+ * (embedded-agent-subscribe.handlers.messages.ts:915). Tool internals never reach here
139
+ * (they arrive on `onToolResult`, which the client does not post). Never throws.
140
+ */
141
+ export function extractBlockReplyText(
142
+ payload: OpenClawBlockReplyPayload | undefined | null,
143
+ ): DaemonTranscriptMessage | null {
144
+ if (!payload || typeof payload !== "object") return null;
145
+ // Reasoning/thinking blocks are internal — not user-visible transcript.
146
+ if (payload.isReasoning) return null;
147
+ const text = typeof payload.text === "string" ? payload.text : "";
148
+ if (!text.trim()) return null;
149
+ return { role: "assistant", text };
150
+ }
151
+
152
+ /**
153
+ * The OpenClaw in-process daemon client. One instance per plugin process; the entry
154
+ * injects the shared REST client + the host run-context resolver + the re-dispatch
155
+ * hook, and wires this client's `controlHooks` into the control handler.
156
+ */
157
+ export class OpenClawDaemonClient {
158
+ private readonly restClient: DaemonRestClient;
159
+ private readonly resolveRunContext: OpenClawDaemonClientOptions["resolveRunContext"];
160
+ private readonly redispatch: OpenClawDaemonClientOptions["redispatch"];
161
+ private readonly buildTurnPrompt: OpenClawDaemonClientOptions["buildTurnPrompt"];
162
+ private readonly logger: DaemonClientLogger;
163
+
164
+ /** AbortController per in-flight run, keyed `entityType:entityUuid`. */
165
+ private readonly aborts = new Map<string, AbortEntry>();
166
+ /** Execution snapshot source — one entry per running/queued resource. */
167
+ private readonly executions = new Map<string, ExecutionEntry>();
168
+ /**
169
+ * At-most-once turn dedup, keyed `turn:<uuid>`. Shared by the live deliver_turn
170
+ * path and the reconnect backfill so a turn observed by either runs at most once
171
+ * (mirrors the CLI host's `seen` set; backfill.mjs).
172
+ */
173
+ private readonly seenTurns = new Set<string>();
174
+
175
+ private runCounter = 0;
176
+
177
+ constructor(opts: OpenClawDaemonClientOptions) {
178
+ this.restClient = opts.restClient;
179
+ this.resolveRunContext = opts.resolveRunContext;
180
+ this.redispatch = opts.redispatch;
181
+ this.buildTurnPrompt = opts.buildTurnPrompt;
182
+ this.logger = opts.logger ?? NOOP_LOGGER;
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Control hooks — wired into the T3 control handler (ControlBehaviorHooks).
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /**
190
+ * The behavior hooks the control handler routes verified commands to. The handler
191
+ * owns the double-check (own connection + held entity); these implement the actual
192
+ * abort / re-dispatch / pending-turns-sweep.
193
+ */
194
+ get controlHooks(): {
195
+ isEntityRunning: (entityType: string, entityUuid: string) => boolean;
196
+ onInterrupt: (entityType: string, entityUuid: string) => void;
197
+ onResume: (entityType: string, entityUuid: string) => void;
198
+ onDeliverTurn: (turnUuid?: string) => void;
199
+ } {
200
+ return {
201
+ isEntityRunning: (entityType, entityUuid) => this.aborts.has(execKey(entityType, entityUuid)),
202
+ onInterrupt: (entityType, entityUuid) => this.interrupt(entityType, entityUuid),
203
+ onResume: (entityType, entityUuid) => this.resume(entityType, entityUuid),
204
+ onDeliverTurn: (turnUuid) => {
205
+ // Fire-and-forget: the control handler is synchronous; the sweep is async.
206
+ void this.deliverTurn(turnUuid);
207
+ },
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Abort the matching in-flight run (true mid-run stop via abortSignal) and mark it
213
+ * `interrupting` so the run's settle path reports reason=user (not crash). No-op when
214
+ * no run is registered (the handler's Check 2 already gates this, but we re-check so a
215
+ * direct caller is safe). Never throws.
216
+ */
217
+ interrupt(entityType: string, entityUuid: string): void {
218
+ const key = execKey(entityType, entityUuid);
219
+ const entry = this.aborts.get(key);
220
+ if (!entry) {
221
+ this.logger.info(`[Chorus] interrupt: no in-flight run for ${key}; ignoring`);
222
+ return;
223
+ }
224
+ entry.interrupting = true;
225
+ try {
226
+ entry.controller.abort();
227
+ this.logger.info(`[Chorus] interrupt: aborted in-flight run for ${key}`);
228
+ } catch (err) {
229
+ this.logger.warn(`[Chorus] interrupt: abort() failed for ${key}: ${err}`);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Re-dispatch a wake for the entity to continue the SAME session (the run is gone;
235
+ * resolveRunContext re-derives the same sessionKey → getSessionEntry resolves the
236
+ * existing sessionId). The control handler has no prompt; we synthesize a minimal
237
+ * resume prompt. Continues under the same business key (directIdea/entity).
238
+ */
239
+ resume(entityType: string, entityUuid: string): void {
240
+ this.logger.info(`[Chorus] resume: re-dispatching wake for ${entityType}:${entityUuid}`);
241
+ this.redispatch({
242
+ prompt:
243
+ `[Chorus] Your previous run for this ${entityType} was interrupted by a human and is now being resumed. ` +
244
+ `Continue where you left off (entityType: ${entityType}, entityUuid: ${entityUuid}).`,
245
+ contextKey: `chorus:resume:${entityUuid}`,
246
+ entityType,
247
+ entityUuid,
248
+ });
249
+ }
250
+
251
+ /**
252
+ * Read connection-scoped pending turns and run the unstarted human_instruction turn.
253
+ * With a `turnUuid` run PRECISELY that one (live deliver_turn); without one sweep all
254
+ * (reconnect backfill). Idempotent via the shared `seenTurns` set keyed `turn:<uuid>`.
255
+ * Never throws into the caller. Mirrors backfill.mjs `backfillPendingTurns`.
256
+ */
257
+ async deliverTurn(onlyTurnUuid?: string): Promise<void> {
258
+ const result = await this.restClient.readPendingTurns();
259
+ if (!result.ok || !result.data) {
260
+ // Nothing to read yet (skipped) or a logged failure — nothing to dispatch.
261
+ return;
262
+ }
263
+ let dispatched = 0;
264
+ for (const turn of result.data.turns) {
265
+ if (!turn || typeof turn.turnUuid !== "string") continue;
266
+ // Single-turn precision: when a uuid was announced, run ONLY it.
267
+ if (onlyTurnUuid && turn.turnUuid !== onlyTurnUuid) continue;
268
+ const seenKey = `turn:${turn.turnUuid}`;
269
+ if (this.seenTurns.has(seenKey)) continue;
270
+ this.seenTurns.add(seenKey);
271
+ dispatched++;
272
+ // Reconstruct the execution entity DIRECTLY from the turn's own ids (mirrors
273
+ // cli/event-router.mjs dispatchPendingTurn): an idea-anchored conversation
274
+ // reports against the real idea (`idea:<directIdeaUuid>`), an ad-hoc conversation
275
+ // against itself (`daemon_session:<sessionId>`). entityType MUST be set — without
276
+ // it entityOf() returns null, so the run reports no execution row (invisible in
277
+ // the UI) and registers no AbortController (uninterruptible). entityUuid aligns
278
+ // with the session business key so the report anchor, the OpenClaw session, and
279
+ // the per-session UI match key are all the same value.
280
+ const directIdeaUuid =
281
+ typeof turn.directIdeaUuid === "string" ? turn.directIdeaUuid : null;
282
+ this.redispatch({
283
+ prompt: this.buildTurnPrompt(turn),
284
+ contextKey: `chorus:deliver_turn:${turn.turnUuid}`,
285
+ entityType: directIdeaUuid ? "idea" : "daemon_session",
286
+ entityUuid: directIdeaUuid ?? turn.sessionId,
287
+ directIdeaUuid,
288
+ turnUuid: turn.turnUuid,
289
+ });
290
+ }
291
+ if (dispatched > 0) {
292
+ const scope = onlyTurnUuid ? `turn ${onlyTurnUuid}` : `${dispatched} pending turn(s)`;
293
+ this.logger.info(`[Chorus] deliver: re-derived ${scope} from the turn table`);
294
+ }
295
+ }
296
+
297
+ /** Reconnect backfill: full connection-scoped pending-turns sweep (no single-turn filter). */
298
+ async onReconnect(): Promise<void> {
299
+ await this.deliverTurn();
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // The wake run wrapper.
304
+ // ---------------------------------------------------------------------------
305
+
306
+ /**
307
+ * Run one wake via runEmbeddedAgent with full daemon reporting. Never throws — a
308
+ * wake failure is logged + reported (crash), never propagated (the SSE service must
309
+ * stay alive). Fire-and-forget from the caller's perspective; returns a promise the
310
+ * tests can await.
311
+ *
312
+ * Lifecycle (mirrors waker.mjs):
313
+ * 1. resolve run context (drop on null);
314
+ * 2. derive sessionKey from the business key → getSessionEntry → sessionId/sessionFile;
315
+ * 3. register the AbortController + mark the execution running → turnAdvance(running)
316
+ * + execution-state snapshot;
317
+ * 4. run with transcript callbacks (onBlockReply → post {role:"assistant", text});
318
+ * 5. on settle → turnAdvance(ended) + snapshot; classify interrupt(user) vs crash;
319
+ * 6. finally → deregister the controller + drop the execution row + snapshot.
320
+ */
321
+ async runWake(req: WakeRequest): Promise<void> {
322
+ const { prompt, contextKey } = req;
323
+ const entity = entityOf(req);
324
+ const key = entity ? execKey(entity.entityType, entity.entityUuid) : null;
325
+ const rootIdeaUuid = req.rootIdeaUuid ?? null;
326
+ // Session anchor = the business key: directIdeaUuid when present, else the entity
327
+ // uuid (quick task / standalone doc / ad-hoc daemon_session). This is the `sessionId`
328
+ // used in ALL reports (turn-advance/transcript) — NOT the OpenClaw sessionKey, which
329
+ // is the agent's queue key. Two distinct identifiers (see deriveSessionId / sessionKey).
330
+ const reportSessionId = deriveSessionId(req);
331
+ if (!reportSessionId) {
332
+ this.logger.warn(
333
+ `[Chorus] Wake DROPPED — no session id (no directIdea/entity) for contextKey=${contextKey}`,
334
+ );
335
+ return;
336
+ }
337
+
338
+ let ctx: WakeRunContext | null;
339
+ try {
340
+ ctx = this.resolveRunContext();
341
+ } catch (err) {
342
+ this.logger.warn(`[Chorus] Wake DROPPED — run-context resolution failed (${contextKey}): ${err}`);
343
+ return;
344
+ }
345
+ if (!ctx) {
346
+ this.logger.warn(
347
+ `[Chorus] Wake DROPPED — no resolvable session/agent runtime on this host (${contextKey}).`,
348
+ );
349
+ return;
350
+ }
351
+
352
+ // Resolve the EXISTING session for the business key so the wake continues the same
353
+ // conversation (resume / deliver_turn re-enter it). The OpenClaw sessionKey is
354
+ // derived deterministically from the business key, so a later resume re-derives the
355
+ // same key and getSessionEntry returns the same sessionId/sessionFile.
356
+ const sessionKey = deriveSessionKey(reportSessionId, ctx.sessionKey);
357
+ let sessionId: string;
358
+ let sessionFile: string;
359
+ try {
360
+ const sessionEntry = ctx.agent.session.getSessionEntry({ sessionKey, agentId: ctx.agentId });
361
+ // No existing OpenClaw session for this key (the FIRST wake on the business key):
362
+ // open a NEW session whose id is the business key itself. The business key
363
+ // (directIdeaUuid / entityUuid / ad-hoc sessionId) is always a uuid, which
364
+ // satisfies OpenClaw's SAFE_SESSION_ID_RE (`^[a-z0-9][a-z0-9._-]{0,127}$`).
365
+ // The run id (`nextRunId`) is NOT a valid session id — it embeds the colon-laden
366
+ // contextKey, which `resolveSessionFilePath` rejects with "Invalid session ID" —
367
+ // so it must never be used as the session id. Reusing the business key here also
368
+ // keeps continuity: a later resume re-derives the SAME sessionKey, and once this
369
+ // first run has persisted the session, getSessionEntry returns this same id.
370
+ sessionId = sessionEntry?.sessionId ?? reportSessionId;
371
+ sessionFile = ctx.agent.session.resolveSessionFilePath(
372
+ sessionId,
373
+ sessionEntry?.sessionFile ? { sessionFile: sessionEntry.sessionFile } : undefined,
374
+ { agentId: ctx.agentId },
375
+ );
376
+ } catch (err) {
377
+ this.logger.warn(`[Chorus] Wake DROPPED — session resolution failed (${contextKey}): ${err}`);
378
+ return;
379
+ }
380
+
381
+ const controller = new AbortController();
382
+ const abortEntry: AbortEntry = { controller, interrupting: false };
383
+ if (key) this.aborts.set(key, abortEntry);
384
+
385
+ // Mark RUNNING + report turn-advance(running) + execution snapshot.
386
+ if (entity && key) {
387
+ this.executions.set(key, {
388
+ entityType: entity.entityType,
389
+ entityUuid: entity.entityUuid,
390
+ rootIdeaUuid,
391
+ status: "running",
392
+ startedAt: new Date().toISOString(),
393
+ });
394
+ this.emitExecutionSnapshot();
395
+ }
396
+ let advancedToRunning = false;
397
+ await this.advanceTurn(reportSessionId, "running", entity);
398
+ advancedToRunning = true;
399
+
400
+ const runId = this.nextRunId(contextKey);
401
+ this.logger.info(
402
+ `[Chorus] Waking agent via embedded run (sessionKey=${sessionKey}, sessionId=${reportSessionId}, ` +
403
+ `model=${ctx.modelRef ? `${ctx.modelRef.provider}/${ctx.modelRef.model}` : "host-default"}, contextKey=${contextKey})`,
404
+ );
405
+
406
+ let aborted = false;
407
+ let crashed = false;
408
+ try {
409
+ const result = await ctx.agent.runEmbeddedAgent({
410
+ sessionId,
411
+ sessionKey,
412
+ agentId: ctx.agentId,
413
+ trigger: "manual",
414
+ sessionFile,
415
+ ...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}),
416
+ workspaceDir: ctx.workspaceDir,
417
+ config: ctx.config,
418
+ prompt,
419
+ timeoutMs: ctx.timeoutMs,
420
+ runId,
421
+ disableMessageTool: true,
422
+ abortSignal: controller.signal,
423
+ // Streaming transcript: post ONLY finalized assistant visible text. Reasoning
424
+ // (onReasoningStream / isReasoning blocks) and tool internals (onToolResult) are
425
+ // intentionally NOT posted (match the CLI host's stream-json filter).
426
+ onBlockReply: (payload) => {
427
+ const msg = extractBlockReplyText(payload);
428
+ if (msg) void this.postTranscript(reportSessionId, [msg]);
429
+ },
430
+ ...(ctx.modelRef ? { provider: ctx.modelRef.provider, model: ctx.modelRef.model } : {}),
431
+ });
432
+ // result.meta.aborted distinguishes a clean abort from a normal completion
433
+ // (types.ts:140). An abort flagged here OR an interrupt requested → user-abort.
434
+ aborted = result?.meta?.aborted === true || abortEntry.interrupting;
435
+ } catch (err) {
436
+ // The run rejected. If an interrupt was requested, it's a user abort; otherwise
437
+ // it's an unexpected crash.
438
+ if (abortEntry.interrupting || controller.signal.aborted) {
439
+ aborted = true;
440
+ } else {
441
+ crashed = true;
442
+ }
443
+ this.logger.warn(
444
+ `[Chorus] Wake turn ${crashed ? "crashed" : "aborted"} (sessionId=${reportSessionId}, ${contextKey}): ` +
445
+ `${err instanceof Error ? err.message : String(err)}`,
446
+ );
447
+ } finally {
448
+ // Deregister FIRST so a stale controller can never abort a later run for the
449
+ // same entity (spec: "A settled run deregisters its controller").
450
+ if (key) this.aborts.delete(key);
451
+ }
452
+
453
+ // Turn lifecycle: advance running→ended regardless of outcome (a turn ends whether
454
+ // clean, aborted, or crashed). Guarded on advancedToRunning so a never-started wake
455
+ // never attempts an illegal pending→ended transition.
456
+ if (advancedToRunning) {
457
+ await this.advanceTurn(reportSessionId, "ended", entity);
458
+ }
459
+
460
+ // Interrupt-vs-crash reporting (entity-keyed — only for a reportable resource).
461
+ if (entity) {
462
+ if (aborted) {
463
+ await this.report(entity, "user");
464
+ } else if (crashed) {
465
+ await this.report(entity, "crash");
466
+ }
467
+ // A clean completion reports nothing (mirrors waker.mjs).
468
+ }
469
+
470
+ // Drop the execution row + emit a fresh snapshot (absence == ended server-side).
471
+ if (key && this.executions.delete(key)) {
472
+ this.emitExecutionSnapshot();
473
+ }
474
+
475
+ this.logger.info(
476
+ `[Chorus] Wake complete (sessionId=${reportSessionId}, contextKey=${contextKey}, ` +
477
+ `outcome=${aborted ? "interrupted" : crashed ? "crashed" : "completed"})`,
478
+ );
479
+ }
480
+
481
+ /**
482
+ * Mark a resource QUEUED and emit a snapshot. Called before a wake actually runs
483
+ * (e.g. when it sits behind a same-session run) so the server sees it waiting. The
484
+ * running transition in `runWake` overwrites it. Never throws.
485
+ */
486
+ markQueued(req: WakeRequest): void {
487
+ const entity = entityOf(req);
488
+ if (!entity) return;
489
+ const key = execKey(entity.entityType, entity.entityUuid);
490
+ const existing = this.executions.get(key);
491
+ // Don't downgrade a running resource to queued if a duplicate dispatch arrives.
492
+ if (existing && existing.status === "running") return;
493
+ this.executions.set(key, {
494
+ entityType: entity.entityType,
495
+ entityUuid: entity.entityUuid,
496
+ rootIdeaUuid: req.rootIdeaUuid ?? null,
497
+ status: "queued",
498
+ startedAt: existing?.startedAt ?? null,
499
+ });
500
+ this.emitExecutionSnapshot();
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // Internal reporting helpers — never throw into the wake path.
505
+ // ---------------------------------------------------------------------------
506
+
507
+ private buildExecutionSnapshot(): DaemonExecutionRow[] {
508
+ return [...this.executions.values()].map((e) => ({
509
+ entityType: e.entityType,
510
+ entityUuid: e.entityUuid,
511
+ rootIdeaUuid: e.rootIdeaUuid,
512
+ status: e.status,
513
+ startedAt: e.startedAt,
514
+ }));
515
+ }
516
+
517
+ /** Fire-and-forget execution-state snapshot. Never throws. */
518
+ private emitExecutionSnapshot(): void {
519
+ void this.restClient.executionState({ executions: this.buildExecutionSnapshot() });
520
+ }
521
+
522
+ private async advanceTurn(
523
+ sessionId: string,
524
+ status: "running" | "ended",
525
+ entity: { entityType: string; entityUuid: string } | null,
526
+ ): Promise<void> {
527
+ try {
528
+ await this.restClient.turnAdvance({
529
+ sessionId,
530
+ status,
531
+ entityType: entity?.entityType ?? null,
532
+ entityUuid: entity?.entityUuid ?? null,
533
+ });
534
+ } catch (err) {
535
+ this.logger.warn(`[Chorus] advanceTurn failed for session ${sessionId} → ${status}: ${err}`);
536
+ }
537
+ }
538
+
539
+ private async postTranscript(sessionId: string, messages: DaemonTranscriptMessage[]): Promise<void> {
540
+ try {
541
+ await this.restClient.transcript({ sessionId, messages });
542
+ } catch (err) {
543
+ this.logger.warn(`[Chorus] transcript post failed for session ${sessionId}: ${err}`);
544
+ }
545
+ }
546
+
547
+ private async report(
548
+ entity: { entityType: string; entityUuid: string },
549
+ reason: "user" | "crash",
550
+ ): Promise<void> {
551
+ try {
552
+ await this.restClient.reportInterrupt({
553
+ entityType: entity.entityType,
554
+ entityUuid: entity.entityUuid,
555
+ reason,
556
+ });
557
+ } catch (err) {
558
+ this.logger.warn(
559
+ `[Chorus] reportInterrupt failed for ${entity.entityType}:${entity.entityUuid} (${reason}): ${err}`,
560
+ );
561
+ }
562
+ }
563
+
564
+ private nextRunId(contextKey: string): string {
565
+ this.runCounter += 1;
566
+ return `chorus-wake-${this.runCounter}-${contextKey}`;
567
+ }
568
+ }
569
+
570
+ // ---------------------------------------------------------------------------
571
+ // Pure helpers (exported for unit testing).
572
+ // ---------------------------------------------------------------------------
573
+
574
+ /** Registry key for an entity. */
575
+ export function execKey(entityType: string, entityUuid: string): string {
576
+ return `${entityType}:${entityUuid}`;
577
+ }
578
+
579
+ /**
580
+ * The reportable resource for a wake — `{ entityType, entityUuid }` — or null when it
581
+ * has no recognized target (mirrors waker.mjs `#entityOf`).
582
+ */
583
+ export function entityOf(req: {
584
+ entityType?: string | null;
585
+ entityUuid?: string | null;
586
+ }): { entityType: string; entityUuid: string } | null {
587
+ const { entityType, entityUuid } = req;
588
+ if (
589
+ typeof entityType === "string" &&
590
+ typeof entityUuid === "string" &&
591
+ entityUuid.length > 0 &&
592
+ EXECUTION_ENTITY_TYPES.has(entityType)
593
+ ) {
594
+ return { entityType, entityUuid };
595
+ }
596
+ return null;
597
+ }
598
+
599
+ /**
600
+ * The session BUSINESS KEY used in all reports (turn-advance/transcript): the
601
+ * directIdeaUuid when the entity has an idea ancestor, else the entity uuid. Mirrors
602
+ * the CLI host's `sessionId = directIdeaUuid ?? notification.entityUuid` (waker.mjs:286).
603
+ */
604
+ export function deriveSessionId(req: {
605
+ directIdeaUuid?: string | null;
606
+ entityUuid?: string | null;
607
+ }): string | null {
608
+ return req.directIdeaUuid ?? req.entityUuid ?? null;
609
+ }
610
+
611
+ /**
612
+ * Derive the deterministic OpenClaw `sessionKey` for a wake from its business key. The
613
+ * OpenClaw session store is keyed by `sessionKey` (the agent queue key), NOT by the
614
+ * Chorus business id — so to make `resume`/`deliver_turn` continue the SAME OpenClaw
615
+ * session we must derive the SAME key from the SAME business id every time. We namespace
616
+ * the business id under the host's main-agent key so a Chorus wake gets its own stable
617
+ * lane that re-resolves identically across runs (the in-process analog of
618
+ * `claude --resume <directIdeaUuid>`, where the disk transcript is the stable anchor).
619
+ */
620
+ export function deriveSessionKey(businessKey: string, mainSessionKey: string): string {
621
+ return `${mainSessionKey}:chorus:${businessKey}`;
622
+ }