@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.
- package/LICENSE.md +14 -0
- package/README.md +102 -0
- package/dist/agent.d.ts +104 -0
- package/dist/agent.js +329 -0
- package/dist/agent.js.map +1 -0
- package/dist/approval.d.ts +72 -0
- package/dist/approval.js +161 -0
- package/dist/approval.js.map +1 -0
- package/dist/approvals.d.ts +2 -0
- package/dist/approvals.js +148 -0
- package/dist/approvals.js.map +1 -0
- package/dist/auth.d.ts +79 -0
- package/dist/auth.js +181 -0
- package/dist/auth.js.map +1 -0
- package/dist/automode.d.ts +33 -0
- package/dist/automode.js +69 -0
- package/dist/automode.js.map +1 -0
- package/dist/branches.d.ts +25 -0
- package/dist/branches.js +81 -0
- package/dist/branches.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +12 -0
- package/dist/cli.js.map +1 -0
- package/dist/codesearch.d.ts +27 -0
- package/dist/codesearch.js +111 -0
- package/dist/codesearch.js.map +1 -0
- package/dist/concurrency.d.ts +14 -0
- package/dist/concurrency.js +52 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/config.d.ts +106 -0
- package/dist/config.js +119 -0
- package/dist/config.js.map +1 -0
- package/dist/engine-antigravity.d.ts +23 -0
- package/dist/engine-antigravity.js +357 -0
- package/dist/engine-antigravity.js.map +1 -0
- package/dist/engine-claude.d.ts +18 -0
- package/dist/engine-claude.js +64 -0
- package/dist/engine-claude.js.map +1 -0
- package/dist/engine.d.ts +122 -0
- package/dist/engine.js +36 -0
- package/dist/engine.js.map +1 -0
- package/dist/env.d.ts +1 -0
- package/dist/env.js +10 -0
- package/dist/env.js.map +1 -0
- package/dist/environment.d.ts +39 -0
- package/dist/environment.js +100 -0
- package/dist/environment.js.map +1 -0
- package/dist/flaredeck-check.d.ts +21 -0
- package/dist/flaredeck-check.js +12 -0
- package/dist/flaredeck-check.js.map +1 -0
- package/dist/flaredeck-proxy.d.ts +60 -0
- package/dist/flaredeck-proxy.js +186 -0
- package/dist/flaredeck-proxy.js.map +1 -0
- package/dist/flaredeck.d.ts +132 -0
- package/dist/flaredeck.js +106 -0
- package/dist/flaredeck.js.map +1 -0
- package/dist/gemini.d.ts +21 -0
- package/dist/gemini.js +59 -0
- package/dist/gemini.js.map +1 -0
- package/dist/gitguard.d.ts +2 -0
- package/dist/gitguard.js +90 -0
- package/dist/gitguard.js.map +1 -0
- package/dist/github.d.ts +38 -0
- package/dist/github.js +153 -0
- package/dist/github.js.map +1 -0
- package/dist/githubauth.d.ts +21 -0
- package/dist/githubauth.js +90 -0
- package/dist/githubauth.js.map +1 -0
- package/dist/intake.d.ts +124 -0
- package/dist/intake.js +383 -0
- package/dist/intake.js.map +1 -0
- package/dist/jam.d.ts +45 -0
- package/dist/jam.js +385 -0
- package/dist/jam.js.map +1 -0
- package/dist/jev.d.ts +31 -0
- package/dist/jev.js +75 -0
- package/dist/jev.js.map +1 -0
- package/dist/jevguard.d.ts +28 -0
- package/dist/jevguard.js +213 -0
- package/dist/jevguard.js.map +1 -0
- package/dist/mailbox.d.ts +22 -0
- package/dist/mailbox.js +51 -0
- package/dist/mailbox.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +764 -0
- package/dist/main.js.map +1 -0
- package/dist/markdown.d.ts +5 -0
- package/dist/markdown.js +81 -0
- package/dist/markdown.js.map +1 -0
- package/dist/oauth.d.ts +30 -0
- package/dist/oauth.js +216 -0
- package/dist/oauth.js.map +1 -0
- package/dist/permissions.d.ts +43 -0
- package/dist/permissions.js +57 -0
- package/dist/permissions.js.map +1 -0
- package/dist/preflight.d.ts +71 -0
- package/dist/preflight.js +116 -0
- package/dist/preflight.js.map +1 -0
- package/dist/providers.d.ts +26 -0
- package/dist/providers.js +27 -0
- package/dist/providers.js.map +1 -0
- package/dist/repopicker.d.ts +3 -0
- package/dist/repopicker.js +111 -0
- package/dist/repopicker.js.map +1 -0
- package/dist/runs.d.ts +59 -0
- package/dist/runs.js +211 -0
- package/dist/runs.js.map +1 -0
- package/dist/sandbox.d.ts +13 -0
- package/dist/sandbox.js +110 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/schedules.d.ts +11 -0
- package/dist/schedules.js +85 -0
- package/dist/schedules.js.map +1 -0
- package/dist/secretguard.d.ts +4 -0
- package/dist/secretguard.js +115 -0
- package/dist/secretguard.js.map +1 -0
- package/dist/secrets.d.ts +27 -0
- package/dist/secrets.js +51 -0
- package/dist/secrets.js.map +1 -0
- package/dist/serve.d.ts +17 -0
- package/dist/serve.js +1206 -0
- package/dist/serve.js.map +1 -0
- package/dist/status.d.ts +73 -0
- package/dist/status.js +98 -0
- package/dist/status.js.map +1 -0
- package/dist/stream.d.ts +91 -0
- package/dist/stream.js +103 -0
- package/dist/stream.js.map +1 -0
- package/dist/streamtoken.d.ts +2 -0
- package/dist/streamtoken.js +30 -0
- package/dist/streamtoken.js.map +1 -0
- package/dist/team.d.ts +61 -0
- package/dist/team.js +136 -0
- package/dist/team.js.map +1 -0
- package/dist/ui.d.ts +110 -0
- package/dist/ui.js +348 -0
- package/dist/ui.js.map +1 -0
- package/dist/updater.d.ts +15 -0
- package/dist/updater.js +178 -0
- package/dist/updater.js.map +1 -0
- package/dist/workspace.d.ts +115 -0
- package/dist/workspace.js +247 -0
- package/dist/workspace.js.map +1 -0
- package/dist/worktree.d.ts +42 -0
- package/dist/worktree.js +140 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +52 -0
- 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
|