@onepointfour-packs/flaredeck-agent 0.2.38 → 0.2.40
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 +114 -0
- package/dist/hydrate.js +79 -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 +178 -96
- 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/providers.d.ts +10 -0
- package/dist/providers.js +23 -0
- package/dist/providers.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 +1061 -114
- 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,81 @@ 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
|
+
/**
|
|
351
|
+
* The workspace's GitHub connection, from FlareDeck — current at every session, refreshed there
|
|
352
|
+
* with the OAuth App's secret this public package cannot hold. When set it wins over the
|
|
353
|
+
* machine's own github.json; when absent the machine's login works exactly as before.
|
|
354
|
+
*/
|
|
355
|
+
let flaredeckGithub = null;
|
|
356
|
+
/** The command guard's config, so a rotated OpenRouter key reaches it (see refreshMcp). */
|
|
357
|
+
let jevCfg = null;
|
|
358
|
+
const refreshMcp = async () => {
|
|
359
|
+
try {
|
|
360
|
+
const { hydrateMcpServers, mcpConfigFetcher } = await import('./hydrate.js');
|
|
361
|
+
const tenantForMcp = tenantFromToken(await opts.auth());
|
|
362
|
+
if (!tenantForMcp)
|
|
363
|
+
return null;
|
|
364
|
+
const hydrated = await hydrateMcpServers({ mcpServers: localMcp }, mcpConfigFetcher(config.mcpUrl, tenantForMcp, () => opts.auth()));
|
|
365
|
+
config.mcpServers = hydrated.mcpServers;
|
|
366
|
+
if (hydrated.note && hydrated.note !== lastMcpNote) {
|
|
367
|
+
lastMcpNote = hydrated.note;
|
|
368
|
+
log('tool', hydrated.note);
|
|
369
|
+
}
|
|
370
|
+
const gh = hydrated.github ?? null;
|
|
371
|
+
if (!!gh !== !!flaredeckGithub || gh?.login !== flaredeckGithub?.login) {
|
|
372
|
+
log('tool', gh ? `GitHub: using FlareDeck's connection (@${gh.login ?? 'unknown'})` : 'GitHub: this machine\'s own login');
|
|
373
|
+
}
|
|
374
|
+
flaredeckGithub = gh;
|
|
375
|
+
// The workspace's model keys: Jev's OpenRouter key and Gemini, kept current every session.
|
|
376
|
+
const { setProvidersFromFlaredeck } = await import('./providers.js');
|
|
377
|
+
setProvidersFromFlaredeck(hydrated.providers);
|
|
378
|
+
if (jevCfg)
|
|
379
|
+
jevCfg.apiKey = loadProviders().openrouter?.apiKey ?? jevCfg.apiKey;
|
|
380
|
+
return hydrated.missingRequired ?? null;
|
|
381
|
+
}
|
|
382
|
+
catch (e) {
|
|
383
|
+
// Never fatal: the machine's own list still works, which is what every agent had before.
|
|
384
|
+
log('warn', `could not fetch the MCP list: ${e instanceof Error ? e.message : String(e)}`);
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
const mcpWallFor = (missing) => ({
|
|
389
|
+
check: 'mcp',
|
|
390
|
+
reason: `A required MCP server is not connected — ${missing.map((m) => `${m.name}: ${m.why}`).join('; ')}. ` +
|
|
391
|
+
`Nothing is worked until it is; I resume by myself within a minute of it being connected.`,
|
|
392
|
+
});
|
|
393
|
+
// Awaited, because the first card must not run with a list about to change underneath it.
|
|
394
|
+
await refreshMcp();
|
|
240
395
|
// A restart begins with no environments: the last run's are nobody's now (see the function).
|
|
241
396
|
for (const path of new Set([config.repo, ...inScope.map((c) => c.path)])) {
|
|
242
397
|
void stopLeftoverEnvironments(path, (t) => log('tool', t)).catch(() => { });
|
|
@@ -386,7 +541,7 @@ export async function serve(opts) {
|
|
|
386
541
|
for (const id of [...answeredHere.keys()])
|
|
387
542
|
if (!mine.some((a) => a.request_id === id))
|
|
388
543
|
answeredHere.delete(id);
|
|
389
|
-
}).catch((e) => log('warn', `could not read the approvals list: ${
|
|
544
|
+
}).catch((e) => log('warn', `could not read the approvals list: ${describeFailure(e)}`))
|
|
390
545
|
.finally(() => { refreshing = null; });
|
|
391
546
|
return refreshing;
|
|
392
547
|
};
|
|
@@ -431,7 +586,8 @@ export async function serve(opts) {
|
|
|
431
586
|
* Its refusals and its asks both end up in decisions.jsonl beside the config.
|
|
432
587
|
*/
|
|
433
588
|
const jevKey = loadProviders().openrouter?.apiKey;
|
|
434
|
-
|
|
589
|
+
// Kept as an object so a key rotated in FlareDeck reaches the guard at the next session.
|
|
590
|
+
const guard = jevGuard(jevCfg = {
|
|
435
591
|
apiKey: jevKey ?? '',
|
|
436
592
|
decider: (req, opt) => decider(req, opt),
|
|
437
593
|
log: (t) => log('tool', t),
|
|
@@ -469,11 +625,12 @@ export async function serve(opts) {
|
|
|
469
625
|
// configured production targets are added to the shell every machine already has.
|
|
470
626
|
const runsSet = opts.deck
|
|
471
627
|
? runTools(config.runs ?? {}, () => onCard, opts.deck, (t) => log('tool', t), {
|
|
472
|
-
|
|
628
|
+
attach: (card, path, comment) => attachFile(card, path, comment),
|
|
629
|
+
flag: async (reason, kind, ask) => {
|
|
473
630
|
const cur = onCard;
|
|
474
631
|
if (!cur)
|
|
475
632
|
return null;
|
|
476
|
-
return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind);
|
|
633
|
+
return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind, ask);
|
|
477
634
|
},
|
|
478
635
|
unflag: async (cur) => {
|
|
479
636
|
parked.delete(cur.card);
|
|
@@ -695,6 +852,137 @@ export async function serve(opts) {
|
|
|
695
852
|
if (engine.name !== 'claude')
|
|
696
853
|
log('event', `${engine.name} is answering on this agent`);
|
|
697
854
|
let liveRun = null;
|
|
855
|
+
/** The checkout this agent's current or last card is in — never swept from under it. */
|
|
856
|
+
let currentTree = null;
|
|
857
|
+
const activeTrees = () => new Set(currentTree ? [currentTree] : []);
|
|
858
|
+
/**
|
|
859
|
+
* Paused: a wall this agent cannot get past by itself, so it does no work at all — no turns,
|
|
860
|
+
* no tokens — until its lead resumes it. See flaredeck-mcp/src/mcp/pause.ts for the why.
|
|
861
|
+
*/
|
|
862
|
+
let paused = null;
|
|
863
|
+
let readyReported = false;
|
|
864
|
+
/** Walls the agent cannot climb, checked in code — never by asking the model. */
|
|
865
|
+
const walls = async () => {
|
|
866
|
+
/* FlareDeck's token is checked from a LOCAL, never put into config.github: saveConfig writes
|
|
867
|
+
config.github to github.json, and the hourly OAuth refresh calls saveConfig — so FlareDeck's
|
|
868
|
+
token would have been copied onto this machine's disk, and survived Disconnect there. */
|
|
869
|
+
if (!flaredeckGithub)
|
|
870
|
+
config.github = loadGitHub() ?? config.github;
|
|
871
|
+
const gh = flaredeckGithub ? { accessToken: flaredeckGithub.token } : config.github;
|
|
872
|
+
const login = await canPublish(gh).catch(() => null);
|
|
873
|
+
if (login)
|
|
874
|
+
return { check: 'github', reason: flaredeckGithub
|
|
875
|
+
? `GitHub refuses FlareDeck's connection (@${flaredeckGithub.login ?? 'unknown'}), so nothing I write can be published. Reconnect GitHub on Integrations.`
|
|
876
|
+
: `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).` };
|
|
877
|
+
if (gh?.accessToken) {
|
|
878
|
+
for (const c of [{ path: config.repo }, ...(await checkouts(config.repo, config.repos))]) {
|
|
879
|
+
const remote = await new Promise((resolve) => {
|
|
880
|
+
execFile('git', ['remote', 'get-url', 'origin'], { cwd: c.path }, (err, out) => resolve(err ? '' : String(out).trim()));
|
|
881
|
+
});
|
|
882
|
+
const why = remote ? await canPushTo(gh.accessToken, remote) : null;
|
|
883
|
+
if (why)
|
|
884
|
+
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.` };
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
const missing = await refreshMcp();
|
|
888
|
+
if (missing?.length)
|
|
889
|
+
return mcpWallFor(missing);
|
|
890
|
+
const free = freeDiskBytes(config.repo);
|
|
891
|
+
if (free !== null && free < DISK_MIN) {
|
|
892
|
+
await sweepWorktrees(config.repo, WORKTREE_IDLE_LOW_DISK_MS, (t) => log('tool', t), activeTrees()).catch(() => { });
|
|
893
|
+
const after = freeDiskBytes(config.repo);
|
|
894
|
+
if (after !== null && after < DISK_MIN) {
|
|
895
|
+
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).` };
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return null;
|
|
899
|
+
};
|
|
900
|
+
const enterPause = async (wall) => {
|
|
901
|
+
if (paused?.check === wall.check)
|
|
902
|
+
return;
|
|
903
|
+
paused = wall;
|
|
904
|
+
readyReported = false;
|
|
905
|
+
state.paused = wall.reason;
|
|
906
|
+
log('warn', `PAUSED (${wall.check}): ${wall.reason}`);
|
|
907
|
+
void liveRun?.stop().catch(() => { });
|
|
908
|
+
try {
|
|
909
|
+
const r = await opts.deck?.pause(wall.check, wall.reason);
|
|
910
|
+
/* A solo agent has no lead: its cards stay with it (each gets a comment) and nobody is
|
|
911
|
+
messaged. Saying "told the lead, handed back …" then described something that never
|
|
912
|
+
happened. */
|
|
913
|
+
if (r && !r.already) {
|
|
914
|
+
log('event', r.lead
|
|
915
|
+
? `told the lead${r.handedBack.length ? `, handed back ${r.handedBack.join(', ')}` : ''} — doing nothing until resumed`
|
|
916
|
+
: `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.'}`);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
catch (e) {
|
|
920
|
+
log('warn', `could not tell FlareDeck I am paused: ${e instanceof Error ? e.message : String(e)}`);
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
// While paused: ask FlareDeck every minute whether the lead has resumed this agent (one REST
|
|
924
|
+
// call, no model), and re-check the wall every ten, saying once when it is gone.
|
|
925
|
+
let lastWallCheck = 0;
|
|
926
|
+
const pauseTick = setInterval(() => {
|
|
927
|
+
if (!paused)
|
|
928
|
+
return;
|
|
929
|
+
void (async () => {
|
|
930
|
+
const server = await opts.deck?.pauseState().catch(() => undefined);
|
|
931
|
+
if (server === null) {
|
|
932
|
+
const still = await walls();
|
|
933
|
+
if (still) {
|
|
934
|
+
paused = null;
|
|
935
|
+
await enterPause(still);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
log('event', `resumed by the lead — back to work`);
|
|
939
|
+
paused = null;
|
|
940
|
+
state.paused = undefined;
|
|
941
|
+
void resumeOwnWork();
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
/* Disk is a statfs call, so it is checked every tick; the others ask GitHub, so every ten
|
|
945
|
+
minutes. A disk wall that has cleared resumes the agent at once — there is nothing for a
|
|
946
|
+
lead to decide about a number, and waiting for one left the agent idle for hours after
|
|
947
|
+
the space had already been freed. */
|
|
948
|
+
const selfClearing = paused.check === 'disk' || paused.check === 'mcp';
|
|
949
|
+
if (selfClearing) {
|
|
950
|
+
let cleared = '';
|
|
951
|
+
if (paused.check === 'disk') {
|
|
952
|
+
const free = freeDiskBytes(config.repo);
|
|
953
|
+
if (free === null || free < DISK_MIN)
|
|
954
|
+
return;
|
|
955
|
+
cleared = `disk is clear (${(free / 1e9).toFixed(0)} GB free)`;
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
// One HTTP call a minute while paused; null (FlareDeck unreachable) is not "connected".
|
|
959
|
+
const missing = await refreshMcp();
|
|
960
|
+
if (missing === null || missing.length)
|
|
961
|
+
return;
|
|
962
|
+
cleared = 'the required MCP servers are connected';
|
|
963
|
+
}
|
|
964
|
+
const r = await opts.deck?.resumeSelf().catch(() => null);
|
|
965
|
+
if (r?.resumed) {
|
|
966
|
+
log('event', `${cleared} — resumed myself and told the lead`);
|
|
967
|
+
paused = null;
|
|
968
|
+
state.paused = undefined;
|
|
969
|
+
readyReported = false;
|
|
970
|
+
void resumeOwnWork();
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
// An older server without self-resume: fall through to telling the lead, as before.
|
|
974
|
+
}
|
|
975
|
+
if ((!selfClearing && Date.now() - lastWallCheck < 10 * 60 * 1000) || readyReported)
|
|
976
|
+
return;
|
|
977
|
+
lastWallCheck = Date.now();
|
|
978
|
+
if (!(await walls())) {
|
|
979
|
+
readyReported = true;
|
|
980
|
+
log('event', `${paused.check} is fixed — told the lead; waiting to be resumed`);
|
|
981
|
+
await opts.deck?.ready().catch(() => { });
|
|
982
|
+
}
|
|
983
|
+
})();
|
|
984
|
+
}, 60 * 1000);
|
|
985
|
+
pauseTick.unref?.();
|
|
698
986
|
/**
|
|
699
987
|
* This turn's browser, so it can be closed when the turn ends.
|
|
700
988
|
*
|
|
@@ -729,8 +1017,30 @@ export async function serve(opts) {
|
|
|
729
1017
|
void drain();
|
|
730
1018
|
}
|
|
731
1019
|
};
|
|
1020
|
+
/**
|
|
1021
|
+
* A question typed in this pane.
|
|
1022
|
+
*
|
|
1023
|
+
* Whatever it says is passed through untouched, with one line in front of it saying who is
|
|
1024
|
+
* speaking. Nothing here tries to work out whether it is a question or an instruction: a
|
|
1025
|
+
* pattern that guessed got it wrong both ways — "work KAN-0074" did nothing because a bare
|
|
1026
|
+
* question never makes a worktree, and anything clever enough to catch that also caught
|
|
1027
|
+
* "work out why KAN-0074 failed" and started a build nobody asked for. The model reads English
|
|
1028
|
+
* better than a regular expression does; it only needed telling whose English it is.
|
|
1029
|
+
*
|
|
1030
|
+
* The authority is real and worth stating plainly. This person is at the machine. They can do
|
|
1031
|
+
* anything the agent can do and more, directly, without asking — so there is nothing to
|
|
1032
|
+
* protect here, and treating their instruction as a suggestion only wastes their time.
|
|
1033
|
+
*/
|
|
732
1034
|
const ask = (text) => {
|
|
733
|
-
const event = {
|
|
1035
|
+
const event = {
|
|
1036
|
+
id: `ask-${Date.now()}`,
|
|
1037
|
+
event: 'ask',
|
|
1038
|
+
line: text,
|
|
1039
|
+
message: 'This was typed by the person at this machine, in the agent\'s own terminal. They have ' +
|
|
1040
|
+
'every permission you have and more, and they are not waiting on anyone to approve it. ' +
|
|
1041
|
+
'Read it as what it plainly says: answer it if it is a question, do it if it is an ' +
|
|
1042
|
+
'instruction, and do not ask whether you may.\n\n' + text,
|
|
1043
|
+
};
|
|
734
1044
|
if (enqueue(event)) {
|
|
735
1045
|
prepareAhead(event);
|
|
736
1046
|
void drain();
|
|
@@ -751,6 +1061,14 @@ export async function serve(opts) {
|
|
|
751
1061
|
/** Worktrees whose environment comes down unless a turn returns to them first. */
|
|
752
1062
|
const idleStops = new Map();
|
|
753
1063
|
const ENV_IDLE_MS = 20 * 60_000;
|
|
1064
|
+
/**
|
|
1065
|
+
* Checkouts with a sign-in still waiting for a person, and since when. Their environment is
|
|
1066
|
+
* kept up: the sign-in browser is open ON it, and stopping it left that page retrying a dead
|
|
1067
|
+
* server — hundreds of ERR_CONNECTION_REFUSED lines — and a sign-in nobody could finish.
|
|
1068
|
+
* Bounded, so a sign-in that never settles cannot keep an environment up for ever.
|
|
1069
|
+
*/
|
|
1070
|
+
const signInPending = new Map();
|
|
1071
|
+
const SIGN_IN_HOLD_MS = 2 * 60 * 60_000;
|
|
754
1072
|
const scheduleEnvironmentStop = (cwd) => {
|
|
755
1073
|
const before = idleStops.get(cwd);
|
|
756
1074
|
if (before)
|
|
@@ -760,6 +1078,11 @@ export async function serve(opts) {
|
|
|
760
1078
|
// Never the checkout a turn is in right now, even if the timer and the turn cross.
|
|
761
1079
|
if (cwd === turnCwd && state.working)
|
|
762
1080
|
return;
|
|
1081
|
+
const waiting = signInPending.get(cwd);
|
|
1082
|
+
if (waiting && Date.now() - waiting < SIGN_IN_HOLD_MS) {
|
|
1083
|
+
scheduleEnvironmentStop(cwd);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
763
1086
|
void stopEnvironment(cwd).then((ok) => { if (ok)
|
|
764
1087
|
log('tool', `environment stopped in ${cwd.split('/').pop()} (idle 20m)`); }).catch(() => { });
|
|
765
1088
|
}, ENV_IDLE_MS));
|
|
@@ -815,7 +1138,21 @@ export async function serve(opts) {
|
|
|
815
1138
|
// every in-process FlareDeck call comes back on. From then until the turn ends, every
|
|
816
1139
|
// FlareDeck call fails as "interrupted" and the agent concludes FlareDeck is down. 'next'
|
|
817
1140
|
// waits for the tool batch to return and lands at the boundary, with nothing cut off.
|
|
818
|
-
|
|
1141
|
+
/* A run that was stopped (a pause stops it) can still be referenced here for a moment. If
|
|
1142
|
+
it will not take the line, it is not running: answer it as a question instead of
|
|
1143
|
+
dropping what the person typed. */
|
|
1144
|
+
const run = liveRun;
|
|
1145
|
+
void run.steer(text).then((heard) => {
|
|
1146
|
+
if (heard !== false)
|
|
1147
|
+
return;
|
|
1148
|
+
if (liveRun === run)
|
|
1149
|
+
liveRun = null;
|
|
1150
|
+
ask(text);
|
|
1151
|
+
}).catch(() => {
|
|
1152
|
+
if (liveRun === run)
|
|
1153
|
+
liveRun = null;
|
|
1154
|
+
ask(text);
|
|
1155
|
+
});
|
|
819
1156
|
}
|
|
820
1157
|
else {
|
|
821
1158
|
ask(text);
|
|
@@ -842,7 +1179,13 @@ export async function serve(opts) {
|
|
|
842
1179
|
* which point the SDK compacts silently. So the live turn is told, once, to wrap up: finish
|
|
843
1180
|
* the step, leave the handover, stop. Deliberate, and it happens while there is still room.
|
|
844
1181
|
*/
|
|
845
|
-
|
|
1182
|
+
//
|
|
1183
|
+
// OFF by default now. Stopping an agent part-way through a card cost more than compaction did:
|
|
1184
|
+
// the card was handed to a fresh session that re-read everything to get back to where the old
|
|
1185
|
+
// one already was. The end-of-card handover still happens. Set FLAREDECK_AGENT_WRAP_UP_AT to a
|
|
1186
|
+
// token count to bring the mid-turn nudge back.
|
|
1187
|
+
const wrapUpAt = Number(process.env['FLAREDECK_AGENT_WRAP_UP_AT']);
|
|
1188
|
+
const CONTEXT_WRAP_UP_TOKENS = Number.isFinite(wrapUpAt) && wrapUpAt > 0 ? wrapUpAt : Infinity;
|
|
846
1189
|
const keepAlive = (key, cwd, tokens) => {
|
|
847
1190
|
const prev = live.get(key);
|
|
848
1191
|
if (prev?.timer)
|
|
@@ -997,15 +1340,85 @@ export async function serve(opts) {
|
|
|
997
1340
|
const sweepMinutes = Number(process.env['FLAREDECK_AGENT_SWEEP_MINUTES']);
|
|
998
1341
|
const SWEEP_EVERY_MS = Math.max(5, Number.isFinite(sweepMinutes) ? sweepMinutes : 60) * 60 * 1000;
|
|
999
1342
|
const sweepsEnabled = process.env['FLAREDECK_AGENT_NO_SWEEP'] !== '1';
|
|
1343
|
+
/**
|
|
1344
|
+
* Read a plan's usage and post every window to the ledger.
|
|
1345
|
+
*
|
|
1346
|
+
* This is what the Secrets bars and `choose` route on. It used to happen only when `choose`
|
|
1347
|
+
* handed a plan over, at most once an hour, in the background — so the plans a run did not get
|
|
1348
|
+
* stayed "not measured yet" for days, and `choose` ranked on nothing and handed out a plan that
|
|
1349
|
+
* was already at its weekly cap. Now: every plan at startup, and the plan a card used once the
|
|
1350
|
+
* card is done, which is when its numbers have just moved.
|
|
1351
|
+
*
|
|
1352
|
+
* Never throws and never blocks work: a failed reading costs a little routing accuracy.
|
|
1353
|
+
*/
|
|
1354
|
+
const measurePlan = async (subs, plan, force = false) => {
|
|
1355
|
+
if (!subs)
|
|
1356
|
+
return;
|
|
1357
|
+
const last = usageReadAt.get(plan.id) ?? 0;
|
|
1358
|
+
if (!force && Date.now() - last < USAGE_REFRESH_MS)
|
|
1359
|
+
return;
|
|
1360
|
+
usageReadAt.set(plan.id, Date.now());
|
|
1361
|
+
try {
|
|
1362
|
+
const { readUsage } = await import('./usage.js');
|
|
1363
|
+
let why = '';
|
|
1364
|
+
const windows = await readUsage({ token: plan.token, onEmpty: (w) => { why = w; } });
|
|
1365
|
+
if (!windows.length) {
|
|
1366
|
+
log('warn', `claude usage: ${plan.label} — could not read it (${why || 'no detail'})`);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
for (const w of windows)
|
|
1370
|
+
await subs.seen(plan.id, w.window, w.utilization, w.resetsAt);
|
|
1371
|
+
log('tool', `claude usage: ${plan.label} · ${windows.map((w) => `${w.window} ${w.utilization}%`).join(' · ')}`);
|
|
1372
|
+
}
|
|
1373
|
+
catch { /* bookkeeping must never interrupt work */ }
|
|
1374
|
+
};
|
|
1375
|
+
/** Every plan, one after another — each read starts a Claude process, so not all at once. */
|
|
1376
|
+
const measureAll = async () => {
|
|
1377
|
+
const subs = await subscriptionsForAgent(config.mcpUrl, () => opts.auth(), () => { }).catch(() => null);
|
|
1378
|
+
if (!subs)
|
|
1379
|
+
return;
|
|
1380
|
+
const plans = await subs.measure().catch(() => []);
|
|
1381
|
+
// Said, because a plan missing here — a revoked token — is otherwise invisible.
|
|
1382
|
+
log('tool', `claude usage: measuring ${plans.length ? plans.map((p) => p.label).join(', ') : 'nothing (no readable subscription tokens)'}`);
|
|
1383
|
+
for (const plan of plans)
|
|
1384
|
+
await measurePlan(subs, plan, true);
|
|
1385
|
+
};
|
|
1386
|
+
void measureAll();
|
|
1387
|
+
// The walls, checked once at start — before any card is picked up. A restart while paused
|
|
1388
|
+
// picks the pause back up from FlareDeck rather than re-announcing it.
|
|
1389
|
+
void (async () => {
|
|
1390
|
+
const server = await opts.deck?.pauseState().catch(() => undefined);
|
|
1391
|
+
if (server) {
|
|
1392
|
+
paused = { check: server.check, reason: server.reason };
|
|
1393
|
+
readyReported = !!server.ready;
|
|
1394
|
+
state.paused = server.reason;
|
|
1395
|
+
log('warn', `PAUSED since ${server.since.slice(0, 16).replace('T', ' ')} (${server.check}) — doing nothing until the lead resumes me`);
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
const wall = await walls();
|
|
1399
|
+
if (wall)
|
|
1400
|
+
await enterPause(wall);
|
|
1401
|
+
})();
|
|
1000
1402
|
const sweepNow = () => {
|
|
1001
|
-
if (!sweepsEnabled || stopping)
|
|
1403
|
+
if (paused || !sweepsEnabled || stopping)
|
|
1002
1404
|
return;
|
|
1003
1405
|
// 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
|
|
1406
|
+
// anywhere.
|
|
1407
|
+
if (working || queue.length)
|
|
1006
1408
|
return;
|
|
1007
|
-
|
|
1008
|
-
|
|
1409
|
+
void (async () => {
|
|
1410
|
+
/* A card already in progress IS the answer to "what next" — no model needed. Asking the
|
|
1411
|
+
model instead had it try to start a second card every sweep, be refused by FlareDeck's
|
|
1412
|
+
one-card rule, say "the harness will pick up KAN-0074", and stop — for an hour, because
|
|
1413
|
+
nothing did. So the harness resumes it first, and only sweeps when nothing is held. */
|
|
1414
|
+
if (await resumeOwnWork())
|
|
1415
|
+
return;
|
|
1416
|
+
// `live.size` covers a card whose session is parked between events.
|
|
1417
|
+
if (working || queue.length || live.size)
|
|
1418
|
+
return;
|
|
1419
|
+
if (enqueue({ id: 'sweep', event: 'sweep', line: 'checking what is still assigned to you' }))
|
|
1420
|
+
void drain();
|
|
1421
|
+
})();
|
|
1009
1422
|
};
|
|
1010
1423
|
/**
|
|
1011
1424
|
* A card of mine already in progress, picked up again without being asked.
|
|
@@ -1019,9 +1432,20 @@ export async function serve(opts) {
|
|
|
1019
1432
|
* harness reads it and queues the card itself, as the ordinary card event it should always
|
|
1020
1433
|
* have been — with a worktree, a branch, and everything else a card turn gets.
|
|
1021
1434
|
*/
|
|
1435
|
+
/**
|
|
1436
|
+
* Pickups per card since anything real happened on it. A card whose work is done but which was
|
|
1437
|
+
* never moved out of the in-progress lane came back every sweep and every restart, each time a
|
|
1438
|
+
* turn that found "nothing new to act on" — and it blocked every other card. After two such
|
|
1439
|
+
* pickups the harness leaves it and says so; any real event on the card (a comment, a move, an
|
|
1440
|
+
* assignment) clears the count.
|
|
1441
|
+
*/
|
|
1442
|
+
const resumedIdle = new Map();
|
|
1443
|
+
const RESUME_IDLE_LIMIT = 2;
|
|
1022
1444
|
const resumeOwnWork = async () => {
|
|
1023
|
-
|
|
1024
|
-
|
|
1445
|
+
/* Not `live.size`: a session kept WARM for a follow-up is a cache, not work, and blocking on
|
|
1446
|
+
it meant the card in progress was never resumed within fifteen minutes of any turn. */
|
|
1447
|
+
if (paused || !sweepsEnabled || stopping || working || queue.length)
|
|
1448
|
+
return false;
|
|
1025
1449
|
let mine = [];
|
|
1026
1450
|
try {
|
|
1027
1451
|
// Scoped, like every other account-wide question. This one is asked by the HARNESS rather
|
|
@@ -1039,11 +1463,36 @@ export async function serve(opts) {
|
|
|
1039
1463
|
.map((c) => ({ code: c.code }));
|
|
1040
1464
|
}
|
|
1041
1465
|
catch {
|
|
1042
|
-
return; // unreachable board is the stream's problem to report, not this timer's
|
|
1466
|
+
return false; // unreachable board is the stream's problem to report, not this timer's
|
|
1467
|
+
}
|
|
1468
|
+
/* IN PROGRESS by the lane's TYPE — the same test FlareDeck's one-card rule uses. This took the
|
|
1469
|
+
first outstanding card in priority order whatever its lane, so it picked KAN-0080 out of
|
|
1470
|
+
Review while KAN-0074 sat in "In dev" untouched: the model was told "the harness picks up
|
|
1471
|
+
the card in progress" and the harness picked a different one, and the agent went round
|
|
1472
|
+
that loop for an hour. Lane NAMES differ per board ("In dev", "Doing"); the type does not. */
|
|
1473
|
+
let next;
|
|
1474
|
+
for (const c of mine) {
|
|
1475
|
+
if (sessions.has(c.code) || live.has(c.code))
|
|
1476
|
+
continue;
|
|
1477
|
+
if ((resumedIdle.get(c.code) ?? 0) >= RESUME_IDLE_LIMIT)
|
|
1478
|
+
continue;
|
|
1479
|
+
const card = await opts.deck?.card(c.code).catch(() => null);
|
|
1480
|
+
// Waiting on a person: nothing to do until they answer, and their answer is an event that
|
|
1481
|
+
// wakes this agent anyway. Reopening it only spends a turn finding that out again.
|
|
1482
|
+
if (card?.needs_human)
|
|
1483
|
+
continue;
|
|
1484
|
+
if (String(card?.lane?.type ?? '').toLowerCase() === 'inprogress') {
|
|
1485
|
+
next = c;
|
|
1486
|
+
break;
|
|
1487
|
+
}
|
|
1043
1488
|
}
|
|
1044
|
-
const next = mine.find((c) => !sessions.has(c.code) && !live.has(c.code));
|
|
1045
1489
|
if (!next)
|
|
1046
|
-
return;
|
|
1490
|
+
return false;
|
|
1491
|
+
const n = (resumedIdle.get(next.code) ?? 0) + 1;
|
|
1492
|
+
resumedIdle.set(next.code, n);
|
|
1493
|
+
if (n === RESUME_IDLE_LIMIT) {
|
|
1494
|
+
log('warn', `${next.code}: picked up ${n} times with nothing new on it — after this one I leave it. Move it out of in-progress, or comment on it, to change that.`);
|
|
1495
|
+
}
|
|
1047
1496
|
log('tool', `picking ${next.code} back up — it is yours and already in progress`);
|
|
1048
1497
|
if (enqueue({
|
|
1049
1498
|
id: `resume-${next.code}-${Date.now()}`,
|
|
@@ -1053,6 +1502,7 @@ export async function serve(opts) {
|
|
|
1053
1502
|
cardKey: next.code,
|
|
1054
1503
|
}))
|
|
1055
1504
|
void drain();
|
|
1505
|
+
return true;
|
|
1056
1506
|
};
|
|
1057
1507
|
const sweepTimer = setInterval(sweepNow, SWEEP_EVERY_MS);
|
|
1058
1508
|
sweepTimer.unref();
|
|
@@ -1135,9 +1585,15 @@ export async function serve(opts) {
|
|
|
1135
1585
|
if (event.event !== 'ask')
|
|
1136
1586
|
log('event', event.line);
|
|
1137
1587
|
// A sweep or a typed question is work, but it is not a card — see startCard.
|
|
1588
|
+
// Something real happened on this card — not the harness's own pickup — so it may have
|
|
1589
|
+
// work again: clear the idle-pickup count that stops it being resumed.
|
|
1590
|
+
if (event.cardCode && !String(event.id ?? '').startsWith('resume-'))
|
|
1591
|
+
resumedIdle.delete(event.cardCode);
|
|
1138
1592
|
status.startCard(current, !!event.cardKey);
|
|
1139
1593
|
void report();
|
|
1140
1594
|
let didWork = true;
|
|
1595
|
+
/** Set when the turn failed on its Claude plan, not its work: run it again at once. */
|
|
1596
|
+
let switchPlan = false;
|
|
1141
1597
|
turnCwd = '';
|
|
1142
1598
|
try {
|
|
1143
1599
|
await handle(event);
|
|
@@ -1146,13 +1602,30 @@ export async function serve(opts) {
|
|
|
1146
1602
|
// One bad event must not take the daemon down — that is the failure it exists to fix.
|
|
1147
1603
|
didWork = !(e instanceof CouldNotWork);
|
|
1148
1604
|
log('warn', `${current}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1605
|
+
if (e instanceof RetryOnAnotherPlan)
|
|
1606
|
+
switchPlan = true;
|
|
1149
1607
|
}
|
|
1150
1608
|
// Acknowledged only after the work, and only if the work could happen at all. A crash
|
|
1151
1609
|
// mid-turn redelivers; so does a turn that never had what it needed.
|
|
1152
1610
|
status.finishCard(didWork);
|
|
1153
1611
|
void report();
|
|
1154
1612
|
pending.delete(event.id);
|
|
1155
|
-
|
|
1613
|
+
// The plan this event ran on has just moved: read it now, so the next `choose` and the
|
|
1614
|
+
// Secrets tab see where it stands. Not after a plan switch — that plan is filed already.
|
|
1615
|
+
// Skipped when the run already reported usage on its own — a separate read would cost a
|
|
1616
|
+
// probe request to learn what the run just said.
|
|
1617
|
+
if (turnPlan && !switchPlan && !turnSawUsage) {
|
|
1618
|
+
const plan = turnPlan;
|
|
1619
|
+
void subscriptionsForAgent(config.mcpUrl, () => opts.auth(), () => { })
|
|
1620
|
+
.then((subs) => measurePlan(subs, plan))
|
|
1621
|
+
.catch(() => { });
|
|
1622
|
+
}
|
|
1623
|
+
turnPlan = null;
|
|
1624
|
+
turnSawUsage = false;
|
|
1625
|
+
// Before the ask/sweep branch: a sweep that hit a dead plan is retried like any other event.
|
|
1626
|
+
if (switchPlan)
|
|
1627
|
+
enqueue(event, true);
|
|
1628
|
+
else if (event.event === 'ask' || event.event === 'sweep' || event.event === 'run-answered') { /* local: nothing to acknowledge, nothing to retry */ }
|
|
1156
1629
|
else if (didWork)
|
|
1157
1630
|
await stream.ack([event.id]);
|
|
1158
1631
|
else {
|
|
@@ -1290,7 +1763,59 @@ export async function serve(opts) {
|
|
|
1290
1763
|
* needs-human" over the top of it. An agent said it had asked for help, a person was never told,
|
|
1291
1764
|
* and the card sat.
|
|
1292
1765
|
*/
|
|
1293
|
-
|
|
1766
|
+
/**
|
|
1767
|
+
* Put a local file on a card, done here rather than by the agent's shell. The upload is a PUT of
|
|
1768
|
+
* bytes to FlareDeck's own upload address, and the auto-mode classifier refused it as
|
|
1769
|
+
* "destructive, reaching production" — so an agent with seven screenshots asked a person to
|
|
1770
|
+
* upload them. Same three steps the tools describe (mint a link, PUT, wire it in), no curl.
|
|
1771
|
+
*/
|
|
1772
|
+
const attachFile = async (card, path, comment) => {
|
|
1773
|
+
const { existsSync, readFileSync, realpathSync, statSync } = await import('node:fs');
|
|
1774
|
+
const { basename, extname, isAbsolute, resolve } = await import('node:path');
|
|
1775
|
+
const { tmpdir } = await import('node:os');
|
|
1776
|
+
const full = isAbsolute(path) ? path : resolve(card.cwd, path);
|
|
1777
|
+
if (!existsSync(full))
|
|
1778
|
+
throw new Error(`there is no file at ${full}`);
|
|
1779
|
+
const real = realpathSync(full);
|
|
1780
|
+
const roots = [card.cwd, '/tmp', '/private/tmp', tmpdir()].map((r) => { try {
|
|
1781
|
+
return realpathSync(r);
|
|
1782
|
+
}
|
|
1783
|
+
catch {
|
|
1784
|
+
return r;
|
|
1785
|
+
} });
|
|
1786
|
+
if (!roots.some((r) => real === r || real.startsWith(`${r}/`)))
|
|
1787
|
+
throw new Error('only files inside your checkout or /tmp can be attached');
|
|
1788
|
+
const size = statSync(real).size;
|
|
1789
|
+
if (size > 30 * 1024 * 1024)
|
|
1790
|
+
throw new Error(`the file is ${Math.round(size / 1048576)} MB; the limit is 30 MB`);
|
|
1791
|
+
const types = {
|
|
1792
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
|
1793
|
+
'.pdf': 'application/pdf', '.csv': 'text/csv', '.txt': 'text/plain', '.log': 'text/plain', '.json': 'application/json',
|
|
1794
|
+
'.md': 'text/markdown', '.html': 'text/html', '.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
1795
|
+
};
|
|
1796
|
+
const contentType = types[extname(real).toLowerCase()] ?? 'application/octet-stream';
|
|
1797
|
+
const ids = { card_id: card.code || card.card, ...(card.board ? { board_id: card.board } : {}) };
|
|
1798
|
+
const minted = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-attachment-upload-url', {
|
|
1799
|
+
...ids, file_name: basename(real), content_type: contentType,
|
|
1800
|
+
});
|
|
1801
|
+
const uploadUrl = /https?:\/\/[^\s"`]+\/upload\/[^\s"`]+/.exec(minted)?.[0];
|
|
1802
|
+
const objectPath = /`object_path`: `([^`]+)`/.exec(minted)?.[1];
|
|
1803
|
+
if (!uploadUrl || !objectPath)
|
|
1804
|
+
throw new Error('FlareDeck did not return an upload address');
|
|
1805
|
+
const put = await fetch(uploadUrl, { method: 'PUT', body: readFileSync(real), headers: { 'Content-Type': contentType } });
|
|
1806
|
+
if (!put.ok)
|
|
1807
|
+
throw new Error(`the upload was refused (${put.status})`);
|
|
1808
|
+
const wired = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'attach-uploaded-file', {
|
|
1809
|
+
...ids, object_path: objectPath, attach_to: 'comment', ...(comment ? { comment } : {}),
|
|
1810
|
+
});
|
|
1811
|
+
log('tool', `attached ${basename(real)} to ${card.code || card.card}`);
|
|
1812
|
+
return `Attached ${basename(real)} to ${card.code || card.card}. ${wired.split('\n')[0]}`;
|
|
1813
|
+
};
|
|
1814
|
+
const flagCard = async (ref, reason, kind,
|
|
1815
|
+
/** The one-line ask and its answer buttons — what the person actually sees on a phone. */
|
|
1816
|
+
ask,
|
|
1817
|
+
/** Somewhere for the person to GO, which is what a sign-in request is mostly made of. */
|
|
1818
|
+
links) => {
|
|
1294
1819
|
const label = ref.code || ref.card || 'card';
|
|
1295
1820
|
try {
|
|
1296
1821
|
const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'set-needs-human', {
|
|
@@ -1299,6 +1824,8 @@ export async function serve(opts) {
|
|
|
1299
1824
|
...(ref.board ? { board_id: ref.board } : {}),
|
|
1300
1825
|
reason,
|
|
1301
1826
|
...(kind ? { kind } : {}),
|
|
1827
|
+
...(ask ? { question: ask.question, options: ask.options } : {}),
|
|
1828
|
+
...(links?.length ? { links } : {}),
|
|
1302
1829
|
});
|
|
1303
1830
|
if (out.trimStart().startsWith('\u274c') || out.trimStart().startsWith('\ud83d\udeab')) {
|
|
1304
1831
|
log('warn', `${label}: FlareDeck did not flag this card \u2014 ${out.split('\n')[0]}`);
|
|
@@ -1355,7 +1882,7 @@ export async function serve(opts) {
|
|
|
1355
1882
|
log('event', `${event.cardCode || event.cardKey}: comment on a finished card — nothing to do`);
|
|
1356
1883
|
return;
|
|
1357
1884
|
}
|
|
1358
|
-
|
|
1885
|
+
let key = event.cardKey || (event.event === 'ask' ? 'ask' : event.event === 'sweep' ? 'sweep' : event.event === 'scheduled' ? `schedule-${event.scheduleId}` : 'inbox');
|
|
1359
1886
|
let turnTokens = 0;
|
|
1360
1887
|
let nudged = false;
|
|
1361
1888
|
// A checkout of its own per card, so it cannot edit a working tree someone else is in.
|
|
@@ -1444,9 +1971,42 @@ export async function serve(opts) {
|
|
|
1444
1971
|
// the publishing tools and the journal. Without this, HAL committed its work on a message
|
|
1445
1972
|
// turn, was told to publish with push_branch, and went looking for push_branch on the
|
|
1446
1973
|
// filesystem: the harness had named a tool that turn did not have.
|
|
1974
|
+
// Room on the disk before a card gets a checkout. An agent left alone overnight filled one to
|
|
1975
|
+
// 762 MB free, and from there nothing works and nothing says why: Docker cannot make a
|
|
1976
|
+
// container, builds die half-written, the machine swaps. Below LOW the idle worktrees go first
|
|
1977
|
+
// (sooner than usual); below MIN the card is not started, and a person is told what to clear.
|
|
1978
|
+
if (event.cardKey && willChange && (chosen || where.kind === 'none')) {
|
|
1979
|
+
let free = freeDiskBytes(repo);
|
|
1980
|
+
if (free !== null && free < DISK_LOW) {
|
|
1981
|
+
await sweepWorktrees(repo, WORKTREE_IDLE_LOW_DISK_MS, (t) => log('tool', t), activeTrees()).catch(() => { });
|
|
1982
|
+
free = freeDiskBytes(repo);
|
|
1983
|
+
}
|
|
1984
|
+
if (free !== null && free < DISK_MIN) {
|
|
1985
|
+
const wall = await walls();
|
|
1986
|
+
if (wall) {
|
|
1987
|
+
await enterPause(wall);
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1447
1992
|
const tree = event.cardKey && (chosen || where.kind === 'none')
|
|
1448
1993
|
? await ensureWorktree(repo, key, (t) => log('tool', t), startFrom, config.worktreeLocalFiles)
|
|
1449
|
-
: await worktreeTheMessageIsAbout(event, repo, (t) => log('tool', t));
|
|
1994
|
+
: await worktreeTheMessageIsAbout(event, repo, (t) => log('tool', t), (code) => ensureWorktree(repo, code, (t) => log('tool', t), startFrom, config.worktreeLocalFiles));
|
|
1995
|
+
/**
|
|
1996
|
+
* Questions about a CARD get their own session, not the shared one.
|
|
1997
|
+
*
|
|
1998
|
+
* Every pane ask used the single key 'ask', and a session keeps the tools it was built with.
|
|
1999
|
+
* So the first question asked before a card had a checkout built a session with no browser
|
|
2000
|
+
* and no publishing tools — and every later question about that card resumed it and
|
|
2001
|
+
* inherited the lack. The agent reported it precisely ("the browser tools aren't mounted
|
|
2002
|
+
* this session") and went looking for headless Chrome in the shell instead, which is both
|
|
2003
|
+
* worse and the kind of workaround the guards exist to stop.
|
|
2004
|
+
*
|
|
2005
|
+
* Keyed on the checkout, so a question about a card shares a session with other questions
|
|
2006
|
+
* about THAT card and nothing else.
|
|
2007
|
+
*/
|
|
2008
|
+
if (event.event === 'ask' && tree)
|
|
2009
|
+
key = `ask:${tree.path}`;
|
|
1450
2010
|
const cwd = tree?.path ?? repo;
|
|
1451
2011
|
// Back on this checkout: whatever was waiting to take its environment down can stand down.
|
|
1452
2012
|
turnCwd = cwd;
|
|
@@ -1458,6 +2018,7 @@ export async function serve(opts) {
|
|
|
1458
2018
|
// So a later message naming this card by its CODE can find the work. See rememberWorktree.
|
|
1459
2019
|
if (tree && (event.cardCode || event.cardKey)) {
|
|
1460
2020
|
rememberWorktree(event.cardCode || event.cardKey, tree.path, tree.branch);
|
|
2021
|
+
currentTree = tree.path;
|
|
1461
2022
|
}
|
|
1462
2023
|
// No worktree, but this turn is going to change something. That used to carry on in whatever
|
|
1463
2024
|
// directory happened to be the fallback — no branch, no isolation, and no word said. An agent
|
|
@@ -1585,6 +2146,19 @@ export async function serve(opts) {
|
|
|
1585
2146
|
if (!resume && event.cardKey && event.event !== 'review' && event.event !== 'ask') {
|
|
1586
2147
|
await startOnBoard(event.cardCode || event.cardKey);
|
|
1587
2148
|
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Which card this turn is about — including when nobody dispatched it.
|
|
2151
|
+
*
|
|
2152
|
+
* This was set only from the event's own cardKey, so a question typed in the pane about a
|
|
2153
|
+
* card had none: the agent had the card, its checkout and its environment, hit a sign-in it
|
|
2154
|
+
* could not pass, and was told "there is no card being worked, so there is nobody to ask
|
|
2155
|
+
* about it". It fell back to leaving a comment, which nobody is notified about. The one
|
|
2156
|
+
* moment the escalation exists for, and it was unavailable.
|
|
2157
|
+
*
|
|
2158
|
+
* `tag` is the code the turn resolved to — from the event, or from the card a pane question
|
|
2159
|
+
* named. FlareDeck takes a human code wherever it takes a card id, so the code alone is
|
|
2160
|
+
* enough to flag, comment and clear.
|
|
2161
|
+
*/
|
|
1588
2162
|
onCard = event.cardKey
|
|
1589
2163
|
? {
|
|
1590
2164
|
card: event.cardKey,
|
|
@@ -1592,7 +2166,9 @@ export async function serve(opts) {
|
|
|
1592
2166
|
...(event.boardKey ? { board: event.boardKey } : {}),
|
|
1593
2167
|
...(event.cardCode ? { code: event.cardCode } : {}),
|
|
1594
2168
|
}
|
|
1595
|
-
:
|
|
2169
|
+
: tag
|
|
2170
|
+
? { card: tag, cwd, code: tag, ...(event.boardKey ? { board: event.boardKey } : {}) }
|
|
2171
|
+
: null;
|
|
1596
2172
|
job = tree
|
|
1597
2173
|
? {
|
|
1598
2174
|
cwd,
|
|
@@ -1601,7 +2177,22 @@ export async function serve(opts) {
|
|
|
1601
2177
|
// back to the repo's default branch when it has not.
|
|
1602
2178
|
base: startFrom ?? (await baseBranch(cwd)),
|
|
1603
2179
|
card: event.cardCode || key,
|
|
2180
|
+
onAuthFailure: (why) => {
|
|
2181
|
+
void enterPause({ check: 'github', reason: flaredeckGithub
|
|
2182
|
+
? `GitHub refused FlareDeck's connection (@${flaredeckGithub.login ?? 'unknown'}) when I pushed (${why.slice(0, 160)}), so nothing I write can be published. Reconnect GitHub on Integrations, and check that the account can push to this repository (an org may need to approve the OAuth App).`
|
|
2183
|
+
: `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).` });
|
|
2184
|
+
},
|
|
1604
2185
|
token: async () => {
|
|
2186
|
+
// FlareDeck's connection first, asked again at every push: it refreshes the token
|
|
2187
|
+
// centrally, so an agent running all day never pushes with an expired one.
|
|
2188
|
+
if (flaredeckGithub) {
|
|
2189
|
+
await refreshMcp();
|
|
2190
|
+
if (flaredeckGithub)
|
|
2191
|
+
return flaredeckGithub.token;
|
|
2192
|
+
}
|
|
2193
|
+
// Read fresh at every push: the login is the machine's, so signing in again from
|
|
2194
|
+
// any pane fixes an agent that has been running all day with the old one.
|
|
2195
|
+
config.github = loadGitHub() ?? config.github;
|
|
1605
2196
|
if (!config.github)
|
|
1606
2197
|
throw new Error('This machine has no GitHub login — run flaredeck-agent login.');
|
|
1607
2198
|
return freshGitHubToken(config.github, (next) => {
|
|
@@ -1629,7 +2220,7 @@ export async function serve(opts) {
|
|
|
1629
2220
|
// servers before it was allowed to look at a stylesheet. An agent that finds it does need
|
|
1630
2221
|
// something running can start it itself; the brief says how.
|
|
1631
2222
|
const needsRunning = (prepared?.jev?.needs_running ?? 0) >= 0.5;
|
|
1632
|
-
|
|
2223
|
+
let env = forACard && needsRunning
|
|
1633
2224
|
// The script this board declared for the repository this card is in, if it declared one.
|
|
1634
2225
|
// Matched on the remote, the same way the base branch is.
|
|
1635
2226
|
? await startEnvironment(cwd, (t) => log('tool', t), where.kind === 'repo' ? where.script : undefined)
|
|
@@ -1641,20 +2232,276 @@ export async function serve(opts) {
|
|
|
1641
2232
|
// card is usually about code that is already live somewhere, and an agent that can only see
|
|
1642
2233
|
// what it started locally cannot tell its own change from how the thing already behaved.
|
|
1643
2234
|
const declaredSites = where.kind === 'repo' ? where.sites ?? {} : {};
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
2235
|
+
// Which started part is the app itself — asked of the parts, since boards rarely name them.
|
|
2236
|
+
const web = Object.keys(env.endpoints).length ? await webPart(env.endpoints).catch(() => undefined) : undefined;
|
|
2237
|
+
if (web)
|
|
2238
|
+
log('tool', `browser: "${web}" serves the app — open_page opens it by default`);
|
|
2239
|
+
/**
|
|
2240
|
+
* Starting the project, as something the agent can ASK FOR.
|
|
2241
|
+
*
|
|
2242
|
+
* It was decided once, before the turn, by a classifier not even consulted for a question
|
|
2243
|
+
* typed in the pane — so the answer defaulted to "no" however plainly the card said
|
|
2244
|
+
* otherwise, and the agent had to find the script and start it detached by hand. Now it can
|
|
2245
|
+
* ask, and a failure hands back the error together with what it may do about it.
|
|
2246
|
+
*/
|
|
2247
|
+
const envTools = environmentTools({
|
|
2248
|
+
cwd: () => cwd,
|
|
2249
|
+
script: () => (where.kind === 'repo' ? where.script : undefined),
|
|
2250
|
+
running: () => (env.ran ? env : null),
|
|
2251
|
+
// The same deployed addresses the browser gets, so a sign-in can be asked for the dev or
|
|
2252
|
+
// UAT site and not only for something this card started.
|
|
2253
|
+
sites: () => declaredSites,
|
|
2254
|
+
// Straight onto the live picture: the browser reads its addresses through a getter, so
|
|
2255
|
+
// open_page works the moment this returns — no new turn, nothing to re-read.
|
|
2256
|
+
onStarted: (started) => {
|
|
2257
|
+
env = started;
|
|
2258
|
+
reachable = { cwd, endpoints: env.endpoints };
|
|
2259
|
+
},
|
|
2260
|
+
onNote: (t) => log('tool', t),
|
|
2261
|
+
/**
|
|
2262
|
+
* Turning "ask somebody" into something that actually asks.
|
|
2263
|
+
*
|
|
2264
|
+
* Three pieces already worked and were never joined up: open_page DETECTS a sign-in wall
|
|
2265
|
+
* and says which kind it is, signInSession captures a session a person completes by hand,
|
|
2266
|
+
* and loadSession replays it for every later card. The gap was the middle. All wallBrief
|
|
2267
|
+
* could do was tell the agent in prose to ask, with no tool to call — so the agent wrote a
|
|
2268
|
+
* comment, flagged the card and stopped. That was the only thing available to it, and it
|
|
2269
|
+
* read like a model that would not try.
|
|
2270
|
+
*
|
|
2271
|
+
* So: start the viewer, flag the card with the address (a flag is what reaches a phone),
|
|
2272
|
+
* and when the session lands, store it, clear the flag and put the card back at the front
|
|
2273
|
+
* of the queue. The agent's next turn opens the app already signed in.
|
|
2274
|
+
*
|
|
2275
|
+
* The viewer is LOOPBACK, deliberately: it drives a browser part-way through somebody's
|
|
2276
|
+
* sign-in, which is not a thing to publish on a reachable port. So the person has to be at
|
|
2277
|
+
* this machine. That is the case this is for — an agent testing localhost on a developer's
|
|
2278
|
+
* own box — and for a cloud agent it is a stated limit rather than a silent failure.
|
|
2279
|
+
*/
|
|
2280
|
+
...(onCard
|
|
2281
|
+
? {
|
|
2282
|
+
requestSignIn: async ({ part, url, why }) => {
|
|
2283
|
+
const card = onCard;
|
|
2284
|
+
if (!card)
|
|
2285
|
+
return { asked: false, why: 'the card went away while this was being asked' };
|
|
2286
|
+
if (!(await ensureBrowser((t) => log('tool', `browser: ${t}`)))) {
|
|
2287
|
+
return { asked: false, why: 'this machine has no browser and could not fetch one' };
|
|
2288
|
+
}
|
|
2289
|
+
const { chromium } = await import('playwright-core');
|
|
2290
|
+
const { signInSession } = await import('./signin.js');
|
|
2291
|
+
const { rememberSignInNow, HEADLESS_FULL } = await import('./browser.js');
|
|
2292
|
+
const { randomBytes } = await import('node:crypto');
|
|
2293
|
+
/* Through FlareDeck, not through a port on this machine. The person may be on a
|
|
2294
|
+
phone and the agent may be on a cloud box, which is the case this exists for;
|
|
2295
|
+
loopback only ever served the one where they are in the same room. */
|
|
2296
|
+
const id = `sgn_${randomBytes(9).toString('base64url')}`;
|
|
2297
|
+
/* agent-service, not the MCP. The relay was first built onto flaredeck-mcp, which
|
|
2298
|
+
cannot host it: the MCP deploys as a Firebase Function, and a Function hands you
|
|
2299
|
+
(req, res) with no http.Server underneath, so a WebSocket upgrade can never
|
|
2300
|
+
complete there. agent-service is a real Cloud Run service and already the one
|
|
2301
|
+
this harness holds an event stream open to. */
|
|
2302
|
+
const serviceUrl = process.env['FLAREDECK_RELAY_URL'] ?? config.agentServiceUrl;
|
|
2303
|
+
if (!serviceUrl) {
|
|
2304
|
+
// Configured by default, so this means somebody blanked it. Say which setting.
|
|
2305
|
+
return { asked: false, why: 'this harness has no agentServiceUrl, so a sign-in cannot be relayed' };
|
|
2306
|
+
}
|
|
2307
|
+
const relayBase = serviceUrl.replace(/^http/, 'ws').replace(/\/+$/, '');
|
|
2308
|
+
const appBase = (process.env['FLAREDECK_APP_URL'] ?? 'https://app.flaredeck.app').replace(/\/+$/, '');
|
|
2309
|
+
const viewerUrl = (t) => `${appBase}/signin/${id}?t=${encodeURIComponent(t)}`;
|
|
2310
|
+
/* Resolve as far as the ADDRESS and no further. Waiting for the sign-in itself would
|
|
2311
|
+
hold the agent's tool call open for as long as a person takes to get to their
|
|
2312
|
+
machine, and not waiting is the whole point: the card parks, the turn ends. */
|
|
2313
|
+
let settle;
|
|
2314
|
+
const ready = new Promise((r) => { settle = r; });
|
|
2315
|
+
const ref = {
|
|
2316
|
+
card: card.card,
|
|
2317
|
+
...(card.board ? { board: card.board } : {}),
|
|
2318
|
+
...(card.code ? { code: card.code } : {}),
|
|
2319
|
+
};
|
|
2320
|
+
// Held up while the person has not signed in yet — see signInPending.
|
|
2321
|
+
const signCwd = turnCwd;
|
|
2322
|
+
if (signCwd)
|
|
2323
|
+
signInPending.set(signCwd, Date.now());
|
|
2324
|
+
/* The page's console, forwarded — but a page retrying something gone repeats one
|
|
2325
|
+
line hundreds of times. The first is logged; repeats are counted and summarised. */
|
|
2326
|
+
let lastNote = '';
|
|
2327
|
+
let repeats = 0;
|
|
2328
|
+
const pageNote = (t) => {
|
|
2329
|
+
if (t === lastNote) {
|
|
2330
|
+
repeats++;
|
|
2331
|
+
if (repeats === 3 || repeats % 50 === 0)
|
|
2332
|
+
log('tool', `sign-in: (the same line ${repeats + 1}× so far) ${t.slice(0, 120)}`);
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
lastNote = t;
|
|
2336
|
+
repeats = 0;
|
|
2337
|
+
log('tool', `sign-in: ${t}`);
|
|
2338
|
+
};
|
|
2339
|
+
void signInSession({
|
|
2340
|
+
url,
|
|
2341
|
+
launch: () => chromium.launch(HEADLESS_FULL),
|
|
2342
|
+
relay: { url: relayBase, token: await opts.auth(), id, viewerUrl },
|
|
2343
|
+
onReady: (viewer) => {
|
|
2344
|
+
log('tool', `sign-in wanted for "${part}" — ${viewer}`);
|
|
2345
|
+
void flagCard(ref, `${url} needs someone to sign in before this card can go on. ${why}\n\n` +
|
|
2346
|
+
`Open the sign-in below and do it there. It is this agent's real browser, ` +
|
|
2347
|
+
`shown live — click and type in the picture, then press Save & finish. The ` +
|
|
2348
|
+
`session is kept for the whole project, so this is once, not once per card.`, 'sign_in', undefined, [{ url: viewer, label: 'Sign in' }]);
|
|
2349
|
+
settle({ asked: true, where: viewer });
|
|
2350
|
+
},
|
|
2351
|
+
onNote: pageNote,
|
|
2352
|
+
})
|
|
2353
|
+
.then(async (r) => {
|
|
2354
|
+
if (signCwd)
|
|
2355
|
+
signInPending.delete(signCwd);
|
|
2356
|
+
if (!r.ok || !r.session) {
|
|
2357
|
+
log('warn', `sign-in for "${part}" did not finish: ${r.why ?? 'nothing was saved'}`);
|
|
2358
|
+
/* Say so ON THE CARD. The flag already raised carries a link to a viewer
|
|
2359
|
+
that is now dead, and a person coming back to it an hour later would find
|
|
2360
|
+
a page that cannot connect and no indication why — which reads as the
|
|
2361
|
+
feature being broken rather than as a window having closed. */
|
|
2362
|
+
void flagCard(ref, `The sign-in for ${url} expired before anyone did it — the link in the ` +
|
|
2363
|
+
`earlier request no longer works.\n\n${why}\n\nNothing is broken: ask ` +
|
|
2364
|
+
`the agent to try again and a fresh sign-in is offered.`, 'sign_in');
|
|
2365
|
+
settle({ asked: false, why: r.why ?? 'nothing was saved' });
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
/* Awaited, and the failure is handled. Sessions live only in FlareDeck now —
|
|
2369
|
+
there is no copy on this machine — so a failed write means the sign-in is
|
|
2370
|
+
GONE. Announcing "Signed in" and resuming the card would wake an agent that
|
|
2371
|
+
meets the same wall and asks again: a loop that reads as the feature being
|
|
2372
|
+
broken rather than as one write having failed. */
|
|
2373
|
+
const saved = await rememberSignInNow(config.repo, part, r.session, (t) => log('tool', `sign-in: ${t}`));
|
|
2374
|
+
if (!saved.ok) {
|
|
2375
|
+
log('warn', `sign-in for "${part}" completed but was NOT saved: ${saved.message ?? 'no reason given'}`);
|
|
2376
|
+
void flagCard(ref, `Someone signed in to ${url}, but the session could not be saved to FlareDeck ` +
|
|
2377
|
+
`(${saved.message ?? 'no reason given'}). Credentials are kept only in FlareDeck, ` +
|
|
2378
|
+
`so nothing was retained here and the sign-in has been lost.\n\n${why}\n\n` +
|
|
2379
|
+
`Once FlareDeck is reachable, ask the agent to try again.`, 'sign_in');
|
|
2380
|
+
settle({ asked: false, why: saved.message ?? 'the session could not be saved' });
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
const site = saved.id;
|
|
2384
|
+
log('tool', `signed in — kept for ${site}; every card on this project opens straight in`);
|
|
2385
|
+
await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'clear-needs-human', {
|
|
2386
|
+
card_id: card.code || card.card,
|
|
2387
|
+
...(card.board ? { board_id: card.board } : {}),
|
|
2388
|
+
}).catch((e) => log('warn', `could not clear needs-human: ${e instanceof Error ? e.message : String(e)}`));
|
|
2389
|
+
parked.delete(card.card);
|
|
2390
|
+
resumeParked(card, `Signed in. The session is kept for ${site}, so open_page opens the app signed ` +
|
|
2391
|
+
`in from now on. Carry on with what you were going to check: ${why}`);
|
|
2392
|
+
})
|
|
2393
|
+
.catch((e) => {
|
|
2394
|
+
const why2 = e instanceof Error ? e.message : String(e);
|
|
2395
|
+
log('warn', `sign-in for "${part}" failed: ${why2}`);
|
|
2396
|
+
settle({ asked: false, why: why2 });
|
|
2397
|
+
});
|
|
2398
|
+
return ready;
|
|
2399
|
+
},
|
|
2400
|
+
}
|
|
2401
|
+
: {}),
|
|
2402
|
+
});
|
|
2403
|
+
/**
|
|
2404
|
+
* Always mounted. Mounting it only when something was already running is what cached a
|
|
2405
|
+
* stale toolset for a whole session: the first question about a card came before its
|
|
2406
|
+
* checkout existed, so that session had no browser, and every later question resumed it and
|
|
2407
|
+
* inherited the lack — the agent went looking for headless Chrome in the shell instead. An
|
|
2408
|
+
* agent with nothing to look at simply does not call these; the brief says what to do first.
|
|
2409
|
+
*/
|
|
2410
|
+
const eyes = turnEyes = browserTools({
|
|
2411
|
+
repo: config.repo,
|
|
2412
|
+
endpoints: () => env.endpoints,
|
|
2413
|
+
...(web ? { defaultPart: () => web } : {}),
|
|
2414
|
+
sites: () => declaredSites,
|
|
2415
|
+
contract: () => env.contract,
|
|
2416
|
+
cwd,
|
|
2417
|
+
onNote: (t) => log('tool', t),
|
|
2418
|
+
});
|
|
1654
2419
|
reachable = { cwd, endpoints: env.endpoints };
|
|
1655
|
-
|
|
1656
|
-
|
|
2420
|
+
/**
|
|
2421
|
+
* Say which it is, because they are not the same and the difference matters.
|
|
2422
|
+
*
|
|
2423
|
+
* "this card does not look like it needs one" was printed whenever the project was not
|
|
2424
|
+
* started — including when nothing had looked. Intake does not run for a question typed in
|
|
2425
|
+
* the pane, so `needs_running` defaulted to 0 and the agent was told a judgement had been
|
|
2426
|
+
* made about its card that had not been made at all.
|
|
2427
|
+
*/
|
|
2428
|
+
if (forACard && !needsRunning) {
|
|
2429
|
+
log('tool', prepared
|
|
2430
|
+
? 'environment not started — this card does not look like it needs one'
|
|
2431
|
+
: 'environment not started — nothing judged whether it is needed; call start_environment if it is');
|
|
2432
|
+
}
|
|
2433
|
+
// A lead's sweep is a JOB, not a nudge: clear the ready column by running the team. A nudge
|
|
2434
|
+
// got one look and "nothing to do"; a goal keeps the lead going, turn after turn, until the
|
|
2435
|
+
// board itself says it is done — proven by get-portfolio, not by the lead's own say-so.
|
|
2436
|
+
const leadGoal = isLead && event.event === 'sweep' && process.env['FLAREDECK_AGENT_LEAD_GOAL'] !== '0'
|
|
2437
|
+
? LEAD_SWEEP_GOAL
|
|
2438
|
+
: undefined;
|
|
2439
|
+
if (leadGoal)
|
|
2440
|
+
log('tool', 'sweep with a goal: clear the ready column by dispatching (≤8 turns)');
|
|
2441
|
+
/**
|
|
2442
|
+
* Which Claude subscription pays for this run.
|
|
2443
|
+
*
|
|
2444
|
+
* Three subscriptions and twenty-odd agents, with daily and weekly caps that trip at
|
|
2445
|
+
* different times, used to mean going round the machines logging in again. FlareDeck holds
|
|
2446
|
+
* the tokens and the ledger now and picks one per run.
|
|
2447
|
+
*
|
|
2448
|
+
* Not reaching FlareDeck is NOT the same as having nothing left: `machine` means carry on
|
|
2449
|
+
* with whatever this box is logged in as, exactly as before any of this existed. Only
|
|
2450
|
+
* `none` — every subscription genuinely spent or paused — stops the run, and it says when
|
|
2451
|
+
* one frees up rather than failing blankly.
|
|
2452
|
+
*/
|
|
2453
|
+
/* The MCP list, fresh for this session — and a required server that is not connected stops
|
|
2454
|
+
the work here, before a plan is spent on a card that cannot be done properly. */
|
|
2455
|
+
const missingMcp = await refreshMcp();
|
|
2456
|
+
if (missingMcp?.length) {
|
|
2457
|
+
await enterPause(mcpWallFor(missingMcp));
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
const subs = await subscriptionsForAgent(config.mcpUrl, () => opts.auth(), (t) => log('tool', t));
|
|
2461
|
+
const claudePlan = subs ? await subs.choose(failedPlans.get(event.id) ?? []) : null;
|
|
2462
|
+
if (claudePlan?.use === 'token') {
|
|
2463
|
+
log('tool', `claude: ${claudePlan.label}`);
|
|
2464
|
+
// On the identity line too, not only in the scrollback: the scrollback moves on, and
|
|
2465
|
+
// "which account is this agent spending" is a thing you glance at, not search for.
|
|
2466
|
+
state.claudeAccount = claudePlan.label;
|
|
2467
|
+
// Measured again once this event is done — see measurePlan.
|
|
2468
|
+
turnPlan = { id: claudePlan.id, label: claudePlan.label, token: claudePlan.token };
|
|
2469
|
+
}
|
|
2470
|
+
if (claudePlan?.use === 'none') {
|
|
2471
|
+
const when = claudePlan.availableAt ? new Date(claudePlan.availableAt).toLocaleString() : 'later';
|
|
2472
|
+
const tried = failedPlans.get(event.id)?.length ?? 0;
|
|
2473
|
+
failedPlans.delete(event.id);
|
|
2474
|
+
/**
|
|
2475
|
+
* Spent, not paused: the harness knows to the minute when a plan comes back — FlareDeck's
|
|
2476
|
+
* answer carries the soonest reset — so it waits for it and carries on by itself. Nobody is
|
|
2477
|
+
* asked to do anything about a clock. Only a fleet with NO reset coming (every plan paused,
|
|
2478
|
+
* which only a person undoes) is flagged.
|
|
2479
|
+
*/
|
|
2480
|
+
if (claudePlan.availableAt && claudePlan.availableAt > Date.now()) {
|
|
2481
|
+
const at = claudePlan.availableAt;
|
|
2482
|
+
if (!waitingForPlan.has(event.id)) {
|
|
2483
|
+
// A little after the reset, so the first request is not refused for being a second early.
|
|
2484
|
+
const timer = setTimeout(() => {
|
|
2485
|
+
waitingForPlan.delete(event.id);
|
|
2486
|
+
log('event', `a Claude subscription is back — resuming ${event.cardCode || event.line}`);
|
|
2487
|
+
if (enqueue(event, true))
|
|
2488
|
+
void drain();
|
|
2489
|
+
}, at - Date.now() + 60_000);
|
|
2490
|
+
timer.unref?.();
|
|
2491
|
+
waitingForPlan.set(event.id, timer);
|
|
2492
|
+
}
|
|
2493
|
+
log('warn', `every Claude subscription is spent${tried ? ` (${tried} failed on this turn)` : ''} — waiting, resuming at ${new Date(at).toLocaleTimeString()}`);
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
log('warn', `every Claude subscription is spent or paused${tried ? ` (${tried} failed on this turn)` : ''} — the soonest frees up ${when}`);
|
|
2497
|
+
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` +
|
|
2498
|
+
`${tried ? ', or just failed on this card' : ''}. ` +
|
|
2499
|
+
`The soonest one frees up ${when}.\n\nNothing is wrong with the card. Add or unpause a ` +
|
|
2500
|
+
`subscription on the Integrations page, or wait for the window to reset.`, 'technical');
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
1657
2503
|
const engineContext = {
|
|
2504
|
+
...(leadGoal ? { goal: leadGoal } : {}),
|
|
1658
2505
|
config: { ...config, repo: cwd },
|
|
1659
2506
|
// Only a real worktree confines anything; without one nothing is enforced here, and the
|
|
1660
2507
|
// guard below makes sure a turn that would CHANGE code never gets that far.
|
|
@@ -1684,6 +2531,7 @@ export async function serve(opts) {
|
|
|
1684
2531
|
...(resume ? { resume } : {}),
|
|
1685
2532
|
...(prepared?.model ? { model: prepared.model } : {}),
|
|
1686
2533
|
...(prepared?.size ? { size: prepared.size } : {}),
|
|
2534
|
+
...(claudePlan?.use === 'token' ? { claudeToken: claudePlan.token } : {}),
|
|
1687
2535
|
tools: {
|
|
1688
2536
|
...(job ? { git: { server: gitServer, toolNames: GIT_TOOL_NAMES } } : {}),
|
|
1689
2537
|
// Eyes, for any card. They used to be mounted only when the harness had itself started
|
|
@@ -1691,7 +2539,8 @@ export async function serve(opts) {
|
|
|
1691
2539
|
// no browser AND no mention of one, so it could not know that writing the script it is
|
|
1692
2540
|
// perfectly able to write was the thing standing between it and seeing its own change.
|
|
1693
2541
|
// The endpoints are read through a getter, so something the agent starts mid-turn counts.
|
|
1694
|
-
|
|
2542
|
+
browser: eyes,
|
|
2543
|
+
env: envTools,
|
|
1695
2544
|
team: teamSet,
|
|
1696
2545
|
flaredeck,
|
|
1697
2546
|
runs: runsSet,
|
|
@@ -1712,7 +2561,7 @@ export async function serve(opts) {
|
|
|
1712
2561
|
freeWorkerBrief(event) +
|
|
1713
2562
|
// Only tell an agent about the environment when the card actually needs one running.
|
|
1714
2563
|
environmentBrief(env, cwd, needsRunning) + notStartedBrief(forACard && !needsRunning, cwd) +
|
|
1715
|
-
browserBrief(env.endpoints, declaredSites, forACard) +
|
|
2564
|
+
browserBrief(env.endpoints, declaredSites, forACard, web) +
|
|
1716
2565
|
(wrongEnv.length
|
|
1717
2566
|
? `\n\nSTOP — the environment that started is not yours:\n${wrongEnv.map((w) => ` · ${w}`).join('\n')}\n\n` +
|
|
1718
2567
|
`Anything you test against it tells you about another card's code, and it will answer ` +
|
|
@@ -1724,82 +2573,149 @@ export async function serve(opts) {
|
|
|
1724
2573
|
liveRun = run;
|
|
1725
2574
|
// An agent that has started fighting the tools is not going to stop on its own.
|
|
1726
2575
|
const struggling = watchForDesperation();
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
2576
|
+
/**
|
|
2577
|
+
* One cap is usually seen two or three times in a run: the structured `rate_limit_event`
|
|
2578
|
+
* (which knows the window and the reset), then the error on the assistant message and the
|
|
2579
|
+
* result text (which know neither). Reporting all of them filed an `unknown` window with a
|
|
2580
|
+
* guessed five-hour block on top of the real one — keeping a plan out hours past its actual
|
|
2581
|
+
* reset. Once a run has reported a named window, the anonymous echoes are dropped.
|
|
2582
|
+
*/
|
|
2583
|
+
let spentNamed = false;
|
|
2584
|
+
let spentAny = false;
|
|
2585
|
+
const spentReports = [];
|
|
2586
|
+
let doneText = '';
|
|
2587
|
+
let runError = null;
|
|
2588
|
+
try {
|
|
2589
|
+
for await (const out of run.events) {
|
|
2590
|
+
status.observe(out);
|
|
2591
|
+
if (out.kind === 'done' && !out.ok)
|
|
2592
|
+
doneText = String(out.result ?? '');
|
|
2593
|
+
if (out.kind === 'limits' && claudePlan?.use === 'token') {
|
|
2594
|
+
/* Reported, never interpreted here. 'allowed_warning' arrives while the plan is still
|
|
2595
|
+
working — treating it as spent would retire every subscription at about half capacity
|
|
2596
|
+
and leave the agents with nothing while each account still had headroom. */
|
|
2597
|
+
if (out.spent) {
|
|
2598
|
+
const named = !!out.window && out.window !== 'unknown';
|
|
2599
|
+
spentAny = true;
|
|
2600
|
+
if (named || !spentNamed) {
|
|
2601
|
+
spentNamed ||= named;
|
|
2602
|
+
spentReports.push(subs?.spent(claudePlan.id, out.window, out.resetsAt, out.why ?? `hit during ${event.cardCode ?? event.cardKey ?? 'a run'}`) ?? Promise.resolve());
|
|
2603
|
+
log('warn', `claude: ${claudePlan.label} hit its ${out.window ?? 'usage'} limit (${out.source ?? 'unknown signal'})`);
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
else if (out.window && typeof out.utilization === 'number') {
|
|
2607
|
+
turnSawUsage = true;
|
|
2608
|
+
void subs?.seen(claudePlan.id, out.window, out.utilization, out.resetsAt);
|
|
1739
2609
|
}
|
|
1740
|
-
if (tag)
|
|
1741
|
-
void tellLeadItIsStuck(tag, struggling.refusals());
|
|
1742
2610
|
}
|
|
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`);
|
|
2611
|
+
if (out.kind === 'tool') {
|
|
2612
|
+
const stepBack = struggling.saw(out.name);
|
|
2613
|
+
if (stepBack) {
|
|
2614
|
+
log('warn', `${struggling.refusals()} refusals this turn — telling it to stop and ask`);
|
|
2615
|
+
const heard = await run.steer(stepBack).catch(() => false);
|
|
2616
|
+
// An engine that cannot be steered gets stopped instead: letting it carry on is how a
|
|
2617
|
+
// turn becomes twenty minutes of working around a guard.
|
|
2618
|
+
if (!heard) {
|
|
2619
|
+
log('warn', 'cannot steer this engine — ending the turn');
|
|
2620
|
+
await run.stop().catch(() => { });
|
|
2621
|
+
}
|
|
2622
|
+
if (tag)
|
|
2623
|
+
void tellLeadItIsStuck(tag, struggling.refusals());
|
|
2624
|
+
}
|
|
1777
2625
|
}
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
if (
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
2626
|
+
state.tasks = status.snapshot().tasks;
|
|
2627
|
+
// An answer to a question typed here is the whole point of the turn, so it is shown whole;
|
|
2628
|
+
// a card turn's narration is one line each, the card holds the full account.
|
|
2629
|
+
if (out.kind === 'text')
|
|
2630
|
+
log(event.event === 'ask' ? 'answer' : 'say', out.text);
|
|
2631
|
+
else if (out.kind === 'tool')
|
|
2632
|
+
log('tool', out.name);
|
|
2633
|
+
// A changed plan is the most informative thing an agent emits, so it is pushed at once
|
|
2634
|
+
// rather than waiting for the timer.
|
|
2635
|
+
else if (out.kind === 'todo') {
|
|
2636
|
+
log('todo', out.text);
|
|
2637
|
+
void report();
|
|
1788
2638
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
2639
|
+
// The plan replaces the previous one — TodoWrite always sends the whole list.
|
|
2640
|
+
else if (out.kind === 'plan') {
|
|
2641
|
+
state.plan = out.items;
|
|
2642
|
+
state.tasks = {
|
|
2643
|
+
done: out.items.filter((t) => t.status === 'completed').length,
|
|
2644
|
+
total: out.items.length,
|
|
2645
|
+
};
|
|
2646
|
+
void report();
|
|
2647
|
+
}
|
|
2648
|
+
else if (out.kind === 'ready') {
|
|
2649
|
+
if (out.sessionId)
|
|
2650
|
+
sessions.set(key, out.sessionId);
|
|
2651
|
+
// The preflight proved the server answers; this proves the SESSION got it. The SDK will
|
|
2652
|
+
// start the model with a server still 'pending' or 'needs-auth' and list its tools from
|
|
2653
|
+
// memory, so a count alone is not enough — the status has to say connected. Refuse the
|
|
2654
|
+
// turn otherwise: the event stays queued and comes back once the tools are really there.
|
|
2655
|
+
const fd = out.mcp.find((s) => s.name === config.mcpServerName);
|
|
2656
|
+
log('tool', `session: FlareDeck ${fd?.status ?? 'absent'} · ${out.tools.length} tools · ${out.permissionMode}`);
|
|
2657
|
+
if (!fd || fd.status !== 'connected' || out.tools.length === 0) {
|
|
2658
|
+
throw new CouldNotWork(`this session's FlareDeck server is ${fd?.status ?? 'absent'}, so the card cannot be read — leaving the event queued`);
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
else if (out.kind === 'usage') {
|
|
1796
2662
|
turnTokens = out.contextTokens;
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
2663
|
+
if (!nudged && turnTokens >= CONTEXT_WRAP_UP_TOKENS && liveRun) {
|
|
2664
|
+
nudged = true;
|
|
2665
|
+
log('warn', `context at ${Math.round(turnTokens / 1000)}k — asking it to wrap up before the window fills`);
|
|
2666
|
+
const wrapUpText = `Your context is at ${Math.round(turnTokens / 1000)}k tokens and will be compacted soon, which loses detail. ` +
|
|
2667
|
+
'Finish the step you are on, then stop: comment on the card with where things stand, put anything half-done ' +
|
|
2668
|
+
'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.';
|
|
2669
|
+
void liveRun.steer(wrapUpText);
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
else if (out.kind === 'compacted')
|
|
2673
|
+
log('warn', 'the SDK compacted this session mid-turn — detail before this point is summarised, not kept');
|
|
2674
|
+
else if (out.kind === 'done') {
|
|
2675
|
+
if (!out.ok)
|
|
2676
|
+
log('warn', 'the turn did not finish cleanly');
|
|
2677
|
+
if (out.contextTokens)
|
|
2678
|
+
turnTokens = out.contextTokens;
|
|
2679
|
+
// The number the intake step is meant to move: how big this turn's context got.
|
|
2680
|
+
if (turnTokens)
|
|
2681
|
+
log('tool', `turn: ${Math.round(turnTokens / 1000)}k context${prepared?.size ? ` · ${prepared.size} work` : ''}`);
|
|
2682
|
+
}
|
|
1800
2683
|
}
|
|
1801
2684
|
}
|
|
2685
|
+
catch (e) {
|
|
2686
|
+
runError = e;
|
|
2687
|
+
}
|
|
1802
2688
|
liveRun = null;
|
|
2689
|
+
/**
|
|
2690
|
+
* The plan failed, not the work: switch plans and run this event again, now.
|
|
2691
|
+
*
|
|
2692
|
+
* Two causes, told apart because they are fixed differently. A spent window is already filed
|
|
2693
|
+
* above (awaited here, so `choose` sees it). A rejected token — "401 Invalid bearer token",
|
|
2694
|
+
* a mis-pasted or revoked setup-token — is not capacity and is not filed as spent; it is
|
|
2695
|
+
* excluded for this event, and says plainly that the token needs replacing.
|
|
2696
|
+
*
|
|
2697
|
+
* The retry resumes the same session, so work already done in the turn is not lost; it costs
|
|
2698
|
+
* the prompt cache, which is the price of carrying on at all.
|
|
2699
|
+
*/
|
|
2700
|
+
if (claudePlan?.use === 'token') {
|
|
2701
|
+
const said = `${runError instanceof Error ? runError.message : runError ?? ''} ${doneText}`;
|
|
2702
|
+
const rejected = /API Error: 401|invalid bearer token|OAuth token has expired|OAuth token (was )?revoked/i.test(said);
|
|
2703
|
+
if (rejected || spentAny) {
|
|
2704
|
+
await Promise.allSettled(spentReports);
|
|
2705
|
+
const tried = [...(failedPlans.get(event.id) ?? []), claudePlan.id];
|
|
2706
|
+
if (rejected) {
|
|
2707
|
+
log('warn', `claude: ${claudePlan.label} — its token was rejected. Replace it on Integrations → Secrets.`);
|
|
2708
|
+
}
|
|
2709
|
+
if (tried.length <= MAX_PLAN_SWITCHES) {
|
|
2710
|
+
failedPlans.set(event.id, tried);
|
|
2711
|
+
throw new RetryOnAnotherPlan(`${claudePlan.label} ${rejected ? 'rejected its token' : 'is at its limit'} — switching subscription and trying again`);
|
|
2712
|
+
}
|
|
2713
|
+
failedPlans.delete(event.id);
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
failedPlans.delete(event.id);
|
|
2717
|
+
if (runError)
|
|
2718
|
+
throw runError;
|
|
1803
2719
|
// A question from the pane: no card to hand over to. Its session is kept so the next question
|
|
1804
2720
|
// can build on this one, and simply dropped once it has grown too large to be worth carrying.
|
|
1805
2721
|
if (event.event === 'ask') {
|
|
@@ -1961,8 +2877,8 @@ export async function serve(opts) {
|
|
|
1961
2877
|
ui = startUi({ state: () => ({ ...state, queued: queue.length }), onSteer: steer, onQuit: shutdown, ...(decide ? { onDecide: decide } : {}) });
|
|
1962
2878
|
try {
|
|
1963
2879
|
for await (const event of stream.events((s) => {
|
|
1964
|
-
state.connected = s
|
|
1965
|
-
log(
|
|
2880
|
+
state.connected = s.startsWith('listening');
|
|
2881
|
+
log(state.connected ? 'tool' : 'warn', s);
|
|
1966
2882
|
})) {
|
|
1967
2883
|
// A verdict on a request this agent is waiting on. Handled HERE, not in drain(): the turn
|
|
1968
2884
|
// that asked is still running — blocked inside the tool call — and drain is serialised
|
|
@@ -1976,6 +2892,14 @@ export async function serve(opts) {
|
|
|
1976
2892
|
beat();
|
|
1977
2893
|
continue;
|
|
1978
2894
|
}
|
|
2895
|
+
// Paused: nothing is worked. Acknowledged, because its cards went back to the lead and the
|
|
2896
|
+
// lead decides what happens to them; carrying a backlog through a pause would replay it.
|
|
2897
|
+
if (paused) {
|
|
2898
|
+
log('tool', `paused — left for the lead: ${event.line}`);
|
|
2899
|
+
await stream.ack([event.id]);
|
|
2900
|
+
beat();
|
|
2901
|
+
continue;
|
|
2902
|
+
}
|
|
1979
2903
|
// Another board's work. Acknowledged so it does not come round again, and never read: no
|
|
1980
2904
|
// intake, no Jev call, no turn. One line, so it is visible that scope is doing something
|
|
1981
2905
|
// rather than that the agent has gone deaf.
|
|
@@ -2038,6 +2962,18 @@ async function baseBranch(cwd) {
|
|
|
2038
2962
|
c.on('error', () => resolve('main'));
|
|
2039
2963
|
});
|
|
2040
2964
|
}
|
|
2965
|
+
/**
|
|
2966
|
+
* What a lead's sweep must achieve, as a `/goal` condition (under 4000 characters). Judged by a
|
|
2967
|
+
* small model from the transcript alone, so the proof has to be IN the transcript: a final
|
|
2968
|
+
* get-portfolio, quoted. Bounded, because a column that cannot be emptied (no free worker, every
|
|
2969
|
+
* card blocked) must end in reasons, not in a loop.
|
|
2970
|
+
*/
|
|
2971
|
+
const LEAD_SWEEP_GOAL = 'Every card in your board\'s ready-for-work column (the lane FlareDeck calls Ready For Dev — its ' +
|
|
2972
|
+
'type is todo) has been dealt with: handed to a worker that reports to you and is free, with ' +
|
|
2973
|
+
'assign-card, or left there with a one-line reason it must wait (no free worker, blocked, ' +
|
|
2974
|
+
'waiting on a person). You dispatch; you do not build cards yourself. The goal is met only when ' +
|
|
2975
|
+
'your most recent turn called get-portfolio and you quoted, from that output, how many cards ' +
|
|
2976
|
+
'are in the ready column and, for each card still there, its one-line reason. Or stop after 8 turns.';
|
|
2041
2977
|
function prompt(event, isLead = false) {
|
|
2042
2978
|
if (event.event === 'sweep') {
|
|
2043
2979
|
return [
|
|
@@ -2136,10 +3072,21 @@ function prompt(event, isLead = false) {
|
|
|
2136
3072
|
'dispatch, re-assign, or change anything else — this is a review, not a card to work.',
|
|
2137
3073
|
].join('\n');
|
|
2138
3074
|
}
|
|
3075
|
+
// A card the HARNESS picked back up because it sits in progress with this agent's name on it.
|
|
3076
|
+
// Finished work left there comes back on every check and blocks every other card.
|
|
3077
|
+
const resumed = String(event.id ?? '').startsWith('resume-')
|
|
3078
|
+
? ['', 'You were handed this card again only because it is still in an in-progress lane assigned to',
|
|
3079
|
+
'you. If its work is already finished — answered, delivered, nothing left for you — move it on to',
|
|
3080
|
+
'its next lane with update-card-status NOW. Left in progress, it is handed back to you on every',
|
|
3081
|
+
'check and FlareDeck will not let you start any other card. If it is waiting on a PERSON and you',
|
|
3082
|
+
'may not move it yourself, call set-needs-human saying what they need to do — a card waiting on a',
|
|
3083
|
+
'person is not handed back to you, and their answer wakes you.']
|
|
3084
|
+
: [];
|
|
2139
3085
|
return [
|
|
2140
3086
|
`A FlareDeck event just arrived for you:`,
|
|
2141
3087
|
'',
|
|
2142
3088
|
event.line,
|
|
3089
|
+
...resumed,
|
|
2143
3090
|
'',
|
|
2144
3091
|
'Deal with it. Read the card before you change anything — INCLUDING its agent notes, where a',
|
|
2145
3092
|
'previous session may have left you a handover: what it got to, what it tried that failed,',
|