@onepointfour-packs/flaredeck-agent 0.2.37 → 0.2.39

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.
Files changed (102) hide show
  1. package/dist/agent.d.ts +42 -3
  2. package/dist/agent.js +84 -6
  3. package/dist/agent.js.map +1 -1
  4. package/dist/automode.js +43 -3
  5. package/dist/automode.js.map +1 -1
  6. package/dist/boards.d.ts +72 -0
  7. package/dist/boards.js +136 -0
  8. package/dist/boards.js.map +1 -0
  9. package/dist/branches.d.ts +12 -1
  10. package/dist/branches.js +13 -3
  11. package/dist/branches.js.map +1 -1
  12. package/dist/browser.d.ts +59 -0
  13. package/dist/browser.js +260 -26
  14. package/dist/browser.js.map +1 -1
  15. package/dist/config.d.ts +22 -0
  16. package/dist/config.js +37 -2
  17. package/dist/config.js.map +1 -1
  18. package/dist/credentials.d.ts +217 -0
  19. package/dist/credentials.js +332 -0
  20. package/dist/credentials.js.map +1 -0
  21. package/dist/engine-antigravity.js +2 -0
  22. package/dist/engine-antigravity.js.map +1 -1
  23. package/dist/engine-claude.js +14 -2
  24. package/dist/engine-claude.js.map +1 -1
  25. package/dist/engine.d.ts +19 -0
  26. package/dist/engine.js.map +1 -1
  27. package/dist/environment.d.ts +15 -3
  28. package/dist/environment.js +79 -9
  29. package/dist/environment.js.map +1 -1
  30. package/dist/envtools.d.ts +58 -0
  31. package/dist/envtools.js +151 -0
  32. package/dist/envtools.js.map +1 -0
  33. package/dist/flaredeck-proxy.d.ts +11 -1
  34. package/dist/flaredeck-proxy.js +27 -1
  35. package/dist/flaredeck-proxy.js.map +1 -1
  36. package/dist/flaredeck.d.ts +24 -0
  37. package/dist/flaredeck.js +20 -0
  38. package/dist/flaredeck.js.map +1 -1
  39. package/dist/github.d.ts +2 -0
  40. package/dist/github.js +4 -0
  41. package/dist/github.js.map +1 -1
  42. package/dist/githubauth.d.ts +11 -1
  43. package/dist/githubauth.js +82 -16
  44. package/dist/githubauth.js.map +1 -1
  45. package/dist/hydrate.d.ts +76 -0
  46. package/dist/hydrate.js +77 -0
  47. package/dist/hydrate.js.map +1 -0
  48. package/dist/limits.d.ts +184 -0
  49. package/dist/limits.js +251 -0
  50. package/dist/limits.js.map +1 -0
  51. package/dist/main.js +177 -33
  52. package/dist/main.js.map +1 -1
  53. package/dist/picker.d.ts +10 -0
  54. package/dist/picker.js +150 -0
  55. package/dist/picker.js.map +1 -0
  56. package/dist/preflight.d.ts +43 -0
  57. package/dist/preflight.js +133 -19
  58. package/dist/preflight.js.map +1 -1
  59. package/dist/repopicker.js +8 -96
  60. package/dist/repopicker.js.map +1 -1
  61. package/dist/runs.d.ts +19 -4
  62. package/dist/runs.js +74 -9
  63. package/dist/runs.js.map +1 -1
  64. package/dist/serve.d.ts +6 -0
  65. package/dist/serve.js +1152 -138
  66. package/dist/serve.js.map +1 -1
  67. package/dist/session.d.ts +51 -0
  68. package/dist/session.js +145 -0
  69. package/dist/session.js.map +1 -0
  70. package/dist/settle.d.ts +18 -0
  71. package/dist/settle.js +39 -0
  72. package/dist/settle.js.map +1 -0
  73. package/dist/signin.d.ts +47 -0
  74. package/dist/signin.js +331 -0
  75. package/dist/signin.js.map +1 -0
  76. package/dist/stream.d.ts +8 -0
  77. package/dist/stream.js +44 -3
  78. package/dist/stream.js.map +1 -1
  79. package/dist/subscriptions.d.ts +115 -0
  80. package/dist/subscriptions.js +140 -0
  81. package/dist/subscriptions.js.map +1 -0
  82. package/dist/team.js +7 -1
  83. package/dist/team.js.map +1 -1
  84. package/dist/tmux.d.ts +1 -0
  85. package/dist/tmux.js +47 -0
  86. package/dist/tmux.js.map +1 -0
  87. package/dist/ui.d.ts +13 -0
  88. package/dist/ui.js +7 -2
  89. package/dist/ui.js.map +1 -1
  90. package/dist/usage.d.ts +105 -0
  91. package/dist/usage.js +348 -0
  92. package/dist/usage.js.map +1 -0
  93. package/dist/wall.d.ts +35 -0
  94. package/dist/wall.js +94 -0
  95. package/dist/wall.js.map +1 -0
  96. package/dist/workspace.d.ts +15 -1
  97. package/dist/workspace.js +25 -1
  98. package/dist/workspace.js.map +1 -1
  99. package/dist/worktree.d.ts +19 -0
  100. package/dist/worktree.js +179 -4
  101. package/dist/worktree.js.map +1 -1
  102. package/package.json +1 -1
package/dist/serve.js CHANGED
@@ -1,5 +1,8 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
1
3
  import { createRequire } from 'node:module';
2
- import { START_SCRIPT as START_SCRIPT_NAME, browserBrief, crossWired, environmentBrief, notStartedBrief, startEnvironment, stopEnvironment, stopLeftoverEnvironments } from './environment.js';
4
+ import { ensureTmuxScrollback } from './tmux.js';
5
+ import { START_SCRIPT as START_SCRIPT_NAME, webPart, browserBrief, crossWired, environmentBrief, notStartedBrief, startEnvironment, stopEnvironment, stopLeftoverEnvironments } from './environment.js';
3
6
  import { GIT_TOOL_NAMES, githubTools, publishBranch } from './github.js';
4
7
  import { freshGitHubToken } from './githubauth.js';
5
8
  import { loadBaseBranch, loadMergeConfig, preflight, preflightBrief } from './preflight.js';
@@ -10,10 +13,14 @@ import { preflightFlaredeck } from './flaredeck-check.js';
10
13
  import { NOT_FOR_WORKERS, REPLACED_BY_HARNESS, callFlaredeckTool, flaredeckProxy } from './flaredeck-proxy.js';
11
14
  import { SCRIPT_REQUEST, allTargets, resumeApprovedRuns, runTools } from './runs.js';
12
15
  import { deliverDecision, flaredeckDecider } from './approval.js';
16
+ import { subscriptionsForAgent } from './subscriptions.js';
13
17
  import { CouldNotWork, engineFor } from './engine.js';
18
+ /** The run failed on its Claude subscription — capped or token rejected — not on its work. */
19
+ class RetryOnAnotherPlan extends CouldNotWork {
20
+ }
14
21
  import { jevGuard } from './jevguard.js';
15
22
  import { loadProviders } from './providers.js';
16
- import { currentBranch, ensureWorktree, releaseSharedPushGuard, existingWorktree, produced, releaseWorktree, tooEarlyToFinish, uncommittedCount, worktreeBrief } from './worktree.js';
23
+ import { currentBranch, ensureWorktree, freeDiskBytes, sweepWorktrees, releaseSharedPushGuard, existingWorktree, produced, releaseWorktree, tooEarlyToFinish, uncommittedCount, worktreeBrief } from './worktree.js';
17
24
  import { askingForComponent, checkouts, resolveCheckout, voteByCode, workspaceBrief } from './workspace.js';
18
25
  import { intake, intakeText, unclearBrief } from './intake.js';
19
26
  import { standingBrief } from './agent.js';
@@ -21,13 +28,16 @@ import { startUi } from './ui.js';
21
28
  import { checkAndStage, harnessVersion, installedSdkVersion, pendingUpdate, updatesEnabled } from './updater.js';
22
29
  import { updateSidecarSdk } from './updater-python.js';
23
30
  import { managedPython, usingManagedPython } from './engine-antigravity.js';
24
- import { mkdirSync } from 'node:fs';
31
+ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
25
32
  import { join } from 'node:path';
26
- import { configDir, saveConfig } from './config.js';
27
- import { browserTools } from './browser.js';
33
+ import { configDir, loadGitHub, saveConfig } from './config.js';
34
+ import { boardArg, onMyBoards, outstandingCards } from './boards.js';
35
+ import { browserTools, ensureBrowser } from './browser.js';
36
+ import { environmentTools } from './envtools.js';
28
37
  import { watchForDesperation } from './desperation.js';
29
38
  import { note, recallWorktree, rememberWorktree, resumeBrief } from './journal.js';
30
- import { EventStream } from './stream.js';
39
+ import { EventStream, describeFailure } from './stream.js';
40
+ import { canPublish, canPushTo } from './githubauth.js';
31
41
  /**
32
42
  * The always-on agent.
33
43
  *
@@ -43,8 +53,64 @@ import { EventStream } from './stream.js';
43
53
  const VERSION = createRequire(import.meta.url)('../package.json').version;
44
54
  /** How often the agent says it is alive, whether or not anything is happening. */
45
55
  const HEARTBEAT_MS = 5 * 60 * 1000;
56
+ /** A card worktree unused this long, with nothing unpublished on it, is released. */
57
+ const WORKTREE_IDLE_MS = 12 * 60 * 60 * 1000;
58
+ /** Short of disk, an hour unused is enough: the card comes back from what it pushed. */
59
+ /**
60
+ * Every pane line, full length, on disk: `<config>/agent.log`.
61
+ *
62
+ * The pane truncates and scrolls away, and the per-card journal keeps only 40 short lines and
63
+ * only while a card runs — so anything between cards (startup, usage reads, pauses) existed
64
+ * nowhere once it left the screen, and "what did it say?" meant a screenshot. Rotated at 5 MB to
65
+ * one `.1`, so a daemon that runs for weeks cannot fill the disk it is also guarding.
66
+ */
67
+ const AGENT_LOG_MAX = 5 * 1024 * 1024;
68
+ function writeAgentLog(line) {
69
+ try {
70
+ const path = join(configDir(), 'agent.log');
71
+ if (existsSync(path) && statSync(path).size > AGENT_LOG_MAX)
72
+ renameSync(path, `${path}.1`);
73
+ appendFileSync(path, `${new Date().toISOString()} ${line.kind.padEnd(5)} ${line.card ? `[${line.card}] ` : ''}${line.text.replace(/\n/g, '\n ')}\n`);
74
+ }
75
+ catch { /* a log that cannot be written must never stop the agent */ }
76
+ }
77
+ const WORKTREE_IDLE_LOW_DISK_MS = 60 * 60 * 1000;
78
+ /** Below this, idle worktrees are released early. */
79
+ const DISK_LOW = 30e9;
80
+ /** Below this, no card is started. */
81
+ const DISK_MIN = 10e9;
46
82
  export async function serve(opts) {
47
83
  const { config } = opts;
84
+ /** The tenant the agent's own token is for. Reads the claim rather than the config file,
85
+ * because the token is what the server authorises against. */
86
+ const tenantFromToken = (token) => {
87
+ try {
88
+ const part = token.split('.')[1] ?? '';
89
+ return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')).tenantId ?? null;
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ };
95
+ /** When each plan's usage was last read, so a burst of short events does not read it each time. */
96
+ const usageReadAt = new Map();
97
+ const USAGE_REFRESH_MS = 5 * 60_000;
98
+ /** The plan the event being handled ran on, so its usage can be read once the event is done. */
99
+ let turnPlan = null;
100
+ /** Events parked until a Claude plan resets, by event id — one timer each, never two. */
101
+ const waitingForPlan = new Map();
102
+ /** The run reported its plan's usage itself, so no separate read is needed after it. */
103
+ let turnSawUsage = false;
104
+ /**
105
+ * Plans that failed on an event, by event id, so its retry is offered a different one.
106
+ *
107
+ * A plan that hits its cap or has its token rejected used to fail the card outright while the
108
+ * other plans sat unused. Now the event goes straight back on the queue and `choose` is told
109
+ * what not to hand out. Bounded by the plans that exist — each retry spends one — and by
110
+ * MAX_PLAN_SWITCHES as a backstop.
111
+ */
112
+ const failedPlans = new Map();
113
+ const MAX_PLAN_SWITCHES = 4;
48
114
  const stream = new EventStream(opts.baseUrl, opts.auth);
49
115
  const state = {
50
116
  version: harnessVersion(),
@@ -72,6 +138,7 @@ export async function serve(opts) {
72
138
  if (state.lines.length > 400)
73
139
  state.lines.splice(0, state.lines.length - 400);
74
140
  ui?.log(line);
141
+ writeAgentLog(line);
75
142
  };
76
143
  const updated = process.env['FLAREDECK_AGENT_UPDATED'];
77
144
  if (updated)
@@ -166,14 +233,20 @@ export async function serve(opts) {
166
233
  * with no code in it — which is right: a message is not authority to start work, only to carry
167
234
  * on with work that exists.
168
235
  */
169
- const worktreeTheMessageIsAbout = async (event, repo, onStep) => {
236
+ const worktreeTheMessageIsAbout = async (event, repo, onStep,
237
+ /** Makes one when none exists — see the note where this is called. */
238
+ make) => {
170
239
  // `ask` too: a question typed in the pane is usually about the card in front of the person,
171
240
  // and without this the turn has no checkout and therefore no publishing tools — so an agent
172
241
  // asked to commit its work truthfully answers that it cannot.
173
242
  if (!['message', 'commented', 'ask'].includes(event.event))
174
243
  return null;
175
244
  const text = `${event.line ?? ''} ${event.message ?? ''}`;
176
- const codes = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,9}-\d{3,}\b/g) ?? [])];
245
+ // Prose a person typed, so there is no field to read and a pattern is the only way. The
246
+ // bound was ten characters before the dash, which silently excluded real boards —
247
+ // DOCUMENTREPOSITOR-0022 is seventeen. Where a TOOL answers, the code is read from the
248
+ // fields it prints instead; see outstandingCards.
249
+ const codes = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,23}-\d{3,}\b/g) ?? [])];
177
250
  for (const code of codes) {
178
251
  // What the harness wrote down when it made the worktree, first: the directory is named from
179
252
  // the card's internal id and the branch from whatever the agent chose, so neither can be
@@ -190,6 +263,22 @@ export async function serve(opts) {
190
263
  tag = code;
191
264
  return found;
192
265
  }
266
+ /**
267
+ * Nothing there yet, and the person at this machine named it — so make it.
268
+ *
269
+ * This is what stopped "work KAN-0074" working: a card nobody had touched had no checkout,
270
+ * and the agent rightly refused to run it from the shared one, which was on another branch
271
+ * with uncommitted changes. Only for a pane ask: a comment or a message naming a card is
272
+ * usually talking ABOUT it, and a card event makes its own.
273
+ */
274
+ if (event.event === 'ask') {
275
+ const made = await make(code).catch(() => null);
276
+ if (made) {
277
+ onStep(`made a checkout for ${code}, ${made.branch}`);
278
+ tag = code;
279
+ return made;
280
+ }
281
+ }
193
282
  }
