@onepointfour-packs/flaredeck-agent 0.2.112 → 0.2.114

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
@@ -8,6 +8,7 @@ import { freshGitHubToken } from './githubauth.js';
8
8
  import { answerFrom, createPresence, heldFor, modeCommand } from './presence.js';
9
9
  import { defaultUploadRoots, fileTools, flaredeckStores } from './filestore.js';
10
10
  import { scratchFor } from './cardmess.js';
11
+ import { ASSISTANT_KEY, claudeProjectDir, historyMessage, recentExchanges, latestCliSession, assistantBrief, assistantIgnores, assistantPerson, asAssistantEvent, loadAssistantSession, saveAssistantSession } from './assistant.js';
11
12
  import { codePrefix, loadPrefixes, savePrefixes, SCRATCH_SWEEP_EVERY_MS, sweepScratch } from './scratch.js';
12
13
  import { findImages, namedForAgent } from './images.js';
13
14
  import { loadBaseBranch, loadMergeConfig, preflight, preflightBrief } from './preflight.js';
@@ -35,8 +36,8 @@ import { isCheckout, newerHarness, readyOnDisk, restartDecision, updateSelf } fr
35
36
  import { cloneBoardRepos, isEmptyFolder, parseBoardRepos } from './bootstrap-repo.js';
36
37
  import { updateSidecarSdk } from './updater-python.js';
37
38
  import { managedPython, usingManagedPython } from './engine-antigravity.js';
38
- import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
39
- import { join } from 'node:path';
39
+ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
40
+ import { dirname, join } from 'node:path';
40
41
  import { configDir, loadGitHub, saveConfig, slug } from './config.js';
41
42
  import { boardArg, onMyBoards, outstandingCards } from './boards.js';
42
43
  import { browserTools, ensureBrowser } from './browser.js';
