@botcord/daemon 0.2.55 → 0.2.57

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/provision.js CHANGED
@@ -263,6 +263,9 @@ export function createProvisioner(opts) {
263
263
  });
264
264
  return { ok: true, result };
265
265
  }
266
+ case "wake_agent": {
267
+ return handleWakeAgent(gateway, frame.params);
268
+ }
266
269
  default:
267
270
  daemonLog.warn("provision.dispatch: unknown frame type", {
268
271
  type: frame.type,
@@ -275,6 +278,71 @@ export function createProvisioner(opts) {
275
278
  }
276
279
  };
277
280
  }
281
+ async function handleWakeAgent(gateway, raw) {
282
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
283
+ return {
284
+ ok: false,
285
+ error: { code: "bad_params", message: "wake_agent params must be an object" },
286
+ };
287
+ }
288
+ const params = raw;
289
+ const agentId = params.agent_id || params.agentId;
290
+ const message = params.message;
291
+ if (!agentId || typeof agentId !== "string") {
292
+ return {
293
+ ok: false,
294
+ error: { code: "bad_params", message: "wake_agent requires params.agent_id" },
295
+ };
296
+ }
297
+ if (!message || typeof message !== "string") {
298
+ return {
299
+ ok: false,
300
+ error: { code: "bad_params", message: "wake_agent requires params.message" },
301
+ };
302
+ }
303
+ const channels = gateway.snapshot().channels;
304
+ if (!channels[agentId]) {
305
+ return {
306
+ ok: false,
307
+ error: { code: "agent_not_loaded", message: `agent ${agentId} is not loaded in daemon gateway` },
308
+ };
309
+ }
310
+ const runId = params.run_id || params.runId || `wake-${Date.now()}`;
311
+ const scheduleId = params.schedule_id || params.scheduleId;
312
+ const dedupeKey = params.dedupe_key || params.dedupeKey;
313
+ const conversationId = `rm_schedule_${agentId}`;
314
+ const msg = {
315
+ id: runId,
316
+ channel: agentId,
317
+ accountId: agentId,
318
+ conversation: {
319
+ id: conversationId,
320
+ kind: "direct",
321
+ title: "BotCord Scheduler",
322
+ threadId: scheduleId ?? null,
323
+ },
324
+ sender: {
325
+ id: "hub",
326
+ name: "BotCord Scheduler",
327
+ kind: "system",
328
+ },
329
+ text: message,
330
+ raw: {
331
+ source_type: "botcord_schedule",
332
+ schedule_id: scheduleId,
333
+ run_id: runId,
334
+ dedupe_key: dedupeKey,
335
+ },
336
+ mentioned: true,
337
+ receivedAt: Date.now(),
338
+ trace: {
339
+ id: runId,
340
+ streamable: false,
341
+ },
342
+ };
343
+ await gateway.injectInbound(msg);
344
+ return { ok: true, result: { agent_id: agentId } };
345
+ }
278
346
  function validateGatewayParams(raw, spec) {
279
347
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
280
348
  return {
@@ -240,6 +240,11 @@ export function buildWorkingMemoryPrompt(opts) {
240
240
  "- sections: named buckets (contacts, pending_tasks, preferences, etc.).",
241
241
  "- Updating one section never touches others. Empty content deletes a section.",
242
242
  "",
243
+ "For cross-room work, update memory before or immediately after delegating:",
244
+ "- 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.",
245
+ "- 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.",
246
+ "- Remove or mark the entry done once the source room has been updated.",
247
+ "",
243
248
  "Only update when something meaningful changes. Keep each section tight.",
244
249
  ];
245
250
  if (!workingMemory) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.55",
3
+ "version": "0.2.57",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,46 @@
1
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+ import { createDiagnosticBundle } from "../diagnostics.js";
7
+
8
+ describe("diagnostics bundle", () => {
9
+ it("writes a zip bundle under ~/.botcord/diagnostics", async () => {
10
+ const tmp = mkdtempSync(path.join(tmpdir(), "botcord-diag-test-"));
11
+ const logFile = path.join(tmp, "daemon.log");
12
+ const configFile = path.join(tmp, "config.json");
13
+ const snapshotFile = path.join(tmp, "snapshot.json");
14
+ const diagnosticsDir = path.join(tmp, "diagnostics");
15
+ writeFileSync(logFile, 'Authorization: Bearer secret-token\n{"refreshToken":"drt_secret"}\n');
16
+ writeFileSync(configFile, '{"token":"agent-secret","ok":true}\n');
17
+ writeFileSync(snapshotFile, '{"version":1}\n');
18
+
19
+ const bundle = await createDiagnosticBundle({
20
+ diagnosticsDir,
21
+ logFile,
22
+ configFile,
23
+ snapshotFile,
24
+ doctor: { text: "doctor ok", json: { ok: true } },
25
+ });
26
+ expect(bundle.filename).toMatch(/^botcord-daemon-diagnostics-.*\.zip$/);
27
+ expect(bundle.path).toContain(diagnosticsDir);
28
+ expect(existsSync(bundle.path)).toBe(true);
29
+ const bytes = readFileSync(bundle.path);
30
+ expect(bytes.subarray(0, 4).toString("binary")).toBe("PK\u0003\u0004");
31
+
32
+ const listing = execFileSync("unzip", ["-l", bundle.path], {
33
+ encoding: "utf8",
34
+ });
35
+ expect(listing).toContain("daemon.log");
36
+ expect(listing).toContain("doctor.json");
37
+ expect(listing).toContain("status.json");
38
+ expect(listing).toContain("config.json.redacted");
39
+
40
+ const log = execFileSync("unzip", ["-p", bundle.path, "daemon.log"], {
41
+ encoding: "utf8",
42
+ });
43
+ expect(log).toContain("Authorization: Bearer [REDACTED]");
44
+ expect(log).toContain('"refreshToken":"[REDACTED]"');
45
+ }, 20_000);
46
+ });
@@ -104,6 +104,7 @@ interface FakeGateway {
104
104
  upsertManagedRoute: ReturnType<typeof vi.fn>;
105
105
  removeManagedRoute: ReturnType<typeof vi.fn>;
106
106
  replaceManagedRoutes: ReturnType<typeof vi.fn>;
107
+ injectInbound: ReturnType<typeof vi.fn>;
107
108
  listManagedRoutes: () => GatewayRoute[];
108
109
  snapshot: () => GatewayRuntimeSnapshot;
109
110
  }
@@ -128,6 +129,7 @@ function makeFakeGateway(initialChannelIds: string[] = []): FakeGateway {
128
129
  managed.clear();
129
130
  for (const [id, route] of routes) managed.set(id, route);
130
131
  }),
132
+ injectInbound: vi.fn(async () => {}),
131
133
  listManagedRoutes: (): GatewayRoute[] => Array.from(managed.values()),
132
134
  snapshot: (): GatewayRuntimeSnapshot => ({
133
135
  channels: Object.fromEntries(
@@ -251,6 +253,49 @@ describe("list_agent_files handler", () => {
251
253
  });
252
254
  });
253
255
 
256
+ describe("wake_agent handler", () => {
257
+ it("injects a scheduled turn into the gateway dispatcher", async () => {
258
+ const gw = makeFakeGateway(["ag_wake"]);
259
+ const handler = createProvisioner({ gateway: gw as any });
260
+ const res = await handler({
261
+ id: "req_wake",
262
+ type: "wake_agent",
263
+ params: {
264
+ agent_id: "ag_wake",
265
+ message: "【BotCord 自主任务】执行本轮工作目标。",
266
+ run_id: "sr_test",
267
+ schedule_id: "sch_test",
268
+ dedupe_key: "sch_test:1:auto",
269
+ },
270
+ });
271
+
272
+ expect(res.ok).toBe(true);
273
+ expect(gw.injectInbound).toHaveBeenCalledTimes(1);
274
+ const msg = gw.injectInbound.mock.calls[0][0];
275
+ expect(msg.id).toBe("sr_test");
276
+ expect(msg.channel).toBe("ag_wake");
277
+ expect(msg.accountId).toBe("ag_wake");
278
+ expect(msg.sender.id).toBe("hub");
279
+ expect(msg.sender.kind).toBe("system");
280
+ expect(msg.text).toContain("BotCord 自主任务");
281
+ expect(msg.conversation.threadId).toBe("sch_test");
282
+ });
283
+
284
+ it("rejects wake_agent for an unloaded agent", async () => {
285
+ const gw = makeFakeGateway(["ag_loaded"]);
286
+ const handler = createProvisioner({ gateway: gw as any });
287
+ const res = await handler({
288
+ id: "req_wake_missing",
289
+ type: "wake_agent",
290
+ params: { agent_id: "ag_missing", message: "tick" },
291
+ });
292
+
293
+ expect(res.ok).toBe(false);
294
+ expect(res.error?.code).toBe("agent_not_loaded");
295
+ expect(gw.injectInbound).not.toHaveBeenCalled();
296
+ });
297
+ });
298
+
254
299
  describe("reload_config handler", () => {
255
300
  it("adds agents listed in config but missing from gateway", async () => {
256
301
  mockState.cfg = {
@@ -207,6 +207,15 @@ describe("buildWorkingMemoryPrompt", () => {
207
207
  expect(p).toContain("currently empty");
208
208
  });
209
209
 
210
+ it("instructs agents to persist cross-room handoffs", () => {
211
+ const p = wm.buildWorkingMemoryPrompt({ workingMemory: null });
212
+ expect(p).toContain("For cross-room work");
213
+ expect(p).toContain("pending_tasks");
214
+ expect(p).toContain("source room");
215
+ expect(p).toContain("target room");
216
+ expect(p).toContain("where to report completion");
217
+ });
218
+
210
219
  it("renders goal + named sections", () => {
211
220
  const p = wm.buildWorkingMemoryPrompt({
212
221
  workingMemory: {
@@ -237,4 +246,3 @@ describe("buildWorkingMemoryPrompt", () => {
237
246
  expect(p).toContain("‹current_memory›");
238
247
  });
239
248
  });
240
-
package/src/daemon.ts CHANGED
@@ -46,6 +46,7 @@ import { composeBotCordUserTurn } from "./turn-text.js";
46
46
  import { UserAuthManager } from "./user-auth.js";
47
47
  import { PolicyResolver } from "./gateway/policy-resolver.js";
48
48
  import { scanMention } from "./mention-scan.js";
49
+ import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
49
50
 
50
51
  /**
51
52
  * Default hard cap for a single runtime turn. Long-running coding/research
@@ -558,7 +559,30 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
558
559
  const provisioner = createProvisioner({ gateway, policyResolver, onAgentInstalled });
559
560
  controlChannel = new ControlChannel({
560
561
  auth: userAuth,
561
- handle: provisioner,
562
+ handle: async (frame) => {
563
+ if (frame.type === "collect_diagnostics") {
564
+ logger.info("diagnostics: collect requested", { frameId: frame.id });
565
+ const bundle = await createDiagnosticBundle();
566
+ const upload = await uploadDiagnosticBundle({ auth: userAuth, bundle });
567
+ logger.info("diagnostics: uploaded", {
568
+ frameId: frame.id,
569
+ bundleId: upload.bundleId,
570
+ sizeBytes: upload.sizeBytes,
571
+ localPath: bundle.path,
572
+ });
573
+ return {
574
+ ok: true,
575
+ result: {
576
+ bundle_id: upload.bundleId,
577
+ filename: upload.filename,
578
+ size_bytes: upload.sizeBytes,
579
+ expires_at: upload.expiresAt ?? null,
580
+ local_path: bundle.path,
581
+ },
582
+ };
583
+ }
584
+ return provisioner(frame);
585
+ },
562
586
  });
563
587
  try {
564
588
  await controlChannel.start();
@@ -0,0 +1,348 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir, hostname, platform, release, arch } from "node:os";
3
+ import path from "node:path";
4
+ import { Buffer } from "node:buffer";
5
+ import { deflateRawSync } from "node:zlib";
6
+ import {
7
+ AUTH_EXPIRED_FLAG_PATH,
8
+ USER_AUTH_PATH,
9
+ type UserAuthManager,
10
+ type UserAuthRecord,
11
+ } from "./user-auth.js";
12
+ import {
13
+ CONFIG_FILE_PATH,
14
+ PID_PATH,
15
+ SNAPSHOT_PATH,
16
+ loadConfig,
17
+ type DaemonConfig,
18
+ } from "./config.js";
19
+ import { LOG_FILE_PATH } from "./log.js";
20
+ import {
21
+ channelsFromDaemonConfig,
22
+ defaultHttpFetcher,
23
+ renderDoctor,
24
+ runDoctor,
25
+ type DoctorFileReader,
26
+ type DoctorRuntimeEntry,
27
+ } from "./doctor.js";
28
+ import { detectRuntimes } from "./adapters/runtimes.js";
29
+
30
+ const DIAGNOSTICS_DIR = path.join(homedir(), ".botcord", "diagnostics");
31
+ const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
32
+
33
+ export interface CreateDiagnosticBundleOptions {
34
+ diagnosticsDir?: string;
35
+ logFile?: string;
36
+ configFile?: string;
37
+ snapshotFile?: string;
38
+ doctor?: { text: string; json: unknown };
39
+ }
40
+
41
+ export interface DiagnosticBundleResult {
42
+ path: string;
43
+ filename: string;
44
+ sizeBytes: number;
45
+ createdAt: string;
46
+ }
47
+
48
+ export interface DiagnosticUploadResult {
49
+ bundleId: string;
50
+ filename: string;
51
+ sizeBytes: number;
52
+ expiresAt?: string;
53
+ }
54
+
55
+ const SECRET_PATTERNS: Array<[RegExp, string]> = [
56
+ [/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]"],
57
+ [/("?(?:accessToken|access_token|refreshToken|refresh_token|token|privateKey|private_key|secret)"?\s*:\s*")[^"]+(")/gi, "$1[REDACTED]$2"],
58
+ [/(drt_)[A-Za-z0-9_-]+/g, "$1[REDACTED]"],
59
+ [/(dit_)[A-Za-z0-9_-]+/g, "$1[REDACTED]"],
60
+ [/([?&](?:token|access_token|refresh_token|install_token)=)[^&\s"']+/gi, "$1[REDACTED]"],
61
+ ];
62
+
63
+ function redact(input: string): string {
64
+ let out = input;
65
+ for (const [pattern, replacement] of SECRET_PATTERNS) {
66
+ out = out.replace(pattern, replacement);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ function safeReadText(file: string): string | null {
72
+ if (!existsSync(file)) return null;
73
+ try {
74
+ return redact(readFileSync(file, "utf8"));
75
+ } catch (err) {
76
+ return `read failed: ${err instanceof Error ? err.message : String(err)}\n`;
77
+ }
78
+ }
79
+
80
+ function readUserAuthSummary(): Record<string, unknown> | null {
81
+ const raw = safeReadText(USER_AUTH_PATH);
82
+ if (!raw) return null;
83
+ try {
84
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
85
+ return {
86
+ userId: typeof parsed.userId === "string" ? parsed.userId : null,
87
+ daemonInstanceId:
88
+ typeof parsed.daemonInstanceId === "string" ? parsed.daemonInstanceId : null,
89
+ hubUrl: typeof parsed.hubUrl === "string" ? parsed.hubUrl : null,
90
+ expiresAt: typeof parsed.expiresAt === "number" ? parsed.expiresAt : null,
91
+ loggedInAt: typeof parsed.loggedInAt === "string" ? parsed.loggedInAt : null,
92
+ label: typeof parsed.label === "string" ? parsed.label : null,
93
+ authExpiredFlagPresent: existsSync(AUTH_EXPIRED_FLAG_PATH),
94
+ };
95
+ } catch (err) {
96
+ return {
97
+ error: `user-auth summary failed: ${err instanceof Error ? err.message : String(err)}`,
98
+ authExpiredFlagPresent: existsSync(AUTH_EXPIRED_FLAG_PATH),
99
+ };
100
+ }
101
+ }
102
+
103
+ const fsFileReader: DoctorFileReader = {
104
+ readFile(p: string): string | null {
105
+ if (!existsSync(p)) return null;
106
+ try {
107
+ return readFileSync(p, "utf8");
108
+ } catch {
109
+ return null;
110
+ }
111
+ },
112
+ };
113
+
114
+ async function buildDoctorEntries(): Promise<{
115
+ text: string;
116
+ json: unknown;
117
+ }> {
118
+ const entries: DoctorRuntimeEntry[] = detectRuntimes();
119
+ let channels: ReturnType<typeof channelsFromDaemonConfig> = [];
120
+ let cfgForEndpoints: DaemonConfig | null = null;
121
+ try {
122
+ cfgForEndpoints = loadConfig();
123
+ channels = channelsFromDaemonConfig(cfgForEndpoints);
124
+ } catch {
125
+ channels = [];
126
+ }
127
+ if (cfgForEndpoints?.openclawGateways && cfgForEndpoints.openclawGateways.length > 0) {
128
+ const { collectRuntimeSnapshotAsync } = await import("./provision.js");
129
+ const snap = await collectRuntimeSnapshotAsync({ cfg: cfgForEndpoints });
130
+ const byId = new Map(snap.runtimes.map((r) => [r.id, r]));
131
+ for (const e of entries) {
132
+ const r = byId.get(e.id);
133
+ if (r?.endpoints) e.endpoints = r.endpoints;
134
+ }
135
+ }
136
+ const input = await runDoctor(entries, channels, {
137
+ credentialsPath: (accountId) =>
138
+ path.join(homedir(), ".botcord", "credentials", `${accountId}.json`),
139
+ fileReader: fsFileReader,
140
+ fetcher: defaultHttpFetcher,
141
+ timeoutMs: 5_000,
142
+ });
143
+ return { text: renderDoctor(input), json: input };
144
+ }
145
+
146
+ function crc32(buf: Buffer): number {
147
+ let crc = 0xffffffff;
148
+ for (const b of buf) {
149
+ crc ^= b;
150
+ for (let i = 0; i < 8; i += 1) {
151
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
152
+ }
153
+ }
154
+ return (crc ^ 0xffffffff) >>> 0;
155
+ }
156
+
157
+ function dosDateTime(date: Date): { time: number; date: number } {
158
+ const year = Math.max(1980, date.getFullYear());
159
+ return {
160
+ time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
161
+ date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
162
+ };
163
+ }
164
+
165
+ function u16(n: number): Buffer {
166
+ const b = Buffer.alloc(2);
167
+ b.writeUInt16LE(n & 0xffff, 0);
168
+ return b;
169
+ }
170
+
171
+ function u32(n: number): Buffer {
172
+ const b = Buffer.alloc(4);
173
+ b.writeUInt32LE(n >>> 0, 0);
174
+ return b;
175
+ }
176
+
177
+ function createZip(entries: Array<{ name: string; data: string | Buffer }>): Buffer {
178
+ const localParts: Buffer[] = [];
179
+ const centralParts: Buffer[] = [];
180
+ let offset = 0;
181
+ const now = new Date();
182
+ const dt = dosDateTime(now);
183
+
184
+ for (const entry of entries) {
185
+ const name = Buffer.from(entry.name.replace(/^\/+/, ""), "utf8");
186
+ const data = Buffer.isBuffer(entry.data)
187
+ ? entry.data
188
+ : Buffer.from(entry.data, "utf8");
189
+ const compressed = deflateRawSync(data, { level: 9 });
190
+ const crc = crc32(data);
191
+ const local = Buffer.concat([
192
+ u32(0x04034b50),
193
+ u16(20),
194
+ u16(0),
195
+ u16(8),
196
+ u16(dt.time),
197
+ u16(dt.date),
198
+ u32(crc),
199
+ u32(compressed.length),
200
+ u32(data.length),
201
+ u16(name.length),
202
+ u16(0),
203
+ name,
204
+ compressed,
205
+ ]);
206
+ localParts.push(local);
207
+
208
+ centralParts.push(Buffer.concat([
209
+ u32(0x02014b50),
210
+ u16(20),
211
+ u16(20),
212
+ u16(0),
213
+ u16(8),
214
+ u16(dt.time),
215
+ u16(dt.date),
216
+ u32(crc),
217
+ u32(compressed.length),
218
+ u32(data.length),
219
+ u16(name.length),
220
+ u16(0),
221
+ u16(0),
222
+ u16(0),
223
+ u16(0),
224
+ u32(0),
225
+ u32(offset),
226
+ name,
227
+ ]));
228
+ offset += local.length;
229
+ }
230
+
231
+ const central = Buffer.concat(centralParts);
232
+ const end = Buffer.concat([
233
+ u32(0x06054b50),
234
+ u16(0),
235
+ u16(0),
236
+ u16(entries.length),
237
+ u16(entries.length),
238
+ u32(central.length),
239
+ u32(offset),
240
+ u16(0),
241
+ ]);
242
+ return Buffer.concat([...localParts, central, end]);
243
+ }
244
+
245
+ export async function createDiagnosticBundle(
246
+ opts: CreateDiagnosticBundleOptions = {},
247
+ ): Promise<DiagnosticBundleResult> {
248
+ const createdAt = new Date();
249
+ const stamp = createdAt.toISOString().replace(/[:.]/g, "-");
250
+ const filename = `botcord-daemon-diagnostics-${stamp}.zip`;
251
+ const diagnosticsDir = opts.diagnosticsDir ?? DIAGNOSTICS_DIR;
252
+ const logFile = opts.logFile ?? LOG_FILE_PATH;
253
+ const configFile = opts.configFile ?? CONFIG_FILE_PATH;
254
+ const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
255
+ mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
256
+
257
+ const doctor = opts.doctor ?? await buildDoctorEntries();
258
+ const status = {
259
+ createdAt: createdAt.toISOString(),
260
+ host: hostname(),
261
+ platform: platform(),
262
+ release: release(),
263
+ arch: arch(),
264
+ node: process.version,
265
+ pidPath: PID_PATH,
266
+ pid: process.pid,
267
+ configPath: configFile,
268
+ snapshotPath: snapshotFile,
269
+ logPath: logFile,
270
+ diagnosticsDir,
271
+ userAuth: readUserAuthSummary(),
272
+ };
273
+
274
+ const entries: Array<{ name: string; data: string | Buffer }> = [
275
+ { name: "README.txt", data: "BotCord daemon diagnostics bundle. Sensitive tokens are redacted before packaging.\n" },
276
+ { name: "status.json", data: JSON.stringify(status, null, 2) + "\n" },
277
+ { name: "doctor.txt", data: doctor.text + "\n" },
278
+ { name: "doctor.json", data: JSON.stringify(doctor.json, null, 2) + "\n" },
279
+ ];
280
+ const log = safeReadText(logFile);
281
+ entries.push({
282
+ name: "daemon.log",
283
+ data: log ?? `no log file at ${logFile}\n`,
284
+ });
285
+ const config = safeReadText(configFile);
286
+ entries.push({
287
+ name: "config.json.redacted",
288
+ data: config ?? `no config file at ${configFile}\n`,
289
+ });
290
+ const snapshot = safeReadText(snapshotFile);
291
+ entries.push({
292
+ name: "snapshot.json",
293
+ data: snapshot ?? `no snapshot file at ${snapshotFile}\n`,
294
+ });
295
+
296
+ const zip = createZip(entries);
297
+ const out = path.join(diagnosticsDir, filename);
298
+ writeFileSync(out, zip, { mode: 0o600 });
299
+ return {
300
+ path: out,
301
+ filename,
302
+ sizeBytes: zip.length,
303
+ createdAt: createdAt.toISOString(),
304
+ };
305
+ }
306
+
307
+ export async function uploadDiagnosticBundle(opts: {
308
+ auth: UserAuthManager;
309
+ bundle: DiagnosticBundleResult;
310
+ }): Promise<DiagnosticUploadResult> {
311
+ const record: UserAuthRecord | null = opts.auth.current;
312
+ if (!record) throw new Error("daemon not logged in");
313
+ const data = readFileSync(opts.bundle.path);
314
+ if (data.length > MAX_UPLOAD_BYTES) {
315
+ throw new Error(`diagnostic bundle is too large (${data.length} bytes, max ${MAX_UPLOAD_BYTES})`);
316
+ }
317
+ const token = await opts.auth.ensureAccessToken();
318
+ const url = `${record.hubUrl.replace(/\/+$/, "")}/daemon/diagnostics/upload`;
319
+ const resp = await fetch(url, {
320
+ method: "POST",
321
+ headers: {
322
+ Authorization: `Bearer ${token}`,
323
+ "Content-Type": "application/zip",
324
+ "X-BotCord-Filename": opts.bundle.filename,
325
+ },
326
+ body: data,
327
+ });
328
+ const json = await resp.json().catch(() => null) as Record<string, unknown> | null;
329
+ if (!resp.ok) {
330
+ const detail =
331
+ typeof json?.detail === "string"
332
+ ? json.detail
333
+ : typeof json?.error === "string"
334
+ ? json.error
335
+ : `HTTP ${resp.status}`;
336
+ throw new Error(`diagnostic upload failed: ${detail}`);
337
+ }
338
+ const bundleId = typeof json?.bundle_id === "string" ? json.bundle_id : null;
339
+ if (!bundleId) throw new Error("diagnostic upload response missing bundle_id");
340
+ return {
341
+ bundleId,
342
+ filename: typeof json?.filename === "string" ? json.filename : opts.bundle.filename,
343
+ sizeBytes: typeof json?.size_bytes === "number" ? json.size_bytes : data.length,
344
+ ...(typeof json?.expires_at === "string" ? { expiresAt: json.expires_at } : {}),
345
+ };
346
+ }
347
+
348
+ export { DIAGNOSTICS_DIR };
@@ -349,5 +349,40 @@ process.stdout.write(JSON.stringify({type:"result", subtype:"success", session_i
349
349
  const modeIdx = argv.indexOf("--permission-mode");
350
350
  expect(argv[modeIdx + 1]).toBe("plan");
351
351
  });
352
+
353
+ it("drops inherited Codex-only extraArgs while preserving shared Claude flags", async () => {
354
+ const adapter = new ClaudeCodeAdapter({ binary: echoScript() });
355
+ const ctrl = new AbortController();
356
+ const res = await adapter.run({
357
+ text: "x",
358
+ sessionId: null,
359
+ accountId: "ag_test",
360
+ cwd: tmpRoot,
361
+ signal: ctrl.signal,
362
+ trustLevel: "public",
363
+ extraArgs: [
364
+ "-c",
365
+ 'model="gpt-5.2"',
366
+ "--sandbox",
367
+ "read-only",
368
+ "--skip-git-repo-check",
369
+ "--json",
370
+ "-p",
371
+ "codex-profile",
372
+ "--model",
373
+ "sonnet",
374
+ ],
375
+ });
376
+ const argv = JSON.parse(res.text) as string[];
377
+ expect(argv).not.toContain("-c");
378
+ expect(argv).not.toContain('model="gpt-5.2"');
379
+ expect(argv).not.toContain("--sandbox");
380
+ expect(argv).not.toContain("read-only");
381
+ expect(argv).not.toContain("--skip-git-repo-check");
382
+ expect(argv).not.toContain("--json");
383
+ expect(argv).not.toContain("codex-profile");
384
+ expect(argv).toContain("--model");
385
+ expect(argv[argv.indexOf("--model") + 1]).toBe("sonnet");
386
+ });
352
387
  });
353
388
  });