194
283
  return null;
195
284
  };
@@ -228,6 +317,63 @@ export async function serve(opts) {
228
317
  if (await releaseSharedPushGuard(c.path).catch(() => false))
229
318
  log('tool', `restored push on ${c.path} (a worktree's push guard had leaked into it)`);
230
319
  }
320
+ // Worktrees left by earlier cards — see sweepWorktrees. In the background: nothing waits on it.
321
+ void (async () => {
322
+ for (const c of [{ path: config.repo }, ...inScope])
323
+ await sweepWorktrees(c.path, WORKTREE_IDLE_MS, (t) => log('tool', t)).catch(() => { });
324
+ })();
325
+ // Inside tmux, the history this pane prints is only reachable with tmux's mouse mode on.
326
+ void ensureTmuxScrollback((t) => log('tool', t)).catch(() => { });
327
+ // The browser, fetched while nothing is waiting on it. 186MB is a long pause to take in the
328
+ // middle of the first card that needs eyes, and the agent is idle right now.
329
+ void ensureBrowser((t) => log('tool', `browser: ${t}`)).catch(() => { });
330
+ /**
331
+ * The workspace's MCP servers, so `flaredeck-agent login` is the only credential step on a
332
+ * machine. Connect Sceneflo once in FlareDeck and every agent has it; rotate its token there
333
+ * and every agent follows on its next start.
334
+ *
335
+ * Awaited, unlike the browser fetch above, because the first card must not run with a list
336
+ * that is about to change underneath it — an agent that starts without a server it should
337
+ * have reports a missing tool, which reads as a product fault rather than a race.
338
+ */
339
+ /**
340
+ * Re-fetched before every session, not only at start: a signed-in server's access token lasts
341
+ * about an hour, so a list fetched once at 08:00 hands out a dead token by 09:00. FlareDeck
342
+ * refreshes it centrally; asking again is all it takes. Merged over the machine's ORIGINAL local
343
+ * list each time, so a server removed in FlareDeck does not linger as "local".
344
+ *
345
+ * Returns the REQUIRED servers that could not be connected — null when FlareDeck could not be
346
+ * asked, which must never pause an agent: not knowing is not the same as knowing one is missing.
347
+ */
348
+ const localMcp = [...(config.mcpServers ?? [])];
349
+ let lastMcpNote = '';
350
+ const refreshMcp = async () => {
351
+ try {
352
+ const { hydrateMcpServers, mcpConfigFetcher } = await import('./hydrate.js');
353
+ const tenantForMcp = tenantFromToken(await opts.auth());
354
+ if (!tenantForMcp)
355
+ return null;
356
+ const hydrated = await hydrateMcpServers({ mcpServers: localMcp }, mcpConfigFetcher(config.mcpUrl, tenantForMcp, () => opts.auth()));
357
+ config.mcpServers = hydrated.mcpServers;
358
+ if (hydrated.note && hydrated.note !== lastMcpNote) {
359
+ lastMcpNote = hydrated.note;
360
+ log('tool', hydrated.note);
361
+ }
362
+ return hydrated.missingRequired ?? null;
363
+ }
364
+ catch (e) {
365
+ // Never fatal: the machine's own list still works, which is what every agent had before.
366
+ log('warn', `could not fetch the MCP list: ${e instanceof Error ? e.message : String(e)}`);
367
+ return null;
368
+ }
369
+ };
370
+ const mcpWallFor = (missing) => ({
371
+ check: 'mcp',
372
+ reason: `A required MCP server is not connected — ${missing.map((m) => `${m.name}: ${m.why}`).join('; ')}. ` +
373
+ `Nothing is worked until it is; I resume by myself within a minute of it being connected.`,
374
+ });
375
+ // Awaited, because the first card must not run with a list about to change underneath it.
376
+ await refreshMcp();
231
377
  // A restart begins with no environments: the last run's are nobody's now (see the function).
