@sideboard-ai/core 0.1.44 → 0.1.45

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.
@@ -133,11 +133,15 @@ function humanizeAgentFailDetail(detail) {
133
133
  }
134
134
  function formatTurnExitError(exitCode, stderrSummary) {
135
135
  const code = exitCode ?? 1;
136
- const detail = humanizeAgentFailDetail(stderrSummary);
136
+ const raw = stderrSummary.trim();
137
+ if (/^exit\s*\d+$/i.test(raw)) {
138
+ return `exit ${code}: agent exited without details (credits, auth, rate limits, or a CLI error)`;
139
+ }
140
+ const detail = humanizeAgentFailDetail(raw);
137
141
  if (!detail) {
138
142
  return `exit ${code}: agent exited without details (credits, auth, rate limits, or a CLI error)`;
139
143
  }
140
- if (looksLikeAgentFailureMessage(stderrSummary)) return detail;
144
+ if (looksLikeAgentFailureMessage(raw)) return detail;
141
145
  return `exit ${code}: ${detail}`;
142
146
  }
143
147
  var NODE_VERSION_FOOTER;
@@ -477,7 +481,9 @@ function normalizeThread(raw) {
477
481
  attachments: Array.isArray(raw.attachments) ? raw.attachments : [],
478
482
  prTitle: raw.prTitle ?? null,
479
483
  userSetTitle: Boolean(raw.userSetTitle),
480
- activeRuns: Array.isArray(raw.activeRuns) ? raw.activeRuns : []
484
+ activeRuns: Array.isArray(raw.activeRuns) ? raw.activeRuns : [],
485
+ quotaResumeAt: raw.quotaResumeAt ?? null,
486
+ quotaContinuedFromId: raw.quotaContinuedFromId ?? null
481
487
  };
482
488
  }
