@botcord/daemon 0.2.74 → 0.2.76
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/cloud-auth.d.ts +47 -0
- package/dist/cloud-auth.js +51 -0
- package/dist/cloud-daemon.d.ts +43 -0
- package/dist/cloud-daemon.js +252 -0
- package/dist/cloud-mode.d.ts +45 -0
- package/dist/cloud-mode.js +55 -0
- package/dist/cloud-settle.d.ts +81 -0
- package/dist/cloud-settle.js +100 -0
- package/dist/daemon-singleton.d.ts +26 -0
- package/dist/daemon-singleton.js +91 -0
- package/dist/daemon.d.ts +1 -1
- package/dist/daemon.js +15 -6
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.js +15 -4
- package/dist/gateway/channels/botcord.d.ts +1 -1
- package/dist/gateway/channels/botcord.js +48 -5
- package/dist/gateway/dispatcher.d.ts +34 -1
- package/dist/gateway/dispatcher.js +277 -20
- package/dist/gateway/gateway.d.ts +9 -1
- package/dist/gateway/gateway.js +4 -1
- package/dist/gateway/runtime-errors.d.ts +6 -0
- package/dist/gateway/runtime-errors.js +14 -0
- package/dist/gateway/runtimes/claude-code.d.ts +8 -0
- package/dist/gateway/runtimes/claude-code.js +92 -4
- package/dist/gateway/runtimes/deepseek-tui.js +19 -5
- package/dist/gateway/transcript.d.ts +1 -1
- package/dist/gateway/types.d.ts +33 -0
- package/dist/index.js +71 -80
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +39 -1
- package/dist/status-render.js +17 -0
- package/package.json +2 -2
- package/src/__tests__/cloud-auth.test.ts +42 -0
- package/src/__tests__/cloud-daemon.test.ts +237 -0
- package/src/__tests__/cloud-mode.test.ts +65 -0
- package/src/__tests__/cloud-settle.test.ts +287 -0
- package/src/__tests__/daemon-singleton.test.ts +89 -0
- package/src/__tests__/doctor.test.ts +34 -0
- package/src/__tests__/runtime-discovery.test.ts +90 -0
- package/src/__tests__/status-render.test.ts +34 -0
- package/src/cloud-auth.ts +78 -0
- package/src/cloud-daemon.ts +338 -0
- package/src/cloud-mode.ts +70 -0
- package/src/cloud-settle.ts +182 -0
- package/src/daemon-singleton.ts +122 -0
- package/src/daemon.ts +18 -5
- package/src/doctor.ts +18 -5
- package/src/gateway/__tests__/botcord-channel.test.ts +74 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +101 -1
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +19 -0
- package/src/gateway/__tests__/dispatcher.test.ts +120 -0
- package/src/gateway/channels/botcord.ts +54 -7
- package/src/gateway/dispatcher.ts +354 -21
- package/src/gateway/gateway.ts +16 -1
- package/src/gateway/runtime-errors.ts +15 -0
- package/src/gateway/runtimes/claude-code.ts +98 -2
- package/src/gateway/runtimes/deepseek-tui.ts +23 -5
- package/src/gateway/transcript.ts +1 -1
- package/src/gateway/types.ts +34 -0
- package/src/index.ts +83 -74
- package/src/provision.ts +45 -1
- package/src/status-render.ts +24 -0
|
@@ -188,6 +188,23 @@ function makeEnvelope(
|
|
|
188
188
|
return { message: makeMessage(partial), ack };
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
function cloudRunRaw(
|
|
192
|
+
budget: { max_wall_time_seconds?: number; max_tool_calls?: number },
|
|
193
|
+
): Record<string, unknown> {
|
|
194
|
+
return {
|
|
195
|
+
envelope: {
|
|
196
|
+
type: "cloud_run",
|
|
197
|
+
payload: {
|
|
198
|
+
text: "run this",
|
|
199
|
+
cloud_run: {
|
|
200
|
+
run_id: "crun_test",
|
|
201
|
+
budget,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
191
208
|
function baseConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
192
209
|
return {
|
|
193
210
|
channels: [{ id: "botcord", type: "botcord", accountId: "ag_me" }],
|
|
@@ -225,6 +242,8 @@ describe("Dispatcher", () => {
|
|
|
225
242
|
channel?: FakeChannel;
|
|
226
243
|
runtimeFactory?: RuntimeFactory;
|
|
227
244
|
turnTimeoutMs?: number;
|
|
245
|
+
runtimeAuthFailureThreshold?: number;
|
|
246
|
+
runtimeAuthFailureCooldownMs?: number;
|
|
228
247
|
}) {
|
|
229
248
|
const { store, dir } = await makeStore();
|
|
230
249
|
tempDirs.push(dir);
|
|
@@ -237,6 +256,8 @@ describe("Dispatcher", () => {
|
|
|
237
256
|
sessionStore: store,
|
|
238
257
|
log: silentLogger(),
|
|
239
258
|
turnTimeoutMs: args.turnTimeoutMs,
|
|
259
|
+
runtimeAuthFailureThreshold: args.runtimeAuthFailureThreshold,
|
|
260
|
+
runtimeAuthFailureCooldownMs: args.runtimeAuthFailureCooldownMs,
|
|
240
261
|
});
|
|
241
262
|
return { dispatcher, channel, store };
|
|
242
263
|
}
|
|
@@ -293,6 +314,51 @@ describe("Dispatcher", () => {
|
|
|
293
314
|
expect(store.all()[0].threadId).toBe("t_1");
|
|
294
315
|
});
|
|
295
316
|
|
|
317
|
+
it("cloud_run: forwards budget caps to the runtime", async () => {
|
|
318
|
+
const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-cloud" });
|
|
319
|
+
const { dispatcher } = await scaffold({
|
|
320
|
+
runtimeFactory: () => runtime,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await dispatcher.handle(
|
|
324
|
+
makeEnvelope({
|
|
325
|
+
id: "msg_cloud_budget",
|
|
326
|
+
raw: cloudRunRaw({ max_wall_time_seconds: 12, max_tool_calls: 7 }),
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
expect(runtime.calls.length).toBe(1);
|
|
331
|
+
expect(runtime.calls[0].budget).toEqual({
|
|
332
|
+
maxWallTimeMs: 12000,
|
|
333
|
+
maxToolCalls: 7,
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("cloud_run: aborts the turn when tool-call budget is exceeded", async () => {
|
|
338
|
+
const runtime = new FakeRuntime({
|
|
339
|
+
reply: "should not deliver",
|
|
340
|
+
newSessionId: "sid-budget",
|
|
341
|
+
blocks: [
|
|
342
|
+
{ raw: { type: "tool", name: "one" }, kind: "tool_use", seq: 1 },
|
|
343
|
+
{ raw: { type: "tool", name: "two" }, kind: "tool_use", seq: 2 },
|
|
344
|
+
],
|
|
345
|
+
});
|
|
346
|
+
const { dispatcher, channel, store } = await scaffold({
|
|
347
|
+
runtimeFactory: () => runtime,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
await dispatcher.handle(
|
|
351
|
+
makeEnvelope({
|
|
352
|
+
id: "msg_cloud_tool_budget",
|
|
353
|
+
raw: cloudRunRaw({ max_tool_calls: 1 }),
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
expect(channel.sends.length).toBe(1);
|
|
358
|
+
expect(channel.sends[0].message.text).toContain("Cloud run budget exceeded");
|
|
359
|
+
expect(store.all().length).toBe(0);
|
|
360
|
+
});
|
|
361
|
+
|
|
296
362
|
it("sends replies to the provider reply id when it differs from the internal message id", async () => {
|
|
297
363
|
const runtime = new FakeRuntime({ reply: "ok" });
|
|
298
364
|
const feishuChannel = new FakeChannel({ id: "gw_feishu_1", type: "feishu" });
|
|
@@ -394,6 +460,60 @@ describe("Dispatcher", () => {
|
|
|
394
460
|
expect(channel.sends[0].message.type).toBe("error");
|
|
395
461
|
});
|
|
396
462
|
|
|
463
|
+
it("treats auth failure text as an error and does not persist the failed session", async () => {
|
|
464
|
+
let callNo = 0;
|
|
465
|
+
const runtimeFactory: RuntimeFactory = () => {
|
|
466
|
+
callNo += 1;
|
|
467
|
+
if (callNo === 1) return new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
|
|
468
|
+
return new FakeRuntime({
|
|
469
|
+
reply: "Failed to authenticate. API Error: 403 Request not allowed",
|
|
470
|
+
newSessionId: "sid-bad",
|
|
471
|
+
});
|
|
472
|
+
};
|
|
473
|
+
const { dispatcher, store, channel } = await scaffold({ runtimeFactory });
|
|
474
|
+
|
|
475
|
+
await dispatcher.handle(
|
|
476
|
+
makeEnvelope({ id: "msg_1", conversation: { id: "rm_oc_auth", kind: "direct" } }),
|
|
477
|
+
);
|
|
478
|
+
expect(store.all()[0].runtimeSessionId).toBe("sid-1");
|
|
479
|
+
|
|
480
|
+
await dispatcher.handle(
|
|
481
|
+
makeEnvelope({ id: "msg_2", conversation: { id: "rm_oc_auth", kind: "direct" } }),
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
expect(store.all().length).toBe(0);
|
|
485
|
+
expect(channel.sends).toHaveLength(2);
|
|
486
|
+
expect(channel.sends[1].message.type).toBe("error");
|
|
487
|
+
expect(channel.sends[1].message.text).toContain("Runtime error");
|
|
488
|
+
expect(channel.sends[1].message.text).toContain("Failed to authenticate");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("opens an auth circuit breaker after repeated failures and skips runtime spawn", async () => {
|
|
492
|
+
const runtime = new FakeRuntime({
|
|
493
|
+
reply: "Failed to authenticate. API Error: 403 Request not allowed",
|
|
494
|
+
newSessionId: "sid-bad",
|
|
495
|
+
});
|
|
496
|
+
const { dispatcher, channel, store } = await scaffold({
|
|
497
|
+
runtimeFactory: () => runtime,
|
|
498
|
+
runtimeAuthFailureThreshold: 2,
|
|
499
|
+
runtimeAuthFailureCooldownMs: 60_000,
|
|
500
|
+
});
|
|
501
|
+
const conversation = { id: "rm_oc_auth_breaker", kind: "direct" as const };
|
|
502
|
+
|
|
503
|
+
await dispatcher.handle(makeEnvelope({ id: "msg_1", conversation }));
|
|
504
|
+
await dispatcher.handle(makeEnvelope({ id: "msg_2", conversation }));
|
|
505
|
+
expect(runtime.calls).toHaveLength(2);
|
|
506
|
+
expect(Object.values(dispatcher.runtimeCircuitBreakers())).toHaveLength(1);
|
|
507
|
+
|
|
508
|
+
await dispatcher.handle(makeEnvelope({ id: "msg_3", conversation }));
|
|
509
|
+
|
|
510
|
+
expect(runtime.calls).toHaveLength(2);
|
|
511
|
+
expect(store.all()).toHaveLength(0);
|
|
512
|
+
expect(channel.sends).toHaveLength(3);
|
|
513
|
+
expect(channel.sends[2].message.type).toBe("error");
|
|
514
|
+
expect(channel.sends[2].message.text).toContain("dispatch paused");
|
|
515
|
+
});
|
|
516
|
+
|
|
397
517
|
it("applies composeUserTurn before handing text to the runtime", async () => {
|
|
398
518
|
const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
|
|
399
519
|
const { store, dir } = await makeStore();
|
|
@@ -43,7 +43,8 @@ type InboxDrainTrigger =
|
|
|
43
43
|
| "ws_auth_ok"
|
|
44
44
|
| "ws_inbox_update"
|
|
45
45
|
| "coalesced_inbox_update"
|
|
46
|
-
| "has_more_continue"
|
|
46
|
+
| "has_more_continue"
|
|
47
|
+
| "poll_interval";
|
|
47
48
|
|
|
48
49
|
/** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
|
|
49
50
|
export interface BotCordChannelClient {
|
|
@@ -90,7 +91,7 @@ export interface BotCordChannelOptions {
|
|
|
90
91
|
credentialsPath?: string;
|
|
91
92
|
/** Override the Hub base URL. Defaults to the `hubUrl` stored in credentials. */
|
|
92
93
|
hubBaseUrl?: string;
|
|
93
|
-
/**
|
|
94
|
+
/** Periodic inbox polling fallback. Set to 0 to disable. Defaults to 30s. */
|
|
94
95
|
pollIntervalMs?: number;
|
|
95
96
|
/** Test hook: supply a pre-built client instead of loading credentials from disk. */
|
|
96
97
|
client?: BotCordChannelClient;
|
|
@@ -153,6 +154,9 @@ function defaultClientFactory(input: {
|
|
|
153
154
|
function isOwnerTrust(msg: InboxMessage): boolean {
|
|
154
155
|
if (msg.room_id?.startsWith(OWNER_CHAT_PREFIX)) return true;
|
|
155
156
|
if (msg.source_type === "dashboard_user_chat") return true;
|
|
157
|
+
// Cloud Agent run tasks are Hub-issued on the user's behalf, same
|
|
158
|
+
// trust posture as owner chat.
|
|
159
|
+
if (msg.source_type === "cloud_agent_run") return true;
|
|
156
160
|
return false;
|
|
157
161
|
}
|
|
158
162
|
|
|
@@ -186,11 +190,19 @@ function normalizeInbox(
|
|
|
186
190
|
if (!env) return null;
|
|
187
191
|
// `message` is the normal conversational envelope; `contact_request` is
|
|
188
192
|
// a lightweight inbound asking the agent to notify its owner (the
|
|
189
|
-
// composer appends the notify-owner hint)
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
193
|
+
// composer appends the notify-owner hint); `cloud_run` carries a
|
|
194
|
+
// Cloud Agent run task with embedded run_id + budget (the cloud
|
|
195
|
+
// daemon's runtime adapter reads them from `raw.envelope.payload.cloud_run`
|
|
196
|
+
// and reports usage back via /internal/cloud-agents/.../settle when the
|
|
197
|
+
// run completes). All other envelope types (notification, system,
|
|
198
|
+
// contact_added/removed, …) are still filtered out — they belong in
|
|
199
|
+
// a separate push-notification path that daemon does not yet implement.
|
|
200
|
+
if (
|
|
201
|
+
env.type !== "message" &&
|
|
202
|
+
env.type !== "contact_request" &&
|
|
203
|
+
env.type !== "cloud_run"
|
|
204
|
+
)
|
|
205
|
+
return null;
|
|
194
206
|
if (!msg.room_id) return null;
|
|
195
207
|
|
|
196
208
|
const rawText =
|
|
@@ -482,6 +494,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
482
494
|
let ws: WebSocket | null = null;
|
|
483
495
|
let reconnectTimer: NodeJS.Timeout | null = null;
|
|
484
496
|
let keepaliveTimer: NodeJS.Timeout | null = null;
|
|
497
|
+
let pollTimer: NodeJS.Timeout | null = null;
|
|
485
498
|
let reconnectAttempt = 0;
|
|
486
499
|
let connectionSeq = 0;
|
|
487
500
|
let consecutiveAuthFailures = 0;
|
|
@@ -507,6 +520,10 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
507
520
|
clearInterval(keepaliveTimer);
|
|
508
521
|
keepaliveTimer = null;
|
|
509
522
|
}
|
|
523
|
+
if (pollTimer) {
|
|
524
|
+
clearInterval(pollTimer);
|
|
525
|
+
pollTimer = null;
|
|
526
|
+
}
|
|
510
527
|
}
|
|
511
528
|
|
|
512
529
|
function markStatus(patch: Partial<ChannelStatusSnapshot>) {
|
|
@@ -709,6 +726,16 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
709
726
|
});
|
|
710
727
|
log.info("botcord ws authenticated", { agentId: msg.agent_id });
|
|
711
728
|
void fireInbox("ws_auth_ok");
|
|
729
|
+
const pollIntervalMs = options.pollIntervalMs ?? 30_000;
|
|
730
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
731
|
+
if (pollIntervalMs > 0) {
|
|
732
|
+
pollTimer = setInterval(() => {
|
|
733
|
+
if (ws === socket && socket.readyState === WebSocket.OPEN) {
|
|
734
|
+
void fireInbox("poll_interval");
|
|
735
|
+
}
|
|
736
|
+
}, pollIntervalMs);
|
|
737
|
+
pollTimer.unref?.();
|
|
738
|
+
}
|
|
712
739
|
if (keepaliveTimer) clearInterval(keepaliveTimer);
|
|
713
740
|
keepaliveTimer = setInterval(() => {
|
|
714
741
|
if (ws === socket && socket.readyState === WebSocket.OPEN) {
|
|
@@ -1042,6 +1069,14 @@ function normalizeBlockForHub(
|
|
|
1042
1069
|
}
|
|
1043
1070
|
|
|
1044
1071
|
// "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
|
|
1072
|
+
if (isTerminalRuntimeBlock(raw)) {
|
|
1073
|
+
payload.terminal = true;
|
|
1074
|
+
payload.details = formatBlockDetails(raw);
|
|
1075
|
+
const event = typeof raw?.event === "string" ? raw.event : undefined;
|
|
1076
|
+
const embedded = typeof raw?.payload?.event === "string" ? raw.payload.event : undefined;
|
|
1077
|
+
if (event || embedded) payload.event = event ?? embedded;
|
|
1078
|
+
return { kind: "other", seq, payload };
|
|
1079
|
+
}
|
|
1045
1080
|
if (raw?.type === "result") {
|
|
1046
1081
|
if (typeof raw.result === "string") payload.text = raw.result;
|
|
1047
1082
|
if (typeof raw.subtype === "string") payload.subtype = raw.subtype;
|
|
@@ -1050,6 +1085,18 @@ function normalizeBlockForHub(
|
|
|
1050
1085
|
return { kind: "other", seq, payload };
|
|
1051
1086
|
}
|
|
1052
1087
|
|
|
1088
|
+
function isTerminalRuntimeBlock(raw: any): boolean {
|
|
1089
|
+
const event = typeof raw?.event === "string" ? raw.event : undefined;
|
|
1090
|
+
const embedded = typeof raw?.payload?.event === "string" ? raw.payload.event : undefined;
|
|
1091
|
+
const terminal = event ?? embedded;
|
|
1092
|
+
return (
|
|
1093
|
+
terminal === "turn.completed" ||
|
|
1094
|
+
terminal === "turn.finished" ||
|
|
1095
|
+
terminal === "turn.done" ||
|
|
1096
|
+
terminal === "done"
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1053
1100
|
function formatBlockDetails(raw: unknown): string {
|
|
1054
1101
|
if (!raw || typeof raw !== "object") return "";
|
|
1055
1102
|
const r = raw as any;
|