flowviant 0.117.0 → 0.124.0

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.
Files changed (64) hide show
  1. package/README.md +35 -0
  2. package/bin/cli.mjs +37 -0
  3. package/bin/lib/agentAsk.mjs +123 -0
  4. package/bin/lib/agentBrief.mjs +36 -0
  5. package/bin/lib/agentFootprint.mjs +76 -0
  6. package/bin/lib/agentInternalWait.mjs +2 -2
  7. package/bin/lib/agentPlan.mjs +78 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentTaskKinds.mjs +1 -1
  10. package/bin/lib/artifactContracts.mjs +91 -20
  11. package/bin/lib/artifactIteration.mjs +182 -0
  12. package/bin/lib/artifactRecovery.mjs +36 -0
  13. package/bin/lib/artifactReporter.mjs +183 -19
  14. package/bin/lib/artifacts.mjs +6 -3
  15. package/bin/lib/claudeAuth.mjs +15 -5
  16. package/bin/lib/claudePosture.mjs +110 -14
  17. package/bin/lib/claudeStream.mjs +20 -2
  18. package/bin/lib/codexProjectRead.mjs +63 -0
  19. package/bin/lib/codexResearch.mjs +16 -3
  20. package/bin/lib/commandSpecs.mjs +6 -0
  21. package/bin/lib/daemonLocks.mjs +5 -3
  22. package/bin/lib/desktopContract.mjs +3 -0
  23. package/bin/lib/discoveryContract.mjs +19 -0
  24. package/bin/lib/fleet.mjs +46 -4
  25. package/bin/lib/fleetReports.mjs +44 -3
  26. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  27. package/bin/lib/hooks/readGuard.mjs +13 -4
  28. package/bin/lib/imageArtifact.mjs +74 -0
  29. package/bin/lib/maintenance.mjs +47 -0
  30. package/bin/lib/maintenanceInventory.mjs +39 -0
  31. package/bin/lib/mcpInstall.mjs +163 -0
  32. package/bin/lib/mcpInventory.mjs +119 -0
  33. package/bin/lib/planDependencies.mjs +70 -0
  34. package/bin/lib/postureTools.mjs +85 -0
  35. package/bin/lib/projectToolRuntimes.mjs +227 -3
  36. package/bin/lib/projectTools.mjs +61 -4
  37. package/bin/lib/prompts.mjs +164 -41
  38. package/bin/lib/repositoryBaseline.mjs +68 -0
  39. package/bin/lib/runTurn.mjs +35 -16
  40. package/bin/lib/runtimeCapabilities.mjs +77 -3
  41. package/bin/lib/runtimeClaude.mjs +27 -9
  42. package/bin/lib/runtimeCodex.mjs +27 -59
  43. package/bin/lib/runtimeDetection.mjs +6 -0
  44. package/bin/lib/runtimeEvents.mjs +12 -1
  45. package/bin/lib/runtimeOfferings.mjs +75 -3
  46. package/bin/lib/turnDiagnostics.mjs +93 -0
  47. package/bin/lib/turnProfile.mjs +15 -3
  48. package/bin/lib/work.mjs +28 -7
  49. package/bin/lib/workAgentBrief.mjs +92 -0
  50. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  51. package/bin/lib/workAgentPlans.mjs +21 -70
  52. package/bin/lib/workAgentPrecheck.mjs +5 -0
  53. package/bin/lib/workAgentTurnExecution.mjs +93 -22
  54. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  55. package/bin/lib/workAgentTurns.mjs +60 -4
  56. package/bin/lib/workArtifactReconcile.mjs +79 -0
  57. package/bin/lib/workCappedTurn.mjs +10 -0
  58. package/bin/lib/workCommandAudit.mjs +8 -1
  59. package/bin/lib/workPlaces.mjs +18 -2
  60. package/bin/lib/workSessionTurns.mjs +148 -13
  61. package/bin/lib/workToolLog.mjs +70 -3
  62. package/bin/lib/workTurnShape.mjs +6 -4
  63. package/npm-shrinkwrap.json +12 -2
  64. package/package.json +7 -3
@@ -23,6 +23,7 @@ import { gitOrNull } from './git.mjs';
23
23
  import { stashCard } from './agentCards.mjs';
24
24
  import { c } from './ui.mjs';
25
25
  import { runTurn } from './runTurn.mjs';
26
+ import { killGroup } from './processGroup.mjs';
26
27
  import { SYSTEM_AGENT_FOR, AGENT_TASK_KICKOFF, AGENT_TASK_SPEC, AGENT_HUMAN_KICKOFF, withProjectContext } from './prompts.mjs';
27
28
  import { AGENT_TASK_KINDS, agentTaskKindOf, unknownAgentTaskKind } from './agentTaskKinds.mjs';
28
29
  import { knowledgeDirFor } from './knowledgeFiles.mjs';
@@ -30,7 +31,9 @@ import { scrub as envScrub } from './uplinkScrub.mjs';
30
31
  import { canRun, runtimeById } from './runtimes.mjs';