@@ -858,6 +859,13 @@ export async function serve(opts) {
858
859
  let isLead = false;
859
860
  /** A manager: above the leads, with no lead of its own. See MANAGER_BRIEF. */
860
861
  let isManager = false;
862
+ /**
863
+ * One person's own agent: on no team, never dispatched to, listening only to `person`. One long
864
+ * conversation, never handed over — the SDK compacts it — and loose rules. See ASSISTANT_BRIEF.
865
+ */
866
+ let isAssistant = false;
867
+ let person = null;
868
+ const assistantSessionFile = join(configDir(), 'assistant-sessions', `${slug(`${opts.agent ?? 'agent'}-${config.repo}`)}.json`);
861
869
  /** The mode and the held questions, as the pane draws them. */
862
870
  const showPresence = () => {
863
871
  if (!isManager) {
@@ -869,7 +877,7 @@ export async function serve(opts) {
869
877
  const until = presence.deadline();
870
878
  state.asks = presence.held().map((a) => ({ card: a.code || a.card, question: a.question, options: a.options, person: a.person, ...(until ? { until } : {}) }));
871
879
  };
872
- const keepTool = (name) => offeredTo(isManager ? 'manager' : isLead ? 'lead' : 'worker', name);
880
+ const keepTool = (name) => offeredTo(isManager || isAssistant ? 'manager' : isLead ? 'lead' : 'worker', name);
873
881
  /** Cards already handed to the lead, so a retried tool call does not assign or message twice. */
874
882
  const handedBack = new Set();
875
883
  try {
@@ -898,6 +906,15 @@ export async function serve(opts) {
898
906
  // The role exists in FlareDeck before the harness has a manager mode (docs/spec-manager-role.md
899
907
  // §14). Said plainly, so nobody reads a manager account running here as a manager at work.
900
908
  isManager = team.you.role === 'manager';
909
+ isAssistant = team.you.role === 'assistant';
910
+ if (isAssistant) {
911
+ const tenant = tenantFromToken(await opts.auth());
912
+ person = tenant ? await assistantPerson(config.mcpUrl, tenant, () => opts.auth()) : null;
913
+ const kept = loadAssistantSession(assistantSessionFile);
914
+ log('event', person
915
+ ? `You are ${person.name}'s assistant — you listen only to them, in FlareDeck chat${kept ? ', carrying on your conversation from before' : ''}.`
916
+ : 'You are an assistant, but nobody is set as the person you work for — set "Works for" on this agent in FlareDeck. Until then nothing reaches you.');
917
+ }
901
918
  showPresence();
902
919
  if (isManager) {
903
920
  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.');
@@ -1452,6 +1469,8 @@ export async function serve(opts) {
1452
1469
  files: fileTools(stores, source, () => onCard ? { card: onCard.card, ...(onCard.code ? { code: onCard.code } : {}), ...(envKey ? { environment: envKey } : {}) } : null, () => defaultUploadRoots(cwd, forACard ? scratchFor(cwd) : undefined), (t) => log('tool', t)),
1453
1470
  };
1454
1471
  };
1472
+ /** The subscription each open session last ran on — a fallback for its handover. */
1473
+ const planOfSession = new Map();
1455
1474
  /** One session per card, so an agent picking a card back up remembers starting it. */
1456
1475
  const sessions = new Map();
1457
1476
  /**
@@ -1696,7 +1715,7 @@ export async function serve(opts) {
1696
1715
  await enterPause(wall);
1697
1716
  })();
1698
1717
  const sweepNow = () => {
1699
- if (paused || !sweepsEnabled || stopping)
1718
+ if (paused || !sweepsEnabled || stopping || isAssistant)
1700
1719
  return;
1701
1720
  // Busy is reason enough to wait: the next tick is minutes away and the work is not going
1702
1721
  // anywhere.
@@ -1738,6 +1757,8 @@ export async function serve(opts) {
1738
1757
  const resumedIdle = new Map();
1739
1758
  const RESUME_IDLE_LIMIT = 2;
1740
1759
  const resumeOwnWork = async () => {
1760
+ if (isAssistant)
1761
+ return false; // an assistant has no "own work" — only its person's asks
1741
1762
  /* Not `live.size`: a session kept WARM for a follow-up is a cache, not work, and blocking on
1742
1763
  it meant the card in progress was never resumed within fifteen minutes of any turn. */
1743
1764
  if (paused || !sweepsEnabled || stopping || working || queue.length)
@@ -2332,11 +2353,26 @@ export async function serve(opts) {
2332
2353
  log('event', `${event.cardCode || event.cardKey}: ${event.actor} is talking to somebody else on the card — nothing to do`);
2333
2354
  return;
2334
2355
  }
2356
+ // An assistant hears its person and nothing else, and works outside the card machinery.
2357
+ if (isAssistant) {
2358
+ const why = assistantIgnores(event, person?.uid ?? null);
2359
+ if (why) {
2360
+ log('event', `${event.cardCode || event.event}: ${why} — not for ${person?.name ?? 'anyone'}'s assistant`);
2361
+ return;
2362
+ }
2363
+ event = asAssistantEvent(event, person?.name ?? 'your person');
2364
+ // The conversation from before a restart, picked up where it was.
2365
+ if (!sessions.has(ASSISTANT_KEY)) {
2366
+ const kept = loadAssistantSession(assistantSessionFile);
2367
+ if (kept)
2368
+ sessions.set(ASSISTANT_KEY, kept);
2369
+ }
2370
+ }
2335
2371
  if (isBookkeeping(event, agentNames)) {
2336
2372
  log('event', `${event.cardCode || event.cardKey}: ${event.event === 'commented' ? 'a harness\'s own record on the card' : event.event === 'approval' ? 'an approval for a person to give' : event.event === 'updated' ? 'the card\'s record changed' : 'the card was flagged for a person'} — nothing to do`);
2337
2373
  return;
2338
2374
  }
2339
- let key = event.cardKey || (event.event === 'ask' ? 'ask' : event.event === 'sweep' ? 'sweep' : event.event === 'scheduled' ? `schedule-${event.scheduleId}` : 'inbox');
2375
+ let key = isAssistant ? ASSISTANT_KEY : event.cardKey || (event.event === 'ask' ? 'ask' : event.event === 'sweep' ? 'sweep' : event.event === 'scheduled' ? `schedule-${event.scheduleId}` : 'inbox');
2340
2376
  let turnTokens = 0;
2341
2377
  let nudged = false;
2342
2378
  // A checkout of its own per card, so it cannot edit a working tree someone else is in.
@@ -2461,7 +2497,32 @@ export async function serve(opts) {
2461
2497
  */
2462
2498
  if (event.event === 'ask' && tree)
2463
2499
  key = `ask:${tree.path}`;
2464
- const cwd = tree?.path ?? repo;
2500
+ // An assistant's very first turn, with no conversation of its own: its person's newest Claude
2501
+ // CLI session in this folder, carried on as a FORK — the history comes across, the person's
2502
+ // own session file is left as it was, and the two never write to the same place.
2503
+ const pickedUp = isAssistant && key === ASSISTANT_KEY && !sessions.get(key) ? latestCliSession(config.repo) : null;
2504
+ if (pickedUp) {
2505
+ log('event', `carrying on from your Claude session ${pickedUp.slice(0, 8)} in this folder (a copy — yours is left as it is)`);
2506
+ // So the person sees, in FlareDeck, where the conversation was: the last thirty exchanges,
2507
+ // once, as one message marked as the past.
2508
+ const posted = `${assistantSessionFile}.history`;
2509
+ if (person && !existsSync(posted)) {
2510
+ const ex = recentExchanges(join(claudeProjectDir(config.repo), `${pickedUp}.jsonl`));
2511
+ if (ex.length) {
2512
+ const name = (opts.agent ?? 'your assistant').replace(/\s*\(.*$/, '');
2513
+ await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'send-message', { to: person.uid, message: historyMessage(ex, name) })
2514
+ .then(() => { try {
2515
+ mkdirSync(dirname(posted), { recursive: true });
2516
+ writeFileSync(posted, pickedUp);
2517
+ }
2518
+ catch { /* posted; a repeat is harmless */ } log('tool', `posted the last ${ex.length} exchanges to ${person.name}'s chat`); })
2519
+ .catch((e) => log('warn', `could not post the earlier conversation to the chat: ${e instanceof Error ? e.message : String(e)}`));
2520
+ }
2521
+ }
2522
+ }
2523
+ // An assistant always works from the folder it was started in: that is where its person's own
2524
+ // Claude sessions live, and where `continue` finds the one to carry on.
2525
+ const cwd = isAssistant ? config.repo : tree?.path ?? repo;
2465
2526
  // Back on this checkout: whatever was waiting to take its environment down can stand down.
2466
2527
  turnCwd = cwd;
2467
2528
  const pendingStop = idleStops.get(cwd);
@@ -2982,6 +3043,7 @@ export async function serve(opts) {
2982
3043
  state.claudeAccount = claudePlan.label;
2983
3044
  // Measured again once this event is done — see measurePlan.
2984
3045
  turnPlan = { id: claudePlan.id, label: claudePlan.label, token: claudePlan.token };
3046
+ planOfSession.set(key, claudePlan.token);
2985
3047
  }
2986
3048
  if (claudePlan?.use === 'none') {
2987
3049
  const when = claudePlan.availableAt ? new Date(claudePlan.availableAt).toLocaleString() : 'later';
@@ -3018,7 +3080,7 @@ export async function serve(opts) {
3018
3080
  }
3019
3081
  const engineContext = {
3020
3082
  ...(leadGoal ? { goal: leadGoal } : {}),
3021
- config: { ...config, repo: cwd },
3083
+ config: { ...config, repo: cwd, ...(isAssistant ? { assistantMode: true } : {}) },
3022
3084
  // Only a real worktree confines anything; without one nothing is enforced here, and the
3023
3085
  // guard below makes sure a turn that would CHANGE code never gets that far.
3024
3086
  //
@@ -3033,7 +3095,8 @@ export async function serve(opts) {
3033
3095
  // did exactly that, in the shared checkout every worktree is cut from, on a branch of its
3034
3096
  // own. Without a worktree there is nowhere legitimate to write, so the turn gets scratch
3035
3097
  // space and has to hand the card back instead.
3036
- sandbox: tree ? tree.path : scratchDir(),
3098
+ // An assistant is not confined to a checkout: it works for one person, anywhere they need.
3099
+ ...(isAssistant ? {} : { sandbox: tree ? tree.path : scratchDir() }),
3037
3100
  decider: sessionDecider,
3038
3101
  // Both only reachable in-process, so an out-of-process engine is handed them explicitly
3039
3102
  // rather than being left unable to publish and unchecked when it says it has.
@@ -3045,6 +3108,9 @@ export async function serve(opts) {
3045
3108
  saved: savedSoFar,
3046
3109
  token: await opts.auth(),
3047
3110
  ...(resume ? { resume } : {}),
3111
+ // An assistant with no conversation of its own yet carries on its person's last Claude
3112
+ // session in this folder — the one they were working in before the assistant existed.
3113
+ ...(!resume && isAssistant && key === ASSISTANT_KEY && pickedUp ? { resume: pickedUp, forkSession: true } : {}),
3048
3114
  ...(prepared?.model ? { model: prepared.model } : {}),
3049
3115
  ...(prepared?.size ? { size: prepared.size } : {}),
3050
3116
  ...(claudePlan?.use === 'token' ? { claudeToken: claudePlan.token } : {}),
@@ -3083,6 +3149,7 @@ export async function serve(opts) {
3083
3149
  unclearBrief(prepared?.jev ?? null, leadName) + preflightBrief(checks) + backBrief(event) +
3084
3150
  freeWorkerBrief(event) + (leadWithoutCode ? LEAD_WITHOUT_CODE_BRIEF : '') +
3085
3151
  (isManager ? MANAGER_BRIEF + (event.event === 'sweep' ? MANAGER_SWEEP_BRIEF : '') : '') +
3152
+ (isAssistant ? assistantBrief(person) : '') +
3086
3153
  // Only tell an agent about the environment when the card actually needs one running.
3087
3154
  environmentBrief(env, cwd, needsRunning) + notStartedBrief(forACard && !needsRunning, cwd) +
3088
3155
  browserBrief(env.endpoints, declaredSites, forACard, web) +
@@ -3189,8 +3256,11 @@ export async function serve(opts) {
3189
3256
  void report();
3190
3257
  }
3191
3258
  else if (out.kind === 'ready') {
3192
- if (out.sessionId)
3259
+ if (out.sessionId) {
3193
3260
  sessions.set(key, out.sessionId);
3261
+ if (key === ASSISTANT_KEY)
3262
+ saveAssistantSession(assistantSessionFile, out.sessionId);
3263
+ }
3194
3264
  // The preflight proved the server answers; this proves the SESSION got it. The SDK will
3195
3265
  // start the model with a server still 'pending' or 'needs-auth' and list its tools from
3196
3266
  // memory, so a count alone is not enough — the status has to say connected. Refuse the
@@ -3262,6 +3332,10 @@ export async function serve(opts) {
3262
3332
  failedPlans.delete(event.id);
3263
3333
  if (runError)
3264
3334
  throw runError;
3335
+ // An assistant's conversation is never handed over or closed: it is one long talk with one
3336
+ // person, compacted by the SDK as it grows, and resumed after a restart from the saved id.
3337
+ if (isAssistant)
3338
+ return;
3265
3339
  // A question from the pane: no card to hand over to. Its session is kept so the next question
3266
3340
  // can build on this one, and simply dropped once it has grown too large to be worth carrying.
3267
3341
  if (event.event === 'ask') {
@@ -3370,6 +3444,17 @@ export async function serve(opts) {
3370
3444
  so the shared checkout serves. */
3371
3445
  if (!existsSync(cwd))
3372
3446
  cwd = config.repo;
3447
+ // The handover runs on a subscription FlareDeck picks, like any turn. It ran on this
3448
+ // machine's own Claude login instead, and when that account had used its week every handover
3449
+ // failed — "You've hit your weekly limit" — while the turns before it ran fine on the plan
3450
+ // they were given (Norm at 14:00, an HR Goat agent at 14:34, 14 Oct). With every plan spent
3451
+ // there is nothing to run it on; the card keeps its last note.
3452
+ const handoverPlan = await handoverSubscription(() => subscriptionsForAgent(config.mcpUrl, () => opts.auth(), (t) => log('tool', t)), planOfSession.get(key));
3453
+ if (handoverPlan === 'none') {
3454
+ log('warn', `${key}: every Claude subscription is used up — no handover note this time`);
3455
+ sessions.delete(key);
3456
+ return;
3457
+ }
3373
3458
  try {
3374
3459
  const wrapUp = engine.run((done
3375
3460
  ? [
@@ -3407,6 +3492,7 @@ export async function serve(opts) {
3407
3492
  decider: sessionDecider,
3408
3493
  token: await opts.auth(),
3409
3494
  ...(resume ? { resume } : {}),
3495
+ ...(handoverPlan ? { claudeToken: handoverPlan } : {}),
3410
3496
  tools: { team: teamSet, flaredeck: await proxyForWrapUp() },
3411
3497
  });
3412
3498
  liveRun = wrapUp;
@@ -3699,6 +3785,27 @@ export async function decideForSession(request, options, via) {
3699
3785
  const v = await via.judge(command);
3700
3786
  return v.allow ? { allow: true } : { allow: false, reason: v.reason ?? 'Not allowed.' };
3701
3787
  }
3788
+ /**
3789
+ * Which Claude token a handover runs on: a fresh pick from FlareDeck, because the plan the turn
3790
+ * used may have run out since; the turn's own plan when FlareDeck cannot be asked; 'none' when
3791
+ * every plan is spent; null to fall back to this machine's own login (no subscriptions set up).
3792
+ */
3793
+ export async function handoverSubscription(subs, lastUsed) {
3794
+ try {
3795
+ const s = await subs();
3796
+ if (!s)
3797
+ return lastUsed ?? null;
3798
+ const pick = await s.choose([]);
3799
+ if (pick.use === 'token' && pick.token)
3800
+ return pick.token;
3801
+ if (pick.use === 'none')
3802
+ return 'none';
3803
+ return lastUsed ?? null;
3804
+ }
3805
+ catch {
3806
+ return lastUsed ?? null;
3807
+ }
3808
+ }
3702
3809
  export function moveIsStale(event, laneNow) {
3703
3810
  if (event.event !== 'moved' || !event.laneName || !laneNow)
3704
3811
  return false;