flowviant 0.125.17 → 0.125.20

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.
@@ -12,10 +12,12 @@ import { gitOrNull } from './git.mjs';
12
12
  import { parsePrecheck } from './agentPlan.mjs';
13
13
  import { readStash } from './agentCards.mjs';
14
14
  import { c, note } from './ui.mjs';
15
- import { runTurn } from './runTurn.mjs';
15
+ import { runTurn as defaultRunTurn } from './runTurn.mjs';
16
16
  import { SYSTEM_PRECHECK, AGENT_PRECHECK_KICKOFF } from './prompts.mjs';
17
17
  import { scrub as envScrub } from './uplinkScrub.mjs';
18
- import { pickRuntimeFor } from './runtimeDetection.mjs';
18
+ import { detectRuntimes as defaultDetectRuntimes } from './runtimeDetection.mjs';
19
+ import { canRun, runtimeById } from './runtimes.mjs';
20
+ import { brainFor } from './workBrain.mjs';
19
21
  import { runCappedTurn } from './workCappedTurn.mjs';
20
22
  import { taskIdsFromMessage } from './worktreeDiff.mjs';
21
23
  import { limitLine } from './cliLimit.mjs';
@@ -23,12 +25,12 @@ import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
23
25
  import { postFleetJson } from './fleetPost.mjs';
24
26
  import { agentBrowserHome } from './noWindowEnv.mjs';
25
27
 
26
- export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workChildren }) {
28
+ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workChildren, runTurn = defaultRunTurn, detectRuntimes = defaultDetectRuntimes }) {
27
29
  const AGENT_PRECHECK_URL = fleetEndpoint('agent-precheck', FLEET_URL);
28
30
 
29
31
  // ── THE AI PRE-REVIEW ──────────────────────────────────────────────────────
30
32
  //
31
- // A FRESH Claude reads the branch before the human does. The owner asked for
33
+ // A fresh conversation reads the branch before the human does. The owner asked for
32
34
  // it in these words: "before having the user manually check, can we have the
33
35
  // daemon … spawn an agent to review the work so basically we get an ai to look
34
36
  // at the review before a human looks at it for a double check."
@@ -135,15 +137,14 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
135
137
  return { text, taskIds: [...taskIds] };
136
138
  };
137
139
 
