openmausbot 0.1.69 → 0.1.70

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.
@@ -173,9 +173,25 @@ async function releaseBytes(asset) {
173
173
  const cached = cacheDirectory ? join(cacheDirectory, asset.name) : "";
174
174
  if (cached && existsSync(cached)) return readFileSync(cached);
175
175
  const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${asset.name}`;
176
- const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(12e4) });
177
- if (!response.ok) throw new Error(`could not download ${asset.name}: HTTP ${response.status}`);
178
- return Buffer.from(await response.arrayBuffer());
176
+ const retryableStatuses = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
177
+ for (let attempt = 1; attempt <= 3; attempt++) {
178
+ let failure;
179
+ try {
180
+ const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(12e4) });
181
+ if (response.ok) return Buffer.from(await response.arrayBuffer());
182
+ await response.body?.cancel().catch(() => {
183
+ });
184
+ failure = new Error(`could not download ${asset.name}: HTTP ${response.status}`);
185
+ if (!retryableStatuses.has(response.status)) throw failure;
186
+ } catch (error) {
187
+ if (!(error instanceof TypeError) && !["TimeoutError", "AbortError"].includes(error?.name)) throw error;
188
+ failure = error;
189
+ }
190
+ if (attempt === 3) throw failure;
191
+ const delay = 1e3 * 2 ** (attempt - 1);
192
+ console.warn(`cloudflared download attempt ${attempt}/3 failed: ${failure.message}; retrying in ${delay}ms`);
193
+ await new Promise((resolve) => setTimeout(resolve, delay));
194
+ }
179
195
  }
180
196
  async function stageTarget(root, target) {
181
197
  const asset = CLOUDFLARED_ASSETS[target];
@@ -251,6 +267,7 @@ export {
251
267
  executableTarget,
252
268
  parsePrepareCloudflaredArgs,
253
269
  prepareCloudflared,
270
+ releaseBytes,
254
271
  sha256,
255
272
  targetForCurrentHost,
256
273
  targetsForHost,
@@ -86,19 +86,26 @@ export function threadsWaitingOn(toBotId) {
86
86
  }
87
87
  /** Mark a target's observed busy period as finished and return the source
88
88
  * threads that should be retried. This makes retries count distinct busy
89
- * periods, not unrelated drain requests on the same source thread. */
90
- export function releaseDelegationsWaitingOn(toBotId) {
91
- const threads = threadsWaitingOn(toBotId);
92
- if (!threads.length)
93
- return threads;
94
- for (const threadId of threads) {
95
- for (const item of pendingDelegations.get(threadId) ?? []) {
96
- if (item.toBotId === toBotId)
97
- delete item.waitingOnBusy;
89
+ * periods, not unrelated drain requests on the same source thread.
90
+ * `only` narrows the release: a bot that is still busy in one thread has
91
+ * nevertheless freed a slot for the fresh-thread handoffs waiting on it,
92
+ * while its active-thread handoffs go on waiting for it to go idle. */
93
+ export function releaseDelegationsWaitingOn(toBotId, only) {
94
+ const released = [];
95
+ for (const [threadId, items] of pendingDelegations) {
96
+ let any = false;
97
+ for (const item of items) {
98
+ if (item.toBotId !== toBotId || item.waitingOnBusy !== true || (only && !only(item)))
99
+ continue;
100
+ delete item.waitingOnBusy;
101
+ any = true;
98
102
  }
103
+ if (any)
104
+ released.push(threadId);
99
105
  }
100
- savePending();
101
- return threads;
106
+ if (released.length)
107
+ savePending();
108
+ return released;
102
109
  }
103
110
  function savePending() {
104
111
  try {
@@ -140,6 +147,9 @@ export function _loadPending() {
140
147
  if (typeof item.originatingGroupId === "string" && item.originatingGroupId) {
141
148
  loaded.originatingGroupId = item.originatingGroupId;
142
149
  }
150
+ if (typeof item.targetThreadId === "string" && item.targetThreadId) {
151
+ loaded.targetThreadId = item.targetThreadId;
152
+ }
143
153
  return [loaded];
144
154
  });
145
155
  if (items.length)
@@ -195,6 +205,7 @@ export function pendingDelegationSnapshot() {
195
205
  sourceBotId: item.sourceBotId,
196
206
  toBotId: item.toBotId,
197
207
  ...(item.reason ? { reason: item.reason } : {}),
208
+ ...(item.targetThreadId ? { targetThreadId: item.targetThreadId } : {}),
198
209
  })));
199
210
  }
200
211
  /** How many handoffs one turn may queue. Small on purpose: this is the only
@@ -231,18 +242,27 @@ export function queueDelegation(bus, from, item, maxDepth, sourceThreadId = from
231
242
  list.push({ ...item, id, sourceBotId: from.id, attempts: 0, ...(groupId ? { originatingGroupId: groupId } : {}) });
232
243
  pendingDelegations.set(sourceThreadId, list);
233
244
  savePending();
234
- const label = `Delegated to @${target.name}${item.reason ? `: ${item.reason}` : ""}`;
235
245
  const sourceGroup = sourceThreadId ? bus.store.groupByThread(sourceThreadId) : undefined;
236
- bus.store.appendMessage(sourceThreadId, {
246
+ // A fresh-thread handoff is announced as the thread it opened, with a
247
+ // link to it; a classic one as the delegation it is.
248
+ const openedThread = item.targetThreadId ? bus.store.taskByThread(target.id, item.targetThreadId) : undefined;
249
+ const chip = {
237
250
  role: "bot",
238
251
  kind: "activity",
239
252
  // settled at birth: queueing is the whole act. Left open, the chip
240
253
  // would spin until the transcript is closed — the chat never patches it
241
- tool: { name: label, ok: true },
242
- ...(sourceGroup && !sourceGroup.dm
243
- ? { from: { botId: from.id, name: from.name, color: from.color } }
244
- : {}),
245
- });
254
+ tool: {
255
+ name: openedThread
256
+ ? `Opened thread #${openedThread.title} on ${target.name}`
257
+ : `Delegated to @${target.name}${item.reason ? `: ${item.reason}` : ""}`,
258
+ ok: true,
259
+ },
260
+ };
261
+ if (openedThread)
262
+ chip.threadRef = { botId: target.id, threadId: openedThread.threadId, title: openedThread.title };
263
+ if (sourceGroup && !sourceGroup.dm)
264
+ chip.from = { botId: from.id, name: from.name, color: from.color };
265
+ bus.store.appendMessage(sourceThreadId, chip);
246
266
  return { result: "ok", id };
