@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
package/dist/index.js CHANGED
@@ -33,7 +33,7 @@ function deviceIdFromToolContext(ctx) {
33
33
  }
34
34
  const sk = typeof ctx.sessionKey === "string" && ctx.sessionKey.trim()
35
35
  ? ctx.sessionKey.trim()
36
- : (ctx.runId ? getOpenClawAgentRunContext(ctx.runId)?.sessionKey?.trim() : undefined) ?? "";
36
+ : ((ctx.runId ? getOpenClawAgentRunContext(ctx.runId)?.sessionKey?.trim() : undefined) ?? "");
37
37
  if (sk) {
38
38
  const d = resolveFridayDeviceIdForSessionKey(sk);
39
39
  if (d)
@@ -1,4 +1,4 @@
1
- type DispatchFn = (args: unknown) => Promise<unknown> | unknown;
1
+ type DispatchFn = (args: unknown) => unknown;
2
2
  export declare function runFridayDispatch(args: Parameters<DispatchFn>[0]): ReturnType<DispatchFn>;
3
3
  export declare function __setMockFridayDispatchForTests(fn: DispatchFn): void;
4
4
  export declare function __resetMockFridayDispatchForTests(): void;
@@ -1,9 +1,12 @@
1
- export declare function loadNodePairingModule(): Promise<{
2
- listNodePairing: Function;
3
- approveNodePairing: Function;
4
- }>;
1
+ type ListNodePairingFn = () => Promise<any>;
2
+ type ApproveNodePairingFn = (requestId: string, options: {
3
+ callerScopes?: unknown;
4
+ }) => Promise<any>;
5
+ type NodePairingModule = {
6
+ listNodePairing: ListNodePairingFn;
7
+ approveNodePairing: ApproveNodePairingFn;
8
+ };
9
+ export declare function loadNodePairingModule(): Promise<NodePairingModule>;
5
10
  /** Vitest-only: inject mock pairing functions. */
6
- export declare function __setMockNodePairingForTests(mock: {
7
- listNodePairing: Function;
8
- approveNodePairing: Function;
9
- }): void;
11
+ export declare function __setMockNodePairingForTests(mock: NodePairingModule): void;
12
+ export {};
@@ -16,7 +16,9 @@ function resolveOpenClawDistFromPath() {
16
16
  readdirSync(dist);
17
17
  return dist;
18
18
  }
19
- catch { }
19
+ catch {
20
+ // Not a real dist dir — keep walking PATH.
21
+ }
20
22
  }
21
23
  return null;
22
24
  }
@@ -45,7 +47,9 @@ function resolveOpenClawDist() {
45
47
  readdirSync(root);
46
48
  return root;
47
49
  }
48
- catch { }
50
+ catch {
51
+ // Candidate dir doesn't exist — try the next one.
52
+ }
49
53
  }
50
54
  throw new Error("OpenClaw dist directory not found. Set OPENCLAW_DIST env var.");
51
55
  }
