@onepointfour-packs/flaredeck-agent 0.2.36 → 0.2.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/serve.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { EventEmitter } from 'node:events';
1
2
  import { createRequire } from 'node:module';
2
- import { START_SCRIPT as START_SCRIPT_NAME, browserBrief, crossWired, environmentBrief, notStartedBrief, startEnvironment } from './environment.js';
3
+ import { ensureTmuxScrollback } from './tmux.js';
4
+ import { START_SCRIPT as START_SCRIPT_NAME, browserBrief, crossWired, environmentBrief, notStartedBrief, startEnvironment, stopEnvironment, stopLeftoverEnvironments } from './environment.js';
3
5
  import { GIT_TOOL_NAMES, githubTools, publishBranch } from './github.js';
4
6
  import { freshGitHubToken } from './githubauth.js';
5
7
  import { loadBaseBranch, loadMergeConfig, preflight, preflightBrief } from './preflight.js';
@@ -24,6 +26,7 @@ import { managedPython, usingManagedPython } from './engine-antigravity.js';
24
26
  import { mkdirSync } from 'node:fs';
25
27
  import { join } from 'node:path';
26
28
  import { configDir, saveConfig } from './config.js';
29
+ import { boardArg, onMyBoards, outstandingCards } from './boards.js';
27
30
  import { browserTools } from './browser.js';
28
31
  import { watchForDesperation } from './desperation.js';
29
32
  import { note, recallWorktree, rememberWorktree, resumeBrief } from './journal.js';
@@ -173,7 +176,11 @@ export async function serve(opts) {
173
176
  if (!['message', 'commented', 'ask'].includes(event.event))
174
177
  return null;
175
178
  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) ?? [])];