247
267
  }
248
268
  /** Drain queued delegations for a source thread (called on its
@@ -418,35 +438,12 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
418
438
  if (dropIfUnreachable(bus, sender, target, sourceThreadId, item)) {
419
439
  return "settled";
420
440
  }
421
- if (target.busy) {
422
- if (item.waitingOnBusy)
423
- return "requeued";
424
- item.attempts += 1;
425
- item.waitingOnBusy = true;
426
- if (item.attempts < MAX_BUSY_ATTEMPTS) {
427
- savePending();
428
- bus.store.appendMessage(sourceThreadId, {
429
- role: "bot",
430
- kind: "activity",
431
- tool: { name: `Delegation to @${target.name} waiting — they're busy (retry ${item.attempts}/${MAX_BUSY_ATTEMPTS} when they finish)` },
432
- });
433
- return "requeued";
434
- }
435
- recordDelegationReceipt({
436
- id: item.id,
437
- sourceThreadId,
438
- toBotId: target.id,
439
- toBotName: target.name,
440
- status: "busy_gave_up",
441
- result: `@${target.name} stayed busy through ${MAX_BUSY_ATTEMPTS} retries`,
442
- });
443
- bus.store.appendMessage(sourceThreadId, {
444
- role: "bot",
445
- kind: "activity",
446
- tool: { name: `Delegation to @${target.name} canceled — still busy after ${MAX_BUSY_ATTEMPTS} retries`, ok: false },
447
- });
441
+ if (dropIfThreadGone(bus, target, sourceThreadId, item)) {
448
442
  return "settled";
449
443
  }
444
+ const held = holdWhileTargetBusy(bus, target, sourceThreadId, item);
445
+ if (held)
446
+ return held;
450
447
  if (item.waitingOnBusy) {
451
448
  delete item.waitingOnBusy;
452
449
  savePending();
@@ -494,35 +491,12 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
494
491
  if (dropIfUnreachable(bus, currentSender, current, sourceThreadId, item)) {
495
492
  return "settled";
496
493
  }
497
- if (current.busy) {
498
- if (item.waitingOnBusy)
499
- return "requeued";
500
- item.attempts += 1;
501
- item.waitingOnBusy = true;
502
- if (item.attempts < MAX_BUSY_ATTEMPTS) {
503
- savePending();
504
- bus.store.appendMessage(sourceThreadId, {
505
- role: "bot",
506
- kind: "activity",
507
- tool: { name: `Delegation to @${current.name} waiting — they're busy (retry ${item.attempts}/${MAX_BUSY_ATTEMPTS} when they finish)` },
508
- });
509
- return "requeued";
510
- }
511
- recordDelegationReceipt({
512
- id: item.id,
513
- sourceThreadId,
514
- toBotId: current.id,
515
- toBotName: current.name,
516
- status: "busy_gave_up",
517
- result: `@${current.name} stayed busy through ${MAX_BUSY_ATTEMPTS} retries`,
518
- });
519
- bus.store.appendMessage(sourceThreadId, {
520
- role: "bot",
521
- kind: "activity",
522
- tool: { name: `Delegation to @${current.name} canceled — still busy after ${MAX_BUSY_ATTEMPTS} retries`, ok: false },
523
- });
494
+ if (dropIfThreadGone(bus, current, sourceThreadId, item)) {
524
495
  return "settled";
525
496
  }
497
+ const heldAfterApproval = holdWhileTargetBusy(bus, current, sourceThreadId, item);
498
+ if (heldAfterApproval)
499
+ return heldAfterApproval;
526
500
  sender = currentSender;
527
501
  target = current;
528
502
  }
@@ -533,10 +507,94 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
533
507
  const channel = getOrCreateChannel(bus.store, sender, target, originatingGroup);
534
508
  mirrorExchange(bus, sender, target, item.message, channel, sourceThreadId);
535
509
  const reasonLine = item.reason ? `\n\n[Reason: ${item.reason}]` : "";
536
- const prefixed = `[Delegated by @${sender.name}, another bot in this OpenMausBot workspace. Do the work and reply directly.]\n\n${item.message}${reasonLine}`;
537
- await runTarget(item.toBotId, prefixed, item.depth + 1, sourceThreadId, channel, item.id, sender.id);
510
+ // A fresh thread's first line gets the shared peer-provenance note from
511
+ // the harness (which knows whether the opener was unattended); the
512
+ // classic handoff keeps the prefix it has always had.
513
+ const prefixed = item.targetThreadId
514
+ ? item.message
515
+ : `[Delegated by @${sender.name}, another bot in this OpenMausBot workspace. Do the work and reply directly.]\n\n${item.message}${reasonLine}`;
516
+ await runTarget(item.toBotId, prefixed, item.depth + 1, sourceThreadId, channel, item.id, sender.id, item.targetThreadId);
538
517
  return "dispatched";
539
518
  }
519
+ /** A busy target holds the handoff. What "busy" means depends on where the
520
+ * turn will run: a classic delegation lands in the target's active thread,
521
+ * so it waits for the bot to go idle, with bounded retries so a stuck peer
522
+ * cannot pin the handoff forever. A fresh-thread handoff needs only a free
523
+ * slot, and waits in line for one without a bound — it IS the line, the
524
+ * same one a person's message joins at that limit. Returns null when the
525
+ * target can take the turn now. */
526
+ function holdWhileTargetBusy(bus, target, sourceThreadId, item) {
527
+ if (item.targetThreadId) {
528
+ if (bus.threadSlotFree ? bus.threadSlotFree(target.id) : !target.busy)
529
+ return null;
530
+ if (item.waitingOnBusy)
531
+ return "requeued";
532
+ item.waitingOnBusy = true;
533
+ savePending();
534
+ // one chip per wait, not one per retry: the line moves on every settle
535
+ if (item.attempts === 0) {
536
+ item.attempts = 1;
537
+ const title = bus.store.taskByThread(target.id, item.targetThreadId)?.title ?? "thread";
538
+ bus.store.appendMessage(sourceThreadId, {
539
+ role: "bot",
540
+ kind: "activity",
541
+ tool: { name: `Thread #${title} on @${target.name} waiting for a free slot` },
542
+ });
543
+ }
544
+ return "requeued";
545
+ }
546
+ if (!target.busy)
547
+ return null;
548
+ if (item.waitingOnBusy)
549
+ return "requeued";
550
+ item.attempts += 1;
551
+ item.waitingOnBusy = true;
552
+ if (item.attempts < MAX_BUSY_ATTEMPTS) {
553
+ savePending();
554
+ bus.store.appendMessage(sourceThreadId, {
555
+ role: "bot",
556
+ kind: "activity",
557
+ tool: { name: `Delegation to @${target.name} waiting — they're busy (retry ${item.attempts}/${MAX_BUSY_ATTEMPTS} when they finish)` },
558
+ });
559
+ return "requeued";
560
+ }
561
+ recordDelegationReceipt({
562
+ id: item.id,
563
+ sourceThreadId,
564
+ toBotId: target.id,
565
+ toBotName: target.name,
566
+ status: "busy_gave_up",
567
+ result: `@${target.name} stayed busy through ${MAX_BUSY_ATTEMPTS} retries`,
568
+ });
569
+ bus.store.appendMessage(sourceThreadId, {
570
+ role: "bot",
571
+ kind: "activity",
572
+ tool: { name: `Delegation to @${target.name} canceled — still busy after ${MAX_BUSY_ATTEMPTS} retries`, ok: false },
573
+ });
574
+ return "settled";
575
+ }
576
+ /** The thread a fresh-thread handoff was opened in may be deleted while the
577
+ * handoff waits. There is nowhere for the turn to run then, and running it
578
+ * in the target's active thread would put a bot's job in front of the
579
+ * person unasked. */
580
+ function dropIfThreadGone(bus, target, sourceThreadId, item) {
581
+ if (!item.targetThreadId || bus.store.taskByThread(target.id, item.targetThreadId))
582
+ return false;
583
+ recordDelegationReceipt({
584
+ id: item.id,
585
+ sourceThreadId,
586
+ toBotId: target.id,
587
+ toBotName: target.name,
588
+ status: "dropped",
589
+ result: `the thread opened on @${target.name} was deleted before it could start`,
590
+ });
591
+ bus.store.appendMessage(sourceThreadId, {
592
+ role: "bot",
593
+ kind: "activity",
594
+ tool: { name: `Thread on @${target.name} canceled — it was deleted before it could start`, ok: false },
595
+ });
596
+ return true;
597
+ }
540
598
  /** The source thread may be a bot's own task or a shared group where the
541
599
  * bot is a member. This is the same check the API gate uses for group turns. */
