@onepointfour-packs/flaredeck-agent 0.1.0

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 (148) hide show
  1. package/LICENSE.md +14 -0
  2. package/README.md +102 -0
  3. package/dist/agent.d.ts +104 -0
  4. package/dist/agent.js +329 -0
  5. package/dist/agent.js.map +1 -0
  6. package/dist/approval.d.ts +72 -0
  7. package/dist/approval.js +161 -0
  8. package/dist/approval.js.map +1 -0
  9. package/dist/approvals.d.ts +2 -0
  10. package/dist/approvals.js +148 -0
  11. package/dist/approvals.js.map +1 -0
  12. package/dist/auth.d.ts +79 -0
  13. package/dist/auth.js +181 -0
  14. package/dist/auth.js.map +1 -0
  15. package/dist/automode.d.ts +33 -0
  16. package/dist/automode.js +69 -0
  17. package/dist/automode.js.map +1 -0
  18. package/dist/branches.d.ts +25 -0
  19. package/dist/branches.js +81 -0
  20. package/dist/branches.js.map +1 -0
  21. package/dist/cli.d.ts +2 -0
  22. package/dist/cli.js +12 -0
  23. package/dist/cli.js.map +1 -0
  24. package/dist/codesearch.d.ts +27 -0
  25. package/dist/codesearch.js +111 -0
  26. package/dist/codesearch.js.map +1 -0
  27. package/dist/concurrency.d.ts +14 -0
  28. package/dist/concurrency.js +52 -0
  29. package/dist/concurrency.js.map +1 -0
  30. package/dist/config.d.ts +106 -0
  31. package/dist/config.js +119 -0
  32. package/dist/config.js.map +1 -0
  33. package/dist/engine-antigravity.d.ts +23 -0
  34. package/dist/engine-antigravity.js +357 -0
  35. package/dist/engine-antigravity.js.map +1 -0
  36. package/dist/engine-claude.d.ts +18 -0
  37. package/dist/engine-claude.js +64 -0
  38. package/dist/engine-claude.js.map +1 -0
  39. package/dist/engine.d.ts +122 -0
  40. package/dist/engine.js +36 -0
  41. package/dist/engine.js.map +1 -0
  42. package/dist/env.d.ts +1 -0
  43. package/dist/env.js +10 -0
  44. package/dist/env.js.map +1 -0
  45. package/dist/environment.d.ts +39 -0
  46. package/dist/environment.js +100 -0
  47. package/dist/environment.js.map +1 -0
  48. package/dist/flaredeck-check.d.ts +21 -0
  49. package/dist/flaredeck-check.js +12 -0
  50. package/dist/flaredeck-check.js.map +1 -0
  51. package/dist/flaredeck-proxy.d.ts +60 -0
  52. package/dist/flaredeck-proxy.js +186 -0
  53. package/dist/flaredeck-proxy.js.map +1 -0
  54. package/dist/flaredeck.d.ts +132 -0
  55. package/dist/flaredeck.js +106 -0
  56. package/dist/flaredeck.js.map +1 -0
  57. package/dist/gemini.d.ts +21 -0
  58. package/dist/gemini.js +59 -0
  59. package/dist/gemini.js.map +1 -0
  60. package/dist/gitguard.d.ts +2 -0
  61. package/dist/gitguard.js +90 -0
  62. package/dist/gitguard.js.map +1 -0
  63. package/dist/github.d.ts +38 -0
  64. package/dist/github.js +153 -0
  65. package/dist/github.js.map +1 -0
  66. package/dist/githubauth.d.ts +21 -0
  67. package/dist/githubauth.js +90 -0
  68. package/dist/githubauth.js.map +1 -0
  69. package/dist/intake.d.ts +124 -0
  70. package/dist/intake.js +383 -0
  71. package/dist/intake.js.map +1 -0
  72. package/dist/jam.d.ts +45 -0
  73. package/dist/jam.js +385 -0
  74. package/dist/jam.js.map +1 -0
  75. package/dist/jev.d.ts +31 -0
  76. package/dist/jev.js +75 -0
  77. package/dist/jev.js.map +1 -0
  78. package/dist/jevguard.d.ts +28 -0
  79. package/dist/jevguard.js +213 -0
  80. package/dist/jevguard.js.map +1 -0
  81. package/dist/mailbox.d.ts +22 -0
  82. package/dist/mailbox.js +51 -0
  83. package/dist/mailbox.js.map +1 -0
  84. package/dist/main.d.ts +2 -0
  85. package/dist/main.js +764 -0
  86. package/dist/main.js.map +1 -0
  87. package/dist/markdown.d.ts +5 -0
  88. package/dist/markdown.js +81 -0
  89. package/dist/markdown.js.map +1 -0
  90. package/dist/oauth.d.ts +30 -0
  91. package/dist/oauth.js +216 -0
  92. package/dist/oauth.js.map +1 -0
  93. package/dist/permissions.d.ts +43 -0
  94. package/dist/permissions.js +57 -0
  95. package/dist/permissions.js.map +1 -0
  96. package/dist/preflight.d.ts +71 -0
  97. package/dist/preflight.js +116 -0
  98. package/dist/preflight.js.map +1 -0
  99. package/dist/providers.d.ts +26 -0
  100. package/dist/providers.js +27 -0
  101. package/dist/providers.js.map +1 -0
  102. package/dist/repopicker.d.ts +3 -0
  103. package/dist/repopicker.js +111 -0
  104. package/dist/repopicker.js.map +1 -0
  105. package/dist/runs.d.ts +59 -0
  106. package/dist/runs.js +211 -0
  107. package/dist/runs.js.map +1 -0
  108. package/dist/sandbox.d.ts +13 -0
  109. package/dist/sandbox.js +110 -0
  110. package/dist/sandbox.js.map +1 -0
  111. package/dist/schedules.d.ts +11 -0
  112. package/dist/schedules.js +85 -0
  113. package/dist/schedules.js.map +1 -0
  114. package/dist/secretguard.d.ts +4 -0
  115. package/dist/secretguard.js +115 -0
  116. package/dist/secretguard.js.map +1 -0
  117. package/dist/secrets.d.ts +27 -0
  118. package/dist/secrets.js +51 -0
  119. package/dist/secrets.js.map +1 -0
  120. package/dist/serve.d.ts +17 -0
  121. package/dist/serve.js +1206 -0
  122. package/dist/serve.js.map +1 -0
  123. package/dist/status.d.ts +73 -0
  124. package/dist/status.js +98 -0
  125. package/dist/status.js.map +1 -0
  126. package/dist/stream.d.ts +91 -0
  127. package/dist/stream.js +103 -0
  128. package/dist/stream.js.map +1 -0
  129. package/dist/streamtoken.d.ts +2 -0
  130. package/dist/streamtoken.js +30 -0
  131. package/dist/streamtoken.js.map +1 -0
  132. package/dist/team.d.ts +61 -0
  133. package/dist/team.js +136 -0
  134. package/dist/team.js.map +1 -0
  135. package/dist/ui.d.ts +110 -0
  136. package/dist/ui.js +348 -0
  137. package/dist/ui.js.map +1 -0
  138. package/dist/updater.d.ts +15 -0
  139. package/dist/updater.js +178 -0
  140. package/dist/updater.js.map +1 -0
  141. package/dist/workspace.d.ts +115 -0
  142. package/dist/workspace.js +247 -0
  143. package/dist/workspace.js.map +1 -0
  144. package/dist/worktree.d.ts +42 -0
  145. package/dist/worktree.js +140 -0
  146. package/dist/worktree.js.map +1 -0
  147. package/package.json +52 -0
  148. package/python/sidecar.py +178 -0