232
378
  for (const path of new Set([config.repo, ...inScope.map((c) => c.path)])) {
233
379
  void stopLeftoverEnvironments(path, (t) => log('tool', t)).catch(() => { });
@@ -336,13 +482,22 @@ export async function serve(opts) {
336
482
  const reader = opts.me ?? opts.deck ?? null;
337
483
  const answeredHere = new Map();
338
484
  let refreshing = null;
485
+ /** This agent's own id, and (on a lead) the agents that report to it — whose requests it sees. */
486
+ let selfId = '';
487
+ let teamIds = new Set();
339
488
  const refreshApprovals = () => {
340
489
  if (!reader)
341
490
  return Promise.resolve();
342
491
  if (refreshing)
343
492
  return refreshing;
344
493
  refreshing = reader.pendingApprovals().then((all) => {
345
- const mine = all.filter((a) => a.tool_name === SCRIPT_REQUEST);
494
+ // Its own requests, and on a lead its team's. Every pending request in the workspace used
495
+ // to be listed on every pane, so CASE showed HAL's request as if it were CASE's business.
496
+ // Until this agent knows who it is (the team read failed), nothing is hidden.
497
+ const ours = (a) => !selfId
498
+ || a.sender_key === selfId
499
+ || (isLead && (a.review?.by === selfId || (!!a.sender_key && teamIds.has(a.sender_key))));
500
+ const mine = all.filter((a) => a.tool_name === SCRIPT_REQUEST && ours(a));
346
501
  state.approvals = mine.map((a) => {
347
502
  const lines = (a.input_preview || '').split('\n');
348
503
  const preview = lines.find((l) => l.startsWith('-- RAN') || /would_touch/.test(l))?.replace(/^-- /, '').replace(/^harness preview.*?:\s*/, '');
@@ -368,7 +523,7 @@ export async function serve(opts) {
368
523
  for (const id of [...answeredHere.keys()])
369
524
  if (!mine.some((a) => a.request_id === id))
370
525
  answeredHere.delete(id);
371
- }).catch((e) => log('warn', `could not read the approvals list: ${e instanceof Error ? e.message : String(e)}`))
526
+ }).catch((e) => log('warn', `could not read the approvals list: ${describeFailure(e)}`))
372
527
  .finally(() => { refreshing = null; });
373
528
  return refreshing;
374
529
  };
@@ -451,35 +606,65 @@ export async function serve(opts) {
451
606
  // configured production targets are added to the shell every machine already has.
452
607
  const runsSet = opts.deck
453
608
  ? runTools(config.runs ?? {}, () => onCard, opts.deck, (t) => log('tool', t), {
454
- flag: async (reason, kind) => {
609
+ attach: (card, path, comment) => attachFile(card, path, comment),
610
+ flag: async (reason, kind, ask) => {
455
611
  const cur = onCard;
456
612
  if (!cur)
457
613
  return null;
458
- return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind);
614
+ return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind, ask);
459
615
  },
460
- unflag: async () => {
461
- const cur = onCard;
462
- if (!cur)
463
- return;
616
+ unflag: async (cur) => {
464
617
  parked.delete(cur.card);
465
618
  await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'clear-needs-human', {
466
619
  card_id: cur.code || cur.card,
467
620
  ...(cur.board ? { board_id: cur.board } : {}),
468
621
  }).catch((e) => log('warn', `could not clear needs-human: ${e instanceof Error ? e.message : String(e)}`));
469
622
  },
470
- }, () => leadId, onAsk)
623
+ }, () => leadId, onAsk, (card, text) => resumeParked(card, text))
471
624
  : undefined;
472
625
  // Answering from this keyboard: the person's own login decides, through the same endpoint the
473
626
  // phone uses, so the agent waiting on it sees the verdict as any other.
474
- const decide = opts.me
627
+ /**
628
+ * The person answering from this pane. Signed in once per machine, from the pane when nobody
629
+ * has: press a/d and it asks. Never the agent's own login — an approval is a person's decision,
630
+ * and the agent has a shell on this box and its own credentials in it.
631
+ */
632
+ let me = opts.me;
633
+ state.person = !!me;
634
+ const startSignIn = () => {
635
+ if (signingIn) {
636
+ log('event', 'still waiting for the sign-in — paste the address your browser ended on');
637
+ return;
638
+ }
639
+ if (!opts.signInMe) {
640
+ log('warn', 'nobody is signed in here to answer with — run: flaredeck-agent login --me');
641
+ return;
642
+ }
643
+ signingIn = true;
644
+ log('event', 'Sign in as YOURSELF to answer from this pane (once per machine). Open the address below, sign in, then paste the address your browser ends up on into the box here and press Enter.');
645
+ opts.signInMe((t) => { if (t.trim())
646
+ log('event', t); }, signInReader)
647
+ .then((client) => {
648
+ me = client ?? undefined;
649
+ state.person = !!me;
650
+ log(me ? 'event' : 'warn', me ? 'signed in — a approves, d denies, from this pane' : 'signed in, but that login has no workspace here');
651
+ })
652
+ .catch((e) => log('warn', `sign-in did not finish: ${e instanceof Error ? e.message : String(e)}`))
653
+ .finally(() => { signingIn = false; });
654
+ };
655
+ const decide = opts.signInMe || opts.me
475
656
  ? (requestId, verdict) => {
657
+ if (!me) {
658
+ startSignIn();
659
+ return;
660
+ }
476
661
  const row = (state.approvals ?? []).find((a) => a.requestId === requestId);
477
662
  if (!row || row.stage !== 'ready')
478
663
  return;
479
664
  answeredHere.set(requestId, verdict);
480
665
  row.stage = 'answered';
481
666
  row.verdict = verdict;
482
- void opts.me.decide(row.commentId, verdict)
667
+ void me.decide(row.commentId, verdict)
483
668
  .then(() => log('you', `${verdict === 'allow' ? 'approved' : 'denied'} ${row.name} (${row.agent}, ${row.card}) from here`))
484
669
  .catch((e) => { answeredHere.delete(requestId); log('warn', `could not record that: ${e instanceof Error ? e.message : String(e)}`); void refreshApprovals(); });
485
670
  }
@@ -563,6 +748,8 @@ export async function serve(opts) {
563
748
  if (leadId)
564
749
  log('tool', `your lead is ${leadName} — ${leadName} verifies your work before it goes anywhere`);
565
750
  isLead = team.you.role === 'lead';
751
+ selfId = team.you.agentId;
752
+ teamIds = new Set(team.agents.filter((a) => a.reportsTo === team.you.agentId).map((a) => a.agentId));
566
753
  if (team.you.role === 'lead') {
567
754
  teamSet = {
568
755
  // After a review the list changes — an endorsed request is now answerable, from THIS
@@ -587,11 +774,34 @@ export async function serve(opts) {
587
774
  * Polled rather than pushed, because nothing streams another agent's status — and slowly,
588
775
  * since agents check in every five minutes, so a faster poll would show the same row again.
589
776
  */
777
+ /**
778
+ * An idle worker is the lead's problem, and the lead already knows within a minute.
779
+ *
780
+ * It polls its team every sixty seconds to draw the pane, so "CASE idle 4m ago" is sitting on
781
+ * its screen — and it did nothing with it. Dispatch waited for the hourly sweep, or for the
782
+ * worker to say it was free, which for a while it never did. Eleven cards waited in Ready For
783
+ * Dev while two agents sat idle and the lead reported "waiting for work".
784
+ *
785
+ * The poll is already happening, so noticing costs nothing. Rate-limited because an idle
786
+ * worker with genuinely nothing to give it stays idle, and a lead asked about it every minute
787
+ * learns to say "nothing" without looking.
788
+ */
789
+ const DISPATCH_AGAIN_MS = 10 * 60 * 1000;
790
+ let lastDispatch = 0;
791
+ const workersAreIdle = (team) => myTeam(team).mine.filter((a) => !a.card && a.state !== 'stopped' && !a.stale).map((a) => a.name);
590
792
  if (leadOfTeam) {
591
793
  const teamTimer = setInterval(() => {
592
794
  void (async () => {
593
795
  try {
594
- showTeam(await fetchTeam(opts.baseUrl, await opts.auth()));
796
+ const team = await fetchTeam(opts.baseUrl, await opts.auth());
797
+ showTeam(team);
798
+ const free = workersAreIdle(team);
799
+ if (free.length && !working && !queue.length && Date.now() - lastDispatch > DISPATCH_AGAIN_MS) {
800
+ lastDispatch = Date.now();
801
+ log('event', `${free.join(', ')} ${free.length === 1 ? 'is' : 'are'} idle — looking for work to hand over`);
802
+ if (enqueue({ id: `dispatch-${Date.now()}`, event: 'sweep', line: `${free.join(', ')} have nothing to do` }))
803
+ void drain();
804
+ }
595
805
  }
596
806
  catch {
597
807
  // A team the lead briefly cannot see is not worth a line in the log every minute; the
@@ -623,14 +833,188 @@ export async function serve(opts) {
623
833
  if (engine.name !== 'claude')
624
834
  log('event', `${engine.name} is answering on this agent`);
625
835
  let liveRun = null;
836
+ /** The checkout this agent's current or last card is in — never swept from under it. */
837
+ let currentTree = null;
838
+ const activeTrees = () => new Set(currentTree ? [currentTree] : []);
839
+ /**
840
+ * Paused: a wall this agent cannot get past by itself, so it does no work at all — no turns,
841
+ * no tokens — until its lead resumes it. See flaredeck-mcp/src/mcp/pause.ts for the why.
842
+ */
843
+ let paused = null;
844
+ let readyReported = false;
845
+ /** Walls the agent cannot climb, checked in code — never by asking the model. */
846
+ const walls = async () => {
847
+ config.github = loadGitHub() ?? config.github;
848
+ const login = await canPublish(config.github).catch(() => null);
849
+ if (login)
850
+ return { check: 'github', reason: `GitHub refuses this machine's login, so nothing I write can be published. Somebody signs it in again on this machine: \`flaredeck-agent login --github-token ghp_…\` (a classic token with repo and workflow scope does not expire).` };
851
+ if (config.github?.accessToken) {
852
+ for (const c of [{ path: config.repo }, ...(await checkouts(config.repo, config.repos))]) {
853
+ const remote = await new Promise((resolve) => {
854
+ execFile('git', ['remote', 'get-url', 'origin'], { cwd: c.path }, (err, out) => resolve(err ? '' : String(out).trim()));
855
+ });
856
+ const why = remote ? await canPushTo(config.github.accessToken, remote) : null;
857
+ if (why)
858
+ return { check: 'github', reason: `${why} Nothing I write in ${c.path} could be published. Fix the access, or sign this machine in with an account that has it.` };
859
+ }
860
+ }
861
+ const missing = await refreshMcp();
862
+ if (missing?.length)
863
+ return mcpWallFor(missing);
864
+ const free = freeDiskBytes(config.repo);
865
+ if (free !== null && free < DISK_MIN) {
866
+ await sweepWorktrees(config.repo, WORKTREE_IDLE_LOW_DISK_MS, (t) => log('tool', t), activeTrees()).catch(() => { });
867
+ const after = freeDiskBytes(config.repo);
868
+ if (after !== null && after < DISK_MIN) {
869
+ return { check: 'disk', reason: `This machine has only ${(after / 1e9).toFixed(1)} GB of disk free, and builds, Docker and test environments all fail when it fills. Idle worktrees are already cleared; free space (Docker images, caches, old checkouts).` };
870
+ }
871
+ }
872
+ return null;
873
+ };
874
+ const enterPause = async (wall) => {
875
+ if (paused?.check === wall.check)
876
+ return;
877
+ paused = wall;
878
+ readyReported = false;
879
+ state.paused = wall.reason;
880
+ log('warn', `PAUSED (${wall.check}): ${wall.reason}`);
881
+ void liveRun?.stop().catch(() => { });
882
+ try {
883
+ const r = await opts.deck?.pause(wall.check, wall.reason);
884
+ /* A solo agent has no lead: its cards stay with it (each gets a comment) and nobody is
885
+ messaged. Saying "told the lead, handed back …" then described something that never
886
+ happened. */
887
+ if (r && !r.already) {
888
+ log('event', r.lead
889
+ ? `told the lead${r.handedBack.length ? `, handed back ${r.handedBack.join(', ')}` : ''} — doing nothing until resumed`
890
+ : `no lead to tell — ${r.handedBack.length ? `left a note on ${r.handedBack.join(', ')}; they stay mine` : 'nothing handed back'}. ${wall.check === 'disk' ? 'I resume myself once the disk is clear.' : wall.check === 'mcp' ? 'I resume myself once it is connected.' : 'A person resumes me.'}`);
891
+ }
892
+ }
893
+ catch (e) {
894
+ log('warn', `could not tell FlareDeck I am paused: ${e instanceof Error ? e.message : String(e)}`);
895
+ }
896
+ };
897
+ // While paused: ask FlareDeck every minute whether the lead has resumed this agent (one REST
898
+ // call, no model), and re-check the wall every ten, saying once when it is gone.
899
+ let lastWallCheck = 0;
900
+ const pauseTick = setInterval(() => {
901
+ if (!paused)
902
+ return;
903
+ void (async () => {
904
+ const server = await opts.deck?.pauseState().catch(() => undefined);
905
+ if (server === null) {
906
+ const still = await walls();
907
+ if (still) {
908
+ paused = null;
909
+ await enterPause(still);
910
+ return;
911
+ }
912
+ log('event', `resumed by the lead — back to work`);
913
+ paused = null;
914
+ state.paused = undefined;
915
+ void resumeOwnWork();
916
+ return;
917
+ }
918
+ /* Disk is a statfs call, so it is checked every tick; the others ask GitHub, so every ten
919
+ minutes. A disk wall that has cleared resumes the agent at once — there is nothing for a
920
+ lead to decide about a number, and waiting for one left the agent idle for hours after
921
+ the space had already been freed. */
922
+ const selfClearing = paused.check === 'disk' || paused.check === 'mcp';
923
+ if (selfClearing) {
924
+ let cleared = '';
925
+ if (paused.check === 'disk') {
926
+ const free = freeDiskBytes(config.repo);
927
+ if (free === null || free < DISK_MIN)
928
+ return;
929
+ cleared = `disk is clear (${(free / 1e9).toFixed(0)} GB free)`;
930
+ }
931
+ else {
932
+ // One HTTP call a minute while paused; null (FlareDeck unreachable) is not "connected".
933
+ const missing = await refreshMcp();
934
+ if (missing === null || missing.length)
935
+ return;
936
+ cleared = 'the required MCP servers are connected';
937
+ }
938
+ const r = await opts.deck?.resumeSelf().catch(() => null);
939
+ if (r?.resumed) {
940
+ log('event', `${cleared} — resumed myself and told the lead`);
941
+ paused = null;
942
+ state.paused = undefined;
943
+ readyReported = false;
944
+ void resumeOwnWork();
945
+ return;
946
+ }
947
+ // An older server without self-resume: fall through to telling the lead, as before.
948
+ }
949
+ if ((!selfClearing && Date.now() - lastWallCheck < 10 * 60 * 1000) || readyReported)
950
+ return;
951
+ lastWallCheck = Date.now();
952
+ if (!(await walls())) {
953
+ readyReported = true;
954
+ log('event', `${paused.check} is fixed — told the lead; waiting to be resumed`);
955
+ await opts.deck?.ready().catch(() => { });
956
+ }
957
+ })();
958
+ }, 60 * 1000);
959
+ pauseTick.unref?.();
960
+ /**
961
+ * This turn's browser, so it can be closed when the turn ends.
962
+ *
963
+ * A fresh tool set is built per turn, so without this the previous turn's Chromium was both
964
+ * unreachable and still running — a leaked browser tree for every turn that looked at
965
+ * anything, on a process that stays up for days.
966
+ */
967
+ let turnEyes = null;
626
968
  /**
627
969
  * Typed with nothing running: a question for the agent, answered now. It goes through the same
628
970
  * queue as a card event so it is serialised with real work, but it is local — never acknowledged
629
971
  * to the server, never a card — and it opens one long-lived session of its own so a follow-up
630
972
  * question remembers the last one.
631
973
  */
974
+ /**
975
+ * A parked card's request was answered: put the card back at the FRONT of the queue, resuming
976
+ * its own session in its own worktree, with what happened. The turn on whatever card is running
977
+ * now finishes first.
978
+ */
979
+ const resumeParked = (card, text) => {
980
+ const event = {
981
+ id: `run-${card.card}-${Date.now()}`,
982
+ event: 'run-answered',
983
+ line: text,
984
+ message: text,
985
+ cardKey: card.card,
986
+ ...(card.board ? { boardKey: card.board } : {}),
987
+ ...(card.code ? { cardCode: card.code } : {}),
988
+ };
989
+ log('event', `${card.code || card.card}: request answered — back at the front of the queue`);
990
+ if (enqueue(event, true)) {
991
+ void drain();
992
+ }
993
+ };
994
+ /**
995
+ * A question typed in this pane.
996
+ *
997
+ * Whatever it says is passed through untouched, with one line in front of it saying who is
998
+ * speaking. Nothing here tries to work out whether it is a question or an instruction: a
999
+ * pattern that guessed got it wrong both ways — "work KAN-0074" did nothing because a bare
1000
+ * question never makes a worktree, and anything clever enough to catch that also caught
1001
+ * "work out why KAN-0074 failed" and started a build nobody asked for. The model reads English
1002
+ * better than a regular expression does; it only needed telling whose English it is.
1003
+ *
1004
+ * The authority is real and worth stating plainly. This person is at the machine. They can do
1005
+ * anything the agent can do and more, directly, without asking — so there is nothing to
1006
+ * protect here, and treating their instruction as a suggestion only wastes their time.
1007
+ */
632
1008
  const ask = (text) => {
633
- const event = { id: `ask-${Date.now()}`, event: 'ask', line: text, message: text };
1009
+ const event = {
1010
+ id: `ask-${Date.now()}`,
1011
+ event: 'ask',
1012
+ line: text,
1013
+ message: 'This was typed by the person at this machine, in the agent\'s own terminal. They have ' +
1014
+ 'every permission you have and more, and they are not waiting on anyone to approve it. ' +
1015
+ 'Read it as what it plainly says: answer it if it is a question, do it if it is an ' +
1016
+ 'instruction, and do not ask whether you may.\n\n' + text,
1017
+ };
634
1018
  if (enqueue(event)) {
635
1019
  prepareAhead(event);
636
1020
  void drain();
@@ -643,6 +1027,9 @@ export async function serve(opts) {
643
1027
  * blocking call here would freeze the pane and the agent's own event stream for as long as the
644
1028
  * command took. The agent never sees it; say on the card or in a steer if it should know.
645
1029
  */
1030
+ /** A person signing in from the pane: lines typed meanwhile go to the sign-in, not the agent. */
1031
+ let signingIn = false;
1032
+ const signInReader = Object.assign(new EventEmitter(), { prompt() { }, setPrompt() { }, close() { } });
646
1033
  /** The checkout the current turn is working in ('' for one that has none). */
647
1034
  let turnCwd = '';
648
1035
  /** Worktrees whose environment comes down unless a turn returns to them first. */
@@ -690,6 +1077,11 @@ export async function serve(opts) {
690
1077
  });
691
1078
  };
692
1079
  const steer = (text) => {
1080
+ // Mid sign-in, the next line is the redirect address — for the sign-in, never the agent.
1081
+ if (signingIn) {
1082
+ signInReader.emit('line', text);
1083
+ return;
1084
+ }
693
1085
  if (text.startsWith('!')) {
694
1086
  const command = text.slice(1).trim();
695
1087
  if (command)
@@ -707,7 +1099,21 @@ export async function serve(opts) {
707
1099
  // every in-process FlareDeck call comes back on. From then until the turn ends, every
708
1100
  // FlareDeck call fails as "interrupted" and the agent concludes FlareDeck is down. 'next'
709
1101
  // waits for the tool batch to return and lands at the boundary, with nothing cut off.
710
- void liveRun.steer(text).catch(() => log('warn', 'could not deliver that to the running turn'));
1102
+ /* A run that was stopped (a pause stops it) can still be referenced here for a moment. If
1103
+ it will not take the line, it is not running: answer it as a question instead of
1104
+ dropping what the person typed. */
1105
+ const run = liveRun;
1106
+ void run.steer(text).then((heard) => {
1107
+ if (heard !== false)
1108
+ return;
1109
+ if (liveRun === run)
1110
+ liveRun = null;
1111
+ ask(text);
1112
+ }).catch(() => {
1113
+ if (liveRun === run)
1114
+ liveRun = null;
1115
+ ask(text);
1116
+ });
711
1117
  }
712
1118
  else {
713
1119
  ask(text);
@@ -734,7 +1140,13 @@ export async function serve(opts) {
734
1140
  * which point the SDK compacts silently. So the live turn is told, once, to wrap up: finish
735
1141
  * the step, leave the handover, stop. Deliberate, and it happens while there is still room.
736
1142
  */
737
- const CONTEXT_WRAP_UP_TOKENS = 150_000;
1143
+ //
1144
+ // OFF by default now. Stopping an agent part-way through a card cost more than compaction did:
1145
+ // the card was handed to a fresh session that re-read everything to get back to where the old
1146
+ // one already was. The end-of-card handover still happens. Set FLAREDECK_AGENT_WRAP_UP_AT to a
1147
+ // token count to bring the mid-turn nudge back.
1148
+ const wrapUpAt = Number(process.env['FLAREDECK_AGENT_WRAP_UP_AT']);
1149
+ const CONTEXT_WRAP_UP_TOKENS = Number.isFinite(wrapUpAt) && wrapUpAt > 0 ? wrapUpAt : Infinity;
738
1150
  const keepAlive = (key, cwd, tokens) => {
739
1151
  const prev = live.get(key);
740
1152
  if (prev?.timer)
@@ -760,6 +1172,11 @@ export async function serve(opts) {
760
1172
  const pending = new Set();
761
1173
  /** Keep the pushed status in step with the queue — it is what a lead's pane reads. */
762
1174
  const syncQueue = () => status.setQueue(queue.length, queue[0]?.cardCode || queue[0]?.cardKey || '');
1175
+ /**
1176
+ * The boards this agent works, by key. Empty means all of them, which is what every install
1177
+ * did before this existed.
1178
+ */
1179
+ const myBoards = new Set((config.boards ?? []).map((b) => b.key));
763
1180
  const enqueue = (event, front = false) => {
764
1181
  if (pending.has(event.id))
765
1182
  return false;
@@ -785,6 +1202,8 @@ export async function serve(opts) {
785
1202
  const wantsIntake = (event) => !!event.cardKey
786
1203
  && event.event !== 'review'
787
1204
  && event.event !== 'ask'
1205
+ // A parked card coming back is the same work resuming, not a new card to brief.
1206
+ && event.event !== 'run-answered'
788
1207
  && !(event.event === 'commented' && event.cardDone && !event.mentioned)
789
1208
  // Someone else's card is not worth reading ahead. `drain` already declines to WORK these —
790
1209
  // a card this agent once touched keeps sending it every move and comment made by whoever
@@ -882,15 +1301,85 @@ export async function serve(opts) {
882
1301
  const sweepMinutes = Number(process.env['FLAREDECK_AGENT_SWEEP_MINUTES']);
883
1302
  const SWEEP_EVERY_MS = Math.max(5, Number.isFinite(sweepMinutes) ? sweepMinutes : 60) * 60 * 1000;
884
1303
  const sweepsEnabled = process.env['FLAREDECK_AGENT_NO_SWEEP'] !== '1';
1304
+ /**
1305
+ * Read a plan's usage and post every window to the ledger.
1306
+ *
1307
+ * This is what the Secrets bars and `choose` route on. It used to happen only when `choose`
1308
+ * handed a plan over, at most once an hour, in the background — so the plans a run did not get
1309
+ * stayed "not measured yet" for days, and `choose` ranked on nothing and handed out a plan that
1310
+ * was already at its weekly cap. Now: every plan at startup, and the plan a card used once the
1311
+ * card is done, which is when its numbers have just moved.
1312
+ *
1313
+ * Never throws and never blocks work: a failed reading costs a little routing accuracy.
1314
+ */
1315
+ const measurePlan = async (subs, plan, force = false) => {
1316
+ if (!subs)
1317
+ return;
1318
+ const last = usageReadAt.get(plan.id) ?? 0;
1319
+ if (!force && Date.now() - last < USAGE_REFRESH_MS)
1320
+ return;
1321
+ usageReadAt.set(plan.id, Date.now());
1322
+ try {
1323
+ const { readUsage } = await import('./usage.js');
1324
+ let why = '';
1325
+ const windows = await readUsage({ token: plan.token, onEmpty: (w) => { why = w; } });
1326
+ if (!windows.length) {
1327
+ log('warn', `claude usage: ${plan.label} — could not read it (${why || 'no detail'})`);
1328
+ return;
1329
+ }
1330
+ for (const w of windows)
1331
+ await subs.seen(plan.id, w.window, w.utilization, w.resetsAt);
1332
+ log('tool', `claude usage: ${plan.label} · ${windows.map((w) => `${w.window} ${w.utilization}%`).join(' · ')}`);
1333
+ }
1334
+ catch { /* bookkeeping must never interrupt work */ }
1335
+ };
1336
+ /** Every plan, one after another — each read starts a Claude process, so not all at once. */
1337
+ const measureAll = async () => {
1338
+ const subs = await subscriptionsForAgent(config.mcpUrl, () => opts.auth(), () => { }).catch(() => null);
1339
+ if (!subs)
1340
+ return;
1341
+ const plans = await subs.measure().catch(() => []);
1342
+ // Said, because a plan missing here — a revoked token — is otherwise invisible.
1343
+ log('tool', `claude usage: measuring ${plans.length ? plans.map((p) => p.label).join(', ') : 'nothing (no readable subscription tokens)'}`);
1344
+ for (const plan of plans)
1345
+ await measurePlan(subs, plan, true);
1346
+ };
1347
+ void measureAll();
1348
+ // The walls, checked once at start — before any card is picked up. A restart while paused
1349
+ // picks the pause back up from FlareDeck rather than re-announcing it.
1350
+ void (async () => {
1351
+ const server = await opts.deck?.pauseState().catch(() => undefined);
1352
+ if (server) {
1353
+ paused = { check: server.check, reason: server.reason };
1354
+ readyReported = !!server.ready;
1355
+ state.paused = server.reason;
1356
+ log('warn', `PAUSED since ${server.since.slice(0, 16).replace('T', ' ')} (${server.check}) — doing nothing until the lead resumes me`);
1357
+ return;
1358
+ }
1359
+ const wall = await walls();
1360
+ if (wall)
1361
+ await enterPause(wall);
1362
+ })();
885
1363
  const sweepNow = () => {
886
- if (!sweepsEnabled || stopping)
1364
+ if (paused || !sweepsEnabled || stopping)
887
1365
  return;
888
1366
  // Busy is reason enough to wait: the next tick is minutes away and the work is not going
889
- // anywhere. `live.size` covers a card whose session is parked between events.
890
- if (working || queue.length || live.size)
1367
+ // anywhere.
1368
+ if (working || queue.length)
891
1369
  return;
892
- if (enqueue({ id: 'sweep', event: 'sweep', line: 'checking what is still assigned to you' }))
893
- void drain();
1370
+ void (async () => {
1371
+ /* A card already in progress IS the answer to "what next" — no model needed. Asking the
1372
+ model instead had it try to start a second card every sweep, be refused by FlareDeck's
1373
+ one-card rule, say "the harness will pick up KAN-0074", and stop — for an hour, because
1374
+ nothing did. So the harness resumes it first, and only sweeps when nothing is held. */
1375
+ if (await resumeOwnWork())
1376
+ return;
1377
+ // `live.size` covers a card whose session is parked between events.
1378
+ if (working || queue.length || live.size)
1379
+ return;
1380
+ if (enqueue({ id: 'sweep', event: 'sweep', line: 'checking what is still assigned to you' }))
1381
+ void drain();
1382
+ })();
894
1383
  };
895
1384
  /**
896
1385
  * A card of mine already in progress, picked up again without being asked.
@@ -905,21 +1394,46 @@ export async function serve(opts) {
905
1394
  * have been — with a worktree, a branch, and everything else a card turn gets.
906
1395
  */
907
1396
  const resumeOwnWork = async () => {
908
- if (!sweepsEnabled || stopping || working || queue.length || live.size)
909
- return;
1397
+ /* Not `live.size`: a session kept WARM for a follow-up is a cache, not work, and blocking on
1398
+ it meant the card in progress was never resumed within fifteen minutes of any turn. */
1399
+ if (paused || !sweepsEnabled || stopping || working || queue.length)
1400
+ return false;
910
1401
  let mine = [];
911
1402
  try {
912
- const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-outstanding', {});
1403
+ // Scoped, like every other account-wide question. This one is asked by the HARNESS rather
1404
+ // than by the model, so the proxy that narrows the model's calls never saw it — and a
1405
+ // board-scoped agent went straight back to picking up another team's cards: KD-1361, then
1406
+ // KD-1362, from a board nobody had ticked. The card then 404s, because it is not on a
1407
+ // board this agent works.
1408
+ const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-outstanding', { ...(boardArg([...myBoards]) !== undefined ? { board: boardArg([...myBoards]) } : {}) });
913
1409
  const text = typeof out === 'string' ? out : JSON.stringify(out);
914
- // The codes it names, in the order it names them: priority order, which is the board's.
915
- mine = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,9}-\d{3,}\b/g) ?? [])].map((code) => ({ code }));
1410
+ // Read from the fields the answer prints, and filtered here as well as asked for — the
1411
+ // server is deployed separately from this, so the scope cannot depend on its version.
1412
+ // In the order it names them: priority order, which is the board's.
1413
+ mine = outstandingCards(text)
1414
+ .filter((c) => onMyBoards(myBoards, c))
1415
+ .map((c) => ({ code: c.code }));
916
1416
  }
917
1417
  catch {
918
- return; // unreachable board is the stream's problem to report, not this timer's
1418
+ return false; // unreachable board is the stream's problem to report, not this timer's
1419
+ }
1420
+ /* IN PROGRESS by the lane's TYPE — the same test FlareDeck's one-card rule uses. This took the
1421
+ first outstanding card in priority order whatever its lane, so it picked KAN-0080 out of
1422
+ Review while KAN-0074 sat in "In dev" untouched: the model was told "the harness picks up
1423
+ the card in progress" and the harness picked a different one, and the agent went round
1424
+ that loop for an hour. Lane NAMES differ per board ("In dev", "Doing"); the type does not. */
1425
+ let next;
1426
+ for (const c of mine) {
1427
+ if (sessions.has(c.code) || live.has(c.code))
1428
+ continue;
1429
+ const card = await opts.deck?.card(c.code).catch(() => null);
1430
+ if (String(card?.lane?.type ?? '').toLowerCase() === 'inprogress') {
1431
+ next = c;
1432
+ break;
1433
+ }
919
1434
  }
920
- const next = mine.find((c) => !sessions.has(c.code) && !live.has(c.code));
921
1435
  if (!next)
922
- return;
1436
+ return false;
923
1437
  log('tool', `picking ${next.code} back up — it is yours and already in progress`);
924
1438
  if (enqueue({
925
1439
  id: `resume-${next.code}-${Date.now()}`,
@@ -929,6 +1443,7 @@ export async function serve(opts) {
929
1443
  cardKey: next.code,
930
1444
  }))
931
1445
  void drain();
1446
+ return true;
932
1447
  };
933
1448
  const sweepTimer = setInterval(sweepNow, SWEEP_EVERY_MS);
934
1449
  sweepTimer.unref();
@@ -1014,6 +1529,8 @@ export async function serve(opts) {
1014
1529
  status.startCard(current, !!event.cardKey);
1015
1530
  void report();
1016
1531
  let didWork = true;
1532
+ /** Set when the turn failed on its Claude plan, not its work: run it again at once. */
1533
+ let switchPlan = false;
1017
1534
  turnCwd = '';
1018
1535
  try {
1019
1536
  await handle(event);
@@ -1022,13 +1539,30 @@ export async function serve(opts) {
1022
1539
  // One bad event must not take the daemon down — that is the failure it exists to fix.
1023
1540
  didWork = !(e instanceof CouldNotWork);
1024
1541
  log('warn', `${current}: ${e instanceof Error ? e.message : String(e)}`);
1542
+ if (e instanceof RetryOnAnotherPlan)
1543
+ switchPlan = true;
1025
1544
  }
1026
1545
  // Acknowledged only after the work, and only if the work could happen at all. A crash
1027
1546
  // mid-turn redelivers; so does a turn that never had what it needed.
1028
1547
  status.finishCard(didWork);
1029
1548
  void report();
1030
1549
  pending.delete(event.id);
1031
- if (event.event === 'ask' || event.event === 'sweep') { /* local: nothing to acknowledge, nothing to retry */ }
1550
+ // The plan this event ran on has just moved: read it now, so the next `choose` and the
1551
+ // Secrets tab see where it stands. Not after a plan switch — that plan is filed already.
1552
+ // Skipped when the run already reported usage on its own — a separate read would cost a
1553
+ // probe request to learn what the run just said.
1554
+ if (turnPlan && !switchPlan && !turnSawUsage) {
1555
+ const plan = turnPlan;
1556
+ void subscriptionsForAgent(config.mcpUrl, () => opts.auth(), () => { })
1557
+ .then((subs) => measurePlan(subs, plan))
1558
+ .catch(() => { });
1559
+ }
1560
+ turnPlan = null;
1561
+ turnSawUsage = false;
1562
+ // Before the ask/sweep branch: a sweep that hit a dead plan is retried like any other event.
1563
+ if (switchPlan)
1564
+ enqueue(event, true);
1565
+ else if (event.event === 'ask' || event.event === 'sweep' || event.event === 'run-answered') { /* local: nothing to acknowledge, nothing to retry */ }
1032
1566
  else if (didWork)
1033
1567
  await stream.ack([event.id]);
1034
1568
  else {
@@ -1049,6 +1583,13 @@ export async function serve(opts) {
1049
1583
  // soon — and then taken down, so a day of cards does not leave a day of servers running.
1050
1584
  if (turnCwd && turnCwd !== config.repo)
1051
1585
  scheduleEnvironmentStop(turnCwd);
1586
+ // The environment is kept warm; the browser is not. It holds a Chromium tree, the next
1587
+ // turn builds its own, and a page left open is not something a later turn can reach.
1588
+ if (turnEyes) {
1589
+ const e = turnEyes;
1590
+ turnEyes = null;
1591
+ await e.close().catch(() => { });
1592
+ }
1052
1593
  current = '';
1053
1594
  delete state.plan;
1054
1595
  state.working = '';
@@ -1062,9 +1603,15 @@ export async function serve(opts) {
1062
1603
  finally {
1063
1604
  working = false;
1064
1605
  }
1065
- // The queue is empty and nothing is running: this agent has nothing to do. Tell its lead,
1066
- // once, rather than leaving them to notice.
1067
- if (!queue.length && !live.size)
1606
+ // An empty queue at the end of a drain means nothing is running and nothing is waiting: this
1607
+ // agent has nothing to do. Tell its lead, once, rather than leaving them to notice.
1608
+ //
1609
+ // This used to require `!live.size` as well, which reads like "nothing running" and is not.
1610
+ // `live` holds sessions kept WARM for fifteen minutes so a follow-up on the same card costs
1611
+ // a resume instead of a re-orientation — a cache, not work. So every agent went quiet for
1612
+ // fifteen minutes after finishing anything, which is exactly the window in which it most
1613
+ // needs a new card. Two sat idle and silent while eleven cards waited.
1614
+ if (!queue.length)
1068
1615
  void offerToLead();
1069
1616
  }
1070
1617
  /**
@@ -1153,7 +1700,59 @@ export async function serve(opts) {
1153
1700
  * needs-human" over the top of it. An agent said it had asked for help, a person was never told,
1154
1701
  * and the card sat.
1155
1702
  */
1156
- const flagCard = async (ref, reason, kind) => {
1703
+ /**
1704
+ * Put a local file on a card, done here rather than by the agent's shell. The upload is a PUT of
1705
+ * bytes to FlareDeck's own upload address, and the auto-mode classifier refused it as
1706
+ * "destructive, reaching production" — so an agent with seven screenshots asked a person to
1707
+ * upload them. Same three steps the tools describe (mint a link, PUT, wire it in), no curl.
1708
+ */
1709
+ const attachFile = async (card, path, comment) => {
1710
+ const { existsSync, readFileSync, realpathSync, statSync } = await import('node:fs');
1711
+ const { basename, extname, isAbsolute, resolve } = await import('node:path');
1712
+ const { tmpdir } = await import('node:os');
1713
+ const full = isAbsolute(path) ? path : resolve(card.cwd, path);
1714
+ if (!existsSync(full))
1715
+ throw new Error(`there is no file at ${full}`);
1716
+ const real = realpathSync(full);
1717
+ const roots = [card.cwd, '/tmp', '/private/tmp', tmpdir()].map((r) => { try {
1718
+ return realpathSync(r);
1719
+ }
1720
+ catch {
1721
+ return r;
1722
+ } });
1723
+ if (!roots.some((r) => real === r || real.startsWith(`${r}/`)))
1724
+ throw new Error('only files inside your checkout or /tmp can be attached');
1725
+ const size = statSync(real).size;
1726
+ if (size > 30 * 1024 * 1024)
1727
+ throw new Error(`the file is ${Math.round(size / 1048576)} MB; the limit is 30 MB`);
1728
+ const types = {
1729
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
1730
+ '.pdf': 'application/pdf', '.csv': 'text/csv', '.txt': 'text/plain', '.log': 'text/plain', '.json': 'application/json',
1731
+ '.md': 'text/markdown', '.html': 'text/html', '.mp4': 'video/mp4', '.webm': 'video/webm',
1732
+ };
1733
+ const contentType = types[extname(real).toLowerCase()] ?? 'application/octet-stream';
1734
+ const ids = { card_id: card.code || card.card, ...(card.board ? { board_id: card.board } : {}) };
1735
+ const minted = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-attachment-upload-url', {
1736
+ ...ids, file_name: basename(real), content_type: contentType,
1737
+ });
1738
+ const uploadUrl = /https?:\/\/[^\s"`]+\/upload\/[^\s"`]+/.exec(minted)?.[0];
1739
+ const objectPath = /`object_path`: `([^`]+)`/.exec(minted)?.[1];
1740
+ if (!uploadUrl || !objectPath)
1741
+ throw new Error('FlareDeck did not return an upload address');
1742
+ const put = await fetch(uploadUrl, { method: 'PUT', body: readFileSync(real), headers: { 'Content-Type': contentType } });
1743
+ if (!put.ok)
1744
+ throw new Error(`the upload was refused (${put.status})`);
1745
+ const wired = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'attach-uploaded-file', {
1746
+ ...ids, object_path: objectPath, attach_to: 'comment', ...(comment ? { comment } : {}),
1747
+ });
1748
+ log('tool', `attached ${basename(real)} to ${card.code || card.card}`);
1749
+ return `Attached ${basename(real)} to ${card.code || card.card}. ${wired.split('\n')[0]}`;
1750
+ };
1751
+ const flagCard = async (ref, reason, kind,
1752
+ /** The one-line ask and its answer buttons — what the person actually sees on a phone. */
1753
+ ask,
1754
+ /** Somewhere for the person to GO, which is what a sign-in request is mostly made of. */
1755
+ links) => {
1157
1756
  const label = ref.code || ref.card || 'card';
1158
1757
  try {
1159
1758
  const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'set-needs-human', {
@@ -1162,6 +1761,8 @@ export async function serve(opts) {
1162
1761
  ...(ref.board ? { board_id: ref.board } : {}),
1163
1762
  reason,
1164
1763
  ...(kind ? { kind } : {}),
1764
+ ...(ask ? { question: ask.question, options: ask.options } : {}),
1765
+ ...(links?.length ? { links } : {}),
1165
1766
  });
1166
1767
  if (out.trimStart().startsWith('\u274c') || out.trimStart().startsWith('\ud83d\udeab')) {
1167
1768
  log('warn', `${label}: FlareDeck did not flag this card \u2014 ${out.split('\n')[0]}`);
@@ -1218,7 +1819,7 @@ export async function serve(opts) {
1218
1819
  log('event', `${event.cardCode || event.cardKey}: comment on a finished card — nothing to do`);
1219
1820
  return;
1220
1821
  }
1221
- const key = event.cardKey || (event.event === 'ask' ? 'ask' : event.event === 'sweep' ? 'sweep' : event.event === 'scheduled' ? `schedule-${event.scheduleId}` : 'inbox');
1822
+ let key = event.cardKey || (event.event === 'ask' ? 'ask' : event.event === 'sweep' ? 'sweep' : event.event === 'scheduled' ? `schedule-${event.scheduleId}` : 'inbox');
1222
1823
  let turnTokens = 0;
1223
1824
  let nudged = false;
1224
1825
  // A checkout of its own per card, so it cannot edit a working tree someone else is in.
@@ -1307,9 +1908,42 @@ export async function serve(opts) {
1307
1908
  // the publishing tools and the journal. Without this, HAL committed its work on a message
1308
1909
  // turn, was told to publish with push_branch, and went looking for push_branch on the
1309
1910
  // filesystem: the harness had named a tool that turn did not have.
1911
+ // Room on the disk before a card gets a checkout. An agent left alone overnight filled one to
1912
+ // 762 MB free, and from there nothing works and nothing says why: Docker cannot make a
1913
+ // container, builds die half-written, the machine swaps. Below LOW the idle worktrees go first
1914
+ // (sooner than usual); below MIN the card is not started, and a person is told what to clear.
1915
+ if (event.cardKey && willChange && (chosen || where.kind === 'none')) {
1916
+ let free = freeDiskBytes(repo);
1917
+ if (free !== null && free < DISK_LOW) {
1918
+ await sweepWorktrees(repo, WORKTREE_IDLE_LOW_DISK_MS, (t) => log('tool', t), activeTrees()).catch(() => { });
1919
+ free = freeDiskBytes(repo);
1920
+ }
1921
+ if (free !== null && free < DISK_MIN) {
1922
+ const wall = await walls();
1923
+ if (wall) {
1924
+ await enterPause(wall);
1925
+ return;
1926
+ }
1927
+ }
1928
+ }
1310
1929
  const tree = event.cardKey && (chosen || where.kind === 'none')
1311
1930
  ? await ensureWorktree(repo, key, (t) => log('tool', t), startFrom, config.worktreeLocalFiles)
1312
- : await worktreeTheMessageIsAbout(event, repo, (t) => log('tool', t));
1931
+ : await worktreeTheMessageIsAbout(event, repo, (t) => log('tool', t), (code) => ensureWorktree(repo, code, (t) => log('tool', t), startFrom, config.worktreeLocalFiles));
1932
+ /**
1933
+ * Questions about a CARD get their own session, not the shared one.
1934
+ *
1935
+ * Every pane ask used the single key 'ask', and a session keeps the tools it was built with.
1936
+ * So the first question asked before a card had a checkout built a session with no browser
1937
+ * and no publishing tools — and every later question about that card resumed it and
1938
+ * inherited the lack. The agent reported it precisely ("the browser tools aren't mounted
1939
+ * this session") and went looking for headless Chrome in the shell instead, which is both
1940
+ * worse and the kind of workaround the guards exist to stop.
1941
+ *
1942
+ * Keyed on the checkout, so a question about a card shares a session with other questions
1943
+ * about THAT card and nothing else.
1944
+ */
1945
+ if (event.event === 'ask' && tree)
1946
+ key = `ask:${tree.path}`;
1313
1947
  const cwd = tree?.path ?? repo;
1314
1948
  // Back on this checkout: whatever was waiting to take its environment down can stand down.
1315
1949
  turnCwd = cwd;
@@ -1321,6 +1955,7 @@ export async function serve(opts) {
1321
1955
  // So a later message naming this card by its CODE can find the work. See rememberWorktree.
1322
1956
  if (tree && (event.cardCode || event.cardKey)) {
1323
1957
  rememberWorktree(event.cardCode || event.cardKey, tree.path, tree.branch);
1958
+ currentTree = tree.path;
1324
1959
  }
1325
1960
  // No worktree, but this turn is going to change something. That used to carry on in whatever
1326
1961
  // directory happened to be the fallback — no branch, no isolation, and no word said. An agent
@@ -1416,7 +2051,7 @@ export async function serve(opts) {
1416
2051
  tag = code;
1417
2052
  state.working = code;
1418
2053
  };
1419
- const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), finishGate, noteCard, keepTool, savedSoFar);
2054
+ const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), finishGate, noteCard, keepTool, savedSoFar, [...myBoards]);
1420
2055
  const held = live.get(key);
1421
2056
  if (held?.timer)
1422
2057
  clearTimeout(held.timer);
@@ -1448,6 +2083,19 @@ export async function serve(opts) {
1448
2083
  if (!resume && event.cardKey && event.event !== 'review' && event.event !== 'ask') {
1449
2084
  await startOnBoard(event.cardCode || event.cardKey);
1450
2085
  }
2086
+ /**
2087
+ * Which card this turn is about — including when nobody dispatched it.
2088
+ *
2089
+ * This was set only from the event's own cardKey, so a question typed in the pane about a
2090
+ * card had none: the agent had the card, its checkout and its environment, hit a sign-in it
2091
+ * could not pass, and was told "there is no card being worked, so there is nobody to ask
2092
+ * about it". It fell back to leaving a comment, which nobody is notified about. The one
2093
+ * moment the escalation exists for, and it was unavailable.
2094
+ *
2095
+ * `tag` is the code the turn resolved to — from the event, or from the card a pane question
2096
+ * named. FlareDeck takes a human code wherever it takes a card id, so the code alone is
2097
+ * enough to flag, comment and clear.
2098
+ */
1451
2099
  onCard = event.cardKey
1452
2100
  ? {
1453
2101
  card: event.cardKey,
@@ -1455,7 +2103,9 @@ export async function serve(opts) {
1455
2103
  ...(event.boardKey ? { board: event.boardKey } : {}),
1456
2104
  ...(event.cardCode ? { code: event.cardCode } : {}),
1457
2105
  }
1458
- : null;
2106
+ : tag
2107
+ ? { card: tag, cwd, code: tag, ...(event.boardKey ? { board: event.boardKey } : {}) }
2108
+ : null;
1459
2109
  job = tree
1460
2110
  ? {
1461
2111
  cwd,
@@ -1464,7 +2114,13 @@ export async function serve(opts) {
1464
2114
  // back to the repo's default branch when it has not.
1465
2115
  base: startFrom ?? (await baseBranch(cwd)),
1466
2116
  card: event.cardCode || key,
2117
+ onAuthFailure: (why) => {
2118
+ void enterPause({ check: 'github', reason: `GitHub refused this machine's login when I pushed (${why.slice(0, 160)}), so nothing I write can be published. Somebody signs it in again on this machine: \`flaredeck-agent login --github-token ghp_…\` (a classic token with repo and workflow scope does not expire).` });
2119
+ },
1467
2120
  token: async () => {
2121
+ // Read fresh at every push: the login is the machine's, so signing in again from
2122
+ // any pane fixes an agent that has been running all day with the old one.
2123
+ config.github = loadGitHub() ?? config.github;
1468
2124
  if (!config.github)
1469
2125
  throw new Error('This machine has no GitHub login — run flaredeck-agent login.');
1470
2126
  return freshGitHubToken(config.github, (next) => {
@@ -1492,7 +2148,7 @@ export async function serve(opts) {
1492
2148
  // servers before it was allowed to look at a stylesheet. An agent that finds it does need
1493
2149
  // something running can start it itself; the brief says how.
1494
2150
  const needsRunning = (prepared?.jev?.needs_running ?? 0) >= 0.5;
1495
- const env = forACard && needsRunning
2151
+ let env = forACard && needsRunning
1496
2152
  // The script this board declared for the repository this card is in, if it declared one.
1497
2153
  // Matched on the remote, the same way the base branch is.
1498
2154
  ? await startEnvironment(cwd, (t) => log('tool', t), where.kind === 'repo' ? where.script : undefined)
@@ -1500,10 +2156,259 @@ export async function serve(opts) {
1500
2156
  // The guard is built once at startup; what this agent can reach changes per card. Handing it
1501
2157
  // the live picture is the difference between judging "$DATABASE_URL" as a scratch database
1502
2158
  // and judging it as a live one.
2159
+ // Deployed addresses the board declared for this card's repository — the dev or UAT site. A
2160
+ // card is usually about code that is already live somewhere, and an agent that can only see
2161
+ // what it started locally cannot tell its own change from how the thing already behaved.
2162
+ const declaredSites = where.kind === 'repo' ? where.sites ?? {} : {};
2163
+ // Which started part is the app itself — asked of the parts, since boards rarely name them.
2164
+ const web = Object.keys(env.endpoints).length ? await webPart(env.endpoints).catch(() => undefined) : undefined;
2165
+ if (web)
2166
+ log('tool', `browser: "${web}" serves the app — open_page opens it by default`);
2167
+ /**
2168
+ * Starting the project, as something the agent can ASK FOR.
2169
+ *
2170
+ * It was decided once, before the turn, by a classifier not even consulted for a question
2171
+ * typed in the pane — so the answer defaulted to "no" however plainly the card said
2172
+ * otherwise, and the agent had to find the script and start it detached by hand. Now it can
2173
+ * ask, and a failure hands back the error together with what it may do about it.
2174
+ */
2175
+ const envTools = environmentTools({
2176
+ cwd: () => cwd,
2177
+ script: () => (where.kind === 'repo' ? where.script : undefined),
2178
+ running: () => (env.ran ? env : null),
2179
+ // The same deployed addresses the browser gets, so a sign-in can be asked for the dev or
2180
+ // UAT site and not only for something this card started.
2181
+ sites: () => declaredSites,
2182
+ // Straight onto the live picture: the browser reads its addresses through a getter, so
2183
+ // open_page works the moment this returns — no new turn, nothing to re-read.
2184
+ onStarted: (started) => {
2185
+ env = started;
2186
+ reachable = { cwd, endpoints: env.endpoints };
2187
+ },
2188
+ onNote: (t) => log('tool', t),
2189
+ /**
2190
+ * Turning "ask somebody" into something that actually asks.
2191
+ *
2192
+ * Three pieces already worked and were never joined up: open_page DETECTS a sign-in wall
2193
+ * and says which kind it is, signInSession captures a session a person completes by hand,
2194
+ * and loadSession replays it for every later card. The gap was the middle. All wallBrief
2195
+ * could do was tell the agent in prose to ask, with no tool to call — so the agent wrote a
2196
+ * comment, flagged the card and stopped. That was the only thing available to it, and it
2197
+ * read like a model that would not try.
2198
+ *
2199
+ * So: start the viewer, flag the card with the address (a flag is what reaches a phone),
2200
+ * and when the session lands, store it, clear the flag and put the card back at the front
2201
+ * of the queue. The agent's next turn opens the app already signed in.
2202
+ *
2203
+ * The viewer is LOOPBACK, deliberately: it drives a browser part-way through somebody's
2204
+ * sign-in, which is not a thing to publish on a reachable port. So the person has to be at
2205
+ * this machine. That is the case this is for — an agent testing localhost on a developer's
2206
+ * own box — and for a cloud agent it is a stated limit rather than a silent failure.
2207
+ */
2208
+ ...(onCard
2209
+ ? {
2210
+ requestSignIn: async ({ part, url, why }) => {
2211
+ const card = onCard;
2212
+ if (!card)
2213
+ return { asked: false, why: 'the card went away while this was being asked' };
2214
+ if (!(await ensureBrowser((t) => log('tool', `browser: ${t}`)))) {
2215
+ return { asked: false, why: 'this machine has no browser and could not fetch one' };
2216
+ }
2217
+ const { chromium } = await import('playwright-core');
2218
+ const { signInSession } = await import('./signin.js');
2219
+ const { rememberSignInNow, HEADLESS_FULL } = await import('./browser.js');
2220
+ const { randomBytes } = await import('node:crypto');
2221
+ /* Through FlareDeck, not through a port on this machine. The person may be on a
2222
+ phone and the agent may be on a cloud box, which is the case this exists for;
2223
+ loopback only ever served the one where they are in the same room. */
2224
+ const id = `sgn_${randomBytes(9).toString('base64url')}`;
2225
+ /* agent-service, not the MCP. The relay was first built onto flaredeck-mcp, which
2226
+ cannot host it: the MCP deploys as a Firebase Function, and a Function hands you
2227
+ (req, res) with no http.Server underneath, so a WebSocket upgrade can never
2228
+ complete there. agent-service is a real Cloud Run service and already the one
2229
+ this harness holds an event stream open to. */
2230
+ const serviceUrl = process.env['FLAREDECK_RELAY_URL'] ?? config.agentServiceUrl;
2231
+ if (!serviceUrl) {
2232
+ // Configured by default, so this means somebody blanked it. Say which setting.
2233
+ return { asked: false, why: 'this harness has no agentServiceUrl, so a sign-in cannot be relayed' };
2234
+ }
2235
+ const relayBase = serviceUrl.replace(/^http/, 'ws').replace(/\/+$/, '');
2236
+ const appBase = (process.env['FLAREDECK_APP_URL'] ?? 'https://app.flaredeck.app').replace(/\/+$/, '');
2237
+ const viewerUrl = (t) => `${appBase}/signin/${id}?t=${encodeURIComponent(t)}`;
2238
+ /* Resolve as far as the ADDRESS and no further. Waiting for the sign-in itself would
2239
+ hold the agent's tool call open for as long as a person takes to get to their
2240
+ machine, and not waiting is the whole point: the card parks, the turn ends. */
2241
+ let settle;
2242
+ const ready = new Promise((r) => { settle = r; });
2243
+ const ref = {
2244
+ card: card.card,
2245
+ ...(card.board ? { board: card.board } : {}),
2246
+ ...(card.code ? { code: card.code } : {}),
2247
+ };
2248
+ void signInSession({
2249
+ url,
2250
+ launch: () => chromium.launch(HEADLESS_FULL),
2251
+ relay: { url: relayBase, token: await opts.auth(), id, viewerUrl },
2252
+ onReady: (viewer) => {
2253
+ log('tool', `sign-in wanted for "${part}" — ${viewer}`);
2254
+ void flagCard(ref, `${url} needs someone to sign in before this card can go on. ${why}\n\n` +
2255
+ `Open the sign-in below and do it there. It is this agent's real browser, ` +
2256
+ `shown live — click and type in the picture, then press Save & finish. The ` +
2257
+ `session is kept for the whole project, so this is once, not once per card.`, 'sign_in', undefined, [{ url: viewer, label: 'Sign in' }]);
2258
+ settle({ asked: true, where: viewer });
2259
+ },
2260
+ onNote: (t) => log('tool', `sign-in: ${t}`),
2261
+ })
2262
+ .then(async (r) => {
2263
+ if (!r.ok || !r.session) {
2264
+ log('warn', `sign-in for "${part}" did not finish: ${r.why ?? 'nothing was saved'}`);
2265
+ /* Say so ON THE CARD. The flag already raised carries a link to a viewer
2266
+ that is now dead, and a person coming back to it an hour later would find
2267
+ a page that cannot connect and no indication why — which reads as the
2268
+ feature being broken rather than as a window having closed. */
2269
+ void flagCard(ref, `The sign-in for ${url} expired before anyone did it — the link in the ` +
2270
+ `earlier request no longer works.\n\n${why}\n\nNothing is broken: ask ` +
2271
+ `the agent to try again and a fresh sign-in is offered.`, 'sign_in');
2272
+ settle({ asked: false, why: r.why ?? 'nothing was saved' });
2273
+ return;
2274
+ }
2275
+ /* Awaited, and the failure is handled. Sessions live only in FlareDeck now —
2276
+ there is no copy on this machine — so a failed write means the sign-in is
2277
+ GONE. Announcing "Signed in" and resuming the card would wake an agent that
2278
+ meets the same wall and asks again: a loop that reads as the feature being
2279
+ broken rather than as one write having failed. */
2280
+ const saved = await rememberSignInNow(config.repo, part, r.session, (t) => log('tool', `sign-in: ${t}`));
2281
+ if (!saved.ok) {
2282
+ log('warn', `sign-in for "${part}" completed but was NOT saved: ${saved.message ?? 'no reason given'}`);
2283
+ void flagCard(ref, `Someone signed in to ${url}, but the session could not be saved to FlareDeck ` +
2284
+ `(${saved.message ?? 'no reason given'}). Credentials are kept only in FlareDeck, ` +
2285
+ `so nothing was retained here and the sign-in has been lost.\n\n${why}\n\n` +
2286
+ `Once FlareDeck is reachable, ask the agent to try again.`, 'sign_in');
2287
+ settle({ asked: false, why: saved.message ?? 'the session could not be saved' });
2288
+ return;
2289
+ }
2290
+ const site = saved.id;
2291
+ log('tool', `signed in — kept for ${site}; every card on this project opens straight in`);
2292
+ await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'clear-needs-human', {
2293
+ card_id: card.code || card.card,
2294
+ ...(card.board ? { board_id: card.board } : {}),
2295
+ }).catch((e) => log('warn', `could not clear needs-human: ${e instanceof Error ? e.message : String(e)}`));
2296
+ parked.delete(card.card);
2297
+ resumeParked(card, `Signed in. The session is kept for ${site}, so open_page opens the app signed ` +
2298
+ `in from now on. Carry on with what you were going to check: ${why}`);
2299
+ })
2300
+ .catch((e) => {
2301
+ const why2 = e instanceof Error ? e.message : String(e);
2302
+ log('warn', `sign-in for "${part}" failed: ${why2}`);
2303
+ settle({ asked: false, why: why2 });
2304
+ });
2305
+ return ready;
2306
+ },
2307
+ }
2308
+ : {}),
2309
+ });
2310
+ /**
2311
+ * Always mounted. Mounting it only when something was already running is what cached a
2312
+ * stale toolset for a whole session: the first question about a card came before its
2313
+ * checkout existed, so that session had no browser, and every later question resumed it and
2314
+ * inherited the lack — the agent went looking for headless Chrome in the shell instead. An
2315
+ * agent with nothing to look at simply does not call these; the brief says what to do first.
2316
+ */
2317
+ const eyes = turnEyes = browserTools({
2318
+ repo: config.repo,
2319
+ endpoints: () => env.endpoints,
2320
+ ...(web ? { defaultPart: () => web } : {}),
2321
+ sites: () => declaredSites,
2322
+ contract: () => env.contract,
2323
+ cwd,
2324
+ onNote: (t) => log('tool', t),
2325
+ });
1503
2326
  reachable = { cwd, endpoints: env.endpoints };
1504
- if (forACard && !needsRunning)
1505
- log('tool', 'environment not started — this card does not look like it needs one');
2327
+ /**
2328
+ * Say which it is, because they are not the same and the difference matters.
2329
+ *
2330
+ * "this card does not look like it needs one" was printed whenever the project was not
2331
+ * started — including when nothing had looked. Intake does not run for a question typed in
2332
+ * the pane, so `needs_running` defaulted to 0 and the agent was told a judgement had been
2333
+ * made about its card that had not been made at all.
2334
+ */
2335
+ if (forACard && !needsRunning) {
2336
+ log('tool', prepared
2337
+ ? 'environment not started — this card does not look like it needs one'
2338
+ : 'environment not started — nothing judged whether it is needed; call start_environment if it is');
2339
+ }
2340
+ // A lead's sweep is a JOB, not a nudge: clear the ready column by running the team. A nudge
2341
+ // got one look and "nothing to do"; a goal keeps the lead going, turn after turn, until the
2342
+ // board itself says it is done — proven by get-portfolio, not by the lead's own say-so.
2343
+ const leadGoal = isLead && event.event === 'sweep' && process.env['FLAREDECK_AGENT_LEAD_GOAL'] !== '0'
2344
+ ? LEAD_SWEEP_GOAL
2345
+ : undefined;
2346
+ if (leadGoal)
2347
+ log('tool', 'sweep with a goal: clear the ready column by dispatching (≤8 turns)');
2348
+ /**
2349
+ * Which Claude subscription pays for this run.
2350
+ *
2351
+ * Three subscriptions and twenty-odd agents, with daily and weekly caps that trip at
2352
+ * different times, used to mean going round the machines logging in again. FlareDeck holds
2353
+ * the tokens and the ledger now and picks one per run.
2354
+ *
2355
+ * Not reaching FlareDeck is NOT the same as having nothing left: `machine` means carry on
2356
+ * with whatever this box is logged in as, exactly as before any of this existed. Only
2357
+ * `none` — every subscription genuinely spent or paused — stops the run, and it says when
2358
+ * one frees up rather than failing blankly.
2359
+ */
2360
+ /* The MCP list, fresh for this session — and a required server that is not connected stops
2361
+ the work here, before a plan is spent on a card that cannot be done properly. */
2362
+ const missingMcp = await refreshMcp();
2363
+ if (missingMcp?.length) {
2364
+ await enterPause(mcpWallFor(missingMcp));
2365
+ return;
2366
+ }
2367
+ const subs = await subscriptionsForAgent(config.mcpUrl, () => opts.auth(), (t) => log('tool', t));
2368
+ const claudePlan = subs ? await subs.choose(failedPlans.get(event.id) ?? []) : null;
2369
+ if (claudePlan?.use === 'token') {
2370
+ log('tool', `claude: ${claudePlan.label}`);
2371
+ // On the identity line too, not only in the scrollback: the scrollback moves on, and
2372
+ // "which account is this agent spending" is a thing you glance at, not search for.
2373
+ state.claudeAccount = claudePlan.label;
2374
+ // Measured again once this event is done — see measurePlan.
2375
+ turnPlan = { id: claudePlan.id, label: claudePlan.label, token: claudePlan.token };
2376
+ }
2377
+ if (claudePlan?.use === 'none') {
2378
+ const when = claudePlan.availableAt ? new Date(claudePlan.availableAt).toLocaleString() : 'later';
2379
+ const tried = failedPlans.get(event.id)?.length ?? 0;
2380
+ failedPlans.delete(event.id);
2381
+ /**
2382
+ * Spent, not paused: the harness knows to the minute when a plan comes back — FlareDeck's
2383
+ * answer carries the soonest reset — so it waits for it and carries on by itself. Nobody is
2384
+ * asked to do anything about a clock. Only a fleet with NO reset coming (every plan paused,
2385
+ * which only a person undoes) is flagged.
2386
+ */
2387
+ if (claudePlan.availableAt && claudePlan.availableAt > Date.now()) {
2388
+ const at = claudePlan.availableAt;
2389
+ if (!waitingForPlan.has(event.id)) {
2390
+ // A little after the reset, so the first request is not refused for being a second early.
2391
+ const timer = setTimeout(() => {
2392
+ waitingForPlan.delete(event.id);
2393
+ log('event', `a Claude subscription is back — resuming ${event.cardCode || event.line}`);
2394
+ if (enqueue(event, true))
2395
+ void drain();
2396
+ }, at - Date.now() + 60_000);
2397
+ timer.unref?.();
2398
+ waitingForPlan.set(event.id, timer);
2399
+ }
2400
+ log('warn', `every Claude subscription is spent${tried ? ` (${tried} failed on this turn)` : ''} — waiting, resuming at ${new Date(at).toLocaleTimeString()}`);
2401
+ return;
2402
+ }
2403
+ log('warn', `every Claude subscription is spent or paused${tried ? ` (${tried} failed on this turn)` : ''} — the soonest frees up ${when}`);
2404
+ await flagCard({ card: event.cardKey ?? '', ...(event.boardKey ? { board: event.boardKey } : {}), ...(event.cardCode ? { code: event.cardCode } : {}) }, `I cannot start this card: every Claude subscription in FlareDeck is spent, paused` +
2405
+ `${tried ? ', or just failed on this card' : ''}. ` +
2406
+ `The soonest one frees up ${when}.\n\nNothing is wrong with the card. Add or unpause a ` +
2407
+ `subscription on the Integrations page, or wait for the window to reset.`, 'technical');
2408
+ return;
2409
+ }
1506
2410
  const engineContext = {
2411
+ ...(leadGoal ? { goal: leadGoal } : {}),
1507
2412
  config: { ...config, repo: cwd },
1508
2413
  // Only a real worktree confines anything; without one nothing is enforced here, and the
1509
2414
  // guard below makes sure a turn that would CHANGE code never gets that far.
@@ -1533,21 +2438,16 @@ export async function serve(opts) {
1533
2438
  ...(resume ? { resume } : {}),
1534
2439
  ...(prepared?.model ? { model: prepared.model } : {}),
1535
2440
  ...(prepared?.size ? { size: prepared.size } : {}),
2441
+ ...(claudePlan?.use === 'token' ? { claudeToken: claudePlan.token } : {}),
1536
2442
  tools: {
1537
2443
  ...(job ? { git: { server: gitServer, toolNames: GIT_TOOL_NAMES } } : {}),
1538
- // Eyes, when there is something running to look at. A card with no environment gets no
1539
- // browser tools and is told nothing about them.
1540
- ...(env.ran && Object.keys(env.endpoints).length
1541
- ? {
1542
- browser: browserTools({
1543
- repo: config.repo,
1544
- endpoints: () => env.endpoints,
1545
- contract: () => env.contract,
1546
- cwd,
1547
- onNote: (t) => log('tool', t),
1548
- }),
1549
- }
1550
- : {}),
2444
+ // Eyes, for any card. They used to be mounted only when the harness had itself started
2445
+ // something, which left an agent working alone unable to get them: no start script meant
2446
+ // no browser AND no mention of one, so it could not know that writing the script it is
2447
+ // perfectly able to write was the thing standing between it and seeing its own change.
2448
+ // The endpoints are read through a getter, so something the agent starts mid-turn counts.
2449
+ browser: eyes,
2450
+ env: envTools,
1551
2451
  team: teamSet,
1552
2452
  flaredeck,
1553
2453
  runs: runsSet,
@@ -1568,7 +2468,7 @@ export async function serve(opts) {
1568
2468
  freeWorkerBrief(event) +
1569
2469
  // Only tell an agent about the environment when the card actually needs one running.
1570
2470
  environmentBrief(env, cwd, needsRunning) + notStartedBrief(forACard && !needsRunning, cwd) +
1571
- browserBrief(env.ran && Object.keys(env.endpoints).length > 0, env.endpoints) +
2471
+ browserBrief(env.endpoints, declaredSites, forACard, web) +
1572
2472
  (wrongEnv.length
1573
2473
  ? `\n\nSTOP — the environment that started is not yours:\n${wrongEnv.map((w) => ` · ${w}`).join('\n')}\n\n` +
1574
2474
  `Anything you test against it tells you about another card's code, and it will answer ` +
@@ -1580,82 +2480,149 @@ export async function serve(opts) {
1580
2480
  liveRun = run;
1581
2481
  // An agent that has started fighting the tools is not going to stop on its own.
1582
2482
  const struggling = watchForDesperation();
1583
- for await (const out of run.events) {
1584
- status.observe(out);
1585
- if (out.kind === 'tool') {
1586
- const stepBack = struggling.saw(out.name);
1587
- if (stepBack) {
1588
- log('warn', `${struggling.refusals()} refusals this turn — telling it to stop and ask`);
1589
- const heard = await run.steer(stepBack).catch(() => false);
1590
- // An engine that cannot be steered gets stopped instead: letting it carry on is how a
1591
- // turn becomes twenty minutes of working around a guard.
1592
- if (!heard) {
1593
- log('warn', 'cannot steer this engine — ending the turn');
1594
- await run.stop().catch(() => { });
2483
+ /**
2484
+ * One cap is usually seen two or three times in a run: the structured `rate_limit_event`
2485
+ * (which knows the window and the reset), then the error on the assistant message and the
2486
+ * result text (which know neither). Reporting all of them filed an `unknown` window with a
2487
+ * guessed five-hour block on top of the real one — keeping a plan out hours past its actual
2488
+ * reset. Once a run has reported a named window, the anonymous echoes are dropped.
2489
+ */
2490
+ let spentNamed = false;
2491
+ let spentAny = false;
2492
+ const spentReports = [];
2493
+ let doneText = '';
2494
+ let runError = null;
2495
+ try {
2496
+ for await (const out of run.events) {
2497
+ status.observe(out);
2498
+ if (out.kind === 'done' && !out.ok)
2499
+ doneText = String(out.result ?? '');
2500
+ if (out.kind === 'limits' && claudePlan?.use === 'token') {
2501
+ /* Reported, never interpreted here. 'allowed_warning' arrives while the plan is still
2502
+ working — treating it as spent would retire every subscription at about half capacity
2503
+ and leave the agents with nothing while each account still had headroom. */
2504
+ if (out.spent) {
2505
+ const named = !!out.window && out.window !== 'unknown';
2506
+ spentAny = true;
2507
+ if (named || !spentNamed) {
2508
+ spentNamed ||= named;
2509
+ spentReports.push(subs?.spent(claudePlan.id, out.window, out.resetsAt, out.why ?? `hit during ${event.cardCode ?? event.cardKey ?? 'a run'}`) ?? Promise.resolve());
2510
+ log('warn', `claude: ${claudePlan.label} hit its ${out.window ?? 'usage'} limit (${out.source ?? 'unknown signal'})`);
2511
+ }
2512
+ }
2513
+ else if (out.window && typeof out.utilization === 'number') {
2514
+ turnSawUsage = true;
2515
+ void subs?.seen(claudePlan.id, out.window, out.utilization, out.resetsAt);
1595
2516
  }
1596
- if (tag)
1597
- void tellLeadItIsStuck(tag, struggling.refusals());
1598
2517
  }
1599
- }
1600
- state.tasks = status.snapshot().tasks;
1601
- // An answer to a question typed here is the whole point of the turn, so it is shown whole;
1602
- // a card turn's narration is one line each, the card holds the full account.
1603
- if (out.kind === 'text')
1604
- log(event.event === 'ask' ? 'answer' : 'say', out.text);
1605
- else if (out.kind === 'tool')
1606
- log('tool', out.name);
1607
- // A changed plan is the most informative thing an agent emits, so it is pushed at once
1608
- // rather than waiting for the timer.
1609
- else if (out.kind === 'todo') {
1610
- log('todo', out.text);
1611
- void report();
1612
- }
1613
- // The plan replaces the previous one — TodoWrite always sends the whole list.
1614
- else if (out.kind === 'plan') {
1615
- state.plan = out.items;
1616
- state.tasks = {
1617
- done: out.items.filter((t) => t.status === 'completed').length,
1618
- total: out.items.length,
1619
- };
1620
- void report();
1621
- }
1622
- else if (out.kind === 'ready') {
1623
- if (out.sessionId)
1624
- sessions.set(key, out.sessionId);
1625
- // The preflight proved the server answers; this proves the SESSION got it. The SDK will
1626
- // start the model with a server still 'pending' or 'needs-auth' and list its tools from
1627
- // memory, so a count alone is not enough — the status has to say connected. Refuse the
1628
- // turn otherwise: the event stays queued and comes back once the tools are really there.
1629
- const fd = out.mcp.find((s) => s.name === config.mcpServerName);
1630
- log('tool', `session: FlareDeck ${fd?.status ?? 'absent'} · ${out.tools.length} tools · ${out.permissionMode}`);
1631
- if (!fd || fd.status !== 'connected' || out.tools.length === 0) {
1632
- throw new CouldNotWork(`this session's FlareDeck server is ${fd?.status ?? 'absent'}, so the card cannot be read — leaving the event queued`);
2518
+ if (out.kind === 'tool') {
2519
+ const stepBack = struggling.saw(out.name);
2520
+ if (stepBack) {
2521
+ log('warn', `${struggling.refusals()} refusals this turn — telling it to stop and ask`);
2522
+ const heard = await run.steer(stepBack).catch(() => false);
2523
+ // An engine that cannot be steered gets stopped instead: letting it carry on is how a
2524
+ // turn becomes twenty minutes of working around a guard.
2525
+ if (!heard) {
2526
+ log('warn', 'cannot steer this engine — ending the turn');
2527
+ await run.stop().catch(() => { });
2528
+ }
2529
+ if (tag)
2530
+ void tellLeadItIsStuck(tag, struggling.refusals());
2531
+ }
1633
2532
  }
1634
- }
1635
- else if (out.kind === 'usage') {
1636
- turnTokens = out.contextTokens;
1637
- if (!nudged && turnTokens >= CONTEXT_WRAP_UP_TOKENS && liveRun) {
1638
- nudged = true;
1639
- log('warn', `context at ${Math.round(turnTokens / 1000)}k — asking it to wrap up before the window fills`);
1640
- const wrapUpText = `Your context is at ${Math.round(turnTokens / 1000)}k tokens and will be compacted soon, which loses detail. ` +
1641
- 'Finish the step you are on, then stop: comment on the card with where things stand, put anything half-done ' +
1642
- 'and anything that did NOT work into set-card-notes, and end the turn. A fresh session will pick the card up from the note.';
1643
- void liveRun.steer(wrapUpText);
2533
+ state.tasks = status.snapshot().tasks;
2534
+ // An answer to a question typed here is the whole point of the turn, so it is shown whole;
2535
+ // a card turn's narration is one line each, the card holds the full account.
2536
+ if (out.kind === 'text')
2537
+ log(event.event === 'ask' ? 'answer' : 'say', out.text);
2538
+ else if (out.kind === 'tool')
2539
+ log('tool', out.name);
2540
+ // A changed plan is the most informative thing an agent emits, so it is pushed at once
2541
+ // rather than waiting for the timer.
2542
+ else if (out.kind === 'todo') {
2543
+ log('todo', out.text);
2544
+ void report();
1644
2545
  }
1645
- }
1646
- else if (out.kind === 'compacted')
1647
- log('warn', 'the SDK compacted this session mid-turn — detail before this point is summarised, not kept');
1648
- else if (out.kind === 'done') {
1649
- if (!out.ok)
1650
- log('warn', 'the turn did not finish cleanly');
1651
- if (out.contextTokens)
2546
+ // The plan replaces the previous one — TodoWrite always sends the whole list.
2547
+ else if (out.kind === 'plan') {
2548
+ state.plan = out.items;
2549
+ state.tasks = {
2550
+ done: out.items.filter((t) => t.status === 'completed').length,
2551
+ total: out.items.length,
2552
+ };
2553
+ void report();
2554
+ }
2555
+ else if (out.kind === 'ready') {
2556
+ if (out.sessionId)
2557
+ sessions.set(key, out.sessionId);
2558
+ // The preflight proved the server answers; this proves the SESSION got it. The SDK will
2559
+ // start the model with a server still 'pending' or 'needs-auth' and list its tools from
2560
+ // memory, so a count alone is not enough — the status has to say connected. Refuse the
2561
+ // turn otherwise: the event stays queued and comes back once the tools are really there.
2562
+ const fd = out.mcp.find((s) => s.name === config.mcpServerName);
2563
+ log('tool', `session: FlareDeck ${fd?.status ?? 'absent'} · ${out.tools.length} tools · ${out.permissionMode}`);
2564
+ if (!fd || fd.status !== 'connected' || out.tools.length === 0) {
2565
+ throw new CouldNotWork(`this session's FlareDeck server is ${fd?.status ?? 'absent'}, so the card cannot be read — leaving the event queued`);
2566
+ }
2567
+ }
2568
+ else if (out.kind === 'usage') {
1652
2569
  turnTokens = out.contextTokens;
1653
- // The number the intake step is meant to move: how big this turn's context got.
1654
- if (turnTokens)
1655
- log('tool', `turn: ${Math.round(turnTokens / 1000)}k context${prepared?.size ? ` · ${prepared.size} work` : ''}`);
2570
+ if (!nudged && turnTokens >= CONTEXT_WRAP_UP_TOKENS && liveRun) {
2571
+ nudged = true;
2572
+ log('warn', `context at ${Math.round(turnTokens / 1000)}k — asking it to wrap up before the window fills`);
2573
+ const wrapUpText = `Your context is at ${Math.round(turnTokens / 1000)}k tokens and will be compacted soon, which loses detail. ` +
2574
+ 'Finish the step you are on, then stop: comment on the card with where things stand, put anything half-done ' +
2575
+ 'and anything that did NOT work into set-card-notes, and end the turn. A fresh session will pick the card up from the note.';
2576
+ void liveRun.steer(wrapUpText);
2577
+ }
2578
+ }
2579
+ else if (out.kind === 'compacted')
2580
+ log('warn', 'the SDK compacted this session mid-turn — detail before this point is summarised, not kept');
2581
+ else if (out.kind === 'done') {
2582
+ if (!out.ok)
2583
+ log('warn', 'the turn did not finish cleanly');
2584
+ if (out.contextTokens)
2585
+ turnTokens = out.contextTokens;
2586
+ // The number the intake step is meant to move: how big this turn's context got.
2587
+ if (turnTokens)
2588
+ log('tool', `turn: ${Math.round(turnTokens / 1000)}k context${prepared?.size ? ` · ${prepared.size} work` : ''}`);
2589
+ }
1656
2590
  }
1657
2591
  }
2592
+ catch (e) {
2593
+ runError = e;
2594
+ }
1658
2595
  liveRun = null;
2596
+ /**
2597
+ * The plan failed, not the work: switch plans and run this event again, now.
2598
+ *
2599
+ * Two causes, told apart because they are fixed differently. A spent window is already filed
2600
+ * above (awaited here, so `choose` sees it). A rejected token — "401 Invalid bearer token",
2601
+ * a mis-pasted or revoked setup-token — is not capacity and is not filed as spent; it is
2602
+ * excluded for this event, and says plainly that the token needs replacing.
2603
+ *
2604
+ * The retry resumes the same session, so work already done in the turn is not lost; it costs
2605
+ * the prompt cache, which is the price of carrying on at all.
2606
+ */
2607
+ if (claudePlan?.use === 'token') {
2608
+ const said = `${runError instanceof Error ? runError.message : runError ?? ''} ${doneText}`;
2609
+ const rejected = /API Error: 401|invalid bearer token|OAuth token has expired|OAuth token (was )?revoked/i.test(said);
2610
+ if (rejected || spentAny) {
2611
+ await Promise.allSettled(spentReports);
2612
+ const tried = [...(failedPlans.get(event.id) ?? []), claudePlan.id];
2613
+ if (rejected) {
2614
+ log('warn', `claude: ${claudePlan.label} — its token was rejected. Replace it on Integrations → Secrets.`);
2615
+ }
2616
+ if (tried.length <= MAX_PLAN_SWITCHES) {
2617
+ failedPlans.set(event.id, tried);
2618
+ throw new RetryOnAnotherPlan(`${claudePlan.label} ${rejected ? 'rejected its token' : 'is at its limit'} — switching subscription and trying again`);
2619
+ }
2620
+ failedPlans.delete(event.id);
2621
+ }
2622
+ }
2623
+ failedPlans.delete(event.id);
2624
+ if (runError)
2625
+ throw runError;
1659
2626
  // A question from the pane: no card to hand over to. Its session is kept so the next question
1660
2627
  // can build on this one, and simply dropped once it has grown too large to be worth carrying.
1661
2628
  if (event.event === 'ask') {
@@ -1817,8 +2784,8 @@ export async function serve(opts) {
1817
2784
  ui = startUi({ state: () => ({ ...state, queued: queue.length }), onSteer: steer, onQuit: shutdown, ...(decide ? { onDecide: decide } : {}) });
1818
2785
  try {
1819
2786
  for await (const event of stream.events((s) => {
1820
- state.connected = s === 'listening';
1821
- log(s === 'listening' ? 'tool' : 'warn', s);
2787
+ state.connected = s.startsWith('listening');
2788
+ log(state.connected ? 'tool' : 'warn', s);
1822
2789
  })) {
1823
2790
  // A verdict on a request this agent is waiting on. Handled HERE, not in drain(): the turn
1824
2791
  // that asked is still running — blocked inside the tool call — and drain is serialised
@@ -1832,11 +2799,34 @@ export async function serve(opts) {
1832
2799
  beat();
1833
2800
  continue;
1834
2801
  }
2802
+ // Paused: nothing is worked. Acknowledged, because its cards went back to the lead and the
2803
+ // lead decides what happens to them; carrying a backlog through a pause would replay it.
2804
+ if (paused) {
2805
+ log('tool', `paused — left for the lead: ${event.line}`);
2806
+ await stream.ack([event.id]);
2807
+ beat();
2808
+ continue;
2809
+ }
2810
+ // Another board's work. Acknowledged so it does not come round again, and never read: no
2811
+ // intake, no Jev call, no turn. One line, so it is visible that scope is doing something
2812
+ // rather than that the agent has gone deaf.
2813
+ if (!onMyBoards(myBoards, event)) {
2814
+ log('tool', `not this agent's board (${event.boardName || event.boardKey}) — ${event.line}`);
2815
+ await stream.ack([event.id]);
2816
+ beat();
2817
+ continue;
2818
+ }
1835
2819
  // A review request for this lead: the model handles it as a turn, but the list can show
1836
2820
  // the row straight away.
1837
2821
  if (event.event === 'review')
1838
2822
  void refreshApprovals();
1839
- if (enqueue(event)) {
2823
+ // A review jumps the queue. It has a deadline and a worker blocked on it; behind a backlog
2824
+ // it waited for every card ahead of it. HAL's request sat behind seven events on a freshly
2825
+ // restarted JARVIS, listed in the pane as "with lead" and never looked at, while the hour
2826
+ // ran down. Front, not interrupt: the turn already running finishes first.
2827
+ if (enqueue(event, event.event === 'review')) {
2828
+ if (event.event === 'review')
2829
+ log('event', `review waiting — taken next: ${event.line}`);
1840
2830
  prepareAhead(event);
1841
2831
  void drain();
1842
2832
  }
@@ -1879,6 +2869,18 @@ async function baseBranch(cwd) {
1879
2869
  c.on('error', () => resolve('main'));
1880
2870
  });
1881
2871
  }
2872
+ /**
2873
+ * What a lead's sweep must achieve, as a `/goal` condition (under 4000 characters). Judged by a
2874
+ * small model from the transcript alone, so the proof has to be IN the transcript: a final
2875
+ * get-portfolio, quoted. Bounded, because a column that cannot be emptied (no free worker, every
2876
+ * card blocked) must end in reasons, not in a loop.
2877
+ */
2878
+ const LEAD_SWEEP_GOAL = 'Every card in your board\'s ready-for-work column (the lane FlareDeck calls Ready For Dev — its ' +
2879
+ 'type is todo) has been dealt with: handed to a worker that reports to you and is free, with ' +
2880
+ 'assign-card, or left there with a one-line reason it must wait (no free worker, blocked, ' +
2881
+ 'waiting on a person). You dispatch; you do not build cards yourself. The goal is met only when ' +
2882
+ 'your most recent turn called get-portfolio and you quoted, from that output, how many cards ' +
2883
+ 'are in the ready column and, for each card still there, its one-line reason. Or stop after 8 turns.';
1882
2884
  function prompt(event, isLead = false) {
1883
2885
  if (event.event === 'sweep') {
1884
2886
  return [
@@ -1900,6 +2902,16 @@ function prompt(event, isLead = false) {
1900
2902
  'assign-card, newest bugs first. One each — an agent already on a card is refused a',
1901
2903
  'second, and it messages you the moment it is free.',
1902
2904
  '',
2905
+ 'Then look at the balance. list_agents shows what each one holds and how many are',
2906
+ 'queued behind it. Where one worker has several cards waiting and another has none,',
2907
+ 'move a card across with assign-card: a card queued behind a busy agent is not being',
2908
+ 'worked, and the idle one could finish it today.',
2909
+ '',
2910
+ 'Each agent is shown with the repository it has checked out. Move a card only to an',
2911
+ 'agent that has the repository the card is for — on a board where everyone works the',
2912
+ 'same one they are interchangeable, and on a board with a ui agent and an api agent',
2913
+ 'they are not.',
2914
+ '',
1903
2915
  ]
1904
2916
  : []),
1905
2917
  'If it returns nothing, say so in one line and stop. That is a perfectly good outcome and',
@@ -1910,11 +2922,13 @@ function prompt(event, isLead = false) {
1910
2922
  '',
1911
2923
  '- A card for one of the agents who report to you: assign it to the right one. That dispatch',
1912
2924
  ' IS the work, and this turn is done.',
1913
- '- A card that is yours and NOT already in progress: move it to the board\'s in-progress lane',
1914
- ' with update-card-status, and stop. Do not read it, do not start the work, do not comment',
1915
- ' on it. Moving it hands it back to you as a card of its own — with a checkout of your own,',
1916
- ' on its own branch — which is the only place the work can actually be done and published',
1917
- ' from. Working it here would edit the shared checkout and could not be pushed anywhere.',
2925
+ '- A card that is yours and NOT already in progress: hand it to a worker if you have one free,',
2926
+ ' with assign-card. You run one turn at a time, and a card takes minutes — for all of them you',
2927
+ ' would be unable to assign anything or answer anyone, which is the one job only you can do.',
2928
+ ' With nobody free, move it to the board\'s in-progress lane with update-card-status and stop:',
2929
+ ' that hands it back to you as a card of its own, with a checkout and a branch, which is the',
2930
+ ' only place work can be done and published from. Either way, stop here — reading it, starting',
2931
+ ' it or commenting on it in this turn all edit the shared checkout, which publishes nowhere.',
1918
2932
  '- A card already in progress: say which one in a line, and stop. The harness picks those up',
1919
2933
  ' by itself, shortly, with a checkout of its own — you do not need to do anything for it.',
1920
2934
  '',