@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
@@ -1,3 +1,8 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
1
6
  export type SseListenerStatus = "connected" | "disconnected" | "reconnecting";
2
7
 
3
8
  export interface SseNotificationEvent {
@@ -8,10 +13,42 @@ export interface SseNotificationEvent {
8
13
  [key: string]: unknown;
9
14
  }
10
15
 
16
+ /**
17
+ * The server's post-handshake `connection_registered` data event (carrying the
18
+ * DaemonConnection uuid this stream registered as). Forked to `onConnectionId`,
19
+ * NEVER to the wake path. See api/events/notifications/route.ts.
20
+ */
21
+ export interface SseControlEvent {
22
+ type: string; // "control" | "connection_registered"
23
+ command?: string; // interrupt | resume | deliver_turn (control only)
24
+ connectionUuid?: string; // connection_registered only
25
+ targetConnectionUuid?: string; // control only
26
+ entityType?: string;
27
+ entityUuid?: string;
28
+ turnUuid?: string;
29
+ [key: string]: unknown;
30
+ }
31
+
11
32
  export interface ChorusSseListenerOptions {
12
33
  chorusUrl: string;
13
34
  apiKey: string;
14
35
  onEvent: (event: SseNotificationEvent) => void;
36
+ /**
37
+ * Called once the server reports which DaemonConnection this stream registered
38
+ * as (the `connection_registered` data event). Stored as the connection
39
+ * identity (connection-state) and refreshed on every reconnect. This event is
40
+ * NOT a wake — it is forked here BEFORE `onEvent`, so the router never sees it.
41
+ */
42
+ onConnectionId?: (connectionUuid: string) => void;
43
+ /**
44
+ * Called for a `type:"control"` data event (the reverse control channel). This
45
+ * is NOT a wake: the control event is forked here BEFORE `onEvent`, so the
46
+ * router / wake path never sees it and it can never spawn a new embedded-agent
47
+ * run for the control event itself. The handler verifies the target connection
48
+ * (+ entity, for interrupt) and routes to the behavior hooks — see
49
+ * control-handler.ts.
50
+ */
51
+ onControl?: (event: SseControlEvent) => void;
15
52
  onReconnect: () => Promise<void>;
16
53
  logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
17
54
  }
@@ -19,8 +56,37 @@ export interface ChorusSseListenerOptions {
19
56
  const INITIAL_DELAY_MS = 1_000;
20
57
  const MAX_DELAY_MS = 30_000;
21
58
 
59
+ // Plugin version — read from this package's own package.json so the value the
60
+ // server's DaemonConnection registry records always matches the installed
61
+ // plugin rather than a hardcoded literal. The compiled output lives in dist/
62
+ // and the source in src/; both are one level under the package root, so
63
+ // "../package.json" resolves to the package manifest in either case. Defensive:
64
+ // fall back to "0.0.0" if the manifest is unreadable — a missing version must
65
+ // never block the listener from connecting.
66
+ function readPluginVersion(): string {
67
+ try {
68
+ const here = dirname(fileURLToPath(import.meta.url));
69
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as {
70
+ version?: unknown;
71
+ };
72
+ return typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
73
+ } catch {
74
+ return "0.0.0";
75
+ }
76
+ }
77
+
78
+ const PLUGIN_VERSION = readPluginVersion();
79
+
80
+ // Plugin process start time, captured once at module load. Reconnects re-send
81
+ // this original start (recomputed to ISO-8601 at URL-construction time), not the
82
+ // reconnect moment.
83
+ const PROCESS_STARTED_AT = new Date();
84
+
22
85
  export class ChorusSseListener {
23
86
  private readonly opts: ChorusSseListenerOptions;
87
+ private readonly onConnectionId: (connectionUuid: string) => void;
88
+ private readonly onControl: (event: SseControlEvent) => void;
89
+ private readonly endpoint: string;
24
90
  private _status: SseListenerStatus = "disconnected";
25
91
  private abortController: AbortController | null = null;
26
92
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -28,6 +94,23 @@ export class ChorusSseListener {
28
94
 
29
95
  constructor(opts: ChorusSseListenerOptions) {
30
96
  this.opts = opts;
97
+ // Default the optional bidirectional callbacks to no-ops so a caller that
98
+ // only wants the wake path (no daemon reporting) still works unchanged.
99
+ this.onConnectionId = opts.onConnectionId ?? (() => {});
100
+ this.onControl = opts.onControl ?? (() => {});
101
+
102
+ // Build the self-reporting endpoint URL once and reuse it across every
103
+ // (re)connect, so the reconnect path always re-sends the same params. The
104
+ // CLI reports clientType=openclaw so the server's connection registry can
105
+ // distinguish an OpenClaw daemon from a chorus CLI (claude_code) daemon.
106
+ // These params are display-only metadata; auth remains the Bearer header.
107
+ const params = new URLSearchParams({
108
+ clientType: "openclaw",
109
+ clientVersion: PLUGIN_VERSION,
110
+ host: hostname(),
111
+ startedAt: PROCESS_STARTED_AT.toISOString(),
112
+ });
113
+ this.endpoint = `${this.opts.chorusUrl.replace(/\/$/, "")}/api/events/notifications?${params.toString()}`;
31
114
  }
32
115
 
33
116
  get status(): SseListenerStatus {
@@ -41,11 +124,9 @@ export class ChorusSseListener {
41
124
  const abortController = new AbortController();
42
125
  this.abortController = abortController;
43
126
 
44
- const url = `${this.opts.chorusUrl.replace(/\/$/, "")}/api/events/notifications`;
45
-
46
127
  let response: Response;
47
128
  try {
48
- response = await fetch(url, {
129
+ response = await fetch(this.endpoint, {
49
130
  headers: {
50
131
  Authorization: `Bearer ${this.opts.apiKey}`,
51
132
  Accept: "text/event-stream",
@@ -151,12 +232,43 @@ export class ChorusSseListener {
151
232
  // Data lines
152
233
  if (line.startsWith("data: ")) {
153
234
  const jsonStr = line.slice(6);
235
+ let event: SseNotificationEvent;
154
236
  try {
155
- const event: SseNotificationEvent = JSON.parse(jsonStr);
156
- this.opts.onEvent(event);
237
+ event = JSON.parse(jsonStr);
157
238
  } catch (err) {
158
239
  this.opts.logger.warn(`SSE JSON parse error: ${err} — raw: ${jsonStr}`);
240
+ continue;
241
+ }
242
+
243
+ // The server's post-handshake `connection_registered` data event tells us
244
+ // which DaemonConnection this stream registered as. Capture it (the
245
+ // connection identity used to attribute reports + double-check control
246
+ // commands) and do NOT forward it to the wake path — it isn't a
247
+ // notification. Forked here so the router never logs it as ignored.
248
+ if (event.type === "connection_registered" && typeof event.connectionUuid === "string") {
249
+ try {
250
+ this.onConnectionId(event.connectionUuid);
251
+ } catch (err) {
252
+ this.opts.logger.warn(`onConnectionId callback error: ${err}`);
253
+ }
254
+ continue;
255
+ }
256
+
257
+ // Reverse control channel: a `type:"control"` event is NOT a wake. Fork it
258
+ // to onControl and `continue` — it MUST NEVER fall through to onEvent (the
259
+ // router / wake path), or a control command could be mistaken for a wake and
260
+ // spawn a new embedded-agent run. This is the structural guarantee the spec
261
+ // requires.
262
+ if (event.type === "control") {
263
+ try {
264
+ this.onControl(event as SseControlEvent);
265
+ } catch (err) {
266
+ this.opts.logger.warn(`onControl callback error: ${err}`);
267
+ }
268
+ continue;
159
269
  }
270
+
271
+ this.opts.onEvent(event);
160
272
  }
161
273
  }
162
274
  }
package/src/wake.ts CHANGED
@@ -1,4 +1,8 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
1
+ import type {
2
+ OpenClawPluginApi,
3
+ OpenClawRuntimeAgent,
4
+ } from "openclaw/plugin-sdk/plugin-entry";
5
+ import type { WakeRunContext } from "./daemon-client.js";
2
6
 
3
7
  /**
4
8
  * Wake mechanism for the Chorus plugin.
@@ -142,34 +146,21 @@ export function resolveModelRef(
142
146
  }
143
147
 
144
148
  /**
145
- * Narrowed view of the `api.runtime.agent` surface we use to run a turn. Only
146
- * the members this plugin touches are typed; the host provides the full
147
- * `PluginRuntimeCore.agent` at runtime (types-core.ts:180-221).
149
+ * Resolve the `api.runtime.agent` surface we use to run a turn, returning null
150
+ * when the host does not expose it (narrow registration modes / older hosts).
151
+ *
152
+ * `api.runtime` is now typed (`OpenClawPluginRuntime`) via the SDK shim — the
153
+ * `agent` slice mirrors `PluginRuntimeCore.agent` (types-core.ts:180-221). We
154
+ * still runtime-check each function we call (the declared type is a structural
155
+ * promise the host fulfills; a real host that drops a member must degrade
156
+ * gracefully, not throw).
148
157
  */
149
- interface SessionEntryLike {
150
- sessionId: string;
151
- sessionFile?: string;
152
- }
153
- interface RuntimeAgentSlice {
154
- runEmbeddedAgent: (params: Record<string, unknown>) => Promise<unknown>;
155
- resolveAgentDir: (cfg: unknown, agentId: string) => string;
156
- resolveAgentWorkspaceDir: (cfg: unknown, agentId: string) => string;
157
- resolveAgentTimeoutMs: (opts: { cfg?: unknown }) => number;
158
- session: {
159
- getSessionEntry: (options: { sessionKey: string; agentId?: string }) => SessionEntryLike | undefined;
160
- resolveSessionFilePath: (
161
- sessionId: string,
162
- entry?: { sessionFile?: string },
163
- opts?: { agentId?: string },
164
- ) => string;
165
- };
166
- }
167
-
168
- function getRuntimeAgent(api: OpenClawPluginApi): RuntimeAgentSlice | null {
169
- const agent = (api.runtime as { agent?: Partial<RuntimeAgentSlice> } | undefined)?.agent;
158
+ function getRuntimeAgent(api: OpenClawPluginApi): OpenClawRuntimeAgent | null {
159
+ const agent = api.runtime?.agent as Partial<OpenClawRuntimeAgent> | undefined;
170
160
  if (
171
161
  !agent ||
172
162
  typeof agent.runEmbeddedAgent !== "function" ||
163
+ typeof agent.resolveAgentDir !== "function" ||
173
164
  typeof agent.resolveAgentWorkspaceDir !== "function" ||
174
165
  typeof agent.resolveAgentTimeoutMs !== "function" ||
175
166
  typeof agent.session?.getSessionEntry !== "function" ||
@@ -177,7 +168,7 @@ function getRuntimeAgent(api: OpenClawPluginApi): RuntimeAgentSlice | null {
177
168
  ) {
178
169
  return null;
179
170
  }
180
- return agent as RuntimeAgentSlice;
171
+ return agent as OpenClawRuntimeAgent;
181
172
  }
182
173
 
183
174
  /**
@@ -191,6 +182,58 @@ function nextRunId(contextKey: string): string {
191
182
  return `chorus-wake-${wakeCounter}-${contextKey}`;
192
183
  }
193
184
 
185
+ /**
186
+ * Resolve the host run context the daemon client needs to run a wake via
187
+ * `runEmbeddedAgent` — the main-agent session key + agent id + workspace/timeout +
188
+ * model override + the typed `runtime.agent` surface. Returns null when the wake must
189
+ * be DROPPED (no resolvable session key, or the host does not expose the agent
190
+ * runtime / a required session helper). This is the SINGLE place that reaches into
191
+ * `api.config` / `api.runtime`, so the daemon client stays host-API-agnostic and
192
+ * unit-testable with a plain context object.
193
+ *
194
+ * NOTE: this resolves only the host-derived knobs (the agent's main-session lane,
195
+ * workspace, model). The per-wake session ANCHOR (the Chorus business key →
196
+ * deterministic sessionKey → getSessionEntry) is owned by the daemon client, so a
197
+ * resume continues the same session regardless of when the context was resolved.
198
+ */
199
+ export function resolveWakeRunContext(
200
+ api: OpenClawPluginApi,
201
+ logger: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void },
202
+ ): WakeRunContext | null {
203
+ const sessionKey = resolveSessionKey(api);
204
+ if (!sessionKey) {
205
+ logger.warn("[Chorus] Wake run-context unavailable — could not resolve a main agent session key.");
206
+ return null;
207
+ }
208
+ const agent = getRuntimeAgent(api);
209
+ if (!agent) {
210
+ logger.warn(
211
+ "[Chorus] Wake run-context unavailable — api.runtime.agent.runEmbeddedAgent (or a required session helper) is not exposed on this host.",
212
+ );
213
+ return null;
214
+ }
215
+ const cfg = api.config;
216
+ const agentId = resolveAgentId(api);
217
+ let workspaceDir: string;
218
+ let agentDir: string | undefined;
219
+ let timeoutMs: number;
220
+ try {
221
+ workspaceDir = agent.resolveAgentWorkspaceDir(cfg, agentId);
222
+ agentDir = agent.resolveAgentDir(cfg, agentId);
223
+ timeoutMs = agent.resolveAgentTimeoutMs({ cfg });
224
+ } catch (err) {
225
+ logger.warn(`[Chorus] Wake run-context unavailable — workspace/timeout resolution failed: ${err}`);
226
+ return null;
227
+ }
228
+ const modelRef = resolveModelRef(api);
229
+ if (!modelRef) {
230
+ logger.warn(
231
+ "[Chorus] No agents.defaults.model configured — wake turns will use the host default model, which may be unavailable.",
232
+ );
233
+ }
234
+ return { agent, sessionKey, agentId, config: cfg, workspaceDir, agentDir, timeoutMs, modelRef };
235
+ }
236
+
194
237
  /**
195
238
  * Build a `wake(message, contextKey)` callback bound to the host runtime.
196
239
  *