@onepointfour-packs/flaredeck-agent 0.2.81 → 0.2.83

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
@@ -5,6 +5,7 @@ import { ensureTmuxScrollback } from './tmux.js';
5
5
  import { START_SCRIPT as START_SCRIPT_NAME, webPart, browserBrief, crossWired, environmentBrief, environmentStillUp, notStartedBrief, startEnvironment, stopEnvironment, stopLeftoverEnvironments } from './environment.js';
6
6
  import { GIT_TOOL_NAMES, githubTools, publishBranch } from './github.js';
7
7
  import { freshGitHubToken } from './githubauth.js';
8
+ import { answerFrom, createPresence, heldFor, modeCommand } from './presence.js';
8
9
  import { loadBaseBranch, loadMergeConfig, preflight, preflightBrief } from './preflight.js';
9
10
  import { pushStatus, StatusTracker } from './status.js';
10
11
  import { dispatchDecision, fetchTeam, myTeam, teamTools, TEAM_TOOL_NAMES } from './team.js';
@@ -504,6 +505,8 @@ export async function serve(opts) {
504
505
  * asking for help rather than waiting for it.
505
506
  */
506
507
  const parked = new Map();
508
+ /** Whether the manager's person is at this pane, and the questions held for them. See presence.ts. */
509
+ const presence = createPresence();
507
510
  /** Cards just released from a park, and what released them — told to the agent, once. */
508
511
  const released = new Map();
509
512
  /** Agent names, so a comment from a PERSON can be told from agents talking among themselves. */
@@ -717,10 +720,15 @@ export async function serve(opts) {
717
720
  const cur = onCard;
718
721
  if (!cur)
719
722
  return null;
720
- return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind, ask);
723
+ // A question, from a manager, with its person at this pane: ask FlareDeck to keep it off
724
+ // their phone. FlareDeck decides whether it is theirs; a script request is never held.
725
+ const hold = isManager && !!ask && presence.mode() === 'local';
726
+ return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind, ask, undefined, hold);
721
727
  },
