@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.
- package/dist/index.js +1 -1
- package/dist/src/agent/dispatch-bridge.d.ts +1 -1
- package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
- package/dist/src/agent/node-pairing-bridge.js +6 -2
- package/dist/src/agent/operator-scope.d.ts +19 -0
- package/dist/src/agent/operator-scope.js +54 -0
- package/dist/src/agent/subagent-registry.js +0 -3
- package/dist/src/channel-actions.js +3 -1
- package/dist/src/channel.js +0 -2
- package/dist/src/collect-message-media-paths.js +10 -1
- package/dist/src/friday-session.js +34 -10
- package/dist/src/history/normalize-message.js +22 -8
- package/dist/src/http/handlers/agent-config.js +10 -4
- package/dist/src/http/handlers/cancel.js +4 -2
- package/dist/src/http/handlers/device-approve.js +3 -1
- package/dist/src/http/handlers/files-download.js +6 -8
- package/dist/src/http/handlers/files.js +1 -1
- package/dist/src/http/handlers/health.js +18 -4
- package/dist/src/http/handlers/history-messages.js +1 -1
- package/dist/src/http/handlers/history-sessions.js +5 -3
- package/dist/src/http/handlers/messages.js +34 -11
- package/dist/src/http/handlers/models-list.js +1 -1
- package/dist/src/http/handlers/nodes-approve.js +1 -6
- package/dist/src/http/handlers/plugin-info.js +1 -1
- package/dist/src/http/server.js +4 -2
- package/dist/src/link-preview/og-parse.js +3 -1
- package/dist/src/plugin-install-info.js +4 -1
- package/dist/src/session/session-manager.js +9 -3
- package/dist/src/session-usage-store.js +3 -1
- package/dist/src/skills-discovery.d.ts +5 -4
- package/dist/src/skills-discovery.js +27 -22
- package/dist/src/sse/offline-queue.js +4 -1
- package/dist/src/tool-catalog.js +2 -3
- package/dist/src/upgrade-runtime.d.ts +1 -1
- package/dist/src/version.js +3 -1
- package/index.ts +43 -35
- package/install.js +131 -43
- package/package.json +10 -1
- package/src/agent/abort-run.ts +2 -3
- package/src/agent/dispatch-bridge.ts +2 -1
- package/src/agent/media-bridge.ts +9 -2
- package/src/agent/node-pairing-bridge.ts +29 -15
- package/src/agent/operator-scope.test.ts +66 -0
- package/src/agent/operator-scope.ts +63 -0
- package/src/agent/run-usage-accumulator.ts +4 -2
- package/src/agent/subagent-registry.ts +0 -4
- package/src/agent-run-context-bridge.ts +3 -1
- package/src/channel-actions.test.ts +10 -4
- package/src/channel-actions.ts +3 -1
- package/src/channel.outbound.test.ts +18 -4
- package/src/channel.ts +121 -123
- package/src/collect-message-media-paths.ts +15 -6
- package/src/config.ts +1 -4
- package/src/e2e/agents-list.e2e.test.ts +9 -2
- package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
- package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
- package/src/e2e/auto-approve.integration.test.ts +13 -7
- package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
- package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
- package/src/e2e/offline-replay.e2e.test.ts +17 -3
- package/src/e2e/send-text.e2e.test.ts +11 -2
- package/src/e2e/slash-commands.e2e.test.ts +5 -1
- package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
- package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
- package/src/e2e/subagent.e2e.test.ts +136 -53
- package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
- package/src/friday-session.forward-agent.test.ts +44 -12
- package/src/friday-session.ts +44 -20
- package/src/history/normalize-message.test.ts +35 -8
- package/src/history/normalize-message.ts +24 -12
- package/src/history/read-transcript.ts +1 -4
- package/src/http/handlers/agent-config.test.ts +10 -3
- package/src/http/handlers/agent-config.ts +22 -8
- package/src/http/handlers/agents-list.test.ts +1 -5
- package/src/http/handlers/cancel.test.ts +12 -3
- package/src/http/handlers/cancel.ts +4 -2
- package/src/http/handlers/device-approve.test.ts +12 -3
- package/src/http/handlers/device-approve.ts +33 -21
- package/src/http/handlers/files-download.ts +17 -13
- package/src/http/handlers/files.test.ts +8 -2
- package/src/http/handlers/files.ts +21 -7
- package/src/http/handlers/health.test.ts +43 -11
- package/src/http/handlers/health.ts +22 -6
- package/src/http/handlers/history-messages.test.ts +51 -9
- package/src/http/handlers/history-messages.ts +4 -1
- package/src/http/handlers/history-sessions.test.ts +46 -9
- package/src/http/handlers/history-sessions.ts +5 -3
- package/src/http/handlers/history-set-title.test.ts +14 -5
- package/src/http/handlers/link-preview.test.ts +57 -16
- package/src/http/handlers/link-preview.ts +4 -1
- package/src/http/handlers/messages.test.ts +12 -8
- package/src/http/handlers/messages.ts +67 -19
- package/src/http/handlers/models-list.ts +14 -8
- package/src/http/handlers/nodes-approve.test.ts +15 -4
- package/src/http/handlers/nodes-approve.ts +38 -40
- package/src/http/handlers/plugin-info.ts +5 -6
- package/src/http/handlers/plugin-upgrade.ts +4 -1
- package/src/http/handlers/sse.ts +3 -1
- package/src/http/server.ts +9 -6
- package/src/link-preview/og-parse.test.ts +6 -2
- package/src/link-preview/og-parse.ts +10 -3
- package/src/link-preview/preview-service.ts +4 -1
- package/src/link-preview/ssrf-guard.test.ts +72 -15
- package/src/link-preview/ssrf-guard.ts +2 -1
- package/src/media-fetch.test.ts +7 -2
- package/src/media-fetch.ts +1 -2
- package/src/openclaw.d.ts +26 -9
- package/src/plugin-install-info.ts +20 -9
- package/src/run-metadata.ts +2 -1
- package/src/session/session-manager.ts +19 -11
- package/src/session-usage-snapshot.ts +3 -1
- package/src/session-usage-store.ts +3 -1
- package/src/skills-discovery.test.ts +14 -10
- package/src/skills-discovery.ts +43 -27
- package/src/sse/emitter.test.ts +1 -1
- package/src/sse/emitter.ts +9 -3
- package/src/sse/offline-queue.ts +17 -8
- package/src/test-support/app-simulator.ts +17 -3
- package/src/test-support/mock-dispatch.ts +17 -4
- package/src/thinking-levels.ts +3 -1
- package/src/tool-catalog.ts +16 -7
- package/src/upgrade-runtime.ts +4 -2
- package/src/version.ts +5 -1
- 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) =>
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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>
|
package/dist/src/channel.js
CHANGED
|
@@ -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 [
|
|
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) ||
|
|
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" &&
|
|
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
|
-
? {
|
|
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" ?
|
|
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
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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, {
|
|
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, {
|
|
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
|
|
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({
|
|
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
|
|
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 =
|
|
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 = [
|
|
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 &&
|
|
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 {
|
|
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
|
|
130
|
-
|
|
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" &&
|
|
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)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
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)
|
|
236
|
-
|
|
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" &&
|
|
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
|
|
466
|
-
?
|
|
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,
|