138
- const runPrecheck = async (agentId, wt, agentName, repository) => {
139
- /**
140
- * WHICH CLI READS. `pickRuntimeFor('consult')` — the same picker the scratch
141
- * planner uses, and for the same reason: this prompt was written against
142
- * Claude, and a machine with no read-only-capable runtime simply does not
143
- * produce a precheck. Nothing is posted and nothing is said on the row.
144
- */
145
- const rt = pickRuntimeFor('consult');
146
- if (!rt) return;
140
+ const runPrecheck = async (agentId, wt, agentName, repository, source) => {
141
+ // A fresh read-only conversation, on this agent's CLI/model/effort.
142
+ // Missing provenance or a missing CLI skips this optional reading; it
143
+ // never licenses a machine-wide provider substitution.
144
+ if (!source) return;
145
+ const rt = source.runtime || 'claude';
146
+ if (!canRun(runtimeById(rt), 'consult') || !detectRuntimes().some(row => row.id === rt && row.installed)) return;
147
+ const brain = brainFor(source, rt);
147
148
  /**
148
149
  * THE PRESSURE GUARD, at the spawn, exactly as every other unattended lane
149
150
  * asks it. `churn` and never `interactive`: nobody is watching this, and a
@@ -232,6 +233,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
232
233
  profile: 'consult',
233
234
  cwd: wt,
234
235
  runtime: rt,
236
+ ...brain,
235
237
  // The agent's own browser profile, in the worktree this reader stands
236
238
  // in; no display either way (noWindowEnv.mjs, 2026-09-29).
237
239
  browserHome: agentBrowserHome(sessionMetaPath, wt, agentId),
@@ -242,8 +244,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
242
244
  work: { kind: 'pre-review', id: agentId, name: agentName },
243
245
  // The CLI's own count for this reading. Set, never accumulated: one
244
246
  // turn is one `result` event, and the adding-up is the server's.
245
- // Tagged with the CLI, so a Claude pre-review of a Codex agent is
246
- // charged as Claude's and not folded into the agent's own figure.
247
+ // Tagged with the same CLI as the associated agent.
247
248
  onUsage: (u) => {
248
249
  usage = { ...u, runtime: rt };
249
250
  },
@@ -29,7 +29,7 @@ export function createWorkAgentReview({ runCheck, runPrecheck }) {
29
29
  * INSIDE the place's writer lock on a path whose callers settle real work —
30
30
  * `runAgentMerge` in particular reports a claimed merge after this returns.
31
31
  */
32
- const runReviewEntry = async (agentId, wt, agentName, repository) => {
32
+ const runReviewEntry = async (agentId, wt, agentName, repository, brain) => {
33
33
  let result = { status: 'unknown' };
34
34
  try {
35
35
  result = await runCheck(agentId, wt, repository) ?? result;
@@ -37,7 +37,7 @@ export function createWorkAgentReview({ runCheck, runPrecheck }) {
37
37
  /* the row keeps its previous check answer, which is null the first time */
38
38
  }
39
39
  try {
40
- await runPrecheck(agentId, wt, agentName, repository);
40
+ await runPrecheck(agentId, wt, agentName, repository, brain);
41
41
  } catch {
42
42
  /* no pre-review is posted, and absence renders nothing */
43
43
  }
@@ -1,3 +1,5 @@
1
+ import { reportLocalCleanup } from './localCleanupReport.mjs';
2
+ import { createManagedLocalCleanup } from './managedLocalCleanup.mjs';
1
3
  import { imageRevisionInstructions } from './artifactIteration.mjs';
2
4
  import { agentChildScope } from './agentQueueContext.mjs';
3
5
  import { measureTestFindings } from './testFindings.mjs';
@@ -33,6 +35,7 @@ import { addUsage } from './turnUsage.mjs';
33
35
  import { stashCard } from './agentCards.mjs';
34
36
  import { c } from './ui.mjs';
35
37
  import { runTurn } from './runTurn.mjs';
38
+ import { liveGroups, processesSupported } from './processes.mjs';
36
39
  import { killGroup } from './processGroup.mjs';
37
40
  import { AGENT_TASK_SPEC } from './prompts.mjs';
38
41
  import { agentKickoff, agentFollowupKickoff, agentTurnSystem } from './workAgentTurnShape.mjs';
@@ -247,7 +250,7 @@ export function createAgentTurnExecution({
247
250
  return;
248
251
  }
249
252
  /**
250
- * …AND ITS SCRATCH BESIDE IT, EMPTY (0.115.0): a fenced shell's temp files
253
+ * …AND ITS SCRATCH BESIDE IT (0.115.0): a fenced shell's temp files
251
254
  * (a here-document, `mktemp`) go under `.flowviant/tmp`, which Codex's
252
255
  * design fence grants because `/tmp` is read-only in there
253
256
  * (`ensureFenceScratch`). Made for every file kind — one rule, no kind
@@ -427,6 +430,12 @@ export function createAgentTurnExecution({
427
430
  artifactsBefore.imageRevision = job.imageRevision;
428
431
  artifactInstructions += '\n\n' + imageRevisionInstructions(job.imageRevision, job.resultInputs);
429
432
  }
433
+ const cleanupPath = sessionMetaPath(wt, 'flowviant-managed-local', agentId);
434
+ const localCleanup = PROJECT_ID && cleanupPath ? createManagedLocalCleanup({
435
+ placeDir: wt, projectId: PROJECT_ID, agentId, path: cleanupPath,
436
+ codexHome: agentResume.codexHome,
437
+ outputCache: artifacts.cacheFor?.({ placeDir: wt, agentId }) ?? null,
438
+ }) : null;
430
439
  let projectTools = null;
431
440
  let orchestrator = null;
432
441
  let intermediateDelivery = null;
@@ -443,6 +452,7 @@ export function createAgentTurnExecution({
443
452
  }
444
453
  const tools = prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources: repositoryRun?.sources ?? [], repositoryTool: repositoryRun?.tool ?? null, orchestratorConfig: orchestrator?.config ?? null, orchestrated: Boolean(orchestrator), codexHome: agentResume.codexHome });
445
454
  projectTools = tools.projectTools;
455
+ localCleanup?.begin(turnId);
446
456
  const { toolsNote } = tools;
447
457
  const agentTurnArgs = {
448
458
  ...((liveInput || orchestrator) ? { liveMessages: (run.live = makeLiveMessages({
@@ -730,6 +740,7 @@ export function createAgentTurnExecution({
730
740
  // The ran marker and the thread this turn spoke under, written now
731
741
  // that the turn is over (workAgentResume.mjs).
732
742
  agentResume.persist();
743
+ localCleanup?.capture();
733
744
  /**
734
745
  * AN ACTION THAT CHANGES WHAT THE MACHINE WOULD MEASURE MUST CAUSE A
735
746
  * NEW MEASUREMENT — the rule every session settle keeps, and the one
@@ -794,6 +805,19 @@ export function createAgentTurnExecution({
794
805
  modelExports: intermediateDelivery?.modelExports,
795
806
  });
796
807
  if (body.outcome === 'delivered') intermediateDelivery?.commit();
808
+ // This place's writer lock is still held. Measure the exited children
809
+ // and their groups before releasing any file requirements. Dirty repository files are unrelated to managed scratch eligibility.
810
+ if (localCleanup) {
811
+ const cleanupResults = localCleanup.capture(); // binds newly uploaded exact image counterparts
812
+ const groups = new Set([...spawnedChildren].map(ch => ch.pid).filter(pid => Number.isInteger(pid) && pid > 1));
813
+ const drained = !run.interrupted && processesSupported() && groups.size > 0 &&
814
+ [...spawnedChildren].every(ch => ch.exitCode != null || ch.signalCode != null) && liveGroups(groups).size === 0;
815
+ cleanupResults.push(...localCleanup.completeTurn({ drained,
816
+ handoffComplete: body.outcome === 'delivered', uploadsComplete: artifacts.pendingCount?.() === 0 }));
817
+ cleanupResults.push(...await localCleanup.reclaim({ drained }));
818
+ for (const result of cleanupResults) reportLocalCleanup({ ...result,
819
+ projectId: PROJECT_ID, agentId, placeDir: wt, place, turnId });
820
+ }
797
821
  if (res) setRuntimeLimit(PROJECT_ID, rt, null);
798
822
  if (body.outcome === 'delivered' && job.task?.taskType === 'tests' && res?.testFindings) {
799
823
  body.testFindings = measureTestFindings(res.testFindings, repositoryRun?.sourcesNow() ?? [{ wt, baseRef: baseRef(), id: job.repos?.length === 1 ? job.repos[0] : null }], envScrub);
@@ -831,7 +855,7 @@ export function createAgentTurnExecution({
831
855
  // pre-review HERE, in the worktree we are already standing in and still
832
856
  // hold the lock on — see `runReviewEntry`. Only after the settle that may
833
857
  // say delivered: the three `nothing` settles never opened it.
834
- if (final && reply?.review === true) await runReviewEntry(agentId, wt, job.agentName);
858
+ if (final && reply?.review === true) await runReviewEntry(agentId, wt, job.agentName, undefined, job);
835
859
  } finally { await repositoryRun?.tool?.close(); }
836
860
  };
837
861
 
@@ -322,7 +322,7 @@ export function createWorkAgentTurns({
322
322
  const wt = typeof held.body.worktree === 'string' ? held.body.worktree : null;
323
323
  if (reply?.review === true && isSafePathSegment(place) && wt && existsSync(wt)) {
324
324
  await inPlace(place, place.startsWith(AGENT_PLACE_PREFIX), () =>
325
- runReviewEntry(String(job.agentId || ''), wt, job.agentName)
325
+ runReviewEntry(String(job.agentId || ''), wt, job.agentName, undefined, job)
326
326
  );
327
327
  }
328
328
  })()
@@ -0,0 +1,125 @@
1
+ /** Startup/idle runner for local-only cleanup. Recovery I/O is outside the
2
+ * writer lock; only a fresh prepared version may enter the short unlink phase. */
3
+ import { randomUUID } from 'node:crypto';
4
+ import { cleanupState } from './localCleanupState.mjs';
5
+ import { agentIdFromPlace } from './workPlaces.mjs';
6
+
7
+ export const LOCAL_CLEANUP_INTERVAL_MS = 15_000;
8
+ export const LOCAL_CLEANUP_BACKOFF_MS = 60_000;
9
+ const MAX_BACKOFF = 60 * 60_000;
10
+ const keyOf = ref => JSON.stringify([ref.kind, ref.name, ref.localVersion]);
11
+
12
+ export function createWorkLocalCleanup({ placeLocks, inPlace, targetsFor, eligible, permission = () => false,
13
+ now = Date.now, log = () => {}, onResult = () => {}, onIncomplete = () => {} }) {
14
+ let snapshot = null, sweeping = null, lastAt = -Infinity, placeCursor = 0;
15
+ const targetCursors = new Map();
16
+ const allowed = (place, locked = false) => {
17
+ if (!snapshot || snapshot.serving !== true || snapshot.draining !== false ||
18
+ !Array.isArray(snapshot.activeIds) || !Array.isArray(snapshot.heldElsewhere) ||
19
+ now() < snapshot.at || now() - snapshot.at > 15_000 || permission() !== true ||
20
+ !snapshot.activeIds.includes(place) || snapshot.heldElsewhere.includes(place)) return false;
21
+ const lock = placeLocks.get(place);
22
+ if (locked ? lock?.waiters?.length > 0 : placeLocks.has(place)) return false;
23
+ return eligible(place) === true;
24
+ };
25
+ const attemptTarget = async (place, target) => {
26
+ const identity = JSON.stringify({ place, placeDir: target.placeDir, agentId: target.agentId, projectId: target.projectId });
27
+ const store = cleanupState(target.statePath, identity), release = store.claim();
28
+ if (!release) return;
29
+ let locked = false;
30
+ const guard = () => allowed(place, locked);
31
+ const memo = new Map(); let preparedOnly = false;
32
+ try {
33
+ if (!guard()) return;
34
+ const state = store.load();
35
+ // Replay durable outcomes before selecting work, including when every
36
+ // version is already evicted. Stable ids make replay retry-safe.
37
+ const publish = () => {
38
+ let changed = false;
39
+ for (const entry of state.results) {
40
+ if (entry.reported === true) continue;
41
+ if (onResult(entry) !== false) { entry.reported = true; changed = true; }
42
+ }
43
+ if (changed) store.save(state);
44
+ };
45
+ publish();
46
+ for (const attempt of Object.values(state.attempts)) {
47
+ if (attempt.state !== 'running' || attempt.interruptionReported) continue;
48
+ if (onIncomplete({ id: `interrupted:${attempt.id}`, status: 'retained',
49
+ reason: 'interrupted-attempt-completion-unknown', incomplete: true,
50
+ projectId: target.projectId, agentId: target.agentId, placeDir: target.placeDir,
51
+ place, at: now(), nextAt: attempt.nextAt, tries: attempt.tries,
52
+ resumedAttempt: attempt.id, previousCompletion: 'unknown' }) !== false) {
53
+ attempt.interruptionReported = true; store.save(state);
54
+ }
55
+ }
56
+ const operations = target.operations({ guard, recoveryMemo: memo, preparedOnly: () => preparedOnly });
57
+ if (!operations.length) return;
58
+ let chosen = null;
59
+ for (let i = 0; i < Math.min(32, operations.length); i++) {
60
+ const index = (state.cursor + i) % operations.length, op = operations[index], key = keyOf(op.ref);
61
+ const previous = state.attempts[key];
62
+ if (op.ref.state === 'discarded' || (op.ref.state === 'evicted' && previous?.state !== 'running')) continue;
63
+ if (previous && previous.nextAt > now()) continue;
64
+ chosen = { op, key, previous }; state.cursor = (index + 1) % operations.length; break;
65
+ }
66
+ if (!chosen) { state.cursor = (state.cursor + 32) % operations.length; store.save(state); return; }
67
+ const { op, key, previous } = chosen, id = randomUUID();
68
+ const tries = Math.min(100, (previous?.tries ?? 0) + 1);
69
+ state.attempts[key] = { id, state: 'running', tries, nextAt: now() + LOCAL_CLEANUP_BACKOFF_MS, startedAt: now() };
70
+ // Persist before remote verification/unlink. A restart honours backoff
71
+ // and re-runs the prerequisite's current version/pin/recovery checks.
72
+ store.save(state);
73
+ let result = await op.prepare();
74
+ if (result.status === 'ready') {
75
+ if (!guard() || placeLocks.has(place)) result = { status: 'retained', reason: 'lifecycle-changed' };
76
+ else await inPlace(place, true, async () => {
77
+ locked = true; preparedOnly = true;
78
+ try {
79
+ if (!guard()) { result = { status: 'retained', reason: 'lifecycle-changed' }; return; }
80
+ const current = target.operations({ guard, recoveryMemo: memo, preparedOnly: () => preparedOnly })
81
+ .find(candidate => keyOf(candidate.ref) === key);
82
+ result = current ? await current.run() : { status: 'retained', reason: 'local-version-changed' };
83
+ } finally { locked = false; }
84
+ });
85
+ }
86
+ const retryable = result.status === 'failed' || result.incomplete === true || /remote|recovery|unreadable|unlink-failed/.test(result.reason ?? '');
87
+ const nextAt = now() + (retryable ? Math.min(MAX_BACKOFF, LOCAL_CLEANUP_BACKOFF_MS * 2 ** Math.min(tries - 1, 6)) : LOCAL_CLEANUP_BACKOFF_MS);
88
+ const entry = { ...result, id, kind: op.ref.kind, name: op.ref.name, localVersion: op.ref.localVersion,
89
+ projectId: target.projectId, agentId: target.agentId, placeDir: target.placeDir, place, at: now(),
90
+ bytes: result.status === 'reclaimed' ? result.bytes : 0, nextAt, tries,
91
+ ...(previous?.state === 'running' ? { resumedAttempt: previous.id, previousCompletion: 'unknown' } : {}) };
92
+ state.attempts[key] = { id, state: result.incomplete ? 'incomplete' : result.status, tries: retryable ? tries : 0, nextAt, result: entry };
93
+ state.results.push(entry); state.results = state.results.slice(-128);
94
+ const liveKeys = new Set(operations.map(candidate => keyOf(candidate.ref)));
95
+ for (const old of Object.keys(state.attempts)) if (!liveKeys.has(old)) delete state.attempts[old];
96
+ store.save(state);
97
+ publish();
98
+ } finally { memo.clear(); release(); }
99
+ };
100
+ const reconcileLocalCleanup = evidence => {
101
+ // Refresh even while an older verification is in flight. Lost serving
102
+ // permission, new foreground work and peer leases invalidate its guard.
103
+ snapshot = { ...evidence, at: now() };
104
+ if (sweeping || now() - lastAt < LOCAL_CLEANUP_INTERVAL_MS || permission() !== true ||
105
+ evidence?.serving !== true || evidence?.draining !== false || !Array.isArray(evidence.activeIds) || !Array.isArray(evidence.heldElsewhere)) return sweeping;
106
+ const places = evidence.activeIds.filter(place => agentIdFromPlace(place) && /^[A-Za-z0-9_-]+$/.test(place));
107
+ for (const place of targetCursors.keys()) if (!places.includes(place)) targetCursors.delete(place);
108
+ if (!places.length) return null;
109
+ lastAt = now(); const place = places[placeCursor++ % places.length];
110
+ if (!allowed(place)) return null;
111
+ sweeping = (async () => {
112
+ try {
113
+ const targets = targetsFor(place);
114
+ if (!targets.length) return;
115
+ const cursor = targetCursors.get(place) ?? 0;
116
+ const target = targets[cursor % targets.length];
117
+ targetCursors.set(place, cursor + 1);
118
+ await attemptTarget(place, target);
119
+ } catch (error) { log(`local cleanup incomplete: ${error.message}`); }
120
+ finally { sweeping = null; }
121
+ })();
122
+ return sweeping;
123
+ };
124
+ return { reconcileLocalCleanup, cleanupBusy: () => sweeping !== null };
125
+ }
@@ -9,7 +9,7 @@ import { mergeBatchPrompt, MERGE_BATCH_SYSTEM } from './repositoryPrompts.mjs';
9
9
  import { runTurn } from './runTurn.mjs';
10
10
  import { runCappedTurn } from './workCappedTurn.mjs';
11
11
  import { candidateObjects } from './modelJson.mjs';
12
- import { pickRuntimeFor } from './runtimeDetection.mjs';
12
+ import { brainFor } from './workBrain.mjs';
13
13
  import { fleetRequest } from './fleetPost.mjs';
14
14
  import { fleetEndpoint } from './fleetWire.mjs';
15
15
  import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
@@ -20,6 +20,22 @@ import { isSafePathSegment, stripUrlUserinfo } from './git.mjs';
20
20
  import { killGroup } from './processGroup.mjs';
21
21
  import { readConfiguredCheck } from './workAgentCheck.mjs';
22
22
 
23
+ /** A joint repair has no unique originating brain when its members differ. */
24
+ export function mergeBatchBrain(members, coordinatorAgentId) {
25
+ if (!members?.length) throw new Error('The selected merge has no originating agent.');
26
+ if (coordinatorAgentId) {
27
+ const selected = members.find(member => member.agentId === coordinatorAgentId);
28
+ if (!selected) throw new Error('The selected merge coordinator is not part of this merge.');
29
+ const runtime = selected.runtime || 'claude';
30
+ return { runtime, ...brainFor(selected, runtime) };
31
+ }
32
+ const picks = members.map(member => ({ runtime: member.runtime || 'claude', model: member.model || null, effort: member.effort || null }));
33
+ const first = picks[0];
34
+ if (picks.some(pick => pick.runtime !== first.runtime || pick.model !== first.model || pick.effort !== first.effort))
35
+ throw new Error('The selected agents use different CLI/model/effort choices. Merge them separately so repair uses each agent’s own choice.');
36
+ return { runtime: first.runtime, ...brainFor(first, first.runtime) };
37
+ }
38
+
23
39
  export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPlace, runCheck, workChildren, groupKillChildren, admit, onRepoChanged, landed, runCli = runTurn }) {
24
40
  const request = async (route, body) => {
25
41
  const response = await fleetRequest(fleetEndpoint(route, FLEET_URL), { body: { ...body, instance: DAEMON_INSTANCE } });
@@ -64,15 +80,14 @@ export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPl
64
80
  if (!result?.ok) throw new Error('Batch ownership or progress changed. Retry will remeasure base.');
65
81
  },
66
82
  coordinate: async (context) => {
67
- const runtime = leader.runtime || pickRuntimeFor('build');
68
- if (!runtime) throw new Error('No coding CLI is installed on this machine to coordinate the batch.');
83
+ const brain = mergeBatchBrain(spec.members, spec.coordinatorAgentId);
69
84
  let child;
70
85
  try {
71
86
  const result = await runCappedTurn({ runTurn: runCli, workChildren, releaseSlot: release,
72
87
  transcriptCwd: context.repositories[0].integrationWorktree, capMs: 20 * 60_000, killChild: killGroup,
73
88
  onSpawn: (ch) => { child = ch; groupKillChildren?.add(ch); },
74
89
  turnArgs: { prompt: mergeBatchPrompt(context), system: MERGE_BATCH_SYSTEM,
75
- runtime, profile: 'build', knowledgeDir: knowledgeDirFor(repoRoot), model: leader.model || undefined, effort: leader.effort || undefined,
90
+ ...brain, profile: 'build', knowledgeDir: knowledgeDirFor(repoRoot),
76
91
  cwd: context.repositories[0].integrationWorktree, writableDirs: context.repositories.filter((r) => !r.alreadyLanded).map((r) => r.integrationWorktree),
77
92
  answerFromResult: true, work } });
78
93
  if (result.wedged) throw new Error('The coordinating CLI ran out of time. Retry the batch to continue with its saved integration work.');
@@ -11,6 +11,7 @@
11
11
  * manager against a real registry file.
12
12
  */
13
13
  import { join } from 'node:path';
14
+ import { constants, openSync, closeSync, fstatSync, readFileSync } from 'node:fs';
14
15
  import { homedir } from 'node:os';
15
16
  import { createHash } from 'node:crypto';
16
17
  import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
@@ -162,5 +163,23 @@ export function createWorkSessionGroups({ repoRoot }) {
162
163
  return measureProcesses(alive, { scrub: envScrub });
163
164
  };
164
165
 
165
- return { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses };
166
+ /** Cleanup needs stronger evidence than a display: reload the durable
167
+ * registry to observe peer/orphan groups, and retain on missing/bad reads. */
168
+ const cleanupProcessesIdle = place => {
169
+ if (!processesSupported()) return null;
170
+ let fd;
171
+ try {
172
+ fd = openSync(GROUPS_FILE, constants.O_RDONLY | constants.O_NOFOLLOW);
173
+ const st = fstatSync(fd);
174
+ if (!st.isFile() || st.size > 256 * 1024) return null;
175
+ const entries = JSON.parse(readFileSync(fd, 'utf8'));
176
+ if (!Array.isArray(entries) || entries.length >= 32 || entries.some(e =>
177
+ typeof e?.sessionId !== 'string' || !Number.isInteger(e.pid) || e.pid <= 1 || typeof e.boot !== 'string' || !e.boot)) return null;
178
+ const groups = new Set(sessionGroups.get(place) ?? []);
179
+ for (const entry of entries) if (entry.sessionId === place && sameBoot(entry.boot, BOOT)) groups.add(entry.pid);
180
+ return liveGroups(groups).size === 0;
181
+ } catch { return null; }
182
+ finally { if (fd !== undefined) closeSync(fd); }
183
+ };
184
+ return { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses, cleanupProcessesIdle };
166
185
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.125.17",
3
+ "version": "0.125.20",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {