flowviant 0.117.0 → 0.124.1

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 (103) hide show
  1. package/README.md +41 -0
  2. package/bin/cli.mjs +44 -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 +4 -3
  7. package/bin/lib/agentPlan.mjs +86 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentRepositories.mjs +48 -0
  10. package/bin/lib/agentTaskKinds.mjs +1 -1
  11. package/bin/lib/artifactContracts.mjs +91 -20
  12. package/bin/lib/artifactIteration.mjs +182 -0
  13. package/bin/lib/artifactRecovery.mjs +36 -0
  14. package/bin/lib/artifactReporter.mjs +183 -19
  15. package/bin/lib/artifacts.mjs +6 -3
  16. package/bin/lib/checkoutIdentity.mjs +25 -0
  17. package/bin/lib/claudeAuth.mjs +15 -5
  18. package/bin/lib/claudeInput.mjs +32 -0
  19. package/bin/lib/claudePosture.mjs +110 -14
  20. package/bin/lib/claudeSessions.mjs +20 -0
  21. package/bin/lib/claudeStream.mjs +20 -2
  22. package/bin/lib/codexProjectRead.mjs +63 -0
  23. package/bin/lib/codexResearch.mjs +16 -3
  24. package/bin/lib/commandSpecs.mjs +6 -0
  25. package/bin/lib/credentials.mjs +52 -17
  26. package/bin/lib/daemonLocks.mjs +26 -6
  27. package/bin/lib/desktopContract.mjs +3 -0
  28. package/bin/lib/discoveryContract.mjs +19 -0
  29. package/bin/lib/fleet.mjs +65 -10
  30. package/bin/lib/fleetReports.mjs +44 -3
  31. package/bin/lib/fleetRoster.mjs +1 -89
  32. package/bin/lib/fleetStartup.mjs +11 -14
  33. package/bin/lib/holder.mjs +4 -37
  34. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  35. package/bin/lib/hooks/readGuard.mjs +13 -4
  36. package/bin/lib/imageArtifact.mjs +74 -0
  37. package/bin/lib/instance.mjs +20 -7
  38. package/bin/lib/landed.mjs +2 -2
  39. package/bin/lib/login.mjs +39 -4
  40. package/bin/lib/maintenance.mjs +47 -0
  41. package/bin/lib/maintenanceInventory.mjs +39 -0
  42. package/bin/lib/mcpInstall.mjs +163 -0
  43. package/bin/lib/mcpInventory.mjs +119 -0
  44. package/bin/lib/placeLock.mjs +9 -7
  45. package/bin/lib/planDependencies.mjs +70 -0
  46. package/bin/lib/postureTools.mjs +85 -0
  47. package/bin/lib/prWorkflow.mjs +15 -0
  48. package/bin/lib/projectLanded.mjs +16 -0
  49. package/bin/lib/projectRepositories.mjs +60 -0
  50. package/bin/lib/projectToolRuntimes.mjs +227 -3
  51. package/bin/lib/projectTools.mjs +63 -5
  52. package/bin/lib/projectWorktreeHome.mjs +25 -0
  53. package/bin/lib/prompts.mjs +170 -42
  54. package/bin/lib/repositoryBaseline.mjs +68 -0
  55. package/bin/lib/repositoryDelivery.mjs +64 -0
  56. package/bin/lib/repositoryMerge.mjs +116 -0
  57. package/bin/lib/repositoryPlaces.mjs +179 -0
  58. package/bin/lib/repositorySetup.mjs +58 -0
  59. package/bin/lib/repositoryTool.mjs +71 -0
  60. package/bin/lib/runTurn.mjs +58 -18
  61. package/bin/lib/runtimeAntigravity.mjs +2 -1
  62. package/bin/lib/runtimeCapabilities.mjs +77 -3
  63. package/bin/lib/runtimeClaude.mjs +31 -10
  64. package/bin/lib/runtimeCodex.mjs +28 -59
  65. package/bin/lib/runtimeDetection.mjs +6 -0
  66. package/bin/lib/runtimeEvents.mjs +12 -1
  67. package/bin/lib/runtimeOfferings.mjs +75 -3
  68. package/bin/lib/shipMerge.mjs +5 -2
  69. package/bin/lib/startProject.mjs +4 -4
  70. package/bin/lib/trace.mjs +8 -3
  71. package/bin/lib/turnDeclaration.mjs +88 -0
  72. package/bin/lib/turnDiagnostics.mjs +93 -0
  73. package/bin/lib/turnLock.mjs +48 -8
  74. package/bin/lib/turnProfile.mjs +15 -3
  75. package/bin/lib/work.mjs +48 -29
  76. package/bin/lib/workAgentBrief.mjs +92 -0
  77. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  78. package/bin/lib/workAgentCheck.mjs +6 -5
  79. package/bin/lib/workAgentMerges.mjs +19 -2
  80. package/bin/lib/workAgentPlans.mjs +21 -70
  81. package/bin/lib/workAgentPrecheck.mjs +10 -5
  82. package/bin/lib/workAgentPublish.mjs +18 -5
  83. package/bin/lib/workAgentRecovery.mjs +85 -0
  84. package/bin/lib/workAgentReview.mjs +5 -3
  85. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  86. package/bin/lib/workAgentTurnExecution.mjs +167 -29
  87. package/bin/lib/workAgentTurnOutcome.mjs +20 -5
  88. package/bin/lib/workAgentTurns.mjs +92 -12
  89. package/bin/lib/workArtifactReconcile.mjs +79 -0
  90. package/bin/lib/workCappedTurn.mjs +10 -0
  91. package/bin/lib/workCommandAudit.mjs +8 -1
  92. package/bin/lib/workDelivery.mjs +21 -0
  93. package/bin/lib/workDiffs.mjs +18 -11
  94. package/bin/lib/workLiveMessages.mjs +129 -0
  95. package/bin/lib/workPlaceReports.mjs +5 -0
  96. package/bin/lib/workPlaces.mjs +24 -3
  97. package/bin/lib/workRetire.mjs +5 -0
  98. package/bin/lib/workSessionTurns.mjs +219 -52
  99. package/bin/lib/workShip.mjs +17 -16
  100. package/bin/lib/workToolLog.mjs +70 -3
  101. package/bin/lib/workTurnShape.mjs +6 -4
  102. package/package.json +7 -3
  103. package/npm-shrinkwrap.json +0 -59