179
+ // Prose a person typed, so there is no field to read and a pattern is the only way. The
180
+ // bound was ten characters before the dash, which silently excluded real boards —
181
+ // DOCUMENTREPOSITOR-0022 is seventeen. Where a TOOL answers, the code is read from the
182
+ // fields it prints instead; see outstandingCards.
183
+ const codes = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,23}-\d{3,}\b/g) ?? [])];
177
184
  for (const code of codes) {
178
185
  // What the harness wrote down when it made the worktree, first: the directory is named from
179
186
  // the card's internal id and the branch from whatever the agent chose, so neither can be
@@ -228,6 +235,12 @@ export async function serve(opts) {
228
235
  if (await releaseSharedPushGuard(c.path).catch(() => false))
229
236
  log('tool', `restored push on ${c.path} (a worktree's push guard had leaked into it)`);
230
237
  }
238
+ // Inside tmux, the history this pane prints is only reachable with tmux's mouse mode on.
239
+ void ensureTmuxScrollback((t) => log('tool', t)).catch(() => { });
240
+ // A restart begins with no environments: the last run's are nobody's now (see the function).
241
+ for (const path of new Set([config.repo, ...inScope.map((c) => c.path)])) {
242
+ void stopLeftoverEnvironments(path, (t) => log('tool', t)).catch(() => { });
243
+ }
231
244
  if (inScope.length > 1) {
232
245
  log('tool', `works in ${inScope.length} repositories: ${inScope.map((c) => c.name).join(', ')}`);
233
246
  }
@@ -332,13 +345,22 @@ export async function serve(opts) {
332
345
  const reader = opts.me ?? opts.deck ?? null;
333
346
  const answeredHere = new Map();
334
347
  let refreshing = null;
348
+ /** This agent's own id, and (on a lead) the agents that report to it — whose requests it sees. */
349
+ let selfId = '';
350
+ let teamIds = new Set();
335
351
  const refreshApprovals = () => {
336
352
  if (!reader)
337
353
  return Promise.resolve();
338
354
  if (refreshing)
339
355
  return refreshing;
340
356
  refreshing = reader.pendingApprovals().then((all) => {
341
- const mine = all.filter((a) => a.tool_name === SCRIPT_REQUEST);
357
+ // Its own requests, and on a lead its team's. Every pending request in the workspace used
358
+ // to be listed on every pane, so CASE showed HAL's request as if it were CASE's business.
359
+ // Until this agent knows who it is (the team read failed), nothing is hidden.
360
+ const ours = (a) => !selfId
361
+ || a.sender_key === selfId
362
+ || (isLead && (a.review?.by === selfId || (!!a.sender_key && teamIds.has(a.sender_key))));
363
+ const mine = all.filter((a) => a.tool_name === SCRIPT_REQUEST && ours(a));
342
364
  state.approvals = mine.map((a) => {
343
365
  const lines = (a.input_preview || '').split('\n');
344
366
  const preview = lines.find((l) => l.startsWith('-- RAN') || /would_touch/.test(l))?.replace(/^-- /, '').replace(/^harness preview.*?:\s*/, '');
@@ -453,29 +475,58 @@ export async function serve(opts) {
453
475
  return null;
454
476
  return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind);
455
477
  },
456
- unflag: async () => {
457
- const cur = onCard;
458
- if (!cur)
459
- return;
478
+ unflag: async (cur) => {
460
479
  parked.delete(cur.card);
461
480
  await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'clear-needs-human', {
462
481
  card_id: cur.code || cur.card,
463
482
  ...(cur.board ? { board_id: cur.board } : {}),
464
483
  }).catch((e) => log('warn', `could not clear needs-human: ${e instanceof Error ? e.message : String(e)}`));
465
484
  },
466
- }, () => leadId, onAsk)
485
+ }, () => leadId, onAsk, (card, text) => resumeParked(card, text))
467
486
  : undefined;
468
487
  // Answering from this keyboard: the person's own login decides, through the same endpoint the
469
488
  // phone uses, so the agent waiting on it sees the verdict as any other.
470
- const decide = opts.me
489
+ /**
490
+ * The person answering from this pane. Signed in once per machine, from the pane when nobody
491
+ * has: press a/d and it asks. Never the agent's own login — an approval is a person's decision,
492
+ * and the agent has a shell on this box and its own credentials in it.
493
+ */
494
+ let me = opts.me;
495
+ state.person = !!me;
496
+ const startSignIn = () => {
497
+ if (signingIn) {
498
+ log('event', 'still waiting for the sign-in — paste the address your browser ended on');
499
+ return;
500
+ }
501
+ if (!opts.signInMe) {
502
+ log('warn', 'nobody is signed in here to answer with — run: flaredeck-agent login --me');
503
+ return;
504
+ }
505
+ signingIn = true;
506
+ 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.');
507
+ opts.signInMe((t) => { if (t.trim())
508
+ log('event', t); }, signInReader)
509
+ .then((client) => {
510
+ me = client ?? undefined;
511
+ state.person = !!me;
512
+ log(me ? 'event' : 'warn', me ? 'signed in — a approves, d denies, from this pane' : 'signed in, but that login has no workspace here');
513
+ })
514
+ .catch((e) => log('warn', `sign-in did not finish: ${e instanceof Error ? e.message : String(e)}`))
515
+ .finally(() => { signingIn = false; });
516
+ };
517
+ const decide = opts.signInMe || opts.me
471
518
  ? (requestId, verdict) => {
519
+ if (!me) {
520
+ startSignIn();
521
+ return;
522
+ }
472
523
  const row = (state.approvals ?? []).find((a) => a.requestId === requestId);
473
524
  if (!row || row.stage !== 'ready')
474
525
  return;
475
526
  answeredHere.set(requestId, verdict);
476
527
  row.stage = 'answered';
477
528
  row.verdict = verdict;
478
- void opts.me.decide(row.commentId, verdict)
529
+ void me.decide(row.commentId, verdict)
479
530
  .then(() => log('you', `${verdict === 'allow' ? 'approved' : 'denied'} ${row.name} (${row.agent}, ${row.card}) from here`))
480
531
  .catch((e) => { answeredHere.delete(requestId); log('warn', `could not record that: ${e instanceof Error ? e.message : String(e)}`); void refreshApprovals(); });
481
532
  }
@@ -559,6 +610,8 @@ export async function serve(opts) {
559
610
  if (leadId)
560
611
  log('tool', `your lead is ${leadName} — ${leadName} verifies your work before it goes anywhere`);
561
612
  isLead = team.you.role === 'lead';
613
+ selfId = team.you.agentId;
614
+ teamIds = new Set(team.agents.filter((a) => a.reportsTo === team.you.agentId).map((a) => a.agentId));
562
615
  if (team.you.role === 'lead') {
563
616
  teamSet = {
564
617
  // After a review the list changes — an endorsed request is now answerable, from THIS
@@ -583,11 +636,34 @@ export async function serve(opts) {
583
636
  * Polled rather than pushed, because nothing streams another agent's status — and slowly,
584
637
  * since agents check in every five minutes, so a faster poll would show the same row again.
585
638
  */
639
+ /**
640
+ * An idle worker is the lead's problem, and the lead already knows within a minute.
641
+ *
642
+ * It polls its team every sixty seconds to draw the pane, so "CASE idle 4m ago" is sitting on
643
+ * its screen — and it did nothing with it. Dispatch waited for the hourly sweep, or for the
644
+ * worker to say it was free, which for a while it never did. Eleven cards waited in Ready For
645
+ * Dev while two agents sat idle and the lead reported "waiting for work".
646
+ *
647
+ * The poll is already happening, so noticing costs nothing. Rate-limited because an idle
648
+ * worker with genuinely nothing to give it stays idle, and a lead asked about it every minute
649
+ * learns to say "nothing" without looking.
650
+ */
651
+ const DISPATCH_AGAIN_MS = 10 * 60 * 1000;
652
+ let lastDispatch = 0;
653
+ const workersAreIdle = (team) => myTeam(team).mine.filter((a) => !a.card && a.state !== 'stopped' && !a.stale).map((a) => a.name);
586
654
  if (leadOfTeam) {
587
655
  const teamTimer = setInterval(() => {
588
656
  void (async () => {
589
657
  try {
590
- showTeam(await fetchTeam(opts.baseUrl, await opts.auth()));
658
+ const team = await fetchTeam(opts.baseUrl, await opts.auth());
659
+ showTeam(team);
660
+ const free = workersAreIdle(team);
661
+ if (free.length && !working && !queue.length && Date.now() - lastDispatch > DISPATCH_AGAIN_MS) {
662
+ lastDispatch = Date.now();
663
+ log('event', `${free.join(', ')} ${free.length === 1 ? 'is' : 'are'} idle — looking for work to hand over`);
664
+ if (enqueue({ id: `dispatch-${Date.now()}`, event: 'sweep', line: `${free.join(', ')} have nothing to do` }))
665
+ void drain();
666
+ }
591
667
  }
592
668
  catch {
593
669
  // A team the lead briefly cannot see is not worth a line in the log every minute; the
@@ -619,12 +695,40 @@ export async function serve(opts) {
619
695
  if (engine.name !== 'claude')
620
696
  log('event', `${engine.name} is answering on this agent`);
621
697
  let liveRun = null;
698
+ /**
699
+ * This turn's browser, so it can be closed when the turn ends.
700
+ *
701
+ * A fresh tool set is built per turn, so without this the previous turn's Chromium was both
702
+ * unreachable and still running — a leaked browser tree for every turn that looked at
703
+ * anything, on a process that stays up for days.
704
+ */
705
+ let turnEyes = null;
622
706
  /**
623
707
  * Typed with nothing running: a question for the agent, answered now. It goes through the same
624
708
  * queue as a card event so it is serialised with real work, but it is local — never acknowledged
625
709
  * to the server, never a card — and it opens one long-lived session of its own so a follow-up
626
710
  * question remembers the last one.
627
711
  */
712
+ /**
713
+ * A parked card's request was answered: put the card back at the FRONT of the queue, resuming
714
+ * its own session in its own worktree, with what happened. The turn on whatever card is running
715
+ * now finishes first.
716
+ */
717
+ const resumeParked = (card, text) => {
718
+ const event = {
719
+ id: `run-${card.card}-${Date.now()}`,
720
+ event: 'run-answered',
721
+ line: text,
722
+ message: text,
723
+ cardKey: card.card,
724
+ ...(card.board ? { boardKey: card.board } : {}),
725
+ ...(card.code ? { cardCode: card.code } : {}),
726
+ };
727
+ log('event', `${card.code || card.card}: request answered — back at the front of the queue`);
728
+ if (enqueue(event, true)) {
729
+ void drain();
730
+ }
731
+ };
628
732
  const ask = (text) => {
629
733
  const event = { id: `ask-${Date.now()}`, event: 'ask', line: text, message: text };
630
734
  if (enqueue(event)) {
@@ -632,7 +736,74 @@ export async function serve(opts) {
632
736
  void drain();
633
737
  }
634
738
  };
739
+ /**
740
+ * `!command` runs in a shell, the way Claude Code's prompt does — for the person at this
741
+ * terminal, not the agent. It runs where the agent is working (the card's worktree, else the
742
+ * repo), so `!npm install` fixes the checkout the agent is stuck in. Async on purpose: a
743
+ * blocking call here would freeze the pane and the agent's own event stream for as long as the
744
+ * command took. The agent never sees it; say on the card or in a steer if it should know.
745
+ */
746
+ /** A person signing in from the pane: lines typed meanwhile go to the sign-in, not the agent. */
747
+ let signingIn = false;
748
+ const signInReader = Object.assign(new EventEmitter(), { prompt() { }, setPrompt() { }, close() { } });
749
+ /** The checkout the current turn is working in ('' for one that has none). */
750
+ let turnCwd = '';
751
+ /** Worktrees whose environment comes down unless a turn returns to them first. */
752
+ const idleStops = new Map();
753
+ const ENV_IDLE_MS = 20 * 60_000;
754
+ const scheduleEnvironmentStop = (cwd) => {
755
+ const before = idleStops.get(cwd);
756
+ if (before)
757
+ clearTimeout(before);
758
+ idleStops.set(cwd, setTimeout(() => {
759
+ idleStops.delete(cwd);
760
+ // Never the checkout a turn is in right now, even if the timer and the turn cross.
761
+ if (cwd === turnCwd && state.working)
762
+ return;
763
+ void stopEnvironment(cwd).then((ok) => { if (ok)
764
+ log('tool', `environment stopped in ${cwd.split('/').pop()} (idle 20m)`); }).catch(() => { });
765
+ }, ENV_IDLE_MS));
766
+ idleStops.get(cwd).unref?.();
767
+ };
768
+ const shell = (command) => {
769
+ const cwd = job?.cwd ?? config.repo;
770
+ log('you', `! ${command}`);
771
+ log('event', `running in ${cwd}`);
772
+ void import('node:child_process').then(({ spawn }) => {
773
+ const child = spawn('bash', ['-lc', command], { cwd, env: process.env });
774
+ const emit = (chunk, partial) => {
775
+ partial.s += chunk.toString('utf8');
776
+ const parts = partial.s.split(/\r?\n/);
777
+ partial.s = parts.pop() ?? '';
778
+ for (const ln of parts)
779
+ log('shell', ln);
780
+ };
781
+ const out = { s: '' };
782
+ const err = { s: '' };
783
+ child.stdout.on('data', (c) => emit(c, out));
784
+ child.stderr.on('data', (c) => emit(c, err));
785
+ child.on('error', (e) => log('warn', `could not run it: ${e.message}`));
786
+ child.on('close', (code) => {
787
+ if (out.s)
788
+ log('shell', out.s);
789
+ if (err.s)
790
+ log('shell', err.s);
791
+ log(code === 0 ? 'event' : 'warn', `exit ${code ?? '?'}`);
792
+ });
793
+ });
794
+ };
635
795
  const steer = (text) => {
796
+ // Mid sign-in, the next line is the redirect address — for the sign-in, never the agent.
797
+ if (signingIn) {
798
+ signInReader.emit('line', text);
799
+ return;
800
+ }
801
+ if (text.startsWith('!')) {
802
+ const command = text.slice(1).trim();
803
+ if (command)
804
+ shell(command);
805
+ return;
806
+ }
636
807
  log('you', text);
637
808
  if (liveRun) {
638
809
  // Stamped human: the SDK treats an unattributed message as untrusted, and rightly — a line
@@ -697,6 +868,11 @@ export async function serve(opts) {
697
868
  const pending = new Set();
698
869
  /** Keep the pushed status in step with the queue — it is what a lead's pane reads. */
699
870
  const syncQueue = () => status.setQueue(queue.length, queue[0]?.cardCode || queue[0]?.cardKey || '');
871
+ /**
872
+ * The boards this agent works, by key. Empty means all of them, which is what every install
873
+ * did before this existed.
874
+ */
875
+ const myBoards = new Set((config.boards ?? []).map((b) => b.key));
700
876
  const enqueue = (event, front = false) => {
701
877
  if (pending.has(event.id))
702
878
  return false;
@@ -722,6 +898,8 @@ export async function serve(opts) {
722
898
  const wantsIntake = (event) => !!event.cardKey
723
899
  && event.event !== 'review'
724
900
  && event.event !== 'ask'
901
+ // A parked card coming back is the same work resuming, not a new card to brief.
902
+ && event.event !== 'run-answered'
725
903
  && !(event.event === 'commented' && event.cardDone && !event.mentioned)
726
904
  // Someone else's card is not worth reading ahead. `drain` already declines to WORK these —
727
905
  // a card this agent once touched keeps sending it every move and comment made by whoever
@@ -846,10 +1024,19 @@ export async function serve(opts) {
846
1024
  return;
847
1025
  let mine = [];
848
1026
  try {
849
- const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-outstanding', {});
1027
+ // Scoped, like every other account-wide question. This one is asked by the HARNESS rather
1028
+ // than by the model, so the proxy that narrows the model's calls never saw it — and a
1029
+ // board-scoped agent went straight back to picking up another team's cards: KD-1361, then
1030
+ // KD-1362, from a board nobody had ticked. The card then 404s, because it is not on a
1031
+ // board this agent works.
1032
+ const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-outstanding', { ...(boardArg([...myBoards]) !== undefined ? { board: boardArg([...myBoards]) } : {}) });
850
1033
  const text = typeof out === 'string' ? out : JSON.stringify(out);
851
- // The codes it names, in the order it names them: priority order, which is the board's.
852
- mine = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,9}-\d{3,}\b/g) ?? [])].map((code) => ({ code }));
1034
+ // Read from the fields the answer prints, and filtered here as well as asked for — the
1035
+ // server is deployed separately from this, so the scope cannot depend on its version.
1036
+ // In the order it names them: priority order, which is the board's.
1037
+ mine = outstandingCards(text)
1038
+ .filter((c) => onMyBoards(myBoards, c))
1039
+ .map((c) => ({ code: c.code }));
853
1040
  }
854
1041
  catch {
855
1042
  return; // unreachable board is the stream's problem to report, not this timer's
@@ -951,6 +1138,7 @@ export async function serve(opts) {
951
1138
  status.startCard(current, !!event.cardKey);
952
1139
  void report();
953
1140
  let didWork = true;
1141
+ turnCwd = '';
954
1142
  try {
955
1143
  await handle(event);
956
1144
  }
@@ -964,7 +1152,7 @@ export async function serve(opts) {
964
1152
  status.finishCard(didWork);
965
1153
  void report();
966
1154
  pending.delete(event.id);
967
- if (event.event === 'ask' || event.event === 'sweep') { /* local: nothing to acknowledge, nothing to retry */ }
1155
+ if (event.event === 'ask' || event.event === 'sweep' || event.event === 'run-answered') { /* local: nothing to acknowledge, nothing to retry */ }
968
1156
  else if (didWork)
969
1157
  await stream.ack([event.id]);
970
1158
  else {
@@ -981,6 +1169,17 @@ export async function serve(opts) {
981
1169
  // model: uncommitted changes, or commits that never reached the remote.
982
1170
  if (tag)
983
1171
  await reportUnfinished(tag);
1172
+ // A card's environment is kept warm for a while — the next comment on it usually comes
1173
+ // soon — and then taken down, so a day of cards does not leave a day of servers running.
1174
+ if (turnCwd && turnCwd !== config.repo)
1175
+ scheduleEnvironmentStop(turnCwd);
1176
+ // The environment is kept warm; the browser is not. It holds a Chromium tree, the next
1177
+ // turn builds its own, and a page left open is not something a later turn can reach.
1178
+ if (turnEyes) {
1179
+ const e = turnEyes;
1180
+ turnEyes = null;
1181
+ await e.close().catch(() => { });
1182
+ }
984
1183
  current = '';
985
1184
  delete state.plan;
986
1185
  state.working = '';
@@ -994,9 +1193,15 @@ export async function serve(opts) {
994
1193
  finally {
995
1194
  working = false;
996
1195
  }
997
- // The queue is empty and nothing is running: this agent has nothing to do. Tell its lead,
998
- // once, rather than leaving them to notice.
999
- if (!queue.length && !live.size)
1196
+ // An empty queue at the end of a drain means nothing is running and nothing is waiting: this
1197
+ // agent has nothing to do. Tell its lead, once, rather than leaving them to notice.
1198
+ //
1199
+ // This used to require `!live.size` as well, which reads like "nothing running" and is not.
1200
+ // `live` holds sessions kept WARM for fifteen minutes so a follow-up on the same card costs
1201
+ // a resume instead of a re-orientation — a cache, not work. So every agent went quiet for
1202
+ // fifteen minutes after finishing anything, which is exactly the window in which it most
1203
+ // needs a new card. Two sat idle and silent while eleven cards waited.
1204
+ if (!queue.length)
1000
1205
  void offerToLead();
1001
1206
  }
1002
1207
  /**
@@ -1166,7 +1371,7 @@ export async function serve(opts) {
1166
1371
  // it may work in — one repo, or every repo in that folder — and the card says which of them
1167
1372
  // this work belongs to. See workspace.ts: the folder is the only declaration, so there is no
1168
1373
  // config list to go stale.
1169
- const where = await resolveCheckout(config.repo, event, config.repos);
1374
+ const where = await resolveCheckout(config.repo, event, config.repos, opts.agent ?? '');
1170
1375
  let chosen = where.kind === 'repo' ? where.checkout : null;
1171
1376
  let fromCard = where.kind === 'repo' && where.branch ? { branch: where.branch, role: where.role } : null;
1172
1377
  if (where.kind === 'ambiguous') {
@@ -1243,6 +1448,13 @@ export async function serve(opts) {
1243
1448
  ? await ensureWorktree(repo, key, (t) => log('tool', t), startFrom, config.worktreeLocalFiles)
1244
1449
  : await worktreeTheMessageIsAbout(event, repo, (t) => log('tool', t));
1245
1450
  const cwd = tree?.path ?? repo;
1451
+ // Back on this checkout: whatever was waiting to take its environment down can stand down.
1452
+ turnCwd = cwd;
1453
+ const pendingStop = idleStops.get(cwd);
1454
+ if (pendingStop) {
1455
+ clearTimeout(pendingStop);
1456
+ idleStops.delete(cwd);
1457
+ }
1246
1458
  // So a later message naming this card by its CODE can find the work. See rememberWorktree.
1247
1459
  if (tree && (event.cardCode || event.cardKey)) {
1248
1460
  rememberWorktree(event.cardCode || event.cardKey, tree.path, tree.branch);
@@ -1341,7 +1553,7 @@ export async function serve(opts) {
1341
1553
  tag = code;
1342
1554
  state.working = code;
1343
1555
  };
1344
- const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), finishGate, noteCard, keepTool, savedSoFar);
1556
+ const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), finishGate, noteCard, keepTool, savedSoFar, [...myBoards]);
1345
1557
  const held = live.get(key);
1346
1558
  if (held?.timer)
1347
1559
  clearTimeout(held.timer);
@@ -1425,6 +1637,20 @@ export async function serve(opts) {
1425
1637
  // The guard is built once at startup; what this agent can reach changes per card. Handing it
1426
1638
  // the live picture is the difference between judging "$DATABASE_URL" as a scratch database
1427
1639
  // and judging it as a live one.
1640
+ // Deployed addresses the board declared for this card's repository — the dev or UAT site. A
1641
+ // card is usually about code that is already live somewhere, and an agent that can only see
1642
+ // what it started locally cannot tell its own change from how the thing already behaved.
1643
+ const declaredSites = where.kind === 'repo' ? where.sites ?? {} : {};
1644
+ const eyes = forACard
1645
+ ? turnEyes = browserTools({
1646
+ repo: config.repo,
1647
+ endpoints: () => env.endpoints,
1648
+ sites: () => declaredSites,
1649
+ contract: () => env.contract,
1650
+ cwd,
1651
+ onNote: (t) => log('tool', t),
1652
+ })
1653
+ : null;
1428
1654
  reachable = { cwd, endpoints: env.endpoints };
1429
1655
  if (forACard && !needsRunning)
1430
1656
  log('tool', 'environment not started — this card does not look like it needs one');
@@ -1460,19 +1686,12 @@ export async function serve(opts) {
1460
1686
  ...(prepared?.size ? { size: prepared.size } : {}),
1461
1687
  tools: {
1462
1688
  ...(job ? { git: { server: gitServer, toolNames: GIT_TOOL_NAMES } } : {}),
1463
- // Eyes, when there is something running to look at. A card with no environment gets no
1464
- // browser tools and is told nothing about them.
1465
- ...(env.ran && Object.keys(env.endpoints).length
1466
- ? {
1467
- browser: browserTools({
1468
- repo: config.repo,
1469
- endpoints: () => env.endpoints,
1470
- contract: () => env.contract,
1471
- cwd,
1472
- onNote: (t) => log('tool', t),
1473
- }),
1474
- }
1475
- : {}),
1689
+ // Eyes, for any card. They used to be mounted only when the harness had itself started
1690
+ // something, which left an agent working alone unable to get them: no start script meant
1691
+ // no browser AND no mention of one, so it could not know that writing the script it is
1692
+ // perfectly able to write was the thing standing between it and seeing its own change.
1693
+ // The endpoints are read through a getter, so something the agent starts mid-turn counts.
1694
+ ...(eyes ? { browser: eyes } : {}),
1476
1695
  team: teamSet,
1477
1696
  flaredeck,
1478
1697
  runs: runsSet,
@@ -1493,7 +1712,7 @@ export async function serve(opts) {
1493
1712
  freeWorkerBrief(event) +
1494
1713
  // Only tell an agent about the environment when the card actually needs one running.
1495
1714
  environmentBrief(env, cwd, needsRunning) + notStartedBrief(forACard && !needsRunning, cwd) +
1496
- browserBrief(env.ran && Object.keys(env.endpoints).length > 0, env.endpoints) +
1715
+ browserBrief(env.endpoints, declaredSites, forACard) +
1497
1716
  (wrongEnv.length
1498
1717
  ? `\n\nSTOP — the environment that started is not yours:\n${wrongEnv.map((w) => ` · ${w}`).join('\n')}\n\n` +
1499
1718
  `Anything you test against it tells you about another card's code, and it will answer ` +
@@ -1757,11 +1976,26 @@ export async function serve(opts) {
1757
1976
  beat();
1758
1977
  continue;
1759
1978
  }
1979
+ // Another board's work. Acknowledged so it does not come round again, and never read: no
1980
+ // intake, no Jev call, no turn. One line, so it is visible that scope is doing something
1981
+ // rather than that the agent has gone deaf.
1982
+ if (!onMyBoards(myBoards, event)) {
1983
+ log('tool', `not this agent's board (${event.boardName || event.boardKey}) — ${event.line}`);
1984
+ await stream.ack([event.id]);
1985
+ beat();
1986
+ continue;
1987
+ }
1760
1988
  // A review request for this lead: the model handles it as a turn, but the list can show
1761
1989
  // the row straight away.
1762
1990
  if (event.event === 'review')
1763
1991
  void refreshApprovals();
1764
- if (enqueue(event)) {
1992
+ // A review jumps the queue. It has a deadline and a worker blocked on it; behind a backlog
1993
+ // it waited for every card ahead of it. HAL's request sat behind seven events on a freshly
1994
+ // restarted JARVIS, listed in the pane as "with lead" and never looked at, while the hour
1995
+ // ran down. Front, not interrupt: the turn already running finishes first.
1996
+ if (enqueue(event, event.event === 'review')) {
1997
+ if (event.event === 'review')
1998
+ log('event', `review waiting — taken next: ${event.line}`);
1765
1999
  prepareAhead(event);
1766
2000
  void drain();
1767
2001
  }
@@ -1825,6 +2059,16 @@ function prompt(event, isLead = false) {
1825
2059
  'assign-card, newest bugs first. One each — an agent already on a card is refused a',
1826
2060
  'second, and it messages you the moment it is free.',
1827
2061
  '',
2062
+ 'Then look at the balance. list_agents shows what each one holds and how many are',
2063
+ 'queued behind it. Where one worker has several cards waiting and another has none,',
2064
+ 'move a card across with assign-card: a card queued behind a busy agent is not being',
2065
+ 'worked, and the idle one could finish it today.',
2066
+ '',
2067
+ 'Each agent is shown with the repository it has checked out. Move a card only to an',
2068
+ 'agent that has the repository the card is for — on a board where everyone works the',
2069
+ 'same one they are interchangeable, and on a board with a ui agent and an api agent',
2070
+ 'they are not.',
2071
+ '',
1828
2072
  ]
1829
2073
  : []),
1830
2074
  'If it returns nothing, say so in one line and stop. That is a perfectly good outcome and',
@@ -1835,11 +2079,13 @@ function prompt(event, isLead = false) {
1835
2079
  '',
1836
2080
  '- A card for one of the agents who report to you: assign it to the right one. That dispatch',
1837
2081
  ' IS the work, and this turn is done.',
1838
- '- A card that is yours and NOT already in progress: move it to the board\'s in-progress lane',
1839
- ' with update-card-status, and stop. Do not read it, do not start the work, do not comment',
1840
- ' on it. Moving it hands it back to you as a card of its own — with a checkout of your own,',
1841
- ' on its own branch — which is the only place the work can actually be done and published',
1842
- ' from. Working it here would edit the shared checkout and could not be pushed anywhere.',
2082
+ '- A card that is yours and NOT already in progress: hand it to a worker if you have one free,',
2083
+ ' with assign-card. You run one turn at a time, and a card takes minutes — for all of them you',
2084
+ ' would be unable to assign anything or answer anyone, which is the one job only you can do.',
2085
+ ' With nobody free, move it to the board\'s in-progress lane with update-card-status and stop:',
2086
+ ' that hands it back to you as a card of its own, with a checkout and a branch, which is the',
2087
+ ' only place work can be done and published from. Either way, stop here — reading it, starting',
2088
+ ' it or commenting on it in this turn all edit the shared checkout, which publishes nowhere.',
1843
2089
  '- A card already in progress: say which one in a line, and stop. The harness picks those up',
1844
2090
  ' by itself, shortly, with a checkout of its own — you do not need to do anything for it.',
1845
2091
  '',