542
600
  function sourceThreadBelongsToBot(store, botId, threadId) {
@@ -13,6 +13,9 @@
13
13
  // immediately, the peer runs after your
14
14
  // current turn finishes, the result is
15
15
  // delivered to the source conversation
16
+ // start_thread(title, msg, bot_id?) → open a real thread — on yourself for
17
+ // separate work, or on a teammate as a
18
+ // handoff that runs on its own
16
19
  // create_bot(name, role, instructions) → Chiefs can add a specialist to
17
20
  // their own section
18
21
  // request_credential(id, reason?) → show a secure, allowlisted key card
@@ -45,6 +48,12 @@ let createdThisTurn = 0;
45
48
  // so the refusal reaches the model without a round trip.
46
49
  const MAX_ROOM_POSTS_PER_TURN = 3;
47
50
  let roomPostsThisTurn = 0;
51
+ // A thread is a real turn with its own run. Five in one turn is a plan
52
+ // ("one per pull request"); more than that is a model that has stopped
53
+ // deciding. The harness holds the same ceiling; this copy exists so the
54
+ // refusal reaches the model without a round trip.
55
+ const MAX_THREADS_PER_TURN = 5;
56
+ let threadsOpenedThisTurn = 0;
48
57
  const delegationTaskIdsThisTurn = new Set();
49
58
  const WEEKDAYS = [
50
59
  "monday",
@@ -382,6 +391,36 @@ const TOOLS = [
382
391
  required: ["task_id"],
383
392
  },
384
393
  },
394
+ {
395
+ name: "list_threads",
396
+ description: "See your own threads and the threads you opened on teammates, newest first: each with its bot, title, state (running, waiting on the person, queued, or idle), whether the person has unread there, and the delegation id if it was a handoff. Use it to check how the threads you started are going before reporting to the person; write a thread's title as #Title when you mention it. A teammate's other threads are never listed — only the ones you opened. This is a read: it starts nothing and changes nothing.",
397
+ inputSchema: { type: "object", additionalProperties: false, properties: {} },
398
+ },
399
+ {
400
+ name: "close_thread",
401
+ description: "Mark a thread you opened (or one of your own) as finished once you have read its result: it goes idle in the person's sidebar with a note saying you closed it. Nothing is deleted — deleting stays the person's decision — and a thread that is still running cannot be closed; wait for it or leave it. Use the thread id from list_threads or from the start_thread result. If a close is refused, do not retry it.",
402
+ inputSchema: {
403
+ type: "object",
404
+ additionalProperties: false,
405
+ properties: { thread_id: { type: "string", description: "The thread id from list_threads or start_thread." } },
406
+ required: ["thread_id"],
407
+ },
408
+ },
409
+ {
410
+ name: "start_thread",
411
+ description: "Open a new thread: one conversation with its own history and its own run, shown to the person as a row under the bot it belongs to. Leave bot_id out to open it on yourself, for a separate job that should run on its own (\"review each pull request\" — one thread per pull request) instead of inside this conversation. Give bot_id (from list_bots) to open it on a teammate: that is a handoff into a fresh thread, which starts after your current turn ends and whose result is delivered here, like delegate_bot. The title becomes the row's name, so make it short and specific; write it as #Title when you mention it to the person. Do not use it for a question you need answered right now (ask_bot), for one task where the teammate's usual conversation is fine (delegate_bot), or for a note nobody has to act on. If a call is refused, do not retry it: say what you still wanted opened.",
412
+ inputSchema: {
413
+ type: "object",
414
+ additionalProperties: false,
415
+ properties: {
416
+ title: { type: "string", description: "The thread's name: one short line, at most 80 characters, specific enough to tell it apart from the others (for example \"QA: PR #412 login fix\")." },
417
+ message: { type: "string", description: "The complete first message of the thread — everything the run needs, since it will not see this conversation." },
418
+ bot_id: { type: "string", description: "Optional: the teammate's id from list_bots. Leave out to open the thread on yourself." },
419
+ folder: { type: "string", description: "Optional: the name of one of that bot's existing folders to file the thread under. Leave out unless the person named one." },
420
+ },
421
+ required: ["title", "message"],
422
+ },
423
+ },
385
424
  {
386
425
  name: "post_to_room",
387
426
  description: "Put one message into a shared room you belong to, for example when the user asks you to tell the team something. Get group_id from list_rooms. This posts and returns: no room member's turn starts, nobody replies, and nothing comes back except confirmation — so never use it to ask a question or hand out work (use ask_bot or delegate_bot for those). Post once, say it in full, and tell the user what you posted. If a post is refused, do not retry it: say what you wanted to post in your reply instead.",
@@ -595,6 +634,14 @@ async function api(path, init) {
595
634
  throw new Error(String(body.error ?? `HTTP ${res.status}`));
596
635
  return body;
597
636
  }
637
+ /** "1st", "2nd", "3rd", "4th" — the queue position as a person says it. */
638
+ function ordinal(n) {
639
+ const rem100 = n % 100;
640
+ if (rem100 >= 11 && rem100 <= 13)
641
+ return `${n}th`;
642
+ const rem10 = n % 10;
643
+ return `${n}${rem10 === 1 ? "st" : rem10 === 2 ? "nd" : rem10 === 3 ? "rd" : "th"}`;
644
+ }
598
645
  function jsonRecord(value) {
599
646
  return value !== null && typeof value === "object" && !Array.isArray(value);
600
647
  }
@@ -829,6 +876,88 @@ async function callTool(name, args) {
829
876
  }
830
877
  return { text: `Task ${taskId} ended without a reply — ${String(r.status ?? "unknown")}${r.result ? `: ${String(r.result)}` : ""}.`, isError: true };
831
878
  }
879
+ if (name === "list_threads") {
880
+ const query = new URLSearchParams({ fromBotId: BOT_ID, fromThreadId: THREAD_ID });
881
+ const r = await api(`/api/internal/threads?${query.toString()}`);
882
+ if (r.error)
883
+ return { text: `Couldn't list threads: ${String(r.error)}`, isError: true };
884
+ const threads = Array.isArray(r.threads) ? r.threads.filter(jsonRecord) : [];
885
+ if (!threads.length)
886
+ return { text: "No threads yet: you have none of your own beyond this one, and you have not opened any on a teammate." };
887
+ const stateWord = { running: "running", "waiting-on-you": "waiting on the person", queued: "queued", idle: "idle" };
888
+ const lines = threads.map((thread) => {
889
+ const where = thread.own === true ? "yours" : `on @${String(thread.botName)}`;
890
+ const state = stateWord[String(thread.state)] ?? String(thread.state);
891
+ const unread = thread.unread === true ? ", unread for the person" : "";
892
+ const handoff = typeof thread.delegationId === "string" && thread.delegationId ? ` [delegation id: ${thread.delegationId}]` : "";
893
+ return `- #${String(thread.title)} (${where}, ${state}${unread}) [thread id: ${String(thread.threadId)}]${handoff}`;
894
+ });
895
+ return { text: `Threads, newest first:\n${lines.join("\n")}` };
896
+ }
897
+ if (name === "close_thread") {
898
+ const threadId = String(args.thread_id ?? "").trim();
899
+ if (!threadId)
900
+ return { text: "close_thread needs the thread_id from list_threads or start_thread.", isError: true };
901
+ const r = await api(`/api/internal/threads/${encodeURIComponent(threadId)}/close`, { method: "POST", body: JSON.stringify({ fromBotId: BOT_ID, fromThreadId: THREAD_ID }) });
902
+ if (r.error)
903
+ return { text: `Couldn't close that thread: ${String(r.error)}`, isError: true };
904
+ return { text: `Closed #${String(r.title)}${r.botName ? ` on @${String(r.botName)}` : ""}. It stays in the person's sidebar, idle, with a note that you closed it.` };
905
+ }
906
+ if (name === "start_thread") {
907
+ const title = String(args.title ?? "").trim();
908
+ const message = String(args.message ?? "").trim();
909
+ if (!title || !message)
910
+ return { text: "start_thread needs title (one short line) and message (the complete first message).", isError: true };
911
+ if (threadsOpenedThisTurn >= MAX_THREADS_PER_TURN) {
912
+ return {
913
+ text: `You have already opened ${MAX_THREADS_PER_TURN} threads this turn, which is the limit. Do not retry — finish your turn and tell the person which threads you still wanted to open, so they can open them or ask you again.`,
914
+ isError: true,
915
+ };
916
+ }
917
+ const toBotId = typeof args.bot_id === "string" ? args.bot_id.trim() : "";
918
+ const folder = typeof args.folder === "string" ? args.folder.trim() : "";
919
+ const body = { fromBotId: BOT_ID, fromThreadId: THREAD_ID, title, message, depth: DEPTH };
920
+ if (toBotId)
921
+ body.toBotId = toBotId;
922
+ if (folder)
923
+ body.folder = folder;
924
+ const r = await api("/api/internal/threads", { method: "POST", body: JSON.stringify(body) });
925
+ // A refusal opened nothing. A "failed" state opened the thread and could
926
+ // not start its turn — that one still counts, and still has an id.
927
+ if (r.error && r.state !== "failed")
928
+ return { text: `Couldn't open that thread: ${String(r.error)}`, isError: true };
929
+ threadsOpenedThisTurn += 1;
930
+ const threadTitle = String(r.title ?? title);
931
+ const threadId = String(r.threadId ?? "");
932
+ const where = r.self === true ? "on yourself" : `on @${String(r.botName ?? "that bot")}`;
933
+ const opened = `Opened thread #${threadTitle} ${where} [thread id: ${threadId}].`;
934
+ if (r.self === true) {
935
+ if (r.state === "running") {
936
+ return { text: `${opened} It is running now, in parallel with this conversation, and its result stays in that thread — it will not be delivered here. Mention it to the person as #${threadTitle}; use list_threads in a later turn to see how it is going.` };
937
+ }
938
+ if (r.state === "queued") {
939
+ const position = Number(r.position) || 1;
940
+ const limit = Number(r.limit) || 0;
941
+ return { text: `${opened} You are at your limit of ${limit} threads running at once, so it is ${ordinal(position)} in line and starts as soon as one of them finishes — nothing more to do. Mention it to the person as #${threadTitle}.` };
942
+ }
943
+ return { text: `${opened} It could not start: ${String(r.error ?? "unknown reason")}. The thread exists but nothing is running in it; tell the person.`, isError: true };
944
+ }
945
+ // A peer thread is a handoff: like delegate_bot, it starts after this
946
+ // turn and reports back here, so the id is a claim ticket the model
947
+ // must not cash in this same turn.
948
+ const delegationId = typeof r.delegationId === "string" ? r.delegationId.trim() : "";
949
+ if (delegationId)
950
+ delegationTaskIdsThisTurn.add(delegationId);
951
+ const approval = r.approvalRequired === true
952
+ ? " The person must approve this handoff first; their card appears after your turn ends."
953
+ : "";
954
+ const timing = r.state === "queued"
955
+ ? ` @${String(r.botName ?? "that bot")} can run ${Number(r.limit) || 0} threads at once and they are all spoken for, so it waits ${ordinal(Number(r.position) || 1)} in line for a free slot after this turn ends.`
956
+ : " It starts when this turn ends, like any handoff.";
957
+ return {
958
+ text: `${opened}${timing}${approval} Its result will be delivered to this conversation automatically (delegation id: ${delegationId || "unknown"}). Acknowledge it, mention it to the person as #${threadTitle}, and finish your turn; do not check or wait for it in this turn.`,
959
+ };
960
+ }
832
961
  if (name === "create_bot") {
833
962
  const botName = String(args.name ?? "").trim();
834
963
  const role = String(args.role ?? "").trim();
@@ -51,6 +51,7 @@ export function claudeLoginCode(value) {
51
51
  const NOT_AVAILABLE = "This sign-in is no longer available. Start sign-in again.";
52
52
  export class ClaudeLoginController {
53
53
  flow = null;
54
+ logoutChild = null;
54
55
  disposed = false;
55
56
  options;
56
57
  constructor(options) {
@@ -149,20 +150,112 @@ export class ClaudeLoginController {
149
150
  await this.flow?.stopping;
150
151
  if (this.flow?.terminationFailed)
151
152
  throw new Error(this.flow.status.message);
153
+ if (this.logoutChild && !await this.stopChild(this.logoutChild)) {
154
+ throw new Error("Claude Code could not be stopped on this server. Ask the server administrator to stop the account command before trying again.");
155
+ }
152
156
  }
153
157
  async dispose() {
154
158
  this.disposed = true;
155
159
  await this.cancel();
156
160
  this.flow = null;
157
161
  }
162
+ /** Remove the sign-in Claude Code stores for this server account (its
163
+ * configuration directory) so a different subscription can sign in. A
164
+ * sign-in in progress is never pulled away underneath the browser
165
+ * completing it, and the CLI's own status is the verdict, as for sign-in. */
166
+ async signOut() {
167
+ if (this.disposed)
168
+ throw new Error("This provider was removed. Refresh Settings before signing out.");
169
+ if (this.flow?.status.phase === "waiting")
170
+ throw new Error("Finish or cancel the Claude sign-in in progress before signing out.");
171
+ await this.flow?.stopping;
172
+ if (this.disposed)
173
+ throw new Error("This provider was removed. Refresh Settings before signing out.");
174
+ const env = this.options.environment();
175
+ const home = env.HOME || env.USERPROFILE || homedir();
176
+ if (!isAbsolute(home) || (env.CLAUDE_CONFIG_DIR && !isAbsolute(env.CLAUDE_CONFIG_DIR))) {
177
+ throw new Error("Use an absolute HOME and CLAUDE_CONFIG_DIR path for this server's Claude provider before signing out.");
178
+ }
179
+ const homeKey = canonicalPath(resolve(env.CLAUDE_CONFIG_DIR || join(home, ".claude")));
180
+ if (authenticatingHomes.has(homeKey)) {
181
+ throw new Error("A Claude sign-in is running for this server account. Finish or cancel it before signing out.");
182
+ }
183
+ authenticatingHomes.add(homeKey);
184
+ try {
185
+ await this.logout(env);
186
+ if (this.disposed)
187
+ throw new Error("This provider was removed. Refresh Settings before signing out.");
188
+ const status = await this.authStatus(env);
189
+ if (status === "in")
190
+ throw new Error("Claude Code still reports a sign-in on this server. Update Claude Code and check its auth status before trying again.");
191
+ if (status === "unknown")
192
+ throw new Error("Claude Code could not confirm the sign-out on this server. Update Claude Code and check its auth status before trying again.");
193
+ }
194
+ finally {
195
+ // A command the OS could not stop may still change credentials. Keep
196
+ // this home reserved until that exact child actually exits.
197
+ const child = this.logoutChild;
198
+ if (child && child.exitCode === null && child.signalCode === null) {
199
+ child.once("close", () => authenticatingHomes.delete(homeKey));
200
+ }
201
+ else
202
+ authenticatingHomes.delete(homeKey);
203
+ }
204
+ }
205
+ /** `claude auth logout`, bounded and non-interactive. Its output stays
206
+ * here; the status command afterwards decides whether it worked. */
207
+ logout(env) {
208
+ return new Promise((resolveDone, rejectDone) => {
209
+ if (this.disposed) {
210
+ rejectDone(new Error("This provider was removed. Refresh Settings before signing out."));
211
+ return;
212
+ }
213
+ let child;
214
+ try {
215
+ child = spawnCli(this.options.cli, ["auth", "logout"], { env: { ...env, NO_COLOR: "1" }, stdio: ["pipe", "pipe", "pipe"] });
216
+ }
217
+ catch {
218
+ rejectDone(new Error("Claude Code could not start on this server. Install or update the configured Claude CLI, then try again."));
219
+ return;
220
+ }
221
+ this.logoutChild = child;
222
+ child.stdin.end();
223
+ child.stdout.on("data", () => { });
224
+ child.stderr.on("data", () => { });
225
+ // A hung CLI must not hold the credential-home lock forever.
226
+ const timer = setTimeout(() => {
227
+ void this.stopChild(child).catch(() => false).then((stopped) => {
228
+ if (!stopped)
229
+ rejectDone(new Error("Claude Code could not be stopped on this server. Ask the server administrator to stop the account command before trying again."));
230
+ });
231
+ }, this.options.startupTimeoutMs ?? 30_000);
232
+ timer.unref();
233
+ child.once("error", (error) => {
234
+ clearTimeout(timer);
235
+ rejectDone(new Error(error.code === "ENOENT"
236
+ ? "Claude Code is not installed on this server. Install it on the server, then try again."
237
+ : "Claude Code could not start on this server. Check the configured CLI path and its executable permissions."));
238
+ });
239
+ child.once("close", () => {
240
+ clearTimeout(timer);
241
+ if (this.logoutChild === child)
242
+ this.logoutChild = null;
243
+ resolveDone();
244
+ });
245
+ });
246
+ }
158
247
  signedIn(env) {
248
+ return this.authStatus(env).then((status) => status === "in");
249
+ }
250
+ /** `claude auth status --json`: signed in, signed out, or not answered. */
251
+ authStatus(env) {
159
252
  return new Promise((resolveStatus) => {
160
253
  let child;
161
254
  try {
162
255
  child = spawnCli(this.options.cli, ["auth", "status", "--json"], { env: { ...env, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
163
256
  }
164
257
  catch {
165
- resolveStatus(false);
258
+ resolveStatus("unknown");
166
259
  return;
167
260
  }
168
261
  let stdout = "";
@@ -173,21 +266,22 @@ export class ClaudeLoginController {
173
266
  child.stderr.on("data", () => { });
174
267
  const timer = setTimeout(() => {
175
268
  child.kill("SIGKILL");
176
- resolveStatus(false);
269
+ resolveStatus("unknown");
177
270
  }, 8_000);
178
271
  timer.unref();
179
272
  child.once("error", () => {
180
273
  clearTimeout(timer);
181
- resolveStatus(false);
274
+ resolveStatus("unknown");
182
275
  });
183
- child.once("close", () => {
276
+ child.once("close", (code) => {
184
277
  clearTimeout(timer);
185
278
  try {
186
279
  const status = JSON.parse(stdout);
187
- resolveStatus(typeof status === "object" && status !== null && Reflect.get(status, "loggedIn") === true);
280
+ const loggedIn = typeof status === "object" && status !== null ? Reflect.get(status, "loggedIn") : undefined;
281
+ resolveStatus(code === 0 && loggedIn === true ? "in" : code === 1 && loggedIn === false ? "out" : "unknown");
188
282
  }
189
283
  catch {
190
- resolveStatus(false);
284
+ resolveStatus("unknown");
191
285
  }
192
286
  });
193
287
  });
@@ -286,7 +380,7 @@ export class ClaudeLoginController {
286
380
  flow.completion.reject(new Error(message ?? "Claude sign-in did not finish."));
287
381
  flow.completion = undefined;
288
382
  }
289
- flow.stopping = this.stopChild(flow)
383
+ flow.stopping = this.stopChild(flow.child)
290
384
  .catch(() => false)
291
385
  .then((stopped) => {
292
386
  const release = () => authenticatingHomes.delete(flow.homeKey);
@@ -305,8 +399,7 @@ export class ClaudeLoginController {
305
399
  if (phase === "succeeded")
306
400
  void this.options.onAuthenticated?.().catch(() => { });
307
401
  }
308
- async stopChild(flow) {
309
- const child = flow.child;
402
+ async stopChild(child) {
310
403
  if (!child || (await killCliTree(child, this.options.terminateTimeoutMs ?? 1500)))
311
404
  return true;
312
405
  if (process.platform !== "win32" && child.pid) {