@botcord/daemon 0.2.55 → 0.2.56

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.
@@ -371,7 +371,7 @@ process.stdout.write(JSON.stringify({type:"item.completed", item:{id:"i0", type:
371
371
  expect(argv).toContain('approval_policy="never"');
372
372
  });
373
373
 
374
- it("extraArgs `-s read-only` suppresses the default sandbox `-c`s", async () => {
374
+ it("extraArgs `-s read-only` is converted to resume-compatible sandbox config", async () => {
375
375
  const adapter = new CodexAdapter({ binary: echoScript() });
376
376
  const ctrl = new AbortController();
377
377
  const res = await adapter.run({
@@ -384,11 +384,87 @@ process.stdout.write(JSON.stringify({type:"item.completed", item:{id:"i0", type:
384
384
  extraArgs: ["-s", "read-only"],
385
385
  });
386
386
  const argv = JSON.parse(res.text) as string[];
387
- // Only the operator-supplied `-s` appears; our defaults are suppressed.
388
- expect(argv.filter((a) => a === "-s").length).toBe(1);
389
- expect(argv[argv.indexOf("-s") + 1]).toBe("read-only");
387
+ expect(argv).not.toContain("-s");
388
+ expect(argv).toContain('sandbox_mode="read-only"');
390
389
  expect(argv).not.toContain('sandbox_mode="workspace-write"');
391
390
  expect(argv).not.toContain('sandbox_mode="danger-full-access"');
392
391
  });
392
+
393
+ it("extraArgs `--sandbox=value` is converted on resume too", async () => {
394
+ const adapter = new CodexAdapter({ binary: echoScript() });
395
+ const ctrl = new AbortController();
396
+ const res = await adapter.run({
397
+ text: "x",
398
+ sessionId: "01234567-89ab-7def-8123-456789abcdef",
399
+ accountId: "ag_test",
400
+ cwd: tmpRoot,
401
+ signal: ctrl.signal,
402
+ trustLevel: "public",
403
+ extraArgs: ["--sandbox=workspace-write"],
404
+ });
405
+ const argv = JSON.parse(res.text) as string[];
406
+ expect(argv[0]).toBe("exec");
407
+ expect(argv[1]).toBe("resume");
408
+ expect(argv).not.toContain("--sandbox=workspace-write");
409
+ expect(argv).toContain('sandbox_mode="workspace-write"');
410
+ expect(argv).not.toContain('sandbox_mode="danger-full-access"');
411
+ });
412
+
413
+ it("maps legacy Codex --full-auto to the current bypass flag", async () => {
414
+ const adapter = new CodexAdapter({ binary: echoScript() });
415
+ const ctrl = new AbortController();
416
+ const res = await adapter.run({
417
+ text: "x",
418
+ sessionId: null,
419
+ accountId: "ag_test",
420
+ cwd: tmpRoot,
421
+ signal: ctrl.signal,
422
+ trustLevel: "public",
423
+ extraArgs: ["--full-auto"],
424
+ });
425
+ const argv = JSON.parse(res.text) as string[];
426
+ expect(argv).not.toContain("--full-auto");
427
+ expect(argv).toContain("--dangerously-bypass-approvals-and-sandbox");
428
+ expect(argv).not.toContain('sandbox_mode="danger-full-access"');
429
+ });
430
+
431
+ it("drops inherited Claude --permission-mode extraArgs and their values", async () => {
432
+ const adapter = new CodexAdapter({ binary: echoScript() });
433
+ const ctrl = new AbortController();
434
+ const res = await adapter.run({
435
+ text: "x",
436
+ sessionId: null,
437
+ accountId: "ag_test",
438
+ cwd: tmpRoot,
439
+ signal: ctrl.signal,
440
+ trustLevel: "public",
441
+ extraArgs: ["--permission-mode", "bypassPermissions", "--model", "gpt-5.2"],
442
+ });
443
+ const argv = JSON.parse(res.text) as string[];
444
+ expect(argv).not.toContain("--permission-mode");
445
+ expect(argv).not.toContain("bypassPermissions");
446
+ expect(argv).toContain("--model");
447
+ expect(argv[argv.indexOf("--model") + 1]).toBe("gpt-5.2");
448
+ expect(argv).toContain('sandbox_mode="danger-full-access"');
449
+ expect(argv).toContain('approval_policy="never"');
450
+ });
451
+
452
+ it("drops inherited Claude --permission-mode=value extraArgs", async () => {
453
+ const adapter = new CodexAdapter({ binary: echoScript() });
454
+ const ctrl = new AbortController();
455
+ const res = await adapter.run({
456
+ text: "x",
457
+ sessionId: null,
458
+ accountId: "ag_test",
459
+ cwd: tmpRoot,
460
+ signal: ctrl.signal,
461
+ trustLevel: "public",
462
+ extraArgs: ["--permission-mode=bypassPermissions"],
463
+ });
464
+ const argv = JSON.parse(res.text) as string[];
465
+ expect(argv).not.toContain("--permission-mode=bypassPermissions");
466
+ expect(argv).toContain('sandbox_mode="danger-full-access"');
467
+ expect(argv).toContain('approval_policy="never"');
468
+ });
393
469
  });
394
470
  });
@@ -261,4 +261,13 @@ export class Gateway {
261
261
  const idx = this.config.channels.findIndex((c) => c.id === id);
262
262
  if (idx >= 0) this.config.channels.splice(idx, 1);
263
263
  }
264
+
265
+ /**
266
+ * Inject a daemon-internal inbound message into the normal dispatcher.
267
+ * Control-plane wakeups use this path so scheduled turns share the same
268
+ * routing, queueing, transcript, and runtime behavior as channel messages.
269
+ */
270
+ async injectInbound(message: GatewayInboundMessage): Promise<void> {
271
+ await this.dispatcher.handle({ message });
272
+ }
264
273
  }
@@ -32,6 +32,77 @@ function invalidClaudeSessionIdError(): string {
32
32
  return "claude-code: invalid sessionId (expected non-control text not starting with '-')";
33
33
  }
34
34
 
35
+ const CLAUDE_FOREIGN_FLAGS_WITH_VALUE = new Set([
36
+ "--color",
37
+ "--config",
38
+ "--disable",
39
+ "--enable",
40
+ "--image",
41
+ "--local-provider",
42
+ "--output-last-message",
43
+ "--output-schema",
44
+ "--profile",
45
+ "--sandbox",
46
+ "-i",
47
+ "-o",
48
+ "-p",
49
+ "-s",
50
+ ]);
51
+ const CLAUDE_FOREIGN_BOOLEAN_FLAGS = new Set([
52
+ "--all",
53
+ "--dangerously-bypass-approvals-and-sandbox",
54
+ "--ephemeral",
55
+ "--full-auto",
56
+ "--ignore-rules",
57
+ "--ignore-user-config",
58
+ "--json",
59
+ "--last",
60
+ "--oss",
61
+ "--print",
62
+ "--skip-git-repo-check",
63
+ ]);
64
+
65
+ function extraFlagName(arg: string): string {
66
+ if (!arg.startsWith("-")) return arg;
67
+ const eq = arg.indexOf("=");
68
+ return eq === -1 ? arg : arg.slice(0, eq);
69
+ }
70
+
71
+ function nextExtraValue(args: string[], index: number): string | undefined {
72
+ const next = args[index + 1];
73
+ if (typeof next !== "string") return undefined;
74
+ if (!next.startsWith("-")) return next;
75
+ return /^-\d/.test(next) ? next : undefined;
76
+ }
77
+
78
+ function sanitizeClaudeExtraArgs(extraArgs: string[] | undefined): string[] {
79
+ if (!extraArgs?.length) return [];
80
+ const out: string[] = [];
81
+ for (let i = 0; i < extraArgs.length; i += 1) {
82
+ const arg = extraArgs[i];
83
+ const name = extraFlagName(arg);
84
+
85
+ if (arg === "-c") {
86
+ const value = nextExtraValue(extraArgs, i);
87
+ if (value !== undefined) i += 1;
88
+ continue;
89
+ }
90
+ if (name === "--config" || name === "--sandbox") {
91
+ if (!arg.includes("=") && nextExtraValue(extraArgs, i) !== undefined) i += 1;
92
+ continue;
93
+ }
94
+ if (CLAUDE_FOREIGN_FLAGS_WITH_VALUE.has(name)) {
95
+ if (!arg.includes("=") && nextExtraValue(extraArgs, i) !== undefined) i += 1;
96
+ continue;
97
+ }
98
+ if (CLAUDE_FOREIGN_BOOLEAN_FLAGS.has(name)) {
99
+ continue;
100
+ }
101
+ out.push(arg);
102
+ }
103
+ return out;
104
+ }
105
+
35
106
  /** Resolve the Claude Code CLI path on PATH or the macOS desktop bundle fallback. */
36
107
  export function resolveClaudeCommand(deps: ProbeDeps = {}): string | null {
37
108
  const onPath = resolveCommandOnPath("claude", deps);
@@ -95,11 +166,12 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
95
166
  }
96
167
 
97
168
  protected buildArgs(opts: RuntimeRunOptions): string[] {
169
+ const extraArgs = sanitizeClaudeExtraArgs(opts.extraArgs);
98
170
  const args = ["-p", opts.text, "--output-format", "stream-json", "--verbose"];
99
171
  // Headless `-p` mode does not load project `.claude/` by default, so
100
172
  // per-agent skills seeded at `<workspace>/.claude/skills/` are invisible
101
173
  // unless we opt in. `extraArgs` wins so operators can still override.
102
- if (!opts.extraArgs?.some((a) => a.startsWith("--setting-sources"))) {
174
+ if (!extraArgs.some((a) => a.startsWith("--setting-sources"))) {
103
175
  args.push("--setting-sources", "project");
104
176
  }
105
177
  if (opts.sessionId) {
@@ -112,16 +184,16 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
112
184
  // MCP) because there is no prompt relay back to the user yet. Default to
113
185
  // bypassPermissions for every trust tier; operators who need a stricter
114
186
  // posture can still override with route/defaultRoute extraArgs.
115
- if (!opts.extraArgs?.some((a) => a.startsWith("--permission-mode"))) {
187
+ if (!extraArgs.some((a) => a.startsWith("--permission-mode"))) {
116
188
  args.push("--permission-mode", "bypassPermissions");
117
189
  }
118
190
  // Claude Code's `--append-system-prompt` is applied per invocation and NOT
119
191
  // persisted in the resumed session transcript — ideal for memory / digest
120
192
  // content that should re-evaluate every turn.
121
- if (opts.systemContext && !opts.extraArgs?.includes("--append-system-prompt")) {
193
+ if (opts.systemContext && !extraArgs.includes("--append-system-prompt")) {
122
194
  args.push("--append-system-prompt", opts.systemContext);
123
195
  }
124
- if (opts.extraArgs?.length) args.push(...opts.extraArgs);
196
+ if (extraArgs.length) args.push(...extraArgs);
125
197
  return args;
126
198
  }
127
199
 
@@ -15,6 +15,69 @@ import type { RuntimeProbeResult, RuntimeRunOptions, StreamBlock } from "../type
15
15
  const CODEX_DESKTOP_BUNDLE_PATH = "/Applications/Codex.app/Contents/Resources/codex";
16
16
  /** Codex UUIDv7 / v4 session ids are 36-char dashed hex; reject anything else to keep argv safe. */
17
17
  const CODEX_SESSION_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
18
+ const CODEX_FOREIGN_EXTRA_FLAGS_WITH_VALUE = new Set([
19
+ "--append-system-prompt",
20
+ "--permission-mode",
21
+ ]);
22
+ const CODEX_SANDBOX_MODES = new Set(["read-only", "workspace-write", "danger-full-access"]);
23
+
24
+ function extraFlagName(arg: string): string {
25
+ if (!arg.startsWith("-")) return arg;
26
+ const eq = arg.indexOf("=");
27
+ return eq === -1 ? arg : arg.slice(0, eq);
28
+ }
29
+
30
+ function nextExtraValue(args: string[], index: number): string | undefined {
31
+ const next = args[index + 1];
32
+ if (typeof next !== "string") return undefined;
33
+ if (!next.startsWith("-")) return next;
34
+ return /^-\d/.test(next) ? next : undefined;
35
+ }
36
+
37
+ function sanitizeCodexExtraArgs(extraArgs: string[] | undefined): string[] {
38
+ if (!extraArgs?.length) return [];
39
+ const out: string[] = [];
40
+ for (let i = 0; i < extraArgs.length; i += 1) {
41
+ const arg = extraArgs[i];
42
+ const name = extraFlagName(arg);
43
+ if (CODEX_FOREIGN_EXTRA_FLAGS_WITH_VALUE.has(name)) {
44
+ if (!arg.includes("=") && nextExtraValue(extraArgs, i) !== undefined) i += 1;
45
+ continue;
46
+ }
47
+ if (name === "-s" || name === "--sandbox") {
48
+ const value = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : nextExtraValue(extraArgs, i);
49
+ if (!arg.includes("=") && value !== undefined) i += 1;
50
+ if (value && CODEX_SANDBOX_MODES.has(value)) {
51
+ out.push("-c", `sandbox_mode="${value}"`);
52
+ }
53
+ continue;
54
+ }
55
+ if (arg === "--full-auto") {
56
+ out.push("--dangerously-bypass-approvals-and-sandbox");
57
+ continue;
58
+ }
59
+ out.push(arg);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ function hasCodexSandboxOverride(args: string[]): boolean {
65
+ for (let i = 0; i < args.length; i += 1) {
66
+ const arg = args[i];
67
+ if (
68
+ arg === "--dangerously-bypass-approvals-and-sandbox" ||
69
+ arg.startsWith("-c sandbox_mode=") ||
70
+ arg.startsWith("-csandbox_mode=") ||
71
+ arg.startsWith("--config=sandbox_mode=")
72
+ ) {
73
+ return true;
74
+ }
75
+ if ((arg === "-c" || arg === "--config") && args[i + 1]?.startsWith("sandbox_mode=")) {
76
+ return true;
77
+ }
78
+ }
79
+ return false;
80
+ }
18
81
 
19
82
  /** Resolve the Codex CLI executable via PATH or macOS desktop bundle. */
20
83
  export function resolveCodexCommand(deps: ProbeDeps = {}): string | null {
@@ -167,6 +230,7 @@ export class CodexAdapter extends NdjsonStreamAdapter {
167
230
  */
168
231
  protected buildArgs(opts: RuntimeRunOptions): string[] {
169
232
  const tail: string[] = [];
233
+ const extraArgs = sanitizeCodexExtraArgs(opts.extraArgs);
170
234
 
171
235
  // Sandbox / approval policy. Expressed as `-c` overrides because
172
236
  // `codex exec resume` rejects `-s` / `--full-auto`. `-c` works on both
@@ -177,16 +241,7 @@ export class CodexAdapter extends NdjsonStreamAdapter {
177
241
  // relay back to the user yet. Default to bypassing both approvals and the
178
242
  // sandbox for every trust tier; operators who need a stricter posture can
179
243
  // still override with route/defaultRoute extraArgs.
180
- const hasSandboxOverride =
181
- opts.extraArgs?.some(
182
- (a) =>
183
- a === "-s" ||
184
- a.startsWith("--sandbox") ||
185
- a === "--full-auto" ||
186
- a === "--dangerously-bypass-approvals-and-sandbox" ||
187
- a.startsWith("-c sandbox_mode=") ||
188
- a.startsWith("-csandbox_mode="),
189
- ) ?? false;
244
+ const hasSandboxOverride = hasCodexSandboxOverride(extraArgs);
190
245
  if (!hasSandboxOverride) {
191
246
  tail.push(
192
247
  "-c",
@@ -196,7 +251,7 @@ export class CodexAdapter extends NdjsonStreamAdapter {
196
251
  );
197
252
  }
198
253
  tail.push("--skip-git-repo-check", "--json");
199
- if (opts.extraArgs?.length) tail.push(...opts.extraArgs);
254
+ if (extraArgs.length) tail.push(...extraArgs);
200
255
 
201
256
  // `--` separates flags from positionals so a prompt starting with `-`
202
257
  // can never be parsed as an option. `systemContext` is NOT prepended to
package/src/index.ts CHANGED
@@ -57,6 +57,7 @@ import {
57
57
  updateWorkingMemory,
58
58
  DEFAULT_SECTION,
59
59
  } from "./working-memory.js";
60
+ import { createDiagnosticBundle } from "./diagnostics.js";
60
61
  import { resolveStartAuthAction } from "./start-auth.js";
61
62
  import {
62
63
  discoverLocalOpenclawGateways,
@@ -131,7 +132,9 @@ Commands:
131
132
  route list
132
133
  route remove --room <rm_xxx>|--prefix <rm_xxx>
133
134
  config Print resolved config
134
- doctor [--json] Scan local runtimes (${ADAPTER_LIST})
135
+ doctor [--json] [--bundle] Scan local runtimes (${ADAPTER_LIST});
136
+ --bundle also writes a zip under
137
+ ~/.botcord/diagnostics/
135
138
  memory get [--agent <ag_xxx>] [--json] Show current working memory
136
139
  memory set [--agent <ag_xxx>] --goal <text>
137
140
  Pin/update the agent's work goal
@@ -164,6 +167,7 @@ const BOOLEAN_FLAGS = new Set([
164
167
  "f",
165
168
  "follow",
166
169
  "json",
170
+ "bundle",
167
171
  "help",
168
172
  "h",
169
173
  "mentioned",
@@ -1342,6 +1346,18 @@ const fsFileReader: DoctorFileReader = {
1342
1346
  };
1343
1347
 
1344
1348
  async function cmdDoctor(args: ParsedArgs): Promise<void> {
1349
+ if (args.flags.bundle === true) {
1350
+ const bundle = await createDiagnosticBundle();
1351
+ if (args.flags.json === true) {
1352
+ console.log(JSON.stringify({ bundle }, null, 2));
1353
+ return;
1354
+ }
1355
+ console.log(`diagnostic bundle written: ${bundle.path}`);
1356
+ console.log(`size: ${bundle.sizeBytes} bytes`);
1357
+ console.log("Send this zip file to the BotCord developer/support contact.");
1358
+ return;
1359
+ }
1360
+
1345
1361
  const entries: import("./doctor.js").DoctorRuntimeEntry[] = detectRuntimes();
1346
1362
  // Doctor should not hard-fail when no config exists yet; channel probes
1347
1363
  // simply produce an empty list in that case.
package/src/provision.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  type UpdateAgentParams,
29
29
  } from "@botcord/protocol-core";
30
30
  import type { Gateway } from "./gateway/index.js";
31
+ import type { GatewayInboundMessage } from "./gateway/index.js";
31
32
  import type { PolicyResolverLike } from "./gateway/policy-resolver.js";
32
33
  import type { PolicyUpdatedParams } from "@botcord/protocol-core";
33
34
  import type {
@@ -398,6 +399,10 @@ export function createProvisioner(opts: ProvisionerOptions): (
398
399
  return { ok: true, result };
399
400
  }
400
401
 
402
+ case "wake_agent": {
403
+ return handleWakeAgent(gateway, frame.params);
404
+ }
405
+
401
406
  default:
402
407
  daemonLog.warn("provision.dispatch: unknown frame type", {
403
408
  type: frame.type,
@@ -411,6 +416,87 @@ export function createProvisioner(opts: ProvisionerOptions): (
411
416
  };
412
417
  }
413
418
 
419
+ interface WakeAgentParams {
420
+ agent_id?: string;
421
+ agentId?: string;
422
+ message?: string;
423
+ run_id?: string;
424
+ runId?: string;
425
+ schedule_id?: string;
426
+ scheduleId?: string;
427
+ dedupe_key?: string;
428
+ dedupeKey?: string;
429
+ }
430
+
431
+ async function handleWakeAgent(gateway: Gateway, raw: unknown): Promise<AckBody> {
432
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
433
+ return {
434
+ ok: false,
435
+ error: { code: "bad_params", message: "wake_agent params must be an object" },
436
+ };
437
+ }
438
+ const params = raw as WakeAgentParams;
439
+ const agentId = params.agent_id || params.agentId;
440
+ const message = params.message;
441
+ if (!agentId || typeof agentId !== "string") {
442
+ return {
443
+ ok: false,
444
+ error: { code: "bad_params", message: "wake_agent requires params.agent_id" },
445
+ };
446
+ }
447
+ if (!message || typeof message !== "string") {
448
+ return {
449
+ ok: false,
450
+ error: { code: "bad_params", message: "wake_agent requires params.message" },
451
+ };
452
+ }
453
+
454
+ const channels = gateway.snapshot().channels;
455
+ if (!channels[agentId]) {
456
+ return {
457
+ ok: false,
458
+ error: { code: "agent_not_loaded", message: `agent ${agentId} is not loaded in daemon gateway` },
459
+ };
460
+ }
461
+
462
+ const runId = params.run_id || params.runId || `wake-${Date.now()}`;
463
+ const scheduleId = params.schedule_id || params.scheduleId;
464
+ const dedupeKey = params.dedupe_key || params.dedupeKey;
465
+ const conversationId = `rm_schedule_${agentId}`;
466
+ const msg: GatewayInboundMessage = {
467
+ id: runId,
468
+ channel: agentId,
469
+ accountId: agentId,
470
+ conversation: {
471
+ id: conversationId,
472
+ kind: "direct",
473
+ title: "BotCord Scheduler",
474
+ threadId: scheduleId ?? null,
475
+ },
476
+ sender: {
477
+ id: "hub",
478
+ name: "BotCord Scheduler",
479
+ kind: "system",
480
+ },
481
+ text: message,
482
+ raw: {
483
+ source_type: "botcord_schedule",
484
+ schedule_id: scheduleId,
485
+ run_id: runId,
486
+ dedupe_key: dedupeKey,
487
+ },
488
+ mentioned: true,
489
+ receivedAt: Date.now(),
490
+ trace: {
491
+ id: runId,
492
+ streamable: false,
493
+ },
494
+ };
495
+
496
+ await gateway.injectInbound(msg);
497
+ return { ok: true, result: { agent_id: agentId } };
498
+ }
499
+
414
500
  // W8: hand-written runtime validator for the third-party gateway frame
415
501
  // params. Rejects malformed payloads with a structured `bad_params` ack
416
502
  // before they hit the per-handler logic, so an attacker can't smuggle a
@@ -307,6 +307,11 @@ export function buildWorkingMemoryPrompt(opts: {
307
307
  "- sections: named buckets (contacts, pending_tasks, preferences, etc.).",
308
308
  "- Updating one section never touches others. Empty content deletes a section.",
309
309
  "",
310
+ "For cross-room work, update memory before or immediately after delegating:",
311
+ "- If you accept a request in one room and continue it in another, record a `pending_tasks` entry with source room id/name, target room id/name, requested deliverable, current status, and where to report completion.",
312
+ "- When a delegated room replies or delivers an artifact, consult `pending_tasks` before deciding `NO_REPLY`; if it matches a pending handoff, acknowledge, update status, and send the promised follow-up to the source room when appropriate.",
313
+ "- Remove or mark the entry done once the source room has been updated.",
314
+ "",
310
315
  "Only update when something meaningful changes. Keep each section tight.",
311
316
  ];
312
317