@syengup/friday-channel-next 0.1.36 → 0.1.38

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 (124) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/src/agent/dispatch-bridge.d.ts +1 -1
  3. package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
  4. package/dist/src/agent/node-pairing-bridge.js +6 -2
  5. package/dist/src/agent/operator-scope.d.ts +19 -0
  6. package/dist/src/agent/operator-scope.js +54 -0
  7. package/dist/src/agent/subagent-registry.js +0 -3
  8. package/dist/src/channel-actions.js +3 -1
  9. package/dist/src/channel.js +0 -2
  10. package/dist/src/collect-message-media-paths.js +10 -1
  11. package/dist/src/friday-session.js +34 -10
  12. package/dist/src/history/normalize-message.js +22 -8
  13. package/dist/src/http/handlers/agent-config.js +10 -4
  14. package/dist/src/http/handlers/cancel.js +4 -2
  15. package/dist/src/http/handlers/device-approve.js +3 -1
  16. package/dist/src/http/handlers/files-download.js +6 -8
  17. package/dist/src/http/handlers/files.js +1 -1
  18. package/dist/src/http/handlers/health.js +18 -4
  19. package/dist/src/http/handlers/history-messages.js +1 -1
  20. package/dist/src/http/handlers/history-sessions.js +5 -3
  21. package/dist/src/http/handlers/messages.js +34 -11
  22. package/dist/src/http/handlers/models-list.js +1 -1
  23. package/dist/src/http/handlers/nodes-approve.js +1 -6
  24. package/dist/src/http/handlers/plugin-info.js +1 -1
  25. package/dist/src/http/server.js +4 -2
  26. package/dist/src/link-preview/og-parse.js +3 -1
  27. package/dist/src/plugin-install-info.js +4 -1
  28. package/dist/src/session/session-manager.js +9 -3
  29. package/dist/src/session-usage-store.js +3 -1
  30. package/dist/src/skills-discovery.d.ts +5 -4
  31. package/dist/src/skills-discovery.js +27 -22
  32. package/dist/src/sse/offline-queue.js +4 -1
  33. package/dist/src/tool-catalog.js +2 -3
  34. package/dist/src/upgrade-runtime.d.ts +1 -1
  35. package/dist/src/version.js +3 -1
  36. package/index.ts +43 -35
  37. package/install.js +131 -43
  38. package/package.json +10 -1
  39. package/src/agent/abort-run.ts +2 -3
  40. package/src/agent/dispatch-bridge.ts +2 -1
  41. package/src/agent/media-bridge.ts +9 -2
  42. package/src/agent/node-pairing-bridge.ts +29 -15
  43. package/src/agent/operator-scope.test.ts +66 -0
  44. package/src/agent/operator-scope.ts +63 -0
  45. package/src/agent/run-usage-accumulator.ts +4 -2
  46. package/src/agent/subagent-registry.ts +0 -4
  47. package/src/agent-run-context-bridge.ts +3 -1
  48. package/src/channel-actions.test.ts +10 -4
  49. package/src/channel-actions.ts +3 -1
  50. package/src/channel.outbound.test.ts +18 -4
  51. package/src/channel.ts +121 -123
  52. package/src/collect-message-media-paths.ts +15 -6
  53. package/src/config.ts +1 -4
  54. package/src/e2e/agents-list.e2e.test.ts +9 -2
  55. package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
  56. package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
  57. package/src/e2e/auto-approve.integration.test.ts +13 -7
  58. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
  59. package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
  60. package/src/e2e/offline-replay.e2e.test.ts +17 -3
  61. package/src/e2e/send-text.e2e.test.ts +11 -2
  62. package/src/e2e/slash-commands.e2e.test.ts +5 -1
  63. package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
  64. package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
  65. package/src/e2e/subagent.e2e.test.ts +136 -53
  66. package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
  67. package/src/friday-session.forward-agent.test.ts +44 -12
  68. package/src/friday-session.ts +44 -20
  69. package/src/history/normalize-message.test.ts +35 -8
  70. package/src/history/normalize-message.ts +24 -12
  71. package/src/history/read-transcript.ts +1 -4
  72. package/src/http/handlers/agent-config.test.ts +10 -3
  73. package/src/http/handlers/agent-config.ts +22 -8
  74. package/src/http/handlers/agents-list.test.ts +1 -5
  75. package/src/http/handlers/cancel.test.ts +12 -3
  76. package/src/http/handlers/cancel.ts +4 -2
  77. package/src/http/handlers/device-approve.test.ts +12 -3
  78. package/src/http/handlers/device-approve.ts +33 -21
  79. package/src/http/handlers/files-download.ts +17 -13
  80. package/src/http/handlers/files.test.ts +8 -2
  81. package/src/http/handlers/files.ts +21 -7
  82. package/src/http/handlers/health.test.ts +43 -11
  83. package/src/http/handlers/health.ts +22 -6
  84. package/src/http/handlers/history-messages.test.ts +51 -9
  85. package/src/http/handlers/history-messages.ts +4 -1
  86. package/src/http/handlers/history-sessions.test.ts +46 -9
  87. package/src/http/handlers/history-sessions.ts +5 -3
  88. package/src/http/handlers/history-set-title.test.ts +14 -5
  89. package/src/http/handlers/link-preview.test.ts +57 -16
  90. package/src/http/handlers/link-preview.ts +4 -1
  91. package/src/http/handlers/messages.test.ts +12 -8
  92. package/src/http/handlers/messages.ts +67 -19
  93. package/src/http/handlers/models-list.ts +14 -8
  94. package/src/http/handlers/nodes-approve.test.ts +15 -4
  95. package/src/http/handlers/nodes-approve.ts +38 -40
  96. package/src/http/handlers/plugin-info.ts +5 -6
  97. package/src/http/handlers/plugin-upgrade.ts +4 -1
  98. package/src/http/handlers/sse.ts +3 -1
  99. package/src/http/server.ts +9 -6
  100. package/src/link-preview/og-parse.test.ts +6 -2
  101. package/src/link-preview/og-parse.ts +10 -3
  102. package/src/link-preview/preview-service.ts +4 -1
  103. package/src/link-preview/ssrf-guard.test.ts +72 -15
  104. package/src/link-preview/ssrf-guard.ts +2 -1
  105. package/src/media-fetch.test.ts +7 -2
  106. package/src/media-fetch.ts +1 -2
  107. package/src/openclaw.d.ts +26 -9
  108. package/src/plugin-install-info.ts +20 -9
  109. package/src/run-metadata.ts +2 -1
  110. package/src/session/session-manager.ts +19 -11
  111. package/src/session-usage-snapshot.ts +3 -1
  112. package/src/session-usage-store.ts +3 -1
  113. package/src/skills-discovery.test.ts +14 -10
  114. package/src/skills-discovery.ts +43 -27
  115. package/src/sse/emitter.test.ts +1 -1
  116. package/src/sse/emitter.ts +9 -3
  117. package/src/sse/offline-queue.ts +17 -8
  118. package/src/test-support/app-simulator.ts +17 -3
  119. package/src/test-support/mock-dispatch.ts +17 -4
  120. package/src/thinking-levels.ts +3 -1
  121. package/src/tool-catalog.ts +16 -7
  122. package/src/upgrade-runtime.ts +4 -2
  123. package/src/version.ts +5 -1
  124. package/tsconfig.json +1 -1