722
728
  unflag: async (cur) => {
723
729
  parked.delete(cur.card);
730
+ if (presence.take(cur.card))
731
+ showPresence();
724
732
  await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'clear-needs-human', {
725
733
  card_id: cur.code || cur.card,
726
734
  ...(cur.board ? { board_id: cur.board } : {}),
@@ -767,6 +775,7 @@ export async function serve(opts) {
767
775
  const row = (state.approvals ?? []).find((a) => a.requestId === requestId);
768
776
  if (!row || row.stage !== 'ready')
769
777
  return;
778
+ presence.touch();
770
779
  answeredHere.set(requestId, verdict);
771
780
  row.stage = 'answered';
772
781
  row.verdict = verdict;
@@ -830,6 +839,17 @@ export async function serve(opts) {
830
839
  let isLead = false;
831
840
  /** A manager: above the leads, with no lead of its own. See MANAGER_BRIEF. */
832
841
  let isManager = false;
842
+ /** The mode and the held questions, as the pane draws them. */
843
+ const showPresence = () => {
844
+ if (!isManager) {
845
+ delete state.presence;
846
+ delete state.asks;
847
+ return;
848
+ }
849
+ state.presence = presence.mode();
850
+ const until = presence.deadline();
851
+ state.asks = presence.held().map((a) => ({ card: a.code || a.card, question: a.question, options: a.options, person: a.person, ...(until ? { until } : {}) }));
852
+ };
833
853
  const keepTool = (name) => offeredTo(isManager ? 'manager' : isLead ? 'lead' : 'worker', name);
834
854
  /** Cards already handed to the lead, so a retried tool call does not assign or message twice. */
835
855
  const handedBack = new Set();
@@ -859,9 +879,12 @@ export async function serve(opts) {
859
879
  // The role exists in FlareDeck before the harness has a manager mode (docs/spec-manager-role.md
860
880
  // §14). Said plainly, so nobody reads a manager account running here as a manager at work.
861
881
  isManager = team.you.role === 'manager';
882
+ showPresence();
862
883
  if (isManager) {
863
884
  log('event', 'You are a manager here — list-agents shows the teams that report to you and the machines they run on; machine-exec reaches those machines.');
864
- log('warn', 'Manager mode is partial: the approval, heads-up and escalation steps are not built yet, so nothing here stops a promote or a delete. See docs/spec-manager-role.md §14.');
885
+ log('event', 'Your team\'s technical, access and information questions come to you first; what you cannot settle goes to the person the board names.');
886
+ log('event', 'At this pane? Type /local and questions for you are shown here instead of on your phone (back to /remote after ten minutes unanswered).');
887
+ log('warn', 'Not enforced yet: approvals and heads-ups. Nothing here stops a promote, a merge or a delete — those limits are instructions for now. See docs/spec-manager-role.md §14.');
865
888
  }
866
889
  selfId = team.you.agentId;
867
890
  teamIds = new Set(team.agents.filter((a) => a.reportsTo === team.you.agentId).map((a) => a.agentId));
@@ -1259,12 +1282,87 @@ export async function serve(opts) {
1259
1282
  });
1260
1283
  });
1261
1284
  };
1285
+ /** Nobody answered here: the held question goes to the phone, as it stands on the card. */
1286
+ const sendOn = async (a) => {
1287
+ const label = a.code || a.card;
1288
+ try {
1289
+ const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'set-needs-human', {
1290
+ card_id: a.code || a.card, ...(a.board ? { board_id: a.board } : {}), push_held: true,
1291
+ });
1292
+ log('warn', `${label}: ${out.split('\n').find((l) => l.startsWith('**Told**'))?.replace(/\*\*/g, '') ?? out.split('\n')[0]}`);
1293
+ }
1294
+ catch (e) {
1295
+ log('warn', `${label}: could not send the question on to ${a.person}'s phone (${e instanceof Error ? e.message : String(e)}) — it is still on the card`);
1296
+ }
1297
+ };
1298
+ const setMode = (mode) => {
1299
+ if (!isManager) {
1300
+ log('warn', '/local and /remote are for a manager\'s pane: they decide where questions for the person the manager reports to are shown.');
1301
+ return;
1302
+ }
1303
+ presence.set(mode);
1304
+ if (mode === 'local') {
1305
+ log('you', 'local — questions for you are shown here and kept off your phone. One left unanswered for ten minutes goes to your phone, and this goes back to remote.');
1306
+ }
1307
+ else {
1308
+ const waiting = presence.release();
1309
+ log('you', `remote — questions go to FlareDeck and your phone${waiting.length ? `, including the ${waiting.length} waiting here` : ''}.`);
1310
+ for (const a of waiting)
1311
+ void sendOn(a);
1312
+ }
1313
+ showPresence();
1314
+ };
1315
+ /**
1316
+ * Answered at the pane. Written on the card first — an answer that exists only in a terminal is
1317
+ * one nobody else can find — then the flag comes off and the card goes back to the agent.
1318
+ */
1319
+ const answerHeld = async (a, answer) => {
1320
+ presence.take(a.card);
1321
+ showPresence();
1322
+ const label = a.code || a.card;
1323
+ log('you', `${label}: ${answer}`);
1324
+ const ref = { card_id: a.code || a.card, ...(a.board ? { board_id: a.board } : {}) };
1325
+ try {
1326
+ await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'create-card-comment', {
1327
+ ...ref, comment: `**${a.person} answered at ${state.agent}'s pane.**\n\n> ${a.question}\n\n${answer}`,
1328
+ });
1329
+ await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'clear-needs-human', ref);
1330
+ }
1331
+ catch (e) {
1332
+ log('warn', `${label}: the answer could not be written to the card (${e instanceof Error ? e.message : String(e)}) — the agent still gets it`);
1333
+ }
1334
+ parked.delete(a.card);
1335
+ resumeParked({ card: a.card, ...(a.board ? { board: a.board } : {}), ...(a.code ? { code: a.code } : {}) }, `${a.person} answered your question at this pane.\n\nYou asked: ${a.question}\nThe answer: ${answer}\n\nIt is on the card as a comment and the flag is cleared. Carry on from where you stopped.`);
1336
+ };
1337
+ const presenceTimer = setInterval(() => {
1338
+ const due = presence.expire();
1339
+ if (due.length) {
1340
+ log('warn', `nobody answered here in ten minutes — ${due.length === 1 ? 'that question has' : `${due.length} questions have`} gone to your phone, and this pane is remote again. /local when you are back.`);
1341
+ for (const a of due)
1342
+ void sendOn(a);
1343
+ }
1344
+ if (due.length || state.asks?.length)
1345
+ showPresence();
1346
+ }, 15_000);
1347
+ presenceTimer.unref();
1262
1348
  const steer = (text) => {
1263
1349
  // Mid sign-in, the next line is the redirect address — for the sign-in, never the agent.
1264
1350
  if (signingIn) {
1265
1351
  signInReader.emit('line', text);
1266
1352
  return;
1267
1353
  }
1354
+ const mode = modeCommand(text);
1355
+ if (mode) {
1356
+ setMode(mode);
1357
+ return;
1358
+ }
1359
+ presence.touch();
1360
+ const held = presence.first();
1361
+ const answer = held ? answerFrom(text, held) : null;
1362
+ if (held && answer) {
1363
+ void answerHeld(held, answer);
1364
+ return;
1365
+ }
1268
1366
  if (text.startsWith('!')) {
1269
1367
  const command = text.slice(1).trim();
1270
1368
  if (command)
@@ -1679,6 +1777,10 @@ export async function serve(opts) {
1679
1777
  // is what tells FlareDeck this agent stopped. Exiting here instead left the last record
1680
1778
  // saying "idle", which reads as a live agent doing nothing rather than one that is gone.
1681
1779
  stream.close();
1780
+ // A question held for the person here must not be stranded by this pane closing.
1781
+ for (const a of presence.release())
1782
+ void sendOn(a);
1783
+ clearInterval(presenceTimer);
1682
1784
  clearInterval(heartbeat);
1683
1785
  clearInterval(sweepTimer);
1684
1786
  clearInterval(worktreeTimer);
@@ -2084,7 +2186,9 @@ export async function serve(opts) {
2084
2186
  /** The one-line ask and its answer buttons — what the person actually sees on a phone. */
2085
2187
  ask,
2086
2188
  /** Somewhere for the person to GO, which is what a sign-in request is mostly made of. */
2087
- links) => {
2189
+ links,
2190
+ /** The person this would go to may be at this pane: flag it, and keep it off their phone. */
2191
+ hold) => {
2088
2192
  const label = ref.code || ref.card || 'card';
2089
2193
  try {
2090
2194
  const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'set-needs-human', {
@@ -2095,6 +2199,7 @@ export async function serve(opts) {
2095
2199
  ...(kind ? { kind } : {}),
2096
2200
  ...(ask ? { question: ask.question, options: ask.options } : {}),
2097
2201
  ...(links?.length ? { links } : {}),
2202
+ ...(hold ? { hold: true } : {}),
2098
2203
  });
2099
2204
  if (out.trimStart().startsWith('\u274c') || out.trimStart().startsWith('\ud83d\udeab')) {
2100
2205
  log('warn', `${label}: FlareDeck did not flag this card \u2014 ${out.split('\n')[0]}`);
@@ -2102,6 +2207,16 @@ export async function serve(opts) {
2102
2207
  }
2103
2208
  // Say who was told, which the tool reports back, so the pane shows whether anyone heard it.
2104
2209
  park(ref.card, reason.split('.')[0]?.slice(0, 60) || 'an agent asked for help');
2210
+ const person = hold ? heldFor(out) : null;
2211
+ if (person && ask) {
2212
+ presence.hold({
2213
+ card: ref.card, ...(ref.board ? { board: ref.board } : {}), ...(ref.code ? { code: ref.code } : {}),
2214
+ question: ask.question, options: ask.options.map((o) => o.label), person, since: Date.now(),
2215
+ });
2216
+ showPresence();
2217
+ log('warn', `${label}: a question for ${person}, shown here and kept off their phone — ${ask.question}`);
2218
+ return `Shown to ${person} at this pane. It goes to their phone if nobody answers here within ten minutes.`;
2219
+ }
2105
2220
  const told = out.split('\n').find((l) => l.startsWith('**Told**'))?.replace(/\*\*/g, '') ?? null;
2106
2221
  log('warn', `${label}: flagged needs-human \u2014 ${reason.split('.')[0]}${told ? ` \u00b7 ${told}` : ''}`);
2107
2222
  return told;
@@ -2144,6 +2259,8 @@ export async function serve(opts) {
2144
2259
  return;
2145
2260
  }
2146
2261
  parked.delete(event.cardKey);
2262
+ if (presence.take(event.cardKey))
2263
+ showPresence();
2147
2264
  released.set(event.cardKey, byPerson ? `${event.actor} has answered` : stale ? 'it has been waiting a while' : 'an approval was decided');
2148
2265
  log('event', `${label}: ${byPerson ? `${event.actor} answered` : stale ? 'waited long enough' : 'an approval was decided'} — picking it back up`);
2149
2266
  }
@@ -3385,6 +3502,13 @@ export const MANAGER_BRIEF = '\n\nYou are a MANAGER, not a worker. Nobody above
3385
3502
  'behind. It runs under YOUR judgement, not the worker\'s guards, and every call is recorded.\n' +
3386
3503
  'Your job is to unblock: do not build a worker\'s card for it. When an agent is stuck, find out ' +
3387
3504
  'why and fix the cause, or tell its lead what you found.\n' +
3505
+ 'Your teams\' TECHNICAL, ACCESS and INFORMATION questions come to you before any person; ' +
3506
+ 'product, design and client questions go straight to the person the board names, and are not ' +
3507
+ 'yours. When one arrives, read the card and settle it if you can: look the answer up, fix the ' +
3508
+ 'machine, or answer from what the board already decided — then say so on the card and ' +
3509
+ 'clear-needs-human, so the agent carries on. If it turns out to need a person, pass it up: ' +
3510
+ 'set-needs-human on that card with the question, its options, your recommendation, and the kind ' +
3511
+ 'of question it really is. It goes to the person the board names for that kind.\n' +
3388
3512
  'NOT YET GUARDED HERE, so until it is, you do not do these without the person\'s say-so, asked ' +
3389
3513
  'for with set-needs-human naming the exact action: anything touching production; merging into a ' +
3390
3514
  'shared branch or promoting; deleting directories, checkouts or data on a shared machine; ' +