@@ -0,0 +1,19 @@
1
+ type ScopeLike = {
2
+ client?: {
3
+ connect?: {
4
+ scopes?: unknown;
5
+ };
6
+ };
7
+ } | null | undefined;
8
+ /**
9
+ * Adds the required operator scopes to a gateway-request-scope's
10
+ * `client.connect.scopes` array in place. Pure and idempotent.
11
+ * Returns the scopes that were actually added (empty if none / no array present).
12
+ */
13
+ export declare function elevateScopeForSubagentSpawn(scope: ScopeLike): string[];
14
+ /**
15
+ * Fetches the live plugin gateway-request-scope and elevates it so the dispatched
16
+ * agent can spawn subagents. Never throws — returns the scopes added (or []).
17
+ */
18
+ export declare function ensureSubagentSpawnScope(): string[];
19
+ export {};
@@ -0,0 +1,54 @@
1
+ // Operator-scope elevation for friday-next agent dispatch.
2
+ //
3
+ // Why this exists: friday-next registers all its routes with auth:"plugin" (it does
4
+ // its own device-token auth, not the gateway operator token). Core's
5
+ // createPluginRouteRuntimeScope gives auth!="gateway" routes an EMPTY operator-scope
6
+ // set. When an agent dispatched from such a route spawns a subagent, the spawn
7
+ // re-enters the in-process gateway `agent` method, which requires `operator.write`
8
+ // (core-descriptors: { name:"agent", scope:"operator.write" }). With an empty ambient
9
+ // scope the spawn fails with `{"error":"missing scope: operator.write"}`.
10
+ //
11
+ // The subagent spawn reads getPluginRuntimeGatewayRequestScope() at spawn time and
12
+ // uses that scope's client for authorization. Because AsyncLocalStorage returns the
13
+ // SAME store object reference, mutating its client.connect.scopes once — before we
14
+ // kick off the dispatch, while still inside the route's ALS context — propagates the
15
+ // elevated scopes to every later reader, including the subagent spawn.
16
+ //
17
+ // Subagent lifecycle admin methods (sessions.patch/delete) are unaffected: core pins
18
+ // those to ADMIN_SCOPE via a synthetic client, so only the `agent` method depends on
19
+ // this ambient operator.write. See memory: subagent-spawn-missing-operator-write.
20
+ import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
21
+ /** Operator scopes the friday-next dispatch needs so agents can spawn subagents. */
22
+ const REQUIRED_OPERATOR_SCOPES = ["operator.write", "operator.read"];
23
+ /**
24
+ * Adds the required operator scopes to a gateway-request-scope's
25
+ * `client.connect.scopes` array in place. Pure and idempotent.
26
+ * Returns the scopes that were actually added (empty if none / no array present).
27
+ */
28
+ export function elevateScopeForSubagentSpawn(scope) {
29
+ const connect = scope?.client?.connect;
30
+ if (!connect || !Array.isArray(connect.scopes)) {
31
+ return [];
32
+ }
33
+ const scopes = connect.scopes;
34
+ const added = [];
35
+ for (const scopeName of REQUIRED_OPERATOR_SCOPES) {
36
+ if (!scopes.includes(scopeName)) {
37
+ scopes.push(scopeName);
38
+ added.push(scopeName);
39
+ }
40
+ }
41
+ return added;
42
+ }
43
+ /**
44
+ * Fetches the live plugin gateway-request-scope and elevates it so the dispatched
45
+ * agent can spawn subagents. Never throws — returns the scopes added (or []).
46
+ */
47
+ export function ensureSubagentSpawnScope() {
48
+ try {
49
+ return elevateScopeForSubagentSpawn(getPluginRuntimeGatewayRequestScope());
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ }
@@ -8,9 +8,6 @@ export function registerSessionKeyForRun(sessionKey, runId) {
8
8
  return;
9
9
  sessionKeyToRunId.set(sessionKey, runId);
10
10
  }
11
- function resolveRunIdForSessionKey(sessionKey) {
12
- return sessionKeyToRunId.get(sessionKey);
13
- }
14
11
  /**
15
12
  * Parse OpenClaw announce compound runId:
16
13
  * announce:v<version>:<sessionKey>:<bareRunId>
@@ -52,7 +52,9 @@ async function readMediaFile(mediaPath, ctx) {
52
52
  return { buffer, mimeType: guessMimeType(mediaPath) };
53
53
  }
54
54
  }
55
- catch { /* fall through */ }
55
+ catch {
56
+ /* fall through */
57
+ }
56
58
  }
57
59
  try {
58
60
  const buffer = fs.readFileSync(mediaPath);
@@ -183,7 +183,6 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
183
183
  const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
184
184
  const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
185
185
  const conn = sseEmitter.getConnection(deviceId);
186
- const ts = new Date().toISOString();
187
186
  logger.info(`[SEND_TEXT] to=${deviceId} runId=${runId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} textLen=${text.length} online=${!!conn}`);
188
187
  if (conn) {
189
188
  sseEmitter.broadcast({
@@ -267,7 +266,6 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
267
266
  const resolved = resolveMediaAttachment(fileUrl);
268
267
  const publicUrl = resolved ? resolved.url : fileUrl;
269
268
  const conn = sseEmitter.getConnection(deviceId);
270
- const ts = new Date().toISOString();
271
269
  logger.info(`[SEND_MEDIA] to=${deviceId} runId=${runId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} audioAsVoice=${audioAsVoice} url=${publicUrl} online=${!!conn}`);
272
270
  if (conn) {
273
271
  sseEmitter.broadcast({
@@ -63,7 +63,16 @@ export function collectMediaPathsFromToolResult(raw, acc) {
63
63
  const filePath = o.filePath;
64
64
  if (typeof filePath === "string")
65
65
  add(filePath);
66
- for (const k of ["details", "result", "content", "text", "body", "message", "arguments", "args"]) {
66
+ for (const k of [
67
+ "details",
68
+ "result",
69
+ "content",
70
+ "text",
71
+ "body",
72
+ "message",
73
+ "arguments",
74
+ "args",
75
+ ]) {
67
76
  if (o[k] !== undefined)
68
77
  visit(o[k]);
69
78
  }
@@ -37,7 +37,7 @@ export function deviceIdFromSessionKey(sessionKey) {
37
37
  if (m1)
38
38
  return m1[1] ?? null;
39
39
  const m2 = sessionKey.match(/^agent:main:friday-next-(.+)$/i);
40
- return m2 ? m2[1] ?? null : null;
40
+ return m2 ? (m2[1] ?? null) : null;
41
41
  }
42
42
  /**
43
43
  * When the app uses a plain `sessionKey` (e.g. `main` → `agent:main:main` in the gateway),
@@ -52,7 +52,8 @@ const deviceIdToLatestHistorySessionKey = new Map();
52
52
  /** Last device that called POST /friday-next/messages (same gateway process). Used for cron/outbound when `to` is placeholder and the app is offline (no SSE). */
53
53
  let lastRegisteredFridayDeviceId;
54
54
  function normalizeFridaySessionKeyCase(sk) {
55
- return /^friday-next-|^agent:main:friday-next-/i.test(sk) || /^agent:main:friday-next:direct:/i.test(sk)
55
+ return /^friday-next-|^agent:main:friday-next-/i.test(sk) ||
56
+ /^agent:main:friday-next:direct:/i.test(sk)
56
57
  ? sk.toLowerCase()
57
58
  : sk;
58
59
  }
@@ -126,7 +127,9 @@ function mergeRunMetadataIntoLifecycleEnd(runId, base) {
126
127
  if (typeof meta.modelName === "string" && meta.modelName.trim()) {
127
128
  extra.modelName = meta.modelName.trim();
128
129
  }
129
- if (typeof meta.totalTokens === "number" && Number.isFinite(meta.totalTokens) && meta.totalTokens > 0) {
130
+ if (typeof meta.totalTokens === "number" &&
131
+ Number.isFinite(meta.totalTokens) &&
132
+ meta.totalTokens > 0) {
130
133
  extra.totalTokens = Math.floor(meta.totalTokens);
131
134
  }
132
135
  if (typeof meta.contextTokensUsed === "number" &&
@@ -242,6 +245,26 @@ export function resolveFridayDeviceIdForOutbound(to, rawCtx) {
242
245
  return lastRegisteredFridayDeviceId;
243
246
  return trimmed || "friday-next";
244
247
  }
248
+ /**
249
+ * Stringify a subagent error payload for the wire. `evt.data.error` comes from an
250
+ * in-process SDK callback, so it can be a real Error, a plain object, or a value
251
+ * that JSON.stringify would throw on (circular/BigInt) — keep this total and
252
+ * never-throwing, since it runs on the error path that emits `subagent ended`.
253
+ */
254
+ function stringifySubagentError(raw) {
255
+ if (raw == null)
256
+ return "unknown";
257
+ if (typeof raw === "string")
258
+ return raw;
259
+ if (raw instanceof Error)
260
+ return raw.message || raw.name || "error";
261
+ try {
262
+ return JSON.stringify(raw) ?? "unknown";
263
+ }
264
+ catch {
265
+ return "unstringifiable error";
266
+ }
267
+ }
245
268
  /**
246
269
  * Forward global OpenClaw agent events to the Friday SSE connection (transparent).
247
270
  *
@@ -272,8 +295,7 @@ export function forwardAgentEventRaw(evt) {
272
295
  if (!deviceIdRaw)
273
296
  return;
274
297
  if (!sk) {
275
- sk =
276
- latestHistorySessionKeyForDeviceId(deviceIdRaw) ?? `friday-next-${deviceIdRaw}`;
298
+ sk = latestHistorySessionKeyForDeviceId(deviceIdRaw) ?? `friday-next-${deviceIdRaw}`;
277
299
  }
278
300
  openClawRunIdToDeviceId.set(evt.runId, deviceIdRaw.toUpperCase());
279
301
  // Register sessionKey → runId so we can resolve parentRunId
@@ -317,9 +339,7 @@ export function forwardAgentEventRaw(evt) {
317
339
  const intent = toolCallId ? consumeSpawnIntent(toolCallId) : undefined;
318
340
  const label = details.taskName ||
319
341
  intent?.label ||
320
- (typeof evt.data.meta === "string"
321
- ? evt.data.meta
322
- : undefined);
342
+ (typeof evt.data.meta === "string" ? evt.data.meta : undefined);
323
343
  const entry = ensureSubagentFromSpawnTool({
324
344
  childSessionKey: details.childSessionKey,
325
345
  bareRunId: details.runId,
@@ -351,7 +371,11 @@ export function forwardAgentEventRaw(evt) {
351
371
  // share the announce runId but have a different sessionKey.
352
372
  const isSubagentOwnEvent = subagentEntry && sk && subagentEntry.childSessionKey === sk;
353
373
  const subagentMeta = isSubagentOwnEvent
354
- ? { label: subagentEntry.label, parentRunId: subagentEntry.parentRunId, depth: subagentEntry.depth }
374
+ ? {
375
+ label: subagentEntry.label,
376
+ parentRunId: subagentEntry.parentRunId,
377
+ depth: subagentEntry.depth,
378
+ }
355
379
  : undefined;
356
380
  let outgoingData = { ...evt.data };
357
381
  if (evt.stream === "thinking") {
@@ -381,7 +405,7 @@ export function forwardAgentEventRaw(evt) {
381
405
  // Emit subagent ended SSE when a subagent run terminates
382
406
  if (isTerminalLifecycle && isSubagentOwnEvent && subagentEntry.status !== "ended") {
383
407
  const outcome = lifecyclePhase === "error" ? "error" : "ok";
384
- const errorStr = lifecyclePhase === "error" ? String(evt.data.error ?? "unknown") : undefined;
408
+ const errorStr = lifecyclePhase === "error" ? stringifySubagentError(evt.data.error) : undefined;
385
409
  const ended = registerSubagentEnded({ runId: evt.runId, outcome, error: errorStr });
386
410
  if (ended) {
387
411
  sseEmitter.broadcast({
@@ -46,6 +46,16 @@ function splitMediaLines(text) {
46
46
  .trim();
47
47
  return { text: cleaned, paths };
48
48
  }
49
+ /**
50
+ * Tools whose `toolResult` carries a user-facing PRODUCED image, which stays a
51
+ * chat attachment. Every OTHER tool's inline image block is the agent's visual
52
+ * INPUT — a file the `read` tool fed to the model, a `canvas` snapshot, a
53
+ * browser screenshot — and must NOT surface as an attachment on history rebuild
54
+ * (it spawns phantom, often-corrupt bubbles for turns where the agent never
55
+ * sent a file). Keep this a whitelist so any new image-CONSUMING tool is safe by
56
+ * default; add new image-PRODUCING tools here explicitly.
57
+ */
58
+ const IMAGE_PRODUCING_TOOLS = new Set(["image_generation"]);
49
59
  const MEDIA_MARKER_RE = /\[media attached:\s*([^\]]+)\]/gi;
50
60
  /** Pull `[media attached: <url>]` markers out of free text into image refs. */
51
61
  function extractMediaMarkers(text) {
@@ -181,14 +191,18 @@ export function normalizeHistoryMessage(raw, index) {
181
191
  if (role === "toolResult") {
182
192
  const split = splitMediaLines(parsed.text);
183
193
  const toolName = readString(record.toolName);
184
- // Canvas snapshots come back as base64 image blocks on the `canvas` tool result so the *agent*
185
- // can "see" the rendered page they must never surface as chat attachments on history rebuild.
186
- // The streaming deliver path already drops the temp-file form (see isCanvasSnapshotMediaPath in
187
- // http/handlers/messages.ts); this is the transcript-rebuild counterpart. The canvas tool has no
188
- // other image-returning action, so all images on a canvas result are snapshots.
189
- const isCanvasResult = toolName === "canvas";
190
- const images = isCanvasResult ? [] : parsed.images;
191
- const mediaPaths = isCanvasResult ? [] : split.paths;
194
+ // Inline image blocks on a toolResult are almost always the agent's visual
195
+ // INPUT a file the `read` tool fed to the model, a `canvas` snapshot (so the
196
+ // agent can "see" the rendered page), a browser screenshot NOT a user-facing
197
+ // attachment. Surfacing them spawns phantom, often-corrupt attachment bubbles on
198
+ // history rebuild for turns where the agent never sent a file. Only tools that
199
+ // PRODUCE a user-facing image keep their blocks. (This was a `canvas`-only
200
+ // blacklist, which still leaked `read`/screenshot images.) The streaming deliver
201
+ // path drops the canvas temp-file form separately (isCanvasSnapshotMediaPath in
202
+ // http/handlers/messages.ts); this is the transcript-rebuild counterpart.
203
+ const keepInlineImages = toolName ? IMAGE_PRODUCING_TOOLS.has(toolName) : false;
204
+ const images = keepInlineImages ? parsed.images : [];
205
+ const mediaPaths = toolName === "canvas" ? [] : split.paths;
192
206
  const toolResult = {
193
207
  ...(readString(record.toolCallId) ? { toolCallId: readString(record.toolCallId) } : {}),
194
208
  ...(toolName ? { toolName } : {}),
@@ -36,7 +36,9 @@ function readString(value) {
36
36
  function readStringArray(value) {
37
37
  if (!Array.isArray(value))
38
38
  return undefined;
39
- const out = value.filter((v) => typeof v === "string" && v.trim().length > 0).map((v) => v.trim());
39
+ const out = value
40
+ .filter((v) => typeof v === "string" && v.trim().length > 0)
41
+ .map((v) => v.trim());
40
42
  return out;
41
43
  }
42
44
  function readToolsConfig(value) {
@@ -109,7 +111,7 @@ function coerceTools(raw) {
109
111
  }
110
112
  /** Skills: array (incl. empty = disable all) only; non-arrays are rejected upstream. */
111
113
  function coerceSkills(raw) {
112
- return Array.isArray(raw) ? readStringArray(raw) ?? [] : undefined;
114
+ return Array.isArray(raw) ? (readStringArray(raw) ?? []) : undefined;
113
115
  }
114
116
  // --- handler -----------------------------------------------------------------
115
117
  export async function handleAgentConfig(req, res, rawAgentId) {
@@ -132,10 +134,14 @@ export async function handleAgentConfig(req, res, rawAgentId) {
132
134
  const tools = readPatch(body, "tools", coerceTools);
133
135
  const skills = readPatch(body, "skills", coerceSkills);
134
136
  if ("skills" in body && body.skills !== null && !Array.isArray(body.skills)) {
135
- return json(res, 400, { error: "skills must be an array of skill ids, [] to disable all, or null to inherit defaults" });
137
+ return json(res, 400, {
138
+ error: "skills must be an array of skill ids, [] to disable all, or null to inherit defaults",
139
+ });
136
140
  }
137
141
  if (!model.sent && !thinkingDefault.sent && !tools.sent && !skills.sent) {
138
- return json(res, 400, { error: "No editable fields provided (model, thinkingDefault, tools, skills)" });
142
+ return json(res, 400, {
143
+ error: "No editable fields provided (model, thinkingDefault, tools, skills)",
144
+ });
139
145
  }
140
146
  const upgrade = getUpgradeRuntime();
141
147
  if (!upgrade)
@@ -22,14 +22,16 @@ export async function handleCancel(req, res) {
22
22
  // sessionKey is the primary identifier (one active run per session); runId is a
23
23
  // back-compat fallback for older apps — resolve it to a sessionKey via the run route.
24
24
  const sessionKey = (typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "") ||
25
- (runId ? getRunRoute(runId)?.sessionKey?.trim() ?? "" : "");
25
+ (runId ? (getRunRoute(runId)?.sessionKey?.trim() ?? "") : "");
26
26
  if (!sessionKey && !runId) {
27
27
  res.statusCode = 400;
28
28
  res.setHeader("Content-Type", "application/json");
29
29
  res.end(JSON.stringify({ error: "Missing sessionKey or runId" }));
30
30
  return true;
31
31
  }
32
- const result = sessionKey ? await abortRunForSessionKey(sessionKey) : { aborted: false, drained: false };
32
+ const result = sessionKey
33
+ ? await abortRunForSessionKey(sessionKey)
34
+ : { aborted: false, drained: false };
33
35
  if (runId)
34
36
  sseEmitter.untrackRun(runId);
35
37
  res.statusCode = 200;
@@ -92,7 +92,9 @@ export async function handleDeviceApprove(req, res) {
92
92
  if (approved.status === "forbidden") {
93
93
  res.statusCode = 403;
94
94
  res.setHeader("Content-Type", "application/json");
95
- res.end(JSON.stringify({ error: `Device approval forbidden: ${approved.reason ?? "unknown"}` }));
95
+ res.end(JSON.stringify({
96
+ error: `Device approval forbidden: ${approved.reason ?? "unknown"}`,
97
+ }));
96
98
  return true;
97
99
  }
98
100
  res.statusCode = 200;
@@ -51,7 +51,10 @@ function tryDecodeURIComponent(segment) {
51
51
  * Safe Content-Disposition: strip CR/LF/quotes from basename; add RFC 5987 filename* for Unicode.
52
52
  */
53
53
  function contentDispositionInline(filename) {
54
- const base = path.basename(filename).replace(/[\r\n"]/g, "_").replace(/\\/g, "_") || "file";
54
+ const base = path
55
+ .basename(filename)
56
+ .replace(/[\r\n"]/g, "_")
57
+ .replace(/\\/g, "_") || "file";
55
58
  const ascii = /^[\x20-\x7E]*$/.test(base) ? base : "file";
56
59
  return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(base)}`;
57
60
  }
@@ -62,9 +65,7 @@ function sendBuffer(req, res, buffer, mimeType, filename) {
62
65
  const total = buffer.length;
63
66
  const disposition = contentDispositionInline(filename);
64
67
  const rangeRaw = req.headers.range;
65
- const range = typeof rangeRaw === "string" && /^bytes=/i.test(rangeRaw.trim())
66
- ? rangeRaw.trim()
67
- : undefined;
68
+ const range = typeof rangeRaw === "string" && /^bytes=/i.test(rangeRaw.trim()) ? rangeRaw.trim() : undefined;
68
69
  res.setHeader("Accept-Ranges", "bytes");
69
70
  res.setHeader("Cache-Control", "private, max-age=3600");
70
71
  res.setHeader("Content-Type", mimeType);
@@ -178,10 +179,7 @@ export async function handleFilesDownload(req, res) {
178
179
  // fileId may include an extension (e.g. "uuid.png") — strip it to get the base id
179
180
  const baseId = fileToken.replace(/\.[^.]+$/, "");
180
181
  const mediaDir = path.join(os.homedir(), ".openclaw", "media", "inbound");
181
- const candidates = [
182
- path.join(mediaDir, baseId),
183
- path.join(mediaDir, fileToken),
184
- ];
182
+ const candidates = [path.join(mediaDir, baseId), path.join(mediaDir, fileToken)];
185
183
  for (const filePath of candidates) {
186
184
  if (fs.existsSync(filePath)) {
187
185
  try {
@@ -214,7 +214,7 @@ export function storeFile(buffer, filename, mimeType) {
214
214
  fs.writeFileSync(storedPath, buffer);
215
215
  }
216
216
  catch (err) {
217
- throw new Error(`Failed to store file: ${String(err)}`);
217
+ throw new Error(`Failed to store file: ${String(err)}`, { cause: err });
218
218
  }
219
219
  const file = {
220
220
  id,
@@ -43,7 +43,10 @@ export async function handleHealth(req, res) {
43
43
  if (nodeDeviceId) {
44
44
  result.nodePairing = await checkNodePairing(nodeDeviceId, selfHeal, result, log);
45
45
  }
46
- result.ok = !result.nodePairing || (result.nodePairing.status === "ok" || result.nodePairing.status === "pending");
46
+ result.ok =
47
+ !result.nodePairing ||
48
+ result.nodePairing.status === "ok" ||
49
+ result.nodePairing.status === "pending";
47
50
  res.statusCode = 200;
48
51
  res.setHeader("Content-Type", "application/json");
49
52
  res.end(JSON.stringify(result));
@@ -108,9 +111,16 @@ async function checkNodePairing(nodeDeviceId, selfHeal, result, log) {
108
111
  const pendingMatch = pendingNodes.find((entry) => entry.nodeId?.trim().toUpperCase() === normalizedNodeId);
109
112
  if (pendingMatch && selfHeal) {
110
113
  try {
111
- const callerScopes = ["operator.admin", "operator.pairing", "operator.read", "operator.write"];
114
+ const callerScopes = [
115
+ "operator.admin",
116
+ "operator.pairing",
117
+ "operator.read",
118
+ "operator.write",
119
+ ];
112
120
  const approved = await approveNodePairing(pendingMatch.requestId, { callerScopes });
113
- const succeeded = approved != null && !("status" in approved && approved.status === "forbidden") && "requestId" in approved;
121
+ const succeeded = approved != null &&
122
+ !("status" in approved && approved.status === "forbidden") &&
123
+ "requestId" in approved;
114
124
  (result.repairActions ??= []).push({
115
125
  component: "nodePairing",
116
126
  action: "approveNodePairing",
@@ -142,5 +152,9 @@ async function checkNodePairing(nodeDeviceId, selfHeal, result, log) {
142
152
  if (pendingMatch) {
143
153
  return { status: "pending", detail: "Node is pending approval", nodePaired: false };
144
154
  }
145
- return { status: "not_found", detail: `Node ${normalizedNodeId} not registered`, nodePaired: false };
155
+ return {
156
+ status: "not_found",
157
+ detail: `Node ${normalizedNodeId} not registered`,
158
+ nodePaired: false,
159
+ };
146
160
  }
@@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url";
13
13
  import { getFridayNextRuntime } from "../../runtime.js";
14
14
  import { extractBearerToken } from "../middleware/auth.js";
15
15
  import { normalizeHistoryMessages } from "../../history/normalize-message.js";
16
- import { readSessionTranscriptRawMessages, resolveSessionId } from "../../history/read-transcript.js";
16
+ import { readSessionTranscriptRawMessages, resolveSessionId, } from "../../history/read-transcript.js";
17
17
  import { resolveMediaAttachment } from "./files.js";
18
18
  import { readSessionUsageSnapshotFromStore } from "../../session-usage-store.js";
19
19
  const DEFAULT_LIMIT = 200;
@@ -126,12 +126,14 @@ function readAgentSessions(agentId) {
126
126
  sessionKey: canonicalKey,
127
127
  agentId,
128
128
  ...(readString(entry.sessionId) ? { sessionId: readString(entry.sessionId) } : {}),
129
- ...(readNumber(entry.updatedAt) !== undefined ? { updatedAt: readNumber(entry.updatedAt) } : {}),
130
- ...(readString(entry.model) ?? readString(entry.modelOverride)
129
+ ...(readNumber(entry.updatedAt) !== undefined
130
+ ? { updatedAt: readNumber(entry.updatedAt) }
131
+ : {}),
132
+ ...((readString(entry.model) ?? readString(entry.modelOverride))
131
133
  ? { model: readString(entry.model) ?? readString(entry.modelOverride) }
132
134
  : {}),
133
135
  // Server-side session display name (matches OpenClaw's resolution order).
134
- ...(readString(entry.displayName) ?? readString(entry.label)
136
+ ...((readString(entry.displayName) ?? readString(entry.label))
135
137
  ? { title: readString(entry.displayName) ?? readString(entry.label) }
136
138
  : {}),
137
139
  });
@@ -21,6 +21,7 @@ import { registerFridaySessionDeviceMapping } from "../../friday-session.js";
21
21
  import { touchFridayInbound } from "../../friday-inbound-stats.js";
22
22
  import { fridayAttachmentLookupKey, fridayFilesPublicUrl, readFile, rememberInboundMediaName, resolveMediaAttachment, resolveMediaUrl, } from "./files.js";
23
23
  import { runFridayDispatch } from "../../agent/dispatch-bridge.js";
24
+ import { ensureSubagentSpawnScope } from "../../agent/operator-scope.js";
24
25
  import { saveInboundMediaBuffer } from "../../agent/media-bridge.js";
25
26
  import { contextTokensFromUsageRecord, getRunMetadata, getRunRoute, hasRunFinalDelivered, markRunFinalDelivered, registerRunRoute, setRunMetadata, } from "../../run-metadata.js";
26
27
  import { createFridayNextLogger, setFridayNextLogLevel } from "../../logging.js";
@@ -148,7 +149,9 @@ export function translateDeliverPayload(pl, kind, meta) {
148
149
  if (typeof meta?.modelName === "string" && meta.modelName.trim()) {
149
150
  nextFridayNext.modelName = meta.modelName.trim();
150
151
  }
151
- if (typeof meta?.totalTokens === "number" && Number.isFinite(meta.totalTokens) && meta.totalTokens > 0) {
152
+ if (typeof meta?.totalTokens === "number" &&
153
+ Number.isFinite(meta.totalTokens) &&
154
+ meta.totalTokens > 0) {
152
155
  nextFridayNext.totalTokens = Math.floor(meta.totalTokens);
153
156
  }
154
157
  if (typeof meta?.contextTokensUsed === "number" &&
@@ -221,10 +224,16 @@ function pickMetadataFromMessageLike(message) {
221
224
  ? usage.totalTokens
222
225
  : undefined) ??
223
226
  (typeof usage?.total === "number" && Number.isFinite(usage.total) ? usage.total : undefined) ??
224
- (typeof usage?.total_tokens === "number" && Number.isFinite(usage.total_tokens) ? usage.total_tokens : undefined);
225
- const totalFromMessage = (typeof m.totalTokens === "number" && Number.isFinite(m.totalTokens) ? m.totalTokens : undefined) ??
226
- (typeof m.total_tokens === "number" && Number.isFinite(m.total_tokens) ? m.total_tokens : undefined);
227
- const totalTokens = Math.floor((totalFromUsage ?? totalFromMessage ?? 0));
227
+ (typeof usage?.total_tokens === "number" && Number.isFinite(usage.total_tokens)
228
+ ? usage.total_tokens
229
+ : undefined);
230
+ const totalFromMessage = (typeof m.totalTokens === "number" && Number.isFinite(m.totalTokens)
231
+ ? m.totalTokens
232
+ : undefined) ??
233
+ (typeof m.total_tokens === "number" && Number.isFinite(m.total_tokens)
234
+ ? m.total_tokens
235
+ : undefined);
236
+ const totalTokens = Math.floor(totalFromUsage ?? totalFromMessage ?? 0);
228
237
  let contextTokensUsed;
229
238
  if (usage) {
230
239
  const ctx = contextTokensFromUsageRecord(usage);
@@ -232,8 +241,12 @@ function pickMetadataFromMessageLike(message) {
232
241
  contextTokensUsed = ctx;
233
242
  }
234
243
  }
235
- const ctxMaxRaw = (typeof m.contextWindow === "number" && Number.isFinite(m.contextWindow) ? m.contextWindow : undefined) ??
236
- (typeof m.maxContextTokens === "number" && Number.isFinite(m.maxContextTokens) ? m.maxContextTokens : undefined);
244
+ const ctxMaxRaw = (typeof m.contextWindow === "number" && Number.isFinite(m.contextWindow)
245
+ ? m.contextWindow
246
+ : undefined) ??
247
+ (typeof m.maxContextTokens === "number" && Number.isFinite(m.maxContextTokens)
248
+ ? m.maxContextTokens
249
+ : undefined);
237
250
  const contextWindowMax = typeof ctxMaxRaw === "number" && ctxMaxRaw > 0 ? Math.floor(ctxMaxRaw) : undefined;
238
251
  if (!modelName && !(totalTokens > 0) && !contextTokensUsed && !contextWindowMax)
239
252
  return null;
@@ -407,7 +420,8 @@ export async function handleMessages(req, res) {
407
420
  dispatcherOptions: {
408
421
  deliver: async (pl, info) => {
409
422
  let meta = getRunMetadata(runId);
410
- if (info.kind.toLowerCase() === "final" && !(meta?.modelName || typeof meta?.totalTokens === "number")) {
423
+ if (info.kind.toLowerCase() === "final" &&
424
+ !(meta?.modelName || typeof meta?.totalTokens === "number")) {
411
425
  const resolved = await resolveRunMetadataFromRuntimeSession(runtime, baseSessionKey);
412
426
  if (resolved) {
413
427
  setRunMetadata(runId, resolved);
@@ -462,9 +476,10 @@ export async function handleMessages(req, res) {
462
476
  // OpenClaw `pi-embedded-subscribe` gates `streamReasoning` on `typeof onReasoningStream === "function"`.
463
477
  // Without this, `emitReasoningStream` never runs and Friday SSE never sees `stream: "thinking"`.
464
478
  onReasoningStream: async (pl) => {
465
- const text = typeof pl === "object" && pl !== null && "text" in pl
466
- ? String(pl.text ?? "")
467
- : "";
479
+ const rawText = typeof pl === "object" && pl !== null && "text" in pl
480
+ ? pl.text
481
+ : undefined;
482
+ const text = typeof rawText === "string" ? rawText : "";
468
483
  log("REASONING_STREAM", normalizedDeviceId, runId, `textLen=${text.length}`);
469
484
  },
470
485
  onReasoningEnd: async () => {
@@ -492,6 +507,14 @@ export async function handleMessages(req, res) {
492
507
  sseEmitter.untrackRun(runId);
493
508
  }
494
509
  };
510
+ // Elevate the route's (empty) operator scope so the dispatched agent can spawn
511
+ // subagents. Must run here, synchronously inside the route's AsyncLocalStorage
512
+ // context, so the live scope object the subagent spawn later reads carries
513
+ // operator.write. See agent/operator-scope.ts.
514
+ const elevatedScopes = ensureSubagentSpawnScope();
515
+ if (elevatedScopes.length > 0) {
516
+ log("SCOPE_ELEVATED", normalizedDeviceId, runId, elevatedScopes.join(","));
517
+ }
495
518
  runAgent().catch((err) => {
496
519
  log("RUN_ERROR", normalizedDeviceId, runId, String(err), "error");
497
520
  sseEmitter.untrackRun(runId);
@@ -22,7 +22,7 @@ function resolveConfiguredModels() {
22
22
  seen.add(modelKey);
23
23
  entries.push({
24
24
  id: modelKey,
25
- name: typeof info?.alias === "string" ? info.alias : meta?.name ?? split.modelId,
25
+ name: typeof info?.alias === "string" ? info.alias : (meta?.name ?? split.modelId),
26
26
  provider: split.provider,
27
27
  reasoning: meta?.reasoning,
28
28
  contextWindow: meta?.contextWindow,