@syengup/friday-channel-next 0.1.30 → 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 (154) hide show
  1. package/README.md +8 -4
  2. package/dist/index.js +1 -1
  3. package/dist/src/agent/abort-run.d.ts +12 -1
  4. package/dist/src/agent/abort-run.js +24 -9
  5. package/dist/src/agent/dispatch-bridge.d.ts +1 -1
  6. package/dist/src/agent/media-bridge.d.ts +8 -1
  7. package/dist/src/agent/media-bridge.js +23 -2
  8. package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
  9. package/dist/src/agent/node-pairing-bridge.js +6 -2
  10. package/dist/src/agent/subagent-registry.js +0 -3
  11. package/dist/src/agent-forward-runtime.d.ts +15 -0
  12. package/dist/src/agent-forward-runtime.js +2 -0
  13. package/dist/src/agent-id.d.ts +8 -0
  14. package/dist/src/agent-id.js +21 -0
  15. package/dist/src/channel-actions.js +48 -15
  16. package/dist/src/channel.js +22 -3
  17. package/dist/src/collect-message-media-paths.js +10 -1
  18. package/dist/src/friday-session.js +34 -10
  19. package/dist/src/history/normalize-message.js +22 -8
  20. package/dist/src/http/handlers/agent-config.d.ts +27 -0
  21. package/dist/src/http/handlers/agent-config.js +188 -0
  22. package/dist/src/http/handlers/agent-files.d.ts +21 -0
  23. package/dist/src/http/handlers/agent-files.js +137 -0
  24. package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
  25. package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
  26. package/dist/src/http/handlers/agents-list.js +1 -19
  27. package/dist/src/http/handlers/cancel.js +14 -6
  28. package/dist/src/http/handlers/device-approve.js +3 -1
  29. package/dist/src/http/handlers/files-download.js +6 -8
  30. package/dist/src/http/handlers/files.d.ts +16 -0
  31. package/dist/src/http/handlers/files.js +81 -13
  32. package/dist/src/http/handlers/health.js +18 -4
  33. package/dist/src/http/handlers/history-messages.js +1 -1
  34. package/dist/src/http/handlers/history-sessions.js +5 -3
  35. package/dist/src/http/handlers/messages.js +33 -14
  36. package/dist/src/http/handlers/models-list.d.ts +5 -0
  37. package/dist/src/http/handlers/models-list.js +9 -1
  38. package/dist/src/http/handlers/nodes-approve.js +1 -6
  39. package/dist/src/http/handlers/plugin-info.js +1 -1
  40. package/dist/src/http/handlers/sessions-settings.js +15 -10
  41. package/dist/src/http/server.js +27 -2
  42. package/dist/src/link-preview/og-parse.js +3 -1
  43. package/dist/src/link-preview/ssrf-guard.js +6 -2
  44. package/dist/src/media-fetch.js +4 -1
  45. package/dist/src/plugin-install-info.js +4 -1
  46. package/dist/src/session/session-manager.js +9 -3
  47. package/dist/src/session-usage-store.js +3 -1
  48. package/dist/src/skills-discovery.d.ts +59 -0
  49. package/dist/src/skills-discovery.js +252 -0
  50. package/dist/src/sse/offline-queue.js +4 -1
  51. package/dist/src/thinking-levels.d.ts +21 -0
  52. package/dist/src/thinking-levels.js +48 -0
  53. package/dist/src/tool-catalog.d.ts +53 -0
  54. package/dist/src/tool-catalog.js +191 -0
  55. package/dist/src/upgrade-runtime.d.ts +1 -1
  56. package/dist/src/version.js +4 -2
  57. package/index.ts +43 -35
  58. package/install.js +131 -43
  59. package/package.json +10 -1
  60. package/src/agent/abort-run.ts +23 -8
  61. package/src/agent/dispatch-bridge.ts +2 -1
  62. package/src/agent/media-bridge.test.ts +71 -0
  63. package/src/agent/media-bridge.ts +30 -1
  64. package/src/agent/node-pairing-bridge.ts +29 -15
  65. package/src/agent/run-usage-accumulator.ts +4 -2
  66. package/src/agent/subagent-registry.ts +0 -4
  67. package/src/agent-forward-runtime.ts +11 -0
  68. package/src/agent-id.ts +24 -0
  69. package/src/agent-run-context-bridge.ts +3 -1
  70. package/src/channel-actions.test.ts +57 -4
  71. package/src/channel-actions.ts +41 -15
  72. package/src/channel.lifecycle.test.ts +41 -0
  73. package/src/channel.outbound.test.ts +18 -4
  74. package/src/channel.ts +140 -120
  75. package/src/collect-message-media-paths.ts +15 -6
  76. package/src/config.ts +1 -4
  77. package/src/e2e/agents-list.e2e.test.ts +9 -2
  78. package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
  79. package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
  80. package/src/e2e/auto-approve.integration.test.ts +13 -7
  81. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
  82. package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
  83. package/src/e2e/offline-replay.e2e.test.ts +17 -3
  84. package/src/e2e/send-text.e2e.test.ts +11 -2
  85. package/src/e2e/slash-commands.e2e.test.ts +5 -1
  86. package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
  87. package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
  88. package/src/e2e/subagent.e2e.test.ts +136 -53
  89. package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
  90. package/src/friday-session.forward-agent.test.ts +44 -12
  91. package/src/friday-session.ts +44 -20
  92. package/src/history/normalize-message.test.ts +35 -8
  93. package/src/history/normalize-message.ts +24 -12
  94. package/src/history/read-transcript.ts +1 -4
  95. package/src/http/handlers/agent-config.test.ts +212 -0
  96. package/src/http/handlers/agent-config.ts +232 -0
  97. package/src/http/handlers/agent-files.test.ts +136 -0
  98. package/src/http/handlers/agent-files.ts +149 -0
  99. package/src/http/handlers/agent-tools-catalog.ts +42 -0
  100. package/src/http/handlers/agents-list.test.ts +1 -5
  101. package/src/http/handlers/agents-list.ts +1 -22
  102. package/src/http/handlers/cancel.test.ts +23 -4
  103. package/src/http/handlers/cancel.ts +14 -6
  104. package/src/http/handlers/device-approve.test.ts +12 -3
  105. package/src/http/handlers/device-approve.ts +33 -21
  106. package/src/http/handlers/files-download.ts +17 -13
  107. package/src/http/handlers/files.test.ts +120 -0
  108. package/src/http/handlers/files.ts +115 -17
  109. package/src/http/handlers/health.test.ts +43 -11
  110. package/src/http/handlers/health.ts +22 -6
  111. package/src/http/handlers/history-messages.test.ts +51 -9
  112. package/src/http/handlers/history-messages.ts +4 -1
  113. package/src/http/handlers/history-sessions.test.ts +46 -9
  114. package/src/http/handlers/history-sessions.ts +5 -3
  115. package/src/http/handlers/history-set-title.test.ts +14 -5
  116. package/src/http/handlers/link-preview.test.ts +57 -16
  117. package/src/http/handlers/link-preview.ts +4 -1
  118. package/src/http/handlers/messages.test.ts +12 -8
  119. package/src/http/handlers/messages.ts +64 -21
  120. package/src/http/handlers/models-list.test.ts +114 -0
  121. package/src/http/handlers/models-list.ts +26 -8
  122. package/src/http/handlers/nodes-approve.test.ts +15 -4
  123. package/src/http/handlers/nodes-approve.ts +38 -40
  124. package/src/http/handlers/plugin-info.ts +5 -6
  125. package/src/http/handlers/plugin-upgrade.ts +4 -1
  126. package/src/http/handlers/sessions-settings.ts +16 -11
  127. package/src/http/handlers/sse.ts +3 -1
  128. package/src/http/server.ts +33 -6
  129. package/src/link-preview/og-parse.test.ts +6 -2
  130. package/src/link-preview/og-parse.ts +10 -3
  131. package/src/link-preview/preview-service.ts +4 -1
  132. package/src/link-preview/ssrf-guard.test.ts +78 -16
  133. package/src/link-preview/ssrf-guard.ts +7 -2
  134. package/src/media-fetch.test.ts +8 -3
  135. package/src/media-fetch.ts +5 -3
  136. package/src/openclaw.d.ts +41 -10
  137. package/src/plugin-install-info.ts +20 -9
  138. package/src/run-metadata.ts +2 -1
  139. package/src/session/session-manager.ts +19 -11
  140. package/src/session-usage-snapshot.ts +3 -1
  141. package/src/session-usage-store.ts +3 -1
  142. package/src/skills-discovery.test.ts +152 -0
  143. package/src/skills-discovery.ts +264 -0
  144. package/src/sse/emitter.test.ts +1 -1
  145. package/src/sse/emitter.ts +9 -3
  146. package/src/sse/offline-queue.ts +17 -8
  147. package/src/test-support/app-simulator.ts +17 -3
  148. package/src/test-support/mock-dispatch.ts +17 -4
  149. package/src/thinking-levels.test.ts +143 -0
  150. package/src/thinking-levels.ts +70 -0
  151. package/src/tool-catalog.ts +261 -0
  152. package/src/upgrade-runtime.ts +4 -2
  153. package/src/version.ts +6 -2
  154. package/tsconfig.json +1 -1
