@onepointfour-packs/flaredeck-agent 0.2.39 → 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/serve.js CHANGED
@@ -347,6 +347,14 @@ export async function serve(opts) {
347
347
  */
348
348
  const localMcp = [...(config.mcpServers ?? [])];
349
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;
350
358
  const refreshMcp = async () => {
351
359
  try {
352
360
  const { hydrateMcpServers, mcpConfigFetcher } = await import('./hydrate.js');
@@ -359,6 +367,16 @@ export async function serve(opts) {
359
367
  lastMcpNote = hydrated.note;
360
368
  log('tool', hydrated.note);
361
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;
362
380
  return hydrated.missingRequired ?? null;
363
381
  }
364
382
  catch (e) {
@@ -568,7 +586,8 @@ export async function serve(opts) {
568
586
  * Its refusals and its asks both end up in decisions.jsonl beside the config.
569
587
  */
570
588
  const jevKey = loadProviders().openrouter?.apiKey;
571
- const guard = jevGuard({
589
+ // Kept as an object so a key rotated in FlareDeck reaches the guard at the next session.
590
+ const guard = jevGuard(jevCfg = {
572
591
  apiKey: jevKey ?? '',
573
592
  decider: (req, opt) => decider(req, opt),
574
593
  log: (t) => log('tool', t),
@@ -844,16 +863,23 @@ export async function serve(opts) {
844
863
  let readyReported = false;
845
864
  /** Walls the agent cannot climb, checked in code — never by asking the model. */
846
865
  const walls = async () => {
847
- config.github = loadGitHub() ?? config.github;
848
- const login = await canPublish(config.github).catch(() => null);
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);
849
873
  if (login)
850
- return { check: 'github', reason: `GitHub refuses this machine's login, so nothing I write can be published. Somebody signs it in again on this machine: \`flaredeck-agent login --github-token ghp_…\` (a classic token with repo and workflow scope does not expire).` };
851
- if (config.github?.accessToken) {
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) {
852
878
  for (const c of [{ path: config.repo }, ...(await checkouts(config.repo, config.repos))]) {
853
879
  const remote = await new Promise((resolve) => {
854
880
  execFile('git', ['remote', 'get-url', 'origin'], { cwd: c.path }, (err, out) => resolve(err ? '' : String(out).trim()));
855
881
  });
856
- const why = remote ? await canPushTo(config.github.accessToken, remote) : null;
882
+ const why = remote ? await canPushTo(gh.accessToken, remote) : null;
857
883
  if (why)
858
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.` };
859
885
  }
@@ -1035,6 +1061,14 @@ export async function serve(opts) {
1035
1061
  /** Worktrees whose environment comes down unless a turn returns to them first. */
1036
1062
  const idleStops = new Map();
1037
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;
1038
1072
  const scheduleEnvironmentStop = (cwd) => {
1039
1073
  const before = idleStops.get(cwd);
1040
1074
  if (before)
@@ -1044,6 +1078,11 @@ export async function serve(opts) {
1044
1078
  // Never the checkout a turn is in right now, even if the timer and the turn cross.
1045
1079
  if (cwd === turnCwd && state.working)
1046
1080
  return;
1081
+ const waiting = signInPending.get(cwd);
1082
+ if (waiting && Date.now() - waiting < SIGN_IN_HOLD_MS) {
1083
+ scheduleEnvironmentStop(cwd);
1084
+ return;
1085
+ }
1047
1086
  void stopEnvironment(cwd).then((ok) => { if (ok)
1048
1087
  log('tool', `environment stopped in ${cwd.split('/').pop()} (idle 20m)`); }).catch(() => { });
1049
1088
  }, ENV_IDLE_MS));
@@ -1393,6 +1432,15 @@ export async function serve(opts) {
1393
1432
  * harness reads it and queues the card itself, as the ordinary card event it should always
1394
1433
  * have been — with a worktree, a branch, and everything else a card turn gets.
1395
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;
1396
1444
  const resumeOwnWork = async () => {
1397
1445
  /* Not `live.size`: a session kept WARM for a follow-up is a cache, not work, and blocking on
1398
1446
  it meant the card in progress was never resumed within fifteen minutes of any turn. */
@@ -1426,7 +1474,13 @@ export async function serve(opts) {
1426
1474
  for (const c of mine) {
1427
1475
  if (sessions.has(c.code) || live.has(c.code))
1428
1476
  continue;
1477
+ if ((resumedIdle.get(c.code) ?? 0) >= RESUME_IDLE_LIMIT)
1478
+ continue;
1429
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;
1430
1484
  if (String(card?.lane?.type ?? '').toLowerCase() === 'inprogress') {
1431
1485
  next = c;
1432
1486
  break;
@@ -1434,6 +1488,11 @@ export async function serve(opts) {
1434
1488
  }
1435
1489
  if (!next)
1436
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
+ }
1437
1496
  log('tool', `picking ${next.code} back up — it is yours and already in progress`);
1438
1497
  if (enqueue({
1439
1498
  id: `resume-${next.code}-${Date.now()}`,
@@ -1526,6 +1585,10 @@ export async function serve(opts) {
1526
1585
  if (event.event !== 'ask')
1527
1586
  log('event', event.line);
1528
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);
1529
1592
  status.startCard(current, !!event.cardKey);
1530
1593
  void report();
1531
1594
  let didWork = true;
@@ -2115,9 +2178,18 @@ export async function serve(opts) {
2115
2178
  base: startFrom ?? (await baseBranch(cwd)),
2116
2179
  card: event.cardCode || key,
2117
2180
  onAuthFailure: (why) => {
2118
- void enterPause({ check: 'github', reason: `GitHub refused this machine's login when I pushed (${why.slice(0, 160)}), so nothing I write can be published. Somebody signs it in again on this machine: \`flaredeck-agent login --github-token ghp_…\` (a classic token with repo and workflow scope does not expire).` });
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).` });
2119
2184
  },
2120
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
+ }
2121
2193
  // Read fresh at every push: the login is the machine's, so signing in again from
2122
2194
  // any pane fixes an agent that has been running all day with the old one.
2123
2195
  config.github = loadGitHub() ?? config.github;
@@ -2245,6 +2317,25 @@ export async function serve(opts) {
2245
2317
  ...(card.board ? { board: card.board } : {}),
2246
2318
  ...(card.code ? { code: card.code } : {}),
2247
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
+ };
2248
2339
  void signInSession({
2249
2340
  url,
2250
2341
  launch: () => chromium.launch(HEADLESS_FULL),
@@ -2257,9 +2348,11 @@ export async function serve(opts) {
2257
2348
  `session is kept for the whole project, so this is once, not once per card.`, 'sign_in', undefined, [{ url: viewer, label: 'Sign in' }]);
2258
2349
  settle({ asked: true, where: viewer });
2259
2350
  },
2260
- onNote: (t) => log('tool', `sign-in: ${t}`),
2351
+ onNote: pageNote,
2261
2352
  })
2262
2353
  .then(async (r) => {
2354
+ if (signCwd)
2355
+ signInPending.delete(signCwd);
2263
2356
  if (!r.ok || !r.session) {
2264
2357
  log('warn', `sign-in for "${part}" did not finish: ${r.why ?? 'nothing was saved'}`);
2265
2358
  /* Say so ON THE CARD. The flag already raised carries a link to a viewer
@@ -2979,10 +3072,21 @@ function prompt(event, isLead = false) {
2979
3072
  'dispatch, re-assign, or change anything else — this is a review, not a card to work.',
2980
3073
  ].join('\n');
2981
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
+ : [];
2982
3085
  return [
2983
3086
  `A FlareDeck event just arrived for you:`,
2984
3087
  '',
2985
3088
  event.line,
3089
+ ...resumed,
2986
3090
  '',
2987
3091
  'Deal with it. Read the card before you change anything — INCLUDING its agent notes, where a',
2988
3092
  'previous session may have left you a handover: what it got to, what it tried that failed,',