@onepointfour-packs/flaredeck-agent 0.2.193 → 0.2.195

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
@@ -29,7 +29,7 @@ class RetryOnAnotherPlan extends CouldNotWork {
29
29
  }
30
30
  import { isJustReading, jevGuard, judgeCommand } from './jevguard.js';
31
31
  import { loadProviders } from './providers.js';
32
- import { cardWorkOnBase, currentBranch, ensureWorktree, finishRefusalStands, freeDiskBytes, sweepWorktrees, releaseSharedPushGuard, existingWorktree, produced, releaseWorktree, tooEarlyToFinish, uncommittedCount, worktreeBrief } from './worktree.js';
32
+ import { cardWorkOnBase, currentBranch, ensureWorktree, finishRefusalStands, freeDiskBytes, headOf, leadGaveItBack, sweepWorktrees, releaseSharedPushGuard, existingWorktree, produced, releaseWorktree, tooEarlyToFinish, uncommittedCount, worktreeBrief } from './worktree.js';
33
33
  import { askingForComponent, checkouts, repoIdentity, resolveCheckout, voteByCode, workspaceBrief, describeCheckout } from './workspace.js';
34
34
  import { chooseRepoWithJev } from './jev.js';
35
35
  import { ROUTE, intake, intakeText, unclearBrief } from './intake.js';
@@ -92,6 +92,33 @@ export function nothingOutstanding(text) {
92
92
  return false;
93
93
  return text.includes('*Nothing to do here. 🎉*') && text.includes('*No unactioned comments or mentions. 🎉*') && !/^### \d+\./m.test(text);
94
94
  }
95
+ /** Tools that only look: reading files, searching, asking FlareDeck or a service what is there. */
96
+ export function onlyLooks(tool) {
97
+ if (['Read', 'Grep', 'Glob', 'LS', 'ToolSearch', 'TodoWrite', 'TaskOutput', 'LSP', 'WebFetch', 'WebSearch', 'Monitor', 'Skill'].includes(tool))
98
+ return true;
99
+ const op = tool.startsWith('mcp__') ? tool.split('__').slice(2).join('__') : '';
100
+ return /^(get|find|list|read|search|health|hello|whats|check|describe|site_map|see_screen|browser_look|browser_screenshot|browser_events|pull_request_status|preview)/i.test(op);
101
+ }
102
+ /** Record a tool call against the session it ran in. */
103
+ export function noteHandoverTool(logs, key, tool, at) {
104
+ const l = logs.get(key) ?? {};
105
+ if (/__set-card-notes$/.test(tool))
106
+ l.notesAt = at;
107
+ else if (!onlyLooks(tool))
108
+ l.changedAt = at;
109
+ logs.set(key, l);
110
+ }
111
+ /**
112
+ * Whether a session ending needs a handover note: it changed something, after its last notes.
113
+ * Unknown (no record) is treated as needed, as before.
114
+ */
115
+ export function handoverNeeded(l) {
116
+ if (!l)
117
+ return true;
118
+ if (!l.changedAt)
119
+ return false;
120
+ return !l.notesAt || l.changedAt > l.notesAt;
121
+ }
95
122
  /** How long after a card's turn the harness leaves it alone, unless an event brings it back. */
96
123
  export const RESUME_COOLDOWN_MS = 15 * 60_000;
97
124
  /** Whether any of these ids had a turn within the cooldown. */