@@ -1,7 +1,19 @@
1
1
  import { existsSync, readdirSync, realpathSync } from "node:fs";
2
2
  import { delimiter, dirname, join } from "node:path";
3
3
 
4
- let cache: { listNodePairing: Function; approveNodePairing: Function } | null = null;
4
+ // Results come from the untyped OpenClaw dist module, so the resolved shapes are
5
+ // `any` at this host boundary — callers read dynamic fields (.pending, .status, …).
6
+ type ListNodePairingFn = () => Promise<any>;
7
+ type ApproveNodePairingFn = (
8
+ requestId: string,
9
+ options: { callerScopes?: unknown },
10
+ ) => Promise<any>;
11
+ type NodePairingModule = {
12
+ listNodePairing: ListNodePairingFn;
13
+ approveNodePairing: ApproveNodePairingFn;
14
+ };
15
+
16
+ let cache: NodePairingModule | null = null;
5
17
 
6
18
  function resolveOpenClawDistFromPath(): string | null {
7
19
  // Walk PATH looking for the openclaw binary, then resolve its real
@@ -16,7 +28,9 @@ function resolveOpenClawDistFromPath(): string | null {
16
28
  const dist = join(dirname(real), "dist");
17
29
  readdirSync(dist);
18
30
  return dist;
19
- } catch {}
31
+ } catch {
32
+ // Not a real dist dir — keep walking PATH.
33
+ }
20
34
  }
