@onepointfour-packs/flaredeck-agent 0.1.0 → 0.2.1

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 (76) hide show
  1. package/dist/agent.d.ts +16 -0
  2. package/dist/agent.js +38 -1
  3. package/dist/agent.js.map +1 -1
  4. package/dist/approval.js +39 -1
  5. package/dist/approval.js.map +1 -1
  6. package/dist/browser.d.ts +19 -0
  7. package/dist/browser.js +261 -0
  8. package/dist/browser.js.map +1 -0
  9. package/dist/config.d.ts +18 -0
  10. package/dist/config.js +2 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/desperation.d.ts +24 -0
  13. package/dist/desperation.js +64 -0
  14. package/dist/desperation.js.map +1 -0
  15. package/dist/engine-antigravity.d.ts +7 -0
  16. package/dist/engine-antigravity.js +280 -32
  17. package/dist/engine-antigravity.js.map +1 -1
  18. package/dist/engine.d.ts +28 -0
  19. package/dist/engine.js.map +1 -1
  20. package/dist/env.d.ts +5 -1
  21. package/dist/env.js +19 -1
  22. package/dist/env.js.map +1 -1
  23. package/dist/environment.d.ts +87 -2
  24. package/dist/environment.js +213 -11
  25. package/dist/environment.js.map +1 -1
  26. package/dist/flaredeck-proxy.d.ts +47 -1
  27. package/dist/flaredeck-proxy.js +120 -3
  28. package/dist/flaredeck-proxy.js.map +1 -1
  29. package/dist/gitguard.d.ts +6 -0
  30. package/dist/gitguard.js +30 -1
  31. package/dist/gitguard.js.map +1 -1
  32. package/dist/github.d.ts +23 -1
  33. package/dist/github.js +105 -28
  34. package/dist/github.js.map +1 -1
  35. package/dist/journal.d.ts +23 -0
  36. package/dist/journal.js +142 -0
  37. package/dist/journal.js.map +1 -0
  38. package/dist/localmcp.d.ts +50 -0
  39. package/dist/localmcp.js +229 -0
  40. package/dist/localmcp.js.map +1 -0
  41. package/dist/main.js +13 -1
  42. package/dist/main.js.map +1 -1
  43. package/dist/permissions.d.ts +17 -0
  44. package/dist/permissions.js +38 -1
  45. package/dist/permissions.js.map +1 -1
  46. package/dist/runs.js +3 -2
  47. package/dist/runs.js.map +1 -1
  48. package/dist/sandbox.d.ts +34 -0
  49. package/dist/sandbox.js +122 -2
  50. package/dist/sandbox.js.map +1 -1
  51. package/dist/schedules.d.ts +1 -1
  52. package/dist/schedules.js +3 -2
  53. package/dist/schedules.js.map +1 -1
  54. package/dist/serve.js +461 -39
  55. package/dist/serve.js.map +1 -1
  56. package/dist/setup.d.ts +1 -0
  57. package/dist/setup.js +99 -0
  58. package/dist/setup.js.map +1 -0
  59. package/dist/stream.d.ts +1 -0
  60. package/dist/team.d.ts +1 -1
  61. package/dist/team.js +3 -2
  62. package/dist/team.js.map +1 -1
  63. package/dist/ui.d.ts +2 -0
  64. package/dist/ui.js +34 -39
  65. package/dist/ui.js.map +1 -1
  66. package/dist/updater-python.d.ts +19 -0
  67. package/dist/updater-python.js +149 -0
  68. package/dist/updater-python.js.map +1 -0
  69. package/dist/workspace.d.ts +2 -0
  70. package/dist/workspace.js +1 -1
  71. package/dist/workspace.js.map +1 -1
  72. package/dist/worktree.d.ts +39 -1
  73. package/dist/worktree.js +147 -4
  74. package/dist/worktree.js.map +1 -1
  75. package/package.json +3 -2
  76. package/python/sidecar.py +209 -7
package/dist/serve.js CHANGED
@@ -1,24 +1,32 @@
1
1
  import { createRequire } from 'node:module';
2
- import { environmentBrief, startEnvironment } from './environment.js';
3
- import { GIT_TOOL_NAMES, githubTools } from './github.js';
2
+ import { START_SCRIPT as START_SCRIPT_NAME, browserBrief, crossWired, environmentBrief, notStartedBrief, startEnvironment } from './environment.js';
3
+ import { GIT_TOOL_NAMES, githubTools, publishBranch } from './github.js';
4
4
  import { freshGitHubToken } from './githubauth.js';
5
5
  import { loadBaseBranch, loadMergeConfig, preflight, preflightBrief } from './preflight.js';
6
6
  import { pushStatus, StatusTracker } from './status.js';
7
7
  import { fetchTeam, myTeam, teamTools, TEAM_TOOL_NAMES } from './team.js';
8
8
  import { scheduleTools, SCHEDULE_TOOL_NAMES } from './schedules.js';
9
9
  import { preflightFlaredeck } from './flaredeck-check.js';
10
- import { callFlaredeckTool, flaredeckProxy } from './flaredeck-proxy.js';
10
+ import { NOT_FOR_WORKERS, callFlaredeckTool, flaredeckProxy } from './flaredeck-proxy.js';
11
11
  import { resumeApprovedRuns, runTools } from './runs.js';
12
12
  import { deliverDecision, flaredeckDecider } from './approval.js';
13
13
  import { CouldNotWork, engineFor } from './engine.js';
