@onepointfour-packs/flaredeck-agent 0.2.209 → 0.2.211

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 (94) hide show
  1. package/dist/agent.js +2 -1
  2. package/dist/agent.js.map +1 -1
  3. package/dist/assistant.d.ts +1 -1
  4. package/dist/assistant.js +4 -2
  5. package/dist/assistant.js.map +1 -1
  6. package/dist/check.d.ts +32 -0
  7. package/dist/check.js +251 -0
  8. package/dist/check.js.map +1 -0
  9. package/dist/config.d.ts +8 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/environment.d.ts +13 -3
  12. package/dist/environment.js +25 -15
  13. package/dist/environment.js.map +1 -1
  14. package/dist/envtools.d.ts +29 -0
  15. package/dist/envtools.js +67 -5
  16. package/dist/envtools.js.map +1 -1
  17. package/dist/features/card-folder.d.ts +69 -0
  18. package/dist/features/card-folder.js +305 -0
  19. package/dist/features/card-folder.js.map +1 -0
  20. package/dist/features/chat.d.ts +45 -0
  21. package/dist/features/chat.js +90 -0
  22. package/dist/features/chat.js.map +1 -0
  23. package/dist/features/dispatch.d.ts +55 -0
  24. package/dist/features/dispatch.js +101 -0
  25. package/dist/features/dispatch.js.map +1 -0
  26. package/dist/features/hand-back.d.ts +60 -0
  27. package/dist/features/hand-back.js +127 -0
  28. package/dist/features/hand-back.js.map +1 -0
  29. package/dist/features/manage-pane.d.ts +31 -0
  30. package/dist/features/manage-pane.js +60 -0
  31. package/dist/features/manage-pane.js.map +1 -0
  32. package/dist/features/release-wiring.d.ts +18 -0
  33. package/dist/features/release-wiring.js +56 -0
  34. package/dist/features/release-wiring.js.map +1 -0
  35. package/dist/features/services.d.ts +45 -0
  36. package/dist/features/services.js +147 -0
  37. package/dist/features/services.js.map +1 -0
  38. package/dist/features/sign-in-request.d.ts +81 -0
  39. package/dist/features/sign-in-request.js +91 -0
  40. package/dist/features/sign-in-request.js.map +1 -0
  41. package/dist/github.d.ts +14 -0
  42. package/dist/github.js +39 -8
  43. package/dist/github.js.map +1 -1
  44. package/dist/harness/events.d.ts +58 -0
  45. package/dist/harness/events.js +126 -0
  46. package/dist/harness/events.js.map +1 -0
  47. package/dist/harness/handover.d.ts +39 -0
  48. package/dist/harness/handover.js +63 -0
  49. package/dist/harness/handover.js.map +1 -0
  50. package/dist/harness/pause.d.ts +67 -0
  51. package/dist/harness/pause.js +128 -0
  52. package/dist/harness/pause.js.map +1 -0
  53. package/dist/harness/plan-usage.d.ts +49 -0
  54. package/dist/harness/plan-usage.js +69 -0
  55. package/dist/harness/plan-usage.js.map +1 -0
  56. package/dist/harness/prompt.d.ts +59 -0
  57. package/dist/harness/prompt.js +289 -0
  58. package/dist/harness/prompt.js.map +1 -0
  59. package/dist/harness/repo-notes.d.ts +1 -0
  60. package/dist/harness/repo-notes.js +26 -0
  61. package/dist/harness/repo-notes.js.map +1 -0
  62. package/dist/harness/restarter.d.ts +33 -0
  63. package/dist/harness/restarter.js +78 -0
  64. package/dist/harness/restarter.js.map +1 -0
  65. package/dist/harness/start-scripts.d.ts +7 -0
  66. package/dist/harness/start-scripts.js +44 -0
  67. package/dist/harness/start-scripts.js.map +1 -0
  68. package/dist/harness/tools.d.ts +37 -0
  69. package/dist/harness/tools.js +17 -0
  70. package/dist/harness/tools.js.map +1 -0
  71. package/dist/hydrate.d.ts +5 -0
  72. package/dist/hydrate.js +1 -0
  73. package/dist/hydrate.js.map +1 -1
  74. package/dist/main.js +32 -1
  75. package/dist/main.js.map +1 -1
  76. package/dist/profile.d.ts +80 -0
  77. package/dist/profile.js +77 -0
  78. package/dist/profile.js.map +1 -0
  79. package/dist/release.js +3 -0
  80. package/dist/release.js.map +1 -1
  81. package/dist/sandbox.js +3 -1
  82. package/dist/sandbox.js.map +1 -1
  83. package/dist/serve.d.ts +21 -115
  84. package/dist/serve.js +971 -1612
  85. package/dist/serve.js.map +1 -1
  86. package/dist/stream.d.ts +5 -0
  87. package/dist/stream.js.map +1 -1
  88. package/dist/workspace.d.ts +6 -0
  89. package/dist/workspace.js +19 -0
  90. package/dist/workspace.js.map +1 -1
  91. package/dist/worktree.d.ts +55 -2
  92. package/dist/worktree.js +130 -5
  93. package/dist/worktree.js.map +1 -1
  94. package/package.json +2 -2
package/dist/serve.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { hostname } from 'node:os';
2
+ import { randomBytes } from 'node:crypto';
2
3
  import { execFile, spawn } from 'node:child_process';
3
4
  import { EventEmitter } from 'node:events';
4
5
  import { createRequire } from 'node:module';
@@ -11,12 +12,13 @@ import { defaultUploadRoots, fileTools, flaredeckStores } from './filestore.js';
11
12
  import { scratchFor } from './cardmess.js';
12
13
  import { installScripts, scriptTools, SCRIPT_TOOL_NAMES } from './trusted-scripts.js';
13
14
  import { boardsNaming, fetchLadder, isSoloWorker, namedOnLadders, releaseTools } from './release.js';
14
- import { LiveFeed, liveSender, midTurnMessage, ASSISTANT_KEY, claudeProjectDir, historyMessage, recentExchanges, latestCliSession, assistantBrief, assistantIgnores, assistantPerson, asAssistantEvent, loadAssistantSession, saveAssistantSession } from './assistant.js';
15
+ import { PROFILES, profileFor, releaseRole, releasesOwnCards } from './profile.js';
16
+ import { ASSISTANT_KEY } from './assistant.js';
15
17
  import { codePrefix, loadPrefixes, savePrefixes, SCRATCH_SWEEP_EVERY_MS, sweepScratch } from './scratch.js';
16
18
  import { findImages, namedForAgent } from './images.js';
17
19
  import { loadBaseBranch, loadMergeConfig, preflight, preflightBrief } from './preflight.js';
18
20
  import { pushStatus, StatusTracker } from './status.js';
19
- import { dispatchDecision, fetchTeam, myTeam, teamTools, TEAM_TOOL_NAMES } from './team.js';
21
+ import { fetchTeam, myTeam, teamTools, TEAM_TOOL_NAMES } from './team.js';
20
22
  import { scheduleTools, SCHEDULE_TOOL_NAMES } from './schedules.js';
21
23
  import { preflightFlaredeck } from './flaredeck-check.js';
22
24
  import { callFlaredeckTool, flaredeckProxy, offeredTo } from './flaredeck-proxy.js';
@@ -25,9 +27,6 @@ import { deliverDecision, flaredeckDecider } from './approval.js';
25
27
  import { subscriptionsForAgent } from './subscriptions.js';
26
28
  import { spendFor, spendLine } from './spend.js';
27
29
  import { CouldNotWork, engineFor } from './engine.js';
28
- /** The run failed on its Claude subscription — capped or token rejected — not on its work. */
29
- class RetryOnAnotherPlan extends CouldNotWork {
30
- }
31
30
  /** Where an assistant's card repositories are cloned: the harness's own folder, never its person's project. */
32
31
  export const cardReposRoot = () => join(configDir(), 'card-repos');
33
32
  /** A clone's folder: the repository's own name, made safe. */
@@ -43,14 +42,13 @@ export function neverStarted(e) {
43
42
  }
44
43
  import { isJustReading, jevGuard, judgeCommand } from './jevguard.js';
45
44
  import { loadProviders } from './providers.js';
46
- import { cardWorkOnBase, currentBranch, ensureWorktree, finishRefusalStands, freeDiskBytes, headOf, leadGaveItBack, useGitHubToken, sweepWorktrees, releaseSharedPushGuard, existingWorktree, produced, releaseWorktree, tooEarlyToFinish, uncommittedCount, worktreeBrief } from './worktree.js';
47
- import { askingForComponent, checkouts, repoIdentity, resolveCheckout, voteByCode, workspaceBrief, describeCheckout } from './workspace.js';
45
+ import { runGit, currentBranch, ensureWorktree, freeDiskBytes, useGitHubToken, sweepWorktrees, sweepCardFolders, releaseSharedPushGuard, existingWorktree, produced, releaseWorktree, uncommittedCount, worktreeBrief } from './worktree.js';
46
+ import { askingForComponent, checkouts, fromCardApi, repoIdentity, resolveCheckout, voteByCode, workspaceBrief, describeCheckout } from './workspace.js';
48
47
  import { chooseRepoWithJev } from './jev.js';
49
48
  import { ROUTE, intake, intakeText, unclearBrief } from './intake.js';
50
49
  import { standingBrief } from './agent.js';
51
50
  import { startUi } from './ui.js';
52
51
  import { checkAndStage, harnessVersion, installedSdkVersion, pendingUpdate, updatesEnabled } from './updater.js';
53
- import { isCheckout, newerHarness, readyOnDisk, restartDecision, updateSelf } from './self-update.js';
54
52
  import { cloneBoardRepos, isEmptyFolder, parseBoardRepos } from './bootstrap-repo.js';
55
53
  import { updateSidecarSdk } from './updater-python.js';
56
54
  import { managedPython, usingManagedPython } from './engine-antigravity.js';
