flowviant 0.124.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 (59) hide show
  1. package/README.md +6 -0
  2. package/bin/cli.mjs +7 -0
  3. package/bin/lib/agentInternalWait.mjs +2 -1
  4. package/bin/lib/agentPlan.mjs +8 -0
  5. package/bin/lib/agentRepositories.mjs +48 -0
  6. package/bin/lib/checkoutIdentity.mjs +25 -0
  7. package/bin/lib/claudeInput.mjs +32 -0
  8. package/bin/lib/claudeSessions.mjs +20 -0
  9. package/bin/lib/credentials.mjs +52 -17
  10. package/bin/lib/daemonLocks.mjs +21 -3
  11. package/bin/lib/fleet.mjs +19 -6
  12. package/bin/lib/fleetRoster.mjs +1 -89
  13. package/bin/lib/fleetStartup.mjs +11 -14
  14. package/bin/lib/holder.mjs +4 -37
  15. package/bin/lib/instance.mjs +20 -7
  16. package/bin/lib/landed.mjs +2 -2
  17. package/bin/lib/login.mjs +39 -4
  18. package/bin/lib/placeLock.mjs +9 -7
  19. package/bin/lib/prWorkflow.mjs +15 -0
  20. package/bin/lib/projectLanded.mjs +16 -0
  21. package/bin/lib/projectRepositories.mjs +60 -0
  22. package/bin/lib/projectTools.mjs +2 -1
  23. package/bin/lib/projectWorktreeHome.mjs +25 -0
  24. package/bin/lib/prompts.mjs +6 -1
  25. package/bin/lib/repositoryDelivery.mjs +64 -0
  26. package/bin/lib/repositoryMerge.mjs +116 -0
  27. package/bin/lib/repositoryPlaces.mjs +179 -0
  28. package/bin/lib/repositorySetup.mjs +58 -0
  29. package/bin/lib/repositoryTool.mjs +71 -0
  30. package/bin/lib/runTurn.mjs +23 -2
  31. package/bin/lib/runtimeAntigravity.mjs +2 -1
  32. package/bin/lib/runtimeClaude.mjs +5 -2
  33. package/bin/lib/runtimeCodex.mjs +2 -1
  34. package/bin/lib/shipMerge.mjs +5 -2
  35. package/bin/lib/startProject.mjs +4 -4
  36. package/bin/lib/trace.mjs +8 -3
  37. package/bin/lib/turnDeclaration.mjs +88 -0
  38. package/bin/lib/turnLock.mjs +48 -8
  39. package/bin/lib/work.mjs +22 -24
  40. package/bin/lib/workAgentCheck.mjs +6 -5
  41. package/bin/lib/workAgentMerges.mjs +19 -2
  42. package/bin/lib/workAgentPrecheck.mjs +5 -5
  43. package/bin/lib/workAgentPublish.mjs +18 -5
  44. package/bin/lib/workAgentRecovery.mjs +85 -0
  45. package/bin/lib/workAgentReview.mjs +5 -3
  46. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  47. package/bin/lib/workAgentTurnExecution.mjs +76 -9
  48. package/bin/lib/workAgentTurnOutcome.mjs +9 -3
  49. package/bin/lib/workAgentTurns.mjs +33 -9
  50. package/bin/lib/workDelivery.mjs +21 -0
  51. package/bin/lib/workDiffs.mjs +18 -11
  52. package/bin/lib/workLiveMessages.mjs +129 -0
  53. package/bin/lib/workPlaceReports.mjs +5 -0
  54. package/bin/lib/workPlaces.mjs +6 -1
  55. package/bin/lib/workRetire.mjs +5 -0
  56. package/bin/lib/workSessionTurns.mjs +72 -40
  57. package/bin/lib/workShip.mjs +17 -16
  58. package/package.json +1 -1
  59. package/npm-shrinkwrap.json +0 -69