14
14
  import { jevGuard } from './jevguard.js';
15
15
  import { loadProviders } from './providers.js';
16
- import { ensureWorktree, releaseWorktree, worktreeBrief } from './worktree.js';
16
+ import { currentBranch, ensureWorktree, existingWorktree, produced, releaseWorktree, tooEarlyToFinish, uncommittedCount, worktreeBrief } from './worktree.js';
17
17
  import { askingForComponent, checkouts, resolveCheckout, voteByCode, workspaceBrief } from './workspace.js';
18
18
  import { intake, intakeText } from './intake.js';
19
+ import { standingBrief } from './agent.js';
19
20
  import { startUi } from './ui.js';
20
21
  import { checkAndStage, installedSdkVersion, pendingUpdate, updatesEnabled } from './updater.js';
21
- import { saveConfig } from './config.js';
22
+ import { updateSidecarSdk } from './updater-python.js';
23
+ import { managedPython, usingManagedPython } from './engine-antigravity.js';
24
+ import { mkdirSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { configDir, saveConfig } from './config.js';
27
+ import { browserTools } from './browser.js';
28
+ import { watchForDesperation } from './desperation.js';
29
+ import { note, recallWorktree, rememberWorktree, resumeBrief } from './journal.js';
22
30
  import { EventStream } from './stream.js';
23
31
  /**
24
32
  * The always-on agent.
@@ -48,12 +56,21 @@ export async function serve(opts) {
48
56
  lines: [],
49
57
  };
50
58
  let ui = null;
59
+ // Which card the lines being logged are about. The live line drops it the moment a turn ends,
60
+ // so without this a finished card leaves a page of work in the scrollback with nothing saying
61
+ // what it was for — the card number survived only in the branch name.
62
+ let tag = '';
51
63
  const log = (kind, text) => {
64
+ const line = tag ? { kind, text, card: tag } : { kind, text };
65
+ // Kept for the next session on this card. Tools and its own words say what it was doing;
66
+ // events are harness bookkeeping and would crowd out the work.
67
+ if (tag && (kind === 'tool' || kind === 'say' || kind === 'todo'))
68
+ note(tag, text);
52
69
  // Keep a bounded tail: a daemon runs for days and nobody scrolls back that far.
53
- state.lines.push({ kind, text });
70
+ state.lines.push(line);
54
71
  if (state.lines.length > 400)
55
72
  state.lines.splice(0, state.lines.length - 400);
56
- ui?.log({ kind, text });
73
+ ui?.log(line);
57
74
  };
58
75
  const updated = process.env['FLAREDECK_AGENT_UPDATED'];
59
76
  if (updated)
@@ -69,7 +86,124 @@ export async function serve(opts) {
69
86
  const looksForUpdates = () => void checkAndStage().then(waiting);
70
87
  setTimeout(looksForUpdates, 30_000).unref();
71
88
  setInterval(looksForUpdates, 6 * 60 * 60 * 1000).unref();
89
+ // The sidecar's Python SDK on the same schedule. It used to be pip-installed once, at first
90
+ // use, and never looked at again — one engine tracked upstream while the other aged. Only the
91
+ // venv the harness made for itself, and only once the engine in use is actually that one.
92
+ if (config.engine === 'antigravity') {
93
+ const looksForSidecarUpdates = () => void updateSidecarSdk(managedPython(), usingManagedPython(), (t) => log('tool', t));
94
+ setTimeout(looksForSidecarUpdates, 45_000).unref();
95
+ setInterval(looksForSidecarUpdates, 6 * 60 * 60 * 1000).unref();
96
+ }
72
97
  }
98
+ /**
99
+ * The scratch sandbox, which must EXIST before it is handed to anything. An engine that runs
100
+ * out of process starts its child there, and a child spawned with a cwd that is not there dies
101
+ * with ENOENT naming the interpreter — which reads as a broken Python install and is not one.
102
+ */
103
+ const scratchDir = () => {
104
+ const dir = join(configDir(), 'scratch');
105
+ try {
106
+ mkdirSync(dir, { recursive: true });
107
+ }
108
+ catch { /* reported by whatever uses it */ }
109
+ return dir;
110
+ };
111
+ /** Cards already reported as left unfinished, so a quiet agent is not reported every turn. */
112
+ const toldAbout = new Set();
113
+ /**
114
+ * Work left behind when a session ended. Told to the lead, because the agent that stopped is
115
+ * by definition not going to say anything, and told once per card per state so a lead is not
116
+ * pestered about the same half-finished branch.
117
+ */
118
+ const reportUnfinished = async (card) => {
119
+ const tree = await existingWorktree(config.repo, card).catch(() => null);
120
+ if (!tree)
121
+ return;
122
+ let left;
123
+ try {
124
+ left = await produced(tree);
125
+ }
126
+ catch {
127
+ return;
128
+ }
129
+ const dirty = await uncommittedCount(tree.path);
130
+ if (!dirty && (left.commits === 0 || left.pushed))
131
+ return; // nothing hanging
132
+ const what = [
133
+ dirty ? `${dirty} file(s) changed and not committed` : '',
134
+ left.commits > 0 && !left.pushed ? `${left.commits} commit(s) not published` : '',
135
+ ].filter(Boolean).join(', ');
136
+ const stamp = `${card}:${what}`;
137
+ if (toldAbout.has(stamp))
138
+ return;
139
+ toldAbout.add(stamp);
140
+ log('warn', `${card} left unfinished — ${what}`);
141
+ if (!leadId)
142
+ return;
143
+ try {
144
+ await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'send-message', {
145
+ to: leadId,
146
+ message: `**${card}: my session ended with work unfinished.**\n\n` +
147
+ `Branch \`${left.branch}\` — ${what}. The checkout is still there and nothing is lost; ` +
148
+ `I stopped before finishing, so this did not reach you as a review.\n\n` +
149
+ `The harness picks the card back up by itself shortly. Tell me if you want it differently.`,
150
+ });
151
+ log('tool', `told ${leadName} that ${card} was left unfinished`);
152
+ }
153
+ catch (e) {
154
+ log('warn', `could not tell ${leadName} about ${card}: ${e instanceof Error ? e.message : String(e)}`);
155
+ }
156
+ };
157
+ /**
158
+ * The checkout a message is about, when it names a card this agent already has one for.
159
+ *
160
+ * Nothing is created here. If there is no worktree the turn stays as it was — a conversation
161
+ * with no code in it — which is right: a message is not authority to start work, only to carry
162
+ * on with work that exists.
163
+ */
164
+ const worktreeTheMessageIsAbout = async (event, repo, onStep) => {
165
+ // `ask` too: a question typed in the pane is usually about the card in front of the person,
166
+ // and without this the turn has no checkout and therefore no publishing tools — so an agent
167
+ // asked to commit its work truthfully answers that it cannot.
168
+ if (!['message', 'commented', 'ask'].includes(event.event))
169
+ return null;
170
+ const text = `${event.line ?? ''} ${event.message ?? ''}`;
171
+ const codes = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,9}-\d{3,}\b/g) ?? [])];
172
+ for (const code of codes) {
173
+ // What the harness wrote down when it made the worktree, first: the directory is named from
174
+ // the card's internal id and the branch from whatever the agent chose, so neither can be
175
+ // matched against the code a message uses.
176
+ const known = recallWorktree(code);
177
+ // The branch is read from the checkout, never from what was written down: an agent may
178
+ // rename its branch, and telling it the name the harness cut months ago is worse than
179
+ // saying nothing. The PATH is the thing worth remembering.
180
+ const found = known
181
+ ? { path: known.path, branch: (await currentBranch({ path: known.path, branch: known.branch, created: false })).name, created: false }
182
+ : await existingWorktree(repo, code).catch(() => null);
183
+ if (found) {
184
+ onStep(`this is about ${code} — using its checkout, ${found.branch}`);
185
+ tag = code;
186
+ return found;
187
+ }
188
+ }
189
+ return null;
190
+ };
191
+ /** Said to the lead when an agent has started working around the harness instead of the card. */
192
+ const tellLeadItIsStuck = async (card, refusals) => {
193
+ if (!leadId)
194
+ return;
195
+ try {
196
+ await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'send-message', {
197
+ to: leadId,
198
+ message: `**${card}: I am stuck and I have stopped.**\n\n` +
199
+ `${refusals} of my commands were refused this turn and I started looking for ways ` +
200
+ `around them, which is never right. Something the harness told me to do, I cannot do ` +
201
+ `here. I have said the details on the card.`,
202
+ });
203
+ log('tool', `told ${leadName} it is stuck on ${card}`);
204
+ }
205
+ catch { /* the pane already says it */ }
206
+ };
73
207
  const say = (text) => log('say', text);
74
208
  // The job the publishing tools act on. Set when a card starts, so those tools cannot name a
75
209
  // branch — they read this instead.
@@ -263,16 +397,31 @@ export async function serve(opts) {
263
397
  };
264
398
  /** Who this agent reports to, when it is a worker on a team: production runs go there first. */
265
399
  let leadId = null;
400
+ let leadName = '';
401
+ /** A lead's surface is wider than a worker's, so the tool list is too. */
402
+ let isLead = false;
403
+ const keepTool = (name) => isLead || !NOT_FOR_WORKERS.includes(name);
404
+ /** Cards already handed to the lead, so a retried tool call does not assign or message twice. */
405
+ const handedBack = new Set();
266
406
  try {
267
407
  const team = await fetchTeam(opts.baseUrl, await opts.auth());
268
- // Only a lead that is actually running can review: one that has never checked in, or has
269
- // gone quiet, would hold every request until it expired. Then the request goes straight to
270
- // the people who can approve it, as it would with no lead at all.
408
+ // A lead this agent was told to report to gets asked, whether or not it is a harness.
409
+ //
410
+ // This used to require the lead to have written a status record recently, which only a
411
+ // harness does — so a lead that is a plain session, driven by a person, read as "not
412
+ // running" and was routed around every time. Somebody set reportsTo on purpose; going over
413
+ // their head because they are not a daemon is the wrong default, and it means approvals
414
+ // reach people without the context the lead has.
415
+ //
416
+ // It is safe to assume, because asking the lead does not replace asking a person: an
417
+ // endorsement PROMOTES the request rather than answering it, and a lead that never responds
418
+ // lapses (see flaredeckDecider) and the request goes on to the approvers.
271
419
  const lead = team.you.reportsTo ? team.agents.find((a) => a.agentId === team.you.reportsTo) : undefined;
272
- const leadAlive = !!lead && lead.quietFor !== null && !lead.stale;
273
- leadId = leadAlive ? team.you.reportsTo : null;
274
- if (team.you.reportsTo && !leadAlive)
275
- log('warn', `your lead (${lead?.name ?? team.you.reportsTo}) is not running — production runs will go straight to the approvers`);
420
+ leadId = team.you.reportsTo || null;
421
+ leadName = lead?.name ?? leadId ?? '';
422
+ if (leadId)
423
+ log('tool', `your lead is ${leadName} — ${leadName} verifies your work before it goes anywhere`);
424
+ isLead = team.you.role === 'lead';
276
425
  if (team.you.role === 'lead') {
277
426
  teamSet = {
278
427
  // After a review the list changes — an endorsed request is now answerable, from THIS
@@ -437,11 +586,45 @@ export async function serve(opts) {
437
586
  && event.event !== 'review'
438
587
  && event.event !== 'ask'
439
588
  && !(event.event === 'commented' && event.cardDone && !event.mentioned)
589
+ // Someone else's card is not worth reading ahead. `drain` already declines to WORK these —
590
+ // a card this agent once touched keeps sending it every move and comment made by whoever
591
+ // holds it now — but that check happens when the event's turn comes, and reading ahead
592
+ // happens the moment it arrives. So an agent paid for a brief, a Jev call and a code search
593
+ // on cards belonging to other agents, to people, and on cards already done, before
594
+ // concluding it should ignore them. Same question, asked early enough to matter.
595
+ && needsMe(event)
596
+ // Named on somebody else's card is correspondence, not work. The agent is still woken —
597
+ // being addressed by name must do something, or @mentioning an agent is useless — but intake
598
+ // exists to brief and SIZE work, and there is no work here to size. One agent ran a full
599
+ // brief, a Jev classification and a code search on a card held by another agent, sitting in
600
+ // a signoff lane, and concluded it was "large · prod data". None of which it was going to do.
601
+ // It reads the card in its turn, as a person would, and answers.
602
+ && !(event.mentioned === true && event.forYou === false)
440
603
  && !sessions.has(event.cardKey);
604
+ /**
605
+ * How many cards may be read ahead at once.
606
+ *
607
+ * The point of reading ahead is that the NEXT card is ready when the current one ends — one
608
+ * card, not the whole queue. But this fired on every event as it arrived, so a backlog of
609
+ * thirty started thirty intakes at once, each a brief, a Jev call and a code search that takes
610
+ * seconds of git grep. That is a thundering herd against two providers at the same moment, and
611
+ * it showed: briefs fell back to Haiku and Jev came back off, with no error anywhere, because
612
+ * both were simply being rate-limited by this agent's own enthusiasm.
613
+ *
614
+ * Two: the next card, and one spare for when the next card turns out not to need this agent.
615
+ * Anything further back is read when its turn comes, which costs a few seconds of latency and
616
+ * nothing else — `handle` already falls back to reading the card itself when nothing was
617
+ * prepared for it.
618
+ */
619
+ const PREPARE_AHEAD_LIMIT = 2;
620
+ let preparing = 0;
441
621
  const prepareAhead = (event) => {
442
622
  if (!wantsIntake(event) || preparedAhead.has(event.id))
443
623
  return;
624
+ if (preparing >= PREPARE_AHEAD_LIMIT)
625
+ return;
444
626
  const label = event.cardCode || event.cardKey || '';
627
+ preparing++;
445
628
  preparedAhead.set(event.id, {
446
629
  at: Date.now(),
447
630
  // The repo this card belongs to, so the code search behind the brief searches the right
@@ -449,7 +632,16 @@ export async function serve(opts) {
449
632
  promise: (async () => {
450
633
  const w = await resolveCheckout(config.repo, event, config.repos);
451
634
  return intake(opts.deck, event.cardCode || event.cardKey, (t) => log('tool', `${label}: ${t}`), false, w.kind === 'repo' ? w.checkout.path : config.repo);
452
- })().catch(() => null),
635
+ })()
636
+ .catch(() => null)
637
+ .finally(() => {
638
+ preparing--;
639
+ // A slot just freed: warm the card actually next in line, rather than whichever event
640
+ // happened to arrive while the queue was full.
641
+ const next = queue.find((e) => !preparedAhead.has(e.id));
642
+ if (next)
643
+ prepareAhead(next);
644
+ }),
453
645
  });
454
646
  };
455
647
  let working = false;
@@ -498,12 +690,55 @@ export async function serve(opts) {
498
690
  if (enqueue({ id: 'sweep', event: 'sweep', line: 'checking what is still assigned to you' }))
499
691
  void drain();
500
692
  };
693
+ /**
694
+ * A card of mine already in progress, picked up again without being asked.
695
+ *
696
+ * The sweep told the model "a card already in progress: say which one, and stop. It will come
697
+ * back to you." Nothing brought it back. A card sitting in the in-progress lane with this
698
+ * agent's name on it generates no further events, so it sat there until a person prodded —
699
+ * twice, usually, because the first prod produced the same observation.
700
+ *
701
+ * Finding it needs no model at all: the board knows, and `get-outstanding` answers. So the
702
+ * harness reads it and queues the card itself, as the ordinary card event it should always
703
+ * have been — with a worktree, a branch, and everything else a card turn gets.
704
+ */
705
+ const resumeOwnWork = async () => {
706
+ if (!sweepsEnabled || stopping || working || queue.length || live.size)
707
+ return;
708
+ let mine = [];
709
+ try {
710
+ const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-outstanding', {});
711
+ const text = typeof out === 'string' ? out : JSON.stringify(out);
712
+ // The codes it names, in the order it names them: priority order, which is the board's.
713
+ mine = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,9}-\d{3,}\b/g) ?? [])].map((code) => ({ code }));
714
+ }
715
+ catch {
716
+ return; // unreachable board is the stream's problem to report, not this timer's
717
+ }
718
+ const next = mine.find((c) => !sessions.has(c.code) && !live.has(c.code));
719
+ if (!next)
720
+ return;
721
+ log('tool', `picking ${next.code} back up — it is yours and already in progress`);
722
+ if (enqueue({
723
+ id: `resume-${next.code}-${Date.now()}`,
724
+ event: 'assigned',
725
+ line: `${next.code} is still yours and in progress`,
726
+ cardCode: next.code,
727
+ cardKey: next.code,
728
+ }))
729
+ void drain();
730
+ };
501
731
  const sweepTimer = setInterval(sweepNow, SWEEP_EVERY_MS);
502
732
  sweepTimer.unref();
503
733
  // Not at once: let the stream connect and deliver anything it is holding first, so a sweep
504
734
  // never races a redelivery of the very cards it would pick up.
505
735
  if (sweepsEnabled)
506
736
  setTimeout(sweepNow, 30_000).unref();
737
+ // Between sweeps, and offset from them so the two never race for the same card.
738
+ const resumeTimer = setInterval(() => void resumeOwnWork(), SWEEP_EVERY_MS);
739
+ resumeTimer.unref();
740
+ if (sweepsEnabled)
741
+ setTimeout(() => void resumeOwnWork(), 90_000).unref();
507
742
  // Ctrl-C must actually stop it. The first press closes the stream and lets a turn in progress
508
743
  // finish; the second gives up on that and exits. A daemon that has to be hunted down with
509
744
  // pkill is worse than one that occasionally abandons a turn — the events are unacknowledged,
@@ -522,6 +757,7 @@ export async function serve(opts) {
522
757
  stream.close();
523
758
  clearInterval(heartbeat);
524
759
  clearInterval(sweepTimer);
760
+ clearInterval(resumeTimer);
525
761
  teamTimers.forEach(clearInterval);
526
762
  // Belt and braces: if unwinding wedges, do not hang forever pretending to shut down. Longer
527
763
  // when cards are still open, because their handovers run first and each is a short turn.
@@ -545,8 +781,22 @@ export async function serve(opts) {
545
781
  await stream.ack([event.id]);
546
782
  continue;
547
783
  }
548
- current = event.event === 'ask' ? 'your question' : event.event === 'sweep' ? 'your outstanding work' : event.event === 'scheduled' ? event.scheduleName || 'a scheduled task' : event.cardCode || event.cardKey || 'a message';
784
+ // What a person reading the pane wants to know is WHICH card, and a code alone is not
785
+ // that — you have to go and look it up. "a message" was worse: it did not even say who
786
+ // from, on a line that sits there for minutes.
787
+ const cardLabel = event.cardCode || event.cardKey || '';
788
+ const title = (event.cardName || '').trim();
789
+ current = event.event === 'ask'
790
+ ? 'your question'
791
+ : event.event === 'sweep'
792
+ ? 'your outstanding work'
793
+ : event.event === 'scheduled'
794
+ ? event.scheduleName || 'a scheduled task'
795
+ : cardLabel
796
+ ? `${cardLabel}${title ? ` · ${title.length > 58 ? `${title.slice(0, 57)}…` : title}` : ''}`
797
+ : `a message${event.actor ? ` from ${event.actor}` : ''}`;
549
798
  state.working = current;
799
+ tag = cardLabel;
550
800
  state.since = Date.now();
551
801
  state.tasks = { done: 0, total: 0 };
552
802
  // What it starts the moment this one is done — so a long turn does not read as a stall.
@@ -584,8 +834,16 @@ export async function serve(opts) {
584
834
  setTimeout(() => { if (enqueue(event, true))
585
835
  void drain(); }, 60_000).unref();
586
836
  }
837
+ // A session ends when the model stops talking, which is not the same as the work being
838
+ // done. HAL ended one with sixteen call sites edited, ten files modified, nothing
839
+ // committed and nobody told — and the next session, with the conversation gone, started
840
+ // the card from the beginning. The worktree answers "was this finished" without asking a
841
+ // model: uncommitted changes, or commits that never reached the remote.
842
+ if (tag)
843
+ await reportUnfinished(tag);
587
844
  current = '';
588
845
  state.working = '';
846
+ tag = '';
589
847
  delete state.branch;
590
848
  delete state.base;
591
849
  delete state.tasks;
@@ -768,10 +1026,19 @@ export async function serve(opts) {
768
1026
  log('event', `working in ${chosen.name}`);
769
1027
  if (fromCard)
770
1028
  log('event', `base ${fromCard.branch} — ${fromCard.role} (${event.environment || 'no environment'}), from the board`);
1029
+ // A card's own turn gets its worktree made. A turn that is ABOUT a card — a lead saying
1030
+ // "commit and push what you have" — gets the one that already exists, and with it the job,
1031
+ // the publishing tools and the journal. Without this, HAL committed its work on a message
1032
+ // turn, was told to publish with push_branch, and went looking for push_branch on the
1033
+ // filesystem: the harness had named a tool that turn did not have.
771
1034
  const tree = event.cardKey && (chosen || where.kind === 'none')
772
- ? await ensureWorktree(repo, key, (t) => log('tool', t), startFrom)
773
- : null;
1035
+ ? await ensureWorktree(repo, key, (t) => log('tool', t), startFrom, config.worktreeLocalFiles)
1036
+ : await worktreeTheMessageIsAbout(event, repo, (t) => log('tool', t));
774
1037
  const cwd = tree?.path ?? repo;
1038
+ // So a later message naming this card by its CODE can find the work. See rememberWorktree.
1039
+ if (tree && (event.cardCode || event.cardKey)) {
1040
+ rememberWorktree(event.cardCode || event.cardKey, tree.path, tree.branch);
1041
+ }
775
1042
  // No worktree, but this turn is going to change something. That used to carry on in whatever
776
1043
  // directory happened to be the fallback — no branch, no isolation, and no word said. An agent
777
1044
  // did exactly that and ended up reading production out of a worktree belonging to someone
@@ -802,7 +1069,71 @@ export async function serve(opts) {
802
1069
  log('tool', `FlareDeck reachable · ${check.discovered.tools.length} tools`);
803
1070
  // Served from in here for this turn: a fresh token per call, so a card that outlives the
804
1071
  // token's hour keeps its tools, and nothing on this machine can shadow the server by name.
805
- const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w));
1072
+ // The gate on calling a card finished: it must have commits of its own, published. Checked
1073
+ // here rather than trusted, because a turn that produced nothing still reports confidently.
1074
+ const finishGate = async () => {
1075
+ const why = await tooEarlyToFinish(tree);
1076
+ if (why) {
1077
+ log('warn', `refused a "done" on an empty branch: ${tree?.branch ?? '(no branch)'}`);
1078
+ return why;
1079
+ }
1080
+ // If this agent reports to someone, it does not decide that its own work is finished. The
1081
+ // lead said "I'll check its saved work myself before it goes anywhere" and then was never
1082
+ // told: the card moved itself to Awaiting Deployment. A report hands back; the lead moves
1083
+ // it, or comments and sends it back, which is what a lead is for.
1084
+ if (!leadId)
1085
+ return null;
1086
+ const code = tag || event.cardCode || event.cardKey || '';
1087
+ if (!code)
1088
+ return null;
1089
+ if (!handedBack.has(code)) {
1090
+ handedBack.add(code);
1091
+ const made = tree ? await produced(tree) : null;
1092
+ const where = tree
1093
+ ? `Branch \`${tree.branch}\`${made ? ` — ${made.commits} commit(s), ${made.pushed ? 'pushed' : 'NOT pushed'}` : ''}.`
1094
+ : 'No branch: nothing of mine is in a checkout for this card.';
1095
+ try {
1096
+ const token = await opts.auth();
1097
+ await callFlaredeckTool(config.mcpUrl, token, 'assign-card', { card_id: code, assignee: leadId });
1098
+ await callFlaredeckTool(config.mcpUrl, token, 'send-message', {
1099
+ to: leadId,
1100
+ message: `**${code} is ready for your review.**\n\n${where}\n\n` +
1101
+ `I have not moved it — it is assigned to you and still in its lane. ` +
1102
+ `Check the work and move it on, or comment on the card and I will fix it.`,
1103
+ });
1104
+ log('tool', `handed ${code} to ${leadName} for review`);
1105
+ }
1106
+ catch (e) {
1107
+ // A handback that did not happen must not read as one that did.
1108
+ handedBack.delete(code);
1109
+ const text = e instanceof Error ? e.message : String(e);
1110
+ log('warn', `could not hand ${code} to ${leadName}: ${text}`);
1111
+ return `Could not hand this card to ${leadName} for review (${text}). Do not move it; say so on the card instead.`;
1112
+ }
1113
+ }
1114
+ return (`${code} is now ${leadName}'s to verify, not yours to close. It is assigned to them and ` +
1115
+ `they have been told, with your branch. Leave the lane alone — they move it on, or comment ` +
1116
+ `and send it back to you. Your part is done; say nothing further about it being finished.`);
1117
+ };
1118
+ // A turn woken by a message says "a message from JARVIS" for as long as it runs, even while
1119
+ // it is plainly working a card. The card tools name the card; this takes it from there, and
1120
+ // only when the turn had none of its own.
1121
+ // Ground truth for the footer on anything this agent says. Null without a worktree: there is
1122
+ // no branch to report on, and a footer saying "nothing committed" would be misleading rather
1123
+ // than informative.
1124
+ const savedSoFar = async () => {
1125
+ if (!tree)
1126
+ return null;
1127
+ const { commits, pushed, branch, known } = await produced(tree);
1128
+ return { branch, commits, pushed, known };
1129
+ };
1130
+ const noteCard = (code) => {
1131
+ if (!code || tag)
1132
+ return;
1133
+ tag = code;
1134
+ state.working = code;
1135
+ };
1136
+ const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), finishGate, noteCard, keepTool, savedSoFar);
806
1137
  const held = live.get(key);
807
1138
  if (held?.timer)
808
1139
  clearTimeout(held.timer);
@@ -853,18 +1184,76 @@ export async function serve(opts) {
853
1184
  },
854
1185
  }
855
1186
  : null;
1187
+ // Declared checks are OPTIONAL: no .flaredeck/agent.yaml means none, and that is the normal
1188
+ // case. Judging what a task needs is the agent's job — it can read the card and the repo, and
1189
+ // most cards need nothing. This is only for the few things a project wants hard-gated
1190
+ // regardless of what any agent thinks.
1191
+ //
1192
+ // Both of these belong to a CARD's own checkout. Without a worktree there is no card being
1193
+ // worked and `cwd` is the shared checkout itself — so a sweep asking "what is still assigned
1194
+ // to me", or a question typed in the pane, was starting a scratch database, running
1195
+ // migrations and bringing up an API and a web server, in the checkout everything else shares,
1196
+ // to answer something that needs no running software at all. Every hour, on the hour.
1197
+ const forACard = !!tree;
1198
+ const checks = forACard ? await preflight(cwd, (t) => log('tool', t)) : [];
1199
+ // Only bring the project UP when the card needs it running. Intake already judges that, and
1200
+ // the judgement was being used to decide whether to MENTION the environment while starting it
1201
+ // regardless — so a six-pixel CSS alignment fix paid for an install, a database and two
1202
+ // servers before it was allowed to look at a stylesheet. An agent that finds it does need
1203
+ // something running can start it itself; the brief says how.
1204
+ const needsRunning = (prepared?.jev?.needs_running ?? 0) >= 0.5;
1205
+ const env = forACard && needsRunning
1206
+ // The script this board declared for the repository this card is in, if it declared one.
1207
+ // Matched on the remote, the same way the base branch is.
1208
+ ? await startEnvironment(cwd, (t) => log('tool', t), where.kind === 'repo' ? where.script : undefined)
1209
+ : { ran: false, ok: true, endpoints: {}, detail: '' };
1210
+ if (forACard && !needsRunning)
1211
+ log('tool', 'environment not started — this card does not look like it needs one');
856
1212
  const engineContext = {
857
1213
  config: { ...config, repo: cwd },
858
1214
  // Only a real worktree confines anything; without one nothing is enforced here, and the
859
1215
  // guard below makes sure a turn that would CHANGE code never gets that far.
860
- ...(tree ? { sandbox: tree.path } : {}),
1216
+ //
1217
+ // A SWEEP is the exception, and the reason is worth stating. It is a handover — find the
1218
+ // next piece of work and start it moving — and it has no card, so no worktree. If it tried
1219
+ // to do the work anyway it would be editing the shared checkout every worktree is cut from,
1220
+ // on no branch, with no way to publish it. The brief says so; this makes it true. Pointing
1221
+ // the sandbox at a directory that holds no source confines writes to scratch space (/tmp is
1222
+ // always allowed) without pretending it has a checkout it does not have.
1223
+ // There is ALWAYS a sandbox. It used to be set only for a card or a sweep, so a turn woken
1224
+ // by a message had none — nothing confined — and an agent told "work HR-0621" by its lead
1225
+ // did exactly that, in the shared checkout every worktree is cut from, on a branch of its
1226
+ // own. Without a worktree there is nowhere legitimate to write, so the turn gets scratch
1227
+ // space and has to hand the card back instead.
1228
+ sandbox: tree ? tree.path : scratchDir(),
861
1229
  decider,
1230
+ // Both only reachable in-process, so an out-of-process engine is handed them explicitly
1231
+ // rather than being left unable to publish and unchecked when it says it has.
1232
+ ...(job ? { publish: async () => publishBranch(job) } : {}),
1233
+ standing: standingBrief(!!teamSet, config.style ?? process.env['FLAREDECK_AGENT_STYLE']),
1234
+ finish: finishGate,
1235
+ noteCard,
1236
+ keep: keepTool,
1237
+ saved: savedSoFar,
862
1238
  token: await opts.auth(),
863
1239
  ...(resume ? { resume } : {}),
864
1240
  ...(prepared?.model ? { model: prepared.model } : {}),
865
1241
  ...(prepared?.size ? { size: prepared.size } : {}),
866
1242
  tools: {
867
1243
  ...(job ? { git: { server: gitServer, toolNames: GIT_TOOL_NAMES } } : {}),
1244
+ // Eyes, when there is something running to look at. A card with no environment gets no
1245
+ // browser tools and is told nothing about them.
1246
+ ...(env.ran && Object.keys(env.endpoints).length
1247
+ ? {
1248
+ browser: browserTools({
1249
+ repo: config.repo,
1250
+ endpoints: () => env.endpoints,
1251
+ contract: () => env.contract,
1252
+ cwd,
1253
+ onNote: (t) => log('tool', t),
1254
+ }),
1255
+ }
1256
+ : {}),
868
1257
  team: teamSet,
869
1258
  flaredeck,
870
1259
  runs: runsSet,
@@ -872,23 +1261,46 @@ export async function serve(opts) {
872
1261
  },
873
1262
  guard,
874
1263
  };
875
- // Declared checks are OPTIONAL: no .flaredeck/agent.yaml means none, and that is the normal
876
- // case. Judging what a task needs is the agent's job — it can read the card and the repo, and
877
- // most cards need nothing. This is only for the few things a project wants hard-gated
878
- // regardless of what any agent thinks.
879
- const checks = await preflight(cwd, (t) => log('tool', t));
880
- // Bring the project up the way it says to, and tell the agent where things ended up — a
881
- // script that takes a free port for parallel worktrees leaves nothing to guess from.
882
- const env = await startEnvironment(cwd, (t) => log('tool', t));
1264
+ // Before the agent believes a single thing the environment tells it: is this environment
1265
+ // actually its own? A front end on an assumed port reaches another worktree's API and answers
1266
+ // perfectly, which is the one failure here that looks like success.
1267
+ const wrongEnv = tree && env.ran ? await crossWired(env.endpoints, tree.path) : [];
1268
+ for (const w of wrongEnv)
1269
+ log('warn', w);
883
1270
  const brief = prompt(event) + intakeText(prepared) +
884
1271
  workspaceBrief(config.repo, await checkouts(config.repo, config.repos), chosen) +
885
- worktreeBrief(tree) + preflightBrief(checks) +
1272
+ worktreeBrief(tree) + (await resumeBrief(event.cardCode || event.cardKey || '', tree)) + preflightBrief(checks) +
886
1273
  // Only tell an agent about the environment when the card actually needs one running.
887
- environmentBrief(env, cwd, (prepared?.jev?.needs_running ?? 0) >= 0.5);
1274
+ environmentBrief(env, cwd, needsRunning) + notStartedBrief(forACard && !needsRunning, cwd) +
1275
+ browserBrief(env.ran && Object.keys(env.endpoints).length > 0, env.endpoints) +
1276
+ (wrongEnv.length
1277
+ ? `\n\nSTOP — the environment that started is not yours:\n${wrongEnv.map((w) => ` · ${w}`).join('\n')}\n\n` +
1278
+ `Anything you test against it tells you about another card's code, and it will answer ` +
1279
+ `normally while doing so. Do not work around it by picking a different port. Say this on ` +
1280
+ `the card, tell whoever owns the board that ${START_SCRIPT_NAME} does not survive parallel ` +
1281
+ `worktrees — it must take a free port AND pass that port to the parts that call it — and stop.`
1282
+ : '');
888
1283
  const run = engine.run(brief, engineContext);
889
1284
  liveRun = run;
1285
+ // An agent that has started fighting the tools is not going to stop on its own.
1286
+ const struggling = watchForDesperation();
890
1287
  for await (const out of run.events) {
891
1288
  status.observe(out);
1289
+ if (out.kind === 'tool') {
1290
+ const stepBack = struggling.saw(out.name);
1291
+ if (stepBack) {
1292
+ log('warn', `${struggling.refusals()} refusals this turn — telling it to stop and ask`);
1293
+ const heard = await run.steer(stepBack).catch(() => false);
1294
+ // An engine that cannot be steered gets stopped instead: letting it carry on is how a
1295
+ // turn becomes twenty minutes of working around a guard.
1296
+ if (!heard) {
1297
+ log('warn', 'cannot steer this engine — ending the turn');
1298
+ await run.stop().catch(() => { });
1299
+ }
1300
+ if (tag)
1301
+ void tellLeadItIsStuck(tag, struggling.refusals());
1302
+ }
1303
+ }
892
1304
  state.tasks = status.snapshot().tasks;
893
1305
  // An answer to a question typed here is the whole point of the turn, so it is shown whole;
894
1306
  // a card turn's narration is one line each, the card holds the full account.
@@ -1129,20 +1541,30 @@ async function baseBranch(cwd) {
1129
1541
  function prompt(event) {
1130
1542
  if (event.event === 'sweep') {
1131
1543
  return [
1132
- 'Nothing has arrived for a while, so this is a check of what you already hold.',
1544
+ 'Nothing has arrived for a while, so this is a check of what you already hold. It is a',
1545
+ 'HANDOVER, not a working turn: you find the next piece of work and start it moving, and it',
1546
+ 'comes back to you properly a moment later.',
1133
1547
  '',
1134
1548
  'Call get-outstanding. It lists the cards assigned to you that are not done, and the comments',
1135
1549
  'addressed to you that nobody has actioned.',
1136
1550
  '',
1137
- 'Then, in order of priority, PICK UP THE FIRST ONE AND WORK IT — read the card including its',
1138
- 'agent notes, do the work, comment on the card as you go. If what you hold is for the agents',
1139
- 'who report to you rather than for you, assign it to the right one instead; that dispatch is',
1140
- 'the work. If a card cannot move without a decision or an approval, call set-needs-human and',
1141
- 'say what you need, then go to the next one.',
1551
+ 'If it returns nothing, say so in one line and stop. That is a perfectly good outcome and',
1552
+ 'costs nothing — do NOT go looking for work elsewhere, do not scan logs, and do not invent a',
1553
+ 'task to justify the turn.',
1554
+ '',
1555
+ 'Otherwise take the FIRST one by priority and, depending on what it is:',
1556
+ '',
1557
+ '- A card for one of the agents who report to you: assign it to the right one. That dispatch',
1558
+ ' IS the work, and this turn is done.',
1559
+ '- A card that is yours and NOT already in progress: move it to the board\'s in-progress lane',
1560
+ ' with update-card-status, and stop. Do not read it, do not start the work, do not comment',
1561
+ ' on it. Moving it hands it back to you as a card of its own — with a checkout of your own,',
1562
+ ' on its own branch — which is the only place the work can actually be done and published',
1563
+ ' from. Working it here would edit the shared checkout and could not be pushed anywhere.',
1564
+ '- A card already in progress: say which one in a line, and stop. The harness picks those up',
1565
+ ' by itself, shortly, with a checkout of its own — you do not need to do anything for it.',
1142
1566
  '',
1143
- 'If get-outstanding returns nothing, say so in one line and stop. That is a perfectly good',
1144
- 'outcome and costs nothing — do NOT go looking for work elsewhere, do not scan logs, and do',
1145
- 'not invent a task to justify the turn.',
1567
+ 'Then stop. One handover per check — the rest are still there next time.',
1146
1568
  ].join('\n');
1147
1569
  }
1148
1570
  if (event.event === 'ask') {