@@ -69,8 +67,27 @@ import { mergeableComments, combineComments, unparkReason, isBossInstruction, is
69
67
  import { LogShipper } from './log-ship.js';
70
68
  import { isOwnMove } from './own-moves.js';
71
69
  import { baseBranchesFromComponent } from './workspace.js';
72
- import { isAfterReleaseMove, isReleasedClose } from './flaredeck-proxy.js';
73
70
  import { canPublish, canPushTo } from './githubauth.js';
71
+ import { createRestarter } from './harness/restarter.js';
72
+ import { createPause } from './harness/pause.js';
73
+ import { mountTools } from './harness/tools.js';
74
+ import { installStartScripts, storedStartScript } from './harness/start-scripts.js';
75
+ import { createServices, envName, serviceDir } from './features/services.js';
76
+ import { repoNotes } from './harness/repo-notes.js';
77
+ import { companionRepos, folderBrief, folderOf, openCardFolder, releaseCardFolder, startCompanions, withCompanions, writeFolderScript } from './features/card-folder.js';
78
+ import { planUsage, RetryOnAnotherPlan } from './harness/plan-usage.js';
79
+ import { createDispatch, isFreeWorkerMessage } from './features/dispatch.js';
80
+ import { finishGate, handBackMemory } from './features/hand-back.js';
81
+ import { requestSignIn } from './features/sign-in-request.js';
82
+ import { managePane } from './features/manage-pane.js';
83
+ import { createChat } from './features/chat.js';
84
+ import { flaredeckReleaseDeps } from './features/release-wiring.js';
85
+ import { onlyLooks, noteHandoverTool, handoverNeeded, cardWentElsewhere, handoverSubscription } from './harness/handover.js';
86
+ import { parkLabel, LEAD_SWEEP_GOAL, MANAGER_BRIEF, MANAGER_SWEEP_BRIEF, LEAD_WITHOUT_CODE_BRIEF, READ_ONLY_BRIEF, waitedNote, isLeadDispatch, prompt, roleBriefs } from './harness/prompt.js';
87
+ import { nothingOutstanding, RESUME_COOLDOWN_MS, workedRecently, unownedCards, isBookkeeping, forSomeoneElse, moveIsStale, workingLabel } from './harness/events.js';
88
+ export { nothingOutstanding, RESUME_COOLDOWN_MS, workedRecently, unownedCards, isBookkeeping, forSomeoneElse, moveIsStale, workingLabel };
89
+ export { parkLabel, LEAD_SWEEP_GOAL, MANAGER_BRIEF, MANAGER_SWEEP_BRIEF, LEAD_WITHOUT_CODE_BRIEF, READ_ONLY_BRIEF, waitedNote, isLeadDispatch, prompt };
90
+ export { onlyLooks, noteHandoverTool, handoverNeeded, cardWentElsewhere, handoverSubscription };
74
91
  /**
75
92
  * The always-on agent.
76
93
  *
@@ -94,61 +111,6 @@ const HEARTBEAT_MS = 5 * 60 * 1000;
94
111
  * hours. Releasing loses nothing: a card that comes back is checked out again from what it pushed,
95
112
  * for the price of one install.
96
113
  */
97
- /** Minutes before trying a failed harness install again: 10 min, 30 min, 2h, then every 6h. */
98
- const INSTALL_BACKOFF_MIN = [10, 30, 120, 360];
99
- /**
100
- * Whether a get-outstanding answer says there is nothing at all: no cards and no unactioned
101
- * comments, and nothing hidden by a filter. Anything else, including an answer that could not be
102
- * read, means the sweep turn runs as before.
103
- */
104
- export function nothingOutstanding(text) {
105
- if (!text)
106
- return false;
107
- return text.includes('*Nothing to do here. 🎉*') && text.includes('*No unactioned comments or mentions. 🎉*') && !/^### \d+\./m.test(text);
108
- }
109
- /** Tools that only look: reading files, searching, asking FlareDeck or a service what is there. */
110
- export function onlyLooks(tool) {
111
- if (['Read', 'Grep', 'Glob', 'LS', 'ToolSearch', 'TodoWrite', 'TaskOutput', 'LSP', 'WebFetch', 'WebSearch', 'Monitor', 'Skill'].includes(tool))
112
- return true;
113
- const op = tool.startsWith('mcp__') ? tool.split('__').slice(2).join('__') : '';
114
- return /^(get|find|list|read|search|health|hello|whats|check|describe|site_map|see_screen|browser_look|browser_screenshot|browser_events|pull_request_status|preview)/i.test(op);
115
- }
116
- /** Record a tool call against the session it ran in. */
117
- export function noteHandoverTool(logs, key, tool, at) {
118
- const l = logs.get(key) ?? {};
119
- if (/__set-card-notes$/.test(tool))
120
- l.notesAt = at;
121
- else if (!onlyLooks(tool))
122
- l.changedAt = at;
123
- logs.set(key, l);
124
- }
125
- /**
126
- * Whether a session ending needs a handover note: it changed something, after its last notes.
127
- * Unknown (no record) is treated as needed, as before.
128
- */
129
- export function handoverNeeded(l) {
130
- if (!l)
131
- return true;
132
- if (!l.changedAt)
133
- return false;
134
- return !l.notesAt || l.changedAt > l.notesAt;
135
- }
136
- /**
137
- * Whether a worker's card has gone to someone else, so its notes are no longer the worker's to
138
- * write. FlareDeck refuses them (set-card-notes: "not assigned to you"), and the handover turn
139
- * reopened a whole session only to be told so — CASE on HR-1128, TARS on HR-1141 (20 Oct).
140
- * Unknown (no card, no assignee, no id) is not "someone else": the handover runs as before.
141
- */
142
- export function cardWentElsewhere(card, selfId) {
143
- const holder = card?.assigned_to?.uid;
144
- return !!selfId && !!holder && holder !== selfId;
145
- }
146
- /** How long after a card's turn the harness leaves it alone, unless an event brings it back. */
147
- export const RESUME_COOLDOWN_MS = 15 * 60_000;
148
- /** Whether any of these ids had a turn within the cooldown. */
149
- export function workedRecently(last, ids, now) {
150
- return ids.some((id) => !!id && now - (last.get(id) ?? 0) < RESUME_COOLDOWN_MS);
151
- }
152
114
  export const WORKTREE_IDLE_MS = 2 * 60 * 60 * 1000;
153
115
  /** How often a running agent clears them. It used to happen only at startup, and agents run for days. */
154
116
  export const WORKTREE_SWEEP_EVERY_MS = 60 * 60 * 1000;
@@ -169,6 +131,23 @@ const waitingSince = new Map();
169
131
  */
170
132
  const AGENT_LOG_MAX = 5 * 1024 * 1024;
171
133
  let agentLogPath = join(configDir(), 'agent.log');
134
+ /**
135
+ * Whether an event is work on the agent's own card (a worker's turn) rather than its person's chat.
136
+ * Its own sweeps and pickups count, and so does a card it parked coming back: a check-back it asked
137
+ * for, or a script a person approved (`run-answered`). Those carry the card but not its assignee, and
138
+ * read as chat they skipped the card's repository — FRIDAY, woken on HRG-0006 to check the website it
139
+ * had just released, looked only in its own HR Goat folder and flagged the card "not on the machine
140
+ * working it" (28 Oct).
141
+ */
142
+ export function isOwnCardWork(e, selfId) {
143
+ if (e.event === 'sweep' && e.id === 'sweep')
144
+ return true;
145
+ if (String(e.id ?? '').startsWith('resume-'))
146
+ return true;
147
+ if (!(e.cardKey || e.cardCode))
148
+ return false;
149
+ return e.event === 'run-answered' || (!!selfId && e.assigneeKey === selfId) || (e.event === 'assigned' && e.forYou === true);
150
+ }
172
151
  /** Which agent's file this process writes — set once serve() knows who it is. */
173
152
  export function agentLogFor(agent) {
174
153
  const slug = agent.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+|-+$/g, '') || 'agent';
@@ -225,7 +204,6 @@ export async function serve(opts) {
225
204
  * MAX_PLAN_SWITCHES as a backstop.
226
205
  */
227
206
  const failedPlans = new Map();
228
- const MAX_PLAN_SWITCHES = 4;
229
207
  const stream = new EventStream(opts.baseUrl, opts.auth);
230
208
  const state = {
231
209
  version: harnessVersion(),
@@ -450,10 +428,14 @@ export async function serve(opts) {
450
428
  if (await releaseSharedPushGuard(c.path).catch(() => false))
451
429
  log('tool', `restored push on ${c.path} (a worktree's push guard had leaked into it)`);
452
430
  }
431
+ /** Where card folders can be: beside a checkout in this folder, beside this folder, among the cloned ones. */
432
+ const cardFolderRoots = () => [...new Set([config.repo, dirname(config.repo), cardReposRoot()])];
453
433
  // Worktrees left by earlier cards — see sweepWorktrees. In the background: nothing waits on it.
454
434
  void (async () => {
455
435
  for (const c of [{ path: config.repo }, ...inScope])
456
436
  await sweepWorktrees(c.path, WORKTREE_IDLE_MS, (t) => log('tool', t)).catch(() => { });
437
+ for (const r of cardFolderRoots())
438
+ await sweepCardFolders(r, WORKTREE_IDLE_MS, (t) => log('tool', t)).catch(() => { });
457
439
  })();
458
440
  // Inside tmux, the history this pane prints is only reachable with tmux's mouse mode on.
459
441
  void ensureTmuxScrollback((t) => log('tool', t)).catch(() => { });
@@ -561,6 +543,8 @@ export async function serve(opts) {
561
543
  if (now !== was && config.trustedScripts.length)
562
544
  log('tool', `trusted scripts: ${config.trustedScripts.map((t) => t.name).join(', ')}`);
563
545
  }
546
+ // Start scripts kept in FlareDeck for the workspace's repositories (harness/start-scripts.ts).
547
+ config.startScripts = installStartScripts(hydrated.startScripts ?? [], join(configDir(), 'start-scripts'));
564
548
  // Skills shared from FlareDeck, kept current the same way. See skills.ts.
565
549
  const { flaredeckSkills, syncSkills } = await import('./skills.js');
566
550
  const plugin = await syncSkills(join(configDir(), 'skills', slug(config.repo)), flaredeckSkills(config.mcpUrl, tenantForMcp, () => opts.auth(), (config.boards ?? []).map((b) => b.key)), (t, warn) => log(warn ? 'warn' : 'tool', t));
@@ -726,42 +710,6 @@ export async function serve(opts) {
726
710
  `your branch and your notes are as you left them. If it does not, say so and ask again ` +
727
711
  `rather than guessing.\n`);
728
712
  };
729
- /**
730
- * A worker saying it is free, recognised by the harness rather than left to the brief.
731
- *
732
- * The message is one the harness writes, so its shape is known and matching it is not
733
- * guesswork. It is turned into an instruction on the turn it arrives, because a standing rule
734
- * in a brief is read once at the start of a long session and is exactly the kind of thing that
735
- * gets skimmed past — and the cost of skimming this one is a worker sitting idle beside a full
736
- * backlog, which is the thing the message exists to prevent.
737
- */
738
- const FREE_WORKER = /(^|\s)(Done with [A-Z][A-Z0-9]*-\d+\.|I am free — nothing queued, nothing running\.)\s*What is next\?/;
739
- const freeWorkerBrief = (event) => {
740
- if (!isLead)
741
- return '';
742
- if (event.event !== 'message' && event.event !== 'mentioned')
743
- return '';
744
- if (!FREE_WORKER.test(`${event.message ?? ''} ${event.line ?? ''}`))
745
- return '';
746
- const who = event.actor || 'A worker';
747
- return (`\n\n## ${who} has nothing to do\n\n` +
748
- `That message is the harness telling you this agent's queue is empty and nothing is running ` +
749
- `on its machine. It is not a status update and it does not need a reply — it needs a card.\n\n` +
750
- `Find it work now, before anything else in this turn:\n` +
751
- ` · \`get-portfolio\` with state "todo" — for you it lists the board's "Start work here" lanes ` +
752
- `AND your pool ("Lead picks from here", usually the Backlog). Your pool is yours to distribute: ` +
753
- `move a card from it into a "Start work here" lane, or assign it, by who is free. Workers can also ` +
754
- `pull from "Start work here" themselves. Never move a card out of any OTHER not-started lane ` +
755
- `(Discovery, a plain Backlog, anything hidden) — what leaves those is a person's decision. A board ` +
756
- `with neither kind of lane has no lane strategy yet: start nothing there, and tell your human.\n` +
757
- ` · \`list_agents\` for what the rest of the team already holds, so you hand it something ` +
758
- `nobody else is part-way through.\n` +
759
- ` · Pick something it can actually run: a repository it has, on a machine you believe is ` +
760
- `working.\n` +
761
- ` · Assign it with \`assign-card\`. That is what wakes it.\n\n` +
762
- `If there is genuinely nothing for it, say so in one line and leave it idle. That is a real ` +
763
- `answer.\n`);
764
- };
765
713
  const park = (cardKey, why) => {
766
714
  if (!cardKey)
767
715
  return;
@@ -792,7 +740,7 @@ export async function serve(opts) {
792
740
  // Until this agent knows who it is (the team read failed), nothing is hidden.
793
741
  const ours = (a) => !selfId
794
742
  || a.sender_key === selfId
795
- || (isLead && (a.review?.by === selfId || (!!a.sender_key && teamIds.has(a.sender_key))));
743
+ || (profile.dispatch && (a.review?.by === selfId || (!!a.sender_key && teamIds.has(a.sender_key))));
796
744
  const mine = all.filter((a) => a.tool_name === SCRIPT_REQUEST && ours(a));
797
745
  state.approvals = mine.map((a) => {
798
746
  const lines = (a.input_preview || '').split('\n');
@@ -924,14 +872,14 @@ export async function serve(opts) {
924
872
  const runsSet = opts.deck
925
873
  ? runTools(config.runs ?? {}, () => onCard, opts.deck, (t) => log('tool', t), {
926
874
  attach: (card, path, comment) => attachFile(card, path, comment),
927
- sendFile: (path, to, message) => sendFile(onCard?.cwd ?? config.repo, path, to || person?.uid || '', message),
875
+ sendFile: (path, to, message) => sendFile(onCard?.cwd ?? config.repo, path, to || chat.person()?.uid || '', message),
928
876
  flag: async (reason, kind, ask, flagOpts) => {
929
877
  const cur = onCard;
930
878
  if (!cur)
931
879
  return null;
932
880
  // A question, from a manager, with its person at this pane: ask FlareDeck to keep it off
933
881
  // their phone. FlareDeck decides whether it is theirs; a script request is never held.
934
- const hold = isManager && !!ask && presence.mode() === 'local';
882
+ const hold = profile.manage && !!ask && presence.mode() === 'local';
935
883
  return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind, ask, undefined, hold, flagOpts?.quiet);
936
884
  },
937
885
  unflag: async (cur) => {
@@ -1040,10 +988,8 @@ export async function serve(opts) {
1040
988
  let leadId = null;
1041
989
  let leadName = '';
1042
990
  const bosses = new Set();
1043
- /** A lead's surface is wider than a worker's, so the tool list is too. */
1044
- let isLead = false;
1045
- /** A manager: above the leads, with no lead of its own. See MANAGER_BRIEF. */
1046
- let isManager = false;
991
+ /** What kind of agent this is: which features are on. Set from the team read; see profile.ts. */
992
+ let profile = PROFILES.worker;
1047
993
  /**
1048
994
  * A worker with nobody above it — no lead and no manager — is its own lead for releasing: it
1049
995
  * merges and promotes its own cards wherever the board says "released by the lead". C3PO, a solo
@@ -1070,23 +1016,18 @@ export async function serve(opts) {
1070
1016
  * from a person. Card work is a worker's turn (its own checkout where the board has code); its
1071
1017
  * person's chat stays one conversation. Releasing is separate: only where a board names it.
1072
1018
  */
1073
- const assistantCard = (e) => isAssistant && (
1074
- // Its own sweep and its own pickups are card work too: worker turns, not the chat.
1075
- (e.event === 'sweep' && e.id === 'sweep')
1076
- || String(e.id ?? '').startsWith('resume-')
1077
- || (!!(e.cardKey || e.cardCode) && (e.assigneeKey === selfId || (e.event === 'assigned' && e.forYou === true))));
1078
- /**
1079
- * One person's own agent: on no team, never dispatched to, listening only to `person`. One long
1080
- * conversation, never handed over — the SDK compacts it — and loose rules. See ASSISTANT_BRIEF.
1081
- */
1082
- let isAssistant = false;
1083
- let person = null;
1084
- /** An assistant's running turn, as its person watches it in FlareDeck chat. See LiveFeed. */
1085
- let liveFeed = null;
1019
+ const assistantCard = (e) => profile.chat && isOwnCardWork(e, selfId);
1086
1020
  const assistantSessionFile = join(configDir(), 'assistant-sessions', `${slug(`${opts.agent ?? 'agent'}-${config.repo}`)}.json`);
1021
+ /** The conversation with the person it works for (the `chat` feature): see features/chat.ts. */
1022
+ const chat = createChat({
1023
+ kind: () => (profile.kind === 'general' ? 'general' : 'assistant'), mcpUrl: config.mcpUrl, auth: () => opts.auth(), repo: config.repo, sessionFile: assistantSessionFile,
1024
+ agent: (opts.agent ?? 'your assistant').replace(/\s*\(.*$/, ''),
1025
+ send: async (to, message) => { await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'send-message', { to, message }); },
1026
+ log,
1027
+ });
1087
1028
  /** The mode and the held questions, as the pane draws them. */
1088
1029
  const showPresence = () => {
1089
- if (!isManager) {
1030
+ if (!profile.manage) {
1090
1031
  delete state.presence;
1091
1032
  delete state.asks;
1092
1033
  return;
@@ -1095,11 +1036,9 @@ export async function serve(opts) {
1095
1036
  const until = presence.deadline();
1096
1037
  state.asks = presence.held().map((a) => ({ card: a.code || a.card, question: a.question, options: a.options, person: a.person, ...(until ? { until } : {}) }));
1097
1038
  };
1098
- const keepTool = (name) => offeredTo(isManager || isAssistant ? 'manager' : isLead ? 'lead' : 'worker', name);
1099
- /** Cards already handed to the lead, so a retried tool call does not assign or message twice. */
1100
- const handedBack = new Set();
1101
- /** The branch head when each card was handed to the lead: unchanged, a card given back is the lead's "move it". */
1102
- const handedBackHead = new Map();
1039
+ const keepTool = (name) => offeredTo(profile.tools, name);
1040
+ /** Cards handed to the lead for review, and the branch head then. See features/hand-back.ts. */
1041
+ const handedBack = handBackMemory();
1103
1042
  try {
1104
1043
  const team = await fetchTeam(opts.baseUrl, await opts.auth());
1105
1044
  // Who is an agent, so a parked card can tell a person answering from agents talking shop.
@@ -1130,25 +1069,15 @@ export async function serve(opts) {
1130
1069
  bosses.add(a.name.trim().toLowerCase());
1131
1070
  if (leadId)
1132
1071
  log('tool', `your lead is ${leadName} — ${leadName} verifies your work before it goes anywhere`);
1133
- isLead = team.you.role === 'lead';
1134
- // The role exists in FlareDeck before the harness has a manager mode (docs/spec-manager-role.md
1135
- // §14). Said plainly, so nobody reads a manager account running here as a manager at work.
1136
- isManager = team.you.role === 'manager';
1137
- isAssistant = team.you.role === 'assistant';
1138
- if (isAssistant) {
1072
+ profile = profileFor(team.you.role);
1073
+ if (profile.chat) {
1139
1074
  state.waitingFor = undefined;
1140
- const tenant = tenantFromToken(await opts.auth());
1141
- person = tenant ? await assistantPerson(config.mcpUrl, tenant, () => opts.auth()) : null;
1142
- liveFeed = tenant && person ? new LiveFeed(liveSender(config.mcpUrl, tenant, () => opts.auth())) : null;
1143
- const kept = loadAssistantSession(assistantSessionFile);
1144
- if (person)
1145
- state.waitingFor = person.name;
1146
- log('event', person
1147
- ? `You are ${person.name}'s assistant — you listen to them in FlareDeck chat, and to whoever types here${kept ? ', carrying on your conversation from before' : ''}.`
1148
- : 'You are an assistant, but nobody is set as the person you work for — set "Works for" on this agent in FlareDeck. Until then nothing reaches you.');
1075
+ await chat.start(tenantFromToken(await opts.auth()));
1076
+ if (chat.person())
1077
+ state.waitingFor = chat.person().name;
1149
1078
  }
1150
1079
  showPresence();
1151
- if (isManager) {
1080
+ if (profile.manage) {
1152
1081
  log('event', 'You are a manager here — list-agents shows the teams that report to you and the machines they run on; machine-exec reaches those machines.');
1153
1082
  log('event', 'Your team\'s technical, access and information questions come to you first; what you cannot settle goes to the person the board names.');
1154
1083
  log('event', 'At this pane? Type /local and questions for you are shown here instead of on your phone (back to /remote after ten minutes unanswered).');
@@ -1167,9 +1096,8 @@ export async function serve(opts) {
1167
1096
  if (soloWorker)
1168
1097
  log('tool', 'nobody is above you, so you release your own cards where the board says "released by the lead"');
1169
1098
  releaseSet = releaseTools({
1170
- role: () => (isManager ? 'manager' : isLead || soloWorker ? 'lead' : isAssistant ? 'assistant' : 'worker'),
1099
+ role: () => releaseRole(profile, soloWorker),
1171
1100
  boards: () => config.boards ?? [],
1172
- ladder: (boardId) => fetchLadder(config.mcpUrl, tenant, boardId, () => opts.auth()),
1173
1101
  job: () => (job ? { branch: job.branch, cwd: job.cwd, card: onCard?.card ?? job.card, ...(onCard?.code ? { code: onCard.code } : {}), ...(onCard?.board ? { board: onCard.board } : {}) } : null),
1174
1102
  token: async () => {
1175
1103
  if (flaredeckGithub) {
@@ -1180,48 +1108,10 @@ export async function serve(opts) {
1180
1108
  throw new Error('FlareDeck has no GitHub connection to release with — connect GitHub on Integrations.');
1181
1109
  },
1182
1110
  root: join(configDir(), 'release'),
1183
- approve: async (card, who, what) => {
1184
- const approvers = [who === 'person' ? self.person : self.manager].filter((x) => !!x);
1185
- if (!approvers.length)
1186
- return { allow: false, reason: `nobody is set as your ${who === 'person' ? 'person (Works for / reports to)' : 'manager'} in FlareDeck to approve it` };
1187
- const ask = flaredeckDecider(deck, card, 60 * 60_000, { approvers });
1188
- const v = await ask({ toolName: 'release', input: what }, { signal: new AbortController().signal });
1189
- return v.allow ? { allow: true } : { allow: false, reason: v.reason };
1190
- },
1191
- originOf: (cwd) => new Promise((res) => {
1192
- const c = spawn('git', ['remote', 'get-url', 'origin'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
1193
- let out = '';
1194
- c.stdout.on('data', (d) => (out += String(d)));
1195
- c.on('close', () => res(out.trim()));
1196
- c.on('error', () => res(''));
1197
- }),
1198
1111
  log: (t) => log('tool', t),
1199
1112
  self: () => selfId ?? '',
1200
- cardInfo: async (code) => {
1201
- const c = await deck.card(code).catch(() => null);
1202
- if (!c)
1203
- return null;
1204
- const since = c.release_since ? Date.parse(String(c.release_since)) : NaN;
1205
- return {
1206
- laneType: c.lane?.type ?? null,
1207
- minutesInReleaseLane: String(c.lane?.type || '').toLowerCase() === 'release' && Number.isFinite(since) ? (Date.now() - since) / 60_000 : null,
1208
- releases: (Array.isArray(c.releases) ? c.releases : []).map((r) => ({ environment: String(r.environment ?? ''), sha: String(r.sha ?? '') })),
1209
- };
1210
- },
1211
- record: async (card, text) => { await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'create-card-comment', { card_id: card, comment: text }); },
1212
- recordReleased: async (o) => {
1213
- const res = await fetch(`${new URL(config.mcpUrl).origin}/api/v1/${encodeURIComponent(tenant)}/cards/released`, {
1214
- method: 'POST', headers: { authorization: `Bearer ${await opts.auth()}`, 'content-type': 'application/json' },
1215
- body: JSON.stringify(o), signal: AbortSignal.timeout(20_000),
1216
- });
1217
- return res.ok ? (await res.json()) : null;
1218
- },
1219
- watch: async (card) => {
1220
- await fetch(`${new URL(config.mcpUrl).origin}/api/v1/${encodeURIComponent(tenant)}/deploy-watch`, {
1221
- method: 'POST', headers: { authorization: `Bearer ${await opts.auth()}`, 'content-type': 'application/json' },
1222
- body: JSON.stringify({ card_key: card }), signal: AbortSignal.timeout(15_000),
1223
- });
1224
- },
1113
+ // The ladder, approvals, card state and release records, from FlareDeck: see features/release-wiring.ts.
1114
+ ...flaredeckReleaseDeps({ mcpUrl: config.mcpUrl, tenant, auth: () => opts.auth(), deck, approvers: self }),
1225
1115
  });
1226
1116
  }
1227
1117
  }
@@ -1264,28 +1154,6 @@ export async function serve(opts) {
1264
1154
  * Polled rather than pushed, because nothing streams another agent's status — and slowly,
1265
1155
  * since agents check in every five minutes, so a faster poll would show the same row again.
1266
1156
  */
1267
- /**
1268
- * An idle worker is the lead's problem, and the lead already knows within a minute.
1269
- *
1270
- * It polls its team every sixty seconds to draw the pane, so "CASE idle 4m ago" is sitting on
1271
- * its screen — and it did nothing with it. Dispatch waited for the hourly sweep, or for the
1272
- * worker to say it was free, which for a while it never did. Eleven cards waited in Ready For
1273
- * Dev while two agents sat idle and the lead reported "waiting for work".
1274
- *
1275
- * The poll is already happening, so noticing costs nothing. Rate-limited because an idle
1276
- * worker with genuinely nothing to give it stays idle, and a lead asked about it every minute
1277
- * learns to say "nothing" without looking.
1278
- */
1279
- const DISPATCH_AGAIN_MS = 10 * 60 * 1000;
1280
- let lastDispatch = 0;
1281
- const workersAreIdle = (team) =>
1282
- // Not 'never-seen': a member that has never connected (FRIDAY is a person's own session) is
1283
- // not a free worker, and counting one kept the team permanently "idle".
1284
- myTeam(team).mine.filter((a) => !a.card && a.state !== 'stopped' && a.state !== 'never-seen' && !a.stale).map((a) => a.name);
1285
- let lastDispatchKey = '';
1286
- /** The ready cards the lead was last woken to look at for a free worker. See drain(). */
1287
- let lastFreeLook = { key: '', at: 0 };
1288
- let lastSkipWhy = '';
1289
1157
  /**
1290
1158
  * What is ready to hand over, read in code — no model turn to find out it is nothing. Unreadable
1291
1159
  * gives ['?'], so callers wake as before: a missed dispatch is worse than a spare turn.
@@ -1298,31 +1166,17 @@ export async function serve(opts) {
1298
1166
  const text = out === null ? null : typeof out === 'string' ? out : JSON.stringify(out);
1299
1167
  return text === null ? ['?'] : unownedCards(text);
1300
1168
  };
1169
+ // The `dispatch` feature: whether a free worker is worth waking the lead for. See features/dispatch.ts.
1170
+ const dispatch = createDispatch({ ready: readyToHand, log });
1301
1171
  if (leadOfTeam) {
1302
1172
  const teamTimer = setInterval(() => {
1303
1173
  void (async () => {
1304
1174
  try {
1305
1175
  const team = await fetchTeam(opts.baseUrl, await opts.auth());
1306
1176
  showTeam(team);
1307
- const free = workersAreIdle(team);
1308
- if (free.length && !working && !queue.length && Date.now() - lastDispatch > DISPATCH_AGAIN_MS) {
1309
- lastDispatch = Date.now();
1310
- const ready = await readyToHand();
1311
- const d = dispatchDecision(lastDispatchKey, ready, free);
1312
- if (!d.wake) {
1313
- if (d.why !== lastSkipWhy)
1314
- log('tool', `${free.join(', ')} idle — not waking the lead: ${d.why}`);
1315
- lastSkipWhy = d.why;
1316
- lastDispatchKey = d.key;
1317
- }
1318
- else {
1319
- lastDispatchKey = d.key;
1320
- lastSkipWhy = '';
1321
- log('event', `${free.join(', ')} ${free.length === 1 ? 'is' : 'are'} idle — looking for work to hand over`);
1322
- if (enqueue({ id: `dispatch-${Date.now()}`, event: 'sweep', line: `${free.join(', ')} have nothing to do` }))
1323
- void drain();
1324
- }
1325
- }
1177
+ const wake = await dispatch.onTeam(team, working || queue.length > 0);
1178
+ if (wake && enqueue({ id: `dispatch-${Date.now()}`, event: 'sweep', line: wake }))
1179
+ void drain();
1326
1180
  }
1327
1181
  catch {
1328
1182
  // A team the lead briefly cannot see is not worth a line in the log every minute; the
@@ -1368,6 +1222,8 @@ export async function serve(opts) {
1368
1222
  for (const c of [{ path: config.repo }, ...inScope]) {
1369
1223
  await sweepWorktrees(c.path, WORKTREE_IDLE_MS, (t) => log('tool', t), activeTrees()).catch(() => { });
1370
1224
  }
1225
+ for (const r of cardFolderRoots())
1226
+ await sweepCardFolders(r, WORKTREE_IDLE_MS, (t) => log('tool', t), activeTrees()).catch(() => { });
1371
1227
  })();
1372
1228
  }, WORKTREE_SWEEP_EVERY_MS);
1373
1229
  worktreeTimer.unref();
@@ -1378,12 +1234,6 @@ export async function serve(opts) {
1378
1234
  setTimeout(sweepTemp, 5 * 60 * 1000).unref();
1379
1235
  const scratchTimer = setInterval(sweepTemp, SCRATCH_SWEEP_EVERY_MS);
1380
1236
  scratchTimer.unref();
1381
- /**
1382
- * Paused: a wall this agent cannot get past by itself, so it does no work at all — no turns,
1383
- * no tokens — until its lead resumes it. See flaredeck-mcp/src/mcp/pause.ts for the why.
1384
- */
1385
- let paused = null;
1386
- let readyReported = false;
1387
1237
  /** Repositories already reported as unpublishable, so each is said once. */
1388
1238
  const warnedRepos = new Set();
1389
1239
  /** Walls the agent cannot climb, checked in code — never by asking the model. */
@@ -1434,6 +1284,8 @@ export async function serve(opts) {
1434
1284
  const free = freeDiskBytes(config.repo);
1435
1285
  if (free !== null && free < DISK_MIN) {
1436
1286
  await sweepWorktrees(config.repo, WORKTREE_IDLE_LOW_DISK_MS, (t) => log('tool', t), activeTrees()).catch(() => { });
1287
+ for (const r of cardFolderRoots())
1288
+ await sweepCardFolders(r, WORKTREE_IDLE_LOW_DISK_MS, (t) => log('tool', t), activeTrees()).catch(() => { });
1437
1289
  const after = freeDiskBytes(config.repo);
1438
1290
  if (after !== null && after < DISK_MIN) {
1439
1291
  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).` };
@@ -1441,103 +1293,28 @@ export async function serve(opts) {
1441
1293
  }
1442
1294
  return null;
1443
1295
  };
1444
- const enterPause = async (wall) => {
1445
- if (paused?.check === wall.check)
1446
- return;
1447
- paused = wall;
1448
- readyReported = false;
1449
- state.paused = wall.reason;
1450
- log('warn', `PAUSED (${wall.check}): ${wall.reason}`);
1451
- void liveRun?.stop().catch(() => { });
1452
- try {
1453
- const r = await opts.deck?.pause(wall.check, wall.reason);
1454
- /* A solo agent has no lead: its cards stay with it (each gets a comment) and nobody is
1455
- messaged. Saying "told the lead, handed back …" then described something that never
1456
- happened. */
1457
- if (r && !r.already) {
1458
- log('event', r.lead
1459
- ? `told the lead${r.handedBack.length ? `, handed back ${r.handedBack.join(', ')}` : ''} — doing nothing until resumed`
1460
- : `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.'}`);
1461
- }
1462
- }
1463
- catch (e) {
1464
- log('warn', `could not tell FlareDeck I am paused: ${e instanceof Error ? e.message : String(e)}`);
1465
- }
1466
- };
1467
- // While paused: ask FlareDeck every minute whether the lead has resumed this agent (one REST
1468
- // call, no model), and re-check the wall every ten, saying once when it is gone.
1469
- let lastWallCheck = 0;
1470
- const pauseTick = setInterval(() => {
1471
- if (!paused)
1472
- return;
1473
- void (async () => {
1474
- const server = await opts.deck?.pauseState().catch(() => undefined);
1475
- if (server === null) {
1476
- const still = await walls();
1477
- if (still) {
1478
- paused = null;
1479
- await enterPause(still);
1480
- return;
1481
- }
1482
- log('event', `resumed by the lead — back to work`);
1483
- paused = null;
1484
- state.paused = undefined;
1485
- void resumeOwnWork();
1486
- return;
1487
- }
1488
- /* Disk is a statfs call, so it is checked every tick; the others ask GitHub, so every ten
1489
- minutes. A disk wall that has cleared resumes the agent at once — there is nothing for a
1490
- lead to decide about a number, and waiting for one left the agent idle for hours after
1491
- the space had already been freed. */
1492
- const selfClearing = paused.check === 'disk' || paused.check === 'mcp';
1493
- if (selfClearing) {
1494
- let cleared = '';
1495
- if (paused.check === 'disk') {
1496
- const free = freeDiskBytes(config.repo);
1497
- if (free === null || free < DISK_MIN)
1498
- return;
1499
- cleared = `disk is clear (${(free / 1e9).toFixed(0)} GB free)`;
1500
- }
1501
- else {
1502
- // One HTTP call a minute while paused; null (FlareDeck unreachable) is not "connected".
1503
- const missing = await refreshMcp();
1504
- if (missing === null || missing.length)
1505
- return;
1506
- cleared = 'the required MCP servers are connected';
1507
- }
1508
- const r = await opts.deck?.resumeSelf().catch(() => null);
1509
- if (r?.resumed) {
1510
- log('event', `${cleared} — resumed myself and told the lead`);
1511
- paused = null;
1512
- state.paused = undefined;
1513
- readyReported = false;
1514
- void resumeOwnWork();
1515
- return;
1516
- }
1517
- // An older server without self-resume: fall through to telling the lead, as before.
1518
- }
1519
- if ((!selfClearing && Date.now() - lastWallCheck < 10 * 60 * 1000) || readyReported)
1520
- return;
1521
- lastWallCheck = Date.now();
1522
- if (!(await walls())) {
1523
- // No lead to resume it (a solo agent, an assistant): it resumes itself, as for disk.
1524
- if (!leadId) {
1525
- const r = await opts.deck?.resumeSelf().catch(() => null);
1526
- if (r?.resumed) {
1527
- log('event', `${paused.check} is fixed — resumed myself (no lead to ask)`);
1528
- paused = null;
1529
- state.paused = undefined;
1530
- readyReported = false;
1531
- void resumeOwnWork();
1532
- return;
1533
- }
1534
- }
1535
- readyReported = true;
1536
- log('event', `${paused.check} is fixed — told the lead; waiting to be resumed`);
1537
- await opts.deck?.ready().catch(() => { });
1296
+ // Paused on a wall it cannot get past by itself: see harness/pause.ts.
1297
+ const pause = createPause({
1298
+ record: opts.deck,
1299
+ walls,
1300
+ cleared: async (check) => {
1301
+ if (check === 'disk') {
1302
+ const free = freeDiskBytes(config.repo);
1303
+ return free === null || free < DISK_MIN ? null : `disk is clear (${(free / 1e9).toFixed(0)} GB free)`;
1538
1304
  }
1539
- })();
1540
- }, 60 * 1000);
1305
+ // One HTTP call a minute while paused; null (FlareDeck unreachable) is not "connected".
1306
+ const missing = await refreshMcp();
1307
+ return missing === null || missing.length ? null : 'the required MCP servers are connected';
1308
+ },
1309
+ hasLead: () => !!leadId,
1310
+ stopRun: () => void liveRun?.stop().catch(() => { }),
1311
+ show: (reason) => { state.paused = reason; },
1312
+ // Declared further down; by the time a pause clears, it is.
1313
+ resume: () => setTimeout(() => void resumeOwnWork(), 0),
1314
+ log,
1315
+ });
1316
+ const enterPause = pause.enter;
1317
+ const pauseTick = setInterval(() => void pause.tick(), 60 * 1000);
1541
1318
  pauseTick.unref?.();
1542
1319
  /**
1543
1320
  * This turn's browser, so it can be closed when the turn ends.
@@ -1640,6 +1417,76 @@ export async function serve(opts) {
1640
1417
  const idleStops = new Map();
1641
1418
  /** Environments this process started and has not stopped, by checkout — reused by later turns. */
1642
1419
  const envUp = new Map();
1420
+ /** How each of those was started, so it is stopped with the same script (a stored one has no file in the checkout). */
1421
+ const envHow = new Map();
1422
+ const stopEnv = (p) => { const h = envHow.get(p); envHow.delete(p); return stopEnvironment(p, h?.declared, h?.stored); };
1423
+ /** The checkouts in the same card folder as `p` (itself included) — one card's environment. */
1424
+ const sameCard = (a, b) => a === b || (!!folderOf(a) && folderOf(a) === folderOf(b));
1425
+ /** What a card folder's other repositories said when they came up, for a turn that reuses them. */
1426
+ const companionNotes = new Map();
1427
+ /** What cards need running beside them, shared between cards (features/services.ts). */
1428
+ const services = createServices({
1429
+ root: join(configDir(), 'services'),
1430
+ git: (args, cwd) => runGit(args, cwd),
1431
+ token: async () => { if (flaredeckGithub)
1432
+ await refreshMcp(); return flaredeckGithub?.token ?? null; },
1433
+ storedScript: (repository) => storedStartScript(config.startScripts, repository),
1434
+ start: startEnvironment,
1435
+ stop: stopEnvironment,
1436
+ log,
1437
+ });
1438
+ /** Keep a start script in FlareDeck for a repository (Integrations → Repositories). */
1439
+ const saveStartScriptFor = async (repository, script) => {
1440
+ const tid = tenantFromToken(await opts.auth());
1441
+ const id = repoIdentity(repository).split('/').slice(-2).map(encodeURIComponent).join('/');
1442
+ const r = await fetch(`${new URL(config.mcpUrl).origin}/api/v1/${encodeURIComponent(tid ?? '')}/repositories/${id}/start`, {
1443
+ method: 'PUT', headers: { authorization: `Bearer ${await opts.auth()}`, 'content-type': 'application/json' },
1444
+ body: JSON.stringify({ script }), signal: AbortSignal.timeout(20_000),
1445
+ });
1446
+ const body = await r.json().catch(() => ({}));
1447
+ return r.ok ? { ok: true } : { ok: false, message: body.message ?? `HTTP ${r.status}` };
1448
+ };
1449
+ /** …and use it on this machine at once, rather than from the next time FlareDeck's list is read. */
1450
+ const keepStartScript = (repository, script) => {
1451
+ const dir = join(configDir(), 'start-scripts');
1452
+ const path = join(dir, `${repoIdentity(repository).replace(/\//g, '__').replace(/[^\w.-]/g, '_')}.sh`);
1453
+ try {
1454
+ mkdirSync(dir, { recursive: true });
1455
+ writeFileSync(path, script, { mode: 0o755 });
1456
+ config.startScripts = { ...(config.startScripts ?? {}), [repoIdentity(repository)]: path };
1457
+ return path;
1458
+ }
1459
+ catch {
1460
+ return undefined;
1461
+ }
1462
+ };
1463
+ /** Bring up a card folder's other repositories, each from its worktree, and keep track of them. */
1464
+ const startFolder = async (cs, environment) => {
1465
+ const r = await startCompanions(cs, environment, { start: startEnvironment, storedScript: (repo) => storedStartScript(config.startScripts, repo), log: (t) => log('tool', t) });
1466
+ for (const c of cs) {
1467
+ const st = r.started.get(c.path);
1468
+ if (!st)
1469
+ continue;
1470
+ const stored = storedStartScript(config.startScripts, c.repository);
1471
+ envUp.set(c.path, st);
1472
+ envHow.set(c.path, { ...(c.script ? { declared: c.script } : {}), ...(stored ? { stored } : {}) });
1473
+ }
1474
+ for (const n of r.notes)
1475
+ log('tool', `folder: ${n}`);
1476
+ return { env: r.env, notes: r.notes };
1477
+ };
1478
+ /** A clone to cut a card folder's worktree from: a checkout here first, else one cloned for it. */
1479
+ const sourceFor = async (repository) => {
1480
+ const id = repoIdentity(repository);
1481
+ for (const root of [config.repo, cardReposRoot()]) {
1482
+ const hit = (await checkouts(root).catch(() => [])).find((c) => c.origin && repoIdentity(c.origin) === id);
1483
+ if (hit)
1484
+ return hit.path;
1485
+ }
1486
+ return services.clone(repository);
1487
+ };
1488
+ const servicesTimer = setInterval(() => void services.stopIdle(), 15 * 60_000);
1489
+ servicesTimer.unref();
1643
1490
  const ENV_IDLE_MS = 20 * 60_000;
1644
1491
  /**
1645
1492
  * Checkouts with a sign-in still waiting for a person, and since when. Their environment is
@@ -1657,7 +1504,8 @@ export async function serve(opts) {
1657
1504
  */
1658
1505
  const stopOtherEnvironments = async (cwd) => {
1659
1506
  for (const other of [...envUp.keys()]) {
1660
- if (other === cwd)
1507
+ // The card's own folder is one environment: its API and CMS are what its site is using.
1508
+ if (sameCard(other, cwd))
1661
1509
  continue;
1662
1510
  const waiting = signInPending.get(other);
1663
1511
  if (waiting && Date.now() - waiting < SIGN_IN_HOLD_MS)
@@ -1668,7 +1516,7 @@ export async function serve(opts) {
1668
1516
  idleStops.delete(other);
1669
1517
  }
1670
1518
  envUp.delete(other);
1671
- const ok = await stopEnvironment(other).catch(() => false);
1519
+ const ok = await stopEnv(other).catch(() => false);
1672
1520
  if (ok)
1673
1521
  log('tool', `environment stopped in ${other.split('/').pop()} — another card's, and this card needs its ports`);
1674
1522
  }
@@ -1687,8 +1535,13 @@ export async function serve(opts) {
1687
1535
  scheduleEnvironmentStop(cwd);
1688
1536
  return;
1689
1537
  }
1538
+ // The whole card folder comes down together: nothing else uses its API and CMS.
1539
+ for (const p of [...envUp.keys()].filter((k) => k !== cwd && sameCard(k, cwd))) {
1540
+ envUp.delete(p);
1541
+ void stopEnv(p).catch(() => false);
1542
+ }
1690
1543
  envUp.delete(cwd);
1691
- void stopEnvironment(cwd).then((ok) => { if (ok)
1544
+ void stopEnv(cwd).then((ok) => { if (ok)
1692
1545
  log('tool', `environment stopped in ${cwd.split('/').pop()} (idle 20m)`); }).catch(() => { });
1693
1546
  }, ENV_IDLE_MS));
1694
1547
  idleStops.get(cwd).unref?.();
@@ -1720,68 +1573,23 @@ export async function serve(opts) {
1720
1573
  });
1721
1574
  });
1722
1575
  };
1723
- /** Nobody answered here: the held question goes to the phone, as it stands on the card. */
1724
- const sendOn = async (a) => {
1725
- const label = a.code || a.card;
1726
- try {
1727
- const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'set-needs-human', {
1728
- card_id: a.code || a.card, ...(a.board ? { board_id: a.board } : {}), push_held: true,
1729
- });
1730
- log('warn', `${label}: ${out.split('\n').find((l) => l.startsWith('**Told**'))?.replace(/\*\*/g, '') ?? out.split('\n')[0]}`);
1731
- }
1732
- catch (e) {
1733
- log('warn', `${label}: could not send the question on to ${a.person}'s phone (${e instanceof Error ? e.message : String(e)}) — it is still on the card`);
1734
- }
1735
- };
1736
- const setMode = (mode) => {
1737
- if (!isManager) {
1738
- log('warn', '/local and /remote are for a manager\'s pane: they decide where questions for the person the manager reports to are shown.');
1739
- return;
1740
- }
1741
- presence.set(mode);
1742
- if (mode === 'local') {
1743
- log('you', 'local — questions for you are shown here and kept off your phone. One left unanswered for ten minutes goes to your phone, and this goes back to remote.');
1744
- }
1745
- else {
1746
- const waiting = presence.release();
1747
- log('you', `remote — questions go to FlareDeck and your phone${waiting.length ? `, including the ${waiting.length} waiting here` : ''}.`);
1748
- for (const a of waiting)
1749
- void sendOn(a);
1750
- }
1751
- showPresence();
1752
- };
1753
- /**
1754
- * Answered at the pane. Written on the card first — an answer that exists only in a terminal is
1755
- * one nobody else can find — then the flag comes off and the card goes back to the agent.
1756
- */
1757
- const answerHeld = async (a, answer) => {
1758
- presence.take(a.card);
1759
- showPresence();
1760
- const label = a.code || a.card;
1761
- log('you', `${label}: ${answer}`);
1762
- const ref = { card_id: a.code || a.card, ...(a.board ? { board_id: a.board } : {}) };
1763
- try {
1764
- await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'create-card-comment', {
1765
- ...ref, comment: `**${a.person} answered at ${state.agent}'s pane.**\n\n> ${a.question}\n\n${answer}`,
1766
- });
1767
- await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'clear-needs-human', ref);
1768
- }
1769
- catch (e) {
1770
- log('warn', `${label}: the answer could not be written to the card (${e instanceof Error ? e.message : String(e)}) — the agent still gets it`);
1771
- }
1772
- parked.delete(a.card);
1773
- resumeParked({ card: a.card, ...(a.board ? { board: a.board } : {}), ...(a.code ? { code: a.code } : {}) }, `${a.person} answered your question at this pane.\n\nYou asked: ${a.question}\nThe answer: ${answer}\n\nIt is on the card as a comment and the flag is cleared. Carry on from where you stopped.`);
1774
- };
1775
- const presenceTimer = setInterval(() => {
1776
- const due = presence.expire();
1777
- if (due.length) {
1778
- log('warn', `nobody answered here in ten minutes — ${due.length === 1 ? 'that question has' : `${due.length} questions have`} gone to your phone, and this pane is remote again. /local when you are back.`);
1779
- for (const a of due)
1780
- void sendOn(a);
1781
- }
1782
- if (due.length || state.asks?.length)
1783
- showPresence();
1784
- }, 15_000);
1576
+ // A manager's person answering at this pane: see features/manage-pane.ts.
1577
+ const pane = managePane({
1578
+ presence,
1579
+ manages: () => profile.manage,
1580
+ call: async (tool, args) => String(await callFlaredeckTool(config.mcpUrl, await opts.auth(), tool, args)),
1581
+ resume: (a, text) => {
1582
+ parked.delete(a.card);
1583
+ resumeParked({ card: a.card, ...(a.board ? { board: a.board } : {}), ...(a.code ? { code: a.code } : {}) }, text);
1584
+ },
1585
+ show: showPresence,
1586
+ agent: () => state.agent,
1587
+ log,
1588
+ });
1589
+ const sendOn = pane.sendOn;
1590
+ const setMode = pane.setMode;
1591
+ const answerHeld = pane.answer;
1592
+ const presenceTimer = setInterval(() => pane.tick(), 15_000);
1785
1593
  presenceTimer.unref();
1786
1594
  const steer = (text) => {
1787
1595
  // Mid sign-in, the next line is the redirect address — for the sign-in, never the agent.
@@ -1943,7 +1751,7 @@ export async function serve(opts) {
1943
1751
  if (pending.has(event.id))
1944
1752
  return false;
1945
1753
  // Workers only: a lead's or manager's sweep also finds cards nobody holds, and those send no event.
1946
- if (event.event === 'sweep' && !isLead && !isManager) {
1754
+ if (event.event === 'sweep' && !profile.oversees) {
1947
1755
  if (sweptSinceLastEvent)
1948
1756
  return false;
1949
1757
  sweptSinceLastEvent = true;
@@ -2012,12 +1820,21 @@ export async function serve(opts) {
2012
1820
  const prepareAhead = (event) => {
2013
1821
  if (!wantsIntake(event) || preparedAhead.has(event.id))
2014
1822
  return;
1823
+ const label = event.cardCode || event.cardKey || '';
1824
+ /* One read per CARD, not per event: a comment and an assignment on HR-1140 arriving together
1825
+ each read the card, its Jam and its code, and each paid Jev (21 Oct). The second shares the
1826
+ first's — it is the same card, read the same moment. */
1827
+ const same = [...preparedAhead.entries()].find(([id, p]) => id !== event.id && p.card === (event.cardKey || event.cardCode) && Date.now() - p.at < PREPARED_FRESH_MS);
1828
+ if (same) {
1829
+ preparedAhead.set(event.id, same[1]);
1830
+ return;
1831
+ }
2015
1832
  if (preparing >= PREPARE_AHEAD_LIMIT)
2016
1833
  return;
2017
- const label = event.cardCode || event.cardKey || '';
2018
1834
  preparing++;
2019
1835
  preparedAhead.set(event.id, {
2020
1836
  at: Date.now(),
1837
+ card: event.cardKey || event.cardCode,
2021
1838
  // The repo this card belongs to, so the code search behind the brief searches the right
2022
1839
  // project — at a workspace root there is no git to grep, and it would silently find nothing.
2023
1840
  promise: (async () => {
@@ -2119,46 +1936,10 @@ export async function serve(opts) {
2119
1936
  await measurePlan(subs, plan, true);
2120
1937
  };
2121
1938
  void measureAll();
2122
- // The walls, checked once at start — before any card is picked up. A restart while paused
2123
- // picks the pause back up from FlareDeck rather than re-announcing it.
2124
- void (async () => {
2125
- const server = await opts.deck?.pauseState().catch(() => undefined);
2126
- if (server) {
2127
- paused = { check: server.check, reason: server.reason };
2128
- readyReported = !!server.ready;
2129
- state.paused = server.reason;
2130
- log('warn', `PAUSED since ${server.since.slice(0, 16).replace('T', ' ')} (${server.check}) — doing nothing until the lead resumes me`);
2131
- /* Checked again now, not in ten minutes: a restart is often BECAUSE somebody fixed it. C3PO
2132
- came back up with GitHub access sorted and still sat paused, waiting for a lead it does
2133
- not have (16 Oct). Fixed and no lead: resume. Fixed with a lead: tell it, once. */
2134
- const still = await walls();
2135
- if (!still) {
2136
- lastWallCheck = Date.now();
2137
- if (!leadId) {
2138
- const r = await opts.deck?.resumeSelf().catch(() => null);
2139
- if (r?.resumed) {
2140
- log('event', `${server.check} is fixed — resumed myself (no lead to ask)`);
2141
- paused = null;
2142
- state.paused = undefined;
2143
- readyReported = false;
2144
- setTimeout(() => void resumeOwnWork(), 0); // declared further down
2145
- return;
2146
- }
2147
- }
2148
- if (!readyReported) {
2149
- readyReported = true;
2150
- log('event', `${server.check} is fixed — told the lead; waiting to be resumed`);
2151
- await opts.deck?.ready().catch(() => { });
2152
- }
2153
- }
2154
- return;
2155
- }
2156
- const wall = await walls();
2157
- if (wall)
2158
- await enterPause(wall);
2159
- })();
1939
+ // The walls, checked once at start — before any card is picked up (harness/pause.ts).
1940
+ void pause.start();
2160
1941
  const sweepNow = () => {
2161
- if (paused || !sweepsEnabled || stopping)
1942
+ if (pause.paused() || !sweepsEnabled || stopping)
2162
1943
  return;
2163
1944
  // Busy is reason enough to wait: the next tick is minutes away and the work is not going
2164
1945
  // anywhere.
@@ -2187,7 +1968,7 @@ export async function serve(opts) {
2187
1968
  }
2188
1969
  if (working || queue.length || live.size)
2189
1970
  return;
2190
- const only = isAssistant ? ' — only the cards people have given you; nothing from the pile, and anything else is your person\'s to direct' : '';
1971
+ const only = profile.cards === 'from-people' ? ' — only the cards people have given you; nothing from the pile, and anything else is your person\'s to direct' : '';
2191
1972
  if (enqueue({ id: 'sweep', event: 'sweep', line: `checking what is still assigned to you${only}` }))
2192
1973
  void drain();
2193
1974
  })();
@@ -2244,7 +2025,7 @@ export async function serve(opts) {
2244
2025
  const resumeOwnWork = async () => {
2245
2026
  /* Not `live.size`: a session kept WARM for a follow-up is a cache, not work, and blocking on
2246
2027
  it meant the card in progress was never resumed within fifteen minutes of any turn. */
2247
- if (paused || !sweepsEnabled || stopping || working || queue.length)
2028
+ if (pause.paused() || !sweepsEnabled || stopping || working || queue.length)
2248
2029
  return false;
2249
2030
  /* FlareDeck decides which card, never the model and never this file (Cobus, 16 Oct): its own
2250
2031
  card in progress first, then its own highest-priority card, then the pile — each by
@@ -2259,6 +2040,9 @@ export async function serve(opts) {
2259
2040
  // Without this FlareDeck offered the same one every time — JARVIS, 16 Oct, "picking HR-0904
2260
2041
  // back up … waiting on a person — left alone" round and round, never reaching another card.
2261
2042
  ...(skipNow().length ? { skip: skipNow() } : {}),
2043
+ // Only its own cards unless the profile takes from the pile. FlareDeck also refuses an
2044
+ // assistant the pile by role; this says it for any kind of agent the harness runs.
2045
+ ...(profile.pile ? {} : { pile: false }),
2262
2046
  }) ?? '');
2263
2047
  }
2264
2048
  catch {
@@ -2280,7 +2064,7 @@ export async function serve(opts) {
2280
2064
  return false;
2281
2065
  // A lead resumes its own card in progress and nothing else: a card on it in a start lane is its
2282
2066
  // to HAND OUT (16 Oct), and one from the pile is a worker's. JARVIS was about to be given HR-0580.
2283
- if ((isLead || isManager) && kind !== 'doing')
2067
+ if (profile.oversees && kind !== 'doing')
2284
2068
  return false;
2285
2069
  if (kind === 'took') {
2286
2070
  // Assigned just now: FlareDeck's "assigned" event brings it here, as for any assignment.
@@ -2330,6 +2114,16 @@ export async function serve(opts) {
2330
2114
  // pkill is worse than one that occasionally abandons a turn — the events are unacknowledged,
2331
2115
  // so whatever it was doing comes back.
2332
2116
  let stopping = false;
2117
+ // Into a newer harness while idle: see harness/restarter.ts.
2118
+ const restarter = createRestarter({
2119
+ log: (kind, text) => log(kind, text),
2120
+ busy: () => ({ stopping, working, queued: queue.length, live: live.size }),
2121
+ shutdown: () => shutdown(),
2122
+ resume: () => void drain(),
2123
+ });
2124
+ const restartDue = restarter.due;
2125
+ const maybeRestart = restarter.maybe;
2126
+ restarter.start();
2333
2127
  const shutdown = () => {
2334
2128
  if (stopping) {
2335
2129
  say('stopping now');
@@ -2349,68 +2143,15 @@ export async function serve(opts) {
2349
2143
  clearInterval(sweepTimer);
2350
2144
  clearInterval(worktreeTimer);
2351
2145
  clearInterval(resumeTimer);
2146
+ clearInterval(servicesTimer);
2147
+ void services.stopAll();
2352
2148
  teamTimers.forEach(clearInterval);
2353
2149
  // Belt and braces: if unwinding wedges, do not hang forever pretending to shut down. Longer
2354
2150
  // when cards are still open, because their handovers run first and each is a short turn.
2355
- setTimeout(() => (restartTo && restarting ? relaunchSelf(restartTo) : process.exit(0)), live.size ? 120_000 : 8000).unref();
2151
+ setTimeout(() => (restarter.relaunchTo() ? relaunchSelf(restarter.relaunchTo()) : process.exit(0)), live.size ? 120_000 : 8000).unref();
2356
2152
  };
2357
2153
  process.on('SIGINT', shutdown);
2358
2154
  process.on('SIGTERM', shutdown);
2359
- /*
2360
- * A newer harness, taken while idle. Startup already installs the newest version; a RUNNING
2361
- * agent never did, so every release meant restarting ten agents by hand. Now: when idle, look;
2362
- * when there is a newer version, stop the normal way (stream closed, FlareDeck told it stopped)
2363
- * and come back up in the same pane on the new one. Never mid-turn, never with work queued.
2364
- */
2365
- const runningVersion = harnessVersion();
2366
- let restartTo = '';
2367
- let readySince = 0;
2368
- let restarting = false;
2369
- let lookAgainAt = 0;
2370
- // LOOKING happens on the timer whether or not a turn is running — a busy agent never had an
2371
- // idle moment at the check, so it never even saw the new version (TARS). Acting waits for a gap.
2372
- const lookForNewer = async () => {
2373
- if (restartTo || !updatesEnabled() || !runningVersion || Date.now() < lookAgainAt)
2374
- return;
2375
- restartTo = await newerHarness(runningVersion);
2376
- if (!restartTo)
2377
- return;
2378
- readySince = Date.now();
2379
- log('tool', `harness ${restartTo} is ready — restarting into it between turns`);
2380
- };
2381
- /** Checked between two events in drain(): a restart can happen with work still queued. */
2382
- // True while installing too, so no new turn starts in the half-minute before the restart.
2383
- const restartDue = () => !!restartTo && (restarting || restartDecision({ ready: restartTo, stopping, working: false, queued: queue.length, live: live.size, readySince, now: Date.now() }));
2384
- let installFailures = 0;
2385
- const maybeRestart = async () => {
2386
- if (stopping || restarting)
2387
- return;
2388
- await lookForNewer();
2389
- if (working)
2390
- return; // a gap comes at the end of this turn: drain() checks between events
2391
- if (!restartDecision({ ready: restartTo, stopping, working, queued: queue.length, live: live.size, readySince, now: Date.now() }))
2392
- return;
2393
- // Install BEFORE stopping. Coming back up on the same version would only find it "ready"
2394
- // again ten minutes later — a restart loop — so a failed install keeps this one running.
2395
- // Nothing to install is fine when another agent on this machine already installed it.
2396
- restarting = true;
2397
- if (!isCheckout() && !(await updateSelf((t) => log('tool', t))) && !readyOnDisk(runningVersion)) {
2398
- restarting = false;
2399
- restartTo = '';
2400
- // Soon at first: most failures are a release asked for before npm had it everywhere, and
2401
- // six hours of the old version for that is what FRIDAY got. Backs off if it keeps failing.
2402
- const wait = INSTALL_BACKOFF_MIN[Math.min(installFailures++, INSTALL_BACKOFF_MIN.length - 1)];
2403
- lookAgainAt = Date.now() + wait * 60_000;
2404
- log('warn', `could not install the newer harness — staying on ${runningVersion}, trying again in ${wait < 60 ? `${wait} min` : `${wait / 60}h`}`);
2405
- void drain(); // the queue was held for the restart; carry on with it
2406
- return;
2407
- }
2408
- installFailures = 0;
2409
- log('tool', `restarting into harness ${restartTo}`);
2410
- shutdown();
2411
- };
2412
- const restartTimer = setInterval(() => void maybeRestart(), 10 * 60_000);
2413
- restartTimer.unref();
2414
2155
  /**
2415
2156
  * An MCP server rejected the session's sign-in: renew it NOW and have the agent retry, instead
2416
2157
  * of the agent concluding the service is broken and flagging a person. Sceneflo rejected HAL
@@ -2466,6 +2207,54 @@ export async function serve(opts) {
2466
2207
  }
2467
2208
  catch { /* keep the order */ }
2468
2209
  }
2210
+ /**
2211
+ * Is a queued event worth a turn? Not when it is for someone else, a move this agent made itself,
2212
+ * a move overtaken by a later one, or a free worker the lead has nothing new for (null: the caller
2213
+ * acknowledges it). Comments on one card that queued up while a turn ran become one event.
2214
+ */
2215
+ async function triage(event) {
2216
+ if (!needsMe(event)) {
2217
+ log('tool', `ignored ${event.event}: ${event.cardCode || 'card'} is not assigned to you (${event.actor || 'someone'})`);
2218
+ return null;
2219
+ }
2220
+ // A move this agent made itself, coming back as an event ("Someone moved it").
2221
+ if (event.event === 'moved' && !event.mentioned && isOwnMove([event.cardCode, event.cardKey], event.laneName)) {
2222
+ log('tool', `ignored moved: ${event.cardCode || event.cardKey} to ${event.laneName} was my own move`);
2223
+ return null;
2224
+ }
2225
+ // A move the card has since moved on from. After a restart, or with several moves in a
2226
+ // minute, these arrive late, and each cost CASE a turn to conclude "out of date, nothing
2227
+ // to do" (HR-0184, 12 Oct). The card's lane now says whether the move still stands.
2228
+ if (event.event === 'moved' && !event.mentioned && event.cardCode && opts.deck) {
2229
+ const now = await opts.deck.card(event.cardCode).then((c) => c?.lane?.name, () => undefined);
2230
+ if (moveIsStale(event, now)) {
2231
+ log('tool', `ignored moved: ${event.cardCode} has since moved on to ${now}`);
2232
+ return null;
2233
+ }
2234
+ }
2235
+ /* Comments on one card that queued up while a turn ran are ONE turn, not one each. TARS
2236
+ finished HR-1019 on 17 Oct to find five comments waiting on it and spent five turns of
2237
+ ~100k saying "nothing new" to each — they had been written while it worked, and the
2238
+ first turn could have read them all. */
2239
+ let merged = [];
2240
+ if ((event.event === 'commented' || event.event === 'mentioned') && event.cardKey) {
2241
+ merged = mergeableComments(queue, event);
2242
+ if (merged.length) {
2243
+ for (const m of merged)
2244
+ queue.splice(queue.indexOf(m), 1);
2245
+ syncQueue();
2246
+ event = combineComments(event, merged);
2247
+ log('tool', `${event.cardCode || event.cardKey}: ${merged.length + 1} comments waiting on this card — read together in one turn`);
2248
+ }
2249
+ }
2250
+ // A worker saying it is free, with nothing ready to give it: the lead's model would only
2251
+ // read the board and say "nothing". Five idle workers after a restart woke R2-D2 five
2252
+ // times for exactly that. The dispatch timer hands work over once something IS ready.
2253
+ if (profile.dispatch && isFreeWorkerMessage(event) && !(await dispatch.wakeForFree(event))) {
2254
+ return null;
2255
+ }
2256
+ return { event, merged };
2257
+ }
2469
2258
  async function drain() {
2470
2259
  if (working)
2471
2260
  return;
@@ -2482,80 +2271,17 @@ export async function serve(opts) {
2482
2271
  // Only what needs this agent. A card it once touched keeps sending it every move and
2483
2272
  // comment made by whoever has it now, and each one cost a full turn — guide, card,
2484
2273
  // orientation — to conclude "not mine". Acknowledge those without waking the model.
2485
- if (!needsMe(event)) {
2486
- log('tool', `ignored ${event.event}: ${event.cardCode || 'card'} is not assigned to you (${event.actor || 'someone'})`);
2487
- pending.delete(event.id);
2488
- await stream.ack([event.id]);
2489
- continue;
2490
- }
2491
- // A move this agent made itself, coming back as an event ("Someone moved it").
2492
- if (event.event === 'moved' && !event.mentioned && isOwnMove([event.cardCode, event.cardKey], event.laneName)) {
2493
- log('tool', `ignored moved: ${event.cardCode || event.cardKey} to ${event.laneName} was my own move`);
2274
+ const original = event;
2275
+ const triaged = await triage(event);
2276
+ if (!triaged) {
2494
2277
  pending.delete(event.id);
2495
2278
  await stream.ack([event.id]);
2496
2279
  continue;
2497
2280
  }
2498
- // A move the card has since moved on from. After a restart, or with several moves in a
2499
- // minute, these arrive late, and each cost CASE a turn to conclude "out of date, nothing
2500
- // to do" (HR-0184, 12 Oct). The card's lane now says whether the move still stands.
2501
- if (event.event === 'moved' && !event.mentioned && event.cardCode && opts.deck) {
2502
- const now = await opts.deck.card(event.cardCode).then((c) => c?.lane?.name, () => undefined);
2503
- if (moveIsStale(event, now)) {
2504
- log('tool', `ignored moved: ${event.cardCode} has since moved on to ${now}`);
2505
- pending.delete(event.id);
2506
- await stream.ack([event.id]);
2507
- continue;
2508
- }
2509
- }
2510
- /* Comments on one card that queued up while a turn ran are ONE turn, not one each. TARS
2511
- finished HR-1019 on 17 Oct to find five comments waiting on it and spent five turns of
2512
- ~100k saying "nothing new" to each — they had been written while it worked, and the
2513
- first turn could have read them all. */
2514
- const original = event;
2515
- let merged = [];
2516
- if ((event.event === 'commented' || event.event === 'mentioned') && event.cardKey) {
2517
- merged = mergeableComments(queue, event);
2518
- if (merged.length) {
2519
- for (const m of merged)
2520
- queue.splice(queue.indexOf(m), 1);
2521
- syncQueue();
2522
- event = combineComments(event, merged);
2523
- log('tool', `${event.cardCode || event.cardKey}: ${merged.length + 1} comments waiting on this card — read together in one turn`);
2524
- }
2525
- }
2526
- // A worker saying it is free, with nothing ready to give it: the lead's model would only
2527
- // read the board and say "nothing". Five idle workers after a restart woke R2-D2 five
2528
- // times for exactly that. The dispatch timer hands work over once something IS ready.
2529
- if (isLead && (event.event === 'message' || event.event === 'mentioned') &&
2530
- FREE_WORKER.test(`${event.message ?? ''} ${event.line ?? ''}`)) {
2531
- const ready = await readyToHand();
2532
- const key = [...ready].sort().join(',');
2533
- // Nothing ready, or the very cards the lead already looked at and chose to hold: JARVIS
2534
- // woke for every "I am free" to re-read the same five cards and assign none. Woken again
2535
- // when the set changes, or after an hour in case whatever held them is gone.
2536
- const unchanged = !!key && key === lastFreeLook.key && Date.now() - lastFreeLook.at < 60 * 60_000;
2537
- if (!ready.length || unchanged) {
2538
- log('tool', `${event.actor || 'a worker'} is free — ${ready.length ? 'the ready cards are the ones already looked at' : 'nothing ready to hand over'}, not waking the model`);
2539
- pending.delete(event.id);
2540
- await stream.ack([event.id]);
2541
- continue;
2542
- }
2543
- lastFreeLook = { key, at: Date.now() };
2544
- }
2545
- // What a person reading the pane wants to know is WHICH card, and a code alone is not
2546
- // that — you have to go and look it up. "a message" was worse: it did not even say who
2547
- // from, on a line that sits there for minutes.
2281
+ event = triaged.event;
2282
+ const { merged } = triaged;
2548
2283
  const cardLabel = event.cardCode || event.cardKey || '';
2549
- const title = (event.cardName || '').trim();
2550
- current = event.event === 'ask'
2551
- ? 'your question'
2552
- : event.event === 'sweep'
2553
- ? 'your outstanding work'
2554
- : event.event === 'scheduled'
2555
- ? event.scheduleName || 'a scheduled task'
2556
- : cardLabel
2557
- ? `${cardLabel}${title ? ` · ${title.length > 58 ? `${title.slice(0, 57)}…` : title}` : ''}`
2558
- : `a message${event.actor ? ` from ${event.actor}` : ''}`;
2284
+ current = workingLabel(event);
2559
2285
  state.working = current;
2560
2286
  tag = cardLabel;
2561
2287
  state.since = Date.now();
@@ -2696,7 +2422,7 @@ export async function serve(opts) {
2696
2422
  * stops reading it, and the whole value is that this message means something.
2697
2423
  */
2698
2424
  async function offerToLead() {
2699
- if (!leadId || !leadName || isLead || offeredWhileIdle)
2425
+ if (!leadId || !leadName || profile.dispatch || offeredWhileIdle)
2700
2426
  return;
2701
2427
  offeredWhileIdle = true;
2702
2428
  // Short on purpose. A lead reads these between other things, and everything else it needs —
@@ -2923,9 +2649,32 @@ export async function serve(opts) {
2923
2649
  ...(event.cardCode ? { code: event.cardCode } : {}),
2924
2650
  }, reason, kind, undefined, undefined, undefined, undefined, true);
2925
2651
  };
2926
- async function handle(event) {
2652
+ /**
2653
+ * A turn, stage 1: is this event work for this agent at all? Expired schedules, cards parked on a
2654
+ * person, chatter between others and the harness's own bookkeeping end here, acknowledged without
2655
+ * a turn (null). An assistant's chat is narrowed to its person.
2656
+ */
2657
+ /**
2658
+ * A card as FlareDeck has it, read at most once in a few seconds. A turn's start read the same card
2659
+ * up to three times — the parked check, the queue check, its repositories — each a round trip
2660
+ * before the model could begin. Only the turn's start uses this; decisions made later in a turn
2661
+ * (the finish gate, a release) read the card fresh.
2662
+ */
2663
+ const startReads = new Map();
2664
+ const cardAtStart = (ref) => {
2665
+ const hit = startReads.get(ref);
2666
+ if (hit && Date.now() - hit.at < 5000)
2667
+ return hit.card;
2668
+ const card = (opts.deck?.card(ref) ?? Promise.resolve(null)).catch(() => null);
2669
+ startReads.set(ref, { at: Date.now(), card });
2670
+ for (const [k, v] of startReads)
2671
+ if (Date.now() - v.at > 60_000)
2672
+ startReads.delete(k);
2673
+ return card;
2674
+ };
2675
+ async function admit(event) {
2927
2676
  // An assistant working a card on a board it releases for is a worker for this turn.
2928
- const isAssistantChat = isAssistant && !assistantCard(event);
2677
+ const isAssistantChat = profile.chat && !assistantCard(event);
2929
2678
  const prefix = codePrefix(event.cardCode);
2930
2679
  if (prefix && !scratchPrefixes.has(prefix)) {
2931
2680
  scratchPrefixes.add(prefix);
@@ -2936,7 +2685,7 @@ export async function serve(opts) {
2936
2685
  // the caller, because skipping it is the finished outcome.
2937
2686
  if (event.event === 'scheduled' && event.validUntil && Date.now() > Date.parse(event.validUntil)) {
2938
2687
  log('warn', `${event.scheduleName || 'scheduled task'}: skipped, it was due ${event.validUntil} and that has passed`);
2939
- return;
2688
+ return null;
2940
2689
  }
2941
2690
  // A comment on a finished card is news, not work. The server stopped sending these to a
2942
2691
  // card's holder — this is the same rule on this side, for an older server and for any other
@@ -2965,19 +2714,19 @@ export async function serve(opts) {
2965
2714
  /* A lead or manager hears its own team asking it, parked or not: that is what it is there
2966
2715
  for. HR-0761 (18 Oct): TARS escalated to Norm, the manager, and Norm's harness left it
2967
2716
  alone four times because the card was parked on a person. */
2968
- const byReport = (isLead || isManager) && isTeamAsk(event, agentNames) && afterPark;
2717
+ const byReport = (profile.oversees) && isTeamAsk(event, agentNames) && afterPark;
2969
2718
  const stale = Date.now() - waiting.since > PARK_EXPIRES_MS;
2970
2719
  /* The flag itself cleared — by a person, the manager, or a release — is the answer too.
2971
2720
  HR-0915 (18 Oct): Norm cleared it and released the fix to qa, and HAL left the card alone
2972
2721
  seven times, "waiting on a person", because no person had COMMENTED. */
2973
2722
  let cleared = false;
2974
2723
  if (!byPerson && event.event !== 'decided' && !stale && opts.deck) {
2975
- const now = await opts.deck.card(event.cardKey || event.cardCode || '').catch(() => null);
2724
+ const now = await cardAtStart(event.cardKey || event.cardCode || '');
2976
2725
  cleared = !!now && now.needs_human === false;
2977
2726
  }
2978
2727
  if (!unparkReason({ byPerson: byPerson || byBoss || byReport, decided: event.event === 'decided', stale, cleared })) {
2979
2728
  log('event', `${label}: waiting on a person (${waiting.why}) — left alone`);
2980
- return;
2729
+ return null;
2981
2730
  }
2982
2731
  for (const k of [event.cardKey, event.cardCode])
2983
2732
  if (k)
@@ -2989,30 +2738,30 @@ export async function serve(opts) {
2989
2738
  }
2990
2739
  if (event.event === 'commented' && event.cardDone && !event.mentioned) {
2991
2740
  log('event', `${event.cardCode || event.cardKey}: comment on a finished card — nothing to do`);
2992
- return;
2741
+ return null;
2993
2742
  }
2994
2743
  if (forSomeoneElse(event, agentNames, (opts.agent ?? '').replace(/\s*\(.*$/, ''))) {
2995
2744
  log('event', `${event.cardCode || event.cardKey}: ${event.actor} is talking to somebody else on the card — nothing to do`);
2996
- return;
2745
+ return null;
2997
2746
  }
2998
2747
  // An assistant hears its person and nothing else, and works outside the card machinery.
2999
2748
  if (isAssistantChat) {
3000
- const why = assistantIgnores(event, person?.uid ?? null);
2749
+ const why = chat.ignores(event);
3001
2750
  if (why) {
3002
- log('event', `${event.cardCode || event.event}: ${why} — not for ${person?.name ?? 'anyone'}'s assistant`);
3003
- return;
2751
+ log('event', `${event.cardCode || event.event}: ${why} — not for ${chat.person()?.name ?? 'anyone'}'s assistant`);
2752
+ return null;
3004
2753
  }
3005
- event = asAssistantEvent(event, person?.name ?? 'your person');
2754
+ event = chat.asTurn(event);
3006
2755
  // The conversation from before a restart, picked up where it was.
3007
2756
  if (!sessions.has(ASSISTANT_KEY)) {
3008
- const kept = loadAssistantSession(assistantSessionFile);
2757
+ const kept = chat.kept();
3009
2758
  if (kept)
3010
2759
  sessions.set(ASSISTANT_KEY, kept);
3011
2760
  }
3012
2761
  }
3013
2762
  if (isBookkeeping(event, agentNames)) {
3014
2763
  log('event', `${event.cardCode || event.cardKey}: ${event.event === 'commented' ? 'a harness\'s own record on the card' : event.event === 'approval' ? 'an approval for a person to give' : event.event === 'updated' ? 'the card\'s record changed' : 'the card was flagged for a person'} — nothing to do`);
3015
- return;
2764
+ return null;
3016
2765
  }
3017
2766
  /* One card in progress at a time. Every event on a QUEUED card used to start it: the turn
3018
2767
  moved it to Doing whatever else the worker held, so with Norm queuing cards HAL and TARS
@@ -3020,8 +2769,8 @@ export async function serve(opts) {
3020
2769
  FlareDeck hands it out by priority when the current one is done — and a comment on one gets
3021
2770
  a reply only. "In progress" is FlareDeck's answer, skipping cards parked on a person. */
3022
2771
  let queuedBehind = '';
3023
- if (!isLead && !isManager && !isAssistantChat && event.cardKey && event.event !== 'review' && event.event !== 'ask' && !sessions.has(event.cardKey)) {
3024
- const here = await opts.deck?.card(event.cardCode || event.cardKey).catch(() => null);
2772
+ if (!profile.oversees && !isAssistantChat && event.cardKey && event.event !== 'review' && event.event !== 'ask' && !sessions.has(event.cardKey)) {
2773
+ const here = await cardAtStart(event.cardCode || event.cardKey || '');
3025
2774
  if (here && !here.done && String(here.lane?.type ?? '').toLowerCase() !== 'inprogress') {
3026
2775
  const pv = String(await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'pull-next-card', {
3027
2776
  preview: true, ...(skipNow().length ? { skip: skipNow() } : {}),
@@ -3031,15 +2780,21 @@ export async function serve(opts) {
3031
2780
  if (doing && doing !== thisCode) {
3032
2781
  if (event.event === 'assigned' || event.event === 'moved' || event.event === 'updated') {
3033
2782
  log('event', `${thisCode || event.cardKey}: queued behind ${doing} — not started now; FlareDeck hands it out when ${doing} is done`);
3034
- return;
2783
+ return null;
3035
2784
  }
3036
2785
  queuedBehind = doing;
3037
2786
  }
3038
2787
  }
3039
2788
  }
2789
+ return { event, isAssistantChat, queuedBehind };
2790
+ }
2791
+ /**
2792
+ * A turn, stage 2: where it works — which repository, which base branch, and a checkout of its own
2793
+ * for a card that will change something. null: the card was flagged for a person or the agent
2794
+ * paused (disk), and there is no turn.
2795
+ */
2796
+ async function placeTurn(event, isAssistantChat) {
3040
2797
  let key = isAssistantChat ? ASSISTANT_KEY : event.cardKey || (event.event === 'ask' ? 'ask' : event.event === 'sweep' ? 'sweep' : event.event === 'scheduled' ? `schedule-${event.scheduleId}` : 'inbox');
3041
- let turnTokens = 0;
3042
- let nudged = false;
3043
2798
  // A checkout of its own per card, so it cannot edit a working tree someone else is in.
3044
2799
  // Falls back to the repo itself when there is no git or the worktree cannot be made —
3045
2800
  // better to work than to refuse, but the agent is told either way.
@@ -3055,15 +2810,18 @@ export async function serve(opts) {
3055
2810
  // config list to go stale.
3056
2811
  // No repository details on the event (the harness's own pickup): the card names its repository.
3057
2812
  if (event.cardKey && !(event.baseBranches ?? []).length && opts.deck) {
3058
- const here = await opts.deck.card(event.cardCode || event.cardKey).catch(() => null);
2813
+ const here = await cardAtStart(event.cardCode || event.cardKey || '');
3059
2814
  // Its Repository Components, resolved to repositories by FlareDeck; the legacy "owner/name"
3060
2815
  // component field only if those say nothing.
3061
- const named = Array.isArray(here?.repositories) ? here.repositories : [];
3062
- const fromCard = named.length
3063
- ? named.map((r) => ({ component: '', repository: r.url, branch: '', role: 'feature' }))
3064
- : baseBranchesFromComponent(here?.component);
2816
+ // With each repository's branch for the card's environment, when FlareDeck sends it.
2817
+ const named = fromCardApi(here?.repositories);
2818
+ const fromCard = named.length ? named : baseBranchesFromComponent(here?.component);
3065
2819
  if (fromCard.length)
3066
2820
  event = { ...event, baseBranches: fromCard };
2821
+ // …and what it runs alongside, which goes in the card's folder with it (features/card-folder.ts).
2822
+ const beside = fromCardApi(here?.alongside);
2823
+ if (beside.length && !event.alongside?.length)
2824
+ event = { ...event, alongside: beside };
3067
2825
  }
3068
2826
  let repoChoiceNote = '';
3069
2827
  // An assistant's card on a board it releases for: the board says which repository. A card the
@@ -3072,7 +2830,7 @@ export async function serve(opts) {
3072
2830
  // A board with no repository at all (a sales board): the card has no code, so no checkout — it
3073
2831
  // is worked from the assistant's own folder, with its email and calendar tools.
3074
2832
  let assistantNoCode = false;
3075
- if (isAssistant && !isAssistantChat && !(event.baseBranches ?? []).some((e) => e.repository) && event.boardKey) {
2833
+ if (profile.anyRepo && !isAssistantChat && !(event.baseBranches ?? []).some((e) => e.repository) && event.boardKey) {
3076
2834
  const tid = tenantFromToken(await opts.auth());
3077
2835
  const l = tid ? await fetchLadder(config.mcpUrl, tid, event.boardKey, () => opts.auth()).catch(() => null) : null;
3078
2836
  const urls = (l?.repositories ?? []).map((r) => r.url).filter(Boolean);
@@ -3087,7 +2845,7 @@ export async function serve(opts) {
3087
2845
  // FlareDeck's GitHub connection, into the harness's own folder, and worked from there.
3088
2846
  if (assistantNoCode)
3089
2847
  where = { kind: 'none' };
3090
- else if (isAssistant && !isAssistantChat && where.kind !== 'repo') {
2848
+ else if (profile.anyRepo && !isAssistantChat && where.kind !== 'repo') {
3091
2849
  const cardRoot = cardReposRoot();
3092
2850
  const gh = flaredeckGithub;
3093
2851
  for (const url of new Set((event.baseBranches ?? []).map((e) => e.repository).filter(Boolean))) {
@@ -3096,7 +2854,11 @@ export async function serve(opts) {
3096
2854
  await cloneBoardRepos(dir, [{ url: String(url).replace(/\.git$/, '') }], gh.token, (t, warn) => log(warn ? 'warn' : 'tool', t));
3097
2855
  }
3098
2856
  const there = await resolveCheckout(cardRoot, event, undefined, opts.agent ?? '').catch(() => null);
3099
- if (there?.kind === 'repo')
2857
+ // Several of them, and the card names no component: that is the question every agent answers
2858
+ // below (the code, then the intake, then a person), not "not on this machine". Dropping it
2859
+ // flagged FRIDAY's HRG-0005 and HRG-0011 six seconds after cloning the very repositories it
2860
+ // said were missing — the website board lists three (29–30 Oct).
2861
+ if (there?.kind === 'repo' || there?.kind === 'ambiguous')
3100
2862
  where = there;
3101
2863
  else if (!gh?.token)
3102
2864
  log('warn', `${event.cardCode || event.cardKey}: its repository is not on this machine and FlareDeck has no GitHub connection to clone it with`);
@@ -3161,12 +2923,13 @@ export async function serve(opts) {
3161
2923
  // one, or being mentioned on someone else's — none of those need a checkout, and flagging
3162
2924
  // them for want of one would make the agent unable to answer about a repo it can plainly
3163
2925
  // read. Those turns simply run without a worktree, and the brief says so.
3164
- const willChange = !!event.cardKey && event.event !== 'review' && event.event !== 'intake' && event.event !== 'ask' && event.forYou !== false && !isLeadDispatch(event, isLead);
2926
+ // An agent that does not change code (profile.code off) never needs a checkout of its own.
2927
+ const willChange = profile.code && !!event.cardKey && event.event !== 'review' && event.event !== 'intake' && event.event !== 'ask' && event.forYou !== false && !isLeadDispatch(event, profile.dispatch);
3165
2928
  if (!chosen && willChange && (where.kind === 'ambiguous' || where.kind === 'unknown')) {
3166
2929
  const reason = askingForComponent(where);
3167
2930
  // Which repository is a technical question about the board's setup, not a business one: COR-0311 (19 Oct) went to its reporter.
3168
2931
  await flagForHuman(event, reason, 'technical');
3169
- return;
2932
+ return null;
3170
2933
  }
3171
2934
  // A FIX with no branch of its own is the one case worth stopping for.
3172
2935
  //
@@ -3182,7 +2945,7 @@ export async function serve(opts) {
3182
2945
  `feature branch would base the fix on unreleased work. Set the branch for ${event.environment || 'this environment'} ` +
3183
2946
  `on the board's repository configuration and this will be picked up.`;
3184
2947
  await flagForHuman(event, reason, 'technical');
3185
- return;
2948
+ return null;
3186
2949
  }
3187
2950
  const repo = chosen?.path ?? config.repo;
3188
2951
  const startFrom = fromCard?.branch ?? loadBaseBranch(repo) ?? config.baseBranch ?? undefined;
@@ -3212,16 +2975,30 @@ export async function serve(opts) {
3212
2975
  const wall = await walls();
3213
2976
  if (wall) {
3214
2977
  await enterPause(wall);
3215
- return;
2978
+ return null;
3216
2979
  }
3217
2980
  }
3218
2981
  }
3219
2982
  // An intake review reads the card and the code; it builds nothing, so it gets no checkout. HR-1004's
3220
2983
  // review made a full worktree (and from dev, which the board no longer uses) just to read a card.
3221
- const tree = event.event === 'intake' || isLeadDispatch(event, isLead) || assistantNoCode ? null
2984
+ // A card with more than one repository — what it changes and what it runs alongside — gets a
2985
+ // folder holding a worktree of each (features/card-folder.ts); one repository, a lone worktree.
2986
+ // Only a profile with folders (solo): in a team, the others run beside the card and a change
2987
+ // that belongs in one goes back to the lead, who gives it to whoever owns that repository.
2988
+ const companions = profile.folders && chosen && event.cardKey && willChange ? companionRepos(event, chosen.origin) : [];
2989
+ const lone = () => ensureWorktree(repo, key, (t) => log('tool', t), startFrom, config.worktreeLocalFiles);
2990
+ const made = !profile.code || event.event === 'intake' || isLeadDispatch(event, profile.dispatch) || assistantNoCode ? null
3222
2991
  : event.cardKey && (chosen || where.kind === 'none')
3223
- ? await ensureWorktree(repo, key, (t) => log('tool', t), startFrom, config.worktreeLocalFiles)
2992
+ ? (companions.length
2993
+ ? (await openCardFolder(repo, key, startFrom, companions, { source: sourceFor, onStep: (t) => log('tool', t), ...(config.worktreeLocalFiles ? { local: config.worktreeLocalFiles } : {}) })) ?? (await lone())
2994
+ : await lone())
3224
2995
  : await worktreeTheMessageIsAbout(event, repo, (t) => log('tool', t), (code) => ensureWorktree(repo, code, (t) => log('tool', t), startFrom, config.worktreeLocalFiles));
2996
+ // A folder made by an earlier turn, or found again after a restart, comes back whole.
2997
+ const tree = made ? await withCompanions(made) : null;
2998
+ if (tree?.companions?.length)
2999
+ writeFolderScript(tree, (r) => storedStartScript(config.startScripts, r), chosen?.origin ?? '', where.kind === 'repo' ? where.script : undefined);
3000
+ if (tree?.companions?.length)
3001
+ log('tool', `card folder ${tree.folder}: ${[tree.path, ...tree.companions.map((c) => c.path)].map((p) => p.split('/').pop()).join(', ')}`);
3225
3002
  /**
3226
3003
  * Questions about a CARD get their own session, not the shared one.
3227
3004
  *
@@ -3240,26 +3017,7 @@ export async function serve(opts) {
3240
3017
  // An assistant's very first turn, with no conversation of its own: its person's newest Claude
3241
3018
  // CLI session in this folder, carried on as a FORK — the history comes across, the person's
3242
3019
  // own session file is left as it was, and the two never write to the same place.
3243
- const pickedUp = isAssistantChat && key === ASSISTANT_KEY && !sessions.get(key) ? latestCliSession(config.repo) : null;
3244
- if (pickedUp) {
3245
- log('event', `carrying on from your Claude session ${pickedUp.slice(0, 8)} in this folder (a copy — yours is left as it is)`);
3246
- // So the person sees, in FlareDeck, where the conversation was: the last thirty exchanges,
3247
- // once, as one message marked as the past.
3248
- const posted = `${assistantSessionFile}.history`;
3249
- if (person && !existsSync(posted)) {
3250
- const ex = recentExchanges(join(claudeProjectDir(config.repo), `${pickedUp}.jsonl`));
3251
- if (ex.length) {
3252
- const name = (opts.agent ?? 'your assistant').replace(/\s*\(.*$/, '');
3253
- await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'send-message', { to: person.uid, message: historyMessage(ex, name) })
3254
- .then(() => { try {
3255
- mkdirSync(dirname(posted), { recursive: true });
3256
- writeFileSync(posted, pickedUp);
3257
- }
3258
- catch { /* posted; a repeat is harmless */ } log('tool', `posted the last ${ex.length} exchanges to ${person.name}'s chat`); })
3259
- .catch((e) => log('warn', `could not post the earlier conversation to the chat: ${e instanceof Error ? e.message : String(e)}`));
3260
- }
3261
- }
3262
- }
3020
+ const pickedUp = isAssistantChat && key === ASSISTANT_KEY && !sessions.get(key) ? await chat.pickUp() : null;
3263
3021
  // An assistant always works from the folder it was started in: that is where its person's own
3264
3022
  // Claude sessions live, and where `continue` finds the one to carry on.
3265
3023
  const cwd = isAssistantChat ? config.repo : tree?.path ?? repo;
@@ -3283,17 +3041,17 @@ export async function serve(opts) {
3283
3041
  // A lead's folder often holds no code at all (R2-D2: ~/Source/Kepler/kepler-agent). Handed a
3284
3042
  // card to build, it flagged a person to "fix the repository" (KD-1354) — when its job is to
3285
3043
  // pass the card to a worker who has the code. So it is told that, and the turn runs.
3286
- const leadWithoutCode = !tree && willChange && !!event.cardKey && isLead;
3044
+ const leadWithoutCode = !tree && willChange && !!event.cardKey && profile.dispatch;
3287
3045
  // An agent whose folder holds no repository at all works in place: there is no checkout of
3288
3046
  // anyone's to protect, which is the whole reason for the flag below. C3PO (PayInc's assistant,
3289
3047
  // a folder of notes and documents) was asked to reword a card and told five people it had
3290
3048
  // "nowhere safe to do the work" (COR-0307).
3291
3049
  const noRepositoryHere = !chosen && where.kind === 'none' && !existsSync(join(repo, '.git'));
3292
- if (!tree && willChange && event.cardKey && !isLead && !noRepositoryHere) {
3050
+ if (!tree && willChange && event.cardKey && !profile.dispatch && !noRepositoryHere) {
3293
3051
  await flagForHuman(event, `I could not make a checkout of my own for this card in ${chosen?.name ?? repo}, so there is nowhere ` +
3294
3052
  `safe to do the work. Everything I changed would land in a checkout somebody else may be using. ` +
3295
3053
  `Something is wrong with the repository or the branch this card asks for — a person needs to look.`, 'technical');
3296
- return;
3054
+ return null;
3297
3055
  }
3298
3056
  if (tree) {
3299
3057
  state.branch = tree.branch;
@@ -3303,135 +3061,126 @@ export async function serve(opts) {
3303
3061
  // from anywhere else: which project this card is actually being changed in.
3304
3062
  if (chosen)
3305
3063
  state.repo = chosen.path;
3306
- // FlareDeck must be there before the model is. Without it the turn cannot read the card,
3307
- // comment, or ask for help — and it would still run, answer fluently that it could not do
3308
- // the work, and be acknowledged. The card would be gone. So: no FlareDeck, no turn.
3309
- // The plan for this run is asked for now, alongside the check, instead of after everything
3310
- // else: one more round trip the person was waiting on. Awaited where it is used.
3311
- const subsEarly = subscriptionsForAgent(config.mcpUrl, () => opts.auth(), (t) => log('tool', t));
3312
- const planEarly = subsEarly.then((sc) => (sc ? sc.choose(failedPlans.get(event.id) ?? []) : null)).catch(() => null);
3313
- const check = await preflightFlaredeck(config.mcpUrl, await opts.auth());
3314
- if (!check.ok) {
3315
- log('warn', check.reason);
3316
- throw new CouldNotWork(`${check.reason} — event left queued`);
3064
+ return { event, key, where, chosen, repo, startFrom, tree, cwd, leadWithoutCode, repoChoiceNote, pickedUp };
3065
+ }
3066
+ /**
3067
+ * A turn, stage: what it runs on. The required MCP servers must be connected; a Claude
3068
+ * subscription is chosen by FlareDeck (or this machine's own login when FlareDeck cannot say);
3069
+ * with every plan spent the event waits for the soonest reset, and past Gemini's spend limit the
3070
+ * turn runs on Claude. null: no turn now — paused, waiting, or the card flagged.
3071
+ */
3072
+ async function choosePlan(event, key, subsEarly, planEarly) {
3073
+ /**
3074
+ * Which Claude subscription pays for this run.
3075
+ *
3076
+ * Three subscriptions and twenty-odd agents, with daily and weekly caps that trip at
3077
+ * different times, used to mean going round the machines logging in again. FlareDeck holds
3078
+ * the tokens and the ledger now and picks one per run.
3079
+ *
3080
+ * Not reaching FlareDeck is NOT the same as having nothing left: `machine` means carry on
3081
+ * with whatever this box is logged in as, exactly as before any of this existed. Only
3082
+ * `none` — every subscription genuinely spent or paused — stops the run, and it says when
3083
+ * one frees up rather than failing blankly.
3084
+ */
3085
+ /* The MCP list, fresh for this session — and a required server that is not connected stops
3086
+ the work here, before a plan is spent on a card that cannot be done properly. */
3087
+ const missingMcp = await refreshMcp();
3088
+ if (missingMcp?.length) {
3089
+ await enterPause(mcpWallFor(missingMcp));
3090
+ return null;
3317
3091
  }
3318
- log('tool', `FlareDeck reachable · ${check.discovered.tools.length} tools`);
3319
- // Served from in here for this turn: a fresh token per call, so a card that outlives the
3320
- // token's hour keeps its tools, and nothing on this machine can shadow the server by name.
3321
- // The gate on calling a card finished: it must have commits of its own, published. Checked
3322
- // here rather than trusted, because a turn that produced nothing still reports confidently.
3323
- const finishGate = async (toLane) => {
3324
- // A released card moving on to testing or review is the next step of the board's flow.
3325
- if (opts.deck) {
3326
- const here = await opts.deck.card(tag || event.cardCode || event.cardKey || '').catch(() => null);
3327
- const released = !!here && ((Array.isArray(here.releases) && here.releases.length > 0) || !!here.deployedAt);
3328
- if (here && toLane && isAfterReleaseMove(toLane, here.lane?.type, here.lane?.name, released)) {
3329
- log('tool', `moving ${here.code || tag} on to ${toLane} — it was released already, so this is the next step, not a hand-back`);
3330
- return null;
3331
- }
3332
- if (here && isReleasedClose(toLane ?? '', released)) {
3333
- log('tool', `closing ${here.code || tag} — its work was released already`);
3334
- return null;
3335
- }
3336
- }
3337
- const why = await tooEarlyToFinish(tree);
3338
- /* A LEAD moving a card on with nothing of its own on the branch is dispatch, not a claim of
3339
- finished work: the code lives on the worker's branch, often already merged. The gate
3340
- refused JARVIS moving HR-0634 (PR #75 merged, on qa) to "On test site", and the lead then
3341
- flagged a person to do the move — an intervention nobody needed. Kept for a lead that HAS
3342
- committed work of its own: that still has to be published before the card moves. */
3343
- const cardCode = event.cardCode || tag;
3344
- if (why && tree && cardCode && (await produced(tree)).commits === 0 && await cardWorkOnBase(tree.path, startFrom, cardCode)) {
3345
- log('tool', `moving ${cardCode} on — its work is already merged into ${startFrom ?? 'the base branch'}, on an earlier branch`);
3346
- return null;
3347
- }
3348
- if (why && tree && !finishRefusalStands(why, isLead || isManager, (await produced(tree)).commits)) {
3349
- log('tool', `moving ${event.cardCode || event.cardKey || 'the card'} on as lead — the work is on a worker's branch, not mine`);
3350
- return null;
3351
- }
3352
- if (why) {
3353
- log('warn', `refused a "done" on an empty branch: ${tree?.branch ?? '(no branch)'}`);
3354
- return why;
3355
- }
3356
- // If this agent reports to someone, it does not decide that its own work is finished. The
3357
- // lead said "I'll check its saved work myself before it goes anywhere" and then was never
3358
- // told: the card moved itself to Awaiting Deployment. A report hands back; the lead moves
3359
- // it, or comments and sends it back, which is what a lead is for.
3360
- if (!leadId)
3361
- return null;
3362
- const code = tag || event.cardCode || event.cardKey || '';
3363
- if (!code)
3364
- return null;
3365
- // Handed over before, and the lead has given it back with nothing new on the branch: the
3366
- // lead reviewed it and wants it moved on. Let the move through instead of refusing again.
3367
- if (handedBack.has(code) && tree && opts.deck) {
3368
- const [here, head] = await Promise.all([opts.deck.card(code).catch(() => null), headOf(tree)]);
3369
- if (leadGaveItBack(handedBackHead.get(code), head, here?.assigned_to?.uid, selfId)) {
3370
- log('tool', `moving ${code} on — ${leadName} reviewed it and gave it back, nothing new since`);
3371
- return null;
3372
- }
3373
- // Back with this worker and changed since (or we cannot tell): it is new work, handed to the
3374
- // lead again like the first time. Remembering the old hand-back forever refused HAL's second
3375
- // "done" on HR-1074 with "JARVIS's to verify" while the card stayed assigned to HAL (20 Oct).
3376
- if (here?.assigned_to?.uid && here.assigned_to.uid === selfId) {
3377
- handedBack.delete(code);
3378
- handedBackHead.delete(code);
3092
+ const subs = await subsEarly.catch(() => null);
3093
+ const claudePlan = await planEarly;
3094
+ if (claudePlan?.use === 'token') {
3095
+ log('tool', `claude: ${claudePlan.label}`);
3096
+ // On the identity line too, not only in the scrollback: the scrollback moves on, and
3097
+ // "which account is this agent spending" is a thing you glance at, not search for.
3098
+ state.claudeAccount = claudePlan.label;
3099
+ // Measured again once this event is done — see measurePlan.
3100
+ turnPlan = { id: claudePlan.id, label: claudePlan.label, token: claudePlan.token };
3101
+ planOfSession.set(key, claudePlan.token);
3102
+ }
3103
+ // Gemini answers on agy, so spent Claude plans stop it only when Claude is needed — at the
3104
+ // Gemini limit, below.
3105
+ if (claudePlan?.use === 'none' && engine.name !== 'antigravity') {
3106
+ const when = claudePlan.availableAt ? new Date(claudePlan.availableAt).toLocaleString() : 'later';
3107
+ const tried = failedPlans.get(event.id)?.length ?? 0;
3108
+ failedPlans.delete(event.id);
3109
+ /**
3110
+ * Spent, not paused: the harness knows to the minute when a plan comes back — FlareDeck's
3111
+ * answer carries the soonest reset — so it waits for it and carries on by itself. Nobody is
3112
+ * asked to do anything about a clock. Only a fleet with NO reset coming (every plan paused,
3113
+ * which only a person undoes) is flagged.
3114
+ */
3115
+ if (claudePlan.availableAt && claudePlan.availableAt > Date.now()) {
3116
+ const at = claudePlan.availableAt;
3117
+ if (!waitingForPlan.has(event.id)) {
3118
+ // A little after the reset, so the first request is not refused for being a second early.
3119
+ const timer = setTimeout(() => {
3120
+ waitingForPlan.delete(event.id);
3121
+ log('event', `a Claude subscription is back — resuming ${event.cardCode || event.line}`);
3122
+ if (enqueue(event, true))
3123
+ void drain();
3124
+ }, at - Date.now() + 60_000);
3125
+ timer.unref?.();
3126
+ waitingForPlan.set(event.id, timer);
3379
3127
  }
3128
+ log('warn', `every Claude subscription is spent${tried ? ` (${tried} failed on this turn)` : ''} — waiting, resuming at ${new Date(at).toLocaleTimeString()}`);
3129
+ return null;
3380
3130
  }
3381
- if (!handedBack.has(code)) {
3382
- handedBack.add(code);
3383
- if (tree) {
3384
- const head = await headOf(tree);
3385
- if (head)
3386
- handedBackHead.set(code, head);
3387
- }
3388
- const made = tree ? await produced(tree) : null;
3389
- const where = tree
3390
- ? `Branch \`${tree.branch}\`${made ? ` — ${made.commits} commit(s), ${made.pushed ? 'pushed' : 'NOT pushed'}` : ''}.`
3391
- : 'No branch: nothing of mine is in a checkout for this card.';
3392
- try {
3393
- const token = await opts.auth();
3394
- await callFlaredeckTool(config.mcpUrl, token, 'assign-card', { card_id: code, assignee: leadId });
3395
- await callFlaredeckTool(config.mcpUrl, token, 'send-message', {
3396
- to: leadId,
3397
- message: `**${code} is ready for your review.**\n\n${where}\n\n` +
3398
- `I have not moved it — it is assigned to you and still in its lane. ` +
3399
- `Check the work and move it on, or comment on the card and I will fix it.`,
3400
- });
3401
- log('tool', `handed ${code} to ${leadName} for review`);
3131
+ log('warn', `every Claude subscription is spent or paused${tried ? ` (${tried} failed on this turn)` : ''} — the soonest frees up ${when}`);
3132
+ 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` +
3133
+ `${tried ? ', or just failed on this card' : ''}. ` +
3134
+ `The soonest one frees up ${when}.\n\nNothing is wrong with the card. Add or unpause a ` +
3135
+ `subscription on the Integrations page, or wait for the window to reset.`, 'technical');
3136
+ return null;
3137
+ }
3138
+ /*
3139
+ * Gemini bills per token, so a workspace sets a dollar limit on it (Integrations → Secrets).
3140
+ * Reached, the turn runs on Claude instead — the same agent, its subscription chosen above —
3141
+ * and Gemini answers again once the limit resets (Cobus, 16 Oct). No Claude to fall back on:
3142
+ * the card waits for the reset, as it would for a spent plan.
3143
+ */
3144
+ let turnEngine = engine;
3145
+ if (engine.name === 'antigravity') {
3146
+ const spend = await geminiSpend?.status().catch(() => null);
3147
+ if (spend?.over) {
3148
+ if (claudePlan?.use === 'token') {
3149
+ turnEngine = claudeFallback ??= new (await import('./engine-claude.js')).ClaudeEngine();
3150
+ if (!geminiOver)
3151
+ log('warn', `${spend.over.message} — Claude answers until ${new Date(spend.over.until).toLocaleString()}`);
3152
+ geminiOver = true;
3402
3153
  }
3403
- catch (e) {
3404
- // A handback that did not happen must not read as one that did.
3405
- handedBack.delete(code);
3406
- handedBackHead.delete(code);
3407
- const text = e instanceof Error ? e.message : String(e);
3408
- log('warn', `could not hand ${code} to ${leadName}: ${text}`);
3409
- return `Could not hand this card to ${leadName} for review (${text}). Do not move it; say so on the card instead.`;
3154
+ else {
3155
+ const at = spend.over.until;
3156
+ if (!waitingForPlan.has(event.id)) {
3157
+ const timer = setTimeout(() => {
3158
+ waitingForPlan.delete(event.id);
3159
+ log('event', `the Gemini limit has reset — resuming ${event.cardCode || event.line}`);
3160
+ if (enqueue(event, true))
3161
+ void drain();
3162
+ }, at - Date.now() + 60_000);
3163
+ timer.unref?.();
3164
+ waitingForPlan.set(event.id, timer);
3165
+ }
3166
+ log('warn', `${spend.over.message}, and there is no Claude subscription to fall back on — waiting until ${new Date(at).toLocaleString()}`);
3167
+ return null;
3410
3168
  }
3411
3169
  }
3412
- return (`${code} is now ${leadName}'s to verify, not yours to close. It is assigned to them and ` +
3413
- `they have been told, with your branch. Leave the lane alone — they move it on, or comment ` +
3414
- `and send it back to you. Your part is done; say nothing further about it being finished.`);
3415
- };
3416
- // A turn woken by a message says "a message from JARVIS" for as long as it runs, even while
3417
- // it is plainly working a card. The card tools name the card; this takes it from there, and
3418
- // only when the turn had none of its own.
3419
- // Ground truth for the footer on anything this agent says. Null without a worktree: there is
3420
- // no branch to report on, and a footer saying "nothing committed" would be misleading rather
3421
- // than informative.
3422
- const savedSoFar = async () => {
3423
- if (!tree)
3424
- return null;
3425
- const { commits, pushed, branch, known } = await produced(tree);
3426
- return { branch, commits, pushed, known };
3427
- };
3428
- const noteCard = (code) => {
3429
- if (!code || tag)
3430
- return;
3431
- tag = code;
3432
- state.working = code;
3433
- };
3434
- const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), finishGate, noteCard, keepTool, savedSoFar, [...myBoards]);
3170
+ else if (geminiOver && spend) {
3171
+ log('event', 'the Gemini limit has reset — Gemini answers again');
3172
+ geminiOver = false;
3173
+ }
3174
+ }
3175
+ return { subs, claudePlan, turnEngine };
3176
+ }
3177
+ /**
3178
+ * A turn, stage: the card read before the model is. A session already open on it is resumed; a
3179
+ * new one gets intake (the card digested, sized, and a model chosen for it). Evidence still
3180
+ * coming makes the event wait; a card that cannot start is flagged (null). Work starting moves
3181
+ * the card to in-progress.
3182
+ */
3183
+ async function prepareCard(event, key, repo, queuedBehind) {
3435
3184
  const held = live.get(key);
3436
3185
  if (held?.timer)
3437
3186
  clearTimeout(held.timer);
@@ -3481,7 +3230,7 @@ export async function serve(opts) {
3481
3230
  waitingSince.delete(event.cardKey);
3482
3231
  if (prepared?.blocked) {
3483
3232
  await flagForHuman(event, `Cannot start: ${prepared.blocked}`);
3484
- return;
3233
+ return null;
3485
3234
  }
3486
3235
  // Work starting is work in progress: a card still waiting in backlog, to-do or bug moves to the
3487
3236
  // board's first In Progress lane. Only on a fresh start, and only out of the waiting lanes —
@@ -3489,30 +3238,15 @@ export async function serve(opts) {
3489
3238
  if (!resume && event.cardKey && event.event !== 'review' && event.event !== 'ask' && !queuedBehind) {
3490
3239
  await startOnBoard(event.cardCode || event.cardKey);
3491
3240
  }
3492
- /**
3493
- * Which card this turn is about — including when nobody dispatched it.
3494
- *
3495
- * This was set only from the event's own cardKey, so a question typed in the pane about a
3496
- * card had none: the agent had the card, its checkout and its environment, hit a sign-in it
3497
- * could not pass, and was told "there is no card being worked, so there is nobody to ask
3498
- * about it". It fell back to leaving a comment, which nobody is notified about. The one
3499
- * moment the escalation exists for, and it was unavailable.
3500
- *
3501
- * `tag` is the code the turn resolved to — from the event, or from the card a pane question
3502
- * named. FlareDeck takes a human code wherever it takes a card id, so the code alone is
3503
- * enough to flag, comment and clear.
3504
- */
3505
- onCard = event.cardKey
3506
- ? {
3507
- card: event.cardKey,
3508
- cwd,
3509
- ...(event.boardKey ? { board: event.boardKey } : {}),
3510
- ...(event.cardCode ? { code: event.cardCode } : {}),
3511
- }
3512
- : tag
3513
- ? { card: tag, cwd, code: tag, ...(event.boardKey ? { board: event.boardKey } : {}) }
3514
- : null;
3515
- job = tree
3241
+ return { resume, prepared };
3242
+ }
3243
+ /**
3244
+ * What committing and publishing this card's checkout needs: its branch and base, where the
3245
+ * card's Parts are recorded, what to do when GitHub refuses a push, and the token to push with
3246
+ * (FlareDeck's GitHub connection first, this machine's login otherwise). null without a checkout.
3247
+ */
3248
+ async function publishJob(event, key, tree, cwd, startFrom) {
3249
+ return tree
3516
3250
  ? {
3517
3251
  cwd,
3518
3252
  branch: tree.branch,
@@ -3520,6 +3254,8 @@ export async function serve(opts) {
3520
3254
  // back to the repo's default branch when it has not.
3521
3255
  base: startFrom ?? (await baseBranch(cwd)),
3522
3256
  card: event.cardCode || key,
3257
+ // The other repositories in the card's folder, published by name.
3258
+ ...(tree.companions?.length ? { others: tree.companions.map((c) => ({ name: c.name, cwd: c.path, branch: c.branch, base: c.base })) } : {}),
3523
3259
  // The card's Parts, from what the branch changes (board settings → part paths).
3524
3260
  onPublished: async (files, where) => {
3525
3261
  if (!event.cardKey)
@@ -3563,6 +3299,14 @@ export async function serve(opts) {
3563
3299
  },
3564
3300
  }
3565
3301
  : null;
3302
+ }
3303
+ /**
3304
+ * A turn, stage: the project running, when the card needs it. Declared preflight checks for a
3305
+ * card's checkout; the environment reused if it is still up here, started if intake says the card
3306
+ * needs it (never for a manager); and the tools to start it on request, ask for a sign-in, and
3307
+ * look at it in a browser. `envNow()` is what is running now — the agent may start it mid-turn.
3308
+ */
3309
+ async function bringUpEnvironment(event, tree, cwd, where, prepared) {
3566
3310
  // Declared checks are OPTIONAL: no .flaredeck/agent.yaml means none, and that is the normal
3567
3311
  // case. Judging what a task needs is the agent's job — it can read the card and the repo, and
3568
3312
  // most cards need nothing. This is only for the few things a project wants hard-gated
@@ -3584,24 +3328,42 @@ export async function serve(opts) {
3584
3328
  // answers from the card, the board and machine-exec. Norm waited five minutes on a start
3585
3329
  // script that hung before answering a yes/no question from CASE — twice in one morning —
3586
3330
  // and then reported its own broken environment to the lead. start_environment is still there.
3587
- const needsRunning = wantsEnvironment(isManager, prepared?.jev?.needs_running);
3331
+ const needsRunning = wantsEnvironment(!profile.environments, prepared?.jev?.needs_running);
3588
3332
  /* Already up in this checkout from an earlier turn — reuse it. After a handover the next
3589
3333
  session ran the start script again on a live environment, the script refused ("harness
3590
3334
  already up, run down first"), and the card lost its app. Checked, not trusted: one that
3591
3335
  stopped answering is started fresh. */
3592
3336
  const cachedEnv = envUp.get(cwd);
3593
- const reuse = forACard && needsRunning && cachedEnv && await environmentStillUp(cachedEnv);
3337
+ const reuse = forACard && needsRunning && cachedEnv && await environmentStillUp(cachedEnv, 2000, cwd);
3594
3338
  if (reuse)
3595
3339
  log('tool', `environment already up here — reusing it${Object.keys(cachedEnv.endpoints).length ? ` (${Object.keys(cachedEnv.endpoints).join(', ')})` : ''}`);
3340
+ // What the card needs running beside its own repository — a site's API and CMS — deployed or
3341
+ // started once and shared (features/services.ts); their addresses go to its start script.
3342
+ const startsHere = !reuse && forACard && needsRunning;
3343
+ // A card folder runs every repository in it from its worktree — the site talks to the API and
3344
+ // CMS code beside it, so a change in either is what gets tested. Otherwise, alongside services.
3345
+ const inFolder = tree?.companions ?? [];
3346
+ const alongside = startsHere && inFolder.length
3347
+ ? await stopOtherEnvironments(cwd).then(() => startFolder(inFolder, event.environment ?? ''))
3348
+ : startsHere && event.alongside?.length ? await services.ensure(event.alongside, event.environment ?? '')
3349
+ : reuse && companionNotes.get(cwd) ? companionNotes.get(cwd)
3350
+ : { env: {}, notes: [] };
3351
+ if (startsHere && inFolder.length)
3352
+ companionNotes.set(cwd, alongside);
3353
+ for (const n of alongside.notes)
3354
+ log('tool', `alongside: ${n}`);
3355
+ // The script this board declared for the repository this card is in, if it declared one —
3356
+ // matched on the remote, the same way the base branch is — else the one kept in FlareDeck.
3357
+ const stored = where.kind === 'repo' ? storedStartScript(config.startScripts, where.checkout.origin) : undefined;
3596
3358
  let env = reuse
3597
3359
  ? cachedEnv
3598
- : forACard && needsRunning
3599
- // The script this board declared for the repository this card is in, if it declared one.
3600
- // Matched on the remote, the same way the base branch is.
3601
- ? await stopOtherEnvironments(cwd).then(() => startEnvironment(cwd, (t) => log('tool', t), where.kind === 'repo' ? where.script : undefined))
3360
+ : startsHere
3361
+ ? await stopOtherEnvironments(cwd).then(() => startEnvironment(cwd, (t) => log('tool', t), where.kind === 'repo' ? where.script : undefined, { ...(stored ? { script: stored } : {}), env: alongside.env }))
3602
3362
  : { ran: false, ok: true, endpoints: {}, detail: '' };
3603
- if (env.ran && env.ok)
3363
+ if (env.ran && env.ok) {
3604
3364
  envUp.set(cwd, env);
3365
+ envHow.set(cwd, { ...(where.kind === 'repo' && where.script ? { declared: where.script } : {}), ...(stored ? { stored } : {}) });
3366
+ }
3605
3367
  // The guard is built once at startup; what this agent can reach changes per card. Handing it
3606
3368
  // the live picture is the difference between judging "$DATABASE_URL" as a scratch database
3607
3369
  // and judging it as a live one.
@@ -3624,7 +3386,106 @@ export async function serve(opts) {
3624
3386
  const envTools = environmentTools({
3625
3387
  cwd: () => cwd,
3626
3388
  script: () => (where.kind === 'repo' ? where.script : undefined),
3389
+ stored: () => (where.kind === 'repo' ? storedStartScript(config.startScripts, where.checkout.origin) : undefined),
3390
+ alongsideEnv: () => alongside.env,
3391
+ // An agent's start script, proved here, kept in FlareDeck for this repository (step 3 of
3392
+ // docs/spec-workspace-repositories.md): every agent after it gets a running project.
3393
+ ...(where.kind === 'repo' ? {
3394
+ saveStartScript: async (script) => {
3395
+ const r = await saveStartScriptFor(where.checkout.origin, script);
3396
+ if (r.ok) {
3397
+ log('tool', `start script saved in FlareDeck for ${where.checkout.name}`);
3398
+ keepStartScript(where.checkout.origin, script);
3399
+ }
3400
+ return r;
3401
+ },
3402
+ } : {}),
3627
3403
  running: () => (env.ran ? env : null),
3404
+ // The card's folder: start some or all of it, start what is missing, keep a start script for
3405
+ // any repository in it (features/card-folder.ts). When an address the card's own repository
3406
+ // was given moves, it restarts too, so it is never left talking to a dead port.
3407
+ ...(inFolder.length ? {
3408
+ folder: (() => {
3409
+ const mainName = cwd.split('/').pop() ?? '';
3410
+ const restartMain = async () => {
3411
+ await stopEnv(cwd).catch(() => false);
3412
+ envUp.delete(cwd);
3413
+ const own = where.kind === 'repo' ? storedStartScript(config.startScripts, where.checkout.origin) : undefined;
3414
+ const again = await startEnvironment(cwd, (t) => log('tool', t), where.kind === 'repo' ? where.script : undefined, { ...(own ? { script: own } : {}), env: alongside.env });
3415
+ if (again.ran && again.ok) {
3416
+ env = again;
3417
+ reachable = { cwd, endpoints: env.endpoints };
3418
+ envUp.set(cwd, again);
3419
+ envHow.set(cwd, { ...(where.kind === 'repo' && where.script ? { declared: where.script } : {}), ...(own ? { stored: own } : {}) });
3420
+ return `${mainName} is running: ${Object.entries(again.endpoints).map(([k, v]) => `${k} ${v}`).join(', ') || 'no addresses printed'}.`;
3421
+ }
3422
+ return `${mainName} did not come up: ${again.detail.split('\n').filter(Boolean).pop() ?? 'no start script'}.`;
3423
+ };
3424
+ /** Bring these companions up again and hand their addresses on; true when one moved. */
3425
+ const bring = async (cs) => {
3426
+ for (const c of cs) {
3427
+ await stopEnv(c.path).catch(() => false);
3428
+ envUp.delete(c.path);
3429
+ }
3430
+ const r = await startFolder(cs, event.environment ?? '');
3431
+ const moved = Object.entries(r.env).some(([k, v]) => alongside.env[k] !== v);
3432
+ Object.assign(alongside.env, r.env);
3433
+ companionNotes.set(cwd, alongside);
3434
+ return { notes: r.notes, moved };
3435
+ };
3436
+ const find = (n) => inFolder.find((x) => x.name.toLowerCase() === n.toLowerCase());
3437
+ return {
3438
+ names: () => [mainName, ...inFolder.map((x) => x.name)],
3439
+ startMissing: async () => {
3440
+ const missing = inFolder.filter((c) => !envUp.has(c.path));
3441
+ if (!missing.length)
3442
+ return companionNotes.get(cwd)?.notes ?? [];
3443
+ await stopOtherEnvironments(cwd);
3444
+ return (await bring(missing)).notes;
3445
+ },
3446
+ start: async (names) => {
3447
+ const unknown = names.filter((n) => !find(n) && n.toLowerCase() !== mainName.toLowerCase());
3448
+ if (unknown.length)
3449
+ return { ok: false, text: `Not in your card's folder: ${unknown.join(', ')}. It holds: ${[mainName, ...inFolder.map((x) => x.name)].join(', ')}.` };
3450
+ await stopOtherEnvironments(cwd);
3451
+ const cs = names.map(find).filter((c) => !!c);
3452
+ const { notes, moved } = cs.length ? await bring(cs) : { notes: [], moved: false };
3453
+ const wantMain = names.some((n) => n.toLowerCase() === mainName.toLowerCase());
3454
+ let text = notes.join('\n');
3455
+ if (wantMain || (moved && env.ran))
3456
+ text += `${text ? '\n' : ''}${moved && !wantMain ? `An address changed, so ${mainName} was restarted to use it. ` : ''}${await restartMain()}`;
3457
+ return { ok: !/not running|did not start|did not come up|deployed one is used/.test(text), text: text || 'Nothing to start.' };
3458
+ },
3459
+ saveScript: async (name, path, body) => {
3460
+ const c = find(name);
3461
+ if (!c)
3462
+ return { ok: false, text: `There is no "${name}" in your card's folder. It holds: ${[mainName, ...inFolder.map((x) => x.name)].join(', ')}.` };
3463
+ await stopEnv(c.path).catch(() => false);
3464
+ envUp.delete(c.path);
3465
+ const started = await startEnvironment(c.path, (t) => log('tool', `${c.name}: ${t}`), undefined, { script: path });
3466
+ if (!started.ok || !Object.keys(started.endpoints).length) {
3467
+ const detail = (started.detail || '').trim().split('\n').slice(-12).join('\n');
3468
+ return { ok: false, text: `${c.name} did not come up${started.ok ? ' with an address (no name=url line)' : ''}, so it was not saved.\n\n${detail || '(it printed nothing)'}\n\nFix it and try again.` };
3469
+ }
3470
+ const saved = await saveStartScriptFor(c.repository, body);
3471
+ const kept = keepStartScript(c.repository, body) ?? path;
3472
+ envUp.set(c.path, started);
3473
+ envHow.set(c.path, { ...(c.script ? { declared: c.script } : {}), stored: kept });
3474
+ const url = Object.values(started.endpoints)[0];
3475
+ const before = { ...alongside.env };
3476
+ alongside.env[envName(c.type || 'service')] = url;
3477
+ alongside.env[envName(serviceDir(c.repository))] = url;
3478
+ companionNotes.set(cwd, { env: alongside.env, notes: [...(companionNotes.get(cwd)?.notes ?? []).filter((n) => !n.startsWith(`${c.name}:`)), `${c.name}${c.type ? ` (${c.type})` : ''}: running from your folder, ${url}`] });
3479
+ writeFolderScript(tree, (r) => storedStartScript(config.startScripts, r), where.kind === 'repo' ? where.checkout.origin : '', where.kind === 'repo' ? where.script : undefined);
3480
+ const moved = Object.entries(alongside.env).some(([k, v]) => before[k] !== v);
3481
+ const main = moved && env.ran ? `\n\n${mainName} was restarted to use it. ${await restartMain()}` : '';
3482
+ return saved.ok
3483
+ ? { ok: true, text: `Saved in FlareDeck for ${c.name}, and running from your folder: ${url}.${main}\n\nTell your lead in one line that ${c.name} now has a start script.` }
3484
+ : { ok: false, text: `It works — ${c.name} is running at ${url} — but FlareDeck did not keep it: ${saved.message ?? 'no reason given'}. Say so on the card.${main}` };
3485
+ },
3486
+ };
3487
+ })(),
3488
+ } : {}),
3628
3489
  // The same deployed addresses the browser gets, so a sign-in can be asked for the dev or
3629
3490
  // UAT site and not only for something this card started.
3630
3491
  sites: () => declaredSites,
@@ -3642,145 +3503,42 @@ export async function serve(opts) {
3642
3503
  },
3643
3504
  onNote: (t) => log('tool', t),
3644
3505
  beforeStart: () => stopOtherEnvironments(cwd),
3645
- /**
3646
- * Turning "ask somebody" into something that actually asks.
3647
- *
3648
- * Three pieces already worked and were never joined up: open_page DETECTS a sign-in wall
3649
- * and says which kind it is, signInSession captures a session a person completes by hand,
3650
- * and loadSession replays it for every later card. The gap was the middle. All wallBrief
3651
- * could do was tell the agent in prose to ask, with no tool to call — so the agent wrote a
3652
- * comment, flagged the card and stopped. That was the only thing available to it, and it
3653
- * read like a model that would not try.
3654
- *
3655
- * So: start the viewer, flag the card with the address (a flag is what reaches a phone),
3656
- * and when the session lands, store it, clear the flag and put the card back at the front
3657
- * of the queue. The agent's next turn opens the app already signed in.
3658
- *
3659
- * The viewer is LOOPBACK, deliberately: it drives a browser part-way through somebody's
3660
- * sign-in, which is not a thing to publish on a reachable port. So the person has to be at
3661
- * this machine. That is the case this is for — an agent testing localhost on a developer's
3662
- * own box — and for a cloud agent it is a stated limit rather than a silent failure.
3663
- */
3506
+ // Asking a person to sign in, through FlareDeck's relay: see features/sign-in-request.ts.
3664
3507
  ...(onCard
3665
3508
  ? {
3666
- requestSignIn: async ({ part, url, why }) => {
3667
- const card = onCard;
3668
- if (!card)
3669
- return { asked: false, why: 'the card went away while this was being asked' };
3670
- if (!(await ensureBrowser((t) => log('tool', `browser: ${t}`)))) {
3671
- return { asked: false, why: 'this machine has no browser and could not fetch one' };
3672
- }
3673
- const { chromium } = await import('playwright-core');
3674
- const { signInSession } = await import('./signin.js');
3675
- const { rememberSignInNow, HEADLESS_FULL } = await import('./browser.js');
3676
- const { randomBytes } = await import('node:crypto');
3677
- /* Through FlareDeck, not through a port on this machine. The person may be on a
3678
- phone and the agent may be on a cloud box, which is the case this exists for;
3679
- loopback only ever served the one where they are in the same room. */
3680
- const id = `sgn_${randomBytes(9).toString('base64url')}`;
3681
- /* agent-service, not the MCP. The relay was first built onto flaredeck-mcp, which
3682
- cannot host it: the MCP deploys as a Firebase Function, and a Function hands you
3683
- (req, res) with no http.Server underneath, so a WebSocket upgrade can never
3684
- complete there. agent-service is a real Cloud Run service and already the one
3685
- this harness holds an event stream open to. */
3686
- const serviceUrl = process.env['FLAREDECK_RELAY_URL'] ?? config.agentServiceUrl;
3687
- if (!serviceUrl) {
3688
- // Configured by default, so this means somebody blanked it. Say which setting.
3689
- return { asked: false, why: 'this harness has no agentServiceUrl, so a sign-in cannot be relayed' };
3690
- }
3691
- const relayBase = serviceUrl.replace(/^http/, 'ws').replace(/\/+$/, '');
3692
- const appBase = (process.env['FLAREDECK_APP_URL'] ?? 'https://app.flaredeck.app').replace(/\/+$/, '');
3693
- const viewerUrl = (t) => `${appBase}/signin/${id}?t=${encodeURIComponent(t)}`;
3694
- /* Resolve as far as the ADDRESS and no further. Waiting for the sign-in itself would
3695
- hold the agent's tool call open for as long as a person takes to get to their
3696
- machine, and not waiting is the whole point: the card parks, the turn ends. */
3697
- let settle;
3698
- const ready = new Promise((r) => { settle = r; });
3699
- const ref = {
3700
- card: card.card,
3701
- ...(card.board ? { board: card.board } : {}),
3702
- ...(card.code ? { code: card.code } : {}),
3703
- };
3704
- // Held up while the person has not signed in yet — see signInPending.
3705
- const signCwd = turnCwd;
3706
- if (signCwd)
3707
- signInPending.set(signCwd, Date.now());
3708
- /* The page's console, forwarded — but a page retrying something gone repeats one
3709
- line hundreds of times. The first is logged; repeats are counted and summarised. */
3710
- let lastNote = '';
3711
- let repeats = 0;
3712
- const pageNote = (t) => {
3713
- if (t === lastNote) {
3714
- repeats++;
3715
- if (repeats === 3 || repeats % 50 === 0)
3716
- log('tool', `sign-in: (the same line ${repeats + 1}× so far) ${t.slice(0, 120)}`);
3717
- return;
3718
- }
3719
- lastNote = t;
3720
- repeats = 0;
3721
- log('tool', `sign-in: ${t}`);
3722
- };
3723
- void signInSession({
3724
- url,
3725
- launch: () => chromium.launch(HEADLESS_FULL),
3726
- relay: { url: relayBase, token: await opts.auth(), id, viewerUrl },
3727
- onReady: (viewer) => {
3728
- log('tool', `sign-in wanted for "${part}" — ${viewer}`);
3729
- void flagCard(ref, `${url} needs someone to sign in before this card can go on. ${why}\n\n` +
3730
- `Open the sign-in below and do it there. It is this agent's real browser, ` +
3731
- `shown live — click and type in the picture, then press Save & finish. The ` +
3732
- `session is kept for the whole project, so this is once, not once per card.`, 'sign_in', undefined, [{ url: viewer, label: 'Sign in' }]);
3733
- settle({ asked: true, where: viewer });
3734
- },
3735
- onNote: pageNote,
3736
- })
3737
- .then(async (r) => {
3738
- if (signCwd)
3739
- signInPending.delete(signCwd);
3740
- if (!r.ok || !r.session) {
3741
- log('warn', `sign-in for "${part}" did not finish: ${r.why ?? 'nothing was saved'}`);
3742
- /* Say so ON THE CARD. The flag already raised carries a link to a viewer
3743
- that is now dead, and a person coming back to it an hour later would find
3744
- a page that cannot connect and no indication why — which reads as the
3745
- feature being broken rather than as a window having closed. */
3746
- void flagCard(ref, `The sign-in for ${url} expired before anyone did it — the link in the ` +
3747
- `earlier request no longer works.\n\n${why}\n\nNothing is broken: ask ` +
3748
- `the agent to try again and a fresh sign-in is offered.`, 'sign_in');
3749
- settle({ asked: false, why: r.why ?? 'nothing was saved' });
3750
- return;
3751
- }
3752
- /* Awaited, and the failure is handled. Sessions live only in FlareDeck now —
3753
- there is no copy on this machine — so a failed write means the sign-in is
3754
- GONE. Announcing "Signed in" and resuming the card would wake an agent that
3755
- meets the same wall and asks again: a loop that reads as the feature being
3756
- broken rather than as one write having failed. */
3757
- const saved = await rememberSignInNow(config.repo, part, r.session, (t) => log('tool', `sign-in: ${t}`));
3758
- if (!saved.ok) {
3759
- log('warn', `sign-in for "${part}" completed but was NOT saved: ${saved.message ?? 'no reason given'}`);
3760
- void flagCard(ref, `Someone signed in to ${url}, but the session could not be saved to FlareDeck ` +
3761
- `(${saved.message ?? 'no reason given'}). Credentials are kept only in FlareDeck, ` +
3762
- `so nothing was retained here and the sign-in has been lost.\n\n${why}\n\n` +
3763
- `Once FlareDeck is reachable, ask the agent to try again.`, 'sign_in');
3764
- settle({ asked: false, why: saved.message ?? 'the session could not be saved' });
3765
- return;
3766
- }
3767
- const site = saved.id;
3768
- log('tool', `signed in — kept for ${site}; every card on this project opens straight in`);
3509
+ requestSignIn: requestSignIn({
3510
+ card: () => onCard,
3511
+ relayService: process.env['FLAREDECK_RELAY_URL'] ?? config.agentServiceUrl,
3512
+ appBase: process.env['FLAREDECK_APP_URL'] ?? 'https://app.flaredeck.app',
3513
+ token: () => opts.auth(),
3514
+ ensureBrowser: () => ensureBrowser((t) => log('tool', `browser: ${t}`)),
3515
+ runSignIn: async (o) => {
3516
+ const { chromium } = await import('playwright-core');
3517
+ const { signInSession } = await import('./signin.js');
3518
+ const { HEADLESS_FULL } = await import('./browser.js');
3519
+ return signInSession({ ...o, launch: () => chromium.launch(HEADLESS_FULL) });
3520
+ },
3521
+ remember: async (part, session) => {
3522
+ const { rememberSignInNow } = await import('./browser.js');
3523
+ return rememberSignInNow(config.repo, part, session, (t) => log('tool', `sign-in: ${t}`));
3524
+ },
3525
+ flag: (ref, reason, links) => void flagCard(ref, reason, 'sign_in', undefined, links),
3526
+ clearFlag: async (ref) => {
3769
3527
  await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'clear-needs-human', {
3770
- card_id: card.code || card.card,
3771
- ...(card.board ? { board_id: card.board } : {}),
3772
- }).catch((e) => log('warn', `could not clear needs-human: ${e instanceof Error ? e.message : String(e)}`));
3773
- parked.delete(card.card);
3774
- resumeParked(card, `Signed in. The session is kept for ${site}, so open_page opens the app signed ` +
3775
- `in from now on. Carry on with what you were going to check: ${why}`);
3776
- })
3777
- .catch((e) => {
3778
- const why2 = e instanceof Error ? e.message : String(e);
3779
- log('warn', `sign-in for "${part}" failed: ${why2}`);
3780
- settle({ asked: false, why: why2 });
3781
- });
3782
- return ready;
3783
- },
3528
+ card_id: ref.code || ref.card, ...(ref.board ? { board_id: ref.board } : {}),
3529
+ });
3530
+ },
3531
+ resume: (card, text) => { parked.delete(card.card); resumeParked({ ...card, cwd }, text); },
3532
+ pending: (() => {
3533
+ // Held up while the person has not signed in yet — see signInPending.
3534
+ let at;
3535
+ return { start: () => { at = turnCwd; if (at)
3536
+ signInPending.set(at, Date.now()); }, done: () => { if (at)
3537
+ signInPending.delete(at); } };
3538
+ })(),
3539
+ newId: () => `sgn_${randomBytes(9).toString('base64url')}`,
3540
+ log,
3541
+ }),
3784
3542
  }
3785
3543
  : {}),
3786
3544
  });
@@ -3810,95 +3568,248 @@ export async function serve(opts) {
3810
3568
  * made about its card that had not been made at all.
3811
3569
  */
3812
3570
  if (forACard && !needsRunning) {
3813
- log('tool', isManager
3571
+ log('tool', profile.manage
3814
3572
  ? 'environment not started — a manager answers from the card and its teams\' machines; call start_environment if you need this project running'
3815
3573
  : prepared
3816
3574
  ? 'environment not started — this card does not look like it needs one'
3817
3575
  : 'environment not started — nothing judged whether it is needed; call start_environment if it is');
3818
3576
  }
3819
- // A lead's sweep is a JOB, not a nudge: clear the ready column by running the team. A nudge
3820
- // got one look and "nothing to do"; a goal keeps the lead going, turn after turn, until the
3821
- // board itself says it is done — proven by get-portfolio, not by the lead's own say-so.
3822
- // Nothing ready means nothing to dispatch: a goal there is only turns spent proving an empty
3823
- // list empty. ['?'] is "could not tell", which keeps the goal.
3824
- const leadGoal = isLead && event.event === 'sweep' && process.env['FLAREDECK_AGENT_LEAD_GOAL'] !== '0' &&
3825
- (await readyToHand()).length > 0
3826
- ? LEAD_SWEEP_GOAL
3827
- : undefined;
3828
- if (leadGoal)
3829
- log('tool', 'sweep with a goal: clear the ready column by dispatching (≤8 turns)');
3577
+ return { forACard, checks, needsRunning, declaredSites, web, envTools, eyes, envNow: () => env, alongsideNotes: alongside.notes };
3578
+ }
3579
+ /**
3580
+ * A turn, running: every event it emits, as it emits it. The pane, the ledger and the live feed
3581
+ * are told; the handover log notes what changed; a turn fighting the tools is told to stop and
3582
+ * ask; one near the end of its context is asked to wrap up; a session whose FlareDeck server did
3583
+ * not connect is refused. Returns how big the context got, the final text, and any error.
3584
+ */
3585
+ async function watchTurn(run, t) {
3586
+ const { event, key, isAssistantChat, prepared, usage, struggling } = t;
3587
+ let turnTokens = 0;
3588
+ let nudged = false;
3589
+ let doneText = '';
3590
+ let runError = null;
3591
+ try {
3592
+ for await (const out of run.events) {
3593
+ status.observe(out);
3594
+ if (out.kind === 'done' && !out.ok)
3595
+ doneText = String(out.result ?? '');
3596
+ usage.observe(out);
3597
+ if (out.kind === 'spend') {
3598
+ const spent = out;
3599
+ void geminiSpend?.record(spent).then((r) => {
3600
+ if (!r) {
3601
+ log('warn', 'gemini: could not report this run\'s tokens to FlareDeck — its spend is not counted');
3602
+ return;
3603
+ }
3604
+ // The tokens behind the dollars, so a figure that looks wrong can be checked (16 Oct).
3605
+ const k = (n) => `${Math.round(n / 1000)}k`;
3606
+ log('tool', `${spendLine(r.usd, r.status)} · ${spent.model}: ${k(spent.prompt)} in (${k(spent.cached)} cached), ${k(spent.output)} out, ${k(spent.thinking)} thinking${r.known_price ? '' : ` · no price set for ${spent.model}, charged at the dearest`}`);
3607
+ if (r.status.over && !geminiOver)
3608
+ log('warn', `${r.status.over.message} — Claude answers from the next turn`);
3609
+ });
3610
+ }
3611
+ if (out.kind === 'tool' && !out.sub)
3612
+ noteHandoverTool(handoverLog, key, out.name, Date.now());
3613
+ if (out.kind === 'tool') {
3614
+ const stepBack = struggling.saw(out.name);
3615
+ if (stepBack) {
3616
+ log('warn', `${struggling.why()} this turn — telling it to stop and ask`);
3617
+ const heard = await run.steer(stepBack).catch(() => false);
3618
+ // An engine that cannot be steered gets stopped instead: letting it carry on is how a
3619
+ // turn becomes twenty minutes of working around a guard.
3620
+ if (!heard) {
3621
+ log('warn', 'cannot steer this engine — ending the turn');
3622
+ await run.stop().catch(() => { });
3623
+ }
3624
+ if (tag)
3625
+ void tellLeadItIsStuck(tag, struggling.refusals());
3626
+ }
3627
+ }
3628
+ state.tasks = status.snapshot().tasks;
3629
+ // An answer to a question typed here is the whole point of the turn, so it is shown whole;
3630
+ // a card turn's narration is one line each, the card holds the full account.
3631
+ // What the person watching an assistant sees: the main conversation only, not sub-agents.
3632
+ if (isAssistantChat)
3633
+ chat.observe(out);
3634
+ if (out.kind === 'task')
3635
+ log('tool', out.state === 'started' ? `background: ${out.text}` : `background ${out.state}: ${out.text.slice(0, 160)}`);
3636
+ if (out.kind === 'text')
3637
+ log(event.event === 'ask' ? 'answer' : 'say', out.text);
3638
+ else if (out.kind === 'tool')
3639
+ log('tool', out.name);
3640
+ // A changed plan is the most informative thing an agent emits, so it is pushed at once
3641
+ // rather than waiting for the timer.
3642
+ else if (out.kind === 'todo') {
3643
+ log('todo', out.text);
3644
+ void report();
3645
+ }
3646
+ // The plan replaces the previous one — TodoWrite always sends the whole list.
3647
+ else if (out.kind === 'plan') {
3648
+ state.plan = out.items;
3649
+ state.tasks = {
3650
+ done: out.items.filter((t) => t.status === 'completed').length,
3651
+ total: out.items.length,
3652
+ };
3653
+ void report();
3654
+ }
3655
+ else if (out.kind === 'ready') {
3656
+ // Kept only where it can be picked up again. An Antigravity conversation dies with the run
3657
+ // that held it, and resuming one that is gone used to start a fresh conversation with a
3658
+ // follow-up message and no card: the handover "that is this card done", sent after a
3659
+ // startup check, had agy searching env and old transcripts for which card it was on — and
3660
+ // a resumed turn skips intake, so it never got sized off the small model either.
3661
+ if (out.sessionId && engine.capabilities.resumeSession) {
3662
+ sessions.set(key, out.sessionId);
3663
+ if (key === ASSISTANT_KEY)
3664
+ chat.keep(out.sessionId);
3665
+ }
3666
+ // The preflight proved the server answers; this proves the SESSION got it. The SDK will
3667
+ // start the model with a server still 'pending' or 'needs-auth' and list its tools from
3668
+ // memory, so a count alone is not enough — the status has to say connected. Refuse the
3669
+ // turn otherwise: the event stays queued and comes back once the tools are really there.
3670
+ const fd = out.mcp.find((s) => s.name === config.mcpServerName);
3671
+ log('tool', `session: FlareDeck ${fd?.status ?? 'absent'} · ${out.tools.length} tools · ${out.permissionMode}`);
3672
+ if (!fd || fd.status !== 'connected' || out.tools.length === 0) {
3673
+ throw new CouldNotWork(`this session's FlareDeck server is ${fd?.status ?? 'absent'}, so the card cannot be read — leaving the event queued`);
3674
+ }
3675
+ }
3676
+ else if (out.kind === 'usage') {
3677
+ turnTokens = out.contextTokens;
3678
+ if (!nudged && turnTokens >= CONTEXT_WRAP_UP_TOKENS && liveRun) {
3679
+ nudged = true;
3680
+ log('warn', `context at ${Math.round(turnTokens / 1000)}k — asking it to wrap up before the window fills`);
3681
+ const wrapUpText = `Your context is at ${Math.round(turnTokens / 1000)}k tokens and will be compacted soon, which loses detail. ` +
3682
+ 'Finish the step you are on, then stop: comment on the card with where things stand, put anything half-done ' +
3683
+ '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.';
3684
+ void liveRun.steer(wrapUpText);
3685
+ }
3686
+ }
3687
+ else if (out.kind === 'compacted')
3688
+ log('warn', 'the SDK compacted this session mid-turn — detail before this point is summarised, not kept');
3689
+ else if (out.kind === 'done') {
3690
+ if (!out.ok)
3691
+ log('warn', 'the turn did not finish cleanly');
3692
+ for (const d of out.denials ?? [])
3693
+ log('warn', `declined by the permission check: ${d.tool}${d.what ? ` ${d.what}` : ''}${d.why ? ` — because: ${d.why}` : ''}`);
3694
+ if (out.contextTokens)
3695
+ turnTokens = out.contextTokens;
3696
+ // The number the intake step is meant to move: how big this turn's context got.
3697
+ if (turnTokens)
3698
+ log('tool', `turn: ${Math.round(turnTokens / 1000)}k context${prepared?.size ? ` · ${prepared.size} work` : ''}`);
3699
+ }
3700
+ }
3701
+ }
3702
+ catch (e) {
3703
+ runError = e;
3704
+ }
3705
+ return { turnTokens, doneText, runError };
3706
+ }
3707
+ async function handle(input) {
3708
+ const admitted = await admit(input);
3709
+ if (!admitted)
3710
+ return;
3711
+ const { isAssistantChat, queuedBehind } = admitted;
3712
+ const placed = await placeTurn(admitted.event, isAssistantChat);
3713
+ if (!placed)
3714
+ return;
3715
+ const { event, key, where, chosen, repo, startFrom, tree, cwd, leadWithoutCode, repoChoiceNote, pickedUp } = placed;
3716
+ // FlareDeck must be there before the model is. Without it the turn cannot read the card,
3717
+ // comment, or ask for help — and it would still run, answer fluently that it could not do
3718
+ // the work, and be acknowledged. The card would be gone. So: no FlareDeck, no turn.
3719
+ // The plan for this run is asked for now, alongside the check, instead of after everything
3720
+ // else: one more round trip the person was waiting on. Awaited where it is used.
3721
+ const subsEarly = subscriptionsForAgent(config.mcpUrl, () => opts.auth(), (t) => log('tool', t));
3722
+ const planEarly = subsEarly.then((sc) => (sc ? sc.choose(failedPlans.get(event.id) ?? []) : null)).catch(() => null);
3723
+ const check = await preflightFlaredeck(config.mcpUrl, await opts.auth());
3724
+ if (!check.ok) {
3725
+ log('warn', check.reason);
3726
+ throw new CouldNotWork(`${check.reason} — event left queued`);
3727
+ }
3728
+ log('tool', `FlareDeck reachable · ${check.discovered.tools.length} tools`);
3729
+ // Served from in here for this turn: a fresh token per call, so a card that outlives the
3730
+ // token's hour keeps its tools, and nothing on this machine can shadow the server by name.
3731
+ // The gate on calling a card finished: it must have commits of its own, published. Checked
3732
+ // here rather than trusted, because a turn that produced nothing still reports confidently.
3733
+ const gate = finishGate({
3734
+ ...(opts.deck ? { card: (ref) => opts.deck.card(ref) } : {}),
3735
+ cardRef: () => tag || event.cardCode || event.cardKey || '',
3736
+ cardCode: () => event.cardCode || tag,
3737
+ tree,
3738
+ startFrom,
3739
+ oversees: profile.oversees,
3740
+ lead: leadId ? { id: leadId, name: leadName } : null,
3741
+ selfId,
3742
+ handedBack,
3743
+ handToLead: async (code, message) => {
3744
+ const token = await opts.auth();
3745
+ await callFlaredeckTool(config.mcpUrl, token, 'assign-card', { card_id: code, assignee: leadId });
3746
+ await callFlaredeckTool(config.mcpUrl, token, 'send-message', { to: leadId, message });
3747
+ },
3748
+ log,
3749
+ });
3750
+ // A turn woken by a message says "a message from JARVIS" for as long as it runs, even while
3751
+ // it is plainly working a card. The card tools name the card; this takes it from there, and
3752
+ // only when the turn had none of its own.
3753
+ // Ground truth for the footer on anything this agent says. Null without a worktree: there is
3754
+ // no branch to report on, and a footer saying "nothing committed" would be misleading rather
3755
+ // than informative.
3756
+ const savedSoFar = async () => {
3757
+ if (!tree)
3758
+ return null;
3759
+ const { commits, pushed, branch, known } = await produced(tree);
3760
+ return { branch, commits, pushed, known };
3761
+ };
3762
+ const noteCard = (code) => {
3763
+ if (!code || tag)
3764
+ return;
3765
+ tag = code;
3766
+ state.working = code;
3767
+ };
3768
+ const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), gate, noteCard, keepTool, savedSoFar, [...myBoards]);
3769
+ const card = await prepareCard(event, key, repo, queuedBehind);
3770
+ if (!card)
3771
+ return;
3772
+ const { resume, prepared } = card;
3830
3773
  /**
3831
- * Which Claude subscription pays for this run.
3774
+ * Which card this turn is about — including when nobody dispatched it.
3832
3775
  *
3833
- * Three subscriptions and twenty-odd agents, with daily and weekly caps that trip at
3834
- * different times, used to mean going round the machines logging in again. FlareDeck holds
3835
- * the tokens and the ledger now and picks one per run.
3776
+ * This was set only from the event's own cardKey, so a question typed in the pane about a
3777
+ * card had none: the agent had the card, its checkout and its environment, hit a sign-in it
3778
+ * could not pass, and was told "there is no card being worked, so there is nobody to ask
3779
+ * about it". It fell back to leaving a comment, which nobody is notified about. The one
3780
+ * moment the escalation exists for, and it was unavailable.
3836
3781
  *
3837
- * Not reaching FlareDeck is NOT the same as having nothing left: `machine` means carry on
3838
- * with whatever this box is logged in as, exactly as before any of this existed. Only
3839
- * `none` — every subscription genuinely spent or paused — stops the run, and it says when
3840
- * one frees up rather than failing blankly.
3782
+ * `tag` is the code the turn resolved to — from the event, or from the card a pane question
3783
+ * named. FlareDeck takes a human code wherever it takes a card id, so the code alone is
3784
+ * enough to flag, comment and clear.
3841
3785
  */
3842
- /* The MCP list, fresh for this session — and a required server that is not connected stops
3843
- the work here, before a plan is spent on a card that cannot be done properly. */
3844
- const missingMcp = await refreshMcp();
3845
- if (missingMcp?.length) {
3846
- await enterPause(mcpWallFor(missingMcp));
3847
- return;
3848
- }
3849
- const subs = await subsEarly.catch(() => null);
3850
- const claudePlan = await planEarly;
3851
- if (claudePlan?.use === 'token') {
3852
- log('tool', `claude: ${claudePlan.label}`);
3853
- // On the identity line too, not only in the scrollback: the scrollback moves on, and
3854
- // "which account is this agent spending" is a thing you glance at, not search for.
3855
- state.claudeAccount = claudePlan.label;
3856
- // Measured again once this event is done — see measurePlan.
3857
- turnPlan = { id: claudePlan.id, label: claudePlan.label, token: claudePlan.token };
3858
- planOfSession.set(key, claudePlan.token);
3859
- }
3860
- // Gemini answers on agy, so spent Claude plans stop it only when Claude is needed — at the
3861
- // Gemini limit, below.
3862
- if (claudePlan?.use === 'none' && engine.name !== 'antigravity') {
3863
- const when = claudePlan.availableAt ? new Date(claudePlan.availableAt).toLocaleString() : 'later';
3864
- const tried = failedPlans.get(event.id)?.length ?? 0;
3865
- failedPlans.delete(event.id);
3866
- /**
3867
- * Spent, not paused: the harness knows to the minute when a plan comes back — FlareDeck's
3868
- * answer carries the soonest reset — so it waits for it and carries on by itself. Nobody is
3869
- * asked to do anything about a clock. Only a fleet with NO reset coming (every plan paused,
3870
- * which only a person undoes) is flagged.
3871
- */
3872
- if (claudePlan.availableAt && claudePlan.availableAt > Date.now()) {
3873
- const at = claudePlan.availableAt;
3874
- if (!waitingForPlan.has(event.id)) {
3875
- // A little after the reset, so the first request is not refused for being a second early.
3876
- const timer = setTimeout(() => {
3877
- waitingForPlan.delete(event.id);
3878
- log('event', `a Claude subscription is back — resuming ${event.cardCode || event.line}`);
3879
- if (enqueue(event, true))
3880
- void drain();
3881
- }, at - Date.now() + 60_000);
3882
- timer.unref?.();
3883
- waitingForPlan.set(event.id, timer);
3884
- }
3885
- log('warn', `every Claude subscription is spent${tried ? ` (${tried} failed on this turn)` : ''} — waiting, resuming at ${new Date(at).toLocaleTimeString()}`);
3886
- return;
3786
+ onCard = event.cardKey
3787
+ ? {
3788
+ card: event.cardKey,
3789
+ cwd,
3790
+ ...(event.boardKey ? { board: event.boardKey } : {}),
3791
+ ...(event.cardCode ? { code: event.cardCode } : {}),
3887
3792
  }
3888
- log('warn', `every Claude subscription is spent or paused${tried ? ` (${tried} failed on this turn)` : ''} — the soonest frees up ${when}`);
3889
- 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` +
3890
- `${tried ? ', or just failed on this card' : ''}. ` +
3891
- `The soonest one frees up ${when}.\n\nNothing is wrong with the card. Add or unpause a ` +
3892
- `subscription on the Integrations page, or wait for the window to reset.`, 'technical');
3793
+ : tag
3794
+ ? { card: tag, cwd, code: tag, ...(event.boardKey ? { board: event.boardKey } : {}) }
3795
+ : null;
3796
+ job = await publishJob(event, key, tree, cwd, startFrom);
3797
+ const { forACard, checks, needsRunning, declaredSites, web, envTools, eyes, envNow, alongsideNotes } = await bringUpEnvironment(event, tree, cwd, where, prepared);
3798
+ // A lead's sweep is a JOB, not a nudge: clear the ready column by running the team. A nudge
3799
+ // got one look and "nothing to do"; a goal keeps the lead going, turn after turn, until the
3800
+ // board itself says it is done — proven by get-portfolio, not by the lead's own say-so.
3801
+ // Nothing ready means nothing to dispatch: a goal there is only turns spent proving an empty
3802
+ // list empty. ['?'] is "could not tell", which keeps the goal.
3803
+ const leadGoal = profile.dispatch && event.event === 'sweep' && process.env['FLAREDECK_AGENT_LEAD_GOAL'] !== '0' &&
3804
+ (await readyToHand()).length > 0
3805
+ ? LEAD_SWEEP_GOAL
3806
+ : undefined;
3807
+ if (leadGoal)
3808
+ log('tool', 'sweep with a goal: clear the ready column by dispatching (≤8 turns)');
3809
+ const planned = await choosePlan(event, key, subsEarly, planEarly);
3810
+ if (!planned)
3893
3811
  return;
3894
- }
3895
- /*
3896
- * Gemini bills per token, so a workspace sets a dollar limit on it (Integrations → Secrets).
3897
- * Reached, the turn runs on Claude instead — the same agent, its subscription chosen above —
3898
- * and Gemini answers again once the limit resets (Cobus, 16 Oct). No Claude to fall back on:
3899
- * the card waits for the reset, as it would for a spent plan.
3900
- */
3901
- let turnEngine = engine;
3812
+ const { subs, claudePlan, turnEngine } = planned;
3902
3813
  /* Helpers (helpers.ts): on by default since 17 Oct — they paid for themselves while measured.
3903
3814
  Off with `helpers: false` in the config or `--no-helpers`. The result of one running in the
3904
3815
  background is typed into THIS turn; if the turn has ended by then, it starts the next one
@@ -3917,46 +3828,16 @@ export async function serve(opts) {
3917
3828
  if (enqueue({ id: `helper-${Date.now()}`, event: 'assigned', cardKey: event.cardKey, cardCode: event.cardCode, ...(event.boardKey ? { boardKey: event.boardKey } : {}), line: `A helper you started on this card finished after your last turn ended:\n${text.slice(0, 6000)}` }))
3918
3829
  void drain();
3919
3830
  };
3920
- if (engine.name === 'antigravity') {
3921
- const spend = await geminiSpend?.status().catch(() => null);
3922
- if (spend?.over) {
3923
- if (claudePlan?.use === 'token') {
3924
- turnEngine = claudeFallback ??= new (await import('./engine-claude.js')).ClaudeEngine();
3925
- if (!geminiOver)
3926
- log('warn', `${spend.over.message} — Claude answers until ${new Date(spend.over.until).toLocaleString()}`);
3927
- geminiOver = true;
3928
- }
3929
- else {
3930
- const at = spend.over.until;
3931
- if (!waitingForPlan.has(event.id)) {
3932
- const timer = setTimeout(() => {
3933
- waitingForPlan.delete(event.id);
3934
- log('event', `the Gemini limit has reset — resuming ${event.cardCode || event.line}`);
3935
- if (enqueue(event, true))
3936
- void drain();
3937
- }, at - Date.now() + 60_000);
3938
- timer.unref?.();
3939
- waitingForPlan.set(event.id, timer);
3940
- }
3941
- log('warn', `${spend.over.message}, and there is no Claude subscription to fall back on — waiting until ${new Date(at).toLocaleString()}`);
3942
- return;
3943
- }
3944
- }
3945
- else if (geminiOver && spend) {
3946
- log('event', 'the Gemini limit has reset — Gemini answers again');
3947
- geminiOver = false;
3948
- }
3949
- }
3950
3831
  const helperSet = helpersOn && turnEngine.name === 'claude' ? helperTools({
3951
3832
  cwd: () => tree?.path ?? cwd,
3952
3833
  ...(claudePlan?.use === 'token' ? { claudeToken: claudePlan.token } : {}),
3953
3834
  // Its own browser, on the same environment the agent's points at.
3954
3835
  browser: () => browserTools({
3955
3836
  repo: config.repo,
3956
- endpoints: () => env.endpoints,
3837
+ endpoints: () => envNow().endpoints,
3957
3838
  ...(web ? { defaultPart: () => web } : {}),
3958
3839
  sites: () => declaredSites,
3959
- contract: () => env.contract,
3840
+ contract: () => envNow().contract,
3960
3841
  cwd,
3961
3842
  onNote: (t) => log('tool', t),
3962
3843
  }),
@@ -3972,7 +3853,8 @@ export async function serve(opts) {
3972
3853
  }) : null;
3973
3854
  const engineContext = {
3974
3855
  ...(leadGoal ? { goal: leadGoal } : {}),
3975
- config: { ...config, repo: cwd, ...(isAssistantChat ? { assistantMode: true } : {}) },
3856
+ // Read anywhere the work could need — the agent's folder and its alongside clones — write only here.
3857
+ config: { ...config, repo: cwd, readAlso: [config.repo, join(configDir(), 'services'), ...(tree?.folder ? [tree.folder] : [])], ...(isAssistantChat ? { assistantMode: true } : {}) },
3976
3858
  ...(isAssistantChat ? { background: true } : {}),
3977
3859
  // Only a real worktree confines anything; without one nothing is enforced here, and the
3978
3860
  // guard below makes sure a turn that would CHANGE code never gets that far.
@@ -3989,13 +3871,14 @@ export async function serve(opts) {
3989
3871
  // own. Without a worktree there is nowhere legitimate to write, so the turn gets scratch
3990
3872
  // space and has to hand the card back instead.
3991
3873
  // An assistant is not confined to a checkout: it works for one person, anywhere they need.
3992
- ...(isAssistantChat ? {} : { sandbox: tree ? tree.path : scratchDir() }),
3874
+ // A card folder is written as a whole: every repository in it is the card's.
3875
+ ...(isAssistantChat ? {} : { sandbox: tree ? tree.folder ?? tree.path : scratchDir() }),
3993
3876
  decider: sessionDecider,
3994
3877
  // Both only reachable in-process, so an out-of-process engine is handed them explicitly
3995
3878
  // rather than being left unable to publish and unchecked when it says it has.
3996
- ...(job ? { publish: async () => publishBranch(job) } : {}),
3879
+ ...(job && profile.code ? { publish: async () => publishBranch(job) } : {}),
3997
3880
  standing: standingBrief(!!teamSet, config.style ?? process.env['FLAREDECK_AGENT_STYLE'], isAssistantChat, config.mcpServers, config.trustedScripts),
3998
- finish: finishGate,
3881
+ finish: gate,
3999
3882
  noteCard,
4000
3883
  keep: keepTool,
4001
3884
  saved: savedSoFar,
@@ -4008,25 +3891,27 @@ export async function serve(opts) {
4008
3891
  ...(prepared?.size ? { size: prepared.size } : {}),
4009
3892
  ...(claudePlan?.use === 'token' ? { claudeToken: claudePlan.token } : {}),
4010
3893
  ...(helpersOn && turnEngine.name === 'antigravity' ? { subagents: true } : {}),
4011
- tools: {
4012
- ...(helperSet ? { helpers: helperSet } : {}),
4013
- ...(job ? { git: { server: gitServer, toolNames: GIT_TOOL_NAMES } } : {}),
3894
+ // What the model can do this turn, by the profile's features: see harness/tools.ts.
3895
+ tools: mountTools({
3896
+ helpers: helperSet,
3897
+ git: { server: gitServer, toolNames: GIT_TOOL_NAMES },
4014
3898
  // The project's file storage, when a person has set one up for these boards.
4015
- ...(await turnFileTools(cwd, forACard, event.environment) ?? {}),
4016
- // Eyes, for any card. They used to be mounted only when the harness had itself started
4017
- // something, which left an agent working alone unable to get them: no start script meant
4018
- // no browser AND no mention of one, so it could not know that writing the script it is
4019
- // perfectly able to write was the thing standing between it and seeing its own change.
4020
- // The endpoints are read through a getter, so something the agent starts mid-turn counts.
3899
+ files: await turnFileTools(cwd, forACard, event.environment),
3900
+ // Eyes, for any card, read through a getter so something the agent starts mid-turn counts.
4021
3901
  browser: eyes,
4022
3902
  env: envTools,
4023
3903
  team: teamSet,
4024
3904
  flaredeck,
4025
3905
  runs: runsSet,
4026
3906
  schedules: schedulesSet,
4027
- ...((config.trustedScripts ?? []).length ? { scripts: scriptsSet } : {}),
4028
- ...(releaseSet && (isLead || isManager || soloWorker || namedReleaser || event.hotfix === true) ? { release: releaseSet } : {}),
4029
- },
3907
+ scripts: scriptsSet,
3908
+ release: releaseSet,
3909
+ }, {
3910
+ profile,
3911
+ hasJob: !!job,
3912
+ mayRelease: releasesOwnCards(profile, soloWorker) || namedReleaser || event.hotfix === true,
3913
+ hasScripts: (config.trustedScripts ?? []).length > 0,
3914
+ }),
4030
3915
  guard: {
4031
3916
  ...guard,
4032
3917
  PostToolUse: [...(guard.PostToolUse ?? []), mcpSignInHook],
@@ -4037,23 +3922,30 @@ export async function serve(opts) {
4037
3922
  // Before the agent believes a single thing the environment tells it: is this environment
4038
3923
  // actually its own? A front end on an assumed port reaches another worktree's API and answers
4039
3924
  // perfectly, which is the one failure here that looks like success.
4040
- const wrongEnv = tree && env.ran ? await crossWired(env.endpoints, tree.path) : [];
3925
+ const wrongEnv = tree && envNow().ran ? await crossWired(envNow().endpoints, tree.path) : [];
4041
3926
  for (const w of wrongEnv)
4042
3927
  log('warn', w);
4043
3928
  // A direct message: the conversation's last ten messages in full, so the agent answers from
4044
3929
  // them instead of spending its first step on read-messages (the event line is shortened).
4045
3930
  const thread = event.event === 'message' && event.actorKey ? await recentThread(event.actorKey) : '';
4046
- const brief = waitedNote(event) + prompt(event, isLead) + thread + intakeText(prepared) +
3931
+ // What this agent keeps on the card's repositories — its own notes, 400 words each, loaded only
3932
+ // with cards in them (harness/repo-notes.ts).
3933
+ const notes = tree ? await repoNotes([chosen?.origin ?? '', ...(tree.companions ?? []).map((c) => c.repository)], async (q) => {
3934
+ const tid = tenantFromToken(await opts.auth());
3935
+ const r = await fetch(`${new URL(config.mcpUrl).origin}/api/v1/${encodeURIComponent(tid ?? '')}/agent/memory?repositories=${encodeURIComponent(q.join(','))}`, { headers: { authorization: `Bearer ${await opts.auth()}` }, signal: AbortSignal.timeout(10_000) });
3936
+ return r.ok ? (await r.json()).repositories ?? {} : {};
3937
+ }).catch(() => '') : '';
3938
+ const brief = waitedNote(event) + prompt(event, profile.dispatch) + thread + intakeText(prepared) +
4047
3939
  workspaceBrief(config.repo, await checkouts(config.repo, scope), chosen) +
4048
- worktreeBrief(tree) + repoChoiceNote + (await resumeBrief(event.cardCode || event.cardKey || '', tree)) +
3940
+ worktreeBrief(tree) + (tree ? folderBrief(tree) : '') + notes + repoChoiceNote + (await resumeBrief(event.cardCode || event.cardKey || '', tree)) +
4049
3941
  unclearBrief(prepared?.jev ?? null, leadName) + preflightBrief(checks) + backBrief(event) +
4050
- freeWorkerBrief(event) + (leadWithoutCode ? LEAD_WITHOUT_CODE_BRIEF : '') +
4051
- (queuedBehind ? `\n\nThis card is QUEUED behind ${queuedBehind}, which you are working now. Reply to what was said here if it needs a reply, and stop: do not start this card's work, change its code or move it. It is handed to you when ${queuedBehind} is done.` : '') +
4052
- (isManager ? MANAGER_BRIEF + (event.event === 'sweep' ? MANAGER_SWEEP_BRIEF : '') : '') +
4053
- (isAssistantChat ? assistantBrief(person) : '') +
3942
+ // What this kind of agent is told on top of the card: see roleBriefs in harness/prompt.ts.
3943
+ roleBriefs(profile, { event, chat: isAssistantChat, person: chat.person(), leadWithoutCode, queuedBehind }) +
4054
3944
  // Only tell an agent about the environment when the card actually needs one running.
4055
- environmentBrief(env, cwd, needsRunning) + notStartedBrief(forACard && !needsRunning, cwd) +
4056
- browserBrief(env.endpoints, declaredSites, forACard, web) +
3945
+ environmentBrief(envNow(), cwd, needsRunning) + notStartedBrief(forACard && !needsRunning, cwd) +
3946
+ (alongsideNotes.length && tree?.folder ? `\n\n## Running from your folder\n\n${alongsideNotes.map((n) => ` · ${n}`).join('\n')}\n` : '') +
3947
+ (alongsideNotes.length && !tree?.folder ? `\n\n## Running beside this card\n\n${alongsideNotes.map((n) => ` · ${n}`).join('\n')}\n\nYou may READ their code — and every repository in ${config.repo} — to understand how your change meets theirs; change only your own checkout.\n` : '') +
3948
+ browserBrief(envNow().endpoints, declaredSites, forACard, web) +
4057
3949
  (wrongEnv.length
4058
3950
  ? `\n\nSTOP — the environment that started is not yours:\n${wrongEnv.map((w) => ` · ${w}`).join('\n')}\n\n` +
4059
3951
  `Anything you test against it tells you about another card's code, and it will answer ` +
@@ -4065,7 +3957,7 @@ export async function serve(opts) {
4065
3957
  liveRun = run;
4066
3958
  thisRun = run;
4067
3959
  if (isAssistantChat)
4068
- liveFeed?.start(String(event.message ?? event.line ?? '').split('\n').filter(Boolean).pop() ?? '');
3960
+ chat.turnStarted(event);
4069
3961
  // Connected MCP servers' tokens, kept current for as long as the session runs. FlareDeck hands
4070
3962
  // a token out with at least five minutes left, so a check every four keeps one valid.
4071
3963
  const mcpTokens = () => JSON.stringify((config.mcpServers ?? []).map((m) => [m.name, m.token ?? '']));
@@ -4087,191 +3979,32 @@ export async function serve(opts) {
4087
3979
  renewTimer.unref();
4088
3980
  // An agent that has started fighting the tools is not going to stop on its own.
4089
3981
  const struggling = watchForDesperation();
4090
- /**
4091
- * One cap is usually seen two or three times in a run: the structured `rate_limit_event`
4092
- * (which knows the window and the reset), then the error on the assistant message and the
4093
- * result text (which know neither). Reporting all of them filed an `unknown` window with a
4094
- * guessed five-hour block on top of the real one — keeping a plan out hours past its actual
4095
- * reset. Once a run has reported a named window, the anonymous echoes are dropped.
4096
- */
4097
- let spentNamed = false;
4098
- let spentAny = false;
4099
- const spentReports = [];
4100
- let doneText = '';
4101
- let runError = null;
4102
- try {
4103
- for await (const out of run.events) {
4104
- status.observe(out);
4105
- if (out.kind === 'done' && !out.ok)
4106
- doneText = String(out.result ?? '');
4107
- if (out.kind === 'limits' && claudePlan?.use === 'token') {
4108
- /* Reported, never interpreted here. 'allowed_warning' arrives while the plan is still
4109
- working — treating it as spent would retire every subscription at about half capacity
4110
- and leave the agents with nothing while each account still had headroom. */
4111
- if (out.spent) {
4112
- const named = !!out.window && out.window !== 'unknown';
4113
- spentAny = true;
4114
- if (named || !spentNamed) {
4115
- spentNamed ||= named;
4116
- spentReports.push(subs?.spent(claudePlan.id, out.window, out.resetsAt, out.why ?? `hit during ${event.cardCode ?? event.cardKey ?? 'a run'}`) ?? Promise.resolve());
4117
- log('warn', `claude: ${claudePlan.label} hit its ${out.window ?? 'usage'} limit (${out.source ?? 'unknown signal'})`);
4118
- }
4119
- }
4120
- else if (out.window && typeof out.utilization === 'number') {
4121
- turnSawUsage = true;
4122
- void subs?.seen(claudePlan.id, out.window, out.utilization, out.resetsAt);
4123
- }
4124
- }
4125
- if (out.kind === 'spend') {
4126
- const spent = out;
4127
- void geminiSpend?.record(spent).then((r) => {
4128
- if (!r) {
4129
- log('warn', 'gemini: could not report this run\'s tokens to FlareDeck — its spend is not counted');
4130
- return;
4131
- }
4132
- // The tokens behind the dollars, so a figure that looks wrong can be checked (16 Oct).
4133
- const k = (n) => `${Math.round(n / 1000)}k`;
4134
- log('tool', `${spendLine(r.usd, r.status)} · ${spent.model}: ${k(spent.prompt)} in (${k(spent.cached)} cached), ${k(spent.output)} out, ${k(spent.thinking)} thinking${r.known_price ? '' : ` · no price set for ${spent.model}, charged at the dearest`}`);
4135
- if (r.status.over && !geminiOver)
4136
- log('warn', `${r.status.over.message} — Claude answers from the next turn`);
4137
- });
4138
- }
4139
- if (out.kind === 'tool' && !out.sub)
4140
- noteHandoverTool(handoverLog, key, out.name, Date.now());
4141
- if (out.kind === 'tool') {
4142
- const stepBack = struggling.saw(out.name);
4143
- if (stepBack) {
4144
- log('warn', `${struggling.why()} this turn — telling it to stop and ask`);
4145
- const heard = await run.steer(stepBack).catch(() => false);
4146
- // An engine that cannot be steered gets stopped instead: letting it carry on is how a
4147
- // turn becomes twenty minutes of working around a guard.
4148
- if (!heard) {
4149
- log('warn', 'cannot steer this engine — ending the turn');
4150
- await run.stop().catch(() => { });
4151
- }
4152
- if (tag)
4153
- void tellLeadItIsStuck(tag, struggling.refusals());
4154
- }
4155
- }
4156
- state.tasks = status.snapshot().tasks;
4157
- // An answer to a question typed here is the whole point of the turn, so it is shown whole;
4158
- // a card turn's narration is one line each, the card holds the full account.
4159
- // What the person watching an assistant sees: the main conversation only, not sub-agents.
4160
- if (isAssistantChat && liveFeed) {
4161
- if (out.kind === 'text' && !out.sub)
4162
- liveFeed.add('text', out.text);
4163
- else if (out.kind === 'tool' && !out.sub)
4164
- liveFeed.add('tool', out.name);
4165
- else if (out.kind === 'task')
4166
- liveFeed.add('note', out.state === 'started' ? `Started in the background: ${out.text}` : `Background work ${out.state}: ${out.text}`);
4167
- else if (out.kind === 'done')
4168
- liveFeed.idle();
4169
- }
4170
- if (out.kind === 'task')
4171
- log('tool', out.state === 'started' ? `background: ${out.text}` : `background ${out.state}: ${out.text.slice(0, 160)}`);
4172
- if (out.kind === 'text')
4173
- log(event.event === 'ask' ? 'answer' : 'say', out.text);
4174
- else if (out.kind === 'tool')
4175
- log('tool', out.name);
4176
- // A changed plan is the most informative thing an agent emits, so it is pushed at once
4177
- // rather than waiting for the timer.
4178
- else if (out.kind === 'todo') {
4179
- log('todo', out.text);
4180
- void report();
4181
- }
4182
- // The plan replaces the previous one — TodoWrite always sends the whole list.
4183
- else if (out.kind === 'plan') {
4184
- state.plan = out.items;
4185
- state.tasks = {
4186
- done: out.items.filter((t) => t.status === 'completed').length,
4187
- total: out.items.length,
4188
- };
4189
- void report();
4190
- }
4191
- else if (out.kind === 'ready') {
4192
- // Kept only where it can be picked up again. An Antigravity conversation dies with the run
4193
- // that held it, and resuming one that is gone used to start a fresh conversation with a
4194
- // follow-up message and no card: the handover "that is this card done", sent after a
4195
- // startup check, had agy searching env and old transcripts for which card it was on — and
4196
- // a resumed turn skips intake, so it never got sized off the small model either.
4197
- if (out.sessionId && engine.capabilities.resumeSession) {
4198
- sessions.set(key, out.sessionId);
4199
- if (key === ASSISTANT_KEY)
4200
- saveAssistantSession(assistantSessionFile, out.sessionId);
4201
- }
4202
- // The preflight proved the server answers; this proves the SESSION got it. The SDK will
4203
- // start the model with a server still 'pending' or 'needs-auth' and list its tools from
4204
- // memory, so a count alone is not enough — the status has to say connected. Refuse the
4205
- // turn otherwise: the event stays queued and comes back once the tools are really there.
4206
- const fd = out.mcp.find((s) => s.name === config.mcpServerName);
4207
- log('tool', `session: FlareDeck ${fd?.status ?? 'absent'} · ${out.tools.length} tools · ${out.permissionMode}`);
4208
- if (!fd || fd.status !== 'connected' || out.tools.length === 0) {
4209
- throw new CouldNotWork(`this session's FlareDeck server is ${fd?.status ?? 'absent'}, so the card cannot be read — leaving the event queued`);
4210
- }
4211
- }
4212
- else if (out.kind === 'usage') {
4213
- turnTokens = out.contextTokens;
4214
- if (!nudged && turnTokens >= CONTEXT_WRAP_UP_TOKENS && liveRun) {
4215
- nudged = true;
4216
- log('warn', `context at ${Math.round(turnTokens / 1000)}k — asking it to wrap up before the window fills`);
4217
- const wrapUpText = `Your context is at ${Math.round(turnTokens / 1000)}k tokens and will be compacted soon, which loses detail. ` +
4218
- 'Finish the step you are on, then stop: comment on the card with where things stand, put anything half-done ' +
4219
- '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.';
4220
- void liveRun.steer(wrapUpText);
4221
- }
4222
- }
4223
- else if (out.kind === 'compacted')
4224
- log('warn', 'the SDK compacted this session mid-turn — detail before this point is summarised, not kept');
4225
- else if (out.kind === 'done') {
4226
- if (!out.ok)
4227
- log('warn', 'the turn did not finish cleanly');
4228
- for (const d of out.denials ?? [])
4229
- log('warn', `declined by the permission check: ${d.tool}${d.what ? ` ${d.what}` : ''}${d.why ? ` — because: ${d.why}` : ''}`);
4230
- if (out.contextTokens)
4231
- turnTokens = out.contextTokens;
4232
- // The number the intake step is meant to move: how big this turn's context got.
4233
- if (turnTokens)
4234
- log('tool', `turn: ${Math.round(turnTokens / 1000)}k context${prepared?.size ? ` · ${prepared.size} work` : ''}`);
4235
- }
4236
- }
4237
- }
4238
- catch (e) {
4239
- runError = e;
4240
- }
3982
+ // What this turn's Claude plan reports to the ledger, and whether it must run again on another.
3983
+ const usage = planUsage({
3984
+ plan: claudePlan?.use === 'token' ? { id: claudePlan.id, label: claudePlan.label } : null,
3985
+ subs, failed: failedPlans, eventId: event.id,
3986
+ during: event.cardCode ?? event.cardKey ?? 'a run',
3987
+ log, onSeen: () => { turnSawUsage = true; },
3988
+ });
3989
+ const { turnTokens, doneText, runError } = await watchTurn(run, { event, key, isAssistantChat, prepared, usage, struggling });
4241
3990
  clearInterval(renewTimer);
4242
3991
  liveRun = null;
4243
3992
  if (isAssistantChat)
4244
- void liveFeed?.end();
4245
- /**
4246
- * The plan failed, not the work: switch plans and run this event again, now.
4247
- *
4248
- * Two causes, told apart because they are fixed differently. A spent window is already filed
4249
- * above (awaited here, so `choose` sees it). A rejected token — "401 Invalid bearer token",
4250
- * a mis-pasted or revoked setup-token — is not capacity and is not filed as spent; it is
4251
- * excluded for this event, and says plainly that the token needs replacing.
4252
- *
4253
- * The retry resumes the same session, so work already done in the turn is not lost; it costs
4254
- * the prompt cache, which is the price of carrying on at all.
4255
- */
4256
- if (claudePlan?.use === 'token') {
4257
- const said = `${runError instanceof Error ? runError.message : runError ?? ''} ${doneText}`;
4258
- const rejected = /API Error: 401|invalid bearer token|OAuth token has expired|OAuth token (was )?revoked/i.test(said);
4259
- if (rejected || spentAny) {
4260
- await Promise.allSettled(spentReports);
4261
- const tried = [...(failedPlans.get(event.id) ?? []), claudePlan.id];
4262
- if (rejected) {
4263
- log('warn', `claude: ${claudePlan.label} — its token was rejected. Replace it on Integrations → Secrets.`);
4264
- }
4265
- if (tried.length <= MAX_PLAN_SWITCHES) {
4266
- failedPlans.set(event.id, tried);
4267
- throw new RetryOnAnotherPlan(`${claudePlan.label} ${rejected ? 'rejected its token' : 'is at its limit'} — switching subscription and trying again`);
4268
- }
4269
- failedPlans.delete(event.id);
4270
- }
4271
- }
4272
- failedPlans.delete(event.id);
3993
+ chat.turnEnded();
3994
+ // A plan that hit its limit or had its token rejected: the event runs again on another (harness/plan-usage.ts).
3995
+ await usage.settle(runError, doneText);
4273
3996
  if (runError)
4274
3997
  throw runError;
3998
+ await afterTurn({ event, key, cwd, tree, chosen, turnTokens, isAssistantChat });
3999
+ }
4000
+ /**
4001
+ * A turn, last stage: what happens to its session now. An assistant's conversation and a pane
4002
+ * question's session are kept; a scheduled run's and a message's end; a card's stays warm for the
4003
+ * next event — unless the card is finished (its checkout released, the handover written) or the
4004
+ * session has grown past the point where a fresh start beats carrying on.
4005
+ */
4006
+ async function afterTurn(t) {
4007
+ const { event, key, cwd, tree, chosen, turnTokens, isAssistantChat } = t;
4275
4008
  // An assistant's conversation is never handed over or closed: it is one long talk with one
4276
4009
  // person, compacted by the SDK as it grows, and resumed after a restart from the saved id.
4277
4010
  if (isAssistantChat)
@@ -4312,7 +4045,9 @@ export async function serve(opts) {
4312
4045
  const c = await opts.deck?.card(event.cardCode || event.cardKey);
4313
4046
  const finished = String(c?.lane?.type ?? '').toLowerCase() === 'done' || c?.done === true;
4314
4047
  if (finished) {
4315
- const { removed, why } = await releaseWorktree(chosen.path, key, (t) => log('tool', t));
4048
+ const { removed, why } = tree.folder
4049
+ ? await releaseCardFolder(tree, key, (t) => log('tool', t))
4050
+ : await releaseWorktree(chosen.path, key, (t) => log('tool', t));
4316
4051
  if (!removed && why !== 'there was no worktree') {
4317
4052
  log('warn', `${event.cardCode || key}: kept its checkout — ${why}`);
4318
4053
  }
@@ -4382,7 +4117,7 @@ export async function serve(opts) {
4382
4117
  // A worker whose card was handed on writes nothing: the notes are the new holder's now. Only
4383
4118
  // for an unfinished card — a finished one has just gone to the lead, and FlareDeck still takes
4384
4119
  // that last note. Leads and managers may write on any card, so theirs always runs.
4385
- if (!done && leadId && !isLead && !isManager && opts.deck) {
4120
+ if (!done && leadId && !profile.oversees && opts.deck) {
4386
4121
  const card = await opts.deck.card(key).catch(() => null);
4387
4122
  if (cardWentElsewhere(card, selfId)) {
4388
4123
  log('tool', `${card?.code || key}: now ${card?.assigned_to?.name || 'someone else'}'s card — no handover note from me`);
@@ -4516,7 +4251,7 @@ export async function serve(opts) {
4516
4251
  }
4517
4252
  // Paused: nothing is worked. Acknowledged, because its cards went back to the lead and the
4518
4253
  // lead decides what happens to them; carrying a backlog through a pause would replay it.
4519
- if (paused) {
4254
+ if (pause.paused()) {
4520
4255
  log('tool', `paused — left for the lead: ${event.line}`);
4521
4256
  await stream.ack([event.id]);
4522
4257
  beat();
@@ -4527,10 +4262,10 @@ export async function serve(opts) {
4527
4262
  // rather than that the agent has gone deaf.
4528
4263
  // An assistant's person is the only one it listens to: anything else is dropped HERE, before
4529
4264
  // intake reads the card and a judge is paid to size it (FRIDAY did both for a JARVIS comment).
4530
- if (isAssistant && !assistantCard(event)) {
4531
- const why = assistantIgnores(event, person?.uid ?? null);
4265
+ if (profile.chat && !assistantCard(event)) {
4266
+ const why = chat.ignores(event);
4532
4267
  if (why) {
4533
- log('event', `${event.cardCode || event.event}: ${why} — not for ${person?.name ?? 'anyone'}'s assistant`);
4268
+ log('event', `${event.cardCode || event.event}: ${why} — not for ${chat.person()?.name ?? 'anyone'}'s assistant`);
4534
4269
  await stream.ack([event.id]);
4535
4270
  beat();
4536
4271
  continue;
@@ -4539,9 +4274,9 @@ export async function serve(opts) {
4539
4274
  // Set from the turn's closure, which the compiler cannot see from this loop.
4540
4275
  const run = liveRun;
4541
4276
  if (run) {
4542
- const heard = await run.steer(midTurnMessage(event, person?.name ?? 'your person'), []).catch(() => false);
4277
+ const heard = await run.steer(chat.midTurn(event), []).catch(() => false);
4543
4278
  if (heard !== false) {
4544
- log('event', `${person?.name ?? 'your person'}: ${String(event.message ?? event.line ?? '').slice(0, 120)} — passed into the running turn`);
4279
+ log('event', `${chat.person()?.name ?? 'your person'}: ${String(event.message ?? event.line ?? '').slice(0, 120)} — passed into the running turn`);
4545
4280
  await stream.ack([event.id]);
4546
4281
  beat();
4547
4282
  continue;
@@ -4587,8 +4322,8 @@ export async function serve(opts) {
4587
4322
  // that never arrives leaves the agent looking alive to everyone reading FlareDeck.
4588
4323
  await report();
4589
4324
  ui?.stop();
4590
- if (restarting && restartTo)
4591
- relaunchSelf(restartTo);
4325
+ if (restarter.relaunchTo())
4326
+ relaunchSelf(restarter.relaunchTo());
4592
4327
  }
4593
4328
  }
4594
4329
  /**
@@ -4607,23 +4342,6 @@ function relaunchSelf(version) {
4607
4342
  execve(process.execPath, args, nextEnv);
4608
4343
  void import('./relaunch.js').then(async ({ runChild }) => process.exit(await runChild(process.execPath, args.slice(1), nextEnv)));
4609
4344
  }
4610
- /**
4611
- * What the agent is actually asked.
4612
- *
4613
- * The event's own one-line rendering is the instruction — it already says what happened and to
4614
- * whom — with only enough added to make clear that acting is expected and that stopping quietly
4615
- * is not an option.
4616
- */
4617
- /** What a pull request should target — the repo's own default, not a guess. */
4618
- /**
4619
- * A few words for the pane from a needs-human reason: its first sentence, without a leading
4620
- * "1." — a reason written as a numbered list was shown as "waiting on a person (1)".
4621
- */
4622
- export function parkLabel(reason) {
4623
- const plain = reason.replace(/\*\*/g, '').replace(/^[\s#*>-]*(?:\d+[.)]\s*)?/, '');
4624
- const first = plain.split(/(?<=[a-z0-9)])[.?!](?:\s|$)|\n/i)[0] ?? '';
4625
- return first.trim().slice(0, 60) || 'an agent asked for help';
4626
- }
4627
4345
  const SIZE_RANK = { small: 0, medium: 1, large: 2 };
4628
4346
  async function baseBranch(cwd) {
4629
4347
  const { spawn } = await import('node:child_process');
@@ -4635,148 +4353,10 @@ async function baseBranch(cwd) {
4635
4353
  c.on('error', () => resolve('main'));
4636
4354
  });
4637
4355
  }
4638
- /**
4639
- * What a lead's sweep must achieve, as a `/goal` condition (under 4000 characters). Judged by a
4640
- * small model from the transcript alone, so the proof has to be IN the transcript: a final
4641
- * get-portfolio, quoted. Bounded, because a column that cannot be emptied (no free worker, every
4642
- * card blocked) must end in reasons, not in a loop.
4643
- */
4644
- // One list, named once: what get-portfolio (state todo) returns. It said "the Ready For Dev lane"
4645
- // and then judged by get-portfolio's todo list, which also holds cards in other todo-type lanes;
4646
- // JARVIS reported "0 in Ready For Dev" turn after turn, never matching, to the 8-turn limit.
4647
- /**
4648
- * The cards in a compact get-portfolio listing that nobody holds — what a lead can actually hand
4649
- * out. A card already assigned to a person or an agent is theirs: counting those made every
4650
- * sweep a dispatch job with nothing to dispatch (HR Goat's pool held eight cards, six of them
4651
- * Cobus's and two the lead's own, and JARVIS was set a goal about them every ten minutes).
4652
- */
4653
- export function unownedCards(listing) {
4654
- const out = new Set();
4655
- for (const line of listing.split('\n')) {
4656
- const m = /^- \[[a-z_]+\] ([A-Z][A-Z0-9]*-\d+)\b.* — owner=(\S+)/.exec(line);
4657
- if (m && m[2] === '\u2014')
4658
- out.add(m[1]);
4659
- }
4660
- return [...out];
4661
- }
4662
4356
  /** Does the harness bring the project up before this turn? What intake judged — and never for a manager. */
4663
4357
  export function wantsEnvironment(manager, needsRunning) {
4664
4358
  return !manager && (needsRunning ?? 0) >= 0.5;
4665
4359
  }
4666
- export const LEAD_SWEEP_GOAL = 'Every card that get-portfolio (state todo) lists for your boards has been dealt with: handed to ' +
4667
- 'a worker that reports to you and is free, with assign-card, or left where it is with a one-line ' +
4668
- 'reason it must wait (no free worker, blocked, waiting on a person). You dispatch; you do not ' +
4669
- 'build cards yourself. The goal is met when your most recent turn called get-portfolio with ' +
4670
- 'state todo and you quoted how many cards it listed and, for each one still unassigned, its ' +
4671
- 'one-line reason. One call is enough: do not call get-portfolio again to re-check a list that ' +
4672
- 'has not changed. The harness ends this after 8 turns whatever the state of the goal.';
4673
- /**
4674
- * What a manager is told on every turn, on top of its own description in FlareDeck.
4675
- *
4676
- * It says what this harness gives a manager TODAY and, as plainly, what it does not yet stop:
4677
- * the approval, heads-up and escalation steps of docs/spec-manager-role.md are not built, so the
4678
- * limits below are instructions rather than guards. They come out as each guard goes in.
4679
- */
4680
- export const MANAGER_BRIEF = '\n\nYou are a MANAGER, not a worker. Nobody above you hands you cards or reviews them; you ' +
4681
- 'answer to the person named in your description.\n' +
4682
- ' · `list-agents` shows your teams — the leads that report to you and their agents — what each ' +
4683
- 'is working on, how long since it checked in, and the machine it runs on (disk, memory, load, ' +
4684
- 'what is wrong there).\n' +
4685
- ' · `machine-exec` runs a command on one of those agents\' machines, as that agent\'s user: ' +
4686
- 'read a log, see what is running, restore a missing file, stop servers a dead checkout left ' +
4687
- 'behind. It runs under YOUR judgement, not the worker\'s guards, and every call is recorded.\n' +
4688
- 'Your job is to unblock: do not build a worker\'s card for it. When an agent is stuck, find out ' +
4689
- 'why and fix the cause, or tell its lead what you found.\n' +
4690
- 'Your teams\' TECHNICAL, ACCESS and INFORMATION questions come to you before any person; ' +
4691
- 'product, design and client questions go straight to the person the board names, and are not ' +
4692
- 'yours. When one arrives, read the card and settle it if you can: look the answer up, fix the ' +
4693
- 'machine, or answer from what the board already decided — then say so on the card and ' +
4694
- 'clear-needs-human, so the agent carries on. If it turns out to need a person, pass it up: ' +
4695
- 'set-needs-human on that card with the question, its options, your recommendation, and the kind ' +
4696
- 'of question it really is. It goes to the person the board names for that kind.\n' +
4697
- 'MERGING AND PROMOTING go through the `release` tool, and only through it. The board decides who may ' +
4698
- 'release into each environment and who must agree first, and the tool enforces that and asks them ' +
4699
- 'by itself — so do NOT ask a person for a merge or promote the board already lets you do, and do not ' +
4700
- 'write merge scripts or keep a clone for them. If release refuses, its reason is the answer.\n' +
4701
- 'NOT YET GUARDED HERE, so until it is, you do not do these without the person\'s say-so, asked ' +
4702
- 'for with set-needs-human naming the exact action: deploying to production by any route other than ' +
4703
- 'release; deleting directories, checkouts or data on a shared machine; changing board settings, ' +
4704
- 'access or credentials; moving a card out of Backlog or a hidden lane.' +
4705
- '\n · A place of your own that outlives any card: `~/.config/flaredeck-agent/scratch/`. Your ' +
4706
- 'card\'s checkout is for that card; do not make clones anywhere else.';
4707
- /** A manager's regular check is of its TEAMS, not of cards assigned to it. */
4708
- export const MANAGER_SWEEP_BRIEF = '\n\nThis check is of your teams. Call `list-agents`. Look for: an agent with no word for a long ' +
4709
- 'time, or stopped; one stuck on the same card with work queued behind it; a machine short of ' +
4710
- 'disk or memory, or an agent running in a bare SSH session; a question to a person that has ' +
4711
- 'waited long. Act on what you can fix yourself within the limits above. If everything is in ' +
4712
- 'order, say so in one line and stop — do not message anyone to report that nothing happened.';
4713
- /** For a lead handed a card in a folder with no code of its own. See leadWithoutCode. */
4714
- export const LEAD_WITHOUT_CODE_BRIEF = '\n\nYou have no checkout of this card\'s code here, so you cannot build it yourself — and you ' +
4715
- 'do not need to: you lead. Hand it to a worker who reports to you and is free, with assign-card ' +
4716
- 'and a one-line note of what is wanted. If none is free, say so on the card and leave it assigned ' +
4717
- 'to you; do not ask a person to set up a repository.';
4718
- /** A "moved to X" event when the card now sits somewhere else. Unknown either way: not stale. */
4719
- /**
4720
- * Something written on a card by a harness, for the record — not by anyone, to anyone.
4721
- *
4722
- * A manager asking a person to approve a merge puts three things on the card within a minute: the
4723
- * flag, the "Approval needed" comment, and later the "Production run … ran" output. Each reached
4724
- * the card's builder as an event and cost it a turn to say "nothing for me to do until qa has it"
4725
- * — HR-0801 woke HAL seven times that way in an afternoon. None of them asks the builder for
4726
- * anything; whoever must act is told through the approval itself.
4727
- *
4728
- * Being NAMED still wakes an agent, whatever the comment is.
4729
- */
4730
- export function isBookkeeping(event, agents = new Set()) {
4731
- // "X needs your approval" is for a person: an agent cannot approve, and its lead is asked
4732
- // through a review, which is its own event. TARS was sent JARVIS's and spent a turn saying so.
4733
- // Before the check for being named: the event addresses whoever it reaches as "you", so it
4734
- // arrives marked as naming them, and the first version of this rule let every one through.
4735
- if (event.event === 'approval')
4736
- return true;
4737
- if (event.mentioned)
4738
- return false;
4739
- if (event.event === 'needs_human_set')
4740
- return true;
4741
- // A card's fields changing under a flag or a claim, by nobody in particular or by an agent.
4742
- // What a PERSON changes on a card is still news.
4743
- if (event.event === 'updated') {
4744
- const actor = (event.actor ?? '').trim().toLowerCase();
4745
- return !actor || actor === 'someone' || agents.has(actor);
4746
- }
4747
- if (event.event !== 'commented')
4748
- return false;
4749
- const said = `${event.message ?? ''}`.trim() || /— [^:]{1,60}: "(.*)$/s.exec(event.line ?? '')?.[1] || '';
4750
- return /^\**(Approval needed\b|Production run\b|No answer in \d+ minutes?, so\b)/.test(said.trim());
4751
- }
4752
- /**
4753
- * One agent addressing ANOTHER on a card this agent holds: the comment opens with an @name, and
4754
- * the name is not this agent's. "@Norm please merge this now" from the lead woke the card's
4755
- * builder, which spent a turn establishing that it was not Norm (HR-0814, HR-0801). An agent that
4756
- * wants the builder names the builder — and being named always wakes it.
4757
- *
4758
- * Only agents' comments. A person who writes "@Darren, thoughts?" on a card may well want the
4759
- * builder to hear it, and people are not expected to know the rule.
4760
- */
4761
- export function forSomeoneElse(event, agents, me) {
4762
- if (event.event !== 'commented' || event.mentioned)
4763
- return false;
4764
- // Who said it and what, from the event's own fields or, where a producer leaves them out, from
4765
- // its line: `commented: HR-1 "title" — JARVIS: "@Norm please…`. The last such pair, since a
4766
- // card's title can hold a dash and a colon of its own.
4767
- const fromLine = [...(event.line ?? '').matchAll(/— ([^:"—]{1,60}): "/g)].pop();
4768
- const actor = ((event.actor ?? '').trim() || fromLine?.[1] || '').trim().toLowerCase();
4769
- if (!agents.has(actor))
4770
- return false;
4771
- const tail = fromLine ? (event.line ?? '').slice((fromLine.index ?? 0) + fromLine[0].length) : '';
4772
- // Markup around the first word must not hide it: "<p>@Norm" opens with a name too.
4773
- const said = (`${event.message ?? ''}`.trim() || tail).replace(/<[^>]+>/g, ' ').trim();
4774
- if (!/^@\S/.test(said))
4775
- return false;
4776
- // Its own name anywhere in it, whatever the event's flag says: without a name to check, stay awake.
4777
- const name = me.trim().toLowerCase();
4778
- return !!name && !said.toLowerCase().includes(`@${name}`);
4779
- }
4780
4360
  /** See sessionDecider. Apart from the wiring, so it can be tested. */
4781
4361
  export async function decideForSession(request, options, via) {
4782
4362
  const command = request.toolName === 'Bash' ? String(request.input['command'] ?? '').trim() : '';
@@ -4791,227 +4371,6 @@ export async function decideForSession(request, options, via) {
4791
4371
  const v = await via.judge(command);
4792
4372
  return v.allow ? { allow: true } : { allow: false, reason: v.reason ?? 'Not allowed.' };
4793
4373
  }
4794
- /**
4795
- * Which Claude token a handover runs on: a fresh pick from FlareDeck, because the plan the turn
4796
- * used may have run out since; the turn's own plan when FlareDeck cannot be asked; 'none' when
4797
- * every plan is spent; null to fall back to this machine's own login (no subscriptions set up).
4798
- */
4799
- export async function handoverSubscription(subs, lastUsed) {
4800
- try {
4801
- const s = await subs();
4802
- if (!s)
4803
- return lastUsed ?? null;
4804
- const pick = await s.choose([]);
4805
- if (pick.use === 'token' && pick.token)
4806
- return pick.token;
4807
- if (pick.use === 'none')
4808
- return 'none';
4809
- return lastUsed ?? null;
4810
- }
4811
- catch {
4812
- return lastUsed ?? null;
4813
- }
4814
- }
4815
- export function moveIsStale(event, laneNow) {
4816
- if (event.event !== 'moved' || !event.laneName || !laneNow)
4817
- return false;
4818
- return event.laneName.trim().toLowerCase() !== laneNow.trim().toLowerCase();
4819
- }
4820
- /**
4821
- * An event that waited behind other work says so. Norm (15 Oct): "by the time I act, the thing has
4822
- * often already been done" — it re-checked everything before every action, not knowing which
4823
- * events were fresh. Only past a few minutes; a fresh one needs no warning.
4824
- */
4825
- export function waitedNote(event, now = Date.now()) {
4826
- const m = event.receivedAt ? Math.round((now - event.receivedAt) / 60_000) : 0;
4827
- if (m < 3)
4828
- return '';
4829
- const age = m < 90 ? `${m} minutes` : `${Math.round(m / 60)} hours`;
4830
- return `(This arrived ${age} ago and waited behind other work. Check the card's lane and newest comments, and the branch, before acting: it may already be done.)\n\n`;
4831
- }
4832
- /** A card assigned to a lead in a lane where work has not started: the lead hands it out. */
4833
- export function isLeadDispatch(event, isLead) {
4834
- if (!isLead || event.event !== 'assigned')
4835
- return false;
4836
- return !/doing|progress|review|test|deploy|await|release|waiting|done/i.test(String(event.laneName ?? ''));
4837
- }
4838
- export function prompt(event, isLead = false) {
4839
- /* A person giving the lead a card is giving it to the TEAM. JARVIS was assigned six cards at once
4840
- (19 Oct) and tried to start four itself, refused each time by the one-card rule. */
4841
- if (isLeadDispatch(event, isLead)) {
4842
- return [
4843
- `${event.line}`,
4844
- '',
4845
- 'This card is on you to HAND OUT, not to do. Read it with get-card-details, then one of:',
4846
- '- It is clear and wanted: assign it to a free worker with assign-card (list-agents shows who is',
4847
- ' free and what each holds). Say on the card who has it and why, in one line.',
4848
- '- It needs a product or business decision first: ask with set-needs-human (short question,',
4849
- ' options), and leave it unassigned to a worker until it is answered.',
4850
- '- No worker is free: leave it where it is; workers take the next card themselves.',
4851
- '',
4852
- 'Do not move it to Doing and do not start building it yourself.',
4853
- ].join('\n');
4854
- }
4855
- if (event.event === 'sweep') {
4856
- return [
4857
- 'Nothing has arrived for a while, so this is a check of what you already hold. It is a',
4858
- 'HANDOVER, not a working turn: you find the next piece of work and start it moving, and it',
4859
- 'comes back to you properly a moment later.',
4860
- '',
4861
- 'Call get-outstanding. It lists the cards assigned to you that are not done, and the comments',
4862
- 'addressed to you that nobody has actioned.',
4863
- '',
4864
- ...(isLead
4865
- ? [
4866
- 'Then call get-portfolio, because get-outstanding only ever shows what is assigned to',
4867
- 'YOU. A card assigned to nobody is on no outstanding list anywhere — not yours, not any',
4868
- 'worker\'s — so it is invisible to the whole team for as long as it stays that way. Four',
4869
- 'bugs sat in a column unassigned while every agent reported itself idle.',
4870
- '',
4871
- 'Anything actionable with no assignee is yours to place: give it to a worker with',
4872
- 'assign-card, newest bugs first. A worker can be given several cards that have not',
4873
- 'started: they queue, and it takes them one at a time. Spread them about evenly.',
4874
- '',
4875
- 'Then look at the balance. list_agents shows what each one holds and how many are',
4876
- 'queued behind it. Where one worker has several cards waiting and another has none,',
4877
- 'move a card across with assign-card: a card queued behind a busy agent is not being',
4878
- 'worked, and the idle one could finish it today.',
4879
- '',
4880
- 'Each agent is shown with the repository it has checked out. Move a card only to an',
4881
- 'agent that has the repository the card is for — on a board where everyone works the',
4882
- 'same one they are interchangeable, and on a board with a ui agent and an api agent',
4883
- 'they are not.',
4884
- '',
4885
- ]
4886
- : []),
4887
- 'If it returns nothing, say so in one line and stop. That is a perfectly good outcome and',
4888
- 'costs nothing — do NOT go looking for work elsewhere, do not scan logs, and do not invent a',
4889
- 'task to justify the turn.',
4890
- '',
4891
- 'Otherwise take the FIRST one by priority and, depending on what it is:',
4892
- '',
4893
- '- A card for one of the agents who report to you: assign it to the right one. That dispatch',
4894
- ' IS the work, and this turn is done.',
4895
- '- A card that is yours and NOT already in progress: hand it to a worker if you have one free,',
4896
- ' with assign-card. You run one turn at a time, and a card takes minutes — for all of them you',
4897
- ' would be unable to assign anything or answer anyone, which is the one job only you can do.',
4898
- ' With nobody free, move it to the board\'s in-progress lane with update-card-status and stop:',
4899
- ' that hands it back to you as a card of its own, with a checkout and a branch, which is the',
4900
- ' only place work can be done and published from. Either way, stop here — reading it, starting',
4901
- ' it or commenting on it in this turn all edit the shared checkout, which publishes nowhere.',
4902
- '- A card already in progress: say which one in a line, and stop. The harness picks those up',
4903
- ' by itself, shortly, with a checkout of its own — you do not need to do anything for it.',
4904
- '',
4905
- 'Then stop. One handover per check — the rest are still there next time.',
4906
- ].join('\n');
4907
- }
4908
- if (event.event === 'ask') {
4909
- return [
4910
- 'The person watching this pane typed a question for you:',
4911
- '',
4912
- event.line,
4913
- '',
4914
- 'Answer it here, in the pane, briefly and concretely — they are reading this terminal, not a',
4915
- 'card. Use your FlareDeck tools to look things up: list-boards and get-board-info for the',
4916
- 'board and its columns, get-cards-by-column for what sits in a column, get-card-details and',
4917
- 'get-outstanding for who holds a card and whether anyone is on it, read-messages for a',
4918
- 'thread. Look, then say what you found. Do NOT change anything — no comments, no moves, no',
4919
- 'claims, no assignments, no needs-human — unless the question explicitly asks you to do it.',
4920
- ].join('\n');
4921
- }
4922
- if (event.event === 'scheduled') {
4923
- return [
4924
- `A scheduled task has come due: "${event.scheduleName || 'unnamed'}". A person approved this schedule,`,
4925
- 'and FlareDeck is running it now. It is not a card — do what it says and report the result where it',
4926
- 'says to (an email, a message, a comment). If it names nobody or nowhere to report, post a comment',
4927
- 'on the most relevant card or send a direct message to the person who would care.',
4928
- '',
4929
- 'The instruction:',
4930
- '',
4931
- event.prompt || event.message || event.line,
4932
- '',
4933
- 'You have no memory of earlier runs, and nothing here may be undone by waiting: act, then finish.',
4934
- 'Do not create further schedules from this run.',
4935
- ].join('\n');
4936
- }
4937
- // Card intake (docs/spec-card-intake.md): this agent is the board's reviewer of new cards.
4938
- if (event.event === 'intake') {
4939
- return [
4940
- 'You are this board\'s intake reviewer. A card is waiting for you in the review lane:',
4941
- '',
4942
- event.line,
4943
- '',
4944
- 'Read it with get-card-details. Your one question: could an agent work it as written? It needs',
4945
- 'WHAT should change, WHERE (the screen, in the menu\'s words), and HOW YOU WOULD KNOW it is done;',
4946
- 'for a bug, how to make it happen. Look in the code (find_code) before deciding something is',
4947
- 'unclear — "the Goals page" may be obvious once you look.',
4948
- '',
4949
- 'Then one review-card decision, and stop:',
4950
- '- Clear already: pass, with a one-line note.',
4951
- '- Clear enough to make clear: rewrite it first — set-card-description (keep the person\'s own',
4952
- ' words under a "Reported as" line), set-card-story with its "Done when", set-card-meta parts —',
4953
- ' then pass.',
4954
- '- Not clear: ask — ONE comment with every question in it, short, in plain words. At most two',
4955
- ' rounds; after that leave a one-line note of what is missing and stop. Do not chase.',
4956
- '- A worker\'s proposal (it says "Proposed by an agent"): pass it if it is real separate work,',
4957
- ' merge it into the card it belongs on, or decline it saying why.',
4958
- '',
4959
- 'You review; you do not do the work, assign it, or start it. A card it was suggested to edit',
4960
- 'shows the suggestion: apply it with set-card-description if it is right.',
4961
- ].join('\n');
4962
- }
4963
- if (event.event === 'review') {
4964
- return [
4965
- 'A worker that reports to you is asking to run a script against production, and it cannot',
4966
- 'go to a person until you have reviewed it:',
4967
- '',
4968
- event.line,
4969
- '',
4970
- 'Read the card with get-card-details. The request is the newest comment carrying an approval,',
4971
- 'with its comment id, the script, the worker\'s own account of it, and the harness\'s row-count',
4972
- 'preview. Check that the WHERE clause matches the stated purpose and that the preview matches',
4973
- 'the expected effect. Then call review_script with that comment id: endorse, and the',
4974
- 'people who can approve it are asked; reject, and the worker is refused with your note. Do not',
4975
- 'dispatch, re-assign, or change anything else — this is a review, not a card to work.',
4976
- ].join('\n');
4977
- }
4978
- // A card the HARNESS picked back up because it sits in progress with this agent's name on it.
4979
- // Finished work left there comes back on every check and blocks every other card.
4980
- const resumed = String(event.id ?? '').startsWith('resume-')
4981
- ? ['', 'You were handed this card again only because it is still in an in-progress lane assigned to',
4982
- 'you. If its work is already finished — answered, delivered, nothing left for you — move it on to',
4983
- 'its next lane with update-card-status NOW. Left in progress, it is handed back to you on every',
4984
- 'check and FlareDeck will not let you start any other card. If it is waiting on a PERSON and you',
4985
- 'may not move it yourself, call set-needs-human saying what they need to do — a card waiting on a',
4986
- 'person is not handed back to you, and their answer wakes you.']
4987
- : [];
4988
- /* The event's one line quotes a message or comment only up to 200 characters, then says
4989
- "(truncated — read-messages for the full text)". The event itself carries the full text, and
4990
- the model was never given it: a person's longer instructions arrived cut ("his message reached
4991
- me cut off after 'for every c…'"), the fetch for the rest failed, and the agent acted on a
4992
- guess. When the line was cut, the full text goes in the prompt. */
4993
- const full = event.message && /\(truncated — /.test(event.line ?? '')
4994
- ? ['', 'The full text of that message:', '', ...String(event.message).split('\n').map((l) => `> ${l}`)]
4995
- : [];
4996
- return [
4997
- `A FlareDeck event just arrived for you:`,
4998
- '',
4999
- event.line,
5000
- ...full,
5001
- ...resumed,
5002
- '',
5003
- 'Deal with it. Read the card before you change anything — INCLUDING its agent notes, where a',
5004
- 'previous session may have left a handover: what it got to, what it tried that failed,',
5005
- 'what about this environment cost it time. Those notes are the CARD\'s, and the last agent',
5006
- 'to work it wrote them — the card says who. If that was not you, the "I" in them is that',
5007
- 'agent: use them to know where the card stands, never as your own history, and never as',
5008
- 'your own comments or your own lessons. Keep your todo list in step with the',
5009
- 'work, and comment on the card so a person can follow what you did. If you cannot finish —',
5010
- 'you need a decision, an approval, or something is blocking you — call set-needs-human and say',
5011
- 'what you need. Nobody is watching this terminal, so stopping without saying so is the one',
5012
- 'thing that does not work.',
5013
- ].join('\n');
5014
- }
5015
4374
  /** An MCP tool result that says the server refused the session's credentials. */
5016
4375
  export function mcpSignInRejected(tool, response) {
5017
4376
  if (!tool.startsWith('mcp__') || tool.startsWith('mcp__flaredeck__'))