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