21
35
  return null;
22
36
  }
@@ -43,15 +57,17 @@ function resolveOpenClawDist(): string {
43
57
  ].filter((v): v is string => typeof v === "string" && v.length > 0);
44
58
 
45
59
  for (const root of candidates) {
46
- try { readdirSync(root); return root; } catch {}
60
+ try {
61
+ readdirSync(root);
62
+ return root;
63
+ } catch {
64
+ // Candidate dir doesn't exist — try the next one.
65
+ }
47
66
  }
48
67
  throw new Error("OpenClaw dist directory not found. Set OPENCLAW_DIST env var.");
49
68
  }
50
69
 
51
- export async function loadNodePairingModule(): Promise<{
52
- listNodePairing: Function;
53
- approveNodePairing: Function;
54
- }> {
70
+ export async function loadNodePairingModule(): Promise<NodePairingModule> {
55
71
  if (cache) return cache;
56
72
  const dist = resolveOpenClawDist();
57
73
  const file = readdirSync(dist).find(
@@ -63,12 +79,13 @@ export async function loadNodePairingModule(): Promise<{
63
79
  // bundled module uses `export { listNodePairing as r, … }`. Resolve the
64
80
  // correct functions by Function.name, which preserves the original name.
65
81
  const mod = await import(join(dist, file));
66
- let listNodePairing: Function | undefined;
67
- let approveNodePairing: Function | undefined;
82
+ let listNodePairing: ListNodePairingFn | undefined;
83
+ let approveNodePairing: ApproveNodePairingFn | undefined;
68
84
  for (const value of Object.values(mod)) {
69
85
  if (typeof value === "function") {
70
- if (value.name === "listNodePairing") listNodePairing = value;
71
- else if (value.name === "approveNodePairing") approveNodePairing = value;
86
+ if (value.name === "listNodePairing") listNodePairing = value as ListNodePairingFn;
87
+ else if (value.name === "approveNodePairing")
88
+ approveNodePairing = value as ApproveNodePairingFn;
72
89
  }
73
90
  }
74
91
  if (!listNodePairing || !approveNodePairing) {
@@ -79,9 +96,6 @@ export async function loadNodePairingModule(): Promise<{
79
96
  }
80
97
 
81
98
  /** Vitest-only: inject mock pairing functions. */
82
- export function __setMockNodePairingForTests(mock: {
83
- listNodePairing: Function;
84
- approveNodePairing: Function;
85
- }): void {
99
+ export function __setMockNodePairingForTests(mock: NodePairingModule): void {
86
100
  cache = mock;
87
101
  }
@@ -39,8 +39,10 @@ export function accumulateRunUsage(
39
39
  const entry = ensure(runId);
40
40
  if (typeof usage.input === "number" && usage.input > 0) entry.input += usage.input;
41
41
  if (typeof usage.output === "number" && usage.output > 0) entry.output += usage.output;
42
- if (typeof usage.cacheRead === "number" && usage.cacheRead > 0) entry.cacheRead += usage.cacheRead;
43
- if (typeof usage.cacheWrite === "number" && usage.cacheWrite > 0) entry.cacheWrite += usage.cacheWrite;
42
+ if (typeof usage.cacheRead === "number" && usage.cacheRead > 0)
43
+ entry.cacheRead += usage.cacheRead;
44
+ if (typeof usage.cacheWrite === "number" && usage.cacheWrite > 0)
45
+ entry.cacheWrite += usage.cacheWrite;
44
46
  if (typeof usage.total === "number" && usage.total > 0) entry.total += usage.total;
45
47
  if (model && model.trim()) entry.model = model.trim();
46
48
  if (provider && provider.trim()) entry.provider = provider.trim();
@@ -39,10 +39,6 @@ export function registerSessionKeyForRun(sessionKey: string, runId: string): voi
39
39
  sessionKeyToRunId.set(sessionKey, runId);
40
40
  }
41
41
 
42
- function resolveRunIdForSessionKey(sessionKey: string): string | undefined {
43
- return sessionKeyToRunId.get(sessionKey);
44
- }
45
-
46
42
  /**
47
43
  * Parse OpenClaw announce compound runId:
48
44
  * announce:v<version>:<sessionKey>:<bareRunId>
@@ -16,6 +16,15 @@ export type FridayAgentForwardRuntime = {
16
16
  }) => Promise<Record<string, unknown> | null>;
17
17
  /** Resolves an agent's workspace dir — used to read IDENTITY.md for the name fallback. */
18
18
  resolveAgentWorkspaceDir?: (cfg: unknown, agentId: string) => string;
19
+ /**
20
+ * Resolves the thinking-level options + default for a provider/model pair, driven by the running
21
+ * gateway's provider plugins + model catalog (so the option set varies per model). Optional: older
22
+ * gateways don't expose it, in which case callers fall back to the base five levels.
23
+ */
24
+ resolveThinkingPolicy?: (params: { provider?: string | null; model?: string | null }) => {
25
+ levels: Array<{ id: string; label: string }>;
26
+ defaultLevel?: string | null;
27
+ };
19
28
  getConfig: () => unknown;
20
29
  };
21
30
 
@@ -30,6 +39,8 @@ export function setFridayAgentForwardRuntime(api: OpenClawPluginApi): void {
30
39
  .updateSessionStoreEntry as FridayAgentForwardRuntime["updateSessionStoreEntry"],
31
40
  resolveAgentWorkspaceDir: (api.runtime.agent as Record<string, unknown>)
32
41
  .resolveAgentWorkspaceDir as FridayAgentForwardRuntime["resolveAgentWorkspaceDir"],
42
+ resolveThinkingPolicy: (api.runtime.agent as Record<string, unknown>)
43
+ .resolveThinkingPolicy as FridayAgentForwardRuntime["resolveThinkingPolicy"],
33
44
  getConfig: () => api.runtime.config.current(),
34
45
  };
35
46
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Agent id normalization shared across handlers.
3
+ *
4
+ * Mirror of OpenClaw's `normalizeAgentId` (src/routing/session-key.ts): trim,
5
+ * lowercase, keep path/shell-safe. Empty → "main".
6
+ */
7
+
8
+ export const DEFAULT_AGENT_ID = "main";
9
+
10
+ /** Agent ids already in path/shell-safe form skip the slug rewrite below. */
11
+ const SAFE_AGENT_ID = /^[a-z0-9][a-z0-9_-]*$/;
12
+
13
+ export function normalizeAgentId(value: unknown): string {
14
+ const trimmed = typeof value === "string" ? value.trim() : "";
15
+ if (!trimmed) return DEFAULT_AGENT_ID;
16
+ const lowered = trimmed.toLowerCase();
17
+ if (SAFE_AGENT_ID.test(lowered)) return lowered;
18
+ return (
19
+ lowered
20
+ .replace(/[^a-z0-9_-]+/g, "-")
21
+ .replace(/^-+|-+$/g, "")
22
+ .slice(0, 64) || DEFAULT_AGENT_ID
23
+ );
24
+ }
@@ -26,7 +26,9 @@ function getAgentEventState(): AgentEventStateLike | undefined {
26
26
  return { runContextById };
27
27
  }
28
28
 
29
- export function getOpenClawAgentRunContext(runId: string): OpenClawAgentRunContextBridge | undefined {
29
+ export function getOpenClawAgentRunContext(
30
+ runId: string,
31
+ ): OpenClawAgentRunContextBridge | undefined {
30
32
  if (!runId) return undefined;
31
33
  return getAgentEventState()?.runContextById.get(runId);
32
34
  }
@@ -6,7 +6,11 @@ import { handleMessageAction } from "./channel-actions.js";
6
6
  import { sseEmitter } from "./sse/emitter.js";
7
7
  import { setOfflineQueueBaseDirForTest } from "./sse/offline-queue.js";
8
8
  import { registerRunRoute } from "./run-metadata.js";
9
- import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "./test-support/mock-runtime.js";
9
+ import {
10
+ createTempHistoryDir,
11
+ removeTempHistoryDir,
12
+ setMockRuntime,
13
+ } from "./test-support/mock-runtime.js";
10
14
 
11
15
  /**
12
16
  * The `message` tool's `action=send` is handled here (NOT via outbound.sendText/sendMedia).
@@ -107,8 +111,8 @@ describe("channel-actions handleSend sessionKey routing", () => {
107
111
  // 8-byte PNG magic header so saveMediaBuffer's magic-byte detection recognizes an image.
108
112
  const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]);
109
113
  const directLink = "https://picsum.photos/600/400";
110
- const fetchMock = vi.fn(async () =>
111
- new Response(pngBytes, { status: 200, headers: { "content-type": "image/png" } }),
114
+ const fetchMock = vi.fn(
115
+ async () => new Response(pngBytes, { status: 200, headers: { "content-type": "image/png" } }),
112
116
  );
113
117
  vi.stubGlobal("fetch", fetchMock);
114
118
 
@@ -163,6 +167,53 @@ describe("channel-actions handleSend sessionKey routing", () => {
163
167
  expect(media?.data.sessionKey).toBe(appSession);
164
168
  });
165
169
 
170
+ it("send with a multi-file mediaUrls[] emits one op:media per file (same runId)", async () => {
171
+ // The agent's `message` tool call with a structured `attachments[]` array is flattened by the
172
+ // OpenClaw core into `params.mediaUrls` (with `media` set to the first entry for back-compat).
173
+ // handleSend must emit one outbound op:media per file, not just the first.
174
+ const deviceId = "DEV-ACT-MULTI";
175
+ const runId = "run-act-multi";
176
+ const appSession = "agent:operator:friday:direct:dev-act-multi:1780561609";
177
+ registerRunRoute({ runId, deviceId, sessionKey: appSession });
178
+ sseEmitter.trackDeviceForRun(deviceId, runId);
179
+ const res = connect(deviceId);
180
+
181
+ const files = ["A.swift", "B.swift", "C.swift"].map((name) => {
182
+ const p = path.join(historyDir, name);
183
+ fs.writeFileSync(p, `// ${name}`);
184
+ return p;
185
+ });
186
+
187
+ const result = await handleMessageAction({
188
+ action: "send",
189
+ params: {
190
+ to: deviceId,
191
+ message: "三个文件发给你",
192
+ media: files[0], // core sets `media` to the first entry
193
+ mediaUrls: files, // ...and the full list here
194
+ },
195
+ sessionKey: "agent:operator:main",
196
+ });
197
+
198
+ expect((result as { ok?: boolean }).ok).toBe(true);
199
+ const mediaFrames = parseOutboundFrames(res).filter(
200
+ (f) => f.type === "outbound" && f.data.op === "media",
201
+ );
202
+ expect(mediaFrames).toHaveLength(3);
203
+ // All media events share the send's runId so the app groups them into one assistant message.
204
+ const runIds = new Set(mediaFrames.map((f) => f.data.runId));
205
+ expect(runIds.size).toBe(1);
206
+ // Original filenames preserved (one per file, no duplicates, no drops).
207
+ const names = mediaFrames
208
+ .map((f) => (f.data.ctx as { originalMediaUrl?: string })?.originalMediaUrl)
209
+ .map((p) => (p ? path.basename(p) : ""));
210
+ expect(new Set(names)).toEqual(new Set(["A.swift", "B.swift", "C.swift"]));
211
+ for (const f of mediaFrames) {
212
+ expect(String(f.data.mediaUrl)).toMatch(/^\/friday-next\/files\//);
213
+ expect(f.data.sessionKey).toBe(appSession);
214
+ }
215
+ });
216
+
166
217
  it("falls back to ctx.sessionKey when the device has no active run-route", async () => {
167
218
  const deviceId = "DEV-ACT-2";
168
219
  const res = connect(deviceId);
@@ -173,7 +224,9 @@ describe("channel-actions handleSend sessionKey routing", () => {
173
224
  sessionKey: "agent:operator:friday:direct:fallback-session",
174
225
  });
175
226
 
176
- const text = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
227
+ const text = parseOutboundFrames(res).find(
228
+ (f) => f.type === "outbound" && f.data.op === "text",
229
+ );
177
230
  expect(text?.data.sessionKey).toBe("agent:operator:friday:direct:fallback-session");
178
231
  });
179
232
  });
@@ -36,6 +36,21 @@ function pickString(params: Record<string, unknown>, keys: string[]): string {
36
36
  return "";
37
37
  }
38
38
 
39
+ function pickStringArray(params: Record<string, unknown>, key: string): string[] {
40
+ const v = params[key];
41
+ if (!Array.isArray(v)) return [];
42
+ const out: string[] = [];
43
+ const seen = new Set<string>();
44
+ for (const entry of v) {
45
+ if (typeof entry !== "string") continue;
46
+ const trimmed = entry.trim();
47
+ if (!trimmed || seen.has(trimmed)) continue;
48
+ seen.add(trimmed);
49
+ out.push(trimmed);
50
+ }
51
+ return out;
52
+ }
53
+
39
54
  async function readMediaFile(
40
55
  mediaPath: string,
41
56
  ctx: MessageActionCtx,
@@ -49,7 +64,9 @@ async function readMediaFile(
49
64
  if (buffer?.length) {
50
65
  return { buffer, mimeType: guessMimeType(mediaPath) };
51
66
  }
52
- } catch { /* fall through */ }
67
+ } catch {
68
+ /* fall through */
69
+ }
53
70
  }
54
71
  try {
55
72
  const buffer = fs.readFileSync(mediaPath);
@@ -101,26 +118,35 @@ async function handleSend(ctx: MessageActionCtx): Promise<unknown> {
101
118
  );
102
119
  }
103
120
 
104
- // Resolve media from an inline base64 buffer or a path/url reference. (`attachments[]` arrays are
105
- // normalized by the OpenClaw core and arrive via outbound.sendMedia, so they're not handled here.)
106
- let media: { buffer: Buffer; mimeType: string } | null = null;
107
- let originalMediaUrl = "";
108
- if (inlineBase64) {
109
- media = decodeBase64Media(
121
+ // Resolve the media to send. A `message` tool call with a structured `attachments[]` array is
122
+ // flattened by the OpenClaw core into `params.mediaUrls` (with `media` set to the first entry for
123
+ // back-compat), so prefer the full list; fall back to a single inline base64 buffer or a
124
+ // path/url reference for the single-attachment / direct-link / buffer cases.
125
+ const mediaSources: { buffer: Buffer; mimeType: string; originalMediaUrl: string }[] = [];
126
+ const mediaUrls = pickStringArray(ctx.params, "mediaUrls");
127
+ if (mediaUrls.length > 0) {
128
+ for (const ref of mediaUrls) {
129
+ const loaded = await readMediaFile(ref, ctx);
130
+ if (loaded) mediaSources.push({ ...loaded, originalMediaUrl: ref });
131
+ }
132
+ } else if (inlineBase64) {
133
+ const loaded = decodeBase64Media(
110
134
  inlineBase64,
111
135
  mediaMimeHint || (filename ? guessMimeType(filename) : ""),
112
136
  );
113
- originalMediaUrl = filename || "inline-buffer";
137
+ if (loaded) mediaSources.push({ ...loaded, originalMediaUrl: filename || "inline-buffer" });
114
138
  } else if (mediaPath) {
115
- media = await readMediaFile(mediaPath, ctx);
116
- originalMediaUrl = mediaPath;
139
+ const loaded = await readMediaFile(mediaPath, ctx);
140
+ if (loaded) mediaSources.push({ ...loaded, originalMediaUrl: mediaPath });
117
141
  }
118
142
 
119
- // Send media via SSE outbound
120
- if (media) {
143
+ // Send each media via its own SSE outbound event. They all share this send's `runId` so the app
144
+ // groups them into a single assistant message (first → attachment, rest → extra attachments).
145
+ if (mediaSources.length > 0) {
121
146
  const { saveMediaBuffer } = await import("openclaw/plugin-sdk/media-store");
122
- const saved = await saveMediaBuffer(media.buffer, media.mimeType, "inbound");
123
- if (saved.id) {
147
+ for (const source of mediaSources) {
148
+ const saved = await saveMediaBuffer(source.buffer, source.mimeType, "inbound");
149
+ if (!saved.id) continue;
124
150
  const publicUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
125
151
  sseEmitter.broadcast(
126
152
  {
@@ -134,7 +160,7 @@ async function handleSend(ctx: MessageActionCtx): Promise<unknown> {
134
160
  audioAsVoice: false,
135
161
  caption: caption || text,
136
162
  mediaUrl: publicUrl,
137
- ctx: { to, text: caption || text, originalMediaUrl },
163
+ ctx: { to, text: caption || text, originalMediaUrl: source.originalMediaUrl },
138
164
  },
139
165
  },
140
166
  to,
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fridayNextChannelPlugin } from "./channel.js";
3
+
4
+ /**
5
+ * friday-next is a passive HTTP+SSE channel whose routes live on the shared gateway server.
6
+ * It still MUST keep its account lifecycle pending (running:true) so the core health-monitor
7
+ * does not poll it as "stopped" and restart it every few minutes. A stopped account drops out
8
+ * of the deliverable-channel registry, so an agent `message` send landing in that window fails
9
+ * with `Unknown channel: friday-next`. See gateway.startAccount keep-alive in channel.ts.
10
+ */
11
+ describe("friday-next channel gateway lifecycle", () => {
12
+ it("exposes a startAccount keep-alive that stays pending until abort", async () => {
13
+ const gateway = (fridayNextChannelPlugin as { gateway?: { startAccount?: unknown } }).gateway;
14
+ expect(gateway?.startAccount).toBeTypeOf("function");
15
+
16
+ const startAccount = gateway!.startAccount as (ctx: {
17
+ accountId: string;
18
+ abortSignal: AbortSignal;
19
+ }) => Promise<unknown>;
20
+
21
+ const controller = new AbortController();
22
+ const started = startAccount({ accountId: "default", abortSignal: controller.signal });
23
+
24
+ // Must stay pending while the account is alive (so the core keeps running:true).
25
+ let settled = false;
26
+ void started.then(
27
+ () => {
28
+ settled = true;
29
+ },
30
+ () => {
31
+ settled = true;
32
+ },
33
+ );
34
+ await new Promise((resolve) => setTimeout(resolve, 20));
35
+ expect(settled).toBe(false);
36
+
37
+ // Aborting (reload / shutdown) resolves it cleanly.
38
+ controller.abort();
39
+ await expect(started).resolves.toBeUndefined();
40
+ });
41
+ });
@@ -6,7 +6,11 @@ import { fridayNextChannelPlugin } from "./channel.js";
6
6
  import { sseEmitter } from "./sse/emitter.js";
7
7
  import { setOfflineQueueBaseDirForTest } from "./sse/offline-queue.js";
8
8
  import { registerRunRoute } from "./run-metadata.js";
9
- import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "./test-support/mock-runtime.js";
9
+ import {
10
+ createTempHistoryDir,
11
+ removeTempHistoryDir,
12
+ setMockRuntime,
13
+ } from "./test-support/mock-runtime.js";
10
14
 
11
15
  /**
12
16
  * Outbound (message-tool send) must route to the session that started the run.
@@ -95,11 +99,19 @@ describe("friday-next channel outbound sessionKey routing", () => {
95
99
  it("run-route wins over ctx sessionKey (ctx carries the agent's base/main session, not the active app session)", async () => {
96
100
  const deviceId = "DEV-TEXT-2";
97
101
  const runId = "run-text-2";
98
- registerRunRoute({ runId, deviceId, sessionKey: "agent:operator:friday-next:direct:route-session" });
102
+ registerRunRoute({
103
+ runId,
104
+ deviceId,
105
+ sessionKey: "agent:operator:friday-next:direct:route-session",
106
+ });
99
107
  sseEmitter.trackDeviceForRun(deviceId, runId);
100
108
  const res = connect(deviceId);
101
109
 
102
- await outbound.sendText({ to: deviceId, text: "hi", requesterSessionKey: "agent:operator:main" });
110
+ await outbound.sendText({
111
+ to: deviceId,
112
+ text: "hi",
113
+ requesterSessionKey: "agent:operator:main",
114
+ });
103
115
 
104
116
  const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
105
117
  expect(evt?.data.sessionKey).toBe("agent:operator:friday-next:direct:route-session");
@@ -129,7 +141,9 @@ describe("friday-next channel outbound sessionKey routing", () => {
129
141
 
130
142
  await outbound.sendMedia({ to: deviceId, text: "caption", mediaUrl: mediaFile });
131
143
 
132
- const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "media");
144
+ const evt = parseOutboundFrames(res).find(
145
+ (f) => f.type === "outbound" && f.data.op === "media",
146
+ );
133
147
  expect(evt).toBeDefined();
134
148
  expect(evt?.data.sessionKey).toBe(sessionKey);
135
149
  expect(evt?.data.deviceId).toBe(deviceId);