@@ -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,6 +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 { readRepositoryPlace } from './repositoryPlaces.mjs';
15
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';
@@ -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
  /**
@@ -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);
@@ -32,6 +32,8 @@ import { isAgyConversationLive } from './agySessions.mjs';
32
32
  import { adoptFence } from './localSessions.mjs';
33
33
  import { resumeRetriesFresh } from './resumeLost.mjs';
34
34
  import { turnLockedByLivePid } from './turnLock.mjs';
35
+ import { createDeliveryReporter } from './workDelivery.mjs';
36
+ import { createLiveMessages } from './workLiveMessages.mjs';
35
37
  import { killGroup } from './processGroup.mjs';
36
38
  import { printable } from './printable.mjs';
37
39
  import { brainFor } from './workBrain.mjs';
@@ -84,6 +86,8 @@ export function createWorkSessionTurns({
84
86
  runTurn = defaultRunTurn,
85
87
  // The clock `settledHere` ages by; injectable only for its bound's test.
86
88
  now = Date.now,
89
+ makeLiveMessages = createLiveMessages,
90
+ reportDelivery = createDeliveryReporter(),
87
91
  }) {
88
92
  const workAnswering = new Set(); // turn ids currently queued/running here
89
93
  const MAX_WORK_TRIES = 3;
@@ -144,12 +148,14 @@ export function createWorkSessionTurns({
144
148
  settledHere.delete(turnId); // re-inserted at the end: the map stays in time order
145
149
  settledHere.set(turnId, at);
146
150
  forgetStale(at);
147
- return settleWorkTurn(turnId, payload);
151
+ const liveMessages = runs.get(turnId)?.live?.receipts();
152
+ return settleWorkTurn(turnId, { ...payload, ...(liveMessages?.ids.length ? { liveMessages } : {}) });
148
153
  };
149
154
 
150
155
  /**
151
- * A PLACE A TURN OF THIS PROCESS IS STARTING IN (2026-09-29) — the turn
152
- * lock's in-process half, keyed by the lock's own path.
156
+ * A TAB A TURN OF THIS PROCESS IS STARTING IN (2026-09-29; per tab since
157
+ * 2026-10-14) — the turn lock's in-process half, keyed by the lock's own
158
+ * path.
153
159
  *
154
160
  * `flowviant-turn.lock` is written at SPAWN, several awaits after it is
155
161
  * read (a credential mint, the attachment fetch), so two turns of one
@@ -159,7 +165,8 @@ export function createWorkSessionTurns({
159
165
  * check, so the second turn waits for a later poll like any locked one,
160
166
  * and it is released in the turn's outer `finally`, after its settle.
161
167
  */
162
- const startingIn = new Set();
168
+ const startingIn = new Map(); // lock path -> the holding tab's measured name
169
+
163
170
  // A roster can contain a turn read before its stop was requested. Never
164
171
  // start that stale job after acknowledging the same roster's interrupt.
165
172
  let rosterInterrupts = new Set();
