@syengup/friday-channel-next 0.1.36 → 0.1.37

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 (120) 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/subagent-registry.js +0 -3
  6. package/dist/src/channel-actions.js +3 -1
  7. package/dist/src/channel.js +0 -2
  8. package/dist/src/collect-message-media-paths.js +10 -1
  9. package/dist/src/friday-session.js +34 -10
  10. package/dist/src/history/normalize-message.js +22 -8
  11. package/dist/src/http/handlers/agent-config.js +10 -4
  12. package/dist/src/http/handlers/cancel.js +4 -2
  13. package/dist/src/http/handlers/device-approve.js +3 -1
  14. package/dist/src/http/handlers/files-download.js +6 -8
  15. package/dist/src/http/handlers/files.js +1 -1
  16. package/dist/src/http/handlers/health.js +18 -4
  17. package/dist/src/http/handlers/history-messages.js +1 -1
  18. package/dist/src/http/handlers/history-sessions.js +5 -3
  19. package/dist/src/http/handlers/messages.js +25 -11
  20. package/dist/src/http/handlers/models-list.js +1 -1
  21. package/dist/src/http/handlers/nodes-approve.js +1 -6
  22. package/dist/src/http/handlers/plugin-info.js +1 -1
  23. package/dist/src/http/server.js +4 -2
  24. package/dist/src/link-preview/og-parse.js +3 -1
  25. package/dist/src/plugin-install-info.js +4 -1
  26. package/dist/src/session/session-manager.js +9 -3
  27. package/dist/src/session-usage-store.js +3 -1
  28. package/dist/src/skills-discovery.d.ts +5 -4
  29. package/dist/src/skills-discovery.js +27 -22
  30. package/dist/src/sse/offline-queue.js +4 -1
  31. package/dist/src/tool-catalog.js +2 -3
  32. package/dist/src/upgrade-runtime.d.ts +1 -1
  33. package/dist/src/version.js +3 -1
  34. package/index.ts +43 -35
  35. package/install.js +131 -43
  36. package/package.json +10 -1
  37. package/src/agent/abort-run.ts +2 -3
  38. package/src/agent/dispatch-bridge.ts +2 -1
  39. package/src/agent/media-bridge.ts +9 -2
  40. package/src/agent/node-pairing-bridge.ts +29 -15
  41. package/src/agent/run-usage-accumulator.ts +4 -2
  42. package/src/agent/subagent-registry.ts +0 -4
  43. package/src/agent-run-context-bridge.ts +3 -1
  44. package/src/channel-actions.test.ts +10 -4
  45. package/src/channel-actions.ts +3 -1
  46. package/src/channel.outbound.test.ts +18 -4
  47. package/src/channel.ts +121 -123
  48. package/src/collect-message-media-paths.ts +15 -6
  49. package/src/config.ts +1 -4
  50. package/src/e2e/agents-list.e2e.test.ts +9 -2
  51. package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
  52. package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
  53. package/src/e2e/auto-approve.integration.test.ts +13 -7
  54. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
  55. package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
  56. package/src/e2e/offline-replay.e2e.test.ts +17 -3
  57. package/src/e2e/send-text.e2e.test.ts +11 -2
  58. package/src/e2e/slash-commands.e2e.test.ts +5 -1
  59. package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
  60. package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
  61. package/src/e2e/subagent.e2e.test.ts +136 -53
  62. package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
  63. package/src/friday-session.forward-agent.test.ts +44 -12
  64. package/src/friday-session.ts +44 -20
  65. package/src/history/normalize-message.test.ts +35 -8
  66. package/src/history/normalize-message.ts +24 -12
  67. package/src/history/read-transcript.ts +1 -4
  68. package/src/http/handlers/agent-config.test.ts +10 -3
  69. package/src/http/handlers/agent-config.ts +22 -8
  70. package/src/http/handlers/agents-list.test.ts +1 -5
  71. package/src/http/handlers/cancel.test.ts +12 -3
  72. package/src/http/handlers/cancel.ts +4 -2
  73. package/src/http/handlers/device-approve.test.ts +12 -3
  74. package/src/http/handlers/device-approve.ts +33 -21
  75. package/src/http/handlers/files-download.ts +17 -13
  76. package/src/http/handlers/files.test.ts +8 -2
  77. package/src/http/handlers/files.ts +21 -7
  78. package/src/http/handlers/health.test.ts +43 -11
  79. package/src/http/handlers/health.ts +22 -6
  80. package/src/http/handlers/history-messages.test.ts +51 -9
  81. package/src/http/handlers/history-messages.ts +4 -1
  82. package/src/http/handlers/history-sessions.test.ts +46 -9
  83. package/src/http/handlers/history-sessions.ts +5 -3
  84. package/src/http/handlers/history-set-title.test.ts +14 -5
  85. package/src/http/handlers/link-preview.test.ts +57 -16
  86. package/src/http/handlers/link-preview.ts +4 -1
  87. package/src/http/handlers/messages.test.ts +12 -8
  88. package/src/http/handlers/messages.ts +57 -19
  89. package/src/http/handlers/models-list.ts +14 -8
  90. package/src/http/handlers/nodes-approve.test.ts +15 -4
  91. package/src/http/handlers/nodes-approve.ts +38 -40
  92. package/src/http/handlers/plugin-info.ts +5 -6
  93. package/src/http/handlers/plugin-upgrade.ts +4 -1
  94. package/src/http/handlers/sse.ts +3 -1
  95. package/src/http/server.ts +9 -6
  96. package/src/link-preview/og-parse.test.ts +6 -2
  97. package/src/link-preview/og-parse.ts +10 -3
  98. package/src/link-preview/preview-service.ts +4 -1
  99. package/src/link-preview/ssrf-guard.test.ts +72 -15
  100. package/src/link-preview/ssrf-guard.ts +2 -1
  101. package/src/media-fetch.test.ts +7 -2
  102. package/src/media-fetch.ts +1 -2
  103. package/src/openclaw.d.ts +16 -9
  104. package/src/plugin-install-info.ts +20 -9
  105. package/src/run-metadata.ts +2 -1
  106. package/src/session/session-manager.ts +19 -11
  107. package/src/session-usage-snapshot.ts +3 -1
  108. package/src/session-usage-store.ts +3 -1
  109. package/src/skills-discovery.test.ts +14 -10
  110. package/src/skills-discovery.ts +43 -27
  111. package/src/sse/emitter.test.ts +1 -1
  112. package/src/sse/emitter.ts +9 -3
  113. package/src/sse/offline-queue.ts +17 -8
  114. package/src/test-support/app-simulator.ts +17 -3
  115. package/src/test-support/mock-dispatch.ts +17 -4
  116. package/src/thinking-levels.ts +3 -1
  117. package/src/tool-catalog.ts +16 -7
  118. package/src/upgrade-runtime.ts +4 -2
  119. package/src/version.ts +5 -1
  120. 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
  }
