@onepointfour-packs/flaredeck-agent 0.2.112 → 0.2.113

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, 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';
@@ -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,15 @@ 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
+ // An assistant always works from the folder it was started in: that is where its person's own
2507
+ // Claude sessions live, and where `continue` finds the one to carry on.
2508
+ const cwd = isAssistant ? config.repo : tree?.path ?? repo;
2465
2509
  // Back on this checkout: whatever was waiting to take its environment down can stand down.
2466
2510
  turnCwd = cwd;
2467
2511
  const pendingStop = idleStops.get(cwd);
@@ -2982,6 +3026,7 @@ export async function serve(opts) {
2982
3026
  state.claudeAccount = claudePlan.label;
2983
3027
  // Measured again once this event is done — see measurePlan.
2984
3028
  turnPlan = { id: claudePlan.id, label: claudePlan.label, token: claudePlan.token };
3029
+ planOfSession.set(key, claudePlan.token);
2985
3030
  }
2986
3031
  if (claudePlan?.use === 'none') {
2987
3032
  const when = claudePlan.availableAt ? new Date(claudePlan.availableAt).toLocaleString() : 'later';
@@ -3018,7 +3063,7 @@ export async function serve(opts) {
3018
3063
  }
3019
3064
  const engineContext = {
3020
3065
  ...(leadGoal ? { goal: leadGoal } : {}),
3021
- config: { ...config, repo: cwd },
3066
+ config: { ...config, repo: cwd, ...(isAssistant ? { assistantMode: true } : {}) },
3022
3067
  // Only a real worktree confines anything; without one nothing is enforced here, and the
3023
3068
  // guard below makes sure a turn that would CHANGE code never gets that far.
3024
3069
  //
@@ -3033,7 +3078,8 @@ export async function serve(opts) {
3033
3078
  // did exactly that, in the shared checkout every worktree is cut from, on a branch of its
3034
3079
  // own. Without a worktree there is nowhere legitimate to write, so the turn gets scratch
3035
3080
  // space and has to hand the card back instead.
3036
- sandbox: tree ? tree.path : scratchDir(),
3081
+ // An assistant is not confined to a checkout: it works for one person, anywhere they need.
3082
+ ...(isAssistant ? {} : { sandbox: tree ? tree.path : scratchDir() }),
3037
3083
  decider: sessionDecider,
3038
3084
  // Both only reachable in-process, so an out-of-process engine is handed them explicitly
3039
3085
  // rather than being left unable to publish and unchecked when it says it has.
@@ -3045,6 +3091,9 @@ export async function serve(opts) {
3045
3091
  saved: savedSoFar,
3046
3092
  token: await opts.auth(),
3047
3093
  ...(resume ? { resume } : {}),
3094
+ // An assistant with no conversation of its own yet carries on its person's last Claude
3095
+ // session in this folder — the one they were working in before the assistant existed.
3096
+ ...(!resume && isAssistant && key === ASSISTANT_KEY && pickedUp ? { resume: pickedUp, forkSession: true } : {}),
3048
3097
  ...(prepared?.model ? { model: prepared.model } : {}),
3049
3098
  ...(prepared?.size ? { size: prepared.size } : {}),
3050
3099
  ...(claudePlan?.use === 'token' ? { claudeToken: claudePlan.token } : {}),
@@ -3083,6 +3132,7 @@ export async function serve(opts) {
3083
3132
  unclearBrief(prepared?.jev ?? null, leadName) + preflightBrief(checks) + backBrief(event) +
3084
3133
  freeWorkerBrief(event) + (leadWithoutCode ? LEAD_WITHOUT_CODE_BRIEF : '') +
3085
3134
  (isManager ? MANAGER_BRIEF + (event.event === 'sweep' ? MANAGER_SWEEP_BRIEF : '') : '') +
3135
+ (isAssistant ? assistantBrief(person) : '') +
3086
3136
  // Only tell an agent about the environment when the card actually needs one running.
3087
3137
  environmentBrief(env, cwd, needsRunning) + notStartedBrief(forACard && !needsRunning, cwd) +
3088
3138
  browserBrief(env.endpoints, declaredSites, forACard, web) +
@@ -3189,8 +3239,11 @@ export async function serve(opts) {
3189
3239
  void report();
3190
3240
  }
3191
3241
  else if (out.kind === 'ready') {
3192
- if (out.sessionId)
3242
+ if (out.sessionId) {
3193
3243
  sessions.set(key, out.sessionId);
3244
+ if (key === ASSISTANT_KEY)
3245
+ saveAssistantSession(assistantSessionFile, out.sessionId);
3246
+ }
3194
3247
  // The preflight proved the server answers; this proves the SESSION got it. The SDK will
3195
3248
  // start the model with a server still 'pending' or 'needs-auth' and list its tools from
3196
3249
  // memory, so a count alone is not enough — the status has to say connected. Refuse the
@@ -3262,6 +3315,10 @@ export async function serve(opts) {
3262
3315
  failedPlans.delete(event.id);
3263
3316
  if (runError)
3264
3317
  throw runError;
3318
+ // An assistant's conversation is never handed over or closed: it is one long talk with one
3319
+ // person, compacted by the SDK as it grows, and resumed after a restart from the saved id.
3320
+ if (isAssistant)
3321
+ return;
3265
3322
  // A question from the pane: no card to hand over to. Its session is kept so the next question
3266
3323
  // can build on this one, and simply dropped once it has grown too large to be worth carrying.
3267
3324
  if (event.event === 'ask') {
@@ -3370,6 +3427,17 @@ export async function serve(opts) {
3370
3427
  so the shared checkout serves. */
3371
3428
  if (!existsSync(cwd))
3372
3429
  cwd = config.repo;
3430
+ // The handover runs on a subscription FlareDeck picks, like any turn. It ran on this
3431
+ // machine's own Claude login instead, and when that account had used its week every handover
3432
+ // failed — "You've hit your weekly limit" — while the turns before it ran fine on the plan
3433
+ // they were given (Norm at 14:00, an HR Goat agent at 14:34, 14 Oct). With every plan spent
3434
+ // there is nothing to run it on; the card keeps its last note.
3435
+ const handoverPlan = await handoverSubscription(() => subscriptionsForAgent(config.mcpUrl, () => opts.auth(), (t) => log('tool', t)), planOfSession.get(key));
3436
+ if (handoverPlan === 'none') {
3437
+ log('warn', `${key}: every Claude subscription is used up — no handover note this time`);
3438
+ sessions.delete(key);
3439
+ return;
3440
+ }
3373
3441
  try {
3374
3442
  const wrapUp = engine.run((done
3375
3443
  ? [
@@ -3407,6 +3475,7 @@ export async function serve(opts) {
3407
3475
  decider: sessionDecider,
3408
3476
  token: await opts.auth(),
3409
3477
  ...(resume ? { resume } : {}),
3478
+ ...(handoverPlan ? { claudeToken: handoverPlan } : {}),
3410
3479
  tools: { team: teamSet, flaredeck: await proxyForWrapUp() },
3411
3480
  });
3412
3481
  liveRun = wrapUp;
@@ -3699,6 +3768,27 @@ export async function decideForSession(request, options, via) {
3699
3768
  const v = await via.judge(command);
3700
3769
  return v.allow ? { allow: true } : { allow: false, reason: v.reason ?? 'Not allowed.' };
3701
3770
  }
3771
+ /**
3772
+ * Which Claude token a handover runs on: a fresh pick from FlareDeck, because the plan the turn
3773
+ * used may have run out since; the turn's own plan when FlareDeck cannot be asked; 'none' when
3774
+ * every plan is spent; null to fall back to this machine's own login (no subscriptions set up).
3775
+ */
3776
+ export async function handoverSubscription(subs, lastUsed) {
3777
+ try {
3778
+ const s = await subs();
3779
+ if (!s)
3780
+ return lastUsed ?? null;
3781
+ const pick = await s.choose([]);
3782
+ if (pick.use === 'token' && pick.token)
3783
+ return pick.token;
3784
+ if (pick.use === 'none')
3785
+ return 'none';
3786
+ return lastUsed ?? null;
3787
+ }
3788
+ catch {
3789
+ return lastUsed ?? null;
3790
+ }
3791
+ }
3702
3792
  export function moveIsStale(event, laneNow) {
3703
3793
  if (event.event !== 'moved' || !event.laneName || !laneNow)
3704
3794
  return false;