flowviant 0.114.0 → 0.114.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/bin/cli.mjs +2 -2
- package/bin/lib/artifactContracts.mjs +26 -0
- package/bin/lib/claudePersonal.mjs +145 -0
- package/bin/lib/credentials.mjs +9 -9
- package/bin/lib/fleetRoster.mjs +1 -1
- package/bin/lib/git.mjs +5 -5
- package/bin/lib/machineListing.mjs +4 -4
- package/bin/lib/noWindowEnv.mjs +104 -0
- package/bin/lib/projectToolRuntimes.mjs +78 -20
- package/bin/lib/projectTools.mjs +11 -3
- package/bin/lib/prompts.mjs +62 -22
- package/bin/lib/resumeLost.mjs +22 -2
- package/bin/lib/runTurn.mjs +26 -6
- package/bin/lib/runtimeClaude.mjs +36 -6
- package/bin/lib/runtimeCodex.mjs +4 -3
- package/bin/lib/startProject.mjs +6 -6
- package/bin/lib/work.mjs +2 -0
- package/bin/lib/workAgentCheck.mjs +10 -4
- package/bin/lib/workAgentPrecheck.mjs +4 -0
- package/bin/lib/workAgentTurnExecution.mjs +35 -2
- package/bin/lib/workSessionTurns.mjs +116 -23
- package/package.json +1 -1
|
@@ -59,6 +59,14 @@ import { createCommandAudit } from './workCommandAudit.mjs';
|
|
|
59
59
|
export const PLAN_TURN_SENTENCE =
|
|
60
60
|
'THIS IS A PLANNING TURN: the person switched this tab to plan mode, so read what you need, decide, and answer with the plan itself as your reply — change nothing (the CLI refuses every write this turn, whatever the mechanics above say about editing, committing or artifacts), and do not try to leave plan mode; they switch it off in the tab when they want the plan carried out.';
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* HOW LONG, AND HOW MANY, SETTLED TURNS THE LANE REMEMBERS (2026-09-29) — see
|
|
64
|
+
* `settledHere` below. Ten minutes is far past any roster a poll could still
|
|
65
|
+
* be holding; the cap keeps a box that answers all day from growing the map.
|
|
66
|
+
*/
|
|
67
|
+
export const SETTLED_HERE_TTL_MS = 10 * 60 * 1000;
|
|
68
|
+
export const SETTLED_HERE_MAX = 500;
|
|
69
|
+
|
|
62
70
|
export function createWorkSessionTurns({
|
|
63
71
|
repoRoot,
|
|
64
72
|
baseDir,
|
|
@@ -91,14 +99,67 @@ export function createWorkSessionTurns({
|
|
|
91
99
|
// The real CLI driver; injectable only so the lane can be driven in a test
|
|
92
100
|
// without spending a model turn (the wiki runner's shape).
|
|
93
101
|
runTurn = defaultRunTurn,
|
|
102
|
+
// The clock `settledHere` ages by; injectable only for its bound's test.
|
|
103
|
+
now = Date.now,
|
|
94
104
|
}) {
|
|
95
105
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
96
106
|
const MAX_WORK_TRIES = 3;
|
|
97
107
|
|
|
108
|
+
/**
|
|
109
|
+
* A TURN THIS LANE SETTLED IS NEVER RUN AGAIN (2026-09-29).
|
|
110
|
+
*
|
|
111
|
+
* A delivered settle forgets the turn everywhere: the report queue drops it
|
|
112
|
+
* from `pendingWorkReports` and `workAttempts`, and the turn's `finally`
|
|
113
|
+
* drops it from `workAnswering`. A roster READ made before that settle
|
|
114
|
+
* landed still offers it — the poll was in flight while the POST went out —
|
|
115
|
+
* and nothing was left to say it had run: the turn ran twice, settled
|
|
116
|
+
* twice, the rerun's reply overwrote the first, and the person's follow-up
|
|
117
|
+
* waited behind a turn nobody asked for. So every settle this lane makes
|
|
118
|
+
* goes through `settleTurn`, which remembers the turn id, and a job in the
|
|
119
|
+
* map is skipped where jobs are picked. Remembered BEFORE the POST: a
|
|
120
|
+
* settle still in flight is covered by `workAnswering`, one that could not
|
|
121
|
+
* be delivered by `pendingWorkReports`, and this covers the one that was.
|
|
122
|
+
*/
|
|
123
|
+
const settledHere = new Map(); // turnId -> when this lane settled it, oldest first
|
|
124
|
+
const forgetStale = (at) => {
|
|
125
|
+
for (const [id, when] of settledHere) {
|
|
126
|
+
if (settledHere.size <= SETTLED_HERE_MAX && at - when < SETTLED_HERE_TTL_MS) break;
|
|
127
|
+
settledHere.delete(id);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const settledRecently = (turnId) => {
|
|
131
|
+
forgetStale(now());
|
|
132
|
+
return settledHere.has(turnId);
|
|
133
|
+
};
|
|
134
|
+
const settleTurn = (turnId, payload) => {
|
|
135
|
+
const at = now();
|
|
136
|
+
settledHere.delete(turnId); // re-inserted at the end: the map stays in time order
|
|
137
|
+
settledHere.set(turnId, at);
|
|
138
|
+
forgetStale(at);
|
|
139
|
+
return settleWorkTurn(turnId, payload);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A PLACE A TURN OF THIS PROCESS IS STARTING IN (2026-09-29) — the turn
|
|
144
|
+
* lock's in-process half, keyed by the lock's own path.
|
|
145
|
+
*
|
|
146
|
+
* `flowviant-turn.lock` is written at SPAWN, several awaits after it is
|
|
147
|
+
* read (a credential mint, the attachment fetch), so two turns of one
|
|
148
|
+
* session offered in one roster both read "unlocked" and both spawned: two
|
|
149
|
+
* CLIs resuming one conversation at once, the incoherence the lock exists
|
|
150
|
+
* to prevent. The claim is checked and taken synchronously beside the file
|
|
151
|
+
* check, so the second turn waits for a later poll like any locked one,
|
|
152
|
+
* and it is released in the turn's outer `finally`, after its settle.
|
|
153
|
+
*/
|
|
154
|
+
const startingIn = new Set();
|
|
155
|
+
|
|
98
156
|
const processWorkTurns = (jobs) => {
|
|
99
157
|
for (const job of jobs ?? []) {
|
|
100
158
|
if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
|
|
101
159
|
if (workAnswering.has(job.id)) continue;
|
|
160
|
+
// Settled by this lane already: a roster read from before the settle
|
|
161
|
+
// landed. See `settledHere`.
|
|
162
|
+
if (settledRecently(job.id)) continue;
|
|
102
163
|
// The turn already RAN and its answer sits in the delivery queue — never
|
|
103
164
|
// run it again while the report is merely undelivered.
|
|
104
165
|
if (pendingWorkReports.has(job.id)) continue;
|
|
@@ -140,7 +201,7 @@ export function createWorkSessionTurns({
|
|
|
140
201
|
* tab for the server's whole expiry window.
|
|
141
202
|
*/
|
|
142
203
|
if (place !== REPO_PLACE && !isSafePathSegment(place)) {
|
|
143
|
-
void
|
|
204
|
+
void settleTurn(job.id, {
|
|
144
205
|
ok: false,
|
|
145
206
|
answer:
|
|
146
207
|
'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
|
|
@@ -178,12 +239,15 @@ export function createWorkSessionTurns({
|
|
|
178
239
|
* out here beside `usage`, and spread wherever it is. Last write wins
|
|
179
240
|
* across a fresh retry: that spawn is the one that answered. */
|
|
180
241
|
let model = null;
|
|
242
|
+
/** The `startingIn` key this turn claimed — released in the outer
|
|
243
|
+
* `finally`, and only by the turn that took it. */
|
|
244
|
+
let startClaim = null;
|
|
181
245
|
try {
|
|
182
246
|
const tries = workAttempts.get(job.id) ?? 0;
|
|
183
247
|
if (tries >= MAX_WORK_TRIES) {
|
|
184
248
|
// Out of local tries: SETTLE, don't skip — a silently skipped turn
|
|
185
249
|
// strands the tab for the server's whole 24h expiry window.
|
|
186
|
-
await
|
|
250
|
+
await settleTurn(job.id, {
|
|
187
251
|
ok: false,
|
|
188
252
|
answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
|
|
189
253
|
});
|
|
@@ -209,7 +273,7 @@ export function createWorkSessionTurns({
|
|
|
209
273
|
typeof job.adopt.id !== 'string' ||
|
|
210
274
|
!/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
|
|
211
275
|
) {
|
|
212
|
-
await
|
|
276
|
+
await settleTurn(job.id, {
|
|
213
277
|
ok: false,
|
|
214
278
|
answer: 'that terminal session id is not one this machine can resume',
|
|
215
279
|
});
|
|
@@ -223,7 +287,7 @@ export function createWorkSessionTurns({
|
|
|
223
287
|
srcCwd = null;
|
|
224
288
|
}
|
|
225
289
|
if (!srcCwd) {
|
|
226
|
-
await
|
|
290
|
+
await settleTurn(job.id, {
|
|
227
291
|
ok: false,
|
|
228
292
|
answer: "the terminal session's directory no longer exists on the machine",
|
|
229
293
|
});
|
|
@@ -247,14 +311,14 @@ export function createWorkSessionTurns({
|
|
|
247
311
|
/* keep the literal path */
|
|
248
312
|
}
|
|
249
313
|
if (!under(srcCwd, realRoot)) {
|
|
250
|
-
await
|
|
314
|
+
await settleTurn(job.id, {
|
|
251
315
|
ok: false,
|
|
252
316
|
answer: "the terminal session's directory is outside this project's repository",
|
|
253
317
|
});
|
|
254
318
|
return;
|
|
255
319
|
}
|
|
256
320
|
if (under(srcCwd, realBase)) {
|
|
257
|
-
await
|
|
321
|
+
await settleTurn(job.id, {
|
|
258
322
|
ok: false,
|
|
259
323
|
answer:
|
|
260
324
|
"that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
|
|
@@ -264,7 +328,7 @@ export function createWorkSessionTurns({
|
|
|
264
328
|
try {
|
|
265
329
|
srcHead = git(['rev-parse', 'HEAD'], srcCwd);
|
|
266
330
|
} catch {
|
|
267
|
-
await
|
|
331
|
+
await settleTurn(job.id, {
|
|
268
332
|
ok: false,
|
|
269
333
|
answer:
|
|
270
334
|
"the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
|
|
@@ -281,7 +345,7 @@ export function createWorkSessionTurns({
|
|
|
281
345
|
? isAgyConversationLive(job.adopt.id)
|
|
282
346
|
: isTerminalSessionLive(job.adopt.id);
|
|
283
347
|
if (adoptLive) {
|
|
284
|
-
await
|
|
348
|
+
await settleTurn(job.id, {
|
|
285
349
|
ok: false,
|
|
286
350
|
answer:
|
|
287
351
|
'That terminal session is still open on the machine — close it there first, then adopt.',
|
|
@@ -297,7 +361,7 @@ export function createWorkSessionTurns({
|
|
|
297
361
|
// the session's own id, which is what every tab has always done.
|
|
298
362
|
const dir = placeWtFor(place, adopting ? srcHead : undefined);
|
|
299
363
|
if (!dir) {
|
|
300
|
-
await
|
|
364
|
+
await settleTurn(job.id, {
|
|
301
365
|
ok: false,
|
|
302
366
|
answer:
|
|
303
367
|
'the session worktree could not be opened on the machine — check the daemon log',
|
|
@@ -308,13 +372,20 @@ export function createWorkSessionTurns({
|
|
|
308
372
|
// life, most likely; the lock outlives a restart. Leave the job
|
|
309
373
|
// pending and look again next poll; spawning a second CLI would put
|
|
310
374
|
// two Claudes in one held context. Costs no attempt: nothing ran.
|
|
375
|
+
// …AND ONE OF OURS MAY BE ABOUT TO: `startingIn`, checked and
|
|
376
|
+
// claimed with no await between, because the file is only written
|
|
377
|
+
// at spawn. Keyed by the lock's path (the directory, when the git
|
|
378
|
+
// dir cannot be named) — the file lock's own scope.
|
|
311
379
|
const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
|
|
312
|
-
|
|
380
|
+
const startKey = lockPath || dir.wt;
|
|
381
|
+
if (startingIn.has(startKey) || turnLockedByLivePid(lockPath)) {
|
|
313
382
|
warn(
|
|
314
383
|
`a turn is already running in "${job.sessionName || job.sessionId}" — waiting for it to finish`
|
|
315
384
|
);
|
|
316
385
|
return;
|
|
317
386
|
}
|
|
387
|
+
startingIn.add(startKey);
|
|
388
|
+
startClaim = startKey;
|
|
318
389
|
// WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
|
|
319
390
|
// which is what every tab ran on until now) — honored by
|
|
320
391
|
// sessionRuntime: on a first turn a named runtime IS the pick, and a
|
|
@@ -327,14 +398,14 @@ export function createWorkSessionTurns({
|
|
|
327
398
|
if (rt.mismatch) {
|
|
328
399
|
// Something upstream changed this tab's identity mid-life. A held
|
|
329
400
|
// context must never be answered by a different brain — say so.
|
|
330
|
-
await
|
|
401
|
+
await settleTurn(job.id, {
|
|
331
402
|
ok: false,
|
|
332
403
|
answer: `this tab is pinned to ${rt.mismatch.pin} but the server says it is a ${rt.mismatch.runtime} tab — reopen a new tab`,
|
|
333
404
|
});
|
|
334
405
|
return;
|
|
335
406
|
}
|
|
336
407
|
if (rt.missing) {
|
|
337
|
-
await
|
|
408
|
+
await settleTurn(job.id, {
|
|
338
409
|
ok: false,
|
|
339
410
|
answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
|
|
340
411
|
});
|
|
@@ -346,14 +417,14 @@ export function createWorkSessionTurns({
|
|
|
346
417
|
// tab can run on (Antigravity has no MCP config, and the session's
|
|
347
418
|
// whole control plane rides one). An honest sentence beats the
|
|
348
419
|
// mcpFor throw this used to crash into every turn.
|
|
349
|
-
await
|
|
420
|
+
await settleTurn(job.id, {
|
|
350
421
|
ok: false,
|
|
351
422
|
answer: `this session runs on ${rt.unsupported}, which cannot drive a Terminal tab on this machine — open a new tab`,
|
|
352
423
|
});
|
|
353
424
|
return;
|
|
354
425
|
}
|
|
355
426
|
if (!rt.id) {
|
|
356
|
-
await
|
|
427
|
+
await settleTurn(job.id, {
|
|
357
428
|
ok: false,
|
|
358
429
|
answer:
|
|
359
430
|
'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
|
|
@@ -366,7 +437,7 @@ export function createWorkSessionTurns({
|
|
|
366
437
|
// (--conversation). Codex has no adoptable store yet, and its
|
|
367
438
|
// args builder backstops this with a loud throw — but a sentence
|
|
368
439
|
// here beats a stack there.
|
|
369
|
-
await
|
|
440
|
+
await settleTurn(job.id, {
|
|
370
441
|
ok: false,
|
|
371
442
|
answer:
|
|
372
443
|
'adopting this terminal session needs its own CLI on the machine — install it, then try again',
|
|
@@ -383,7 +454,7 @@ export function createWorkSessionTurns({
|
|
|
383
454
|
*/
|
|
384
455
|
const planTurn = job.planMode === true && job.capture !== true;
|
|
385
456
|
if (planTurn && rt.id !== 'claude') {
|
|
386
|
-
await
|
|
457
|
+
await settleTurn(job.id, {
|
|
387
458
|
ok: false,
|
|
388
459
|
answer: `plan mode runs on Claude Code only, and this tab is ${rt.id} — turn plan off in the tab, then send the message again`,
|
|
389
460
|
});
|
|
@@ -409,7 +480,7 @@ export function createWorkSessionTurns({
|
|
|
409
480
|
return;
|
|
410
481
|
}
|
|
411
482
|
if (mint?.gone) {
|
|
412
|
-
await
|
|
483
|
+
await settleTurn(job.id, {
|
|
413
484
|
ok: false,
|
|
414
485
|
answer:
|
|
415
486
|
'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
|
|
@@ -417,7 +488,7 @@ export function createWorkSessionTurns({
|
|
|
417
488
|
return;
|
|
418
489
|
}
|
|
419
490
|
if (!mint?.token) {
|
|
420
|
-
await
|
|
491
|
+
await settleTurn(job.id, {
|
|
421
492
|
ok: false,
|
|
422
493
|
answer:
|
|
423
494
|
'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
|
|
@@ -694,6 +765,11 @@ export function createWorkSessionTurns({
|
|
|
694
765
|
cwd: dir.wt,
|
|
695
766
|
mcpArgs: mcp.args,
|
|
696
767
|
mcpEnv: mcp.env,
|
|
768
|
+
// A PERSON AT THE KEYBOARD MAY ASK FOR A WINDOW (2026-09-29): a
|
|
769
|
+
// Terminal tab keeps its environment's display. The capture chat
|
|
770
|
+
// does not — it only reads and stages cards, and the person
|
|
771
|
+
// talking to it may be on a phone (noWindowEnv.mjs).
|
|
772
|
+
display: !captureTab,
|
|
697
773
|
runtime: rt.id,
|
|
698
774
|
label: c.cyan('[tab]'),
|
|
699
775
|
// Only codex announces one (thread.started); held here so the id
|
|
@@ -760,6 +836,11 @@ export function createWorkSessionTurns({
|
|
|
760
836
|
* that tab replied identically, with nothing on any surface able
|
|
761
837
|
* to clear it. A tab bricked forever by its own CLI pruning its
|
|
762
838
|
* history, which it does on its own schedule.
|
|
839
|
+
*
|
|
840
|
+
* An EMPTY Claude resume that emitted init is not retried
|
|
841
|
+
* (2026-09-29, resumeLost.mjs): its conversation was found, and a
|
|
842
|
+
* fresh run would answer with no memory of the chat and re-pin the
|
|
843
|
+
* tab to that new conversation.
|
|
763
844
|
*/
|
|
764
845
|
if (
|
|
765
846
|
!adopting &&
|
|
@@ -826,7 +907,7 @@ export function createWorkSessionTurns({
|
|
|
826
907
|
// transcript is most likely gone (cleaned, expired, deleted). Say
|
|
827
908
|
// exactly that; no sessionRef is recorded, so the server keeps
|
|
828
909
|
// offering the adoption and a retry after the user checks is cheap.
|
|
829
|
-
await
|
|
910
|
+
await settleTurn(job.id, {
|
|
830
911
|
ok: false,
|
|
831
912
|
answer: "Couldn't resume the terminal session — it may have been removed.",
|
|
832
913
|
// Whatever it DID before coming back empty is exactly the
|
|
@@ -857,13 +938,24 @@ export function createWorkSessionTurns({
|
|
|
857
938
|
}
|
|
858
939
|
}
|
|
859
940
|
}
|
|
860
|
-
|
|
941
|
+
/**
|
|
942
|
+
* NO WORDS, SAID AS MEASURED (2026-09-29). Claude's init event means
|
|
943
|
+
* it ran in this conversation — kept, and resumed by the next
|
|
944
|
+
* message, since an empty resume that found it no longer runs fresh
|
|
945
|
+
* (resumeLost.mjs) — and its result held no words. "Signed out" is
|
|
946
|
+
* a guess, left to the case where no init came.
|
|
947
|
+
*/
|
|
948
|
+
const noWords =
|
|
949
|
+
rt.id === 'claude' && seenClaudeSession
|
|
950
|
+
? 'Claude Code ran in this conversation and returned no words — send the message again'
|
|
951
|
+
: 'the turn produced no output on the machine — its CLI may be signed out; try again';
|
|
952
|
+
await settleTurn(job.id, {
|
|
861
953
|
ok: answer.length > 0,
|
|
862
954
|
answer:
|
|
863
955
|
answer.length > 0
|
|
864
956
|
? // Scrub: a reply can quote config or env-adjacent code.
|
|
865
957
|
envScrub(answer).slice(0, 16000)
|
|
866
|
-
:
|
|
958
|
+
: noWords,
|
|
867
959
|
// Only a turn that actually SPOKE proves a conversation lives
|
|
868
960
|
// here. Recording the path unconditionally is how a crashed first
|
|
869
961
|
// turn used to brick resume for the session's whole life.
|
|
@@ -878,7 +970,7 @@ export function createWorkSessionTurns({
|
|
|
878
970
|
if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
|
|
879
971
|
else warn('session turn produced no output — settled as failed');
|
|
880
972
|
} catch (e) {
|
|
881
|
-
await
|
|
973
|
+
await settleTurn(job.id, {
|
|
882
974
|
ok: false,
|
|
883
975
|
// Scrub, like every string that leaves this machine: an exception
|
|
884
976
|
// routinely quotes command output, and command output can quote a
|
|
@@ -894,6 +986,7 @@ export function createWorkSessionTurns({
|
|
|
894
986
|
warn(`session turn failed: ${e?.message ?? e}`);
|
|
895
987
|
} finally {
|
|
896
988
|
workAnswering.delete(job.id);
|
|
989
|
+
if (startClaim) startingIn.delete(startClaim);
|
|
897
990
|
// Nothing spawned, or everything already has: releasing twice is the
|
|
898
991
|
// normal case and costs nothing. A reservation that leaked would
|
|
899
992
|
// shrink this machine's ceiling for the life of the process.
|
|
@@ -923,5 +1016,5 @@ export function createWorkSessionTurns({
|
|
|
923
1016
|
}
|
|
924
1017
|
};
|
|
925
1018
|
|
|
926
|
-
return { processWorkTurns, workAnswering };
|
|
1019
|
+
return { processWorkTurns, workAnswering, settledHere };
|
|
927
1020
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.114.
|
|
3
|
+
"version": "0.114.2",
|
|
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": {
|