@@ -42,7 +42,8 @@ function setForward(store: Record<string, unknown>, withWriter = true): void {
42
42
  runtime: {
43
43
  agent: {
44
44
  session: {
45
- resolveStorePath: (_s?: string, opts?: { agentId?: string }) => `/store/${opts?.agentId ?? "main"}.json`,
45
+ resolveStorePath: (_s?: string, opts?: { agentId?: string }) =>
46
+ `/store/${opts?.agentId ?? "main"}.json`,
46
47
  loadSessionStore: () => store,
47
48
  ...(withWriter
48
49
  ? {
@@ -61,7 +62,9 @@ function setForward(store: Record<string, unknown>, withWriter = true): void {
61
62
  }
62
63
 
63
64
  describe("handleHistorySetTitle", () => {
64
- beforeEach(() => setFridayNextRuntime({ config: { loadConfig: () => CFG }, logger: {} } as never));
65
+ beforeEach(() =>
66
+ setFridayNextRuntime({ config: { loadConfig: () => CFG }, logger: {} } as never),
67
+ );
65
68
  afterEach(() => resetFridayAgentForwardRuntimeForTest());
66
69
 
67
70
  it("rejects GET with 405", async () => {
@@ -102,14 +105,20 @@ describe("handleHistorySetTitle", () => {
102
105
  it("404s when the session is unknown", async () => {
103
106
  setForward({});
104
107
  const res = new MockRes();
105
- await handleHistorySetTitle(makeReq({ sessionKey: "agent:main:nope", title: "x" }, AUTH), res as any);
108
+ await handleHistorySetTitle(
109
+ makeReq({ sessionKey: "agent:main:nope", title: "x" }, AUTH),
110
+ res as any,
111
+ );
106
112
  expect(res.statusCode).toBe(404);
107
113
  });
108
114
 
109
115
  it("503s when the store writer is unavailable", async () => {
110
116
  setForward({ "agent:main:main": { sessionId: "s" } }, false);
111
117
  const res = new MockRes();
112
- await handleHistorySetTitle(makeReq({ sessionKey: "agent:main:main", title: "x" }, AUTH), res as any);
118
+ await handleHistorySetTitle(
119
+ makeReq({ sessionKey: "agent:main:main", title: "x" }, AUTH),
120
+ res as any,
121
+ );
113
122
  expect(res.statusCode).toBe(503);
114
123
  });
115
- })
124
+ });
@@ -11,7 +11,10 @@ vi.mock("node:dns/promises", () => ({
11
11
 
12
12
  import dns from "node:dns/promises";
13
13
  import { handleLinkPreview } from "./link-preview.js";
14
- import { resetLinkPreviewCacheForTest, type LinkPreviewPayload } from "../../link-preview/preview-service.js";
14
+ import {
15
+ resetLinkPreviewCacheForTest,
16
+ type LinkPreviewPayload,
17
+ } from "../../link-preview/preview-service.js";
15
18
  import { setAttachmentsDirForTest } from "./files.js";
16
19
  import { clearFridayNextRuntime, setFridayNextRuntime } from "../../runtime.js";
17
20
 
@@ -33,7 +36,10 @@ class MockRes extends EventEmitter {
33
36
  function makeReq(query: string | null, token: string | null = "tok"): IncomingMessage {
34
37
  return {
35
38
  method: "GET",
36
- url: query == null ? "/friday-next/link-preview" : `/friday-next/link-preview?url=${encodeURIComponent(query)}`,
39
+ url:
40
+ query == null
41
+ ? "/friday-next/link-preview"
42
+ : `/friday-next/link-preview?url=${encodeURIComponent(query)}`,
37
43
  headers: token ? { authorization: `Bearer ${token}` } : {},
38
44
  } as unknown as IncomingMessage;
39
45
  }
@@ -76,7 +82,10 @@ afterEach(() => {
76
82
  describe("handleLinkPreview", () => {
77
83
  it("405 on non-GET", async () => {
78
84
  const res = new MockRes();
79
- await handleLinkPreview({ method: "POST", url: "/friday-next/link-preview", headers: {} } as never, res as never);
85
+ await handleLinkPreview(
86
+ { method: "POST", url: "/friday-next/link-preview", headers: {} } as never,
87
+ res as never,
88
+ );
80
89
  expect(res.statusCode).toBe(405);
81
90
  });
82
91
 
@@ -105,7 +114,10 @@ describe("handleLinkPreview", () => {
105
114
  if (url.includes("cover.png")) {
106
115
  return new Response(PNG_BYTES, { status: 200, headers: { "content-type": "image/png" } });
107
116
  }
108
- return new Response(PAGE_HTML, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } });
117
+ return new Response(PAGE_HTML, {
118
+ status: 200,
119
+ headers: { "content-type": "text/html; charset=utf-8" },
120
+ });
109
121
  }),
110
122
  );
111
123
  const res = await invoke(makeReq("https://example.com/article"));
@@ -142,7 +154,9 @@ describe("handleLinkPreview", () => {
142
154
  const html = `<meta property="og:title" content="T">`;
143
155
  vi.stubGlobal(
144
156
  "fetch",
145
- vi.fn(async () => new Response(html, { status: 200, headers: { "content-type": "text/html" } })),
157
+ vi.fn(
158
+ async () => new Response(html, { status: 200, headers: { "content-type": "text/html" } }),
159
+ ),
146
160
  );
147
161
  const res = await invoke(makeReq("https://example.com/x"));
148
162
  expect(JSON.parse(res.body).preview.siteName).toBe("example.com");
@@ -152,7 +166,13 @@ describe("handleLinkPreview", () => {
152
166
  // 可达但无 OG/title → 退到 hostname 卡片(不再折叠)。
153
167
  vi.stubGlobal(
154
168
  "fetch",
155
- vi.fn(async () => new Response("<html><body>plain</body></html>", { status: 200, headers: { "content-type": "text/html" } })),
169
+ vi.fn(
170
+ async () =>
171
+ new Response("<html><body>plain</body></html>", {
172
+ status: 200,
173
+ headers: { "content-type": "text/html" },
174
+ }),
175
+ ),
156
176
  );
157
177
  const res = await invoke(makeReq("https://example.com/bare"));
158
178
  expect(res.statusCode).toBe(200);
@@ -166,9 +186,15 @@ describe("handleLinkPreview", () => {
166
186
  vi.fn(async (input: URL | string) => {
167
187
  const url = String(input);
168
188
  if (url.includes("favicon")) {
169
- return new Response(ICO_BYTES, { status: 200, headers: { "content-type": "image/x-icon" } });
189
+ return new Response(ICO_BYTES, {
190
+ status: 200,
191
+ headers: { "content-type": "image/x-icon" },
192
+ });
170
193
  }
171
- return new Response(`<meta property="og:title" content="Titled">`, { status: 200, headers: { "content-type": "text/html" } });
194
+ return new Response(`<meta property="og:title" content="Titled">`, {
195
+ status: 200,
196
+ headers: { "content-type": "text/html" },
197
+ });
172
198
  }),
173
199
  );
174
200
  const res = await invoke(makeReq("https://example.com/p"));
@@ -185,7 +211,10 @@ describe("handleLinkPreview", () => {
185
211
  vi.fn(async (input: URL | string) => {
186
212
  const url = String(input);
187
213
  if (url.endsWith("/favicon.ico")) {
188
- return new Response(ICO_BYTES, { status: 200, headers: { "content-type": "image/vnd.microsoft.icon" } });
214
+ return new Response(ICO_BYTES, {
215
+ status: 200,
216
+ headers: { "content-type": "image/vnd.microsoft.icon" },
217
+ });
189
218
  }
190
219
  return new Response("blocked", { status: 403, headers: { "content-type": "text/html" } }); // page blocks bots
191
220
  }),
@@ -199,29 +228,41 @@ describe("handleLinkPreview", () => {
199
228
  });
200
229
 
201
230
  it("502 fetch_failed for a dead domain (page and favicon both fail)", async () => {
202
- vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 404 })));
231
+ vi.stubGlobal(
232
+ "fetch",
233
+ vi.fn(async () => new Response("nope", { status: 404 })),
234
+ );
203
235
  const res = await invoke(makeReq("https://dead.example.com/x"));
204
236
  expect(res.statusCode).toBe(502);
205
237
  expect(JSON.parse(res.body).error).toBe("fetch_failed");
206
238
  });
207
239
 
208
240
  it("502 fetch_failed on non-2xx and non-HTML responses", async () => {
209
- vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 500 })));
241
+ vi.stubGlobal(
242
+ "fetch",
243
+ vi.fn(async () => new Response("nope", { status: 500 })),
244
+ );
210
245
  expect((await invoke(makeReq("https://example.com/down"))).statusCode).toBe(502);
211
246
 
212
247
  resetLinkPreviewCacheForTest();
213
248
  vi.stubGlobal(
214
249
  "fetch",
215
- vi.fn(async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } })),
250
+ vi.fn(
251
+ async () =>
252
+ new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
253
+ ),
216
254
  );
217
255
  expect((await invoke(makeReq("https://example.com/api"))).statusCode).toBe(502);
218
256
  });
219
257
 
220
258
  it("serves the second request from cache without refetching", async () => {
221
- const fetchMock = vi.fn(async () => new Response(`<meta property="og:title" content="Cached">`, {
222
- status: 200,
223
- headers: { "content-type": "text/html" },
224
- }));
259
+ const fetchMock = vi.fn(
260
+ async () =>
261
+ new Response(`<meta property="og:title" content="Cached">`, {
262
+ status: 200,
263
+ headers: { "content-type": "text/html" },
264
+ }),
265
+ );
225
266
  vi.stubGlobal("fetch", fetchMock);
226
267
  await invoke(makeReq("https://example.com/cached"));
227
268
  const afterFirst = fetchMock.mock.calls.length;
@@ -17,7 +17,10 @@ const ERROR_STATUS: Record<LinkPreviewError, number> = {
17
17
  fetch_failed: 502,
18
18
  };
19
19
 
20
- export async function handleLinkPreview(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
20
+ export async function handleLinkPreview(
21
+ req: IncomingMessage,
22
+ res: ServerResponse,
23
+ ): Promise<boolean> {
21
24
  if (req.method !== "GET") {
22
25
  res.statusCode = 405;
23
26
  res.setHeader("Content-Type", "application/json");
@@ -35,9 +35,9 @@ describe("composeBodyWithMediaRefs", () => {
35
35
  });
36
36
 
37
37
  it("omits the leading blank line when text is empty (attachment-only)", () => {
38
- expect(composeBodyWithMediaRefs("", ["[media attached: file:///a]", "[media attached: file:///b]"])).toBe(
39
- "[media attached: file:///a]\n[media attached: file:///b]",
40
- );
38
+ expect(
39
+ composeBodyWithMediaRefs("", ["[media attached: file:///a]", "[media attached: file:///b]"]),
40
+ ).toBe("[media attached: file:///a]\n[media attached: file:///b]");
41
41
  });
42
42
  });
43
43
 
@@ -152,7 +152,9 @@ describe("handleMessages dispatch context (owner fields)", () => {
152
152
  const dispatchCalled = new Promise<void>((resolve) => {
153
153
  __setMockFridayDispatchForTests(async (args: unknown) => {
154
154
  const a = args as {
155
- dispatcherOptions?: { deliver?: (payload: unknown, info: { kind: string }) => Promise<void> };
155
+ dispatcherOptions?: {
156
+ deliver?: (payload: unknown, info: { kind: string }) => Promise<void>;
157
+ };
156
158
  };
157
159
  if (a.dispatcherOptions?.deliver) {
158
160
  await a.dispatcherOptions.deliver(
@@ -169,10 +171,12 @@ describe("handleMessages dispatch context (owner fields)", () => {
169
171
  req.headers = { authorization: "Bearer tok" };
170
172
  const res = new MockRes() as unknown as ServerResponse;
171
173
  let observedPayload: Record<string, unknown> | null = null;
172
- const broadcastSpy = vi.spyOn(sseEmitter, "broadcastToRun").mockImplementation((_: string, evt: unknown) => {
173
- const data = (evt as { data?: { payload?: Record<string, unknown> } })?.data;
174
- if (data?.payload) observedPayload = data.payload;
175
- });
174
+ const broadcastSpy = vi
175
+ .spyOn(sseEmitter, "broadcastToRun")
176
+ .mockImplementation((_: string, evt: unknown) => {
177
+ const data = (evt as { data?: { payload?: Record<string, unknown> } })?.data;
178
+ if (data?.payload) observedPayload = data.payload;
179
+ });
176
180
 
177
181
  const p = handleMessages(req, res);
178
182
  req.end(
@@ -48,6 +48,7 @@ import {
48
48
  resolveMediaUrl,
49
49
  } from "./files.js";
50
50
  import { runFridayDispatch } from "../../agent/dispatch-bridge.js";
51
+ import { ensureSubagentSpawnScope } from "../../agent/operator-scope.js";
51
52
  import { saveInboundMediaBuffer } from "../../agent/media-bridge.js";
52
53
  import {
53
54
  contextTokensFromUsageRecord,
@@ -77,7 +78,10 @@ const log = (
77
78
  logger[level](`[${action}] deviceId=${deviceId}${runPart}${detailPart}`);
78
79
  };
79
80
 
80
- function collectReplyPayloadMediaUrls(pl: { mediaUrls?: string[]; mediaUrl?: string | null }): string[] {
81
+ function collectReplyPayloadMediaUrls(pl: {
82
+ mediaUrls?: string[];
83
+ mediaUrl?: string | null;
84
+ }): string[] {
81
85
  const fromArr = Array.isArray(pl.mediaUrls)
82
86
  ? pl.mediaUrls.filter((u): u is string => typeof u === "string" && u.trim().length > 0)
83
87
  : [];
@@ -159,7 +163,12 @@ export function isCanvasSnapshotMediaPath(url: unknown): boolean {
159
163
  export function translateDeliverPayload(
160
164
  pl: FridayReplyPayload,
161
165
  kind: string,
162
- meta?: { modelName?: string; totalTokens?: number; contextTokensUsed?: number; contextWindowMax?: number },
166
+ meta?: {
167
+ modelName?: string;
168
+ totalTokens?: number;
169
+ contextTokensUsed?: number;
170
+ contextWindowMax?: number;
171
+ },
163
172
  ): Record<string, unknown> {
164
173
  // Strip canvas-snapshot tool-result images before any media resolution (paths here are still the
165
174
  // original `/tmp/openclaw/openclaw-canvas-snapshot-*.jpg` temp paths, not yet copied to friday files).
@@ -210,7 +219,11 @@ export function translateDeliverPayload(
210
219
  if (typeof meta?.modelName === "string" && meta.modelName.trim()) {
211
220
  nextFridayNext.modelName = meta.modelName.trim();
212
221
  }
213
- if (typeof meta?.totalTokens === "number" && Number.isFinite(meta.totalTokens) && meta.totalTokens > 0) {
222
+ if (
223
+ typeof meta?.totalTokens === "number" &&
224
+ Number.isFinite(meta.totalTokens) &&
225
+ meta.totalTokens > 0
226
+ ) {
214
227
  nextFridayNext.totalTokens = Math.floor(meta.totalTokens);
215
228
  }
216
229
  if (
@@ -260,8 +273,10 @@ function scheduleLateFinalMetaPatch(runId: string, attempts = 6): void {
260
273
  sessionKey: route.sessionKey,
261
274
  modelName: meta.modelName ?? null,
262
275
  totalTokens: typeof meta.totalTokens === "number" ? meta.totalTokens : null,
263
- contextTokensUsed: typeof meta.contextTokensUsed === "number" ? meta.contextTokensUsed : null,
264
- contextWindowMax: typeof meta.contextWindowMax === "number" ? meta.contextWindowMax : null,
276
+ contextTokensUsed:
277
+ typeof meta.contextTokensUsed === "number" ? meta.contextTokensUsed : null,
278
+ contextWindowMax:
279
+ typeof meta.contextWindowMax === "number" ? meta.contextWindowMax : null,
265
280
  ts: Date.now(),
266
281
  },
267
282
  },
@@ -298,11 +313,17 @@ function pickMetadataFromMessageLike(message: unknown): {
298
313
  ? usage.totalTokens
299
314
  : undefined) ??
300
315
  (typeof usage?.total === "number" && Number.isFinite(usage.total) ? usage.total : undefined) ??
301
- (typeof usage?.total_tokens === "number" && Number.isFinite(usage.total_tokens) ? usage.total_tokens : undefined);
316
+ (typeof usage?.total_tokens === "number" && Number.isFinite(usage.total_tokens)
317
+ ? usage.total_tokens
318
+ : undefined);
302
319
  const totalFromMessage =
303
- (typeof m.totalTokens === "number" && Number.isFinite(m.totalTokens) ? m.totalTokens : undefined) ??
304
- (typeof m.total_tokens === "number" && Number.isFinite(m.total_tokens) ? m.total_tokens : undefined);
305
- const totalTokens = Math.floor((totalFromUsage ?? totalFromMessage ?? 0));
320
+ (typeof m.totalTokens === "number" && Number.isFinite(m.totalTokens)
321
+ ? m.totalTokens
322
+ : undefined) ??
323
+ (typeof m.total_tokens === "number" && Number.isFinite(m.total_tokens)
324
+ ? m.total_tokens
325
+ : undefined);
326
+ const totalTokens = Math.floor(totalFromUsage ?? totalFromMessage ?? 0);
306
327
 
307
328
  let contextTokensUsed: number | undefined;
308
329
  if (usage) {
@@ -313,8 +334,12 @@ function pickMetadataFromMessageLike(message: unknown): {
313
334
  }
314
335
 
315
336
  const ctxMaxRaw =
316
- (typeof m.contextWindow === "number" && Number.isFinite(m.contextWindow) ? m.contextWindow : undefined) ??
317
- (typeof m.maxContextTokens === "number" && Number.isFinite(m.maxContextTokens) ? m.maxContextTokens : undefined);
337
+ (typeof m.contextWindow === "number" && Number.isFinite(m.contextWindow)
338
+ ? m.contextWindow
339
+ : undefined) ??
340
+ (typeof m.maxContextTokens === "number" && Number.isFinite(m.maxContextTokens)
341
+ ? m.maxContextTokens
342
+ : undefined);
318
343
  const contextWindowMax =
319
344
  typeof ctxMaxRaw === "number" && ctxMaxRaw > 0 ? Math.floor(ctxMaxRaw) : undefined;
320
345
 
@@ -336,9 +361,16 @@ async function resolveRunMetadataFromRuntimeSession(
336
361
  contextTokensUsed?: number;
337
362
  contextWindowMax?: number;
338
363
  } | null> {
339
- const sessionApi = (runtime as unknown as {
340
- subagent?: { getSessionMessages?: (params: { sessionKey: string; limit?: number }) => Promise<{ messages?: unknown[] }> };
341
- }).subagent;
364
+ const sessionApi = (
365
+ runtime as unknown as {
366
+ subagent?: {
367
+ getSessionMessages?: (params: {
368
+ sessionKey: string;
369
+ limit?: number;
370
+ }) => Promise<{ messages?: unknown[] }>;
371
+ };
372
+ }
373
+ ).subagent;
342
374
  if (!sessionApi?.getSessionMessages) return null;
343
375
  try {
344
376
  const response = await sessionApi.getSessionMessages({ sessionKey, limit: 80 });
@@ -373,7 +405,10 @@ export function composeBodyWithMediaRefs(text: string, mediaRefs: string[]): str
373
405
  return trimmed ? `${trimmed}\n\n${mediaRefs.join("\n")}` : mediaRefs.join("\n");
374
406
  }
375
407
 
376
- async function buildBodyForAgentWithAttachments(text: string, attachmentIds: string[]): Promise<string> {
408
+ async function buildBodyForAgentWithAttachments(
409
+ text: string,
410
+ attachmentIds: string[],
411
+ ): Promise<string> {
377
412
  if (attachmentIds.length === 0) return text.trim();
378
413
 
379
414
  const mediaRefs: string[] = [];
@@ -539,7 +574,10 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
539
574
  dispatcherOptions: {
540
575
  deliver: async (pl: any, info: any) => {
541
576
  let meta = getRunMetadata(runId);
542
- if (info.kind.toLowerCase() === "final" && !(meta?.modelName || typeof meta?.totalTokens === "number")) {
577
+ if (
578
+ info.kind.toLowerCase() === "final" &&
579
+ !(meta?.modelName || typeof meta?.totalTokens === "number")
580
+ ) {
543
581
  const resolved = await resolveRunMetadataFromRuntimeSession(runtime, baseSessionKey);
544
582
  if (resolved) {
545
583
  setRunMetadata(runId, resolved);
@@ -602,10 +640,11 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
602
640
  // OpenClaw `pi-embedded-subscribe` gates `streamReasoning` on `typeof onReasoningStream === "function"`.
603
641
  // Without this, `emitReasoningStream` never runs and Friday SSE never sees `stream: "thinking"`.
604
642
  onReasoningStream: async (pl: unknown) => {
605
- const text =
643
+ const rawText =
606
644
  typeof pl === "object" && pl !== null && "text" in pl
607
- ? String((pl as { text?: unknown }).text ?? "")
608
- : "";
645
+ ? (pl as { text?: unknown }).text
646
+ : undefined;
647
+ const text = typeof rawText === "string" ? rawText : "";
609
648
  log("REASONING_STREAM", normalizedDeviceId, runId, `textLen=${text.length}`);
610
649
  },
611
650
  onReasoningEnd: async () => {
@@ -636,6 +675,15 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
636
675
  }
637
676
  };
638
677
 
678
+ // Elevate the route's (empty) operator scope so the dispatched agent can spawn
679
+ // subagents. Must run here, synchronously inside the route's AsyncLocalStorage
680
+ // context, so the live scope object the subagent spawn later reads carries
681
+ // operator.write. See agent/operator-scope.ts.
682
+ const elevatedScopes = ensureSubagentSpawnScope();
683
+ if (elevatedScopes.length > 0) {
684
+ log("SCOPE_ELEVATED", normalizedDeviceId, runId, elevatedScopes.join(","));
685
+ }
686
+
639
687
  runAgent().catch((err) => {
640
688
  log("RUN_ERROR", normalizedDeviceId, runId, String(err), "error");
641
689
  sseEmitter.untrackRun(runId);
@@ -44,7 +44,7 @@ function resolveConfiguredModels(): ResolvedModels {
44
44
  seen.add(modelKey);
45
45
  entries.push({
46
46
  id: modelKey,
47
- name: typeof info?.alias === "string" ? info.alias : meta?.name ?? split.modelId,
47
+ name: typeof info?.alias === "string" ? info.alias : (meta?.name ?? split.modelId),
48
48
  provider: split.provider,
49
49
  reasoning: meta?.reasoning,
50
50
  contextWindow: meta?.contextWindow,
@@ -104,13 +104,19 @@ function resolveConfiguredModels(): ResolvedModels {
104
104
  return { models: entries, defaultModel };
105
105
  }
106
106
 
107
- function buildProviderModelMeta(cfg: Record<string, unknown>): Map<string, {
108
- name?: string;
109
- reasoning?: boolean;
110
- contextWindow?: number;
111
- maxTokens?: number;
112
- }> {
113
- const meta = new Map<string, { name?: string; reasoning?: boolean; contextWindow?: number; maxTokens?: number }>();
107
+ function buildProviderModelMeta(cfg: Record<string, unknown>): Map<
108
+ string,
109
+ {
110
+ name?: string;
111
+ reasoning?: boolean;
112
+ contextWindow?: number;
113
+ maxTokens?: number;
114
+ }
115
+ > {
116
+ const meta = new Map<
117
+ string,
118
+ { name?: string; reasoning?: boolean; contextWindow?: number; maxTokens?: number }
119
+ >();
114
120
  const models = cfg?.models as Record<string, unknown> | undefined;
115
121
  const providers = models?.providers as Record<string, unknown> | undefined;
116
122
  if (providers) {
@@ -24,8 +24,14 @@ class MockRes extends EventEmitter {
24
24
  }
25
25
  }
26
26
 
27
- function mockReq(method: string, headers: Record<string, string> = {}): PassThrough & { method: string; headers: Record<string, string> } {
28
- const stream = new PassThrough() as unknown as PassThrough & { method: string; headers: Record<string, string> };
27
+ function mockReq(
28
+ method: string,
29
+ headers: Record<string, string> = {},
30
+ ): PassThrough & { method: string; headers: Record<string, string> } {
31
+ const stream = new PassThrough() as unknown as PassThrough & {
32
+ method: string;
33
+ headers: Record<string, string>;
34
+ };
29
35
  stream.method = method;
30
36
  stream.headers = headers;
31
37
  return stream;
@@ -92,7 +98,10 @@ describe("handleNodesApprove", () => {
92
98
  });
93
99
 
94
100
  it("returns 404 when listNodePairing returns data without matching node", async () => {
95
- mockList.mockResolvedValueOnce({ pending: [{ requestId: "x", nodeId: "UNMATCHED" }], paired: [] });
101
+ mockList.mockResolvedValueOnce({
102
+ pending: [{ requestId: "x", nodeId: "UNMATCHED" }],
103
+ paired: [],
104
+ });
96
105
 
97
106
  const req = mockReq("POST", { authorization: "Bearer test-token" });
98
107
  const res = new MockRes() as unknown as ServerResponse;
@@ -135,7 +144,9 @@ describe("handleNodesApprove", () => {
135
144
  it("returns 200 with alreadyApproved when node in paired with caps", async () => {
136
145
  mockList.mockResolvedValueOnce({
137
146
  pending: [],
138
- paired: [{ nodeId: NODE_ID, approvedAtMs: 100, caps: ["canvas"], commands: ["canvas.present"] }],
147
+ paired: [
148
+ { nodeId: NODE_ID, approvedAtMs: 100, caps: ["canvas"], commands: ["canvas.present"] },
149
+ ],
139
150
  });
140
151
 
141
152
  const req = mockReq("POST", { authorization: "Bearer test-token" });
@@ -14,15 +14,6 @@ interface PairedNodeEntry {
14
14
  caps?: string[];
15
15
  commands?: string[];
16
16
  }
17
- interface NodePairingList {
18
- pending: PendingNodeEntry[];
19
- paired: PairedNodeEntry[];
20
- }
21
- type ApproveNodePairingResult =
22
- | { requestId: string; node: PairedNodeEntry }
23
- | { status: "forbidden"; missingScope: string }
24
- | null;
25
-
26
17
  export async function handleNodesApprove(
27
18
  req: IncomingMessage,
28
19
  res: ServerResponse,
@@ -62,7 +53,7 @@ export async function handleNodesApprove(
62
53
 
63
54
  const normalizedNodeId = rawNodeId.trim().toUpperCase();
64
55
 
65
- let listData, listNodePairing, approveNodePairing;
56
+ let listData, listNodePairing, approveNodePairing;
66
57
  try {
67
58
  ({ listNodePairing, approveNodePairing } = await loadNodePairingModule());
68
59
  } catch (err) {
@@ -91,12 +82,7 @@ let listData, listNodePairing, approveNodePairing;
91
82
  const requestId = pendingMatch.requestId;
92
83
  log.info(`approving nodeId=${normalizedNodeId} requestId=${requestId}`);
93
84
 
94
- const callerScopes = [
95
- "operator.admin",
96
- "operator.pairing",
97
- "operator.read",
98
- "operator.write",
99
- ];
85
+ const callerScopes = ["operator.admin", "operator.pairing", "operator.read", "operator.write"];
100
86
 
101
87
  let approved;
102
88
  try {
@@ -105,10 +91,12 @@ let listData, listNodePairing, approveNodePairing;
105
91
  log.error(`approveNodePairing failed: ${err instanceof Error ? err.message : String(err)}`);
106
92
  res.statusCode = 502;
107
93
  res.setHeader("Content-Type", "application/json");
108
- res.end(JSON.stringify({
109
- error: "Node approval failed",
110
- detail: err instanceof Error ? err.message : "Unknown error",
111
- }));
94
+ res.end(
95
+ JSON.stringify({
96
+ error: "Node approval failed",
97
+ detail: err instanceof Error ? err.message : "Unknown error",
98
+ }),
99
+ );
112
100
  return true;
113
101
  }
114
102
 
@@ -122,18 +110,22 @@ let listData, listNodePairing, approveNodePairing;
122
110
  if ("status" in approved && approved.status === "forbidden") {
123
111
  res.statusCode = 403;
124
112
  res.setHeader("Content-Type", "application/json");
125
- res.end(JSON.stringify({ error: `Node approval forbidden: ${(approved as any).missingScope ?? "unknown"}` }));
113
+ res.end(
114
+ JSON.stringify({ error: `Node approval forbidden: ${approved.missingScope ?? "unknown"}` }),
115
+ );
126
116
  return true;
127
117
  }
128
118
 
129
119
  res.statusCode = 200;
130
120
  res.setHeader("Content-Type", "application/json");
131
- res.end(JSON.stringify({
132
- ok: true,
133
- nodeId: normalizedNodeId,
134
- requestId: (approved as any).requestId,
135
- approvedAtMs: (approved as any).node?.approvedAtMs,
136
- }));
121
+ res.end(
122
+ JSON.stringify({
123
+ ok: true,
124
+ nodeId: normalizedNodeId,
125
+ requestId: approved.requestId,
126
+ approvedAtMs: approved.node?.approvedAtMs,
127
+ }),
128
+ );
137
129
  return true;
138
130
  }
139
131
 
@@ -147,26 +139,32 @@ let listData, listNodePairing, approveNodePairing;
147
139
  const caps = pairedMatch.caps ?? [];
148
140
  const commands = pairedMatch.commands ?? [];
149
141
  if (caps.length > 0 || commands.length > 0) {
150
- log.info(`nodeId=${normalizedNodeId} already paired with caps=${caps.length} commands=${commands.length}`);
142
+ log.info(
143
+ `nodeId=${normalizedNodeId} already paired with caps=${caps.length} commands=${commands.length}`,
144
+ );
151
145
  res.statusCode = 200;
152
146
  res.setHeader("Content-Type", "application/json");
153
- res.end(JSON.stringify({
154
- ok: true,
155
- nodeId: normalizedNodeId,
156
- alreadyApproved: true,
157
- approvedAtMs: pairedMatch.approvedAtMs,
158
- caps,
159
- commands,
160
- }));
147
+ res.end(
148
+ JSON.stringify({
149
+ ok: true,
150
+ nodeId: normalizedNodeId,
151
+ alreadyApproved: true,
152
+ approvedAtMs: pairedMatch.approvedAtMs,
153
+ caps,
154
+ commands,
155
+ }),
156
+ );
161
157
  return true;
162
158
  }
163
159
  }
164
160
 
165
161
  res.statusCode = 404;
166
162
  res.setHeader("Content-Type", "application/json");
167
- res.end(JSON.stringify({
168
- error: "No pending node found for this nodeId",
169
- nodeId: normalizedNodeId,
170
- }));
163
+ res.end(
164
+ JSON.stringify({
165
+ error: "No pending node found for this nodeId",
166
+ nodeId: normalizedNodeId,
167
+ }),
168
+ );
171
169
  return true;
172
170
  }
@@ -1,11 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { extractBearerToken } from "../middleware/auth.js";
3
3
  import { PLUGIN_VERSION } from "../../version.js";
4
- import {
5
- fetchLatestVersion,
6
- getInstallSource,
7
- semverGreater,
8
- } from "../../plugin-install-info.js";
4
+ import { fetchLatestVersion, getInstallSource, semverGreater } from "../../plugin-install-info.js";
9
5
 
10
6
  export interface PluginInfoResult {
11
7
  currentVersion: string;
@@ -17,7 +13,10 @@ export interface PluginInfoResult {
17
13
  upgradable: boolean;
18
14
  }
19
15
 
20
- export async function handlePluginInfo(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
16
+ export async function handlePluginInfo(
17
+ req: IncomingMessage,
18
+ res: ServerResponse,
19
+ ): Promise<boolean> {
21
20
  if (req.method !== "GET") {
22
21
  res.statusCode = 405;
23
22
  res.setHeader("Content-Type", "application/json");
@@ -18,7 +18,10 @@ const RESTART_DELAY_MS = 500;
18
18
  * eligible — dev (load.paths / source==="path") installs return 409 to protect
19
19
  * the dev environment from duplicate npm installs.
20
20
  */
21
- export async function handlePluginUpgrade(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
21
+ export async function handlePluginUpgrade(
22
+ req: IncomingMessage,
23
+ res: ServerResponse,
24
+ ): Promise<boolean> {
22
25
  if (req.method !== "POST") {
23
26
  res.statusCode = 405;
24
27
  res.setHeader("Content-Type", "application/json");