package/dist/serve.js ADDED
@@ -0,0 +1,1206 @@
1
+ import { createRequire } from 'node:module';
2
+ import { environmentBrief, startEnvironment } from './environment.js';
3
+ import { GIT_TOOL_NAMES, githubTools } from './github.js';
4
+ import { freshGitHubToken } from './githubauth.js';
5
+ import { loadBaseBranch, loadMergeConfig, preflight, preflightBrief } from './preflight.js';
6
+ import { pushStatus, StatusTracker } from './status.js';
7
+ import { fetchTeam, myTeam, teamTools, TEAM_TOOL_NAMES } from './team.js';
8
+ import { scheduleTools, SCHEDULE_TOOL_NAMES } from './schedules.js';
9
+ import { preflightFlaredeck } from './flaredeck-check.js';
10
+ import { callFlaredeckTool, flaredeckProxy } from './flaredeck-proxy.js';
11
+ import { resumeApprovedRuns, runTools } from './runs.js';
12
+ import { deliverDecision, flaredeckDecider } from './approval.js';
13
+ import { CouldNotWork, engineFor } from './engine.js';
14
+ import { jevGuard } from './jevguard.js';
15
+ import { loadProviders } from './providers.js';
16
+ import { ensureWorktree, releaseWorktree, worktreeBrief } from './worktree.js';
17
+ import { askingForComponent, checkouts, resolveCheckout, voteByCode, workspaceBrief } from './workspace.js';
18
+ import { intake, intakeText } from './intake.js';
19
+ import { startUi } from './ui.js';
20
+ import { checkAndStage, installedSdkVersion, pendingUpdate, updatesEnabled } from './updater.js';
21
+ import { saveConfig } from './config.js';
22
+ import { EventStream } from './stream.js';
23
+ /**
24
+ * The always-on agent.
25
+ *
26
+ * Everything the old arrangement asked an agent to remember — arm the monitor, re-arm it when it
27
+ * lapses, notice you were assigned something, say you are still alive — is plain code here, so it
28
+ * cannot be forgotten the way an instruction in a prompt can.
29
+ *
30
+ * Work is serialised on purpose. One card at a time means no two turns racing over the same
31
+ * worktree, and it is the honest shape for v1: a busy agent with a queue is easy to reason about,
32
+ * two half-finished cards are not.
33
+ */
34
+ /** Reported alongside status, so a stale agent on an old build is identifiable as such. */
35
+ const VERSION = createRequire(import.meta.url)('../package.json').version;
36
+ /** How often the agent says it is alive, whether or not anything is happening. */
37
+ const HEARTBEAT_MS = 5 * 60 * 1000;
38
+ export async function serve(opts) {
39
+ const { config } = opts;
40
+ const stream = new EventStream(opts.baseUrl, opts.auth);
41
+ const state = {
42
+ agent: opts.agent ?? 'flaredeck-agent',
43
+ repo: config.repo,
44
+ working: '',
45
+ since: Date.now(),
46
+ queued: 0,
47
+ connected: false,
48
+ lines: [],
49
+ };
50
+ let ui = null;
51
+ const log = (kind, text) => {
52
+ // Keep a bounded tail: a daemon runs for days and nobody scrolls back that far.
53
+ state.lines.push({ kind, text });
54
+ if (state.lines.length > 400)
55
+ state.lines.splice(0, state.lines.length - 400);
56
+ ui?.log({ kind, text });
57
+ };
58
+ const updated = process.env['FLAREDECK_AGENT_UPDATED'];
59
+ if (updated)
60
+ log('tool', updated);
61
+ const waiting = () => {
62
+ const v = pendingUpdate();
63
+ if (v && state.update !== v)
64
+ log('warn', `SDK ${v} downloaded — restart to apply (now ${installedSdkVersion()})`);
65
+ state.update = v || undefined;
66
+ };
67
+ if (updatesEnabled()) {
68
+ waiting();
69
+ const looksForUpdates = () => void checkAndStage().then(waiting);
70
+ setTimeout(looksForUpdates, 30_000).unref();
71
+ setInterval(looksForUpdates, 6 * 60 * 60 * 1000).unref();
72
+ }
73
+ const say = (text) => log('say', text);
74
+ // The job the publishing tools act on. Set when a card starts, so those tools cannot name a
75
+ // branch — they read this instead.
76
+ let job = null;
77
+ // The merge tool exists only where the project declares a merge command. A repo with no such
78
+ // script has no merge tool, so "workers do not merge" needs no rule — there is nothing to call.
79
+ // Read from the checkout the job is in, not from the one this agent started in: a workspace of
80
+ // several repos has a merge script per repo, and running one project's promote script over
81
+ // another project's branch is exactly the kind of irreversible mistake nothing downstream
82
+ // would catch. The TOOL exists if any repo here declares one; a job in a repo that declares
83
+ // none has nothing to call.
84
+ const mergeFor = () => loadMergeConfig(job?.cwd ?? config.repo);
85
+ const inScope = await checkouts(config.repo, config.repos);
86
+ if (inScope.length > 1) {
87
+ log('tool', `works in ${inScope.length} repositories: ${inScope.map((c) => c.name).join(', ')}`);
88
+ }
89
+ else if (!inScope.length) {
90
+ log('warn', `${config.repo} holds no git checkout — this agent can read and comment, but cannot branch or publish.`);
91
+ }
92
+ const anyMerge = inScope.some((c) => loadMergeConfig(c.path)) || !!loadMergeConfig(config.repo);
93
+ const gitServer = githubTools(() => job,
94
+ // Deferred, not the value: the decider is defined below, once the approvals list it refreshes
95
+ // exists. The merge tool only calls it when someone actually merges, which is long after.
96
+ anyMerge ? { config: mergeFor, decider: (req, opt) => decider(req, opt) } : undefined);
97
+ // Production runs exist only where this machine's config names a target, and the request needs
98
+ // the harness's own FlareDeck client to post the approval — the model's tools cannot approve
99
+ // themselves. The card being worked is what the approval is asked on.
100
+ let onCard = null;
101
+ /**
102
+ * The pane's list of production runs waiting on a person: this agent's own request and, on a
103
+ * lead's pane, its team's. Read from the server — the one place every request and verdict
104
+ * lands — and refreshed whenever the stream says something changed, never on a timer worth
105
+ * noticing. Rows a person answered here are marked until the next refresh drops them.
106
+ */
107
+ const reader = opts.me ?? opts.deck ?? null;
108
+ const answeredHere = new Map();
109
+ let refreshing = null;
110
+ const refreshApprovals = () => {
111
+ if (!reader)
112
+ return Promise.resolve();
113
+ if (refreshing)
114
+ return refreshing;
115
+ refreshing = reader.pendingApprovals().then((all) => {
116
+ const mine = all.filter((a) => a.tool_name === 'request_production_run');
117
+ state.approvals = mine.map((a) => {
118
+ const lines = (a.input_preview || '').split('\n');
119
+ const preview = lines.find((l) => l.startsWith('-- RAN') || /would_touch/.test(l))?.replace(/^-- /, '').replace(/^harness preview.*?:\s*/, '');
120
+ const script = lines.filter((l) => !l.startsWith('-- ')).join('\n').trim();
121
+ const answered = answeredHere.get(a.request_id);
122
+ const desc = a.description || '';
123
+ return {
124
+ requestId: a.request_id,
125
+ commentId: a.comment_id,
126
+ agent: a.sender_name ?? '?',
127
+ card: a.card.code ?? a.card.key,
128
+ name: desc.split('"')[1] ?? a.request_id,
129
+ target: desc.split(' on ')[1]?.split(' — ')[0] ?? '',
130
+ purpose: desc.split(' — ').slice(1).join(' — ') || desc,
131
+ ...(preview ? { preview: preview.slice(0, 60) } : {}),
132
+ script,
133
+ stage: answered ? 'answered' : a.review && a.review.state !== 'endorsed' ? 'lead' : 'ready',
134
+ ...(a.review?.state === 'endorsed' ? { endorsedBy: `${a.review.decided_by_name ?? 'lead'}${a.review.note ? `: ${a.review.note}` : ''}` } : {}),
135
+ ...(answered ? { verdict: answered } : {}),
136
+ ...(a.expires_at ? { expiresAt: Date.parse(a.expires_at) } : {}),
137
+ };
138
+ });
139
+ for (const id of [...answeredHere.keys()])
140
+ if (!mine.some((a) => a.request_id === id))
141
+ answeredHere.delete(id);
142
+ }).catch((e) => log('warn', `could not read the approvals list: ${e instanceof Error ? e.message : String(e)}`))
143
+ .finally(() => { refreshing = null; });
144
+ return refreshing;
145
+ };
146
+ const onAsk = (e) => { void refreshApprovals(); if (e.kind === 'answered')
147
+ setTimeout(() => void refreshApprovals(), 1500); };
148
+ /**
149
+ * What answers a tool call the agent is not already allowed to make.
150
+ *
151
+ * The daemon used to hand these to whatever `deciderFor` built, and with no `--card` and no
152
+ * `--ask` that is `denyingDecider` — a decider that can only refuse. So every gated call died
153
+ * here: merge_and_promote's own human gate could never say yes, and the agent's only remaining
154
+ * move was to report itself blocked and wait for somebody to type the command into this pane.
155
+ * That is the opposite of unattended.
156
+ *
157
+ * The ask now goes where the person already is — posted on the card being worked, as the same
158
+ * Approve/Deny the production-run flow uses (web, phone, push) — and the turn waits for the
159
+ * verdict. Nobody has to be at this keyboard. It is routed through the lead first when this
160
+ * agent has one, exactly as a production run is.
161
+ *
162
+ * With no card to ask on (a pane question, the idle sweep) there is nowhere to post, so it
163
+ * falls back to the configured decider and the old refusal stands.
164
+ */
165
+ const decider = async (request, options) => {
166
+ const card = onCard?.card;
167
+ if (!opts.deck || !card)
168
+ return opts.decider(request, options);
169
+ return flaredeckDecider(opts.deck, card, undefined, leadId ? { review_by: leadId } : {}, () => void refreshApprovals(), () => void refreshApprovals())(request, options);
170
+ };
171
+ /**
172
+ * The command gate. ALWAYS on.
173
+ *
174
+ * It used to be installed only when this machine had an OpenRouter key, on the reasoning that a
175
+ * missing key must not quietly change what is allowed. That reasoning was backwards: what it
176
+ * actually meant was that a machine nobody had set up ran `rm -rf` and `DROP TABLE` with no
177
+ * check at all, and the machines least likely to have been set up are exactly the ones running
178
+ * unattended. A guard you can switch off by not creating a file is not a guard.
179
+ *
180
+ * Without a key, judgeCommand's existing unreachable path takes over: a command that looks
181
+ * interesting goes to a PERSON instead of to Jev. Slower and more annoying, which is the right
182
+ * way round — the answer to "no key" is to ask someone, not to stop asking.
183
+ *
184
+ * Its refusals and its asks both end up in decisions.jsonl beside the config.
185
+ */
186
+ const jevKey = loadProviders().openrouter?.apiKey;
187
+ const guard = jevGuard({
188
+ apiKey: jevKey ?? '',
189
+ decider: (req, opt) => decider(req, opt),
190
+ log: (t) => log('tool', t),
191
+ agent: opts.agent ?? 'flaredeck-agent',
192
+ repo: config.repo,
193
+ card: () => onCard?.card ?? null,
194
+ });
195
+ log('tool', jevKey
196
+ ? 'jev guard on — irreversible commands are refused, the rest run'
197
+ : 'jev guard on, with NO OpenRouter key — every risky command will interrupt a person. Set one: flaredeck-agent providers --openrouter <key>');
198
+ const runsSet = Object.keys(config.runs ?? {}).length
199
+ ? runTools(config.runs, () => onCard, opts.deck ?? null, (t) => log('tool', t), () => leadId, onAsk)
200
+ : undefined;
201
+ // Answering from this keyboard: the person's own login decides, through the same endpoint the
202
+ // phone uses, so the agent waiting on it sees the verdict as any other.
203
+ const decide = opts.me
204
+ ? (requestId, verdict) => {
205
+ const row = (state.approvals ?? []).find((a) => a.requestId === requestId);
206
+ if (!row || row.stage !== 'ready')
207
+ return;
208
+ answeredHere.set(requestId, verdict);
209
+ row.stage = 'answered';
210
+ row.verdict = verdict;
211
+ void opts.me.decide(row.commentId, verdict)
212
+ .then(() => log('you', `${verdict === 'allow' ? 'approved' : 'denied'} ${row.name} (${row.agent}, ${row.card}) from here`))
213
+ .catch((e) => { answeredHere.delete(requestId); log('warn', `could not record that: ${e instanceof Error ? e.message : String(e)}`); void refreshApprovals(); });
214
+ }
215
+ : undefined;
216
+ // A slow safety net only — the stream is what keeps this current.
217
+ const approvalsTimer = setInterval(() => void refreshApprovals(), 5 * 60 * 1000);
218
+ approvalsTimer.unref();
219
+ void refreshApprovals();
220
+ // Anything a person approved while this harness was not waiting runs now, before the first
221
+ // event — so the approval the pane showed as "picking it up" is actually picked up.
222
+ if (runsSet && opts.deck) {
223
+ void resumeApprovedRuns(config.runs, opts.deck, config.repo, (t) => log('tool', t))
224
+ .then((n) => { if (n) {
225
+ log('event', `ran ${n} approved production run(s) owed from before this restart`);
226
+ void refreshApprovals();
227
+ } });
228
+ }
229
+ // Recurring tasks. Registering one is only a request: a person approves it before it ever runs.
230
+ const schedulesSet = opts.deck
231
+ ? { server: scheduleTools(opts.deck, () => onCard?.card ?? null), toolNames: SCHEDULE_TOOL_NAMES }
232
+ : undefined;
233
+ if (runsSet && !opts.deck)
234
+ log('warn', 'runs are configured but the harness has no FlareDeck client — requests will be refused');
235
+ /**
236
+ * Lead or worker. FlareDeck owns the answer — it is a property of the ACCOUNT, set once where
237
+ * the team is managed, not a flag on a command line that a machine could disagree with. Asked
238
+ * here, at startup, so the harness mounts the right tools before the first card arrives.
239
+ *
240
+ * If the lookup fails we carry on as a worker. A lead that briefly cannot see its team is a
241
+ * lesser problem than an agent that refuses to start work because of it.
242
+ */
243
+ let teamSet;
244
+ /** Set once this agent is known to be a lead — the id its reports point at. */
245
+ let leadOfTeam = null;
246
+ const teamTimers = [];
247
+ /** Keep only this lead's own reports, in a stable order, shaped for the pane. */
248
+ const showTeam = (team) => {
249
+ state.team = myTeam(team).mine
250
+ .map((a) => ({
251
+ name: a.name,
252
+ card: a.card,
253
+ state: a.state,
254
+ summary: a.summary,
255
+ tasks: a.tasks ?? { done: 0, total: 0 },
256
+ queued: a.queued ?? 0,
257
+ next: a.next ?? '',
258
+ quietFor: a.quietFor,
259
+ stale: a.stale,
260
+ errorsInARow: a.errorsInARow,
261
+ }))
262
+ .sort((x, y) => x.name.localeCompare(y.name));
263
+ };
264
+ /** Who this agent reports to, when it is a worker on a team: production runs go there first. */
265
+ let leadId = null;
266
+ try {
267
+ const team = await fetchTeam(opts.baseUrl, await opts.auth());
268
+ // Only a lead that is actually running can review: one that has never checked in, or has
269
+ // gone quiet, would hold every request until it expired. Then the request goes straight to
270
+ // the people who can approve it, as it would with no lead at all.
271
+ const lead = team.you.reportsTo ? team.agents.find((a) => a.agentId === team.you.reportsTo) : undefined;
272
+ const leadAlive = !!lead && lead.quietFor !== null && !lead.stale;
273
+ leadId = leadAlive ? team.you.reportsTo : null;
274
+ if (team.you.reportsTo && !leadAlive)
275
+ log('warn', `your lead (${lead?.name ?? team.you.reportsTo}) is not running — production runs will go straight to the approvers`);
276
+ if (team.you.role === 'lead') {
277
+ teamSet = {
278
+ // After a review the list changes — an endorsed request is now answerable, from THIS
279
+ // keyboard as likely as any: the lead's pane is where a team is watched from.
280
+ server: teamTools(opts.baseUrl, opts.auth, opts.deck ?? null, () => void refreshApprovals()),
281
+ toolNames: TEAM_TOOL_NAMES,
282
+ };
283
+ const mine = team.agents.filter((a) => a.reportsTo === team.you.agentId).length;
284
+ log('event', `You are the lead here — ${mine} agent(s) report to you.`);
285
+ leadOfTeam = team.you.agentId;
286
+ showTeam(team);
287
+ }
288
+ }
289
+ catch {
290
+ log('warn', 'Could not read your role from FlareDeck — running as a worker.');
291
+ }
292
+ /**
293
+ * A lead's pane shows its team continuously, so one terminal answers "what is everyone doing"
294
+ * without opening four more. Their own reported state, not this machine's guess: each agent
295
+ * pushes its status to FlareDeck, and this reads the same record `team·list_agents` reads.
296
+ *
297
+ * Polled rather than pushed, because nothing streams another agent's status — and slowly,
298
+ * since agents check in every five minutes, so a faster poll would show the same row again.
299
+ */
300
+ if (leadOfTeam) {
301
+ const teamTimer = setInterval(() => {
302
+ void (async () => {
303
+ try {
304
+ showTeam(await fetchTeam(opts.baseUrl, await opts.auth()));
305
+ }
306
+ catch {
307
+ // A team the lead briefly cannot see is not worth a line in the log every minute; the
308
+ // rows simply age in place and their dots go yellow, which is the honest picture.
309
+ }
310
+ })();
311
+ }, 60_000);
312
+ teamTimer.unref();
313
+ teamTimers.push(teamTimer);
314
+ }
315
+ const status = new StatusTracker(config.repo);
316
+ // Pushed when something changes, and on a slow timer so silence itself is informative: a record
317
+ // that stops updating is how FlareDeck will know a machine has gone, which no signal FROM the
318
+ // machine could ever tell it.
319
+ const report = () => pushStatus(opts.baseUrl, opts.auth, status.snapshot(), VERSION);
320
+ const reporter = setInterval(() => void report(), 5 * 60 * 1000);
321
+ reporter.unref();
322
+ // Say hello at once. Waiting for the first tick would leave a just-started agent invisible for
323
+ // five minutes — and "no record" is exactly how a dead machine looks, so a live one must not.
324
+ void report();
325
+ /** The live turn, when there is one — what typing gets delivered to. */
326
+ /**
327
+ * The engine that runs a turn, behind one interface — so a second engine is a second
328
+ * implementation rather than a second harness. Nothing else in this file needs to know
329
+ * which model answered.
330
+ */
331
+ const engine = await engineFor(config);
332
+ state.engine = engine.name;
333
+ if (engine.name !== 'claude')
334
+ log('event', `${engine.name} is answering on this agent`);
335
+ let liveRun = null;
336
+ /**
337
+ * Typed with nothing running: a question for the agent, answered now. It goes through the same
338
+ * queue as a card event so it is serialised with real work, but it is local — never acknowledged
339
+ * to the server, never a card — and it opens one long-lived session of its own so a follow-up
340
+ * question remembers the last one.
341
+ */
342
+ const ask = (text) => {
343
+ const event = { id: `ask-${Date.now()}`, event: 'ask', line: text, message: text };
344
+ if (enqueue(event)) {
345
+ prepareAhead(event);
346
+ void drain();
347
+ }
348
+ };
349
+ const steer = (text) => {
350
+ log('you', text);
351
+ if (liveRun) {
352
+ // Stamped human: the SDK treats an unattributed message as untrusted, and rightly — a line
353
+ // someone typed here deserves more weight than a comment left on a card by anyone.
354
+ //
355
+ // 'next', never 'now'. A 'now' message interrupts whatever tools are in flight, and the
356
+ // CLI closes that turn with a result; streamInput() then treats that result as the end of
357
+ // the conversation and closes the CLI's stdin — the channel every permission verdict and
358
+ // every in-process FlareDeck call comes back on. From then until the turn ends, every
359
+ // FlareDeck call fails as "interrupted" and the agent concludes FlareDeck is down. 'next'
360
+ // waits for the tool batch to return and lands at the boundary, with nothing cut off.
361
+ void liveRun.steer(text).catch(() => log('warn', 'could not deliver that to the running turn'));
362
+ }
363
+ else {
364
+ ask(text);
365
+ }
366
+ };
367
+ /** One session per card, so an agent picking a card back up remembers starting it. */
368
+ const sessions = new Map();
369
+ /**
370
+ * Sessions kept open between events on the same card.
371
+ *
372
+ * A session used to close after every event: turn, handover to the card, gone. Deliberate,
373
+ * and right for a card leaving the agent's hands — but a lead's follow-up comment three
374
+ * minutes later then cost a whole re-orientation (~90k tokens of guide, card and repo) to
375
+ * answer a question the agent had just finished thinking about. So a session now lives for a
376
+ * STINT: it stays until the card has been quiet for a while, or the context has grown large,
377
+ * or the daemon stops. Each of those still ends with the handover. What changed is when.
378
+ */
379
+ const live = new Map();
380
+ const IDLE_CLOSE_MS = 15 * 60 * 1000;
381
+ const CONTEXT_CLOSE_TOKENS = 120_000;
382
+ /**
383
+ * Inside a turn the close check cannot run, and one long turn can climb from a hundred
384
+ * thousand tokens to the window's edge on its own — a reproduction did, 117k to 199k — at
385
+ * which point the SDK compacts silently. So the live turn is told, once, to wrap up: finish
386
+ * the step, leave the handover, stop. Deliberate, and it happens while there is still room.
387
+ */
388
+ const CONTEXT_WRAP_UP_TOKENS = 150_000;
389
+ const keepAlive = (key, cwd, tokens) => {
390
+ const prev = live.get(key);
391
+ if (prev?.timer)
392
+ clearTimeout(prev.timer);
393
+ const timer = setTimeout(() => {
394
+ log('event', `${key}: quiet for ${IDLE_CLOSE_MS / 60000} min — handing over and closing the session`);
395
+ void closeSession(key);
396
+ }, IDLE_CLOSE_MS);
397
+ timer.unref();
398
+ live.set(key, { cwd, tokens, timer });
399
+ };
400
+ const closeSession = async (key) => {
401
+ const s = live.get(key);
402
+ if (!s)
403
+ return;
404
+ if (s.timer)
405
+ clearTimeout(s.timer);
406
+ live.delete(key);
407
+ await wrapUp(key, s.cwd);
408
+ };
409
+ const queue = [];
410
+ /** Ids queued or in flight, so a redelivery and a local retry cannot both run the same card. */
411
+ const pending = new Set();
412
+ /** Keep the pushed status in step with the queue — it is what a lead's pane reads. */
413
+ const syncQueue = () => status.setQueue(queue.length, queue[0]?.cardCode || queue[0]?.cardKey || '');
414
+ const enqueue = (event, front = false) => {
415
+ if (pending.has(event.id))
416
+ return false;
417
+ pending.add(event.id);
418
+ if (front)
419
+ queue.unshift(event);
420
+ else
421
+ queue.push(event);
422
+ syncQueue();
423
+ return true;
424
+ };
425
+ /**
426
+ * Intake starts when the event ARRIVES, not when its turn comes. It is HTTP calls and one small
427
+ * model call with nothing in common with the running turn, so the next card is already read,
428
+ * briefed, sized and mapped by the time the current one ends — the queue holds work ready to
429
+ * start, not work waiting to be read. A result older than this is redone: the thread moved on.
430
+ */
431
+ const PREPARED_FRESH_MS = 15 * 60 * 1000;
432
+ const preparedAhead = new Map();
433
+ // Reading a card ahead of the turn is a model call of its own, so it is skipped for exactly what
434
+ // the turn skips — otherwise the saving is only half taken: no turn runs, but the card was
435
+ // still read and paid for.
436
+ const wantsIntake = (event) => !!event.cardKey
437
+ && event.event !== 'review'
438
+ && event.event !== 'ask'
439
+ && !(event.event === 'commented' && event.cardDone && !event.mentioned)
440
+ && !sessions.has(event.cardKey);
441
+ const prepareAhead = (event) => {
442
+ if (!wantsIntake(event) || preparedAhead.has(event.id))
443
+ return;
444
+ const label = event.cardCode || event.cardKey || '';
445
+ preparedAhead.set(event.id, {
446
+ at: Date.now(),
447
+ // The repo this card belongs to, so the code search behind the brief searches the right
448
+ // project — at a workspace root there is no git to grep, and it would silently find nothing.
449
+ promise: (async () => {
450
+ const w = await resolveCheckout(config.repo, event, config.repos);
451
+ return intake(opts.deck, event.cardCode || event.cardKey, (t) => log('tool', `${label}: ${t}`), false, w.kind === 'repo' ? w.checkout.path : config.repo);
452
+ })().catch(() => null),
453
+ });
454
+ };
455
+ let working = false;
456
+ let current = '';
457
+ let lastBeat = 0;
458
+ const beat = () => {
459
+ if (Date.now() - lastBeat < HEARTBEAT_MS)
460
+ return;
461
+ lastBeat = Date.now();
462
+ // Today this is a log line. It is the hook the card lease will hang from: the point is that
463
+ // the harness emits it on a timer, so a wedged model cannot stop it.
464
+ // The header shows this continuously now, so the heartbeat only needs to mark the passage of
465
+ // time in the log — and only when something is actually running.
466
+ if (working)
467
+ log('tool', `still on ${current} · ${queue.length} queued`);
468
+ };
469
+ // unref: a timer must not be the reason the process stays alive once everything else is done.
470
+ const heartbeat = setInterval(beat, 30_000);
471
+ heartbeat.unref();
472
+ /**
473
+ * The stream only says what CHANGED. A card assigned a fortnight ago fired its event once, it
474
+ * was acknowledged, and nothing re-sends it — so an agent holding real work sat reporting
475
+ * "waiting for work", correctly, for as long as nobody touched its cards. That is how R2-D2 held
476
+ * four cards, one of them fifteen days old, while looking perfectly healthy.
477
+ *
478
+ * So: on a slow timer, ask. One fixed event id, so a sweep can never be queued twice, and only
479
+ * when genuinely idle — a sweep while a card is in flight would have the model start a second
480
+ * piece of work on top of the one it is already doing.
481
+ *
482
+ * What is outstanding is NOT decided here. The turn calls `get-outstanding`, which already owns
483
+ * the lane rules, the priority logic and the comment gating; duplicating any of that in the
484
+ * harness would give two answers that drift. The harness owns only WHEN to look.
485
+ */
486
+ // Guarded, not just clamped: Math.max(5, NaN) is NaN, and setInterval(NaN) fires every tick —
487
+ // a typo in the env var would have turned the slow safety net into a busy loop.
488
+ const sweepMinutes = Number(process.env['FLAREDECK_AGENT_SWEEP_MINUTES']);
489
+ const SWEEP_EVERY_MS = Math.max(5, Number.isFinite(sweepMinutes) ? sweepMinutes : 60) * 60 * 1000;
490
+ const sweepsEnabled = process.env['FLAREDECK_AGENT_NO_SWEEP'] !== '1';
491
+ const sweepNow = () => {
492
+ if (!sweepsEnabled || stopping)
493
+ return;
494
+ // Busy is reason enough to wait: the next tick is minutes away and the work is not going
495
+ // anywhere. `live.size` covers a card whose session is parked between events.
496
+ if (working || queue.length || live.size)
497
+ return;
498
+ if (enqueue({ id: 'sweep', event: 'sweep', line: 'checking what is still assigned to you' }))
499
+ void drain();
500
+ };
501
+ const sweepTimer = setInterval(sweepNow, SWEEP_EVERY_MS);
502
+ sweepTimer.unref();
503
+ // Not at once: let the stream connect and deliver anything it is holding first, so a sweep
504
+ // never races a redelivery of the very cards it would pick up.
505
+ if (sweepsEnabled)
506
+ setTimeout(sweepNow, 30_000).unref();
507
+ // Ctrl-C must actually stop it. The first press closes the stream and lets a turn in progress
508
+ // finish; the second gives up on that and exits. A daemon that has to be hunted down with
509
+ // pkill is worse than one that occasionally abandons a turn — the events are unacknowledged,
510
+ // so whatever it was doing comes back.
511
+ let stopping = false;
512
+ const shutdown = () => {
513
+ if (stopping) {
514
+ say('stopping now');
515
+ process.exit(130);
516
+ }
517
+ stopping = true;
518
+ say(working ? 'stopping — finishing the turn in progress (Ctrl-C again to stop now)' : 'stopping');
519
+ // Close the stream and let the loop unwind normally, so the cleanup in `finally` runs — it
520
+ // is what tells FlareDeck this agent stopped. Exiting here instead left the last record
521
+ // saying "idle", which reads as a live agent doing nothing rather than one that is gone.
522
+ stream.close();
523
+ clearInterval(heartbeat);
524
+ clearInterval(sweepTimer);
525
+ teamTimers.forEach(clearInterval);
526
+ // Belt and braces: if unwinding wedges, do not hang forever pretending to shut down. Longer
527
+ // when cards are still open, because their handovers run first and each is a short turn.
528
+ setTimeout(() => process.exit(0), live.size ? 120_000 : 8000).unref();
529
+ };
530
+ process.on('SIGINT', shutdown);
531
+ process.on('SIGTERM', shutdown);
532
+ async function drain() {
533
+ if (working)
534
+ return;
535
+ working = true;
536
+ try {
537
+ while (queue.length) {
538
+ const event = queue.shift();
539
+ // Only what needs this agent. A card it once touched keeps sending it every move and
540
+ // comment made by whoever has it now, and each one cost a full turn — guide, card,
541
+ // orientation — to conclude "not mine". Acknowledge those without waking the model.
542
+ if (!needsMe(event)) {
543
+ log('tool', `ignored ${event.event}: ${event.cardCode || 'card'} is not assigned to you (${event.actor || 'someone'})`);
544
+ pending.delete(event.id);
545
+ await stream.ack([event.id]);
546
+ continue;
547
+ }
548
+ current = event.event === 'ask' ? 'your question' : event.event === 'sweep' ? 'your outstanding work' : event.event === 'scheduled' ? event.scheduleName || 'a scheduled task' : event.cardCode || event.cardKey || 'a message';
549
+ state.working = current;
550
+ state.since = Date.now();
551
+ state.tasks = { done: 0, total: 0 };
552
+ // What it starts the moment this one is done — so a long turn does not read as a stall.
553
+ state.nextCard = queue[0]?.cardCode || queue[0]?.cardKey || '';
554
+ syncQueue();
555
+ // Everything that ARRIVES is announced; a question typed here already appeared as the
556
+ // `»` line the moment it was typed, and announcing it again just repeated the words back
557
+ // with a different arrow.
558
+ if (event.event !== 'ask')
559
+ log('event', event.line);
560
+ // A sweep or a typed question is work, but it is not a card — see startCard.
561
+ status.startCard(current, !!event.cardKey);
562
+ void report();
563
+ let didWork = true;
564
+ try {
565
+ await handle(event);
566
+ }
567
+ catch (e) {
568
+ // One bad event must not take the daemon down — that is the failure it exists to fix.
569
+ didWork = !(e instanceof CouldNotWork);
570
+ log('warn', `${current}: ${e instanceof Error ? e.message : String(e)}`);
571
+ }
572
+ // Acknowledged only after the work, and only if the work could happen at all. A crash
573
+ // mid-turn redelivers; so does a turn that never had what it needed.
574
+ status.finishCard(didWork);
575
+ void report();
576
+ pending.delete(event.id);
577
+ if (event.event === 'ask' || event.event === 'sweep') { /* local: nothing to acknowledge, nothing to retry */ }
578
+ else if (didWork)
579
+ await stream.ack([event.id]);
580
+ else {
581
+ // Not acknowledged, so the server will redeliver on reconnect — but that can be a long
582
+ // way off. Try again here in a minute as well; the id set stops the two colliding.
583
+ log('warn', 'left queued — retrying in 60s, and on reconnect');
584
+ setTimeout(() => { if (enqueue(event, true))
585
+ void drain(); }, 60_000).unref();
586
+ }
587
+ current = '';
588
+ state.working = '';
589
+ delete state.branch;
590
+ delete state.base;
591
+ delete state.tasks;
592
+ state.nextCard = queue[0]?.cardCode || queue[0]?.cardKey || '';
593
+ }
594
+ }
595
+ finally {
596
+ working = false;
597
+ }
598
+ }
599
+ /**
600
+ * Does this event need THIS agent? A direct message or a mention always does. Being assigned
601
+ * does. A move, comment or creation does only while the card is assigned to this agent —
602
+ * `forYou` is the server's word on that. Anything else is news about someone else's work.
603
+ */
604
+ function needsMe(event) {
605
+ if (event.event === 'ask' || event.event === 'sweep' || event.event === 'message' || event.event === 'mentioned' || event.event === 'scheduled' || event.mentioned === true)
606
+ return true;
607
+ // The server now delivers a card event only to the card's holder or to whoever a comment
608
+ // names, so being sent it at all is the strong signal. Ignore only when the server said,
609
+ // explicitly and with the holder's key in hand, that the holder is someone else — a missing
610
+ // key means it did not know, and "did not know" must not read as "not you". That reading
611
+ // cost a lead a card that had just been created for it.
612
+ if (!event.assigneeKey)
613
+ return true;
614
+ return event.forYou !== false;
615
+ }
616
+ async function startOnBoard(card) {
617
+ const deck = opts.deck;
618
+ if (!deck)
619
+ return;
620
+ try {
621
+ const c = await deck.card(card);
622
+ const type = String(c?.lane?.type ?? '').toLowerCase();
623
+ if (!['backlog', 'todo', 'bug'].includes(type))
624
+ return;
625
+ const board = (await deck.boards()).find((b) => b.id === c.board_id);
626
+ const lanes = (board?.lanes ?? []).map((l, i) => ({ l, i }));
627
+ lanes.sort((a, b) => (a.l.order ?? a.i) - (b.l.order ?? b.i));
628
+ const target = lanes.find(({ l }) => String(l.type ?? '').toLowerCase() === 'inprogress')?.l;
629
+ if (!target)
630
+ return;
631
+ await deck.move(card, target.key, true);
632
+ log('event', `${card}: moved to ${target.name} (In Progress)`);
633
+ }
634
+ catch (e) {
635
+ log('warn', `${card}: could not move to In Progress (${e instanceof Error ? e.message : String(e)})`);
636
+ }
637
+ }
638
+ /**
639
+ * Flag a card for a person, and be sure it actually happened.
640
+ *
641
+ * Two things went wrong here and they hid each other. The call sent a bare Firestore key with no
642
+ * board, which that tool cannot resolve — it wants board + key, or a card CODE — so every
643
+ * needs-human the harness raised was refused. And the refusal came back as an ordinary result
644
+ * with a ❌ in the text rather than as an error, so nothing threw and the pane logged "flagged
645
+ * needs-human" over the top of it. An agent said it had asked for help, a person was never told,
646
+ * and the card sat.
647
+ */
648
+ const flagForHuman = async (event, reason, kind) => {
649
+ const label = event.cardCode || event.cardKey || 'card';
650
+ try {
651
+ const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'set-needs-human', {
652
+ // The code when there is one — it resolves on its own. The board always goes with the key.
653
+ card_id: event.cardCode || event.cardKey,
654
+ ...(event.boardKey ? { board_id: event.boardKey } : {}),
655
+ reason,
656
+ ...(kind ? { kind } : {}),
657
+ });
658
+ if (out.trimStart().startsWith('❌') || out.trimStart().startsWith('🚫')) {
659
+ log('warn', `${label}: FlareDeck did not flag this card — ${out.split('\n')[0]}`);
660
+ return;
661
+ }
662
+ // Say who was told, which the tool reports back, so the pane shows whether anyone heard it.
663
+ const told = out.split('\n').find((l) => l.startsWith('**Told**'));
664
+ log('warn', `${label}: flagged needs-human — ${reason.split('.')[0]}${told ? ` · ${told.replace(/\*\*/g, '')}` : ''}`);
665
+ }
666
+ catch (e) {
667
+ log('warn', `${label}: could not flag the card (${e instanceof Error ? e.message : String(e)}) — ${reason}`);
668
+ }
669
+ };
670
+ async function handle(event) {
671
+ // A scheduled run that sat in the queue past its window (this machine was off, the pane was
672
+ // busy) is dropped, not run late: a "7am summary" at noon is worse than none. Acknowledged by
673
+ // the caller, because skipping it is the finished outcome.
674
+ if (event.event === 'scheduled' && event.validUntil && Date.now() > Date.parse(event.validUntil)) {
675
+ log('warn', `${event.scheduleName || 'scheduled task'}: skipped, it was due ${event.validUntil} and that has passed`);
676
+ return;
677
+ }
678
+ // A comment on a finished card is news, not work. The server stopped sending these to a
679
+ // card's holder — this is the same rule on this side, for an older server and for any other
680
+ // producer that has not learned it. Being NAMED still wakes the agent: pulling it back to a
681
+ // closed card is a deliberate act. Acknowledged by the caller, because ignoring it IS the
682
+ // finished outcome.
683
+ if (event.event === 'commented' && event.cardDone && !event.mentioned) {
684
+ log('event', `${event.cardCode || event.cardKey}: comment on a finished card — nothing to do`);
685
+ return;
686
+ }
687
+ const key = event.cardKey || (event.event === 'ask' ? 'ask' : event.event === 'sweep' ? 'sweep' : event.event === 'scheduled' ? `schedule-${event.scheduleId}` : 'inbox');
688
+ let turnTokens = 0;
689
+ let nudged = false;
690
+ // A checkout of its own per card, so it cannot edit a working tree someone else is in.
691
+ // Falls back to the repo itself when there is no git or the worktree cannot be made —
692
+ // better to work than to refuse, but the agent is told either way.
693
+ // Cut from where this project says work starts — the same branch a pull request targets.
694
+ // The repo's own answer wins — it is reviewed and shared — then the one chosen at login.
695
+ // Where this card's work starts. The card's own world wins: FlareDeck resolved a branch per
696
+ // repository from the board's config and the card's Environment, and the entry for THIS
697
+ // remote is the answer — a production fix is cut from the fix branch, everything else from
698
+ // the feature branch. Only when the board says nothing does the branch chosen at login apply.
699
+ // WHICH repo first, then which branch of it. The folder this agent was started in says what
700
+ // it may work in — one repo, or every repo in that folder — and the card says which of them
701
+ // this work belongs to. See workspace.ts: the folder is the only declaration, so there is no
702
+ // config list to go stale.
703
+ const where = await resolveCheckout(config.repo, event, config.repos);
704
+ let chosen = where.kind === 'repo' ? where.checkout : null;
705
+ let fromCard = where.kind === 'repo' && where.branch ? { branch: where.branch, role: where.role } : null;
706
+ if (where.kind === 'ambiguous') {
707
+ // The board left the question open. The code can often answer it for nothing, and only a
708
+ // clear answer counts — see voteByCode.
709
+ //
710
+ // Voted on the CARD, not on the event. The event carries a title and a line of English
711
+ // ("now assigned to you as Developer"); the description is where someone writes the file,
712
+ // the service or the error that says which repository this is. Voting on the event alone
713
+ // found nothing on a card whose description named exactly one file in exactly one repo.
714
+ let voteText = [event.cardName, event.line, event.message].filter(Boolean).join('\n');
715
+ if (event.cardKey && opts.deck) {
716
+ try {
717
+ const full = await opts.deck.card(event.cardCode || event.cardKey);
718
+ const body = [full?.name, full?.description, ...(full?.comments ?? []).slice(-3).map((c) => c?.message)]
719
+ .filter(Boolean).join('\n');
720
+ if (body.trim())
721
+ voteText = body;
722
+ }
723
+ catch { /* the event's own words are still something to go on */ }
724
+ }
725
+ const voted = await voteByCode(where.candidates, voteText);
726
+ if (voted) {
727
+ chosen = voted.checkout;
728
+ fromCard = voted.branch ? { branch: voted.branch, role: voted.role } : null;
729
+ log('event', `${event.cardCode || key}: no component set — the code points at ${voted.checkout.name}`);
730
+ }
731
+ }
732
+ // A card whose repo cannot be established is flagged, not guessed at. Falling back to this
733
+ // agent's own first repo is how work lands in the wrong repository, and nothing downstream
734
+ // notices: the branch is made, the change is committed, the PR is opened on the wrong project.
735
+ //
736
+ // Only for a turn that will CHANGE something. Being asked a question on a card, reviewing
737
+ // one, or being mentioned on someone else's — none of those need a checkout, and flagging
738
+ // them for want of one would make the agent unable to answer about a repo it can plainly
739
+ // read. Those turns simply run without a worktree, and the brief says so.
740
+ const willChange = !!event.cardKey && event.event !== 'review' && event.event !== 'ask' && event.forYou !== false;
741
+ if (!chosen && willChange && (where.kind === 'ambiguous' || where.kind === 'unknown')) {
742
+ const reason = askingForComponent(where);
743
+ await flagForHuman(event, reason);
744
+ return;
745
+ }
746
+ // A FIX with no branch of its own is the one case worth stopping for.
747
+ //
748
+ // The board knows this card's repository — it said so — but names no branch for the
749
+ // environment the card is in. For ordinary work that is survivable: the repo's own default is
750
+ // a reasonable base and the agent is told it was a fallback. For a fix to what is LIVE it is
751
+ // not, because the fallback is the feature branch: the fix would be cut from unreleased work,
752
+ // carry it along, and the pull request would look like a much bigger change than the fix.
753
+ // Nobody reviewing it would know which part was the fix.
754
+ if (chosen && where.kind === 'repo' && where.role === 'fix' && !where.branch && willChange && event.cardKey) {
755
+ const reason = `This card is a fix to what is live in ${chosen.name}, but the board does not say which branch ` +
756
+ `${event.environment || 'that environment'} work is cut from for that repository. Cutting it from the ` +
757
+ `feature branch would base the fix on unreleased work. Set the branch for ${event.environment || 'this environment'} ` +
758
+ `on the board's repository configuration and this will be picked up.`;
759
+ await flagForHuman(event, reason, 'technical');
760
+ return;
761
+ }
762
+ const repo = chosen?.path ?? config.repo;
763
+ const startFrom = fromCard?.branch ?? loadBaseBranch(repo) ?? config.baseBranch ?? undefined;
764
+ if (chosen && where.kind === 'repo' && !where.branch && event.environment) {
765
+ log('warn', `the board names no branch for ${event.environment} in ${chosen.name} — using ${startFrom ?? "the repo's default"}`);
766
+ }
767
+ if (chosen && where.kind !== 'repo')
768
+ log('event', `working in ${chosen.name}`);
769
+ if (fromCard)
770
+ log('event', `base ${fromCard.branch} — ${fromCard.role} (${event.environment || 'no environment'}), from the board`);
771
+ const tree = event.cardKey && (chosen || where.kind === 'none')
772
+ ? await ensureWorktree(repo, key, (t) => log('tool', t), startFrom)
773
+ : null;
774
+ const cwd = tree?.path ?? repo;
775
+ // No worktree, but this turn is going to change something. That used to carry on in whatever
776
+ // directory happened to be the fallback — no branch, no isolation, and no word said. An agent
777
+ // did exactly that and ended up reading production out of a worktree belonging to someone
778
+ // else; it only read, which was luck. A card that cannot get its own checkout waits for a
779
+ // person instead.
780
+ if (!tree && willChange && event.cardKey) {
781
+ await flagForHuman(event, `I could not make a checkout of my own for this card in ${chosen?.name ?? repo}, so there is nowhere ` +
782
+ `safe to do the work. Everything I changed would land in a checkout somebody else may be using. ` +
783
+ `Something is wrong with the repository or the branch this card asks for — a person needs to look.`, 'technical');
784
+ return;
785
+ }
786
+ if (tree) {
787
+ state.branch = tree.branch;
788
+ state.base = startFrom;
789
+ }
790
+ // In a workspace of several repos the header's repo name is the one fact a person cannot get
791
+ // from anywhere else: which project this card is actually being changed in.
792
+ if (chosen)
793
+ state.repo = chosen.path;
794
+ // FlareDeck must be there before the model is. Without it the turn cannot read the card,
795
+ // comment, or ask for help — and it would still run, answer fluently that it could not do
796
+ // the work, and be acknowledged. The card would be gone. So: no FlareDeck, no turn.
797
+ const check = await preflightFlaredeck(config.mcpUrl, await opts.auth());
798
+ if (!check.ok) {
799
+ log('warn', check.reason);
800
+ throw new CouldNotWork(`${check.reason} — event left queued`);
801
+ }
802
+ log('tool', `FlareDeck reachable · ${check.discovered.tools.length} tools`);
803
+ // Served from in here for this turn: a fresh token per call, so a card that outlives the
804
+ // token's hour keeps its tools, and nothing on this machine can shadow the server by name.
805
+ const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w));
806
+ const held = live.get(key);
807
+ if (held?.timer)
808
+ clearTimeout(held.timer);
809
+ const resume = sessions.get(key);
810
+ if (resume)
811
+ log('tool', `resuming this card's session${held ? ` (${Math.round(held.tokens / 1000)}k tokens)` : ''}`);
812
+ // Intake: digest the card and have a small model write the brief and size the work, so the
813
+ // real turn starts oriented and on a model that fits. Only when a session STARTS — a resumed
814
+ // session already knows the card, and switching its model mid-conversation helps nobody.
815
+ const ahead = preparedAhead.get(event.id);
816
+ preparedAhead.delete(event.id);
817
+ const prepared = !resume && event.cardKey && event.event !== 'review' && event.event !== 'ask'
818
+ ? ahead && Date.now() - ahead.at < PREPARED_FRESH_MS
819
+ ? await ahead.promise
820
+ : await intake(opts.deck, event.cardCode || event.cardKey, (t) => log('tool', t), false, repo)
821
+ : null;
822
+ if (prepared)
823
+ log('tool', `${prepared.summary}${ahead && Date.now() - ahead.at < PREPARED_FRESH_MS ? ' · prepared while the last card ran' : ''}`);
824
+ // Evidence the harness could not read means a turn that would guess. Flag the card for a
825
+ // person with exactly what is missing and stop; the event is done with, not retried, and a
826
+ // person clearing needs-human is what brings the card back.
827
+ if (prepared?.blocked) {
828
+ await flagForHuman(event, `Cannot start: ${prepared.blocked}`);
829
+ return;
830
+ }
831
+ // Work starting is work in progress: a card still waiting in backlog, to-do or bug moves to the
832
+ // board's first In Progress lane. Only on a fresh start, and only out of the waiting lanes —
833
+ // a card in feedback, sign-off, blocked or done is where a person put it.
834
+ if (!resume && event.cardKey && event.event !== 'review' && event.event !== 'ask') {
835
+ await startOnBoard(event.cardCode || event.cardKey);
836
+ }
837
+ onCard = event.cardKey ? { card: event.cardKey, cwd } : null;
838
+ job = tree
839
+ ? {
840
+ cwd,
841
+ branch: tree.branch,
842
+ // Declared beats guessed: a project that merges to `dev` says so, and only falls
843
+ // back to the repo's default branch when it has not.
844
+ base: startFrom ?? (await baseBranch(cwd)),
845
+ card: event.cardCode || key,
846
+ token: async () => {
847
+ if (!config.github)
848
+ throw new Error('This machine has no GitHub login — run flaredeck-agent login.');
849
+ return freshGitHubToken(config.github, (next) => {
850
+ config.github = next;
851
+ saveConfig(config);
852
+ });
853
+ },
854
+ }
855
+ : null;
856
+ const engineContext = {
857
+ config: { ...config, repo: cwd },
858
+ // Only a real worktree confines anything; without one nothing is enforced here, and the
859
+ // guard below makes sure a turn that would CHANGE code never gets that far.
860
+ ...(tree ? { sandbox: tree.path } : {}),
861
+ decider,
862
+ token: await opts.auth(),
863
+ ...(resume ? { resume } : {}),
864
+ ...(prepared?.model ? { model: prepared.model } : {}),
865
+ ...(prepared?.size ? { size: prepared.size } : {}),
866
+ tools: {
867
+ ...(job ? { git: { server: gitServer, toolNames: GIT_TOOL_NAMES } } : {}),
868
+ team: teamSet,
869
+ flaredeck,
870
+ runs: runsSet,
871
+ schedules: schedulesSet,
872
+ },
873
+ guard,
874
+ };
875
+ // Declared checks are OPTIONAL: no .flaredeck/agent.yaml means none, and that is the normal
876
+ // case. Judging what a task needs is the agent's job — it can read the card and the repo, and
877
+ // most cards need nothing. This is only for the few things a project wants hard-gated
878
+ // regardless of what any agent thinks.
879
+ const checks = await preflight(cwd, (t) => log('tool', t));
880
+ // Bring the project up the way it says to, and tell the agent where things ended up — a
881
+ // script that takes a free port for parallel worktrees leaves nothing to guess from.
882
+ const env = await startEnvironment(cwd, (t) => log('tool', t));
883
+ const brief = prompt(event) + intakeText(prepared) +
884
+ workspaceBrief(config.repo, await checkouts(config.repo, config.repos), chosen) +
885
+ worktreeBrief(tree) + preflightBrief(checks) +
886
+ // Only tell an agent about the environment when the card actually needs one running.
887
+ environmentBrief(env, cwd, (prepared?.jev?.needs_running ?? 0) >= 0.5);
888
+ const run = engine.run(brief, engineContext);
889
+ liveRun = run;
890
+ for await (const out of run.events) {
891
+ status.observe(out);
892
+ state.tasks = status.snapshot().tasks;
893
+ // An answer to a question typed here is the whole point of the turn, so it is shown whole;
894
+ // a card turn's narration is one line each, the card holds the full account.
895
+ if (out.kind === 'text')
896
+ log(event.event === 'ask' ? 'answer' : 'say', out.text);
897
+ else if (out.kind === 'tool')
898
+ log('tool', out.name);
899
+ // A changed plan is the most informative thing an agent emits, so it is pushed at once
900
+ // rather than waiting for the timer.
901
+ else if (out.kind === 'todo') {
902
+ log('todo', out.text);
903
+ void report();
904
+ }
905
+ else if (out.kind === 'ready') {
906
+ if (out.sessionId)
907
+ sessions.set(key, out.sessionId);
908
+ // The preflight proved the server answers; this proves the SESSION got it. The SDK will
909
+ // start the model with a server still 'pending' or 'needs-auth' and list its tools from
910
+ // memory, so a count alone is not enough — the status has to say connected. Refuse the
911
+ // turn otherwise: the event stays queued and comes back once the tools are really there.
912
+ const fd = out.mcp.find((s) => s.name === config.mcpServerName);
913
+ log('tool', `session: FlareDeck ${fd?.status ?? 'absent'} · ${out.tools.length} tools · ${out.permissionMode}`);
914
+ if (!fd || fd.status !== 'connected' || out.tools.length === 0) {
915
+ throw new CouldNotWork(`this session's FlareDeck server is ${fd?.status ?? 'absent'}, so the card cannot be read — leaving the event queued`);
916
+ }
917
+ }
918
+ else if (out.kind === 'usage') {
919
+ turnTokens = out.contextTokens;
920
+ if (!nudged && turnTokens >= CONTEXT_WRAP_UP_TOKENS && liveRun) {
921
+ nudged = true;
922
+ log('warn', `context at ${Math.round(turnTokens / 1000)}k — asking it to wrap up before the window fills`);
923
+ const wrapUpText = `Your context is at ${Math.round(turnTokens / 1000)}k tokens and will be compacted soon, which loses detail. ` +
924
+ 'Finish the step you are on, then stop: comment on the card with where things stand, put anything half-done ' +
925
+ '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.';
926
+ void liveRun.steer(wrapUpText);
927
+ }
928
+ }
929
+ else if (out.kind === 'compacted')
930
+ log('warn', 'the SDK compacted this session mid-turn — detail before this point is summarised, not kept');
931
+ else if (out.kind === 'done') {
932
+ if (!out.ok)
933
+ log('warn', 'the turn did not finish cleanly');
934
+ if (out.contextTokens)
935
+ turnTokens = out.contextTokens;
936
+ // The number the intake step is meant to move: how big this turn's context got.
937
+ if (turnTokens)
938
+ log('tool', `turn: ${Math.round(turnTokens / 1000)}k context${prepared?.size ? ` · ${prepared.size} work` : ''}`);
939
+ }
940
+ }
941
+ liveRun = null;
942
+ // A question from the pane: no card to hand over to. Its session is kept so the next question
943
+ // can build on this one, and simply dropped once it has grown too large to be worth carrying.
944
+ if (event.event === 'ask') {
945
+ if (turnTokens >= CONTEXT_CLOSE_TOKENS) {
946
+ sessions.delete(key);
947
+ log('tool', 'question session grew large — the next question starts fresh');
948
+ }
949
+ return;
950
+ }
951
+ // Each scheduled run starts from nothing and leaves nothing: no card to write a handover on.
952
+ if (event.event === 'scheduled') {
953
+ sessions.delete(key);
954
+ return;
955
+ }
956
+ // A message is not a card: nothing to hand over, nothing to keep. A card's session stays open
957
+ // for the next event unless it has grown past the point where a fresh start beats carrying on.
958
+ if (!event.cardKey) {
959
+ await wrapUp(key, cwd);
960
+ return;
961
+ }
962
+ // A finished card does not need its checkout kept. Worktrees accumulate — one repository here
963
+ // had fourteen, nearly all of them done — and each is a full copy on disk. releaseWorktree
964
+ // refuses unless there is genuinely nothing to lose: no uncommitted change, no untracked file,
965
+ // no commit that is not already on the remote. When it refuses it says why, because an orphan
966
+ // somebody finds next month is worse than one they were told about today.
967
+ if (tree && chosen) {
968
+ try {
969
+ // The LANE decides, not the card's `done` flag. That flag is older than the lane types and
970
+ // drifts from them — a card can sit in a lane the board calls done with the flag still
971
+ // false, which is exactly what this card did. The board's own configuration is the only
972
+ // thing that knows what a lane means, here as everywhere else.
973
+ const c = await opts.deck?.card(event.cardCode || event.cardKey);
974
+ const finished = String(c?.lane?.type ?? '').toLowerCase() === 'done' || c?.done === true;
975
+ if (finished) {
976
+ const { removed, why } = await releaseWorktree(chosen.path, key, (t) => log('tool', t));
977
+ if (!removed && why !== 'there was no worktree') {
978
+ log('warn', `${event.cardCode || key}: kept its checkout — ${why}`);
979
+ }
980
+ }
981
+ }
982
+ catch { /* housekeeping must never fail a card that is otherwise finished */ }
983
+ }
984
+ if (turnTokens >= CONTEXT_CLOSE_TOKENS) {
985
+ log('event', `${key}: context at ${Math.round(turnTokens / 1000)}k tokens — handing over and starting fresh next time`);
986
+ // closeSession runs the handover itself; calling wrapUp again here found no session.
987
+ if (live.has(key))
988
+ await closeSession(key);
989
+ else
990
+ await wrapUp(key, cwd);
991
+ return;
992
+ }
993
+ keepAlive(key, cwd, turnTokens);
994
+ }
995
+ /**
996
+ * Close the session deliberately rather than letting it grow until something compacts it.
997
+ *
998
+ * Auto-compaction keeps a session alive by quietly throwing detail away — nobody chooses what
999
+ * goes, and nobody is told. A card is a natural boundary, so the session ends with it: the
1000
+ * agent writes down what the next session would need, and then the conversation is dropped
1001
+ * rather than trimmed.
1002
+ *
1003
+ * What it writes goes on the CARD, not into the harness. FlareDeck is where a person looks, and
1004
+ * a note the next agent reads on the card is one a human can read too, correct, or delete.
1005
+ */
1006
+ /** The handover writes to the card, so it needs FlareDeck as much as the turn did. */
1007
+ async function proxyForWrapUp() {
1008
+ const check = await preflightFlaredeck(config.mcpUrl, await opts.auth());
1009
+ if (!check.ok) {
1010
+ log('warn', `${check.reason} — the handover will have no FlareDeck tools`);
1011
+ return undefined;
1012
+ }
1013
+ return flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered);
1014
+ }
1015
+ async function wrapUp(key, cwd) {
1016
+ // Resume the session the work actually happened in — NOT the options the turn started with.
1017
+ // On a new card those carry no resume id, so reusing them would open a third session and ask
1018
+ // an agent with no memory of the work to summarise it. It would answer, too.
1019
+ const resume = sessions.get(key);
1020
+ if (!resume) {
1021
+ log('warn', 'no session to write a handover from');
1022
+ return;
1023
+ }
1024
+ // Same working directory the card was worked in, so anything it looks at while writing the
1025
+ // handover is what it actually changed.
1026
+ try {
1027
+ const wrapUp = engine.run([
1028
+ 'That is this card done for now. Before the session closes, leave the card in a state',
1029
+ 'someone else can pick up — a person, or you with no memory of this.',
1030
+ '',
1031
+ 'Use set-card-notes ONCE, now, to record — briefly, replacing whatever is there, and only',
1032
+ 'if it is actually worth knowing:',
1033
+ ' · where the work got to, and anything half-finished',
1034
+ ' · what you tried that did NOT work, so nobody repeats it',
1035
+ ' · anything about this environment that cost you time',
1036
+ '',
1037
+ 'Keep it short. If there is genuinely nothing worth passing on, say so and write nothing —',
1038
+ 'an empty note is better than a paragraph restating the card.',
1039
+ ].join('\n'), {
1040
+ config: { ...config, repo: cwd },
1041
+ decider,
1042
+ token: await opts.auth(),
1043
+ ...(resume ? { resume } : {}),
1044
+ tools: { team: teamSet, flaredeck: await proxyForWrapUp() },
1045
+ });
1046
+ liveRun = wrapUp;
1047
+ for await (const out of wrapUp.events) {
1048
+ if (out.kind === 'text')
1049
+ log('say', out.text);
1050
+ else if (out.kind === 'tool')
1051
+ log('tool', out.name);
1052
+ }
1053
+ }
1054
+ catch (e) {
1055
+ log('warn', `handover note failed: ${e instanceof Error ? e.message : String(e)}`);
1056
+ }
1057
+ finally {
1058
+ liveRun = null;
1059
+ // The session ends here. The next event on this card starts fresh and reads the note.
1060
+ sessions.delete(key);
1061
+ log('tool', 'session closed');
1062
+ }
1063
+ }
1064
+ ui = startUi({ state: () => ({ ...state, queued: queue.length }), onSteer: steer, onQuit: shutdown, ...(decide ? { onDecide: decide } : {}) });
1065
+ try {
1066
+ for await (const event of stream.events((s) => {
1067
+ state.connected = s === 'listening';
1068
+ log(s === 'listening' ? 'tool' : 'warn', s);
1069
+ })) {
1070
+ // A verdict on a request this agent is waiting on. Handled HERE, not in drain(): the turn
1071
+ // that asked is still running — blocked inside the tool call — and drain is serialised
1072
+ // behind it, so queueing the answer would wait for the answer. The wait resolves at once,
1073
+ // the event is acknowledged, and the pane is told what happened.
1074
+ if (event.event === 'decided') {
1075
+ const taken = deliverDecision(event.message || '');
1076
+ log(taken ? 'event' : 'tool', `${event.line}${taken ? '' : ' (not waiting on it here)'}`);
1077
+ await stream.ack([event.id]);
1078
+ void refreshApprovals();
1079
+ beat();
1080
+ continue;
1081
+ }
1082
+ // A review request for this lead: the model handles it as a turn, but the list can show
1083
+ // the row straight away.
1084
+ if (event.event === 'review')
1085
+ void refreshApprovals();
1086
+ if (enqueue(event)) {
1087
+ prepareAhead(event);
1088
+ void drain();
1089
+ }
1090
+ beat();
1091
+ }
1092
+ }
1093
+ finally {
1094
+ clearInterval(heartbeat);
1095
+ clearInterval(reporter);
1096
+ // Open sessions get their handover before the record says stopped. Sequential and capped:
1097
+ // this is a stop, not a shift.
1098
+ if (live.size) {
1099
+ say(`handing over ${live.size} open card(s) before stopping`);
1100
+ for (const key of [...live.keys()]) {
1101
+ await Promise.race([closeSession(key), new Promise((r) => setTimeout(r, 45_000))]);
1102
+ }
1103
+ }
1104
+ status.stop();
1105
+ // Awaited, not fired and forgotten: the process is about to exit, and a 'stopped' record
1106
+ // that never arrives leaves the agent looking alive to everyone reading FlareDeck.
1107
+ await report();
1108
+ ui?.stop();
1109
+ }
1110
+ }
1111
+ /**
1112
+ * What the agent is actually asked.
1113
+ *
1114
+ * The event's own one-line rendering is the instruction — it already says what happened and to
1115
+ * whom — with only enough added to make clear that acting is expected and that stopping quietly
1116
+ * is not an option.
1117
+ */
1118
+ /** What a pull request should target — the repo's own default, not a guess. */
1119
+ async function baseBranch(cwd) {
1120
+ const { spawn } = await import('node:child_process');
1121
+ return new Promise((resolve) => {
1122
+ const c = spawn('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { cwd });
1123
+ let out = '';
1124
+ c.stdout.on('data', (d) => (out += String(d)));
1125
+ c.on('close', (code) => resolve(code === 0 && out.trim() ? out.trim().replace(/^origin\//, '') : 'main'));
1126
+ c.on('error', () => resolve('main'));
1127
+ });
1128
+ }
1129
+ function prompt(event) {
1130
+ if (event.event === 'sweep') {
1131
+ return [
1132
+ 'Nothing has arrived for a while, so this is a check of what you already hold.',
1133
+ '',
1134
+ 'Call get-outstanding. It lists the cards assigned to you that are not done, and the comments',
1135
+ 'addressed to you that nobody has actioned.',
1136
+ '',
1137
+ 'Then, in order of priority, PICK UP THE FIRST ONE AND WORK IT — read the card including its',
1138
+ 'agent notes, do the work, comment on the card as you go. If what you hold is for the agents',
1139
+ 'who report to you rather than for you, assign it to the right one instead; that dispatch is',
1140
+ 'the work. If a card cannot move without a decision or an approval, call set-needs-human and',
1141
+ 'say what you need, then go to the next one.',
1142
+ '',
1143
+ 'If get-outstanding returns nothing, say so in one line and stop. That is a perfectly good',
1144
+ 'outcome and costs nothing — do NOT go looking for work elsewhere, do not scan logs, and do',
1145
+ 'not invent a task to justify the turn.',
1146
+ ].join('\n');
1147
+ }
1148
+ if (event.event === 'ask') {
1149
+ return [
1150
+ 'The person watching this pane typed a question for you:',
1151
+ '',
1152
+ event.line,
1153
+ '',
1154
+ 'Answer it here, in the pane, briefly and concretely — they are reading this terminal, not a',
1155
+ 'card. Use your FlareDeck tools to look things up: list-boards and get-board-info for the',
1156
+ 'board and its columns, get-cards-by-column for what sits in a column, get-card-details and',
1157
+ 'get-outstanding for who holds a card and whether anyone is on it, read-messages for a',
1158
+ 'thread. Look, then say what you found. Do NOT change anything — no comments, no moves, no',
1159
+ 'claims, no assignments, no needs-human — unless the question explicitly asks you to do it.',
1160
+ ].join('\n');
1161
+ }
1162
+ if (event.event === 'scheduled') {
1163
+ return [
1164
+ `A scheduled task has come due: "${event.scheduleName || 'unnamed'}". A person approved this schedule,`,
1165
+ 'and FlareDeck is running it now. It is not a card — do what it says and report the result where it',
1166
+ 'says to (an email, a message, a comment). If it names nobody or nowhere to report, post a comment',
1167
+ 'on the most relevant card or send a direct message to the person who would care.',
1168
+ '',
1169
+ 'The instruction:',
1170
+ '',
1171
+ event.prompt || event.message || event.line,
1172
+ '',
1173
+ 'You have no memory of earlier runs, and nothing here may be undone by waiting: act, then finish.',
1174
+ 'Do not create further schedules from this run.',
1175
+ ].join('\n');
1176
+ }
1177
+ if (event.event === 'review') {
1178
+ return [
1179
+ 'A worker that reports to you is asking to run a script against production, and it cannot',
1180
+ 'go to a person until you have reviewed it:',
1181
+ '',
1182
+ event.line,
1183
+ '',
1184
+ 'Read the card with get-card-details. The request is the newest comment carrying an approval,',
1185
+ 'with its comment id, the script, the worker\'s own account of it, and the harness\'s row-count',
1186
+ 'preview. Check that the WHERE clause matches the stated purpose and that the preview matches',
1187
+ 'the expected effect. Then call review_production_run with that comment id: endorse, and the',
1188
+ 'people who can approve it are asked; reject, and the worker is refused with your note. Do not',
1189
+ 'dispatch, re-assign, or change anything else — this is a review, not a card to work.',
1190
+ ].join('\n');
1191
+ }
1192
+ return [
1193
+ `A FlareDeck event just arrived for you:`,
1194
+ '',
1195
+ event.line,
1196
+ '',
1197
+ 'Deal with it. Read the card before you change anything — INCLUDING its agent notes, where a',
1198
+ 'previous session may have left you a handover: what it got to, what it tried that failed,',
1199
+ 'what about this environment cost it time. Keep your todo list in step with the',
1200
+ 'work, and comment on the card so a person can follow what you did. If you cannot finish —',
1201
+ 'you need a decision, an approval, or something is blocking you — call set-needs-human and say',
1202
+ 'what you need. Nobody is watching this terminal, so stopping without saying so is the one',
1203
+ 'thing that does not work.',
1204
+ ].join('\n');
1205
+ }
1206
+ //# sourceMappingURL=serve.js.map