@@ -168,13 +175,23 @@ export function createWorkSessionTurns({
168
175
  for (const job of jobs ?? []) {
169
176
  if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
170
177
  if (rosterInterrupts.has(job.id)) continue;
171
- if (workAnswering.has(job.id)) continue;
178
+ if (workAnswering.has(job.id)) {
179
+ const reason = runs.get(job.id)?.waitingReason;
180
+ if (reason) reportDelivery('session', job, 'queued', reason);
181
+ continue;
182
+ }
172
183
  // Settled by this lane already: a roster read from before the settle
173
184
  // landed. See `settledHere`.
174
185
  if (settledRecently(job.id)) continue;
175
186
  // The turn already RAN and its answer sits in the delivery queue — never
176
187
  // run it again while the report is merely undelivered.
177
188
  if (pendingWorkReports.has(job.id)) continue;
189
+ // This conversation already has a streaming Claude. Its input relay
190
+ // reads the durable queue; a second process must never read these words.
191
+ if ([...runs.values()].some((r) => r.sessionId === job.sessionId && r.live?.connected() && !r.interrupted)) {
192
+ reportDelivery('session', job, 'queued', 'waiting for this turn to take in the message');
193
+ continue;
194
+ }
178
195
  /**
179
196
  * THE BOX IS ABOUT TO FALL OVER, OR THIS MACHINE IS ALREADY AT ITS
180
197
  * CEILING. Defer: return without settling and without consuming an
@@ -195,12 +212,13 @@ export function createWorkSessionTurns({
195
212
  const hold = admit('interactive');
196
213
  if (hold) {
197
214
  sayTurnDeferred(job.sessionId, job.id, hold.reason);
215
+ reportDelivery('session', job, 'queued', hold.reason);
198
216
  continue;
199
217
  }
200
218
  const releaseSlot = admit.reserve();
201
219
  lastDeferSaid.delete(job.sessionId);
202
220
  workAnswering.add(job.id);
203
- const run = { interrupted: false, spawned: [] };
221
+ const run = { interrupted: false, spawned: [], sessionId: job.sessionId, live: null };
204
222
  runs.set(job.id, run);
205
223
  /** The execution generation the server handed, echoed on every settle
206
224
  * so the server can tell this execution's answer from an older one's. */
@@ -236,6 +254,7 @@ export function createWorkSessionTurns({
236
254
  }
237
255
  // A READER: other turns in this place run alongside it. See `inPlace`.
238
256
  inPlace(place, false, async () => {
257
+ run.waitingReason = null;
239
258
  /** This turn's artifact snapshot — set once the place is resolved and
240
259
  * a CLI is about to spawn there; read in the `finally`. Null on every
241
260
  * path that never ran a CLI, which is exactly when nothing is new. */
@@ -404,23 +423,37 @@ export function createWorkSessionTurns({
404
423
  });
405
424
  return;
406
425
  }
407
- // A live CLI is ALREADY in this worktree — this daemon's previous
408
- // life, most likely; the lock outlives a restart. Leave the job
409
- // pending and look again next poll; spawning a second CLI would put
410
- // two Claudes in one held context. Costs no attempt: nothing ran.
426
+ // A live CLI is ALREADY in this tab's conversation — this daemon's
427
+ // previous life, most likely; the lock outlives a restart. Leave the
428
+ // job pending and look again next poll; spawning a second CLI would
429
+ // put two Claudes in one held context. Costs no attempt: nothing ran.
411
430
  // …AND ONE OF OURS MAY BE ABOUT TO: `startingIn`, checked and
412
431
  // claimed with no await between, because the file is only written
413
- // at spawn. Keyed by the lock's path (the directory, when the git
414
- // dir cannot be named) — the file lock's own scope.
415
- const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
416
- const startKey = lockPath || dir.wt;
432
+ // at spawn. Keyed by the lock's path (the directory and the tab,
433
+ // when the git dir cannot be named) — the file lock's own scope.
434
+ // PER TAB (2026-10-14, turnLock.mjs): other tabs in this directory
435
+ // run beside it. The unscoped file is an older daemon's CLI, whose
436
+ // tab is unknown, so it holds every tab here until it exits.
437
+ const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock', job.sessionId);
438
+ const legacyLock = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
439
+ const startKey = lockPath || `${dir.wt}\0${job.sessionId}`;
440
+ const tabId = printable(job.sessionId);
441
+ const tabLabel = `tab "${printable(job.sessionName) || tabId}" (${tabId})`;
442
+ if (turnLockedByLivePid(legacyLock)) {
443
+ reportDelivery('session', job, 'queued', 'waiting for another tab’s turn to finish (legacy lock; tab unknown)');
444
+ warn(
445
+ `a turn from an earlier flowviant holds the place; tab unknown (legacy lock) — ${tabLabel} waits for it to finish`
446
+ );
447
+ return;
448
+ }
417
449
  if (startingIn.has(startKey) || turnLockedByLivePid(lockPath)) {
450
+ reportDelivery('session', job, 'queued', 'waiting for this turn to finish');
418
451
  warn(
419
- `a turn is already running in "${printable(job.sessionName) || job.sessionId}" — waiting for it to finish`
452
+ `${startingIn.get(startKey) || tabLabel} holds the turn lock — waiting for it to finish`
420
453
  );
421
454
  return;
422
455
  }
423
- startingIn.add(startKey);
456
+ startingIn.set(startKey, tabLabel);
424
457
  startClaim = startKey;
425
458
  // WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
426
459
  // which is what every tab ran on until now) — honored by
@@ -683,6 +716,17 @@ export function createWorkSessionTurns({
683
716
  .filter(Boolean)
684
717
  .join('\n\n');
685
718
  const turnArgs = {
719
+ ...(rt.id === 'claude' ? { liveMessages: (run.live = makeLiveMessages({
720
+ scope: 'session', turnId: job.id, attempt: job.attempt, cwd: dir.wt, sessionMetaPath,
721
+ interrupted: () => run.interrupted,
722
+ format: async (followup) => {
723
+ const paths = await fetchAttachments(dir.wt, followup.attachments);
724
+ const note = paths.length ? `[FILES THE HUMAN ATTACHED TO THIS MESSAGE — already on disk in this worktree]\n${paths.map((f) => `- ${f}`).join('\n')}` : '';
725
+ const words = shape.capture ? (followup.libraryPrompt ?? '') + followup.body : followup.body + (followup.libraryPrompt ?? '');
726
+ return sessionTurnKickoff(shape, { sessionId: job.sessionId, sessionName: job.sessionName,
727
+ askedByName: followup.askedByName, message: [words, note].filter(Boolean).join('\n\n') });
728
+ },
729
+ })) } : {}),
686
730
  // The kickoff under the turn's contract: a plain tab (or a plan
687
731
  // turn) has no tools to name and no session id to pass, and asks
688
732
  // for one complete report instead of a stream; a capture chat
@@ -767,13 +811,15 @@ export function createWorkSessionTurns({
767
811
  // a tab's build turn is the machine's measurement; its fenced
768
812
  // capture chat is not.
769
813
  onInit: (i) => {
770
- // The conversation this turn is actually speaking under. Held
771
- // and persisted after the turn ends, so the NEXT one resumes
772
- // this exact thread rather than whatever the directory saw
773
- // last. Last write wins on purpose: a resume that fell back to
774
- // fresh reports the fresh id, healing the marker.
775
- if (typeof i.sessionId === 'string' && i.sessionId.trim())
814
+ // Pin at init so a restart during a long tool can resume the
815
+ // same conversation and recover its native message UUIDs.
816
+ if (typeof i.sessionId === 'string' && i.sessionId.trim()) {
776
817
  seenClaudeSession = i.sessionId.trim();
818
+ if (CLAUDE_SESSION_RE.test(seenClaudeSession)) {
819
+ const marker = sessionMetaPath(dir.wt, RESUME_MARKERS.claude, job.sessionId);
820
+ if (marker) try { writeFileSync(marker, seenClaudeSession); } catch { /* best effort */ }
821
+ }
822
+ }
777
823
  },
778
824
  cwd: dir.wt,
779
825
  mcpArgs: mcp.args,
@@ -792,6 +838,7 @@ export function createWorkSessionTurns({
792
838
  },
793
839
  onSpawn: (ch) => {
794
840
  if (!ch) return;
841
+ if (rt.id !== 'claude') reportDelivery('session', job, 'taken');
795
842
  spawned.push(ch);
796
843
  run.spawned.push(ch);
797
844
  // Asked to stop between the check and the spawn.
@@ -883,24 +930,6 @@ export function createWorkSessionTurns({
883
930
  // before it ever touches disk — it later rides in argv as
884
931
  // `resume <id>` — and best-effort, like the runtime pin: an
885
932
  // unwritable marker just means the tab runs fresh next turn.
886
- // The conversation THIS TAB just spoke under, pinned so the next
887
- // turn resumes it by id rather than asking the directory. Written
888
- // after the turn for the same reason codex's is: an id learned
889
- // mid-turn is only true once the turn that learned it finished.
890
- if (
891
- rt.id === 'claude' &&
892
- seenClaudeSession &&
893
- CLAUDE_SESSION_RE.test(seenClaudeSession)
894
- ) {
895
- const convMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.claude, job.sessionId);
896
- if (convMarker) {
897
- try {
898
- writeFileSync(convMarker, seenClaudeSession);
899
- } catch {
900
- /* best-effort — the next turn re-learns it */
901
- }
902
- }
903
- }
904
933
  if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
905
934
  const threadMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.codex, job.sessionId);
906
935
  if (threadMarker) {
@@ -1069,6 +1098,9 @@ export function createWorkSessionTurns({
1069
1098
  .catch(() => {});
1070
1099
  }
1071
1100
  }
1101
+ }, () => {
1102
+ run.waitingReason = 'waiting for a ship to finish';
1103
+ reportDelivery('session', job, 'queued', run.waitingReason);
1072
1104
  });
1073
1105
  }
1074
1106
  };
@@ -7,7 +7,7 @@ import { gitMergeIn } from './gitMergeIdentity.mjs';
7
7
  import { c, note, ok, warn } from './ui.mjs';
8
8
  import { scrub as envScrub } from './uplinkScrub.mjs';
9
9
  import { REPO_PLACE } from './workPlaces.mjs';
10
- import { turnLockedByLivePid } from './turnLock.mjs';
10
+ import { placeTurnLockHolder } from './turnLock.mjs';
11
11
  import { printable } from './printable.mjs';
12
12
 
13
13
  export function createWorkShipper({
@@ -227,11 +227,24 @@ export function createWorkShipper({
227
227
  ok(`${c.cyan('ship')} ${c.dim('— already on main; nothing new to merge')}`);
228
228
  return;
229
229
  }
230
+ // Any tab's orphaned CLI holds this shared HEAD across a restart.
231
+ // This also applies when the SHIPPING tab is closed: another tab
232
+ // can still be editing its place. Query the directory without
233
+ // re-cutting a retired worktree, after the idempotent receipt path.
234
+ const holder = placeTurnLockHolder(sessionMetaPath(wt, 'flowviant-turn.lock'));
235
+ if (holder) {
236
+ const holderTab = holder.sessionId === null ? 'tab unknown (legacy lock)' : `tab "${printable(holder.sessionId)}"`;
237
+ warn(
238
+ `${holderTab} holds the turn lock — ship of "${sessionName || job.sessionId}" waits for it`
239
+ );
240
+ deferred = true;
241
+ return;
242
+ }
230
243
  const ended = liveIds ? !liveIds.has(job.sessionId) : false;
231
244
  if (ended) {
232
- // The tab is closed: no turn can commit, discard, or resolve
233
- // anything in the directory, so a dirty worktree must not strand
234
- // the branch's committed work in review forever. Ship the TIP.
245
+ // The tab is closed: it cannot commit, discard, or resolve, so
246
+ // a dirty worktree must not strand its committed work in review
247
+ // forever. With every other tab's CLI quiet, ship the TIP.
235
248
  if (!branchExists) {
236
249
  await done({
237
250
  ok: false,
@@ -284,18 +297,6 @@ export function createWorkShipper({
284
297
  });
285
298
  return;
286
299
  }
287
- // A live CLI is in this worktree — a restarted daemon's orphan
288
- // mid-turn (in-process the chain serializes, but the lock is the
289
- // only guarantee that survives a crash). Folding under it would
290
- // rewrite HEAD inside a held conversation; defer like the turn
291
- // path, and the job re-offers next poll.
292
- if (turnLockedByLivePid(sessionMetaPath(dir.wt, 'flowviant-turn.lock'))) {
293
- warn(
294
- `a turn is still running in "${sessionName || job.sessionId}" — ship waits for it`
295
- );
296
- deferred = true;
297
- return;
298
- }
299
300
  if (git(['status', '--porcelain'], dir.wt) !== '') {
300
301
  await done({
301
302
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.124.0",
3
+ "version": "0.124.1",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — 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": {
@@ -1,69 +0,0 @@
1
- {
2
- "name": "flowviant",
3
- "version": "0.124.0",
4
- "lockfileVersion": 3,
5
- "requires": true,
6
- "packages": {
7
- "": {
8
- "name": "flowviant",
9
- "version": "0.124.0",
10
- "license": "MIT",
11
- "dependencies": {
12
- "libsodium-wrappers-sumo": "^0.8.4",
13
- "ws": "^8.18.0"
14
- },
15
- "bin": {
16
- "flowviant": "bin/cli.mjs"
17
- },
18
- "devDependencies": {
19
- "three": "0.180.0"
20
- },
21
- "engines": {
22
- "node": ">=20"
23
- }
24
- },
25
- "node_modules/libsodium-sumo": {
26
- "version": "0.8.4",
27
- "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.4.tgz",
28
- "integrity": "sha512-TMtHShQfVVsaxDygyapvUC3o7YsPgXa/hRWeIgzyFz6w5k/1hirGptCxp1U7XwW3rCskaTTYKgV10v86UiGgNw==",
29
- "license": "ISC"
30
- },
31
- "node_modules/libsodium-wrappers-sumo": {
32
- "version": "0.8.4",
33
- "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.4.tgz",
34
- "integrity": "sha512-ql7hcgulKZ3ekfa2DGAogcCKsWU0diA/0nArz1CFzh93WQdb46/Kj18ka/Hifq6uA3Ush34Pc6vU/6HXeRwUkg==",
35
- "license": "ISC",
36
- "dependencies": {
37
- "libsodium-sumo": "^0.8.0"
38
- }
39
- },
40
- "node_modules/three": {
41
- "version": "0.180.0",
42
- "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
43
- "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
44
- "dev": true,
45
- "license": "MIT"
46
- },
47
- "node_modules/ws": {
48
- "version": "8.21.1",
49
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
50
- "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
51
- "license": "MIT",
52
- "engines": {
53
- "node": ">=10.0.0"
54
- },
55
- "peerDependencies": {
56
- "bufferutil": "^4.0.1",
57
- "utf-8-validate": ">=5.0.2"
58
- },
59
- "peerDependenciesMeta": {
60
- "bufferutil": {
61
- "optional": true
62
- },
63
- "utf-8-validate": {
64
- "optional": true
65
- }
66
- }
67
- }
68
- }
69
- }