483
489
  function createEmptyThread(partial) {
@@ -2631,6 +2637,8 @@ __export(app_settings_exports, {
2631
2637
  isLinearConnected: () => isLinearConnected,
2632
2638
  loadAppSettings: () => loadAppSettings,
2633
2639
  maxConcurrentAgents: () => maxConcurrentAgents,
2640
+ orchestrationQuotaFallbackAgent: () => orchestrationQuotaFallbackAgent,
2641
+ orchestrationQuotaOnLimit: () => orchestrationQuotaOnLimit,
2634
2642
  resolveClaudeExecutable: () => resolveClaudeExecutable,
2635
2643
  resolveEffectiveIssueSource: () => resolveEffectiveIssueSource,
2636
2644
  resolveThreadDefaults: () => resolveThreadDefaults,
@@ -2742,6 +2750,12 @@ function normalizeAdvanced(raw) {
2742
2750
  if (typeof source.autoCleanupOrphans === "boolean") {
2743
2751
  out.autoCleanupOrphans = source.autoCleanupOrphans;
2744
2752
  }
2753
+ if (source.orchestrationQuotaOnLimit === "switch_agent" || source.orchestrationQuotaOnLimit === "wait_reset") {
2754
+ out.orchestrationQuotaOnLimit = source.orchestrationQuotaOnLimit;
2755
+ }
2756
+ if (typeof source.orchestrationQuotaFallbackAgent === "string" && DEFAULT_AGENTS.has(source.orchestrationQuotaFallbackAgent)) {
2757
+ out.orchestrationQuotaFallbackAgent = source.orchestrationQuotaFallbackAgent;
2758
+ }
2745
2759
  return out;
2746
2760
  }
2747
2761
  function normalizeSettings(raw) {
@@ -2976,6 +2990,12 @@ function updateAdvancedSettings(patch) {
2976
2990
  if (typeof patch.autoCleanupOrphans === "boolean") {
2977
2991
  advanced.autoCleanupOrphans = patch.autoCleanupOrphans;
2978
2992
  }
2993
+ if (patch.orchestrationQuotaOnLimit === "switch_agent" || patch.orchestrationQuotaOnLimit === "wait_reset") {
2994
+ advanced.orchestrationQuotaOnLimit = patch.orchestrationQuotaOnLimit;
2995
+ }
2996
+ if (typeof patch.orchestrationQuotaFallbackAgent === "string" && DEFAULT_AGENTS.has(patch.orchestrationQuotaFallbackAgent)) {
2997
+ advanced.orchestrationQuotaFallbackAgent = patch.orchestrationQuotaFallbackAgent;
2998
+ }
2979
2999
  return saveAppSettings({ ...current, advanced });
2980
3000
  }
2981
3001
  function autoRenameBranchEnabled(settings = loadAppSettings()) {
@@ -2996,6 +3016,13 @@ function deleteBranchOnPurgeEnabled(settings = loadAppSettings()) {
2996
3016
  function autoCleanupOrphansEnabled(settings = loadAppSettings()) {
2997
3017
  return Boolean(settings.advanced.autoCleanupOrphans);
2998
3018
  }
3019
+ function orchestrationQuotaOnLimit(settings = loadAppSettings()) {
3020
+ return settings.advanced.orchestrationQuotaOnLimit ?? "switch_agent";
3021
+ }
3022
+ function orchestrationQuotaFallbackAgent(settings = loadAppSettings()) {
3023
+ const preferred = settings.advanced.orchestrationQuotaFallbackAgent;
3024
+ return preferred && DEFAULT_AGENTS.has(preferred) ? preferred : "cursor";
3025
+ }
2999
3026
  function maxConcurrentAgents(settings = loadAppSettings()) {
3000
3027
  const n = settings.advanced.maxConcurrent;
3001
3028
  if (typeof n === "number" && Number.isFinite(n)) {
@@ -3223,11 +3250,14 @@ var init_coordinator_prompt = __esm({
3223
3250
  "Discover:",
3224
3251
  "- list_workspaces \u2014 registered repos (path + github slug when known)",
3225
3252
  "- list_branches / list_prs / list_issues \u2014 pass repoPath from list_workspaces (issues: Linear API or GitHub Issues)",
3253
+ "- list_models \u2014 only when you need a specific model (rare); otherwise leave model unset = Auto",
3226
3254
  "- list_threads / get_thread \u2014 fleet status (what is going on)",
3227
3255
  "Workspaces:",
3228
3256
  "- add_workspace / remove_workspace \u2014 register or unregister a git repo",
3229
3257
  "Worktree threads (chats):",
3230
3258
  "- create_thread \u2014 create a worktree + chat from branch | pr | ticket; pass repoPath + parentThreadId",
3259
+ "- fork_worktree \u2014 fork a worktree chat into a NEW git worktree + chat (transcript attached); optional agent; leave model unset (Auto) unless you have a reason. Not for orchestration chats.",
3260
+ "- fork_chat \u2014 fork a worktree chat (same worktree tab) OR a Global orchestration chat (new orchestration tab); optional agent; leave model unset (Auto) unless you have a reason. Remote coordinators: use this to continue another orchestration chat on a different agent after session limits.",
3231
3261
  "- send_to_thread \u2014 queue a prompt (start/continue a chat turn); pass force_stop: true to interrupt mid-turn / clear stale queued prompts before replacing with a new request",
3232
3262
  "- wait_for_turn / get_turn_result \u2014 wait for and read the agent reply",
3233
3263
  "- stop_thread \u2014 force-stop: kill in-flight turn AND clear queued prompts (do not leave stale queue after an interrupt)",
@@ -5322,6 +5352,199 @@ var init_opencode = __esm({
5322
5352
  }
5323
5353
  });
5324
5354
 
5355
+ // src/agents/list-models.ts
5356
+ async function listBrightsyModels() {
5357
+ try {
5358
+ const targets = await listBrightsyChatTargets();
5359
+ const accountId = targets.activeAccountId;
5360
+ const models = (targets.models ?? []).map((m) => ({
5361
+ id: encodeBrightsyTarget("model", m.id, accountId),
5362
+ displayName: m.name || m.id,
5363
+ description: m.description ?? void 0
5364
+ }));
5365
+ const agents = (targets.agents ?? []).map((a) => ({
5366
+ id: encodeBrightsyTarget("agent", a.id, accountId),
5367
+ displayName: a.name || a.id,
5368
+ description: a.description ?? "Brightsy agent target"
5369
+ }));
5370
+ return [...models, ...agents].slice(0, 80);
5371
+ } catch {
5372
+ return [];
5373
+ }
5374
+ }
5375
+ async function listModelsForAgent(agent) {
5376
+ const kinds = agent ? [agent] : ["claude", "codex", "opencode", "cursor", "brightsy"];
5377
+ const out = [];
5378
+ for (const kind of kinds) {
5379
+ if (kind === "claude") {
5380
+ out.push({
5381
+ agent: kind,
5382
+ auto: true,
5383
+ models: CLAUDE_MODEL_CATALOG,
5384
+ note: "Default Auto \u2014 only pass a model id when you have a reason."
5385
+ });
5386
+ continue;
5387
+ }
5388
+ if (kind === "codex") {
5389
+ out.push({
5390
+ agent: kind,
5391
+ auto: true,
5392
+ models: await listCodexModels(),
5393
+ note: "Default Auto \u2014 only pass a model slug when you have a reason."
5394
+ });
5395
+ continue;
5396
+ }
5397
+ if (kind === "opencode") {
5398
+ out.push({
5399
+ agent: kind,
5400
+ auto: true,
5401
+ models: await listOpencodeModels(),
5402
+ note: "Default Auto \u2014 only pass a provider/model id when you have a reason."
5403
+ });
5404
+ continue;
5405
+ }
5406
+ if (kind === "cursor") {
5407
+ out.push({
5408
+ agent: kind,
5409
+ auto: true,
5410
+ models: await listCursorModels(),
5411
+ note: 'Default Auto \u2014 only pass a model id when you have a reason (or use "default").'
5412
+ });
5413
+ continue;
5414
+ }
5415
+ if (kind === "brightsy") {
5416
+ const models = await listBrightsyModels();
5417
+ out.push({
5418
+ agent: kind,
5419
+ auto: true,
5420
+ models,
5421
+ note: models.length ? "Default Auto / Default agent \u2014 only pass a model/agent id when you have a reason." : "Brightsy not logged in or no targets \u2014 leave model unset for Default."
5422
+ });
5423
+ }
5424
+ }
5425
+ return out;
5426
+ }
5427
+ var CLAUDE_MODEL_CATALOG;
5428
+ var init_list_models = __esm({
5429
+ "src/agents/list-models.ts"() {
5430
+ "use strict";
5431
+ init_brightsy();
5432
+ init_brightsy_targets();
5433
+ init_codex();
5434
+ init_cursor();
5435
+ init_opencode();
5436
+ CLAUDE_MODEL_CATALOG = [
5437
+ { id: "fable", displayName: "Fable" },
5438
+ { id: "opus", displayName: "Opus" },
5439
+ { id: "sonnet", displayName: "Sonnet" },
5440
+ { id: "haiku", displayName: "Haiku" }
5441
+ ];
5442
+ }
5443
+ });
5444
+
5445
+ // src/agents/session-quota.ts
5446
+ function isSessionQuotaLimit(text) {
5447
+ const lower = text.trim().toLowerCase();
5448
+ if (!lower) return false;
5449
+ if (/credit balance is too low|out of credits|insufficient.?quota|billing/.test(lower)) {
5450
+ return false;
5451
+ }
5452
+ if (/prompt is too long|context.*(too long|exceed)|conversation too long/.test(lower)) {
5453
+ return false;
5454
+ }
5455
+ return /you've hit your/.test(lower) || /hit your (session|weekly|opus) limit/.test(lower) || /usage limit/.test(lower) || /rate.?limit|too many requests|\b429\b/.test(lower) && /reset/i.test(text);
5456
+ }
5457
+ function parseSessionQuotaResetAt(text, now = /* @__PURE__ */ new Date()) {
5458
+ const absolute = text.match(
5459
+ /resets\s+(?:at\s+)?(\d{1,2}):(\d{2})\s*(am|pm)(?:\s*\(([^)]+)\))?/i
5460
+ );
5461
+ if (absolute) {
5462
+ const hour12 = Number(absolute[1]);
5463
+ const minute = Number(absolute[2]);
5464
+ const ampm = absolute[3].toLowerCase();
5465
+ const timeZone = absolute[4]?.trim() || Intl.DateTimeFormat().resolvedOptions().timeZone;
5466
+ let hour = hour12 % 12;
5467
+ if (ampm === "pm") hour += 12;
5468
+ const at = zonedWallTimeToUtc(now, hour, minute, timeZone);
5469
+ if (!at) return null;
5470
+ if (at.getTime() <= now.getTime() + 3e4) {
5471
+ const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1e3);
5472
+ return zonedWallTimeToUtc(tomorrow, hour, minute, timeZone);
5473
+ }
5474
+ return at;
5475
+ }
5476
+ const relative = text.match(
5477
+ /resets\s+in\s+(\d+)\s*(minutes?|hours?|days?)/i
5478
+ );
5479
+ if (relative) {
5480
+ const n = Number(relative[1]);
5481
+ const unit = relative[2].toLowerCase();
5482
+ const ms = unit.startsWith("day") ? n * 24 * 60 * 60 * 1e3 : unit.startsWith("hour") ? n * 60 * 60 * 1e3 : n * 60 * 1e3;
5483
+ return new Date(now.getTime() + ms);
5484
+ }
5485
+ return null;
5486
+ }
5487
+ function zonedWallTimeToUtc(day, hour, minute, timeZone) {
5488
+ try {
5489
+ const cal = new Intl.DateTimeFormat("en-US", {
5490
+ timeZone,
5491
+ year: "numeric",
5492
+ month: "2-digit",
5493
+ day: "2-digit"
5494
+ });
5495
+ const parts = Object.fromEntries(
5496
+ cal.formatToParts(day).filter((p) => p.type !== "literal").map((p) => [p.type, p.value])
5497
+ );
5498
+ const year = Number(parts.year);
5499
+ const month = Number(parts.month);
5500
+ const date = Number(parts.day);
5501
+ if (![year, month, date].every((n) => Number.isFinite(n))) return null;
5502
+ const utcGuess = Date.UTC(year, month - 1, date, hour, minute, 0);
5503
+ const dtf = new Intl.DateTimeFormat("en-US", {
5504
+ timeZone,
5505
+ year: "numeric",
5506
+ month: "2-digit",
5507
+ day: "2-digit",
5508
+ hour: "2-digit",
5509
+ minute: "2-digit",
5510
+ second: "2-digit",
5511
+ hourCycle: "h23"
5512
+ });
5513
+ const asParts = Object.fromEntries(
5514
+ dtf.formatToParts(new Date(utcGuess)).filter((p) => p.type !== "literal").map((p) => [p.type, p.value])
5515
+ );
5516
+ const asUtc = Date.UTC(
5517
+ Number(asParts.year),
5518
+ Number(asParts.month) - 1,
5519
+ Number(asParts.day),
5520
+ Number(asParts.hour),
5521
+ Number(asParts.minute),
5522
+ Number(asParts.second || "0")
5523
+ );
5524
+ const offset = asUtc - utcGuess;
5525
+ return new Date(utcGuess - offset);
5526
+ } catch {
5527
+ return null;
5528
+ }
5529
+ }
5530
+ function resolveQuotaFallbackAgent(current, preferred) {
5531
+ const ordered = preferred ? [preferred, ...FALLBACK_ORDER.filter((a) => a !== preferred)] : FALLBACK_ORDER;
5532
+ return ordered.find((a) => a !== current) ?? (current === "cursor" ? "codex" : "cursor");
5533
+ }
5534
+ var FALLBACK_ORDER;
5535
+ var init_session_quota = __esm({
5536
+ "src/agents/session-quota.ts"() {
5537
+ "use strict";
5538
+ FALLBACK_ORDER = [
5539
+ "cursor",
5540
+ "codex",
5541
+ "opencode",
5542
+ "brightsy",
5543
+ "claude"
5544
+ ];
5545
+ }
5546
+ });
5547
+
5325
5548
  // src/agents/install.ts
5326
5549
  function getAgentSetupInfo(agent) {
5327
5550
  return SETUP[agent];
@@ -5502,6 +5725,7 @@ var init_install = __esm({
5502
5725
  // src/agents/index.ts
5503
5726
  var agents_exports = {};
5504
5727
  __export(agents_exports, {
5728
+ CLAUDE_MODEL_CATALOG: () => CLAUDE_MODEL_CATALOG,
5505
5729
  PLAN_MODE_INSTRUCTION: () => PLAN_MODE_INSTRUCTION,
5506
5730
  allAdapters: () => allAdapters,
5507
5731
  brightsyAdapter: () => brightsyAdapter,
@@ -5516,17 +5740,21 @@ __export(agents_exports, {
5516
5740
  getAgentSetupInfo: () => getAgentSetupInfo,
5517
5741
  installAgent: () => installAgent,
5518
5742
  isCursorAutoModel: () => isCursorAutoModel,
5743
+ isSessionQuotaLimit: () => isSessionQuotaLimit,
5519
5744
  listAgentSetupInfo: () => listAgentSetupInfo,
5520
5745
  listBrightsyChatTargets: () => listBrightsyChatTargets,
5521
5746
  listCodexModels: () => listCodexModels,
5522
5747
  listCursorModels: () => listCursorModels,
5748
+ listModelsForAgent: () => listModelsForAgent,
5523
5749
  listOpencodeModels: () => listOpencodeModels,
5524
5750
  loginAgent: () => loginAgent,
5525
5751
  openInSystemTerminal: () => openInSystemTerminal,
5526
5752
  opencodeAdapter: () => opencodeAdapter,
5527
5753
  parseCursorRunnerLine: () => parseCursorRunnerLine,
5754
+ parseSessionQuotaResetAt: () => parseSessionQuotaResetAt,
5528
5755
  permissionMode: () => permissionMode,
5529
- resolveCursorModelId: () => resolveCursorModelId
5756
+ resolveCursorModelId: () => resolveCursorModelId,
5757
+ resolveQuotaFallbackAgent: () => resolveQuotaFallbackAgent
5530
5758
  });
5531
5759
  function getAdapter(kind) {
5532
5760
  return adapters[kind];
@@ -5552,6 +5780,8 @@ var init_agents = __esm({
5552
5780
  init_cursor_events();
5553
5781
  init_cursor();
5554
5782
  init_opencode();
5783
+ init_list_models();
5784
+ init_session_quota();
5555
5785
  init_path();
5556
5786
  init_install();
5557
5787
  adapters = {
@@ -7175,12 +7405,19 @@ function forkChatTab(input) {
7175
7405
  const from = requireThread(input.threadId);
7176
7406
  const slice = forkMessageSlice(from, input.throughIndex);
7177
7407
  const attachment = buildForkTranscriptAttachment(from.title || "Chat", slice);
7178
- return createChatTab({
7408
+ const tab = createChatTab({
7179
7409
  fromThreadId: input.threadId,
7180
7410
  agent: input.agent ?? from.agent,
7411
+ model: input.model,
7181
7412
  title: input.title?.trim() || void 0,
7182
7413
  attachments: [attachment]
7183
7414
  });
7415
+ if (isOrchestratorThread(from) && tab.parentThreadId !== from.id) {
7416
+ const next = { ...tab, parentThreadId: from.id };
7417
+ writeThread(next);
7418
+ return next;
7419
+ }
7420
+ return tab;
7184
7421
  }
7185
7422
 
7186
7423
  // src/review/request-review.ts
@@ -7237,6 +7474,7 @@ async function requestReview(threadRef, send) {
7237
7474
 
7238
7475
  // src/threads/fork-worktree.ts
7239
7476
  init_thread_store();
7477
+ init_global_workspace();
7240
7478
  function requireThread2(idOrRef) {
7241
7479
  const thread = findThreadByRef(idOrRef) ?? null;
7242
7480
  if (!thread) throw new Error(`Thread not found: ${idOrRef}`);
@@ -7244,16 +7482,28 @@ function requireThread2(idOrRef) {
7244
7482
  }
7245
7483
  async function forkThreadWorktree(input, onSetupLine) {
7246
7484
  const from = requireThread2(input.threadId);
7485
+ if (isOrchestratorThread(from)) {
7486
+ throw new Error(
7487
+ "fork_worktree targets a worktree agent thread (not the orchestrator). Pass a child/worktree thread ref."
7488
+ );
7489
+ }
7490
+ if (!from.branchName?.trim() || !from.repoPath?.trim()) {
7491
+ throw new Error(
7492
+ `Cannot fork worktree: thread ${from.id} has no branch/repo (need a real worktree chat).`
7493
+ );
7494
+ }
7247
7495
  const slice = forkMessageSlice(from, input.throughIndex);
7248
7496
  const attachment = buildForkTranscriptAttachment(from.title || "Chat", slice);
7497
+ const nextAgent = input.agent ?? from.agent;
7498
+ const nextModel = input.model !== void 0 ? input.model : input.agent && input.agent !== from.agent ? null : from.model;
7249
7499
  const thread = await createThread(
7250
7500
  {
7251
7501
  sourceType: "branch",
7252
7502
  sourceRef: from.branchName,
7253
7503
  repoPath: from.repoPath,
7254
- agent: input.agent ?? from.agent,
7504
+ agent: nextAgent,
7255
7505
  autonomy: from.autonomy,
7256
- model: from.model,
7506
+ model: nextModel,
7257
7507
  effort: from.effort,
7258
7508
  fast: from.fast,
7259
7509
  planMode: from.planMode,
@@ -7266,6 +7516,115 @@ async function forkThreadWorktree(input, onSetupLine) {
7266
7516
  return thread;
7267
7517
  }
7268
7518
 
7519
+ // src/orchestrator/quota-failover.ts
7520
+ var import_node_crypto4 = require("crypto");
7521
+ init_session_quota();
7522
+ init_app_settings();
7523
+ init_global_workspace();
7524
+ init_thread_store();
7525
+ function planOrchestrationQuotaFailover(thread, limitText, opts) {
7526
+ if (!isOrchestratorThread(thread)) return null;
7527
+ if (!isSessionQuotaLimit(limitText)) return null;
7528
+ const onLimit = opts?.onLimit ?? orchestrationQuotaOnLimit();
7529
+ const resumeAt = parseSessionQuotaResetAt(limitText, opts?.now);
7530
+ if (thread.quotaContinuedFromId) {
7531
+ if (resumeAt) {
7532
+ return {
7533
+ action: "wait_reset",
7534
+ reason: "Already continued once; waiting for quota reset instead.",
7535
+ limitText,
7536
+ resumeAt
7537
+ };
7538
+ }
7539
+ return {
7540
+ action: "none",
7541
+ reason: "Already continued once; no parseable reset time.",
7542
+ limitText
7543
+ };
7544
+ }
7545
+ if (onLimit === "wait_reset") {
7546
+ if (!resumeAt) {
7547
+ return {
7548
+ action: "none",
7549
+ reason: "wait_reset configured but reset time could not be parsed.",
7550
+ limitText
7551
+ };
7552
+ }
7553
+ return {
7554
+ action: "wait_reset",
7555
+ reason: "Settings: wait for quota reset.",
7556
+ limitText,
7557
+ resumeAt
7558
+ };
7559
+ }
7560
+ const preferred = opts?.fallbackAgent ?? orchestrationQuotaFallbackAgent();
7561
+ const fallbackAgent = resolveQuotaFallbackAgent(thread.agent, preferred);
7562
+ return {
7563
+ action: "switch_agent",
7564
+ reason: `Continue on ${fallbackAgent} (Auto) after ${thread.agent} session limit.`,
7565
+ limitText,
7566
+ fallbackAgent
7567
+ };
7568
+ }
7569
+ function buildQuotaHandoffAttachment(from, limitText, fallbackAgent) {
7570
+ const children = listThreads({ includeArchived: false }).filter((t) => t.parentThreadId === from.id && t.status !== "archived").slice(0, 40).map(
7571
+ (t) => `- ${t.title} \xB7 ${t.status} \xB7 ${t.agent} \xB7 sideboard://thread/${t.id}`
7572
+ );
7573
+ const recent = from.messages.slice(-8).map((m) => {
7574
+ const role = m.role === "user" ? "User" : m.role === "agent" ? "Agent" : "Summary";
7575
+ const text = m.text.trim().replace(/\s+/g, " ").slice(0, 280);
7576
+ return text ? `- ${role}: ${text}` : null;
7577
+ }).filter(Boolean);
7578
+ const body = [
7579
+ `# Orchestration handoff`,
7580
+ "",
7581
+ `Previous chat: ${from.title} (\`${from.id}\`) on **${from.agent}** hit a session/usage limit.`,
7582
+ `Limit: ${limitText.trim()}`,
7583
+ `Continuing on **${fallbackAgent}** with Auto model.`,
7584
+ "",
7585
+ `## Goal`,
7586
+ from.sourceRef?.trim() || "(none)",
7587
+ "",
7588
+ `## Child threads`,
7589
+ children.length ? children.join("\n") : "(none listed \u2014 call list_threads)",
7590
+ "",
7591
+ `## Recent turns (truncated)`,
7592
+ recent.length ? recent.join("\n") : "(none)",
7593
+ "",
7594
+ `## Instructions`,
7595
+ `- Continue fleet orchestration from this handoff.`,
7596
+ `- Prefer Sideboard MCP (list_threads, get_thread, send_to_thread, \u2026) for live status.`,
7597
+ `- Leave model Auto unless there is a specific reason to pin one.`,
7598
+ `- Do not wait on the limited ${from.agent} account; keep going on ${fallbackAgent}.`
7599
+ ].join("\n");
7600
+ return {
7601
+ id: (0, import_node_crypto4.randomUUID)(),
7602
+ name: "Orchestration quota handoff.md",
7603
+ kind: "transcript",
7604
+ content: body
7605
+ };
7606
+ }
7607
+ var QUOTA_CONTINUE_PROMPT = (fromAgent, fallback) => [
7608
+ `${fromAgent} hit a session/usage limit. Continue this orchestration on ${fallback} using the attached handoff.`,
7609
+ "Call list_threads for live fleet status, then proceed with the goal. Leave model Auto unless needed."
7610
+ ].join(" ");
7611
+ var QUOTA_RESUME_PROMPT = "Session/usage limit window should have reset. Continue the orchestration from where you left off. Use list_threads for fleet status.";
7612
+ function createQuotaFailoverChat(from, fallbackAgent, limitText) {
7613
+ const handoff = buildQuotaHandoffAttachment(from, limitText, fallbackAgent);
7614
+ const tab = createChatTab({
7615
+ fromThreadId: from.id,
7616
+ agent: fallbackAgent,
7617
+ model: null,
7618
+ attachments: [handoff]
7619
+ });
7620
+ return updateThread(tab.id, {
7621
+ parentThreadId: from.id,
7622
+ quotaContinuedFromId: from.id,
7623
+ sourceRef: from.sourceRef,
7624
+ sourceType: "orchestration"
7625
+ });
7626
+ }
7627
+
7269
7628
  // src/threads/adopt.ts
7270
7629
  var import_node_child_process = require("child_process");
7271
7630
  var import_node_fs20 = require("fs");
@@ -8469,7 +8828,7 @@ function expandComposerPrompt(worktreePath, prompt, opts) {
8469
8828
  // src/composer/stage-files.ts
8470
8829
  var import_node_fs23 = require("fs");
8471
8830
  var import_node_path22 = require("path");
8472
- var import_node_crypto4 = require("crypto");
8831
+ var import_node_crypto5 = require("crypto");
8473
8832
  var IMAGE_EXTENSIONS2 = /* @__PURE__ */ new Set([
8474
8833
  "png",
8475
8834
  "jpg",
@@ -8525,7 +8884,7 @@ function uniqueAttachmentName(dir, originalName) {
8525
8884
  const candidate = `${stem}-${i}${ext}`;
8526
8885
  if (!(0, import_node_fs23.existsSync)((0, import_node_path22.join)(dir, candidate))) return candidate;
8527
8886
  }
8528
- return `${stem}-${(0, import_node_crypto4.randomUUID)()}${ext}`;
8887
+ return `${stem}-${(0, import_node_crypto5.randomUUID)()}${ext}`;
8529
8888
  }
8530
8889
  function previewDataUrlFromBuf(filePath, buf) {
8531
8890
  if (!isImageFilePath(filePath)) return void 0;
@@ -8537,7 +8896,7 @@ function attachmentFromBuffer(name, buf, opts) {
8537
8896
  if (isImageFilePath(name)) {
8538
8897
  const pathHint = opts.path ? `\`${opts.path}\`` : opts.sourceLabel || name;
8539
8898
  return {
8540
- id: (0, import_node_crypto4.randomUUID)(),
8899
+ id: (0, import_node_crypto5.randomUUID)(),
8541
8900
  name,
8542
8901
  kind: "file",
8543
8902
  path: opts.path,
@@ -8550,7 +8909,7 @@ function attachmentFromBuffer(name, buf, opts) {
8550
8909
  }
8551
8910
  if (buf.length > MAX_INLINE_BYTES) {
8552
8911
  return {
8553
- id: (0, import_node_crypto4.randomUUID)(),
8912
+ id: (0, import_node_crypto5.randomUUID)(),
8554
8913
  name,
8555
8914
  kind: "file",
8556
8915
  path: opts.path,
@@ -8559,7 +8918,7 @@ function attachmentFromBuffer(name, buf, opts) {
8559
8918
  }
8560
8919
  if (buf.includes(0)) {
8561
8920
  return {
8562
- id: (0, import_node_crypto4.randomUUID)(),
8921
+ id: (0, import_node_crypto5.randomUUID)(),
8563
8922
  name,
8564
8923
  kind: "file",
8565
8924
  path: opts.path,
@@ -8567,7 +8926,7 @@ function attachmentFromBuffer(name, buf, opts) {
8567
8926
  };
8568
8927
  }
8569
8928
  return {
8570
- id: (0, import_node_crypto4.randomUUID)(),
8929
+ id: (0, import_node_crypto5.randomUUID)(),
8571
8930
  name,
8572
8931
  kind: "file",
8573
8932
  path: opts.path,
@@ -8591,7 +8950,7 @@ function stageAbsolutePathsAsAttachments(worktreePath, absolutePaths) {
8591
8950
  out.push(attachmentFromBuffer(name, buf, { path: rel, sourceLabel: abs }));
8592
8951
  } catch (err) {
8593
8952
  out.push({
8594
- id: (0, import_node_crypto4.randomUUID)(),
8953
+ id: (0, import_node_crypto5.randomUUID)(),
8595
8954
  name: originalName,
8596
8955
  kind: "file",
8597
8956
  content: `(could not attach ${abs}: ${err instanceof Error ? err.message : String(err)})`
@@ -8615,7 +8974,7 @@ function stageBuffersAsAttachments(worktreePath, buffers) {
8615
8974
  out.push(attachmentFromBuffer(name, buf, { path: rel }));
8616
8975
  } catch (err) {
8617
8976
  out.push({
8618
- id: (0, import_node_crypto4.randomUUID)(),
8977
+ id: (0, import_node_crypto5.randomUUID)(),
8619
8978
  name: originalName,
8620
8979
  kind: "file",
8621
8980
  content: `(could not attach ${originalName}: ${err instanceof Error ? err.message : String(err)})`
@@ -8629,7 +8988,7 @@ function attachmentsFromWorktreePaths(worktreePath, relativePaths) {
8629
8988
  for (const rel of relativePaths) {
8630
8989
  if (!rel || rel.includes("..") || rel.startsWith("/")) {
8631
8990
  out.push({
8632
- id: (0, import_node_crypto4.randomUUID)(),
8991
+ id: (0, import_node_crypto5.randomUUID)(),
8633
8992
  name: (0, import_node_path22.basename)(rel) || "file",
8634
8993
  kind: "file",
8635
8994
  content: `(invalid path: ${rel})`
@@ -8645,7 +9004,7 @@ function attachmentsFromWorktreePaths(worktreePath, relativePaths) {
8645
9004
  out.push(attachmentFromBuffer(name, buf, { path: rel, sourceLabel: abs }));
8646
9005
  } catch (err) {
8647
9006
  out.push({
8648
- id: (0, import_node_crypto4.randomUUID)(),
9007
+ id: (0, import_node_crypto5.randomUUID)(),
8649
9008
  name,
8650
9009
  kind: "file",
8651
9010
  content: `(could not read ${rel}: ${err instanceof Error ? err.message : String(err)})`
@@ -8901,6 +9260,8 @@ var Orchestrator = class {
8901
9260
  haltDrain = /* @__PURE__ */ new Set();
8902
9261
  /** WIP snapshot SHA at the start of the latest agent turn (per thread). */
8903
9262
  turnBaselines = /* @__PURE__ */ new Map();
9263
+ /** Timers for orchestration session-quota auto-resume. */
9264
+ quotaResumeTimers = /* @__PURE__ */ new Map();
8904
9265
  maxConcurrent;
8905
9266
  runningCount = 0;
8906
9267
  constructor(opts) {
@@ -8980,6 +9341,113 @@ var Orchestrator = class {
8980
9341
  void this.drainQueue(thread.id);
8981
9342
  }
8982
9343
  }
9344
+ this.schedulePendingQuotaResumes();
9345
+ }
9346
+ clearQuotaResumeTimer(threadId) {
9347
+ const timer = this.quotaResumeTimers.get(threadId);
9348
+ if (timer) clearTimeout(timer);
9349
+ this.quotaResumeTimers.delete(threadId);
9350
+ }
9351
+ /** Schedule (or fire) auto-retry after a provider session/usage limit reset. */
9352
+ scheduleQuotaResume(threadId, resumeAt) {
9353
+ this.clearQuotaResumeTimer(threadId);
9354
+ updateThread(threadId, { quotaResumeAt: resumeAt.toISOString() });
9355
+ const delay = Math.max(5e3, resumeAt.getTime() - Date.now());
9356
+ const capped = Math.min(delay, 2147483647);
9357
+ const timer = setTimeout(() => {
9358
+ this.quotaResumeTimers.delete(threadId);
9359
+ void this.resumeAfterQuotaWait(threadId);
9360
+ }, capped);
9361
+ this.quotaResumeTimers.set(threadId, timer);
9362
+ }
9363
+ schedulePendingQuotaResumes() {
9364
+ for (const thread of listThreads({ includeArchived: false })) {
9365
+ if (!thread.quotaResumeAt) continue;
9366
+ const at = new Date(thread.quotaResumeAt);
9367
+ if (Number.isNaN(at.getTime())) continue;
9368
+ if (at.getTime() <= Date.now()) {
9369
+ void this.resumeAfterQuotaWait(thread.id);
9370
+ } else if (!this.quotaResumeTimers.has(thread.id)) {
9371
+ this.scheduleQuotaResume(thread.id, at);
9372
+ }
9373
+ }
9374
+ }
9375
+ async resumeAfterQuotaWait(threadId) {
9376
+ const thread = findThreadByRef(threadId);
9377
+ if (!thread || thread.status === "archived") return;
9378
+ this.clearQuotaResumeTimer(threadId);
9379
+ try {
9380
+ updateThread(threadId, { quotaResumeAt: null });
9381
+ } catch {
9382
+ return;
9383
+ }
9384
+ if (thread.status === "running" || this.activeTurns.has(threadId) || this.startingTurns.has(threadId)) {
9385
+ return;
9386
+ }
9387
+ await this.send(threadId, QUOTA_RESUME_PROMPT);
9388
+ }
9389
+ /**
9390
+ * Host-side continue when an orchestration chat hits a provider session/usage
9391
+ * limit (not context size): switch agent (Auto) or wait until reset.
9392
+ */
9393
+ async maybeHandleOrchestrationQuotaFailover(threadId, limitText) {
9394
+ const thread = findThreadByRef(threadId);
9395
+ if (!thread) return;
9396
+ const plan = planOrchestrationQuotaFailover(thread, limitText);
9397
+ if (!plan || plan.action === "none") return;
9398
+ if (plan.action === "wait_reset" && plan.resumeAt) {
9399
+ this.haltDrain.add(threadId);
9400
+ this.scheduleQuotaResume(threadId, plan.resumeAt);
9401
+ setStatus(threadId, "idle", null);
9402
+ appendMessage(threadId, {
9403
+ role: "agent",
9404
+ text: `Sideboard will auto-retry this orchestration around ${plan.resumeAt.toLocaleString()} when the session limit resets.`,
9405
+ ts: (/* @__PURE__ */ new Date()).toISOString()
9406
+ });
9407
+ this.emit({
9408
+ type: "quota_failover",
9409
+ threadId,
9410
+ action: "wait_reset",
9411
+ message: plan.reason,
9412
+ resumeAt: plan.resumeAt.toISOString()
9413
+ });
9414
+ this.emit({ type: "status_changed", threadId, status: "idle" });
9415
+ return;
9416
+ }
9417
+ if (plan.action === "switch_agent" && plan.fallbackAgent) {
9418
+ this.haltDrain.add(threadId);
9419
+ const next = createQuotaFailoverChat(
9420
+ thread,
9421
+ plan.fallbackAgent,
9422
+ plan.limitText
9423
+ );
9424
+ this.clearQuotaResumeTimer(threadId);
9425
+ try {
9426
+ updateThread(threadId, { quotaResumeAt: null });
9427
+ } catch {
9428
+ }
9429
+ appendMessage(threadId, {
9430
+ role: "agent",
9431
+ text: `Session limit on ${thread.agent}. Sideboard continued on ${plan.fallbackAgent} (Auto) in [${next.title}](sideboard://thread/${next.id}).`,
9432
+ ts: (/* @__PURE__ */ new Date()).toISOString()
9433
+ });
9434
+ this.emit({
9435
+ type: "quota_failover",
9436
+ threadId,
9437
+ action: "switch_agent",
9438
+ toThreadId: next.id,
9439
+ message: plan.reason
9440
+ });
9441
+ this.emit({
9442
+ type: "status_changed",
9443
+ threadId: next.id,
9444
+ status: next.status
9445
+ });
9446
+ await this.send(
9447
+ next.id,
9448
+ QUOTA_CONTINUE_PROMPT(thread.agent, plan.fallbackAgent)
9449
+ );
9450
+ }
8983
9451
  }
8984
9452
  getThreads(includeArchived = false) {
8985
9453
  return listThreads({ includeArchived });
@@ -9338,11 +9806,16 @@ var Orchestrator = class {
9338
9806
  }
9339
9807
  }
9340
9808
  }
9341
- const failureOnlyMessage = exitCode !== 0 && looksLikeAgentFailureMessage(assistantText) && !parts.some((p) => p.type === "tool" || p.type === "thinking");
9342
- if (!failureOnlyMessage && (assistantText || parts.length > 0)) {
9809
+ const lastStderr = summarizeTurnStderr(stderrTail);
9810
+ const detail = lastStderr || (exitCode !== 0 ? fallbackTurnFailDetail(assistantText) : "");
9811
+ let chatText = assistantText;
9812
+ if (exitCode !== 0 && !chatText && looksLikeAgentFailureMessage(detail)) {
9813
+ chatText = humanizeAgentFailDetail(detail);
9814
+ }
9815
+ if (chatText || parts.length > 0) {
9343
9816
  appendMessage(threadId, {
9344
9817
  role: "agent",
9345
- text: assistantText,
9818
+ text: chatText,
9346
9819
  parts: parts.length > 0 ? parts : void 0,
9347
9820
  durationMs: Math.max(0, Date.now() - turnStartedAt),
9348
9821
  usage,
@@ -9361,13 +9834,12 @@ var Orchestrator = class {
9361
9834
  this.emit({ type: "status_changed", threadId, status: "stopped" });
9362
9835
  this.emit({ type: "turn_finished", threadId, exitCode });
9363
9836
  } else {
9364
- const lastStderr = summarizeTurnStderr(stderrTail);
9365
- const detail = lastStderr || (exitCode !== 0 ? fallbackTurnFailDetail(assistantText) : "");
9366
9837
  const failDetail = formatTurnExitError(exitCode, detail);
9838
+ const explainedInChat = exitCode !== 0 && Boolean(chatText) && (looksLikeAgentFailureMessage(chatText) || failDetail && chatText.includes(failDetail.replace(/^exit\s*\d+:\s*/i, "").trim()));
9367
9839
  setStatus(
9368
9840
  threadId,
9369
9841
  exitCode === 0 ? "idle" : "error",
9370
- exitCode === 0 ? null : failDetail
9842
+ exitCode === 0 || explainedInChat ? null : failDetail
9371
9843
  );
9372
9844
  this.emit({
9373
9845
  type: "status_changed",
@@ -9375,6 +9847,10 @@ var Orchestrator = class {
9375
9847
  status: exitCode === 0 ? "idle" : "error"
9376
9848
  });
9377
9849
  this.emit({ type: "turn_finished", threadId, exitCode });
9850
+ if (exitCode !== 0) {
9851
+ const blob = [chatText, detail].filter(Boolean).join("\n");
9852
+ void this.maybeHandleOrchestrationQuotaFailover(threadId, blob);
9853
+ }
9378
9854
  }
9379
9855
  } catch (err) {
9380
9856
  const message = err instanceof Error ? err.message : String(err);
@@ -9388,6 +9864,7 @@ var Orchestrator = class {
9388
9864
  this.emit({ type: "error", threadId, message });
9389
9865
  this.emit({ type: "status_changed", threadId, status: "error" });
9390
9866
  this.emit({ type: "turn_finished", threadId, exitCode: 1 });
9867
+ void this.maybeHandleOrchestrationQuotaFailover(threadId, message);
9391
9868
  }
9392
9869
  } finally {
9393
9870
  this.startingTurns.delete(threadId);
@@ -10154,6 +10631,7 @@ async function listIssues(repoPath) {
10154
10631
 
10155
10632
  // src/mcp/server.ts
10156
10633
  init_global_workspace();
10634
+ init_list_models();
10157
10635
 
10158
10636
  // src/mcp/archive-guard.ts
10159
10637
  init_global_workspace();
@@ -10570,6 +11048,120 @@ async function startMcpServer() {
10570
11048
  }
10571
11049
  }
10572
11050
  );
11051
+ const agentEnum = import_zod.z.enum(["claude", "codex", "opencode", "brightsy", "cursor"]);
11052
+ server.tool(
11053
+ "list_models",
11054
+ "List models for an agent. Prefer Auto: do not call this unless you have a reason to pin a specific model (user request, cost/latency, capability). Omit agent to list all.",
11055
+ {
11056
+ agent: agentEnum.optional().describe("Limit to one agent; omit for all")
11057
+ },
11058
+ async ({ agent }) => {
11059
+ try {
11060
+ const catalogs = await listModelsForAgent(agent);
11061
+ return {
11062
+ content: [{ type: "text", text: JSON.stringify(catalogs, null, 2) }]
11063
+ };
11064
+ } catch (err) {
11065
+ const message = err instanceof Error ? err.message : String(err);
11066
+ return { content: [{ type: "text", text: message }], isError: true };
11067
+ }
11068
+ }
11069
+ );
11070
+ server.tool(
11071
+ "fork_worktree",
11072
+ "Fork a worktree agent chat into a NEW git worktree + chat (desktop \u201CFork to new workspace\u201D). Seeds a transcript (through through_index, default all). Optional agent override. Leave model unset for Auto (default) \u2014 only pass model when you have a reason. Not for the orchestrator. Then send_to_thread / wait_for_turn on the returned id.",
11073
+ {
11074
+ ref: import_zod.z.string().describe("Worktree thread id/ref to fork"),
11075
+ through_index: import_zod.z.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
11076
+ agent: agentEnum.optional().describe("Agent for the forked chat (default: same as source)"),
11077
+ model: import_zod.z.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
11078
+ title: import_zod.z.string().optional()
11079
+ },
11080
+ async ({ ref, through_index, agent, model, title }) => {
11081
+ try {
11082
+ const source = orch.getThread(ref);
11083
+ if (source) await orch.reconcile(source.repoPath);
11084
+ const thread = await orch.forkThreadWorktree({
11085
+ threadId: ref,
11086
+ throughIndex: through_index,
11087
+ agent,
11088
+ model,
11089
+ title
11090
+ });
11091
+ return {
11092
+ content: [
11093
+ {
11094
+ type: "text",
11095
+ text: JSON.stringify({
11096
+ id: thread.id,
11097
+ title: thread.title,
11098
+ status: thread.status,
11099
+ agent: thread.agent,
11100
+ model: thread.model,
11101
+ branchName: thread.branchName,
11102
+ worktreePath: thread.worktreePath,
11103
+ fromThreadId: source?.id ?? ref,
11104
+ link: `sideboard://thread/${thread.id}`
11105
+ })
11106
+ }
11107
+ ]
11108
+ };
11109
+ } catch (err) {
11110
+ const message = err instanceof Error ? err.message : String(err);
11111
+ return { content: [{ type: "text", text: message }], isError: true };
11112
+ }
11113
+ }
11114
+ );
11115
+ server.tool(
11116
+ "fork_chat",
11117
+ "Fork a chat into a NEW tab on the SAME workspace: worktree agent \u2192 same worktree tab; Global orchestration chat \u2192 new orchestration chat (same synthetic home). Seeds a transcript; optional agent override. Leave model unset for Auto unless you have a reason. Remote coordinators use this to continue an orchestration chat on another agent after session limits. Then send_to_thread / wait_for_turn on the returned id. Use fork_worktree only for worktree agents that need a new git worktree.",
11118
+ {
11119
+ ref: import_zod.z.string().describe("Thread id/ref to fork (worktree agent or orchestration chat)"),
11120
+ through_index: import_zod.z.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
11121
+ agent: agentEnum.optional().describe("Agent for the forked chat (default: same as source)"),
11122
+ model: import_zod.z.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
11123
+ title: import_zod.z.string().optional()
11124
+ },
11125
+ async ({ ref, through_index, agent, model, title }) => {
11126
+ try {
11127
+ const source = orch.getThread(ref);
11128
+ if (!source) {
11129
+ return {
11130
+ content: [{ type: "text", text: `Thread not found: ${ref}` }],
11131
+ isError: true
11132
+ };
11133
+ }
11134
+ const tab = orch.forkChatTab({
11135
+ threadId: source.id,
11136
+ throughIndex: through_index,
11137
+ agent,
11138
+ model,
11139
+ title
11140
+ });
11141
+ return {
11142
+ content: [
11143
+ {
11144
+ type: "text",
11145
+ text: JSON.stringify({
11146
+ id: tab.id,
11147
+ title: tab.title,
11148
+ status: tab.status,
11149
+ agent: tab.agent,
11150
+ model: tab.model,
11151
+ sourceType: tab.sourceType,
11152
+ worktreePath: tab.worktreePath,
11153
+ fromThreadId: source.id,
11154
+ link: `sideboard://thread/${tab.id}`
11155
+ })
11156
+ }
11157
+ ]
11158
+ };
11159
+ } catch (err) {
11160
+ const message = err instanceof Error ? err.message : String(err);
11161
+ return { content: [{ type: "text", text: message }], isError: true };
11162
+ }
11163
+ }
11164
+ );
10573
11165
  server.tool(
10574
11166
  "run_dev_script",
10575
11167
  "Start a .sideboard/.conductor run script for a thread (default script if name omitted); returns port",