@@ -25,8 +25,12 @@ 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 { createDeliveryReporter } from './workDelivery.mjs';
30
+ import { createAgentBriefRunner } from './workAgentBriefRunner.mjs';
28
31
 
29
32
  export function createWorkAgentTurns({
33
+ agentRepositories,
30
34
  REJECT_RETRY_MS,
31
35
  baseRef,
32
36
  inPlace,
@@ -42,13 +46,16 @@ export function createWorkAgentTurns({
42
46
  artifacts,
43
47
  runReviewEntry,
44
48
  publishAgentBranch,
49
+ stopAgentPublishing = () => {},
45
50
  admit,
46
51
  workChildren,
47
52
  agentPublished,
48
53
  agentRemoteAt,
49
54
  /** The turn's files, fetched into its worktree (workAttachments.mjs, 0.112.0). */
50
55
  fetchAgentFiles,
56
+ makeBrief = createAgentBriefRunner({ admit, workChildren }),
51
57
  }) {
58
+ const reportDelivery = createDeliveryReporter();
52
59
  const {
53
60
  postAgentTurn,
54
61
  postAgentTrace,
@@ -65,11 +72,9 @@ export function createWorkAgentTurns({
65
72
  // A TIME. The server types the next prompt when this one lands; this side
66
73
  // does the work and reports what happened.
67
74
  //
68
- // NO MCP ON THIS TURN AT ALL. Everything the agent needs to say fits in its
69
- // final JSON object, and everything it needs to PROVE is measured from git
70
- // here afterwards — an agent naming its own commit shas would be a receipt
71
- // pointing at whatever it liked. That is also what keeps this feature from
72
- // adding a token kind to a scope map that has silently shipped empty twice.
75
+ // Outcomes stay in the final JSON; commits are measured from git. The
76
+ // local card-repository tool is a turn-scoped capability, never a machine
77
+ // credential handed to the CLI.
73
78
  //
74
79
  // UNLEASED, unlike a plan or a kill. The server hands out at most one turn
75
80
  // per agent per poll and its settle is conditional on the row still being
@@ -91,8 +96,24 @@ export function createWorkAgentTurns({
91
96
  * directory names, and those are places.
92
97
  */
93
98
  const agentChildren = new Map(); // placeId -> child process
99
+ /**
100
+ * INTERRUPT NOW (2026-10-13) — each turn this lane has taken: its place and
101
+ * whether a person asked to stop it. A stop kills the CLI's PROCESS GROUP
102
+ * (it is spawned detached, so the commands it started go with it); a turn
103
+ * asked to stop before it spawned never spawns.
104
+ */
105
+ const agentRuns = new Map(); // turnId -> { place, interrupted }
106
+ /** Turns answered `ran: false` here, so a roster read made before that
107
+ * settle landed is not answered again at once. */
108
+ const notRunningSaid = new Map(); // turnId -> when
109
+ /** Agents whose run this lane just stopped: the next turn is the one sent to
110
+ * interrupt it, and is told so even when the roster that handed it over was
111
+ * read before the stop's settle landed. Taken once, for a little while. */
112
+ const stoppedFor = new Map(); // agentId -> when
94
113
 
95
114
  const { runAgentTurnInPlace, commitsBetween } = createAgentTurnExecution({
115
+ agentRepositories,
116
+ makeBrief,
96
117
  baseRef,
97
118
  baseDir,
98
119
  repoRoot,
@@ -166,18 +187,26 @@ export function createWorkAgentTurns({
166
187
  let settled = false;
167
188
  const settleOnce = (body) => {
168
189
  settled = true;
169
- return postAgentTurn(body);
190
+ const liveMessages = agentRuns.get(turnId)?.live?.receipts();
191
+ return postAgentTurn({ ...body, ...(liveMessages?.ids.length ? { liveMessages } : {}) });
170
192
  };
171
193
 
172
194
  // A WRITER on the agent's own place — see the lane header. Only `a-<id>`
173
195
  // places: those are agents' by construction, and anything else here would
174
196
  // be a tab's directory, where a turn is a reader by the product's own law.
197
+ const run = agentRuns.get(turnId) ?? { place, interrupted: false };
175
198
  await inPlace(place, place.startsWith('a-'), async () => {
199
+ run.waitingReason = null;
176
200
  try {
177
- await runAgentTurnInPlace(job, { turnId, agentId, place, releaseSlot, postAgentTurn: settleOnce, measured });
201
+ await runAgentTurnInPlace(job, { turnId, agentId, place, releaseSlot, postAgentTurn: settleOnce, measured, run });
202
+ if (run.interrupted && measured.ranCli) stoppedFor.set(agentId, Date.now());
178
203
  } catch (error) {
179
204
  if (settled) return;
180
- const commits = measured.wt ? commitsBetween(measured.wt, measured.before) : [];
205
+ const commits = measured.repositoryReceipts ? [...new Set(measured.repositoryReceipts().map((r) => r.sha))].slice(0, 50) : measured.wt ? commitsBetween(measured.wt, measured.before) : [];
206
+ if (run.interrupted) {
207
+ await postAgentTurn({ turnId, outcome: 'nothing', interrupted: true, ran: measured.ranCli === true, commits, ...(measured.usage ? { usage: measured.usage } : {}) });
208
+ return;
209
+ }
181
210
  await postAgentTurn(
182
211
  failedTurnSettlement({
183
212
  turnId,
@@ -190,6 +219,9 @@ export function createWorkAgentTurns({
190
219
  })
191
220
  );
192
221
  }
222
+ }, () => {
223
+ run.waitingReason = 'waiting for this turn to finish';
224
+ reportDelivery('agent', job, 'queued', run.waitingReason);
193
225
  });
194
226
  /**
195
227
  * …AND THEN PUBLISH, IF THE PROJECT PUBLISHES.
@@ -235,15 +267,21 @@ export function createWorkAgentTurns({
235
267
  }
236
268
  };
237
269
 
238
- const processAgentTurnJobs = (jobs) => {
270
+ const processAgentTurnJobs = (jobs, { reportsOnly = false } = {}) => {
239
271
  const list = Array.isArray(jobs) ? jobs : [];
240
272
  pruneHeldReports(list);
241
273
  /** One deferral line per tick, however many turns were offered. */
242
274
  let saidPressure = false;
243
275
  for (const job of list.slice(0, 4)) {
244
276
  const id = String(job?.id || '');
245
- if (!id || agentTurns.has(id)) continue;
277
+ if (!id) continue;
278
+ if (agentTurns.has(id)) {
279
+ const reason = agentRuns.get(id)?.waitingReason;
280
+ if (reason) reportDelivery('agent', job, 'queued', reason);
281
+ continue;
282
+ }
246
283
  const held = agentReported.get(id);
284
+ if (reportsOnly && !held) continue;
247
285
  // A body the server REFUSED waits out its backoff — still held, so the
248
286
  // CLI is never re-run in the meantime.
249
287
  if (held && (agentRejectedUntil.get(id) ?? 0) > Date.now()) continue;
@@ -296,8 +334,15 @@ export function createWorkAgentTurns({
296
334
  * would all be admitted against the same stale count. The reserved slot
297
335
  * is what the next iteration sees. See admission.mjs.
298
336
  */
337
+ const missing = agentRepositories?.missing(job);
338
+ if (missing) {
339
+ if (!saidPressure) { saidPressure = true; note(missing); }
340
+ void postAgentActivity(String(job.agentId), missing);
341
+ continue; // no CLI, no failed settle; login and the next poll remeasure
342
+ }
299
343
  const hold = admit('churn');
300
344
  if (hold) {
345
+ reportDelivery('agent', job, 'queued', hold.reason);
301
346
  if (!saidPressure) {
302
347
  saidPressure = true;
303
348
  note(`${c.cyan('agent')} ${c.dim(`— holding off: ${hold.reason}`)}`);
@@ -306,11 +351,16 @@ export function createWorkAgentTurns({
306
351
  }
307
352
  const releaseSlot = admit.reserve();
308
353
  agentTurns.add(id);
309
- void runAgentTurn(job, releaseSlot).finally(() => {
354
+ agentRuns.set(id, { place: String(job.placeId), interrupted: false });
355
+ const stopped = stoppedFor.get(String(job.agentId));
356
+ stoppedFor.delete(String(job.agentId));
357
+ const run = stopped != null && Date.now() - stopped < 120_000 ? { ...job, interrupted: true } : job;
358
+ void runAgentTurn(run, releaseSlot).finally(() => {
310
359
  // Belt for every path that never reached a spawn — the release is
311
360
  // idempotent, so the normal case releases twice.
312
361
  releaseSlot();
313
362
  agentTurns.delete(id);
363
+ agentRuns.delete(id);
314
364
  });
315
365
  }
316
366
  };
@@ -337,6 +387,9 @@ export function createWorkAgentTurns({
337
387
  * a wedged uplink must not hold the stand-down open.
338
388
  */
339
389
  const settleAgentTurns = async (sentence) => {
390
+ // Stand-down precedes flushing held replies; a late accepted reply must
391
+ // never push this displaced machine's work after the holder has moved.
392
+ stopAgentPublishing();
340
393
  const answer = String(sentence ?? '').slice(0, 1000);
341
394
  const posts = [];
342
395
  for (const turnId of agentTurns) {
@@ -347,5 +400,32 @@ export function createWorkAgentTurns({
347
400
  await Promise.allSettled(posts);
348
401
  };
349
402
 
350
- return { processAgentTurnJobs, settleAgentTurns, agentTurns, agentChildren, agentReported };
403
+ /**
404
+ * THE AGENT RUNS A PERSON ASKED TO STOP (`roster.interruptAgentTurns`, read
405
+ * before the jobs). One this lane is running is stopped — the CLI's group
406
+ * killed — and reports `interrupted` once its CLI is gone. One that finished
407
+ * here keeps its own settle. One this lane is not running is answered `ran:
408
+ * false`, and the server puts it back in the queue untouched.
409
+ */
410
+ const interruptAgentTurns = (ids) => {
411
+ const at = Date.now();
412
+ for (const [id, when] of notRunningSaid) if (at - when > 30_000) notRunningSaid.delete(id);
413
+ for (const id of Array.isArray(ids) ? ids : []) {
414
+ if (typeof id !== 'string' || !id) continue;
415
+ const run = agentRuns.get(id);
416
+ if (run) {
417
+ if (!run.interrupted) note(`${c.cyan('agent')} ${c.dim('— interrupting the running turn')}`);
418
+ run.interrupted = true;
419
+ run.abortSetup?.();
420
+ const child = agentChildren.get(run.place);
421
+ if (child) killGroup(child);
422
+ continue;
423
+ }
424
+ if (agentReported.has(id) || notRunningSaid.has(id)) continue;
425
+ notRunningSaid.set(id, at);
426
+ void postAgentTurn({ turnId: id, outcome: 'nothing', interrupted: true, ran: false }).catch(() => {});
427
+ }
428
+ };
429
+
430
+ return { processAgentTurnJobs, interruptAgentTurns, settleAgentTurns, agentTurns, agentChildren, agentReported };
351
431
  }
@@ -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 };
@@ -0,0 +1,21 @@
1
+ /** A measured wait belongs to its message. Reports decorate the queue and
2
+ * never gate a turn; retries come with the next measurement/poll. */
3
+ import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
4
+ import { fleetRequest } from './fleetPost.mjs';
5
+ import { fleetEndpoint } from './fleetWire.mjs';
6
+
7
+ export function createDeliveryReporter(request = fleetRequest, now = Date.now) {
8
+ const said = new Map();
9
+ return (scope, job, state, reason) => {
10
+ const key = `${scope}:${job.id}`;
11
+ const value = `${state}:${reason ?? ''}`;
12
+ const prior = said.get(key);
13
+ if (prior?.value === value && now() - prior.at < 10_000) return;
14
+ said.set(key, { value, at: now() });
15
+ // Bound a day-long daemon's decoration cache.
16
+ if (said.size > 500) said.delete(said.keys().next().value);
17
+ void request(fleetEndpoint('turn-delivery', FLEET_URL), { timeoutMs: 10_000,
18
+ body: { scope, turnId: job.id, instance: DAEMON_INSTANCE, ...(Number.isInteger(job.attempt) ? { attempt: job.attempt } : {}), state, ...(reason ? { reason } : {}) },
19
+ }).then(result => { if (!result.ok && said.get(key)?.value === value) said.delete(key); }).catch(() => { said.delete(key); });
20
+ };
21
+ }
@@ -1,5 +1,6 @@
1
1
  /** Commit diff requests and their bounded reports. */
2
2
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
3
+ import { listStoredProjects } from './credentials.mjs';
3
4
  import { git, GIT_WIDE_BUFFER, OBJECT_ID_RE } from './git.mjs';
4
5
  import { fleetEndpoint } from './fleetWire.mjs';
5
6
  import { scrub, scrubCut } from './uplinkScrub.mjs';
@@ -21,7 +22,9 @@ function gitBig(args, cwd) {
21
22
  }
22
23
  }
23
24
 
24
- export function createWorkDiffs({ repoRoot }) {
25
+ export function createWorkDiffs({ repoRoot, repositories }) {
26
+ // The picker may have changed the active token since config was imported.
27
+ const roots = repositories ?? listStoredProjects().find((entry) => entry.fleetToken === FLEET_TOKEN)?.repositories ?? {};
25
28
  const DIFF_DONE_URL = fleetEndpoint('diff-done', FLEET_URL);
26
29
 
27
30
  /**
@@ -82,24 +85,28 @@ export function createWorkDiffs({ repoRoot }) {
82
85
  // In-flight guard, not a cache: the server stops offering a sha the
83
86
  // moment it settles, so this only stops the SAME poll's job being
84
87
  // started twice while its `git show` is still running.
85
- if (servedDiffs.has(sha)) continue;
86
- servedDiffs.add(sha);
88
+ const jobKey = `${job.repo ?? ''}:${sha}`;
89
+ if (servedDiffs.has(jobKey)) continue;
90
+ servedDiffs.add(jobKey);
91
+ const cwd = job.repo ? roots[job.repositoryName] : repoRoot;
92
+ const report = (body) => postDiff({ ...body, ...(job.repo ? { repo: job.repo } : {}) });
87
93
  void (async () => {
88
94
  try {
95
+ if (!cwd) { await report({ sha, error: 'This repository has no checkout on this machine. Run flowviant login in that checkout.' }); return; }
89
96
  let commit = '';
90
97
  try {
91
- commit = gitBig(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], repoRoot);
98
+ commit = gitBig(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], cwd);
92
99
  } catch (e) {
93
100
  // Exit 1 is `--verify --quiet`'s "not a commit here" (absent,
94
101
  // ambiguous, or another object type); anything else is git's own
95
102
  // failure, relayed in its words as before.
96
103
  if (e?.status !== 1) {
97
- await postDiff({ sha, error: scrubCut(e?.message || e, 500) });
104
+ await report({ sha, error: scrubCut(e?.message || e, 500) });
98
105
  return;
99
106
  }
100
107
  }
101
108
  if (!/^[0-9a-f]{40,64}$/.test(commit)) {
102
- await postDiff({ sha, error: 'no such commit on this machine' });
109
+ await report({ sha, error: 'no such commit on this machine' });
103
110
  return;
104
111
  }
105
112
  let patch = '';
@@ -109,13 +116,13 @@ export function createWorkDiffs({ repoRoot }) {
109
116
  // date already reached the card on the worktree sweep, and
110
117
  // repeating them inside the patch would put a second copy above
111
118
  // every hunk.
112
- patch = scrub(gitBig(['show', '--patch', '--format=', commit], repoRoot));
119
+ patch = scrub(gitBig(['show', '--patch', '--format=', commit], cwd));
113
120
  } catch (e) {
114
- await postDiff({ sha, error: scrubCut(e?.message || e, 500) });
121
+ await report({ sha, error: scrubCut(e?.message || e, 500) });
115
122
  return;
116
123
  }
117
124
  try {
118
- const raw = gitBig(['show', '--numstat', '--format=', commit], repoRoot);
125
+ const raw = gitBig(['show', '--numstat', '--format=', commit], cwd);
119
126
  for (const line of raw.split('\n')) {
120
127
  if (!line.trim()) continue;
121
128
  const [a, d, ...rest] = line.split('\t');
@@ -133,7 +140,7 @@ export function createWorkDiffs({ repoRoot }) {
133
140
  /* a header without counts still beats no diff */
134
141
  }
135
142
  const truncated = Buffer.byteLength(patch, 'utf8') > MAX_PATCH_BYTES;
136
- await postDiff({
143
+ await report({
137
144
  sha,
138
145
  // Cut on a LINE boundary: half a hunk header renders as garbage,
139
146
  // and the viewer says the patch was truncated either way.
@@ -144,7 +151,7 @@ export function createWorkDiffs({ repoRoot }) {
144
151
  truncated,
145
152
  });
146
153
  } finally {
147
- servedDiffs.delete(sha);
154
+ servedDiffs.delete(jobKey);
148
155
  }
149
156
  })();
150
157
  }
@@ -0,0 +1,129 @@
1
+ /** One live conversation's input relay, shared by tabs, capture chats and
2
+ * card agents. The server owns queue/holder checks; the CLI echo owns receipt.
3
+ * Durable receipts plus native transcript UUIDs cover a restart before POST.
4
+ * No model call, new process, or re-send of an unacknowledged write here. */
5
+ import { readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { fleetRequest } from './fleetPost.mjs';
8
+ import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
9
+ import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
10
+ import { messageIdsForSession } from './claudeSessions.mjs';
11
+
12
+ export function createLiveMessages({ scope, turnId, attempt, cwd, sessionMetaPath, format, interrupted = () => false,
13
+ request = fleetRequest, nativeIds = messageIdsForSession, setTimer = setInterval, clearTimer = clearInterval, finalFlushMs = 1_000 }) {
14
+ const identity = { scope, turnId, instance: DAEMON_INSTANCE, ...(attempt !== undefined ? { attempt } : {}) };
15
+ const file = sessionMetaPath(cwd, 'flowviant-live-messages', turnId);
16
+ let sessionId = null;
17
+ let timer = null;
18
+ let stopped = false;
19
+ let send = null;
20
+ let flight = null;
21
+ let posting = null;
22
+ const sent = new Set();
23
+ const initialIds = new Set();
24
+ let initialId = turnId;
25
+ const taken = new Set();
26
+ const confirmed = new Set();
27
+ const outstanding = () => [...taken].filter((id) => !confirmed.has(id));
28
+ try {
29
+ const saved = JSON.parse(readFileSync(file, 'utf8'));
30
+ sessionId = saved.sessionId;
31
+ for (const id of saved.ids ?? []) if (typeof id === 'string') taken.add(id);
32
+ } catch { /* no measured receipts yet */ }
33
+ const save = () => {
34
+ if (!file) return;
35
+ // Keep only ids and the CLI's conversation id, never the person's words.
36
+ const temporary = `${file}.tmp-${process.pid}`;
37
+ try {
38
+ writeFileSync(temporary, JSON.stringify({ sessionId, ids: outstanding() }), { mode: 0o600, flag: 'wx' });
39
+ renameSync(temporary, file); // a crash leaves the previous complete receipt
40
+ } catch { /* the native transcript remains the recovery evidence */ }
41
+ finally { try { rmSync(temporary, { force: true }); } catch { /* best effort */ } }
42
+ };
43
+ const flush = () => {
44
+ if (posting) return posting;
45
+ if (!outstanding().length) return Promise.resolve(true);
46
+ posting = (async () => {
47
+ for (const id of outstanding()) {
48
+ const result = await request(fleetEndpoint('live-turn-messages-taken', FLEET_URL), { body: { ...identity, ids: [id] }, timeoutMs: 15_000 });
49
+ if (!result.ok || result.json?.data?.taken !== true) return false;
50
+ confirmed.add(id);
51
+ save();
52
+ }
53
+ return true;
54
+ })().catch(() => false).finally(() => { posting = null; });
55
+ return posting;
56
+ };
57
+ const onTaken = (id) => {
58
+ if (initialIds.has(id) || taken.has(id)) return;
59
+ taken.add(id);
60
+ save(); // synchronous before the report or final settlement
61
+ void flush();
62
+ };
63
+ const tick = () => {
64
+ if (flight || stopped || interrupted() || !send) return flight ?? Promise.resolve();
65
+ flight = (async () => {
66
+ await flush();
67
+ if (stopped || interrupted()) return;
68
+ const result = await request(fleetEndpoint('live-turn-messages', FLEET_URL), { body: identity, timeoutMs: 15_000 });
69
+ if (!result.ok) {
70
+ if (isConsideredRefusal(result.status)) { if (timer) clearTimer(timer); timer = null; }
71
+ return;
72
+ }
73
+ if (!result.json?.data?.active) return;
74
+ const seen = sessionId ? nativeIds(cwd, sessionId) : null;
75
+ for (const message of result.json.data.messages ?? []) {
76
+ if (stopped || interrupted()) break;
77
+ if (taken.has(message.id) || seen?.has(message.id)) { onTaken(message.id); continue; }
78
+ if (sent.has(message.id)) continue;
79
+ const prompt = await format(message);
80
+ if (!stopped && !interrupted() && send(message.id, prompt)) sent.add(message.id);
81
+ }
82
+ })().catch(() => {}).finally(() => { flight = null; });
83
+ return flight;
84
+ };
85
+ return {
86
+ turnId, onTaken, tick,
87
+ connected: () => Boolean(send) && !stopped,
88
+ get initialId() { return initialId; },
89
+ initialPrompt(prompt, resumeId, resume = Boolean(resumeId), continuation = false) {
90
+ initialId = turnId;
91
+ sessionId = resume || resumeId ? resumeId || sessionId : null;
92
+ // A daemon-authored correction continues the same conversation with
93
+ // new instructions. Restart recovery alone replaces the old prompt.
94
+ if (continuation) {
95
+ initialId = randomUUID();
96
+ initialIds.add(initialId);
97
+ return prompt;
98
+ }
99
+ if ((resume || resumeId) && sessionId && nativeIds(cwd, sessionId)?.has(turnId)) {
100
+ onTaken(turnId); // the native transcript proves the original prompt was read
101
+ initialId = randomUUID(); // continuation, never a second copy of the user's UUID
102
+ initialIds.add(initialId);
103
+ return 'Continue the request already in this conversation after the process stopped. Inspect the work in flight before repeating a command, and finish under the same operating contract.';
104
+ }
105
+ return prompt;
106
+ },
107
+ onInit(id) { sessionId = id; save(); },
108
+ connect(writer) {
109
+ sent.clear(); // a new child can retry writes the previous child never echoed
110
+ stopped = false;
111
+ send = writer;
112
+ timer = setTimer(() => void tick(), 1_000);
113
+ timer?.unref?.();
114
+ void tick();
115
+ },
116
+ receipts: () => ({ ...identity, ids: outstanding() }),
117
+ async stop() {
118
+ stopped = true;
119
+ if (timer) clearTimer(timer);
120
+ timer = null;
121
+ // A completed CLI never waits on a queue read or attachment download.
122
+ // Give receipts one bounded chance; the final report carries the rest.
123
+ let cap;
124
+ try { await Promise.race([flush(), new Promise(resolve => { cap = setTimeout(resolve, finalFlushMs); })]); }
125
+ finally { clearTimeout(cap); }
126
+ if (!outstanding().length && file) try { rmSync(file, { force: true }); } catch { /* best effort */ }
127
+ },
128
+ };
129
+ }
@@ -23,6 +23,7 @@ import { fleetEndpoint } from './fleetWire.mjs';
23
23
  import { isValidPlace } from './workPlaces.mjs';
24
24
 
25
25
  export function createWorkPlaceReports({
26
+ repositoryPlaces,
26
27
  repoRoot,
27
28
  baseRef,
28
29
  placeOf,
@@ -101,6 +102,10 @@ export function createWorkPlaceReports({
101
102
  const wt = placeDir(sessionId);
102
103
  const d = worktreeDiff(wt, baseRef());
103
104
  if (!d) return null;
105
+ const siblings = repositoryPlaces?.entries(place).filter((r) => r.writable) ?? [];
106
+ if (siblings.length) {
107
+ d.commits = siblings.flatMap((r) => (worktreeDiff(r.wt, r.baseRef)?.commits ?? []).map((commit) => ({ ...commit, repo: r.id }))).slice(0, 50);
108
+ }
104
109
  // WHAT IS LISTENING in this worktree, attributed by the CWD of the process
105
110
  // holding the socket. It rides the sweep the daemon already makes rather
106
111
  // than taking a beat of its own, exactly as the commit trailers do — and
@@ -12,7 +12,8 @@
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 { readRepositoryPlace } from './repositoryPlaces.mjs';
16
+ import { existsSync, statSync } from 'node:fs';
16
17
  import { join } from 'node:path';
17
18
  import { git, isSafePathSegment, hasCommits, gitFailure, sessionBranch } from './git.mjs';
18
19
 
@@ -51,7 +52,11 @@ export function isValidPlace(place) {
51
52
  * that does not exist rather than becoming the operator's checkout.
52
53
  */
53
54
  export function worktreePath(baseDir, place) {
54
- return isSafePathSegment(place) ? join(baseDir, 'sessions', place) : null;
55
+ if (!isSafePathSegment(place)) return null;
56
+ const home = join(baseDir, 'sessions', place);
57
+ const state = readRepositoryPlace(home);
58
+ const primary = state?.repositories.find((r) => r.id === state.primary && r.writable);
59
+ return primary ? join(home, primary.folder) : home;
55
60
  }
56
61
 
57
62
  /**
@@ -290,5 +295,21 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
290
295
  }
291
296
  };
292
297
 
293
- return { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath };
298
+ /**
299
+ * WHEN `git worktree add` CUT THIS WORKTREE: its private git dir's
300
+ * `commondir`, written once at creation and left alone by commits, lock,
301
+ * repair and move. A recreated worktree is a new birth. Null for the
302
+ * checkout (no such file) and for anything unreadable — read by the
303
+ * artifact reporter, which then has no evidence a place held older rows.
304
+ */
305
+ const worktreeBornAt = (wt) => {
306
+ const path = sessionMetaPath(wt, 'commondir');
307
+ try {
308
+ return path ? statSync(path).mtimeMs : null;
309
+ } catch {
310
+ return null;
311
+ }
312
+ };
313
+
314
+ return { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath, worktreeBornAt };
294
315
  }
@@ -14,6 +14,7 @@ import { git, isSafePathSegment } from './git.mjs';
14
14
  import { PERSON_PLACE_PREFIX } from './workPlaces.mjs';
15
15
 
16
16
  export function createWorkRetire({
17
+ repositoryPlaces,
17
18
  repoRoot,
18
19
  baseDir,
19
20
  baseRef,
@@ -113,6 +114,10 @@ export function createWorkRetire({
113
114
  // Uncommitted work is the human's — a resource sweep does not outrank
114
115
  // it, closed tab or not. (The non-force remove would refuse anyway;
115
116
  // the explicit check keeps the intent legible.)
117
+ if (repositoryPlaces?.stateFor(id)) {
118
+ if (repositoryPlaces.remove(id)) { workTokens.delete(id); removed++; }
119
+ continue;
120
+ }
116
121
  if (git(['status', '--porcelain'], wt) !== '') continue;
117
122
  git(['worktree', 'remove', wt], repoRoot); // non-force
118
123
  workTokens.delete(id);