@@ -488,8 +515,8 @@ export async function serve(opts) {
488
515
  const isJam = (m) => m.name === 'jam' || /(^|\.)jam\.dev\b/.test(new URL(m.url).hostname);
489
516
  const { setJamFromFlaredeck } = await import('./jam.js');
490
517
  setJamFromFlaredeck(hydrated.mcpServers.some((m) => isJam(m) && m.token)
491
- ? async () => {
492
- await refreshMcp();
518
+ ? async (fresh) => {
519
+ await refreshMcp(fresh === true);
493
520
  return (config.mcpServers ?? []).find((m) => isJam(m) && m.token)?.token ?? null;
494
521
  }
495
522
  : null);
@@ -1008,6 +1035,8 @@ export async function serve(opts) {
1008
1035
  const keepTool = (name) => offeredTo(isManager || isAssistant ? 'manager' : isLead ? 'lead' : 'worker', name);
1009
1036
  /** Cards already handed to the lead, so a retried tool call does not assign or message twice. */
1010
1037
  const handedBack = new Set();
1038
+ /** The branch head when each card was handed to the lead: unchanged, a card given back is the lead's "move it". */
1039
+ const handedBackHead = new Map();
1011
1040
  try {
1012
1041
  const team = await fetchTeam(opts.baseUrl, await opts.auth());
1013
1042
  // Who is an agent, so a parked card can tell a person answering from agents talking shop.
@@ -1544,6 +1573,30 @@ export async function serve(opts) {
1544
1573
  */
1545
1574
  const signInPending = new Map();
1546
1575
  const SIGN_IN_HOLD_MS = 2 * 60 * 60_000;
1576
+ /**
1577
+ * Stop this agent's environments for its other cards before starting one for this card. An agent
1578
+ * works one card at a time, and the other card's servers hold the ports: CASE could not rerun its
1579
+ * HR-1091 browser check because HR-1074's environment still served on them (20 Oct), and JARVIS
1580
+ * hit the same twice on 19 Oct. A checkout with a sign-in waiting on a person is left up.
1581
+ */
1582
+ const stopOtherEnvironments = async (cwd) => {
1583
+ for (const other of [...envUp.keys()]) {
1584
+ if (other === cwd)
1585
+ continue;
1586
+ const waiting = signInPending.get(other);
1587
+ if (waiting && Date.now() - waiting < SIGN_IN_HOLD_MS)
1588
+ continue;
1589
+ const timer = idleStops.get(other);
1590
+ if (timer) {
1591
+ clearTimeout(timer);
1592
+ idleStops.delete(other);
1593
+ }
1594
+ envUp.delete(other);
1595
+ const ok = await stopEnvironment(other).catch(() => false);
1596
+ if (ok)
1597
+ log('tool', `environment stopped in ${other.split('/').pop()} — another card's, and this card needs its ports`);
1598
+ }
1599
+ };
1547
1600
  const scheduleEnvironmentStop = (cwd) => {
1548
1601
  const before = idleStops.get(cwd);
1549
1602
  if (before)
@@ -1782,6 +1835,8 @@ export async function serve(opts) {
1782
1835
  timer.unref();
1783
1836
  live.set(key, { cwd, tokens, timer });
1784
1837
  };
1838
+ /** Per session: when it last wrote the card's notes, and last did something that changed anything. */
1839
+ const handoverLog = new Map();
1785
1840
  const closeSession = async (key, done = false) => {
1786
1841
  const s = live.get(key);
1787
1842
  if (!s)
@@ -3188,8 +3243,29 @@ export async function serve(opts) {
3188
3243
  const code = tag || event.cardCode || event.cardKey || '';
3189
3244
  if (!code)
3190
3245
  return null;
3246
+ // Handed over before, and the lead has given it back with nothing new on the branch: the
3247
+ // lead reviewed it and wants it moved on. Let the move through instead of refusing again.
3248
+ if (handedBack.has(code) && tree && opts.deck) {
3249
+ const [here, head] = await Promise.all([opts.deck.card(code).catch(() => null), headOf(tree)]);
3250
+ if (leadGaveItBack(handedBackHead.get(code), head, here?.assigned_to?.uid, selfId)) {
3251
+ log('tool', `moving ${code} on — ${leadName} reviewed it and gave it back, nothing new since`);
3252
+ return null;
3253
+ }
3254
+ // Back with this worker and changed since (or we cannot tell): it is new work, handed to the
3255
+ // lead again like the first time. Remembering the old hand-back forever refused HAL's second
3256
+ // "done" on HR-1074 with "JARVIS's to verify" while the card stayed assigned to HAL (20 Oct).
3257
+ if (here?.assigned_to?.uid && here.assigned_to.uid === selfId) {
3258
+ handedBack.delete(code);
3259
+ handedBackHead.delete(code);
3260
+ }
3261
+ }
3191
3262
  if (!handedBack.has(code)) {
3192
3263
  handedBack.add(code);
3264
+ if (tree) {
3265
+ const head = await headOf(tree);
3266
+ if (head)
3267
+ handedBackHead.set(code, head);
3268
+ }
3193
3269
  const made = tree ? await produced(tree) : null;
3194
3270
  const where = tree
3195
3271
  ? `Branch \`${tree.branch}\`${made ? ` — ${made.commits} commit(s), ${made.pushed ? 'pushed' : 'NOT pushed'}` : ''}.`
@@ -3208,6 +3284,7 @@ export async function serve(opts) {
3208
3284
  catch (e) {
3209
3285
  // A handback that did not happen must not read as one that did.
3210
3286
  handedBack.delete(code);
3287
+ handedBackHead.delete(code);
3211
3288
  const text = e instanceof Error ? e.message : String(e);
3212
3289
  log('warn', `could not hand ${code} to ${leadName}: ${text}`);
3213
3290
  return `Could not hand this card to ${leadName} for review (${text}). Do not move it; say so on the card instead.`;
@@ -3402,7 +3479,7 @@ export async function serve(opts) {
3402
3479
  : forACard && needsRunning
3403
3480
  // The script this board declared for the repository this card is in, if it declared one.
3404
3481
  // Matched on the remote, the same way the base branch is.
3405
- ? await startEnvironment(cwd, (t) => log('tool', t), where.kind === 'repo' ? where.script : undefined)
3482
+ ? await stopOtherEnvironments(cwd).then(() => startEnvironment(cwd, (t) => log('tool', t), where.kind === 'repo' ? where.script : undefined))
3406
3483
  : { ran: false, ok: true, endpoints: {}, detail: '' };
3407
3484
  if (env.ran && env.ok)
3408
3485
  envUp.set(cwd, env);
@@ -3445,6 +3522,7 @@ export async function serve(opts) {
3445
3522
  envUp.set(cwd, started);
3446
3523
  },
3447
3524
  onNote: (t) => log('tool', t),
3525
+ beforeStart: () => stopOtherEnvironments(cwd),
3448
3526
  /**
3449
3527
  * Turning "ask somebody" into something that actually asks.
3450
3528
  *
@@ -3938,6 +4016,8 @@ export async function serve(opts) {
3938
4016
  log('warn', `${r.status.over.message} — Claude answers from the next turn`);
3939
4017
  });
3940
4018
  }
4019
+ if (out.kind === 'tool' && !out.sub)
4020
+ noteHandoverTool(handoverLog, key, out.name, Date.now());
3941
4021
  if (out.kind === 'tool') {
3942
4022
  const stepBack = struggling.saw(out.name);
3943
4023
  if (stepBack) {
@@ -4168,6 +4248,17 @@ export async function serve(opts) {
4168
4248
  return flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered);
4169
4249
  }
4170
4250
  async function wrapUp(key, cwd, done = false) {
4251
+ // Nothing to hand over: the session changed nothing since it last wrote the card's notes, or
4252
+ // never changed anything. A handover reopens the whole session (often 70k-126k tokens, the
4253
+ // cache long cold) to say so: JARVIS spent about half its turns on 20 Oct rewriting notes it
4254
+ // had just written (HR-0759 five times in three minutes). A finished card is always handed over.
4255
+ if (!done && !handoverNeeded(handoverLog.get(key))) {
4256
+ log('tool', `${key}: nothing changed since the last handover — closing without another`);
4257
+ handoverLog.delete(key);
4258
+ sessions.delete(key);
4259
+ return;
4260
+ }
4261
+ handoverLog.delete(key);
4171
4262
  // Resume the session the work actually happened in — NOT the options the turn started with.
4172
4263
  // On a new card those carry no resume id, so reusing them would open a third session and ask
4173
4264
  // an agent with no memory of the work to summarise it. It would answer, too.
@@ -4228,6 +4319,13 @@ export async function serve(opts) {
4228
4319
  '',
4229
4320
  'Keep it short. If there is genuinely nothing worth passing on, say so and write nothing —',
4230
4321
  'an empty note is better than a paragraph restating the card.',
4322
+ '',
4323
+ // HAL stopped a browser check six minutes into its run on this handover, "so nothing is
4324
+ // left running", and the next session started it over (HR-1074, 20 Oct). The card is
4325
+ // not finished: the environment and a run being waited on are what the next session needs.
4326
+ 'Do NOT stop anything that is still running: the environment, a test run or a background',
4327
+ 'job you are waiting on. The card is not finished, and the next session on it picks those',
4328
+ 'up. Say in the note what is still running and where its result will be.',
4231
4329
  ]).join('\n'), {
4232
4330
  config: { ...config, repo: cwd },
4233
4331
  decider: sessionDecider,