31
32
  import { toolEventOf, CLAUDE_TOOL_PROSE_KINDS } from './runtimeEvents.mjs';
32
33
  import { makeTraceRelay } from './trace.mjs';
33
- import { readBaseTools, prepareAgentTools } from './projectTools.mjs';
34
+ import { mcpJsonServerNamesAbove, readBaseTools, prepareAgentTools } from './projectTools.mjs';
35
+ import { TURN_PROFILES } from './turnProfile.mjs';
36
+ import { postureToolsNote } from './postureTools.mjs';
34
37
  import { runTurnResumingOnce } from './resumeLost.mjs';
35
38
  import { INTERNAL_WAIT_CONTRACT, runAgentWithInternalWaits } from './agentInternalWait.mjs';
36
39
  import { CODEX_THREAD_RE } from './workSessionRuntime.mjs';
@@ -41,6 +44,7 @@ import { ensureArtifactDir, ensureFenceScratch } from './artifacts.mjs';
41
44
  import { agentBrowserHome } from './noWindowEnv.mjs';
42
45
 
43
46
  export function createAgentTurnExecution({
47
+ makeBrief,
44
48
  baseRef,
45
49
  baseDir,
46
50
  repoRoot,
@@ -107,7 +111,9 @@ export function createAgentTurnExecution({
107
111
  * `releaseSlot` hands back the admission reservation the lane took on this
108
112
  * turn's behalf, at the moment the CLI actually exists. Idempotent.
109
113
  */
110
- const runAgentTurnInPlace = async (job, { turnId, agentId, place, releaseSlot, postAgentTurn, measured }) => {
114
+ /** `run` is the lane's record of this turn (`interrupted`, set by
115
+ * `interruptAgentTurns`); absent, nothing can interrupt it. */
116
+ const runAgentTurnInPlace = async (job, { turnId, agentId, place, releaseSlot, postAgentTurn, measured, run = { interrupted: false } }) => {
111
117
  // WORK THAT HAS BEGUN LIVES ON EXACTLY ONE BOX — read BEFORE anything is
112
118
  // cut, because `placeWtFor` would cut a rival branch of the same name. The
113
119
  // rule and its three-state branch measurement: workAgentTurnBegun.mjs.
@@ -375,8 +381,8 @@ export function createAgentTurnExecution({
375
381
  /**
376
382
  * Prose the structured event will carry anyway, dropped so a read does
377
383
  * not render twice — but ONLY on a runtime whose stream reaches
378
- * `onToolEvent` at all. Codex and agy have their own parsers and never
379
- * call it, so dropping their tool prose would blank their agents' traces.
384
+ * `onToolEvent` here. Codex uses completed activity; agy has its own
385
+ * parser. Dropping their tool prose would blank their agents' traces.
380
386
  * `parse: null` is exactly the claudeStream.mjs stream path. See
381
387
  * CLAUDE_TOOL_PROSE_KINDS.
382
388
  */
@@ -408,6 +414,9 @@ export function createAgentTurnExecution({
408
414
  * ever, which holds the self-update and the tray's install for ever.
409
415
  */
410
416
  const spawnedChildren = new Set();
417
+ /** The CLI, unless a person already asked to stop this turn (Interrupt
418
+ * now): then nothing starts — no fresh retry, no continuation. */
419
+ const runCli = (args) => (run.interrupted ? Promise.resolve('') : runTurn(args));
411
420
  /**
412
421
  * WHAT THIS TURN SPENT, AS THE CLI COUNTED IT (2026-09-19).
413
422
  *
@@ -426,6 +435,8 @@ export function createAgentTurnExecution({
426
435
  /** THE MODEL THE TURN RAN ON, as its CLI named it (turnModel.mjs) — rides
427
436
  * every settle `usage` rides, for the same reason; null sends no key. */
428
437
  let model = null;
438
+ const brief = makeBrief?.({ agentId, turnId, runtime: rt, model: () => model ?? job.model });
439
+ brief?.append('prompt', job.task ? { title: job.task.title, message: job.body, discussion: job.cardThread, brief: job.task.brief, criteria: job.task.criteria } : job.body);
429
440
  /**
430
441
  * THE AGENT'S LAST MESSAGE, ALONE — where the final JSON object lives.
431
442
  * Codex's `out` is every message plus stderr plus error text, and
@@ -437,11 +448,32 @@ export function createAgentTurnExecution({
437
448
  /** The agent's artifact directory as it stood before this turn — the
438
449
  * tab lane's rule, in the agent's own worktree (2026-09-22). */
439
450
  const artifactsBefore = beforeArtifacts(wt);
451
+ const artifactInstructions = getArtifactsAccepted()
452
+ ? await artifacts.prepare?.({ placeDir: wt, before: artifactsBefore, agentId }) ?? '' : '';
440
453
  let projectTools = null;
441
454
  try {
442
455
  // A turn reads the base ref afresh. Changes the agent has made to its
443
456
  // own worktree cannot silently change the next turn's tools.
444
457
  projectTools = prepareAgentTools(readBaseTools(repoRoot, baseRef()), rt, process.env, wt, { codexHome: codexAgentHome });
458
+ const mountsProjectMcp = TURN_PROFILES[posture]?.projectMcp === true && (rt === 'claude' || rt === 'codex');
459
+ // A fenced posture on a runner that ignores its own file there (Codex's
460
+ // `--ignore-user-config`) takes the servers as `fencedMount` instead.
461
+ const fencedMount = mountsProjectMcp && posture !== 'build' ? projectTools.fencedMount : null;
462
+ // THE MACHINE'S OWN SERVERS ON CLAUDE (2026-10-11, the owner): the turn
463
+ // is not strict, and every `.mcp.json` name from the worktree up is
464
+ // disabled so only the base copy speaks for the project. Null (an
465
+ // unboundable walk) mounts strictly: the base copy alone.
466
+ const claudeDisabled = mountsProjectMcp && rt === 'claude' ? mcpJsonServerNamesAbove(wt) : null;
467
+ // What this card's posture withholds, said to the agent — with the
468
+ // servers it did and did not mount, and why — so it relays the fence
469
+ // instead of inventing a setting (postureTools.mjs). Empty for a code card.
470
+ const toolsNote = postureToolsNote({
471
+ posture,
472
+ runtime: rt,
473
+ mounted: !mountsProjectMcp ? [] : fencedMount ? fencedMount.mounted : projectTools.mcpServers,
474
+ machine: mountsProjectMcp && (rt === 'codex' || claudeDisabled != null),
475
+ unmounted: [...projectTools.mcpUnmounted.map((u) => ({ ...u, source: 'project' })), ...(fencedMount ? fencedMount.unmounted : [])],
476
+ });
445
477
  const agentTurnArgs = {
446
478
  prompt:
447
479
  job.kind === 'task' && job.task
@@ -451,6 +483,8 @@ export function createAgentTurnExecution({
451
483
  // The card's discussion (0.106.0), printed inside THE CARD by
452
484
  // the same builder the stash above wrote down — and the files
453
485
  // on it (0.112.0), after it, the same way.
486
+ resumeBody: job.body,
487
+ investigationContext: job.investigationContext,
454
488
  cardThread: job.cardThread,
455
489
  cardFiles: files.card,
456
490
  position: job.position ?? 1,
@@ -458,7 +492,10 @@ export function createAgentTurnExecution({
458
492
  })
459
493
  : AGENT_HUMAN_KICKOFF({
460
494
  agentName: job.agentName,
495
+ // INTERRUPT NOW: the previous run was stopped to send this.
496
+ interrupted: job.interrupted === true,
461
497
  message: job.body ?? '',
498
+ investigationContext: job.investigationContext,
462
499
  askedByName: job.askedByName,
463
500
  task: job.task,
464
501
  // The discussion on the one card this message is about, in a
@@ -476,26 +513,28 @@ export function createAgentTurnExecution({
476
513
  // different things about the same directory.
477
514
  // Spoken to the CLI that runs it (0.115.0): a mockup on Codex is
478
515
  // told it is Codex, never Claude.
479
- system: withProjectContext(SYSTEM_AGENT_FOR(taskKind, runtimeById(rt)?.label), {
516
+ system: withProjectContext(SYSTEM_AGENT_FOR(taskKind, runtimeById(rt)?.label, { prerequisitesAccepted: job.prerequisitesAccepted === true }), {
480
517
  knowledgeDir: knowledgeDirFor(repoRoot),
481
518
  // ARTIFACTS (0.94.0), while the server can show one — an agent's
482
519
  // land on its page, under the facts row.
483
520
  artifacts: getArtifactsAccepted(),
484
- }) + `\n\n${INTERNAL_WAIT_CONTRACT}` + (projectTools.instructions ? `\n\n${projectTools.instructions}` : ''),
521
+ }) + (toolsNote ? `\n\n${toolsNote}` : '') + (artifactInstructions ? `\n\n${artifactInstructions}` : '') + `\n\n${INTERNAL_WAIT_CONTRACT}` + (projectTools.instructions ? `\n\n${projectTools.instructions}` : ''),
485
522
  agentTools: projectTools,
486
- ...(projectTools.codexHome ? { mcpEnv: { CODEX_HOME: projectTools.codexHome } } : {}),
523
+ ...((projectTools.codexHome || fencedMount) ? { mcpEnv: { ...(projectTools.codexHome ? { CODEX_HOME: projectTools.codexHome } : {}), ...(fencedMount?.env ?? {}) } } : {}),
487
524
  // NOBODY IS AT THIS SCREEN (2026-09-29): no display, BROWSER=none, and
488
525
  // a browser profile of the agent's own, kept beside its Codex home —
489
526
  // so a Chrome it starts to look at its page never reaches the
490
527
  // person's (noWindowEnv.mjs).
491
528
  browserHome: agentBrowserHome(sessionMetaPath, wt, agentId),
492
- // The base branch's MCP servers, for a code card: Claude's in this
493
- // turn's own 0600 file, strictly; Codex's already in its private
494
- // home's config.toml (projectToolRuntimes.mjs, 2026-10-10), which
495
- // its fenced postures ignore — never `-c` pairs on argv.
496
- ...(posture === 'build' && rt === 'claude'
497
- ? { mcpArgs: ['--strict-mcp-config', '--mcp-config', projectTools.mcpPath] }
498
- : {}),
529
+ // Reviewed project servers and the machine's configured servers, on
530
+ // initial cards, answers and review iterations alike.
531
+ ...(mountsProjectMcp && rt === 'claude'
532
+ ? {
533
+ mcpArgs: [...(claudeDisabled ? [] : ['--strict-mcp-config']), '--mcp-config', projectTools.mcpPath],
534
+ projectMcpServers: projectTools.mcpServers,
535
+ ...(claudeDisabled ? { machineMcp: claudeDisabled } : {}),
536
+ }
537
+ : fencedMount ? { mcpArgs: fencedMount.args } : {}),
499
538
  knowledgeDir: knowledgeDirFor(repoRoot),
500
539
  // The kind's posture, as the one turn profile (turnProfile.mjs):
501
540
  // build for code, the fenced design/research postures otherwise.
@@ -518,6 +557,7 @@ export function createAgentTurnExecution({
518
557
  // narrator keeps: overwritten, never appended, and ignored by the
519
558
  // board past ~90 seconds.
520
559
  onActivity: (a) => {
560
+ brief?.append(a?.kind ?? 'activity', a?.full ?? a?.label);
521
561
  const line = a?.label;
522
562
  if (!line) return;
523
563
  // THE TRACE TAKES EVERY LINE; the pulse takes one every two
@@ -543,7 +583,13 @@ export function createAgentTurnExecution({
543
583
  // Workbench's tool cards are built from, scrubbed at collection by
544
584
  // the builder itself (bounded window BEFORE its caps; see
545
585
  // toolEventOf). A tool it does not know pushes nothing.
546
- onToolEvent: (name, input) => {
586
+ onToolEvent: (name, input, meta) => {
587
+ brief?.append(meta?.result ? 'tool result' : 'tool call', meta?.result
588
+ ? { id: meta.id, exitCode: meta.exitCode, isError: meta.isError, output: meta.output }
589
+ : { name, input });
590
+ // Codex request/result pairs feed capture/planner evidence. This
591
+ // append-only agent trace already relays its completed activity.
592
+ if (meta?.runtime === 'codex') return;
547
593
  trace.tool(toolEventOf(name, input, wt, envScrub));
548
594
  },
549
595
  // The CLI's own per-turn token counts, off the `result` event. One
@@ -566,6 +612,8 @@ export function createAgentTurnExecution({
566
612
  // (runTurn.mjs, `initHarvestFor`). An agent-only box learns from the
567
613
  // startup probe and its Terminal tabs.
568
614
  onSpawn: (ch) => {
615
+ brief?.running(true);
616
+ ch.once('close', () => { if (child === ch) brief?.running(false); });
569
617
  child = ch;
570
618
  spawnedChildren.add(ch);
571
619
  // The AGENT it serves — what the machine snapshot charges this
@@ -583,6 +631,8 @@ export function createAgentTurnExecution({
583
631
  // and a place id IS one. It is what lets a hard stop actually reach
584
632
  // the CLI — see `retireWorkSessions`.
585
633
  agentChildren.set(place, ch);
634
+ // Asked to stop between the check and the spawn.
635
+ if (run.interrupted) killGroup(ch);
586
636
  },
587
637
  };
588
638
  /**
@@ -597,7 +647,7 @@ export function createAgentTurnExecution({
597
647
  * MENTIONS a missing session is never run twice.
598
648
  */
599
649
  out = await runAgentWithInternalWaits(
600
- (args, continuing) => continuing ? runTurn(args) : runTurnResumingOnce(runTurn, args, {
650
+ (args, continuing) => continuing ? runCli(args) : runTurnResumingOnce(runCli, args, {
601
651
  // A Codex write-up always runs fresh over its copy (codexResearch
602
652
  // .mjs), so there is no lost conversation to retry it for.
603
653
  resume: rt === 'codex' && posture === 'research' ? false : args.resume,
@@ -615,6 +665,7 @@ export function createAgentTurnExecution({
615
665
  } }
616
666
  );
617
667
  } finally {
668
+ await brief?.stop();
618
669
  // What the CLI counted, for the lane's catch if anything below throws.
619
670
  measured.usage = usage;
620
671
  measured.model = model;
@@ -664,16 +715,36 @@ export function createAgentTurnExecution({
664
715
  * floor: it must never delay the settle behind it.
665
716
  */
666
717
  void reportSessionWorktree(place).catch(() => {});
667
- // …and what it drew to SHOW the person (2026-09-22): uploaded against
668
- // the AGENT, so it lands on the agent's page whoever is looking.
669
- // Fire-and-forget for the same reason — never delay the settle.
670
- void artifacts
671
- .report({ placeDir: wt, before: artifactsBefore, agentId, turnId })
672
- .catch(() => {});
718
+ // A file card delivers uploaded bytes, not an in-memory retry. Otherwise
719
+ // its dependent card can start while Review has nothing to show.
720
+ await artifacts.report({ placeDir: wt, before: artifactsBefore, agentId, turnId, requireStored: kind.artifact != null });
673
721
  }
674
722
 
675
723
 
676
724
  const commits = commitsBetween(wt, before);
725
+ /**
726
+ * A PERSON INTERRUPTED IT (Interrupt now). Whatever the CLI printed was
727
+ * cut off, so there is no outcome to read: the turn reports `interrupted`
728
+ * with what it measured — the commits it made, its spend, the branch —
729
+ * and leaves the worktree exactly as the stopped run left it. The message
730
+ * sent to interrupt it runs next, here, resuming this conversation. No
731
+ * limit, no park and no review entry: none of that happened.
732
+ */
733
+ if (run.interrupted) {
734
+ measured.ranCli = spawnedChildren.size > 0;
735
+ await postAgentTurn({
736
+ turnId,
737
+ outcome: 'nothing',
738
+ interrupted: true,
739
+ ran: spawnedChildren.size > 0,
740
+ commits,
741
+ ...(usage ? { usage } : {}),
742
+ ...(model ? { model } : {}),
743
+ ...(branch ? { branch } : {}),
744
+ worktree: wt,
745
+ });
746
+ return;
747
+ }
677
748
  // WHAT THE TURN REPORTS is one decision, off what was measured here
678
749
  // (workAgentTurnOutcome.mjs). The side effects it implies stay on this
679
750
  // side: the machine's limit state, the park, and the review-entry beat.
@@ -25,6 +25,7 @@ import { limitLine } from './cliLimit.mjs';
25
25
  import { parseTurnResult } from './agentPlan.mjs';
26
26
  import { changedArtifacts, scanArtifacts } from './artifacts.mjs';
27
27
  import { scrub as envScrub } from './uplinkScrub.mjs';
28
+ import { usableImageArtifact } from './imageArtifact.mjs';
28
29
 
29
30
  export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage, model = null, commits, artifactsBefore, branch, wt }) {
30
31
  const res = (lastAnswer && parseTurnResult(lastAnswer)) || parseTurnResult(out);
@@ -110,7 +111,8 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
110
111
  */
111
112
  if (res.outcome === 'delivered' && kind.artifact) {
112
113
  const want = kind.artifact.match;
113
- const has = (l) => l.some((e) => want.test(e.name));
114
+ const has = (l) => l.some((e) => want.test(e.name)
115
+ && (kind.posture !== 'image' || usableImageArtifact(e)));
114
116
  const standing = scanArtifacts(wt);
115
117
  const wrote = has(changedArtifacts(artifactsBefore, standing));
116
118
  const present = wrote || ((job.kind !== 'task' || job.redo === true) && has(standing));
@@ -126,11 +128,18 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
126
128
  });
127
129
  }
128
130
  }
131
+ const prerequisite = res.prerequisite;
132
+ const cannotRequest = prerequisite && job.prerequisitesAccepted !== true
133
+ ? 'This server does not support prerequisite-card requests yet. This work has not been delivered.'
134
+ : prerequisite?.taskKind === 'image' && (job.runtime ?? 'claude') !== 'codex'
135
+ ? 'This agent runs on a CLI that cannot generate images. Image cards require Codex’s own image generation; this agent cannot run the requested texture work.'
136
+ : null;
129
137
  return report(
130
138
  {
131
139
  turnId,
140
+ ...(prerequisite && !cannotRequest ? { prerequisite } : {}),
132
141
  outcome: res.outcome,
133
- answer: envScrub(res.answer ?? '').slice(0, 8000),
142
+ answer: cannotRequest ?? envScrub(res.answer ?? '').slice(0, 8000),
134
143
  ...(commits.length ? { commits } : {}),
135
144
  // SCRUBBED like the answer beside it: a raised card lands in the
136
145
  // project doc every member reads, and a brief quoting the `.env` the
@@ -25,6 +25,8 @@ import { c, note } from './ui.mjs';
25
25
  import { createAgentTurnReports } from './workAgentTurnReports.mjs';
26
26
  import { createAgentTurnExecution } from './workAgentTurnExecution.mjs';
27
27
  import { failedTurnSettlement } from './workAgentTurnOutcome.mjs';
28
+ import { killGroup } from './processGroup.mjs';
29
+ import { createAgentBriefRunner } from './workAgentBriefRunner.mjs';
28
30
 
29
31
  export function createWorkAgentTurns({
30
32
  REJECT_RETRY_MS,
@@ -48,6 +50,7 @@ export function createWorkAgentTurns({
48
50
  agentRemoteAt,
49
51
  /** The turn's files, fetched into its worktree (workAttachments.mjs, 0.112.0). */
50
52
  fetchAgentFiles,
53
+ makeBrief = createAgentBriefRunner({ admit, workChildren }),
51
54
  }) {
52
55
  const {
53
56
  postAgentTurn,
@@ -91,8 +94,23 @@ export function createWorkAgentTurns({
91
94
  * directory names, and those are places.
92
95
  */
93
96
  const agentChildren = new Map(); // placeId -> child process
97
+ /**
98
+ * INTERRUPT NOW (2026-10-13) — each turn this lane has taken: its place and
99
+ * whether a person asked to stop it. A stop kills the CLI's PROCESS GROUP
100
+ * (it is spawned detached, so the commands it started go with it); a turn
101
+ * asked to stop before it spawned never spawns.
102
+ */
103
+ const agentRuns = new Map(); // turnId -> { place, interrupted }
104
+ /** Turns answered `ran: false` here, so a roster read made before that
105
+ * settle landed is not answered again at once. */
106
+ const notRunningSaid = new Map(); // turnId -> when
107
+ /** Agents whose run this lane just stopped: the next turn is the one sent to
108
+ * interrupt it, and is told so even when the roster that handed it over was
109
+ * read before the stop's settle landed. Taken once, for a little while. */
110
+ const stoppedFor = new Map(); // agentId -> when
94
111
 
95
112
  const { runAgentTurnInPlace, commitsBetween } = createAgentTurnExecution({
113
+ makeBrief,
96
114
  baseRef,
97
115
  baseDir,
98
116
  repoRoot,
@@ -172,12 +190,18 @@ export function createWorkAgentTurns({
172
190
  // A WRITER on the agent's own place — see the lane header. Only `a-<id>`
173
191
  // places: those are agents' by construction, and anything else here would
174
192
  // be a tab's directory, where a turn is a reader by the product's own law.
193
+ const run = agentRuns.get(turnId) ?? { place, interrupted: false };
175
194
  await inPlace(place, place.startsWith('a-'), async () => {
176
195
  try {
177
- await runAgentTurnInPlace(job, { turnId, agentId, place, releaseSlot, postAgentTurn: settleOnce, measured });
196
+ await runAgentTurnInPlace(job, { turnId, agentId, place, releaseSlot, postAgentTurn: settleOnce, measured, run });
197
+ if (run.interrupted && measured.ranCli) stoppedFor.set(agentId, Date.now());
178
198
  } catch (error) {
179
199
  if (settled) return;
180
200
  const commits = measured.wt ? commitsBetween(measured.wt, measured.before) : [];
201
+ if (run.interrupted) {
202
+ await postAgentTurn({ turnId, outcome: 'nothing', interrupted: true, ran: true, commits, ...(measured.usage ? { usage: measured.usage } : {}) });
203
+ return;
204
+ }
181
205
  await postAgentTurn(
182
206
  failedTurnSettlement({
183
207
  turnId,
@@ -235,7 +259,7 @@ export function createWorkAgentTurns({
235
259
  }
236
260
  };
237
261
 
238
- const processAgentTurnJobs = (jobs) => {
262
+ const processAgentTurnJobs = (jobs, { reportsOnly = false } = {}) => {
239
263
  const list = Array.isArray(jobs) ? jobs : [];
240
264
  pruneHeldReports(list);
241
265
  /** One deferral line per tick, however many turns were offered. */
@@ -244,6 +268,7 @@ export function createWorkAgentTurns({
244
268
  const id = String(job?.id || '');
245
269
  if (!id || agentTurns.has(id)) continue;
246
270
  const held = agentReported.get(id);
271
+ if (reportsOnly && !held) continue;
247
272
  // A body the server REFUSED waits out its backoff — still held, so the
248
273
  // CLI is never re-run in the meantime.
249
274
  if (held && (agentRejectedUntil.get(id) ?? 0) > Date.now()) continue;
@@ -306,11 +331,16 @@ export function createWorkAgentTurns({
306
331
  }
307
332
  const releaseSlot = admit.reserve();
308
333
  agentTurns.add(id);
309
- void runAgentTurn(job, releaseSlot).finally(() => {
334
+ agentRuns.set(id, { place: String(job.placeId), interrupted: false });
335
+ const stopped = stoppedFor.get(String(job.agentId));
336
+ stoppedFor.delete(String(job.agentId));
337
+ const run = stopped != null && Date.now() - stopped < 120_000 ? { ...job, interrupted: true } : job;
338
+ void runAgentTurn(run, releaseSlot).finally(() => {
310
339
  // Belt for every path that never reached a spawn — the release is
311
340
  // idempotent, so the normal case releases twice.
312
341
  releaseSlot();
313
342
  agentTurns.delete(id);
343
+ agentRuns.delete(id);
314
344
  });
315
345
  }
316
346
  };
@@ -347,5 +377,31 @@ export function createWorkAgentTurns({
347
377
  await Promise.allSettled(posts);
348
378
  };
349
379
 
350
- return { processAgentTurnJobs, settleAgentTurns, agentTurns, agentChildren, agentReported };
380
+ /**
381
+ * THE AGENT RUNS A PERSON ASKED TO STOP (`roster.interruptAgentTurns`, read
382
+ * before the jobs). One this lane is running is stopped — the CLI's group
383
+ * killed — and reports `interrupted` once its CLI is gone. One that finished
384
+ * here keeps its own settle. One this lane is not running is answered `ran:
385
+ * false`, and the server puts it back in the queue untouched.
386
+ */
387
+ const interruptAgentTurns = (ids) => {
388
+ const at = Date.now();
389
+ for (const [id, when] of notRunningSaid) if (at - when > 30_000) notRunningSaid.delete(id);
390
+ for (const id of Array.isArray(ids) ? ids : []) {
391
+ if (typeof id !== 'string' || !id) continue;
392
+ const run = agentRuns.get(id);
393
+ if (run) {
394
+ if (!run.interrupted) note(`${c.cyan('agent')} ${c.dim('— interrupting the running turn')}`);
395
+ run.interrupted = true;
396
+ const child = agentChildren.get(run.place);
397
+ if (child) killGroup(child);
398
+ continue;
399
+ }
400
+ if (agentReported.has(id) || notRunningSaid.has(id)) continue;
401
+ notRunningSaid.set(id, at);
402
+ void postAgentTurn({ turnId: id, outcome: 'nothing', interrupted: true, ran: false }).catch(() => {});
403
+ }
404
+ };
405
+
406
+ return { processAgentTurnJobs, interruptAgentTurns, settleAgentTurns, agentTurns, agentChildren, agentReported };
351
407
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * AN IDLE AGENT'S DELIVERY, RECONCILED WITHOUT A TURN (2026-10-11).
3
+ *
4
+ * A local discard reached the server only when a turn settled, so an agent
5
+ * waiting in Review kept every stored file a daemon had not relayed removing:
6
+ * the marketing imagery card showed nine images while its worktree held the
7
+ * two the person kept, and nothing would change until somebody sent another
8
+ * message. So each agent worktree this machine holds is measured against its
9
+ * stored inventory once per process, under the place's writer lock and never
10
+ * beside a turn — the reporter's own evidence rule (artifactIteration.mjs)
11
+ * decides what is a discard. Only agents: a tab's next turn is the natural
12
+ * moment to reconcile a conversation, and a delivery in Review has none.
13
+ *
14
+ * A failure is said in the log and tried again on a later beat, a bounded
15
+ * number of times; the stored files stay listed, which is the truth.
16
+ */
17
+ import { existsSync } from 'node:fs';
18
+
19
+ /** Attempts per agent per process, and the wait between two of them. */
20
+ const MAX_TRIES = 4;
21
+ const RETRY_MS = 60_000;
22
+
23
+ export function createWorkArtifactReconcile({
24
+ artifacts,
25
+ placeLocks,
26
+ inPlace,
27
+ placeDir,
28
+ beforeArtifacts,
29
+ getArtifactsAccepted,
30
+ log = () => {},
31
+ now = Date.now,
32
+ }) {
33
+ const done = new Set(); // agent places reconciled by this process
34
+ const tries = new Map(); // agent place -> { n, at }
35
+ let sweeping = null;
36
+
37
+ const reconcileOne = async (place) => {
38
+ const wt = placeDir(place);
39
+ if (!wt || !existsSync(wt)) return;
40
+ const agentId = place.slice(2);
41
+ try {
42
+ await inPlace(place, true, () => artifacts.reconcile({ placeDir: wt, before: beforeArtifacts(wt), agentId }));
43
+ done.add(place);
44
+ tries.delete(place);
45
+ } catch (error) {
46
+ const n = (tries.get(place)?.n ?? 0) + 1;
47
+ tries.set(place, { n, at: now() });
48
+ log(`agent ${agentId}: artifact cleanup incomplete — ${error?.message ?? error}` +
49
+ (n >= MAX_TRIES ? ' (not retried again until the daemon restarts)' : ''));
50
+ }
51
+ };
52
+
53
+ /** The roster's live places; agent ones are `a-<agentId>`. Never awaited by the poll. */
54
+ const reconcileIdleArtifacts = (activeIds) => {
55
+ if (!Array.isArray(activeIds) || !getArtifactsAccepted()) return null;
56
+ if (sweeping) return sweeping;
57
+ const live = new Set(activeIds);
58
+ for (const id of done) if (!live.has(id)) done.delete(id);
59
+ for (const id of tries.keys()) if (!live.has(id)) tries.delete(id);
60
+ const due = activeIds.filter((place) => {
61
+ if (typeof place !== 'string' || !place.startsWith('a-') || place.length <= 2) return false;
62
+ if (done.has(place) || placeLocks.has(place)) return false; // a turn or a merge is there
63
+ const t = tries.get(place);
64
+ return !t || (t.n < MAX_TRIES && now() - t.at >= RETRY_MS);
65
+ });
66
+ if (due.length === 0) return null;
67
+ // One place at a time: each is an inventory read and its removals.
68
+ sweeping = (async () => {
69
+ try {
70
+ for (const place of due) await reconcileOne(place);
71
+ } finally {
72
+ sweeping = null;
73
+ }
74
+ })();
75
+ return sweeping;
76
+ };
77
+
78
+ return { reconcileIdleArtifacts };
79
+ }
@@ -60,6 +60,7 @@ export async function runCappedTurn({
60
60
  onSpawn = () => {},
61
61
  onInit = () => {},
62
62
  removeTranscript = removeTranscriptSoon,
63
+ signal,
63
64
  }) {
64
65
  // Wiring mistakes, refused before anything spawns: a hook inside turnArgs
65
66
  // would be silently replaced below, and a missing cap would fire at once.
@@ -74,7 +75,14 @@ export async function runCappedTurn({
74
75
  const capped = new Promise((resolve) => {
75
76
  stopWaiting = resolve;
76
77
  });
78
+ const cancel = () => {
79
+ wedged = true;
80
+ try { child?.kill('SIGKILL'); } catch { /* already gone */ }
81
+ stopWaiting('');
82
+ };
83
+ signal?.addEventListener('abort', cancel, { once: true });
77
84
  try {
85
+ if (signal?.aborted) { cancel(); return { out: '', wedged, spawned: false, sessionId }; }
78
86
  const turn = runTurn({
79
87
  ...turnArgs,
80
88
  onInit: (i) => {
@@ -96,11 +104,13 @@ export async function runCappedTurn({
96
104
  }, capMs);
97
105
  timer.unref?.();
98
106
  onSpawn(ch);
107
+ if (signal?.aborted) cancel();
99
108
  },
100
109
  });
101
110
  const out = await Promise.race([turn, capped]);
102
111
  return { out, wedged, spawned: child !== null, sessionId };
103
112
  } finally {
113
+ signal?.removeEventListener('abort', cancel);
104
114
  if (timer) clearTimeout(timer);
105
115
  if (child) workChildren.delete(child);
106
116
  releaseSlot();
@@ -64,7 +64,14 @@ export function createCommandAudit({ sessionId, turnId, runtime, cwd }) {
64
64
  // used to cut the command at 2000 before it reached this scrub, so a
65
65
  // secret straddling that character kept its prefix. They now hand a
66
66
  // bounded window, and the server's cap is applied here, last.
67
- auditBatch.push({ command: scrubCut(a.command, MAX_COMMAND_CHARS), at: new Date().toISOString() });
67
+ auditBatch.push({ command: scrubCut(a.command, MAX_COMMAND_CHARS), at: new Date().toISOString(),
68
+ ...(a.observation ? { observation: {
69
+ ...(a.observation.id ? { id: scrubCut(a.observation.id, 160) } : {}),
70
+ outcome: a.observation.outcome ?? 'unknown',
71
+ ...(Number.isSafeInteger(a.observation.exitCode) ? { exitCode: a.observation.exitCode } : {}),
72
+ ...(a.observation.errorCategory ? { errorCategory: a.observation.errorCategory } : {}),
73
+ } } : {}),
74
+ });
68
75
  if (auditBatch.length >= 25) flushAudit();
69
76
  };
70
77
  return { flushAudit, auditCommand };
@@ -12,7 +12,7 @@
12
12
  * the session lane's turn intake both go through it (audit 2026-10-10, F141),
13
13
  * so a lane that learns a place cannot store one unvalidated.
14
14
  */
15
- import { existsSync } from 'node:fs';
15
+ import { existsSync, statSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
17
  import { git, isSafePathSegment, hasCommits, gitFailure, sessionBranch } from './git.mjs';
18
18
 
@@ -290,5 +290,21 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
290
290
  }
291
291
  };
292
292
 
293
- return { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath };
293
+ /**
294
+ * WHEN `git worktree add` CUT THIS WORKTREE: its private git dir's
295
+ * `commondir`, written once at creation and left alone by commits, lock,
296
+ * repair and move. A recreated worktree is a new birth. Null for the
297
+ * checkout (no such file) and for anything unreadable — read by the
298
+ * artifact reporter, which then has no evidence a place held older rows.
299
+ */
300
+ const worktreeBornAt = (wt) => {
301
+ const path = sessionMetaPath(wt, 'commondir');
302
+ try {
303
+ return path ? statSync(path).mtimeMs : null;
304
+ } catch {
305
+ return null;
306
+ }
307
+ };
308
+
309
+ return { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath, worktreeBornAt };
294
310
  }