@onepointfour-packs/flaredeck-agent 0.2.38 → 0.2.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +42 -3
- package/dist/agent.js +84 -6
- package/dist/agent.js.map +1 -1
- package/dist/automode.js +43 -3
- package/dist/automode.js.map +1 -1
- package/dist/branches.d.ts +12 -1
- package/dist/branches.js +13 -3
- package/dist/branches.js.map +1 -1
- package/dist/browser.d.ts +52 -0
- package/dist/browser.js +185 -17
- package/dist/browser.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.js +36 -2
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +217 -0
- package/dist/credentials.js +332 -0
- package/dist/credentials.js.map +1 -0
- package/dist/engine-antigravity.js +2 -0
- package/dist/engine-antigravity.js.map +1 -1
- package/dist/engine-claude.js +14 -2
- package/dist/engine-claude.js.map +1 -1
- package/dist/engine.d.ts +19 -0
- package/dist/engine.js.map +1 -1
- package/dist/environment.d.ts +13 -1
- package/dist/environment.js +35 -2
- package/dist/environment.js.map +1 -1
- package/dist/envtools.d.ts +58 -0
- package/dist/envtools.js +151 -0
- package/dist/envtools.js.map +1 -0
- package/dist/flaredeck.d.ts +24 -0
- package/dist/flaredeck.js +20 -0
- package/dist/flaredeck.js.map +1 -1
- package/dist/github.d.ts +2 -0
- package/dist/github.js +4 -0
- package/dist/github.js.map +1 -1
- package/dist/githubauth.d.ts +11 -1
- package/dist/githubauth.js +82 -16
- package/dist/githubauth.js.map +1 -1
- package/dist/hydrate.d.ts +76 -0
- package/dist/hydrate.js +77 -0
- package/dist/hydrate.js.map +1 -0
- package/dist/limits.d.ts +184 -0
- package/dist/limits.js +251 -0
- package/dist/limits.js.map +1 -0
- package/dist/main.js +121 -18
- package/dist/main.js.map +1 -1
- package/dist/picker.js +50 -4
- package/dist/picker.js.map +1 -1
- package/dist/preflight.d.ts +43 -0
- package/dist/preflight.js +133 -19
- package/dist/preflight.js.map +1 -1
- package/dist/runs.d.ts +11 -3
- package/dist/runs.js +41 -4
- package/dist/runs.js.map +1 -1
- package/dist/serve.js +956 -113
- package/dist/serve.js.map +1 -1
- package/dist/session.d.ts +51 -0
- package/dist/session.js +145 -0
- package/dist/session.js.map +1 -0
- package/dist/settle.d.ts +18 -0
- package/dist/settle.js +39 -0
- package/dist/settle.js.map +1 -0
- package/dist/signin.d.ts +47 -0
- package/dist/signin.js +331 -0
- package/dist/signin.js.map +1 -0
- package/dist/stream.d.ts +6 -0
- package/dist/stream.js +44 -3
- package/dist/stream.js.map +1 -1
- package/dist/subscriptions.d.ts +115 -0
- package/dist/subscriptions.js +140 -0
- package/dist/subscriptions.js.map +1 -0
- package/dist/ui.d.ts +11 -0
- package/dist/ui.js +6 -1
- package/dist/ui.js.map +1 -1
- package/dist/usage.d.ts +105 -0
- package/dist/usage.js +348 -0
- package/dist/usage.js.map +1 -0
- package/dist/wall.d.ts +35 -0
- package/dist/wall.js +94 -0
- package/dist/wall.js.map +1 -0
- package/dist/worktree.d.ts +19 -0
- package/dist/worktree.js +179 -4
- package/dist/worktree.js.map +1 -1
- package/package.json +1 -1
package/dist/serve.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
1
2
|
import { EventEmitter } from 'node:events';
|
|
2
3
|
import { createRequire } from 'node:module';
|
|
3
4
|
import { ensureTmuxScrollback } from './tmux.js';
|
|
4
|
-
import { START_SCRIPT as START_SCRIPT_NAME, browserBrief, crossWired, environmentBrief, notStartedBrief, startEnvironment, stopEnvironment, stopLeftoverEnvironments } from './environment.js';
|
|
5
|
+
import { START_SCRIPT as START_SCRIPT_NAME, webPart, browserBrief, crossWired, environmentBrief, notStartedBrief, startEnvironment, stopEnvironment, stopLeftoverEnvironments } from './environment.js';
|
|
5
6
|
import { GIT_TOOL_NAMES, githubTools, publishBranch } from './github.js';
|
|
6
7
|
import { freshGitHubToken } from './githubauth.js';
|
|
7
8
|
import { loadBaseBranch, loadMergeConfig, preflight, preflightBrief } from './preflight.js';
|
|
@@ -12,10 +13,14 @@ import { preflightFlaredeck } from './flaredeck-check.js';
|
|
|
12
13
|
import { NOT_FOR_WORKERS, REPLACED_BY_HARNESS, callFlaredeckTool, flaredeckProxy } from './flaredeck-proxy.js';
|
|
13
14
|
import { SCRIPT_REQUEST, allTargets, resumeApprovedRuns, runTools } from './runs.js';
|
|
14
15
|
import { deliverDecision, flaredeckDecider } from './approval.js';
|
|
16
|
+
import { subscriptionsForAgent } from './subscriptions.js';
|
|
15
17
|
import { CouldNotWork, engineFor } from './engine.js';
|
|
18
|
+
/** The run failed on its Claude subscription — capped or token rejected — not on its work. */
|
|
19
|
+
class RetryOnAnotherPlan extends CouldNotWork {
|
|
20
|
+
}
|
|
16
21
|
import { jevGuard } from './jevguard.js';
|
|
17
22
|
import { loadProviders } from './providers.js';
|
|
18
|
-
import { currentBranch, ensureWorktree, releaseSharedPushGuard, existingWorktree, produced, releaseWorktree, tooEarlyToFinish, uncommittedCount, worktreeBrief } from './worktree.js';
|
|
23
|
+
import { currentBranch, ensureWorktree, freeDiskBytes, sweepWorktrees, releaseSharedPushGuard, existingWorktree, produced, releaseWorktree, tooEarlyToFinish, uncommittedCount, worktreeBrief } from './worktree.js';
|
|
19
24
|
import { askingForComponent, checkouts, resolveCheckout, voteByCode, workspaceBrief } from './workspace.js';
|
|
20
25
|
import { intake, intakeText, unclearBrief } from './intake.js';
|
|
21
26
|
import { standingBrief } from './agent.js';
|
|
@@ -23,14 +28,16 @@ import { startUi } from './ui.js';
|
|
|
23
28
|
import { checkAndStage, harnessVersion, installedSdkVersion, pendingUpdate, updatesEnabled } from './updater.js';
|
|
24
29
|
import { updateSidecarSdk } from './updater-python.js';
|
|
25
30
|
import { managedPython, usingManagedPython } from './engine-antigravity.js';
|
|
26
|
-
import { mkdirSync } from 'node:fs';
|
|
31
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
|
27
32
|
import { join } from 'node:path';
|
|
28
|
-
import { configDir, saveConfig } from './config.js';
|
|
33
|
+
import { configDir, loadGitHub, saveConfig } from './config.js';
|
|
29
34
|
import { boardArg, onMyBoards, outstandingCards } from './boards.js';
|
|
30
|
-
import { browserTools } from './browser.js';
|
|
35
|
+
import { browserTools, ensureBrowser } from './browser.js';
|
|
36
|
+
import { environmentTools } from './envtools.js';
|
|
31
37
|
import { watchForDesperation } from './desperation.js';
|
|
32
38
|
import { note, recallWorktree, rememberWorktree, resumeBrief } from './journal.js';
|
|
33
|
-
import { EventStream } from './stream.js';
|
|
39
|
+
import { EventStream, describeFailure } from './stream.js';
|
|
40
|
+
import { canPublish, canPushTo } from './githubauth.js';
|
|
34
41
|
/**
|
|
35
42
|
* The always-on agent.
|
|
36
43
|
*
|
|
@@ -46,8 +53,64 @@ import { EventStream } from './stream.js';
|
|
|
46
53
|
const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
47
54
|
/** How often the agent says it is alive, whether or not anything is happening. */
|
|
48
55
|
const HEARTBEAT_MS = 5 * 60 * 1000;
|
|
56
|
+
/** A card worktree unused this long, with nothing unpublished on it, is released. */
|
|
57
|
+
const WORKTREE_IDLE_MS = 12 * 60 * 60 * 1000;
|
|
58
|
+
/** Short of disk, an hour unused is enough: the card comes back from what it pushed. */
|
|
59
|
+
/**
|
|
60
|
+
* Every pane line, full length, on disk: `<config>/agent.log`.
|
|
61
|
+
*
|
|
62
|
+
* The pane truncates and scrolls away, and the per-card journal keeps only 40 short lines and
|
|
63
|
+
* only while a card runs — so anything between cards (startup, usage reads, pauses) existed
|
|
64
|
+
* nowhere once it left the screen, and "what did it say?" meant a screenshot. Rotated at 5 MB to
|
|
65
|
+
* one `.1`, so a daemon that runs for weeks cannot fill the disk it is also guarding.
|
|
66
|
+
*/
|
|
67
|
+
const AGENT_LOG_MAX = 5 * 1024 * 1024;
|
|
68
|
+
function writeAgentLog(line) {
|
|
69
|
+
try {
|
|
70
|
+
const path = join(configDir(), 'agent.log');
|
|
71
|
+
if (existsSync(path) && statSync(path).size > AGENT_LOG_MAX)
|
|
72
|
+
renameSync(path, `${path}.1`);
|
|
73
|
+
appendFileSync(path, `${new Date().toISOString()} ${line.kind.padEnd(5)} ${line.card ? `[${line.card}] ` : ''}${line.text.replace(/\n/g, '\n ')}\n`);
|
|
74
|
+
}
|
|
75
|
+
catch { /* a log that cannot be written must never stop the agent */ }
|
|
76
|
+
}
|
|
77
|
+
const WORKTREE_IDLE_LOW_DISK_MS = 60 * 60 * 1000;
|
|
78
|
+
/** Below this, idle worktrees are released early. */
|
|
79
|
+
const DISK_LOW = 30e9;
|
|
80
|
+
/** Below this, no card is started. */
|
|
81
|
+
const DISK_MIN = 10e9;
|
|
49
82
|
export async function serve(opts) {
|
|
50
83
|
const { config } = opts;
|
|
84
|
+
/** The tenant the agent's own token is for. Reads the claim rather than the config file,
|
|
85
|
+
* because the token is what the server authorises against. */
|
|
86
|
+
const tenantFromToken = (token) => {
|
|
87
|
+
try {
|
|
88
|
+
const part = token.split('.')[1] ?? '';
|
|
89
|
+
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')).tenantId ?? null;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
/** When each plan's usage was last read, so a burst of short events does not read it each time. */
|
|
96
|
+
const usageReadAt = new Map();
|
|
97
|
+
const USAGE_REFRESH_MS = 5 * 60_000;
|
|
98
|
+
/** The plan the event being handled ran on, so its usage can be read once the event is done. */
|
|
99
|
+
let turnPlan = null;
|
|
100
|
+
/** Events parked until a Claude plan resets, by event id — one timer each, never two. */
|
|
101
|
+
const waitingForPlan = new Map();
|
|
102
|
+
/** The run reported its plan's usage itself, so no separate read is needed after it. */
|
|
103
|
+
let turnSawUsage = false;
|
|
104
|
+
/**
|
|
105
|
+
* Plans that failed on an event, by event id, so its retry is offered a different one.
|
|
106
|
+
*
|
|
107
|
+
* A plan that hits its cap or has its token rejected used to fail the card outright while the
|
|
108
|
+
* other plans sat unused. Now the event goes straight back on the queue and `choose` is told
|
|
109
|
+
* what not to hand out. Bounded by the plans that exist — each retry spends one — and by
|
|
110
|
+
* MAX_PLAN_SWITCHES as a backstop.
|
|
111
|
+
*/
|
|
112
|
+
const failedPlans = new Map();
|
|
113
|
+
const MAX_PLAN_SWITCHES = 4;
|
|
51
114
|
const stream = new EventStream(opts.baseUrl, opts.auth);
|
|
52
115
|
const state = {
|
|
53
116
|
version: harnessVersion(),
|
|
@@ -75,6 +138,7 @@ export async function serve(opts) {
|
|
|
75
138
|
if (state.lines.length > 400)
|
|
76
139
|
state.lines.splice(0, state.lines.length - 400);
|
|
77
140
|
ui?.log(line);
|
|
141
|
+
writeAgentLog(line);
|
|
78
142
|
};
|
|
79
143
|
const updated = process.env['FLAREDECK_AGENT_UPDATED'];
|
|
80
144
|
if (updated)
|
|
@@ -169,7 +233,9 @@ export async function serve(opts) {
|
|
|
169
233
|
* with no code in it — which is right: a message is not authority to start work, only to carry
|
|
170
234
|
* on with work that exists.
|
|
171
235
|
*/
|
|
172
|
-
const worktreeTheMessageIsAbout = async (event, repo, onStep
|
|
236
|
+
const worktreeTheMessageIsAbout = async (event, repo, onStep,
|
|
237
|
+
/** Makes one when none exists — see the note where this is called. */
|
|
238
|
+
make) => {
|
|
173
239
|
// `ask` too: a question typed in the pane is usually about the card in front of the person,
|
|
174
240
|
// and without this the turn has no checkout and therefore no publishing tools — so an agent
|
|
175
241
|
// asked to commit its work truthfully answers that it cannot.
|
|
@@ -197,6 +263,22 @@ export async function serve(opts) {
|
|
|
197
263
|
tag = code;
|
|
198
264
|
return found;
|
|
199
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Nothing there yet, and the person at this machine named it — so make it.
|
|
268
|
+
*
|
|
269
|
+
* This is what stopped "work KAN-0074" working: a card nobody had touched had no checkout,
|
|
270
|
+
* and the agent rightly refused to run it from the shared one, which was on another branch
|
|
271
|
+
* with uncommitted changes. Only for a pane ask: a comment or a message naming a card is
|
|
272
|
+
* usually talking ABOUT it, and a card event makes its own.
|
|
273
|
+
*/
|
|
274
|
+
if (event.event === 'ask') {
|
|
275
|
+
const made = await make(code).catch(() => null);
|
|
276
|
+
if (made) {
|
|
277
|
+
onStep(`made a checkout for ${code}, ${made.branch}`);
|
|
278
|
+
tag = code;
|
|
279
|
+
return made;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
200
282
|
}
|
|
201
283
|
return null;
|
|
202
284
|
};
|
|
@@ -235,8 +317,63 @@ export async function serve(opts) {
|
|
|
235
317
|
if (await releaseSharedPushGuard(c.path).catch(() => false))
|
|
236
318
|
log('tool', `restored push on ${c.path} (a worktree's push guard had leaked into it)`);
|
|
237
319
|
}
|
|
320
|
+
// Worktrees left by earlier cards — see sweepWorktrees. In the background: nothing waits on it.
|
|
321
|
+
void (async () => {
|
|
322
|
+
for (const c of [{ path: config.repo }, ...inScope])
|
|
323
|
+
await sweepWorktrees(c.path, WORKTREE_IDLE_MS, (t) => log('tool', t)).catch(() => { });
|
|
324
|
+
})();
|
|
238
325
|
// Inside tmux, the history this pane prints is only reachable with tmux's mouse mode on.
|
|
239
326
|
void ensureTmuxScrollback((t) => log('tool', t)).catch(() => { });
|
|
327
|
+
// The browser, fetched while nothing is waiting on it. 186MB is a long pause to take in the
|
|
328
|
+
// middle of the first card that needs eyes, and the agent is idle right now.
|
|
329
|
+
void ensureBrowser((t) => log('tool', `browser: ${t}`)).catch(() => { });
|
|
330
|
+
/**
|
|
331
|
+
* The workspace's MCP servers, so `flaredeck-agent login` is the only credential step on a
|
|
332
|
+
* machine. Connect Sceneflo once in FlareDeck and every agent has it; rotate its token there
|
|
333
|
+
* and every agent follows on its next start.
|
|
334
|
+
*
|
|
335
|
+
* Awaited, unlike the browser fetch above, because the first card must not run with a list
|
|
336
|
+
* that is about to change underneath it — an agent that starts without a server it should
|
|
337
|
+
* have reports a missing tool, which reads as a product fault rather than a race.
|
|
338
|
+
*/
|
|
339
|
+
/**
|
|
340
|
+
* Re-fetched before every session, not only at start: a signed-in server's access token lasts
|
|
341
|
+
* about an hour, so a list fetched once at 08:00 hands out a dead token by 09:00. FlareDeck
|
|
342
|
+
* refreshes it centrally; asking again is all it takes. Merged over the machine's ORIGINAL local
|
|
343
|
+
* list each time, so a server removed in FlareDeck does not linger as "local".
|
|
344
|
+
*
|
|
345
|
+
* Returns the REQUIRED servers that could not be connected — null when FlareDeck could not be
|
|
346
|
+
* asked, which must never pause an agent: not knowing is not the same as knowing one is missing.
|
|
347
|
+
*/
|
|
348
|
+
const localMcp = [...(config.mcpServers ?? [])];
|
|
349
|
+
let lastMcpNote = '';
|
|
350
|
+
const refreshMcp = async () => {
|
|
351
|
+
try {
|
|
352
|
+
const { hydrateMcpServers, mcpConfigFetcher } = await import('./hydrate.js');
|
|
353
|
+
const tenantForMcp = tenantFromToken(await opts.auth());
|
|
354
|
+
if (!tenantForMcp)
|
|
355
|
+
return null;
|
|
356
|
+
const hydrated = await hydrateMcpServers({ mcpServers: localMcp }, mcpConfigFetcher(config.mcpUrl, tenantForMcp, () => opts.auth()));
|
|
357
|
+
config.mcpServers = hydrated.mcpServers;
|
|
358
|
+
if (hydrated.note && hydrated.note !== lastMcpNote) {
|
|
359
|
+
lastMcpNote = hydrated.note;
|
|
360
|
+
log('tool', hydrated.note);
|
|
361
|
+
}
|
|
362
|
+
return hydrated.missingRequired ?? null;
|
|
363
|
+
}
|
|
364
|
+
catch (e) {
|
|
365
|
+
// Never fatal: the machine's own list still works, which is what every agent had before.
|
|
366
|
+
log('warn', `could not fetch the MCP list: ${e instanceof Error ? e.message : String(e)}`);
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
const mcpWallFor = (missing) => ({
|
|
371
|
+
check: 'mcp',
|
|
372
|
+
reason: `A required MCP server is not connected — ${missing.map((m) => `${m.name}: ${m.why}`).join('; ')}. ` +
|
|
373
|
+
`Nothing is worked until it is; I resume by myself within a minute of it being connected.`,
|
|
374
|
+
});
|
|
375
|
+
// Awaited, because the first card must not run with a list about to change underneath it.
|
|
376
|
+
await refreshMcp();
|
|
240
377
|
// A restart begins with no environments: the last run's are nobody's now (see the function).
|
|
241
378
|
for (const path of new Set([config.repo, ...inScope.map((c) => c.path)])) {
|
|
242
379
|
void stopLeftoverEnvironments(path, (t) => log('tool', t)).catch(() => { });
|
|
@@ -386,7 +523,7 @@ export async function serve(opts) {
|
|
|
386
523
|
for (const id of [...answeredHere.keys()])
|
|
387
524
|
if (!mine.some((a) => a.request_id === id))
|
|
388
525
|
answeredHere.delete(id);
|
|
389
|
-
}).catch((e) => log('warn', `could not read the approvals list: ${
|
|
526
|
+
}).catch((e) => log('warn', `could not read the approvals list: ${describeFailure(e)}`))
|
|
390
527
|
.finally(() => { refreshing = null; });
|
|
391
528
|
return refreshing;
|
|
392
529
|
};
|
|
@@ -469,11 +606,12 @@ export async function serve(opts) {
|
|
|
469
606
|
// configured production targets are added to the shell every machine already has.
|
|
470
607
|
const runsSet = opts.deck
|
|
471
608
|
? runTools(config.runs ?? {}, () => onCard, opts.deck, (t) => log('tool', t), {
|
|
472
|
-
|
|
609
|
+
attach: (card, path, comment) => attachFile(card, path, comment),
|
|
610
|
+
flag: async (reason, kind, ask) => {
|
|
473
611
|
const cur = onCard;
|
|
474
612
|
if (!cur)
|
|
475
613
|
return null;
|
|
476
|
-
return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind);
|
|
614
|
+
return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind, ask);
|
|
477
615
|
},
|
|
478
616
|
unflag: async (cur) => {
|
|
479
617
|
parked.delete(cur.card);
|
|
@@ -695,6 +833,130 @@ export async function serve(opts) {
|
|
|
695
833
|
if (engine.name !== 'claude')
|
|
696
834
|
log('event', `${engine.name} is answering on this agent`);
|
|
697
835
|
let liveRun = null;
|
|
836
|
+
/** The checkout this agent's current or last card is in — never swept from under it. */
|
|
837
|
+
let currentTree = null;
|
|
838
|
+
const activeTrees = () => new Set(currentTree ? [currentTree] : []);
|
|
839
|
+
/**
|
|
840
|
+
* Paused: a wall this agent cannot get past by itself, so it does no work at all — no turns,
|
|
841
|
+
* no tokens — until its lead resumes it. See flaredeck-mcp/src/mcp/pause.ts for the why.
|
|
842
|
+
*/
|
|
843
|
+
let paused = null;
|
|
844
|
+
let readyReported = false;
|
|
845
|
+
/** Walls the agent cannot climb, checked in code — never by asking the model. */
|
|
846
|
+
const walls = async () => {
|
|
847
|
+
config.github = loadGitHub() ?? config.github;
|
|
848
|
+
const login = await canPublish(config.github).catch(() => null);
|
|
849
|
+
if (login)
|
|
850
|
+
return { check: 'github', reason: `GitHub refuses this machine's login, so nothing I write can be published. Somebody signs it in again on this machine: \`flaredeck-agent login --github-token ghp_…\` (a classic token with repo and workflow scope does not expire).` };
|
|
851
|
+
if (config.github?.accessToken) {
|
|
852
|
+
for (const c of [{ path: config.repo }, ...(await checkouts(config.repo, config.repos))]) {
|
|
853
|
+
const remote = await new Promise((resolve) => {
|
|
854
|
+
execFile('git', ['remote', 'get-url', 'origin'], { cwd: c.path }, (err, out) => resolve(err ? '' : String(out).trim()));
|
|
855
|
+
});
|
|
856
|
+
const why = remote ? await canPushTo(config.github.accessToken, remote) : null;
|
|
857
|
+
if (why)
|
|
858
|
+
return { check: 'github', reason: `${why} Nothing I write in ${c.path} could be published. Fix the access, or sign this machine in with an account that has it.` };
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
const missing = await refreshMcp();
|
|
862
|
+
if (missing?.length)
|
|
863
|
+
return mcpWallFor(missing);
|
|
864
|
+
const free = freeDiskBytes(config.repo);
|
|
865
|
+
if (free !== null && free < DISK_MIN) {
|
|
866
|
+
await sweepWorktrees(config.repo, WORKTREE_IDLE_LOW_DISK_MS, (t) => log('tool', t), activeTrees()).catch(() => { });
|
|
867
|
+
const after = freeDiskBytes(config.repo);
|
|
868
|
+
if (after !== null && after < DISK_MIN) {
|
|
869
|
+
return { check: 'disk', reason: `This machine has only ${(after / 1e9).toFixed(1)} GB of disk free, and builds, Docker and test environments all fail when it fills. Idle worktrees are already cleared; free space (Docker images, caches, old checkouts).` };
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return null;
|
|
873
|
+
};
|
|
874
|
+
const enterPause = async (wall) => {
|
|
875
|
+
if (paused?.check === wall.check)
|
|
876
|
+
return;
|
|
877
|
+
paused = wall;
|
|
878
|
+
readyReported = false;
|
|
879
|
+
state.paused = wall.reason;
|
|
880
|
+
log('warn', `PAUSED (${wall.check}): ${wall.reason}`);
|
|
881
|
+
void liveRun?.stop().catch(() => { });
|
|
882
|
+
try {
|
|
883
|
+
const r = await opts.deck?.pause(wall.check, wall.reason);
|
|
884
|
+
/* A solo agent has no lead: its cards stay with it (each gets a comment) and nobody is
|
|
885
|
+
messaged. Saying "told the lead, handed back …" then described something that never
|
|
886
|
+
happened. */
|
|
887
|
+
if (r && !r.already) {
|
|
888
|
+
log('event', r.lead
|
|
889
|
+
? `told the lead${r.handedBack.length ? `, handed back ${r.handedBack.join(', ')}` : ''} — doing nothing until resumed`
|
|
890
|
+
: `no lead to tell — ${r.handedBack.length ? `left a note on ${r.handedBack.join(', ')}; they stay mine` : 'nothing handed back'}. ${wall.check === 'disk' ? 'I resume myself once the disk is clear.' : wall.check === 'mcp' ? 'I resume myself once it is connected.' : 'A person resumes me.'}`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
catch (e) {
|
|
894
|
+
log('warn', `could not tell FlareDeck I am paused: ${e instanceof Error ? e.message : String(e)}`);
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
// While paused: ask FlareDeck every minute whether the lead has resumed this agent (one REST
|
|
898
|
+
// call, no model), and re-check the wall every ten, saying once when it is gone.
|
|
899
|
+
let lastWallCheck = 0;
|
|
900
|
+
const pauseTick = setInterval(() => {
|
|
901
|
+
if (!paused)
|
|
902
|
+
return;
|
|
903
|
+
void (async () => {
|
|
904
|
+
const server = await opts.deck?.pauseState().catch(() => undefined);
|
|
905
|
+
if (server === null) {
|
|
906
|
+
const still = await walls();
|
|
907
|
+
if (still) {
|
|
908
|
+
paused = null;
|
|
909
|
+
await enterPause(still);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
log('event', `resumed by the lead — back to work`);
|
|
913
|
+
paused = null;
|
|
914
|
+
state.paused = undefined;
|
|
915
|
+
void resumeOwnWork();
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
/* Disk is a statfs call, so it is checked every tick; the others ask GitHub, so every ten
|
|
919
|
+
minutes. A disk wall that has cleared resumes the agent at once — there is nothing for a
|
|
920
|
+
lead to decide about a number, and waiting for one left the agent idle for hours after
|
|
921
|
+
the space had already been freed. */
|
|
922
|
+
const selfClearing = paused.check === 'disk' || paused.check === 'mcp';
|
|
923
|
+
if (selfClearing) {
|
|
924
|
+
let cleared = '';
|
|
925
|
+
if (paused.check === 'disk') {
|
|
926
|
+
const free = freeDiskBytes(config.repo);
|
|
927
|
+
if (free === null || free < DISK_MIN)
|
|
928
|
+
return;
|
|
929
|
+
cleared = `disk is clear (${(free / 1e9).toFixed(0)} GB free)`;
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
// One HTTP call a minute while paused; null (FlareDeck unreachable) is not "connected".
|
|
933
|
+
const missing = await refreshMcp();
|
|
934
|
+
if (missing === null || missing.length)
|
|
935
|
+
return;
|
|
936
|
+
cleared = 'the required MCP servers are connected';
|
|
937
|
+
}
|
|
938
|
+
const r = await opts.deck?.resumeSelf().catch(() => null);
|
|
939
|
+
if (r?.resumed) {
|
|
940
|
+
log('event', `${cleared} — resumed myself and told the lead`);
|
|
941
|
+
paused = null;
|
|
942
|
+
state.paused = undefined;
|
|
943
|
+
readyReported = false;
|
|
944
|
+
void resumeOwnWork();
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
// An older server without self-resume: fall through to telling the lead, as before.
|
|
948
|
+
}
|
|
949
|
+
if ((!selfClearing && Date.now() - lastWallCheck < 10 * 60 * 1000) || readyReported)
|
|
950
|
+
return;
|
|
951
|
+
lastWallCheck = Date.now();
|
|
952
|
+
if (!(await walls())) {
|
|
953
|
+
readyReported = true;
|
|
954
|
+
log('event', `${paused.check} is fixed — told the lead; waiting to be resumed`);
|
|
955
|
+
await opts.deck?.ready().catch(() => { });
|
|
956
|
+
}
|
|
957
|
+
})();
|
|
958
|
+
}, 60 * 1000);
|
|
959
|
+
pauseTick.unref?.();
|
|
698
960
|
/**
|
|
699
961
|
* This turn's browser, so it can be closed when the turn ends.
|
|
700
962
|
*
|
|
@@ -729,8 +991,30 @@ export async function serve(opts) {
|
|
|
729
991
|
void drain();
|
|
730
992
|
}
|
|
731
993
|
};
|
|
994
|
+
/**
|
|
995
|
+
* A question typed in this pane.
|
|
996
|
+
*
|
|
997
|
+
* Whatever it says is passed through untouched, with one line in front of it saying who is
|
|
998
|
+
* speaking. Nothing here tries to work out whether it is a question or an instruction: a
|
|
999
|
+
* pattern that guessed got it wrong both ways — "work KAN-0074" did nothing because a bare
|
|
1000
|
+
* question never makes a worktree, and anything clever enough to catch that also caught
|
|
1001
|
+
* "work out why KAN-0074 failed" and started a build nobody asked for. The model reads English
|
|
1002
|
+
* better than a regular expression does; it only needed telling whose English it is.
|
|
1003
|
+
*
|
|
1004
|
+
* The authority is real and worth stating plainly. This person is at the machine. They can do
|
|
1005
|
+
* anything the agent can do and more, directly, without asking — so there is nothing to
|
|
1006
|
+
* protect here, and treating their instruction as a suggestion only wastes their time.
|
|
1007
|
+
*/
|
|
732
1008
|
const ask = (text) => {
|
|
733
|
-
const event = {
|
|
1009
|
+
const event = {
|
|
1010
|
+
id: `ask-${Date.now()}`,
|
|
1011
|
+
event: 'ask',
|
|
1012
|
+
line: text,
|
|
1013
|
+
message: 'This was typed by the person at this machine, in the agent\'s own terminal. They have ' +
|
|
1014
|
+
'every permission you have and more, and they are not waiting on anyone to approve it. ' +
|
|
1015
|
+
'Read it as what it plainly says: answer it if it is a question, do it if it is an ' +
|
|
1016
|
+
'instruction, and do not ask whether you may.\n\n' + text,
|
|
1017
|
+
};
|
|
734
1018
|
if (enqueue(event)) {
|
|
735
1019
|
prepareAhead(event);
|
|
736
1020
|
void drain();
|
|
@@ -815,7 +1099,21 @@ export async function serve(opts) {
|
|
|
815
1099
|
// every in-process FlareDeck call comes back on. From then until the turn ends, every
|
|
816
1100
|
// FlareDeck call fails as "interrupted" and the agent concludes FlareDeck is down. 'next'
|
|
817
1101
|
// waits for the tool batch to return and lands at the boundary, with nothing cut off.
|
|
818
|
-
|
|
1102
|
+
/* A run that was stopped (a pause stops it) can still be referenced here for a moment. If
|
|
1103
|
+
it will not take the line, it is not running: answer it as a question instead of
|
|
1104
|
+
dropping what the person typed. */
|
|
1105
|
+
const run = liveRun;
|
|
1106
|
+
void run.steer(text).then((heard) => {
|
|
1107
|
+
if (heard !== false)
|
|
1108
|
+
return;
|
|
1109
|
+
if (liveRun === run)
|
|
1110
|
+
liveRun = null;
|
|
1111
|
+
ask(text);
|
|
1112
|
+
}).catch(() => {
|
|
1113
|
+
if (liveRun === run)
|
|
1114
|
+
liveRun = null;
|
|
1115
|
+
ask(text);
|
|
1116
|
+
});
|
|
819
1117
|
}
|
|
820
1118
|
else {
|
|
821
1119
|
ask(text);
|
|
@@ -842,7 +1140,13 @@ export async function serve(opts) {
|
|
|
842
1140
|
* which point the SDK compacts silently. So the live turn is told, once, to wrap up: finish
|
|
843
1141
|
* the step, leave the handover, stop. Deliberate, and it happens while there is still room.
|
|
844
1142
|
*/
|
|
845
|
-
|
|
1143
|
+
//
|
|
1144
|
+
// OFF by default now. Stopping an agent part-way through a card cost more than compaction did:
|
|
1145
|
+
// the card was handed to a fresh session that re-read everything to get back to where the old
|
|
1146
|
+
// one already was. The end-of-card handover still happens. Set FLAREDECK_AGENT_WRAP_UP_AT to a
|
|
1147
|
+
// token count to bring the mid-turn nudge back.
|
|
1148
|
+
const wrapUpAt = Number(process.env['FLAREDECK_AGENT_WRAP_UP_AT']);
|
|
1149
|
+
const CONTEXT_WRAP_UP_TOKENS = Number.isFinite(wrapUpAt) && wrapUpAt > 0 ? wrapUpAt : Infinity;
|
|
846
1150
|
const keepAlive = (key, cwd, tokens) => {
|
|
847
1151
|
const prev = live.get(key);
|
|
848
1152
|
if (prev?.timer)
|
|
@@ -997,15 +1301,85 @@ export async function serve(opts) {
|
|
|
997
1301
|
const sweepMinutes = Number(process.env['FLAREDECK_AGENT_SWEEP_MINUTES']);
|
|
998
1302
|
const SWEEP_EVERY_MS = Math.max(5, Number.isFinite(sweepMinutes) ? sweepMinutes : 60) * 60 * 1000;
|
|
999
1303
|
const sweepsEnabled = process.env['FLAREDECK_AGENT_NO_SWEEP'] !== '1';
|
|
1304
|
+
/**
|
|
1305
|
+
* Read a plan's usage and post every window to the ledger.
|
|
1306
|
+
*
|
|
1307
|
+
* This is what the Secrets bars and `choose` route on. It used to happen only when `choose`
|
|
1308
|
+
* handed a plan over, at most once an hour, in the background — so the plans a run did not get
|
|
1309
|
+
* stayed "not measured yet" for days, and `choose` ranked on nothing and handed out a plan that
|
|
1310
|
+
* was already at its weekly cap. Now: every plan at startup, and the plan a card used once the
|
|
1311
|
+
* card is done, which is when its numbers have just moved.
|
|
1312
|
+
*
|
|
1313
|
+
* Never throws and never blocks work: a failed reading costs a little routing accuracy.
|
|
1314
|
+
*/
|
|
1315
|
+
const measurePlan = async (subs, plan, force = false) => {
|
|
1316
|
+
if (!subs)
|
|
1317
|
+
return;
|
|
1318
|
+
const last = usageReadAt.get(plan.id) ?? 0;
|
|
1319
|
+
if (!force && Date.now() - last < USAGE_REFRESH_MS)
|
|
1320
|
+
return;
|
|
1321
|
+
usageReadAt.set(plan.id, Date.now());
|
|
1322
|
+
try {
|
|
1323
|
+
const { readUsage } = await import('./usage.js');
|
|
1324
|
+
let why = '';
|
|
1325
|
+
const windows = await readUsage({ token: plan.token, onEmpty: (w) => { why = w; } });
|
|
1326
|
+
if (!windows.length) {
|
|
1327
|
+
log('warn', `claude usage: ${plan.label} — could not read it (${why || 'no detail'})`);
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
for (const w of windows)
|
|
1331
|
+
await subs.seen(plan.id, w.window, w.utilization, w.resetsAt);
|
|
1332
|
+
log('tool', `claude usage: ${plan.label} · ${windows.map((w) => `${w.window} ${w.utilization}%`).join(' · ')}`);
|
|
1333
|
+
}
|
|
1334
|
+
catch { /* bookkeeping must never interrupt work */ }
|
|
1335
|
+
};
|
|
1336
|
+
/** Every plan, one after another — each read starts a Claude process, so not all at once. */
|
|
1337
|
+
const measureAll = async () => {
|
|
1338
|
+
const subs = await subscriptionsForAgent(config.mcpUrl, () => opts.auth(), () => { }).catch(() => null);
|
|
1339
|
+
if (!subs)
|
|
1340
|
+
return;
|
|
1341
|
+
const plans = await subs.measure().catch(() => []);
|
|
1342
|
+
// Said, because a plan missing here — a revoked token — is otherwise invisible.
|
|
1343
|
+
log('tool', `claude usage: measuring ${plans.length ? plans.map((p) => p.label).join(', ') : 'nothing (no readable subscription tokens)'}`);
|
|
1344
|
+
for (const plan of plans)
|
|
1345
|
+
await measurePlan(subs, plan, true);
|
|
1346
|
+
};
|
|
1347
|
+
void measureAll();
|
|
1348
|
+
// The walls, checked once at start — before any card is picked up. A restart while paused
|
|
1349
|
+
// picks the pause back up from FlareDeck rather than re-announcing it.
|
|
1350
|
+
void (async () => {
|
|
1351
|
+
const server = await opts.deck?.pauseState().catch(() => undefined);
|
|
1352
|
+
if (server) {
|
|
1353
|
+
paused = { check: server.check, reason: server.reason };
|
|
1354
|
+
readyReported = !!server.ready;
|
|
1355
|
+
state.paused = server.reason;
|
|
1356
|
+
log('warn', `PAUSED since ${server.since.slice(0, 16).replace('T', ' ')} (${server.check}) — doing nothing until the lead resumes me`);
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
const wall = await walls();
|
|
1360
|
+
if (wall)
|
|
1361
|
+
await enterPause(wall);
|
|
1362
|
+
})();
|
|
1000
1363
|
const sweepNow = () => {
|
|
1001
|
-
if (!sweepsEnabled || stopping)
|
|
1364
|
+
if (paused || !sweepsEnabled || stopping)
|
|
1002
1365
|
return;
|
|
1003
1366
|
// Busy is reason enough to wait: the next tick is minutes away and the work is not going
|
|
1004
|
-
// anywhere.
|
|
1005
|
-
if (working || queue.length
|
|
1367
|
+
// anywhere.
|
|
1368
|
+
if (working || queue.length)
|
|
1006
1369
|
return;
|
|
1007
|
-
|
|
1008
|
-
|
|
1370
|
+
void (async () => {
|
|
1371
|
+
/* A card already in progress IS the answer to "what next" — no model needed. Asking the
|
|
1372
|
+
model instead had it try to start a second card every sweep, be refused by FlareDeck's
|
|
1373
|
+
one-card rule, say "the harness will pick up KAN-0074", and stop — for an hour, because
|
|
1374
|
+
nothing did. So the harness resumes it first, and only sweeps when nothing is held. */
|
|
1375
|
+
if (await resumeOwnWork())
|
|
1376
|
+
return;
|
|
1377
|
+
// `live.size` covers a card whose session is parked between events.
|
|
1378
|
+
if (working || queue.length || live.size)
|
|
1379
|
+
return;
|
|
1380
|
+
if (enqueue({ id: 'sweep', event: 'sweep', line: 'checking what is still assigned to you' }))
|
|
1381
|
+
void drain();
|
|
1382
|
+
})();
|
|
1009
1383
|
};
|
|
1010
1384
|
/**
|
|
1011
1385
|
* A card of mine already in progress, picked up again without being asked.
|
|
@@ -1020,8 +1394,10 @@ export async function serve(opts) {
|
|
|
1020
1394
|
* have been — with a worktree, a branch, and everything else a card turn gets.
|
|
1021
1395
|
*/
|
|
1022
1396
|
const resumeOwnWork = async () => {
|
|
1023
|
-
|
|
1024
|
-
|
|
1397
|
+
/* Not `live.size`: a session kept WARM for a follow-up is a cache, not work, and blocking on
|
|
1398
|
+
it meant the card in progress was never resumed within fifteen minutes of any turn. */
|
|
1399
|
+
if (paused || !sweepsEnabled || stopping || working || queue.length)
|
|
1400
|
+
return false;
|
|
1025
1401
|
let mine = [];
|
|
1026
1402
|
try {
|
|
1027
1403
|
// Scoped, like every other account-wide question. This one is asked by the HARNESS rather
|
|
@@ -1039,11 +1415,25 @@ export async function serve(opts) {
|
|
|
1039
1415
|
.map((c) => ({ code: c.code }));
|
|
1040
1416
|
}
|
|
1041
1417
|
catch {
|
|
1042
|
-
return; // unreachable board is the stream's problem to report, not this timer's
|
|
1418
|
+
return false; // unreachable board is the stream's problem to report, not this timer's
|
|
1419
|
+
}
|
|
1420
|
+
/* IN PROGRESS by the lane's TYPE — the same test FlareDeck's one-card rule uses. This took the
|
|
1421
|
+
first outstanding card in priority order whatever its lane, so it picked KAN-0080 out of
|
|
1422
|
+
Review while KAN-0074 sat in "In dev" untouched: the model was told "the harness picks up
|
|
1423
|
+
the card in progress" and the harness picked a different one, and the agent went round
|
|
1424
|
+
that loop for an hour. Lane NAMES differ per board ("In dev", "Doing"); the type does not. */
|
|
1425
|
+
let next;
|
|
1426
|
+
for (const c of mine) {
|
|
1427
|
+
if (sessions.has(c.code) || live.has(c.code))
|
|
1428
|
+
continue;
|
|
1429
|
+
const card = await opts.deck?.card(c.code).catch(() => null);
|
|
1430
|
+
if (String(card?.lane?.type ?? '').toLowerCase() === 'inprogress') {
|
|
1431
|
+
next = c;
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
1043
1434
|
}
|
|
1044
|
-
const next = mine.find((c) => !sessions.has(c.code) && !live.has(c.code));
|
|
1045
1435
|
if (!next)
|
|
1046
|
-
return;
|
|
1436
|
+
return false;
|
|
1047
1437
|
log('tool', `picking ${next.code} back up — it is yours and already in progress`);
|
|
1048
1438
|
if (enqueue({
|
|
1049
1439
|
id: `resume-${next.code}-${Date.now()}`,
|
|
@@ -1053,6 +1443,7 @@ export async function serve(opts) {
|
|
|
1053
1443
|
cardKey: next.code,
|
|
1054
1444
|
}))
|
|
1055
1445
|
void drain();
|
|
1446
|
+
return true;
|
|
1056
1447
|
};
|
|
1057
1448
|
const sweepTimer = setInterval(sweepNow, SWEEP_EVERY_MS);
|
|
1058
1449
|
sweepTimer.unref();
|
|
@@ -1138,6 +1529,8 @@ export async function serve(opts) {
|
|
|
1138
1529
|
status.startCard(current, !!event.cardKey);
|
|
1139
1530
|
void report();
|
|
1140
1531
|
let didWork = true;
|
|
1532
|
+
/** Set when the turn failed on its Claude plan, not its work: run it again at once. */
|
|
1533
|
+
let switchPlan = false;
|
|
1141
1534
|
turnCwd = '';
|
|
1142
1535
|
try {
|
|
1143
1536
|
await handle(event);
|
|
@@ -1146,13 +1539,30 @@ export async function serve(opts) {
|
|
|
1146
1539
|
// One bad event must not take the daemon down — that is the failure it exists to fix.
|
|
1147
1540
|
didWork = !(e instanceof CouldNotWork);
|
|
1148
1541
|
log('warn', `${current}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1542
|
+
if (e instanceof RetryOnAnotherPlan)
|
|
1543
|
+
switchPlan = true;
|
|
1149
1544
|
}
|
|
1150
1545
|
// Acknowledged only after the work, and only if the work could happen at all. A crash
|
|
1151
1546
|
// mid-turn redelivers; so does a turn that never had what it needed.
|
|
1152
1547
|
status.finishCard(didWork);
|
|
1153
1548
|
void report();
|
|
1154
1549
|
pending.delete(event.id);
|
|
1155
|
-
|
|
1550
|
+
// The plan this event ran on has just moved: read it now, so the next `choose` and the
|
|
1551
|
+
// Secrets tab see where it stands. Not after a plan switch — that plan is filed already.
|
|
1552
|
+
// Skipped when the run already reported usage on its own — a separate read would cost a
|
|
1553
|
+
// probe request to learn what the run just said.
|
|
1554
|
+
if (turnPlan && !switchPlan && !turnSawUsage) {
|
|
1555
|
+
const plan = turnPlan;
|
|
1556
|
+
void subscriptionsForAgent(config.mcpUrl, () => opts.auth(), () => { })
|
|
1557
|
+
.then((subs) => measurePlan(subs, plan))
|
|
1558
|
+
.catch(() => { });
|
|
1559
|
+
}
|
|
1560
|
+
turnPlan = null;
|
|
1561
|
+
turnSawUsage = false;
|
|
1562
|
+
// Before the ask/sweep branch: a sweep that hit a dead plan is retried like any other event.
|
|
1563
|
+
if (switchPlan)
|
|
1564
|
+
enqueue(event, true);
|
|
1565
|
+
else if (event.event === 'ask' || event.event === 'sweep' || event.event === 'run-answered') { /* local: nothing to acknowledge, nothing to retry */ }
|
|
1156
1566
|
else if (didWork)
|
|
1157
1567
|
await stream.ack([event.id]);
|
|
1158
1568
|
else {
|
|
@@ -1290,7 +1700,59 @@ export async function serve(opts) {
|
|
|
1290
1700
|
* needs-human" over the top of it. An agent said it had asked for help, a person was never told,
|
|
1291
1701
|
* and the card sat.
|
|
1292
1702
|
*/
|
|
1293
|
-
|
|
1703
|
+
/**
|
|
1704
|
+
* Put a local file on a card, done here rather than by the agent's shell. The upload is a PUT of
|
|
1705
|
+
* bytes to FlareDeck's own upload address, and the auto-mode classifier refused it as
|
|
1706
|
+
* "destructive, reaching production" — so an agent with seven screenshots asked a person to
|
|
1707
|
+
* upload them. Same three steps the tools describe (mint a link, PUT, wire it in), no curl.
|
|
1708
|
+
*/
|
|
1709
|
+
const attachFile = async (card, path, comment) => {
|
|
1710
|
+
const { existsSync, readFileSync, realpathSync, statSync } = await import('node:fs');
|
|
1711
|
+
const { basename, extname, isAbsolute, resolve } = await import('node:path');
|
|
1712
|
+
const { tmpdir } = await import('node:os');
|
|
1713
|
+
const full = isAbsolute(path) ? path : resolve(card.cwd, path);
|
|
1714
|
+
if (!existsSync(full))
|
|
1715
|
+
throw new Error(`there is no file at ${full}`);
|
|
1716
|
+
const real = realpathSync(full);
|
|
1717
|
+
const roots = [card.cwd, '/tmp', '/private/tmp', tmpdir()].map((r) => { try {
|
|
1718
|
+
return realpathSync(r);
|
|
1719
|
+
}
|
|
1720
|
+
catch {
|
|
1721
|
+
return r;
|
|
1722
|
+
} });
|
|
1723
|
+
if (!roots.some((r) => real === r || real.startsWith(`${r}/`)))
|
|
1724
|
+
throw new Error('only files inside your checkout or /tmp can be attached');
|
|
1725
|
+
const size = statSync(real).size;
|
|
1726
|
+
if (size > 30 * 1024 * 1024)
|
|
1727
|
+
throw new Error(`the file is ${Math.round(size / 1048576)} MB; the limit is 30 MB`);
|
|
1728
|
+
const types = {
|
|
1729
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
|
1730
|
+
'.pdf': 'application/pdf', '.csv': 'text/csv', '.txt': 'text/plain', '.log': 'text/plain', '.json': 'application/json',
|
|
1731
|
+
'.md': 'text/markdown', '.html': 'text/html', '.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
1732
|
+
};
|
|
1733
|
+
const contentType = types[extname(real).toLowerCase()] ?? 'application/octet-stream';
|
|
1734
|
+
const ids = { card_id: card.code || card.card, ...(card.board ? { board_id: card.board } : {}) };
|
|
1735
|
+
const minted = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-attachment-upload-url', {
|
|
1736
|
+
...ids, file_name: basename(real), content_type: contentType,
|
|
1737
|
+
});
|
|
1738
|
+
const uploadUrl = /https?:\/\/[^\s"`]+\/upload\/[^\s"`]+/.exec(minted)?.[0];
|
|
1739
|
+
const objectPath = /`object_path`: `([^`]+)`/.exec(minted)?.[1];
|
|
1740
|
+
if (!uploadUrl || !objectPath)
|
|
1741
|
+
throw new Error('FlareDeck did not return an upload address');
|
|
1742
|
+
const put = await fetch(uploadUrl, { method: 'PUT', body: readFileSync(real), headers: { 'Content-Type': contentType } });
|
|
1743
|
+
if (!put.ok)
|
|
1744
|
+
throw new Error(`the upload was refused (${put.status})`);
|
|
1745
|
+
const wired = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'attach-uploaded-file', {
|
|
1746
|
+
...ids, object_path: objectPath, attach_to: 'comment', ...(comment ? { comment } : {}),
|
|
1747
|
+
});
|
|
1748
|
+
log('tool', `attached ${basename(real)} to ${card.code || card.card}`);
|
|
1749
|
+
return `Attached ${basename(real)} to ${card.code || card.card}. ${wired.split('\n')[0]}`;
|
|
1750
|
+
};
|
|
1751
|
+
const flagCard = async (ref, reason, kind,
|
|
1752
|
+
/** The one-line ask and its answer buttons — what the person actually sees on a phone. */
|
|
1753
|
+
ask,
|
|
1754
|
+
/** Somewhere for the person to GO, which is what a sign-in request is mostly made of. */
|
|
1755
|
+
links) => {
|
|
1294
1756
|
const label = ref.code || ref.card || 'card';
|
|
1295
1757
|
try {
|
|
1296
1758
|
const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'set-needs-human', {
|
|
@@ -1299,6 +1761,8 @@ export async function serve(opts) {
|
|
|
1299
1761
|
...(ref.board ? { board_id: ref.board } : {}),
|
|
1300
1762
|
reason,
|
|
1301
1763
|
...(kind ? { kind } : {}),
|
|
1764
|
+
...(ask ? { question: ask.question, options: ask.options } : {}),
|
|
1765
|
+
...(links?.length ? { links } : {}),
|
|
1302
1766
|
});
|
|
1303
1767
|
if (out.trimStart().startsWith('\u274c') || out.trimStart().startsWith('\ud83d\udeab')) {
|
|
1304
1768
|
log('warn', `${label}: FlareDeck did not flag this card \u2014 ${out.split('\n')[0]}`);
|
|
@@ -1355,7 +1819,7 @@ export async function serve(opts) {
|
|
|
1355
1819
|
log('event', `${event.cardCode || event.cardKey}: comment on a finished card — nothing to do`);
|
|
1356
1820
|
return;
|
|
1357
1821
|
}
|
|
1358
|
-
|
|
1822
|
+
let key = event.cardKey || (event.event === 'ask' ? 'ask' : event.event === 'sweep' ? 'sweep' : event.event === 'scheduled' ? `schedule-${event.scheduleId}` : 'inbox');
|
|
1359
1823
|
let turnTokens = 0;
|
|
1360
1824
|
let nudged = false;
|
|
1361
1825
|
// A checkout of its own per card, so it cannot edit a working tree someone else is in.
|
|
@@ -1444,9 +1908,42 @@ export async function serve(opts) {
|
|
|
1444
1908
|
// the publishing tools and the journal. Without this, HAL committed its work on a message
|
|
1445
1909
|
// turn, was told to publish with push_branch, and went looking for push_branch on the
|
|
1446
1910
|
// filesystem: the harness had named a tool that turn did not have.
|
|
1911
|
+
// Room on the disk before a card gets a checkout. An agent left alone overnight filled one to
|
|
1912
|
+
// 762 MB free, and from there nothing works and nothing says why: Docker cannot make a
|
|
1913
|
+
// container, builds die half-written, the machine swaps. Below LOW the idle worktrees go first
|
|
1914
|
+
// (sooner than usual); below MIN the card is not started, and a person is told what to clear.
|
|
1915
|
+
if (event.cardKey && willChange && (chosen || where.kind === 'none')) {
|
|
1916
|
+
let free = freeDiskBytes(repo);
|
|
1917
|
+
if (free !== null && free < DISK_LOW) {
|
|
1918
|
+
await sweepWorktrees(repo, WORKTREE_IDLE_LOW_DISK_MS, (t) => log('tool', t), activeTrees()).catch(() => { });
|
|
1919
|
+
free = freeDiskBytes(repo);
|
|
1920
|
+
}
|
|
1921
|
+
if (free !== null && free < DISK_MIN) {
|
|
1922
|
+
const wall = await walls();
|
|
1923
|
+
if (wall) {
|
|
1924
|
+
await enterPause(wall);
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1447
1929
|
const tree = event.cardKey && (chosen || where.kind === 'none')
|
|
1448
1930
|
? await ensureWorktree(repo, key, (t) => log('tool', t), startFrom, config.worktreeLocalFiles)
|
|
1449
|
-
: await worktreeTheMessageIsAbout(event, repo, (t) => log('tool', t));
|
|
1931
|
+
: await worktreeTheMessageIsAbout(event, repo, (t) => log('tool', t), (code) => ensureWorktree(repo, code, (t) => log('tool', t), startFrom, config.worktreeLocalFiles));
|
|
1932
|
+
/**
|
|
1933
|
+
* Questions about a CARD get their own session, not the shared one.
|
|
1934
|
+
*
|
|
1935
|
+
* Every pane ask used the single key 'ask', and a session keeps the tools it was built with.
|
|
1936
|
+
* So the first question asked before a card had a checkout built a session with no browser
|
|
1937
|
+
* and no publishing tools — and every later question about that card resumed it and
|
|
1938
|
+
* inherited the lack. The agent reported it precisely ("the browser tools aren't mounted
|
|
1939
|
+
* this session") and went looking for headless Chrome in the shell instead, which is both
|
|
1940
|
+
* worse and the kind of workaround the guards exist to stop.
|
|
1941
|
+
*
|
|
1942
|
+
* Keyed on the checkout, so a question about a card shares a session with other questions
|
|
1943
|
+
* about THAT card and nothing else.
|
|
1944
|
+
*/
|
|
1945
|
+
if (event.event === 'ask' && tree)
|
|
1946
|
+
key = `ask:${tree.path}`;
|
|
1450
1947
|
const cwd = tree?.path ?? repo;
|
|
1451
1948
|
// Back on this checkout: whatever was waiting to take its environment down can stand down.
|
|
1452
1949
|
turnCwd = cwd;
|
|
@@ -1458,6 +1955,7 @@ export async function serve(opts) {
|
|
|
1458
1955
|
// So a later message naming this card by its CODE can find the work. See rememberWorktree.
|
|
1459
1956
|
if (tree && (event.cardCode || event.cardKey)) {
|
|
1460
1957
|
rememberWorktree(event.cardCode || event.cardKey, tree.path, tree.branch);
|
|
1958
|
+
currentTree = tree.path;
|
|
1461
1959
|
}
|
|
1462
1960
|
// No worktree, but this turn is going to change something. That used to carry on in whatever
|
|
1463
1961
|
// directory happened to be the fallback — no branch, no isolation, and no word said. An agent
|
|
@@ -1585,6 +2083,19 @@ export async function serve(opts) {
|
|
|
1585
2083
|
if (!resume && event.cardKey && event.event !== 'review' && event.event !== 'ask') {
|
|
1586
2084
|
await startOnBoard(event.cardCode || event.cardKey);
|
|
1587
2085
|
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Which card this turn is about — including when nobody dispatched it.
|
|
2088
|
+
*
|
|
2089
|
+
* This was set only from the event's own cardKey, so a question typed in the pane about a
|
|
2090
|
+
* card had none: the agent had the card, its checkout and its environment, hit a sign-in it
|
|
2091
|
+
* could not pass, and was told "there is no card being worked, so there is nobody to ask
|
|
2092
|
+
* about it". It fell back to leaving a comment, which nobody is notified about. The one
|
|
2093
|
+
* moment the escalation exists for, and it was unavailable.
|
|
2094
|
+
*
|
|
2095
|
+
* `tag` is the code the turn resolved to — from the event, or from the card a pane question
|
|
2096
|
+
* named. FlareDeck takes a human code wherever it takes a card id, so the code alone is
|
|
2097
|
+
* enough to flag, comment and clear.
|
|
2098
|
+
*/
|
|
1588
2099
|
onCard = event.cardKey
|
|
1589
2100
|
? {
|
|
1590
2101
|
card: event.cardKey,
|
|
@@ -1592,7 +2103,9 @@ export async function serve(opts) {
|
|
|
1592
2103
|
...(event.boardKey ? { board: event.boardKey } : {}),
|
|
1593
2104
|
...(event.cardCode ? { code: event.cardCode } : {}),
|
|
1594
2105
|
}
|
|
1595
|
-
:
|
|
2106
|
+
: tag
|
|
2107
|
+
? { card: tag, cwd, code: tag, ...(event.boardKey ? { board: event.boardKey } : {}) }
|
|
2108
|
+
: null;
|
|
1596
2109
|
job = tree
|
|
1597
2110
|
? {
|
|
1598
2111
|
cwd,
|
|
@@ -1601,7 +2114,13 @@ export async function serve(opts) {
|
|
|
1601
2114
|
// back to the repo's default branch when it has not.
|
|
1602
2115
|
base: startFrom ?? (await baseBranch(cwd)),
|
|
1603
2116
|
card: event.cardCode || key,
|
|
2117
|
+
onAuthFailure: (why) => {
|
|
2118
|
+
void enterPause({ check: 'github', reason: `GitHub refused this machine's login when I pushed (${why.slice(0, 160)}), so nothing I write can be published. Somebody signs it in again on this machine: \`flaredeck-agent login --github-token ghp_…\` (a classic token with repo and workflow scope does not expire).` });
|
|
2119
|
+
},
|
|
1604
2120
|
token: async () => {
|
|
2121
|
+
// Read fresh at every push: the login is the machine's, so signing in again from
|
|
2122
|
+
// any pane fixes an agent that has been running all day with the old one.
|
|
2123
|
+
config.github = loadGitHub() ?? config.github;
|
|
1605
2124
|
if (!config.github)
|
|
1606
2125
|
throw new Error('This machine has no GitHub login — run flaredeck-agent login.');
|
|
1607
2126
|
return freshGitHubToken(config.github, (next) => {
|
|
@@ -1629,7 +2148,7 @@ export async function serve(opts) {
|
|
|
1629
2148
|
// servers before it was allowed to look at a stylesheet. An agent that finds it does need
|
|
1630
2149
|
// something running can start it itself; the brief says how.
|
|
1631
2150
|
const needsRunning = (prepared?.jev?.needs_running ?? 0) >= 0.5;
|
|
1632
|
-
|
|
2151
|
+
let env = forACard && needsRunning
|
|
1633
2152
|
// The script this board declared for the repository this card is in, if it declared one.
|
|
1634
2153
|
// Matched on the remote, the same way the base branch is.
|
|
1635
2154
|
? await startEnvironment(cwd, (t) => log('tool', t), where.kind === 'repo' ? where.script : undefined)
|
|
@@ -1641,20 +2160,255 @@ export async function serve(opts) {
|
|
|
1641
2160
|
// card is usually about code that is already live somewhere, and an agent that can only see
|
|
1642
2161
|
// what it started locally cannot tell its own change from how the thing already behaved.
|
|
1643
2162
|
const declaredSites = where.kind === 'repo' ? where.sites ?? {} : {};
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
2163
|
+
// Which started part is the app itself — asked of the parts, since boards rarely name them.
|
|
2164
|
+
const web = Object.keys(env.endpoints).length ? await webPart(env.endpoints).catch(() => undefined) : undefined;
|
|
2165
|
+
if (web)
|
|
2166
|
+
log('tool', `browser: "${web}" serves the app — open_page opens it by default`);
|
|
2167
|
+
/**
|
|
2168
|
+
* Starting the project, as something the agent can ASK FOR.
|
|
2169
|
+
*
|
|
2170
|
+
* It was decided once, before the turn, by a classifier not even consulted for a question
|
|
2171
|
+
* typed in the pane — so the answer defaulted to "no" however plainly the card said
|
|
2172
|
+
* otherwise, and the agent had to find the script and start it detached by hand. Now it can
|
|
2173
|
+
* ask, and a failure hands back the error together with what it may do about it.
|
|
2174
|
+
*/
|
|
2175
|
+
const envTools = environmentTools({
|
|
2176
|
+
cwd: () => cwd,
|
|
2177
|
+
script: () => (where.kind === 'repo' ? where.script : undefined),
|
|
2178
|
+
running: () => (env.ran ? env : null),
|
|
2179
|
+
// The same deployed addresses the browser gets, so a sign-in can be asked for the dev or
|
|
2180
|
+
// UAT site and not only for something this card started.
|
|
2181
|
+
sites: () => declaredSites,
|
|
2182
|
+
// Straight onto the live picture: the browser reads its addresses through a getter, so
|
|
2183
|
+
// open_page works the moment this returns — no new turn, nothing to re-read.
|
|
2184
|
+
onStarted: (started) => {
|
|
2185
|
+
env = started;
|
|
2186
|
+
reachable = { cwd, endpoints: env.endpoints };
|
|
2187
|
+
},
|
|
2188
|
+
onNote: (t) => log('tool', t),
|
|
2189
|
+
/**
|
|
2190
|
+
* Turning "ask somebody" into something that actually asks.
|
|
2191
|
+
*
|
|
2192
|
+
* Three pieces already worked and were never joined up: open_page DETECTS a sign-in wall
|
|
2193
|
+
* and says which kind it is, signInSession captures a session a person completes by hand,
|
|
2194
|
+
* and loadSession replays it for every later card. The gap was the middle. All wallBrief
|
|
2195
|
+
* could do was tell the agent in prose to ask, with no tool to call — so the agent wrote a
|
|
2196
|
+
* comment, flagged the card and stopped. That was the only thing available to it, and it
|
|
2197
|
+
* read like a model that would not try.
|
|
2198
|
+
*
|
|
2199
|
+
* So: start the viewer, flag the card with the address (a flag is what reaches a phone),
|
|
2200
|
+
* and when the session lands, store it, clear the flag and put the card back at the front
|
|
2201
|
+
* of the queue. The agent's next turn opens the app already signed in.
|
|
2202
|
+
*
|
|
2203
|
+
* The viewer is LOOPBACK, deliberately: it drives a browser part-way through somebody's
|
|
2204
|
+
* sign-in, which is not a thing to publish on a reachable port. So the person has to be at
|
|
2205
|
+
* this machine. That is the case this is for — an agent testing localhost on a developer's
|
|
2206
|
+
* own box — and for a cloud agent it is a stated limit rather than a silent failure.
|
|
2207
|
+
*/
|
|
2208
|
+
...(onCard
|
|
2209
|
+
? {
|
|
2210
|
+
requestSignIn: async ({ part, url, why }) => {
|
|
2211
|
+
const card = onCard;
|
|
2212
|
+
if (!card)
|
|
2213
|
+
return { asked: false, why: 'the card went away while this was being asked' };
|
|
2214
|
+
if (!(await ensureBrowser((t) => log('tool', `browser: ${t}`)))) {
|
|
2215
|
+
return { asked: false, why: 'this machine has no browser and could not fetch one' };
|
|
2216
|
+
}
|
|
2217
|
+
const { chromium } = await import('playwright-core');
|
|
2218
|
+
const { signInSession } = await import('./signin.js');
|
|
2219
|
+
const { rememberSignInNow, HEADLESS_FULL } = await import('./browser.js');
|
|
2220
|
+
const { randomBytes } = await import('node:crypto');
|
|
2221
|
+
/* Through FlareDeck, not through a port on this machine. The person may be on a
|
|
2222
|
+
phone and the agent may be on a cloud box, which is the case this exists for;
|
|
2223
|
+
loopback only ever served the one where they are in the same room. */
|
|
2224
|
+
const id = `sgn_${randomBytes(9).toString('base64url')}`;
|
|
2225
|
+
/* agent-service, not the MCP. The relay was first built onto flaredeck-mcp, which
|
|
2226
|
+
cannot host it: the MCP deploys as a Firebase Function, and a Function hands you
|
|
2227
|
+
(req, res) with no http.Server underneath, so a WebSocket upgrade can never
|
|
2228
|
+
complete there. agent-service is a real Cloud Run service and already the one
|
|
2229
|
+
this harness holds an event stream open to. */
|
|
2230
|
+
const serviceUrl = process.env['FLAREDECK_RELAY_URL'] ?? config.agentServiceUrl;
|
|
2231
|
+
if (!serviceUrl) {
|
|
2232
|
+
// Configured by default, so this means somebody blanked it. Say which setting.
|
|
2233
|
+
return { asked: false, why: 'this harness has no agentServiceUrl, so a sign-in cannot be relayed' };
|
|
2234
|
+
}
|
|
2235
|
+
const relayBase = serviceUrl.replace(/^http/, 'ws').replace(/\/+$/, '');
|
|
2236
|
+
const appBase = (process.env['FLAREDECK_APP_URL'] ?? 'https://app.flaredeck.app').replace(/\/+$/, '');
|
|
2237
|
+
const viewerUrl = (t) => `${appBase}/signin/${id}?t=${encodeURIComponent(t)}`;
|
|
2238
|
+
/* Resolve as far as the ADDRESS and no further. Waiting for the sign-in itself would
|
|
2239
|
+
hold the agent's tool call open for as long as a person takes to get to their
|
|
2240
|
+
machine, and not waiting is the whole point: the card parks, the turn ends. */
|
|
2241
|
+
let settle;
|
|
2242
|
+
const ready = new Promise((r) => { settle = r; });
|
|
2243
|
+
const ref = {
|
|
2244
|
+
card: card.card,
|
|
2245
|
+
...(card.board ? { board: card.board } : {}),
|
|
2246
|
+
...(card.code ? { code: card.code } : {}),
|
|
2247
|
+
};
|
|
2248
|
+
void signInSession({
|
|
2249
|
+
url,
|
|
2250
|
+
launch: () => chromium.launch(HEADLESS_FULL),
|
|
2251
|
+
relay: { url: relayBase, token: await opts.auth(), id, viewerUrl },
|
|
2252
|
+
onReady: (viewer) => {
|
|
2253
|
+
log('tool', `sign-in wanted for "${part}" — ${viewer}`);
|
|
2254
|
+
void flagCard(ref, `${url} needs someone to sign in before this card can go on. ${why}\n\n` +
|
|
2255
|
+
`Open the sign-in below and do it there. It is this agent's real browser, ` +
|
|
2256
|
+
`shown live — click and type in the picture, then press Save & finish. The ` +
|
|
2257
|
+
`session is kept for the whole project, so this is once, not once per card.`, 'sign_in', undefined, [{ url: viewer, label: 'Sign in' }]);
|
|
2258
|
+
settle({ asked: true, where: viewer });
|
|
2259
|
+
},
|
|
2260
|
+
onNote: (t) => log('tool', `sign-in: ${t}`),
|
|
2261
|
+
})
|
|
2262
|
+
.then(async (r) => {
|
|
2263
|
+
if (!r.ok || !r.session) {
|
|
2264
|
+
log('warn', `sign-in for "${part}" did not finish: ${r.why ?? 'nothing was saved'}`);
|
|
2265
|
+
/* Say so ON THE CARD. The flag already raised carries a link to a viewer
|
|
2266
|
+
that is now dead, and a person coming back to it an hour later would find
|
|
2267
|
+
a page that cannot connect and no indication why — which reads as the
|
|
2268
|
+
feature being broken rather than as a window having closed. */
|
|
2269
|
+
void flagCard(ref, `The sign-in for ${url} expired before anyone did it — the link in the ` +
|
|
2270
|
+
`earlier request no longer works.\n\n${why}\n\nNothing is broken: ask ` +
|
|
2271
|
+
`the agent to try again and a fresh sign-in is offered.`, 'sign_in');
|
|
2272
|
+
settle({ asked: false, why: r.why ?? 'nothing was saved' });
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
/* Awaited, and the failure is handled. Sessions live only in FlareDeck now —
|
|
2276
|
+
there is no copy on this machine — so a failed write means the sign-in is
|
|
2277
|
+
GONE. Announcing "Signed in" and resuming the card would wake an agent that
|
|
2278
|
+
meets the same wall and asks again: a loop that reads as the feature being
|
|
2279
|
+
broken rather than as one write having failed. */
|
|
2280
|
+
const saved = await rememberSignInNow(config.repo, part, r.session, (t) => log('tool', `sign-in: ${t}`));
|
|
2281
|
+
if (!saved.ok) {
|
|
2282
|
+
log('warn', `sign-in for "${part}" completed but was NOT saved: ${saved.message ?? 'no reason given'}`);
|
|
2283
|
+
void flagCard(ref, `Someone signed in to ${url}, but the session could not be saved to FlareDeck ` +
|
|
2284
|
+
`(${saved.message ?? 'no reason given'}). Credentials are kept only in FlareDeck, ` +
|
|
2285
|
+
`so nothing was retained here and the sign-in has been lost.\n\n${why}\n\n` +
|
|
2286
|
+
`Once FlareDeck is reachable, ask the agent to try again.`, 'sign_in');
|
|
2287
|
+
settle({ asked: false, why: saved.message ?? 'the session could not be saved' });
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
const site = saved.id;
|
|
2291
|
+
log('tool', `signed in — kept for ${site}; every card on this project opens straight in`);
|
|
2292
|
+
await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'clear-needs-human', {
|
|
2293
|
+
card_id: card.code || card.card,
|
|
2294
|
+
...(card.board ? { board_id: card.board } : {}),
|
|
2295
|
+
}).catch((e) => log('warn', `could not clear needs-human: ${e instanceof Error ? e.message : String(e)}`));
|
|
2296
|
+
parked.delete(card.card);
|
|
2297
|
+
resumeParked(card, `Signed in. The session is kept for ${site}, so open_page opens the app signed ` +
|
|
2298
|
+
`in from now on. Carry on with what you were going to check: ${why}`);
|
|
2299
|
+
})
|
|
2300
|
+
.catch((e) => {
|
|
2301
|
+
const why2 = e instanceof Error ? e.message : String(e);
|
|
2302
|
+
log('warn', `sign-in for "${part}" failed: ${why2}`);
|
|
2303
|
+
settle({ asked: false, why: why2 });
|
|
2304
|
+
});
|
|
2305
|
+
return ready;
|
|
2306
|
+
},
|
|
2307
|
+
}
|
|
2308
|
+
: {}),
|
|
2309
|
+
});
|
|
2310
|
+
/**
|
|
2311
|
+
* Always mounted. Mounting it only when something was already running is what cached a
|
|
2312
|
+
* stale toolset for a whole session: the first question about a card came before its
|
|
2313
|
+
* checkout existed, so that session had no browser, and every later question resumed it and
|
|
2314
|
+
* inherited the lack — the agent went looking for headless Chrome in the shell instead. An
|
|
2315
|
+
* agent with nothing to look at simply does not call these; the brief says what to do first.
|
|
2316
|
+
*/
|
|
2317
|
+
const eyes = turnEyes = browserTools({
|
|
2318
|
+
repo: config.repo,
|
|
2319
|
+
endpoints: () => env.endpoints,
|
|
2320
|
+
...(web ? { defaultPart: () => web } : {}),
|
|
2321
|
+
sites: () => declaredSites,
|
|
2322
|
+
contract: () => env.contract,
|
|
2323
|
+
cwd,
|
|
2324
|
+
onNote: (t) => log('tool', t),
|
|
2325
|
+
});
|
|
1654
2326
|
reachable = { cwd, endpoints: env.endpoints };
|
|
1655
|
-
|
|
1656
|
-
|
|
2327
|
+
/**
|
|
2328
|
+
* Say which it is, because they are not the same and the difference matters.
|
|
2329
|
+
*
|
|
2330
|
+
* "this card does not look like it needs one" was printed whenever the project was not
|
|
2331
|
+
* started — including when nothing had looked. Intake does not run for a question typed in
|
|
2332
|
+
* the pane, so `needs_running` defaulted to 0 and the agent was told a judgement had been
|
|
2333
|
+
* made about its card that had not been made at all.
|
|
2334
|
+
*/
|
|
2335
|
+
if (forACard && !needsRunning) {
|
|
2336
|
+
log('tool', prepared
|
|
2337
|
+
? 'environment not started — this card does not look like it needs one'
|
|
2338
|
+
: 'environment not started — nothing judged whether it is needed; call start_environment if it is');
|
|
2339
|
+
}
|
|
2340
|
+
// A lead's sweep is a JOB, not a nudge: clear the ready column by running the team. A nudge
|
|
2341
|
+
// got one look and "nothing to do"; a goal keeps the lead going, turn after turn, until the
|
|
2342
|
+
// board itself says it is done — proven by get-portfolio, not by the lead's own say-so.
|
|
2343
|
+
const leadGoal = isLead && event.event === 'sweep' && process.env['FLAREDECK_AGENT_LEAD_GOAL'] !== '0'
|
|
2344
|
+
? LEAD_SWEEP_GOAL
|
|
2345
|
+
: undefined;
|
|
2346
|
+
if (leadGoal)
|
|
2347
|
+
log('tool', 'sweep with a goal: clear the ready column by dispatching (≤8 turns)');
|
|
2348
|
+
/**
|
|
2349
|
+
* Which Claude subscription pays for this run.
|
|
2350
|
+
*
|
|
2351
|
+
* Three subscriptions and twenty-odd agents, with daily and weekly caps that trip at
|
|
2352
|
+
* different times, used to mean going round the machines logging in again. FlareDeck holds
|
|
2353
|
+
* the tokens and the ledger now and picks one per run.
|
|
2354
|
+
*
|
|
2355
|
+
* Not reaching FlareDeck is NOT the same as having nothing left: `machine` means carry on
|
|
2356
|
+
* with whatever this box is logged in as, exactly as before any of this existed. Only
|
|
2357
|
+
* `none` — every subscription genuinely spent or paused — stops the run, and it says when
|
|
2358
|
+
* one frees up rather than failing blankly.
|
|
2359
|
+
*/
|
|
2360
|
+
/* The MCP list, fresh for this session — and a required server that is not connected stops
|
|
2361
|
+
the work here, before a plan is spent on a card that cannot be done properly. */
|
|
2362
|
+
const missingMcp = await refreshMcp();
|
|
2363
|
+
if (missingMcp?.length) {
|
|
2364
|
+
await enterPause(mcpWallFor(missingMcp));
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
const subs = await subscriptionsForAgent(config.mcpUrl, () => opts.auth(), (t) => log('tool', t));
|
|
2368
|
+
const claudePlan = subs ? await subs.choose(failedPlans.get(event.id) ?? []) : null;
|
|
2369
|
+
if (claudePlan?.use === 'token') {
|
|
2370
|
+
log('tool', `claude: ${claudePlan.label}`);
|
|
2371
|
+
// On the identity line too, not only in the scrollback: the scrollback moves on, and
|
|
2372
|
+
// "which account is this agent spending" is a thing you glance at, not search for.
|
|
2373
|
+
state.claudeAccount = claudePlan.label;
|
|
2374
|
+
// Measured again once this event is done — see measurePlan.
|
|
2375
|
+
turnPlan = { id: claudePlan.id, label: claudePlan.label, token: claudePlan.token };
|
|
2376
|
+
}
|
|
2377
|
+
if (claudePlan?.use === 'none') {
|
|
2378
|
+
const when = claudePlan.availableAt ? new Date(claudePlan.availableAt).toLocaleString() : 'later';
|
|
2379
|
+
const tried = failedPlans.get(event.id)?.length ?? 0;
|
|
2380
|
+
failedPlans.delete(event.id);
|
|
2381
|
+
/**
|
|
2382
|
+
* Spent, not paused: the harness knows to the minute when a plan comes back — FlareDeck's
|
|
2383
|
+
* answer carries the soonest reset — so it waits for it and carries on by itself. Nobody is
|
|
2384
|
+
* asked to do anything about a clock. Only a fleet with NO reset coming (every plan paused,
|
|
2385
|
+
* which only a person undoes) is flagged.
|
|
2386
|
+
*/
|
|
2387
|
+
if (claudePlan.availableAt && claudePlan.availableAt > Date.now()) {
|
|
2388
|
+
const at = claudePlan.availableAt;
|
|
2389
|
+
if (!waitingForPlan.has(event.id)) {
|
|
2390
|
+
// A little after the reset, so the first request is not refused for being a second early.
|
|
2391
|
+
const timer = setTimeout(() => {
|
|
2392
|
+
waitingForPlan.delete(event.id);
|
|
2393
|
+
log('event', `a Claude subscription is back — resuming ${event.cardCode || event.line}`);
|
|
2394
|
+
if (enqueue(event, true))
|
|
2395
|
+
void drain();
|
|
2396
|
+
}, at - Date.now() + 60_000);
|
|
2397
|
+
timer.unref?.();
|
|
2398
|
+
waitingForPlan.set(event.id, timer);
|
|
2399
|
+
}
|
|
2400
|
+
log('warn', `every Claude subscription is spent${tried ? ` (${tried} failed on this turn)` : ''} — waiting, resuming at ${new Date(at).toLocaleTimeString()}`);
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
log('warn', `every Claude subscription is spent or paused${tried ? ` (${tried} failed on this turn)` : ''} — the soonest frees up ${when}`);
|
|
2404
|
+
await flagCard({ card: event.cardKey ?? '', ...(event.boardKey ? { board: event.boardKey } : {}), ...(event.cardCode ? { code: event.cardCode } : {}) }, `I cannot start this card: every Claude subscription in FlareDeck is spent, paused` +
|
|
2405
|
+
`${tried ? ', or just failed on this card' : ''}. ` +
|
|
2406
|
+
`The soonest one frees up ${when}.\n\nNothing is wrong with the card. Add or unpause a ` +
|
|
2407
|
+
`subscription on the Integrations page, or wait for the window to reset.`, 'technical');
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
1657
2410
|
const engineContext = {
|
|
2411
|
+
...(leadGoal ? { goal: leadGoal } : {}),
|
|
1658
2412
|
config: { ...config, repo: cwd },
|
|
1659
2413
|
// Only a real worktree confines anything; without one nothing is enforced here, and the
|
|
1660
2414
|
// guard below makes sure a turn that would CHANGE code never gets that far.
|
|
@@ -1684,6 +2438,7 @@ export async function serve(opts) {
|
|
|
1684
2438
|
...(resume ? { resume } : {}),
|
|
1685
2439
|
...(prepared?.model ? { model: prepared.model } : {}),
|
|
1686
2440
|
...(prepared?.size ? { size: prepared.size } : {}),
|
|
2441
|
+
...(claudePlan?.use === 'token' ? { claudeToken: claudePlan.token } : {}),
|
|
1687
2442
|
tools: {
|
|
1688
2443
|
...(job ? { git: { server: gitServer, toolNames: GIT_TOOL_NAMES } } : {}),
|
|
1689
2444
|
// Eyes, for any card. They used to be mounted only when the harness had itself started
|
|
@@ -1691,7 +2446,8 @@ export async function serve(opts) {
|
|
|
1691
2446
|
// no browser AND no mention of one, so it could not know that writing the script it is
|
|
1692
2447
|
// perfectly able to write was the thing standing between it and seeing its own change.
|
|
1693
2448
|
// The endpoints are read through a getter, so something the agent starts mid-turn counts.
|
|
1694
|
-
|
|
2449
|
+
browser: eyes,
|
|
2450
|
+
env: envTools,
|
|
1695
2451
|
team: teamSet,
|
|
1696
2452
|
flaredeck,
|
|
1697
2453
|
runs: runsSet,
|
|
@@ -1712,7 +2468,7 @@ export async function serve(opts) {
|
|
|
1712
2468
|
freeWorkerBrief(event) +
|
|
1713
2469
|
// Only tell an agent about the environment when the card actually needs one running.
|
|
1714
2470
|
environmentBrief(env, cwd, needsRunning) + notStartedBrief(forACard && !needsRunning, cwd) +
|
|
1715
|
-
browserBrief(env.endpoints, declaredSites, forACard) +
|
|
2471
|
+
browserBrief(env.endpoints, declaredSites, forACard, web) +
|
|
1716
2472
|
(wrongEnv.length
|
|
1717
2473
|
? `\n\nSTOP — the environment that started is not yours:\n${wrongEnv.map((w) => ` · ${w}`).join('\n')}\n\n` +
|
|
1718
2474
|
`Anything you test against it tells you about another card's code, and it will answer ` +
|
|
@@ -1724,82 +2480,149 @@ export async function serve(opts) {
|
|
|
1724
2480
|
liveRun = run;
|
|
1725
2481
|
// An agent that has started fighting the tools is not going to stop on its own.
|
|
1726
2482
|
const struggling = watchForDesperation();
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
2483
|
+
/**
|
|
2484
|
+
* One cap is usually seen two or three times in a run: the structured `rate_limit_event`
|
|
2485
|
+
* (which knows the window and the reset), then the error on the assistant message and the
|
|
2486
|
+
* result text (which know neither). Reporting all of them filed an `unknown` window with a
|
|
2487
|
+
* guessed five-hour block on top of the real one — keeping a plan out hours past its actual
|
|
2488
|
+
* reset. Once a run has reported a named window, the anonymous echoes are dropped.
|
|
2489
|
+
*/
|
|
2490
|
+
let spentNamed = false;
|
|
2491
|
+
let spentAny = false;
|
|
2492
|
+
const spentReports = [];
|
|
2493
|
+
let doneText = '';
|
|
2494
|
+
let runError = null;
|
|
2495
|
+
try {
|
|
2496
|
+
for await (const out of run.events) {
|
|
2497
|
+
status.observe(out);
|
|
2498
|
+
if (out.kind === 'done' && !out.ok)
|
|
2499
|
+
doneText = String(out.result ?? '');
|
|
2500
|
+
if (out.kind === 'limits' && claudePlan?.use === 'token') {
|
|
2501
|
+
/* Reported, never interpreted here. 'allowed_warning' arrives while the plan is still
|
|
2502
|
+
working — treating it as spent would retire every subscription at about half capacity
|
|
2503
|
+
and leave the agents with nothing while each account still had headroom. */
|
|
2504
|
+
if (out.spent) {
|
|
2505
|
+
const named = !!out.window && out.window !== 'unknown';
|
|
2506
|
+
spentAny = true;
|
|
2507
|
+
if (named || !spentNamed) {
|
|
2508
|
+
spentNamed ||= named;
|
|
2509
|
+
spentReports.push(subs?.spent(claudePlan.id, out.window, out.resetsAt, out.why ?? `hit during ${event.cardCode ?? event.cardKey ?? 'a run'}`) ?? Promise.resolve());
|
|
2510
|
+
log('warn', `claude: ${claudePlan.label} hit its ${out.window ?? 'usage'} limit (${out.source ?? 'unknown signal'})`);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
else if (out.window && typeof out.utilization === 'number') {
|
|
2514
|
+
turnSawUsage = true;
|
|
2515
|
+
void subs?.seen(claudePlan.id, out.window, out.utilization, out.resetsAt);
|
|
1739
2516
|
}
|
|
1740
|
-
if (tag)
|
|
1741
|
-
void tellLeadItIsStuck(tag, struggling.refusals());
|
|
1742
2517
|
}
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
// The plan replaces the previous one — TodoWrite always sends the whole list.
|
|
1758
|
-
else if (out.kind === 'plan') {
|
|
1759
|
-
state.plan = out.items;
|
|
1760
|
-
state.tasks = {
|
|
1761
|
-
done: out.items.filter((t) => t.status === 'completed').length,
|
|
1762
|
-
total: out.items.length,
|
|
1763
|
-
};
|
|
1764
|
-
void report();
|
|
1765
|
-
}
|
|
1766
|
-
else if (out.kind === 'ready') {
|
|
1767
|
-
if (out.sessionId)
|
|
1768
|
-
sessions.set(key, out.sessionId);
|
|
1769
|
-
// The preflight proved the server answers; this proves the SESSION got it. The SDK will
|
|
1770
|
-
// start the model with a server still 'pending' or 'needs-auth' and list its tools from
|
|
1771
|
-
// memory, so a count alone is not enough — the status has to say connected. Refuse the
|
|
1772
|
-
// turn otherwise: the event stays queued and comes back once the tools are really there.
|
|
1773
|
-
const fd = out.mcp.find((s) => s.name === config.mcpServerName);
|
|
1774
|
-
log('tool', `session: FlareDeck ${fd?.status ?? 'absent'} · ${out.tools.length} tools · ${out.permissionMode}`);
|
|
1775
|
-
if (!fd || fd.status !== 'connected' || out.tools.length === 0) {
|
|
1776
|
-
throw new CouldNotWork(`this session's FlareDeck server is ${fd?.status ?? 'absent'}, so the card cannot be read — leaving the event queued`);
|
|
2518
|
+
if (out.kind === 'tool') {
|
|
2519
|
+
const stepBack = struggling.saw(out.name);
|
|
2520
|
+
if (stepBack) {
|
|
2521
|
+
log('warn', `${struggling.refusals()} refusals this turn — telling it to stop and ask`);
|
|
2522
|
+
const heard = await run.steer(stepBack).catch(() => false);
|
|
2523
|
+
// An engine that cannot be steered gets stopped instead: letting it carry on is how a
|
|
2524
|
+
// turn becomes twenty minutes of working around a guard.
|
|
2525
|
+
if (!heard) {
|
|
2526
|
+
log('warn', 'cannot steer this engine — ending the turn');
|
|
2527
|
+
await run.stop().catch(() => { });
|
|
2528
|
+
}
|
|
2529
|
+
if (tag)
|
|
2530
|
+
void tellLeadItIsStuck(tag, struggling.refusals());
|
|
2531
|
+
}
|
|
1777
2532
|
}
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
if (
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
2533
|
+
state.tasks = status.snapshot().tasks;
|
|
2534
|
+
// An answer to a question typed here is the whole point of the turn, so it is shown whole;
|
|
2535
|
+
// a card turn's narration is one line each, the card holds the full account.
|
|
2536
|
+
if (out.kind === 'text')
|
|
2537
|
+
log(event.event === 'ask' ? 'answer' : 'say', out.text);
|
|
2538
|
+
else if (out.kind === 'tool')
|
|
2539
|
+
log('tool', out.name);
|
|
2540
|
+
// A changed plan is the most informative thing an agent emits, so it is pushed at once
|
|
2541
|
+
// rather than waiting for the timer.
|
|
2542
|
+
else if (out.kind === 'todo') {
|
|
2543
|
+
log('todo', out.text);
|
|
2544
|
+
void report();
|
|
1788
2545
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
2546
|
+
// The plan replaces the previous one — TodoWrite always sends the whole list.
|
|
2547
|
+
else if (out.kind === 'plan') {
|
|
2548
|
+
state.plan = out.items;
|
|
2549
|
+
state.tasks = {
|
|
2550
|
+
done: out.items.filter((t) => t.status === 'completed').length,
|
|
2551
|
+
total: out.items.length,
|
|
2552
|
+
};
|
|
2553
|
+
void report();
|
|
2554
|
+
}
|
|
2555
|
+
else if (out.kind === 'ready') {
|
|
2556
|
+
if (out.sessionId)
|
|
2557
|
+
sessions.set(key, out.sessionId);
|
|
2558
|
+
// The preflight proved the server answers; this proves the SESSION got it. The SDK will
|
|
2559
|
+
// start the model with a server still 'pending' or 'needs-auth' and list its tools from
|
|
2560
|
+
// memory, so a count alone is not enough — the status has to say connected. Refuse the
|
|
2561
|
+
// turn otherwise: the event stays queued and comes back once the tools are really there.
|
|
2562
|
+
const fd = out.mcp.find((s) => s.name === config.mcpServerName);
|
|
2563
|
+
log('tool', `session: FlareDeck ${fd?.status ?? 'absent'} · ${out.tools.length} tools · ${out.permissionMode}`);
|
|
2564
|
+
if (!fd || fd.status !== 'connected' || out.tools.length === 0) {
|
|
2565
|
+
throw new CouldNotWork(`this session's FlareDeck server is ${fd?.status ?? 'absent'}, so the card cannot be read — leaving the event queued`);
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
else if (out.kind === 'usage') {
|
|
1796
2569
|
turnTokens = out.contextTokens;
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
2570
|
+
if (!nudged && turnTokens >= CONTEXT_WRAP_UP_TOKENS && liveRun) {
|
|
2571
|
+
nudged = true;
|
|
2572
|
+
log('warn', `context at ${Math.round(turnTokens / 1000)}k — asking it to wrap up before the window fills`);
|
|
2573
|
+
const wrapUpText = `Your context is at ${Math.round(turnTokens / 1000)}k tokens and will be compacted soon, which loses detail. ` +
|
|
2574
|
+
'Finish the step you are on, then stop: comment on the card with where things stand, put anything half-done ' +
|
|
2575
|
+
'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.';
|
|
2576
|
+
void liveRun.steer(wrapUpText);
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
else if (out.kind === 'compacted')
|
|
2580
|
+
log('warn', 'the SDK compacted this session mid-turn — detail before this point is summarised, not kept');
|
|
2581
|
+
else if (out.kind === 'done') {
|
|
2582
|
+
if (!out.ok)
|
|
2583
|
+
log('warn', 'the turn did not finish cleanly');
|
|
2584
|
+
if (out.contextTokens)
|
|
2585
|
+
turnTokens = out.contextTokens;
|
|
2586
|
+
// The number the intake step is meant to move: how big this turn's context got.
|
|
2587
|
+
if (turnTokens)
|
|
2588
|
+
log('tool', `turn: ${Math.round(turnTokens / 1000)}k context${prepared?.size ? ` · ${prepared.size} work` : ''}`);
|
|
2589
|
+
}
|
|
1800
2590
|
}
|
|
1801
2591
|
}
|
|
2592
|
+
catch (e) {
|
|
2593
|
+
runError = e;
|
|
2594
|
+
}
|
|
1802
2595
|
liveRun = null;
|
|
2596
|
+
/**
|
|
2597
|
+
* The plan failed, not the work: switch plans and run this event again, now.
|
|
2598
|
+
*
|
|
2599
|
+
* Two causes, told apart because they are fixed differently. A spent window is already filed
|
|
2600
|
+
* above (awaited here, so `choose` sees it). A rejected token — "401 Invalid bearer token",
|
|
2601
|
+
* a mis-pasted or revoked setup-token — is not capacity and is not filed as spent; it is
|
|
2602
|
+
* excluded for this event, and says plainly that the token needs replacing.
|
|
2603
|
+
*
|
|
2604
|
+
* The retry resumes the same session, so work already done in the turn is not lost; it costs
|
|
2605
|
+
* the prompt cache, which is the price of carrying on at all.
|
|
2606
|
+
*/
|
|
2607
|
+
if (claudePlan?.use === 'token') {
|
|
2608
|
+
const said = `${runError instanceof Error ? runError.message : runError ?? ''} ${doneText}`;
|
|
2609
|
+
const rejected = /API Error: 401|invalid bearer token|OAuth token has expired|OAuth token (was )?revoked/i.test(said);
|
|
2610
|
+
if (rejected || spentAny) {
|
|
2611
|
+
await Promise.allSettled(spentReports);
|
|
2612
|
+
const tried = [...(failedPlans.get(event.id) ?? []), claudePlan.id];
|
|
2613
|
+
if (rejected) {
|
|
2614
|
+
log('warn', `claude: ${claudePlan.label} — its token was rejected. Replace it on Integrations → Secrets.`);
|
|
2615
|
+
}
|
|
2616
|
+
if (tried.length <= MAX_PLAN_SWITCHES) {
|
|
2617
|
+
failedPlans.set(event.id, tried);
|
|
2618
|
+
throw new RetryOnAnotherPlan(`${claudePlan.label} ${rejected ? 'rejected its token' : 'is at its limit'} — switching subscription and trying again`);
|
|
2619
|
+
}
|
|
2620
|
+
failedPlans.delete(event.id);
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
failedPlans.delete(event.id);
|
|
2624
|
+
if (runError)
|
|
2625
|
+
throw runError;
|
|
1803
2626
|
// A question from the pane: no card to hand over to. Its session is kept so the next question
|
|
1804
2627
|
// can build on this one, and simply dropped once it has grown too large to be worth carrying.
|
|
1805
2628
|
if (event.event === 'ask') {
|
|
@@ -1961,8 +2784,8 @@ export async function serve(opts) {
|
|
|
1961
2784
|
ui = startUi({ state: () => ({ ...state, queued: queue.length }), onSteer: steer, onQuit: shutdown, ...(decide ? { onDecide: decide } : {}) });
|
|
1962
2785
|
try {
|
|
1963
2786
|
for await (const event of stream.events((s) => {
|
|
1964
|
-
state.connected = s
|
|
1965
|
-
log(
|
|
2787
|
+
state.connected = s.startsWith('listening');
|
|
2788
|
+
log(state.connected ? 'tool' : 'warn', s);
|
|
1966
2789
|
})) {
|
|
1967
2790
|
// A verdict on a request this agent is waiting on. Handled HERE, not in drain(): the turn
|
|
1968
2791
|
// that asked is still running — blocked inside the tool call — and drain is serialised
|
|
@@ -1976,6 +2799,14 @@ export async function serve(opts) {
|
|
|
1976
2799
|
beat();
|
|
1977
2800
|
continue;
|
|
1978
2801
|
}
|
|
2802
|
+
// Paused: nothing is worked. Acknowledged, because its cards went back to the lead and the
|
|
2803
|
+
// lead decides what happens to them; carrying a backlog through a pause would replay it.
|
|
2804
|
+
if (paused) {
|
|
2805
|
+
log('tool', `paused — left for the lead: ${event.line}`);
|
|
2806
|
+
await stream.ack([event.id]);
|
|
2807
|
+
beat();
|
|
2808
|
+
continue;
|
|
2809
|
+
}
|
|
1979
2810
|
// Another board's work. Acknowledged so it does not come round again, and never read: no
|
|
1980
2811
|
// intake, no Jev call, no turn. One line, so it is visible that scope is doing something
|
|
1981
2812
|
// rather than that the agent has gone deaf.
|
|
@@ -2038,6 +2869,18 @@ async function baseBranch(cwd) {
|
|
|
2038
2869
|
c.on('error', () => resolve('main'));
|
|
2039
2870
|
});
|
|
2040
2871
|
}
|
|
2872
|
+
/**
|
|
2873
|
+
* What a lead's sweep must achieve, as a `/goal` condition (under 4000 characters). Judged by a
|
|
2874
|
+
* small model from the transcript alone, so the proof has to be IN the transcript: a final
|
|
2875
|
+
* get-portfolio, quoted. Bounded, because a column that cannot be emptied (no free worker, every
|
|
2876
|
+
* card blocked) must end in reasons, not in a loop.
|
|
2877
|
+
*/
|
|
2878
|
+
const LEAD_SWEEP_GOAL = 'Every card in your board\'s ready-for-work column (the lane FlareDeck calls Ready For Dev — its ' +
|
|
2879
|
+
'type is todo) has been dealt with: handed to a worker that reports to you and is free, with ' +
|
|
2880
|
+
'assign-card, or left there with a one-line reason it must wait (no free worker, blocked, ' +
|
|
2881
|
+
'waiting on a person). You dispatch; you do not build cards yourself. The goal is met only when ' +
|
|
2882
|
+
'your most recent turn called get-portfolio and you quoted, from that output, how many cards ' +
|
|
2883
|
+
'are in the ready column and, for each card still there, its one-line reason. Or stop after 8 turns.';
|
|
2041
2884
|
function prompt(event, isLead = false) {
|
|
2042
2885
|
if (event.event === 'sweep') {
|
|
2043
2886
|
return [
|