@@ -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
  });
@@ -148,7 +148,9 @@ export function translateDeliverPayload(pl, kind, meta) {
148
148
  if (typeof meta?.modelName === "string" && meta.modelName.trim()) {
149
149
  nextFridayNext.modelName = meta.modelName.trim();
150
150
  }
151
- if (typeof meta?.totalTokens === "number" && Number.isFinite(meta.totalTokens) && meta.totalTokens > 0) {
151
+ if (typeof meta?.totalTokens === "number" &&
152
+ Number.isFinite(meta.totalTokens) &&
153
+ meta.totalTokens > 0) {
152
154
  nextFridayNext.totalTokens = Math.floor(meta.totalTokens);
153
155
  }
154
156
  if (typeof meta?.contextTokensUsed === "number" &&
@@ -221,10 +223,16 @@ function pickMetadataFromMessageLike(message) {
221
223
  ? usage.totalTokens
222
224
  : undefined) ??
223
225
  (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));
226
+ (typeof usage?.total_tokens === "number" && Number.isFinite(usage.total_tokens)
227
+ ? usage.total_tokens
228
+ : undefined);
229
+ const totalFromMessage = (typeof m.totalTokens === "number" && Number.isFinite(m.totalTokens)
230
+ ? m.totalTokens
231
+ : undefined) ??
232
+ (typeof m.total_tokens === "number" && Number.isFinite(m.total_tokens)
233
+ ? m.total_tokens
234
+ : undefined);
235
+ const totalTokens = Math.floor(totalFromUsage ?? totalFromMessage ?? 0);
228
236
  let contextTokensUsed;
229
237
  if (usage) {
230
238
  const ctx = contextTokensFromUsageRecord(usage);
@@ -232,8 +240,12 @@ function pickMetadataFromMessageLike(message) {
232
240
  contextTokensUsed = ctx;
233
241
  }
234
242
  }
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);
243
+ const ctxMaxRaw = (typeof m.contextWindow === "number" && Number.isFinite(m.contextWindow)
244
+ ? m.contextWindow
245
+ : undefined) ??
246
+ (typeof m.maxContextTokens === "number" && Number.isFinite(m.maxContextTokens)
247
+ ? m.maxContextTokens
248
+ : undefined);
237
249
  const contextWindowMax = typeof ctxMaxRaw === "number" && ctxMaxRaw > 0 ? Math.floor(ctxMaxRaw) : undefined;
238
250
  if (!modelName && !(totalTokens > 0) && !contextTokensUsed && !contextWindowMax)
239
251
  return null;
@@ -407,7 +419,8 @@ export async function handleMessages(req, res) {
407
419
  dispatcherOptions: {
408
420
  deliver: async (pl, info) => {
409
421
  let meta = getRunMetadata(runId);
410
- if (info.kind.toLowerCase() === "final" && !(meta?.modelName || typeof meta?.totalTokens === "number")) {
422
+ if (info.kind.toLowerCase() === "final" &&
423
+ !(meta?.modelName || typeof meta?.totalTokens === "number")) {
411
424
  const resolved = await resolveRunMetadataFromRuntimeSession(runtime, baseSessionKey);
412
425
  if (resolved) {
413
426
  setRunMetadata(runId, resolved);
@@ -462,9 +475,10 @@ export async function handleMessages(req, res) {
462
475
  // OpenClaw `pi-embedded-subscribe` gates `streamReasoning` on `typeof onReasoningStream === "function"`.
463
476
  // Without this, `emitReasoningStream` never runs and Friday SSE never sees `stream: "thinking"`.
464
477
  onReasoningStream: async (pl) => {
465
- const text = typeof pl === "object" && pl !== null && "text" in pl
466
- ? String(pl.text ?? "")
467
- : "";
478
+ const rawText = typeof pl === "object" && pl !== null && "text" in pl
479
+ ? pl.text
480
+ : undefined;
481
+ const text = typeof rawText === "string" ? rawText : "";
468
482
  log("REASONING_STREAM", normalizedDeviceId, runId, `textLen=${text.length}`);
469
483
  },
470
484
  onReasoningEnd: async () => {
@@ -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,
@@ -58,12 +58,7 @@ export async function handleNodesApprove(req, res) {
58
58
  if (pendingMatch) {
59
59
  const requestId = pendingMatch.requestId;
60
60
  log.info(`approving nodeId=${normalizedNodeId} requestId=${requestId}`);
61
- const callerScopes = [
62
- "operator.admin",
63
- "operator.pairing",
64
- "operator.read",
65
- "operator.write",
66
- ];
61
+ const callerScopes = ["operator.admin", "operator.pairing", "operator.read", "operator.write"];
67
62
  let approved;
68
63
  try {
69
64
  approved = await approveNodePairing(requestId, { callerScopes });
@@ -1,6 +1,6 @@
1
1
  import { extractBearerToken } from "../middleware/auth.js";
2
2
  import { PLUGIN_VERSION } from "../../version.js";
3
- import { fetchLatestVersion, getInstallSource, semverGreater, } from "../../plugin-install-info.js";
3
+ import { fetchLatestVersion, getInstallSource, semverGreater } from "../../plugin-install-info.js";
4
4
  export async function handlePluginInfo(req, res) {
5
5
  if (req.method !== "GET") {
6
6
  res.statusCode = 405;
@@ -65,7 +65,8 @@ async function handleFridayNextRoute(req, res) {
65
65
  if (req.method === "POST" && pathname === "/friday-next/nodes-approve") {
66
66
  return await handleNodesApprove(req, res);
67
67
  }
68
- if ((req.method === "PUT" || req.method === "GET") && pathname === "/friday-next/sessions/settings") {
68
+ if ((req.method === "PUT" || req.method === "GET") &&
69
+ pathname === "/friday-next/sessions/settings") {
69
70
  return await handleSessionsSettings(req, res);
70
71
  }
71
72
  if (req.method === "GET" && pathname === "/friday-next/models") {
@@ -106,7 +107,8 @@ async function handleFridayNextRoute(req, res) {
106
107
  return await handleHistoryMessages(req, res);
107
108
  }
108
109
  // Route: PUT /friday-next/sessions/title (sync app session name → server displayName)
109
- if ((req.method === "PUT" || req.method === "POST") && pathname === "/friday-next/sessions/title") {
110
+ if ((req.method === "PUT" || req.method === "POST") &&
111
+ pathname === "/friday-next/sessions/title") {
110
112
  return await handleHistorySetTitle(req, res);
111
113
  }
112
114
  // Route: GET /friday-next/link-preview?url=... (Open Graph metadata for preview cards)
@@ -78,7 +78,9 @@ export function parseOpenGraph(html, baseUrl) {
78
78
  let metaDescription = null;
79
79
  for (const match of slice.matchAll(META_TAG_RE)) {
80
80
  const tag = match[0];
81
- const key = (attributeValue(tag, "property") ?? attributeValue(tag, "name"))?.trim().toLowerCase();
81
+ const key = (attributeValue(tag, "property") ?? attributeValue(tag, "name"))
82
+ ?.trim()
83
+ .toLowerCase();
82
84
  if (!key)
83
85
  continue;
84
86
  const content = attributeValue(tag, "content");
@@ -87,7 +87,10 @@ export async function fetchLatestVersion(nowMs) {
87
87
  const controller = new AbortController();
88
88
  const timer = setTimeout(() => controller.abort(), 5000);
89
89
  try {
90
- const res = await fetch(`https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`, { signal: controller.signal, headers: { Accept: "application/json" } });
90
+ const res = await fetch(`https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`, {
91
+ signal: controller.signal,
92
+ headers: { Accept: "application/json" },
93
+ });
91
94
  if (res.ok) {
92
95
  const body = (await res.json());
93
96
  if (typeof body.version === "string" && body.version)