flowviant 0.117.0 → 0.124.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/bin/cli.mjs +37 -0
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +2 -2
- package/bin/lib/agentPlan.mjs +78 -7
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentTaskKinds.mjs +1 -1
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +183 -19
- package/bin/lib/artifacts.mjs +6 -3
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePosture.mjs +110 -14
- package/bin/lib/claudeStream.mjs +20 -2
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexResearch.mjs +16 -3
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/daemonLocks.mjs +5 -3
- package/bin/lib/desktopContract.mjs +3 -0
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/fleet.mjs +46 -4
- package/bin/lib/fleetReports.mjs +44 -3
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +13 -4
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/projectToolRuntimes.mjs +227 -3
- package/bin/lib/projectTools.mjs +61 -4
- package/bin/lib/prompts.mjs +164 -41
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +35 -16
- package/bin/lib/runtimeCapabilities.mjs +77 -3
- package/bin/lib/runtimeClaude.mjs +27 -9
- package/bin/lib/runtimeCodex.mjs +27 -59
- package/bin/lib/runtimeDetection.mjs +6 -0
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/runtimeOfferings.mjs +75 -3
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnProfile.mjs +15 -3
- package/bin/lib/work.mjs +28 -7
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentPlans.mjs +21 -70
- package/bin/lib/workAgentPrecheck.mjs +5 -0
- package/bin/lib/workAgentTurnExecution.mjs +93 -22
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurns.mjs +60 -4
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workCappedTurn.mjs +10 -0
- package/bin/lib/workCommandAudit.mjs +8 -1
- package/bin/lib/workPlaces.mjs +18 -2
- package/bin/lib/workSessionTurns.mjs +148 -13
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +6 -4
- package/npm-shrinkwrap.json +12 -2
- package/package.json +7 -3
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { git } from './git.mjs';
|
|
21
|
+
import { measureRepositoryBaseline } from './repositoryBaseline.mjs';
|
|
22
|
+
import { REPOSITORY_BASELINE_PARAGRAPH } from './prompts.mjs';
|
|
21
23
|
import { processStartTime } from './procRegistry.mjs';
|
|
22
24
|
import { c, note, ok, warn } from './ui.mjs';
|
|
23
25
|
import { mcpFor } from './claude.mjs';
|
|
@@ -30,6 +32,7 @@ import { isAgyConversationLive } from './agySessions.mjs';
|
|
|
30
32
|
import { adoptFence } from './localSessions.mjs';
|
|
31
33
|
import { resumeRetriesFresh } from './resumeLost.mjs';
|
|
32
34
|
import { turnLockedByLivePid } from './turnLock.mjs';
|
|
35
|
+
import { killGroup } from './processGroup.mjs';
|
|
33
36
|
import { printable } from './printable.mjs';
|
|
34
37
|
import { brainFor } from './workBrain.mjs';
|
|
35
38
|
import { AGY_CONV_RE, CLAUDE_SESSION_RE, CODEX_THREAD_RE, RESUME_MARKERS } from './conversationIds.mjs';
|
|
@@ -48,6 +51,7 @@ export const SETTLED_HERE_MAX = 500;
|
|
|
48
51
|
|
|
49
52
|
export function createWorkSessionTurns({
|
|
50
53
|
repoRoot,
|
|
54
|
+
baseRef = () => null,
|
|
51
55
|
baseDir,
|
|
52
56
|
getMcpUrl,
|
|
53
57
|
getArtifactsAccepted,
|
|
@@ -84,6 +88,31 @@ export function createWorkSessionTurns({
|
|
|
84
88
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
85
89
|
const MAX_WORK_TRIES = 3;
|
|
86
90
|
|
|
91
|
+
/**
|
|
92
|
+
* INTERRUPT NOW (2026-10-13) — each turn this lane has taken, with the CLI
|
|
93
|
+
* children it spawned and whether a person asked to stop it. A stop kills
|
|
94
|
+
* each child's PROCESS GROUP: the CLI is spawned detached, so everything it
|
|
95
|
+
* started (a test run, a dev server) is in that group and goes with it —
|
|
96
|
+
* which is what interrupting a prompt means, and what Pause does too. A
|
|
97
|
+
* turn asked to stop before it spawned never spawns.
|
|
98
|
+
*/
|
|
99
|
+
const runs = new Map(); // turnId -> { interrupted, spawned: [] }
|
|
100
|
+
/** Turns this process answered `ran: false` for, so the roster's next read
|
|
101
|
+
* (made before that settle landed) is not answered again at once. */
|
|
102
|
+
const notRunningSaid = new Map(); // turnId -> when
|
|
103
|
+
/**
|
|
104
|
+
* Sessions whose run this lane just stopped. The next turn there is the one
|
|
105
|
+
* sent to interrupt it, and the roster that hands it over can have been read
|
|
106
|
+
* before the stop's settle landed — so the note does not wait on the
|
|
107
|
+
* server's `interrupted` alone. Taken once, and only for a little while.
|
|
108
|
+
*/
|
|
109
|
+
const stoppedIn = new Map(); // sessionId -> when
|
|
110
|
+
const tookStop = (sessionId) => {
|
|
111
|
+
const when = stoppedIn.get(sessionId);
|
|
112
|
+
stoppedIn.delete(sessionId);
|
|
113
|
+
return when != null && now() - when < 120_000;
|
|
114
|
+
};
|
|
115
|
+
|
|
87
116
|
/**
|
|
88
117
|
* A TURN THIS LANE SETTLED IS NEVER RUN AGAIN (2026-09-29).
|
|
89
118
|
*
|
|
@@ -131,10 +160,14 @@ export function createWorkSessionTurns({
|
|
|
131
160
|
* and it is released in the turn's outer `finally`, after its settle.
|
|
132
161
|
*/
|
|
133
162
|
const startingIn = new Set();
|
|
163
|
+
// A roster can contain a turn read before its stop was requested. Never
|
|
164
|
+
// start that stale job after acknowledging the same roster's interrupt.
|
|
165
|
+
let rosterInterrupts = new Set();
|
|
134
166
|
|
|
135
167
|
const processWorkTurns = (jobs) => {
|
|
136
168
|
for (const job of jobs ?? []) {
|
|
137
169
|
if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
|
|
170
|
+
if (rosterInterrupts.has(job.id)) continue;
|
|
138
171
|
if (workAnswering.has(job.id)) continue;
|
|
139
172
|
// Settled by this lane already: a roster read from before the settle
|
|
140
173
|
// landed. See `settledHere`.
|
|
@@ -167,6 +200,14 @@ export function createWorkSessionTurns({
|
|
|
167
200
|
const releaseSlot = admit.reserve();
|
|
168
201
|
lastDeferSaid.delete(job.sessionId);
|
|
169
202
|
workAnswering.add(job.id);
|
|
203
|
+
const run = { interrupted: false, spawned: [] };
|
|
204
|
+
runs.set(job.id, run);
|
|
205
|
+
/** The execution generation the server handed, echoed on every settle
|
|
206
|
+
* so the server can tell this execution's answer from an older one's. */
|
|
207
|
+
const attempt = Number.isInteger(job.attempt) ? { attempt: job.attempt } : {};
|
|
208
|
+
/** The CLI, unless a person already asked to stop this turn — then no
|
|
209
|
+
* run starts, the fresh retry after an empty resume included. */
|
|
210
|
+
const runCli = (args) => (run.interrupted ? Promise.resolve('') : runTurn(args));
|
|
170
211
|
const place = job.place || job.sessionId;
|
|
171
212
|
/**
|
|
172
213
|
* VALIDATED AT THE TRUST BOUNDARY, through the same door `learnPlaces`
|
|
@@ -185,6 +226,7 @@ export function createWorkSessionTurns({
|
|
|
185
226
|
*/
|
|
186
227
|
if (!notePlace(job.sessionId, place)) {
|
|
187
228
|
void settleTurn(job.id, {
|
|
229
|
+
...attempt,
|
|
188
230
|
ok: false,
|
|
189
231
|
answer:
|
|
190
232
|
'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
|
|
@@ -229,7 +271,7 @@ export function createWorkSessionTurns({
|
|
|
229
271
|
* a turn fails.
|
|
230
272
|
*/
|
|
231
273
|
const spent = () => ({
|
|
232
|
-
...(toolLog
|
|
274
|
+
...(toolLog != null ? { tools: toolLog } : {}),
|
|
233
275
|
...(usage ? { usage } : {}),
|
|
234
276
|
...(model ? { model } : {}),
|
|
235
277
|
});
|
|
@@ -242,6 +284,7 @@ export function createWorkSessionTurns({
|
|
|
242
284
|
// Out of local tries: SETTLE, don't skip — a silently skipped turn
|
|
243
285
|
// strands the tab for the server's whole 24h expiry window.
|
|
244
286
|
await settleTurn(job.id, {
|
|
287
|
+
...attempt,
|
|
245
288
|
ok: false,
|
|
246
289
|
answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
|
|
247
290
|
});
|
|
@@ -271,6 +314,7 @@ export function createWorkSessionTurns({
|
|
|
271
314
|
!/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
|
|
272
315
|
) {
|
|
273
316
|
await settleTurn(job.id, {
|
|
317
|
+
...attempt,
|
|
274
318
|
ok: false,
|
|
275
319
|
answer: 'that terminal session id is not one this machine can resume',
|
|
276
320
|
});
|
|
@@ -285,6 +329,7 @@ export function createWorkSessionTurns({
|
|
|
285
329
|
}
|
|
286
330
|
if (!srcCwd) {
|
|
287
331
|
await settleTurn(job.id, {
|
|
332
|
+
...attempt,
|
|
288
333
|
ok: false,
|
|
289
334
|
answer: "the terminal session's directory no longer exists on the machine",
|
|
290
335
|
});
|
|
@@ -298,6 +343,7 @@ export function createWorkSessionTurns({
|
|
|
298
343
|
const fenced = adoptFence({ repoRoot, excludeDirs: [baseDir] }).check(srcCwd);
|
|
299
344
|
if (fenced === 'outside') {
|
|
300
345
|
await settleTurn(job.id, {
|
|
346
|
+
...attempt,
|
|
301
347
|
ok: false,
|
|
302
348
|
answer: "the terminal session's directory is outside this project's repository",
|
|
303
349
|
});
|
|
@@ -305,6 +351,7 @@ export function createWorkSessionTurns({
|
|
|
305
351
|
}
|
|
306
352
|
if (fenced === 'ours') {
|
|
307
353
|
await settleTurn(job.id, {
|
|
354
|
+
...attempt,
|
|
308
355
|
ok: false,
|
|
309
356
|
answer:
|
|
310
357
|
"that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
|
|
@@ -315,6 +362,7 @@ export function createWorkSessionTurns({
|
|
|
315
362
|
srcHead = git(['rev-parse', 'HEAD'], srcCwd);
|
|
316
363
|
} catch {
|
|
317
364
|
await settleTurn(job.id, {
|
|
365
|
+
...attempt,
|
|
318
366
|
ok: false,
|
|
319
367
|
answer:
|
|
320
368
|
"the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
|
|
@@ -332,6 +380,7 @@ export function createWorkSessionTurns({
|
|
|
332
380
|
: isTerminalSessionLive(job.adopt.id);
|
|
333
381
|
if (adoptLive) {
|
|
334
382
|
await settleTurn(job.id, {
|
|
383
|
+
...attempt,
|
|
335
384
|
ok: false,
|
|
336
385
|
answer:
|
|
337
386
|
'That terminal session is still open on the machine — close it there first, then adopt.',
|
|
@@ -348,6 +397,7 @@ export function createWorkSessionTurns({
|
|
|
348
397
|
const dir = placeWtFor(place, adopting ? srcHead : undefined);
|
|
349
398
|
if (!dir) {
|
|
350
399
|
await settleTurn(job.id, {
|
|
400
|
+
...attempt,
|
|
351
401
|
ok: false,
|
|
352
402
|
answer:
|
|
353
403
|
'the session worktree could not be opened on the machine — check the daemon log',
|
|
@@ -379,12 +429,17 @@ export function createWorkSessionTurns({
|
|
|
379
429
|
// What the artifact directory held BEFORE this turn — the capture chat
|
|
380
430
|
// excepted: it runs read-only, is never told about artifacts, and
|
|
381
431
|
// whatever sibling tabs wrote in this shared place is theirs.
|
|
382
|
-
if (job.capture !== true)
|
|
432
|
+
if (job.capture !== true) {
|
|
433
|
+
artifactScan = { dir: dir.wt, before: beforeArtifacts(dir.wt), reported: false };
|
|
434
|
+
artifactScan.instructions = getArtifactsAccepted()
|
|
435
|
+
? await artifacts.prepare?.({ placeDir: dir.wt, before: artifactScan.before, sessionId: job.sessionId }) ?? '' : '';
|
|
436
|
+
}
|
|
383
437
|
const rt = sessionRuntime(dir.wt, job.runtime || null, job.sessionId);
|
|
384
438
|
if (rt.mismatch) {
|
|
385
439
|
// Something upstream changed this tab's identity mid-life. A held
|
|
386
440
|
// context must never be answered by a different brain — say so.
|
|
387
441
|
await settleTurn(job.id, {
|
|
442
|
+
...attempt,
|
|
388
443
|
ok: false,
|
|
389
444
|
answer: `this tab is pinned to ${rt.mismatch.pin} but the server says it is a ${rt.mismatch.runtime} tab — reopen a new tab`,
|
|
390
445
|
});
|
|
@@ -392,6 +447,7 @@ export function createWorkSessionTurns({
|
|
|
392
447
|
}
|
|
393
448
|
if (rt.missing) {
|
|
394
449
|
await settleTurn(job.id, {
|
|
450
|
+
...attempt,
|
|
395
451
|
ok: false,
|
|
396
452
|
answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
|
|
397
453
|
});
|
|
@@ -404,6 +460,7 @@ export function createWorkSessionTurns({
|
|
|
404
460
|
// whole control plane rides one). An honest sentence beats the
|
|
405
461
|
// mcpFor throw this used to crash into every turn.
|
|
406
462
|
await settleTurn(job.id, {
|
|
463
|
+
...attempt,
|
|
407
464
|
ok: false,
|
|
408
465
|
answer: `this session runs on ${rt.unsupported}, which cannot drive a Terminal tab on this machine — open a new tab`,
|
|
409
466
|
});
|
|
@@ -411,6 +468,7 @@ export function createWorkSessionTurns({
|
|
|
411
468
|
}
|
|
412
469
|
if (!rt.id) {
|
|
413
470
|
await settleTurn(job.id, {
|
|
471
|
+
...attempt,
|
|
414
472
|
ok: false,
|
|
415
473
|
answer:
|
|
416
474
|
'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
|
|
@@ -428,7 +486,7 @@ export function createWorkSessionTurns({
|
|
|
428
486
|
*/
|
|
429
487
|
const shape = sessionTurnShape({ runtime: rt.id, adopting, job });
|
|
430
488
|
if (shape.refusal) {
|
|
431
|
-
await settleTurn(job.id, { ok: false, answer: shape.refusal });
|
|
489
|
+
await settleTurn(job.id, { ...attempt, ok: false, answer: shape.refusal });
|
|
432
490
|
return;
|
|
433
491
|
}
|
|
434
492
|
// A PLAIN tab (agy) mounts no MCP: no credential to mint.
|
|
@@ -447,6 +505,7 @@ export function createWorkSessionTurns({
|
|
|
447
505
|
}
|
|
448
506
|
if (mint?.gone) {
|
|
449
507
|
await settleTurn(job.id, {
|
|
508
|
+
...attempt,
|
|
450
509
|
ok: false,
|
|
451
510
|
answer:
|
|
452
511
|
'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
|
|
@@ -455,6 +514,7 @@ export function createWorkSessionTurns({
|
|
|
455
514
|
}
|
|
456
515
|
if (!mint?.token) {
|
|
457
516
|
await settleTurn(job.id, {
|
|
517
|
+
...attempt,
|
|
458
518
|
ok: false,
|
|
459
519
|
answer:
|
|
460
520
|
'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
|
|
@@ -598,7 +658,7 @@ export function createWorkSessionTurns({
|
|
|
598
658
|
toolLog = turnLog.toolLog;
|
|
599
659
|
const { pushToolEvent } = turnLog;
|
|
600
660
|
const narrator = makeNarrator(job.sessionId, job.id, () =>
|
|
601
|
-
toolLog
|
|
661
|
+
toolLog
|
|
602
662
|
);
|
|
603
663
|
|
|
604
664
|
|
|
@@ -632,6 +692,8 @@ export function createWorkSessionTurns({
|
|
|
632
692
|
sessionName: job.sessionName,
|
|
633
693
|
message,
|
|
634
694
|
askedByName: job.askedByName,
|
|
695
|
+
// INTERRUPT NOW: the previous run was stopped to send this.
|
|
696
|
+
interrupted: tookStop(job.sessionId) || job.interrupted === true,
|
|
635
697
|
}),
|
|
636
698
|
// ONE PROFILE (turnProfile.mjs), the shape's: `plan` for the
|
|
637
699
|
// capture chat, `plan-mode` for a plan turn, else `build`.
|
|
@@ -647,6 +709,7 @@ export function createWorkSessionTurns({
|
|
|
647
709
|
// library that synced a second ago is in THIS turn.
|
|
648
710
|
...(() => {
|
|
649
711
|
const knowledgeDir = knowledgeDirFor(repoRoot);
|
|
712
|
+
if (job.capture === true) toolLog.baseline = measureRepositoryBaseline({ cwd: dir.wt, baseRef, knowledgeDir });
|
|
650
713
|
return {
|
|
651
714
|
// The shape's contract (a plan turn: the PLAIN one, no
|
|
652
715
|
// artifacts paragraph, the planning sentence appended). The
|
|
@@ -655,7 +718,8 @@ export function createWorkSessionTurns({
|
|
|
655
718
|
system: sessionTurnSystem(shape, {
|
|
656
719
|
knowledgeDir,
|
|
657
720
|
artifactsAccepted: getArtifactsAccepted(),
|
|
658
|
-
|
|
721
|
+
runtime: rt.id,
|
|
722
|
+
}) + (toolLog.baseline ? '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(toolLog.baseline) : '') + (artifactScan?.instructions ? `\n\n${artifactScan.instructions}` : ''),
|
|
659
723
|
// The directory is OUTSIDE a worktree's cwd; Claude Code is
|
|
660
724
|
// told it may read there (`--add-dir`) rather than left to
|
|
661
725
|
// refuse a read-only capture turn a path it was just handed.
|
|
@@ -672,7 +736,8 @@ export function createWorkSessionTurns({
|
|
|
672
736
|
answerFromResult: true,
|
|
673
737
|
onActivity: (a) => {
|
|
674
738
|
narrator.line(a?.label);
|
|
675
|
-
|
|
739
|
+
// Claude/Codex commands are audited below with their correlated outcome.
|
|
740
|
+
if (rt.id !== 'claude' && rt.id !== 'codex') auditCommand(a);
|
|
676
741
|
if (a?.kind === 'tool' && APPROVAL_REFUSED.test(a.error ?? '')) approvalRefused.push(a.label);
|
|
677
742
|
},
|
|
678
743
|
// One Codex agent MESSAGE (runtimeEvents.mjs `answer`): the turn
|
|
@@ -681,7 +746,13 @@ export function createWorkSessionTurns({
|
|
|
681
746
|
if (String(a ?? '').trim()) codexSpoke = true;
|
|
682
747
|
},
|
|
683
748
|
// The structured twin of the line above — see toolLog.
|
|
684
|
-
onToolEvent:
|
|
749
|
+
onToolEvent: (name, input, meta) => {
|
|
750
|
+
const e = pushToolEvent(name, input, meta);
|
|
751
|
+
if (e?.t === 'bash') auditCommand({
|
|
752
|
+
kind: 'bash', command: input?.command ?? e.c,
|
|
753
|
+
observation: { id: e.id, outcome: e.outcome, exitCode: e.exitCode, errorCategory: e.errorCategory },
|
|
754
|
+
});
|
|
755
|
+
},
|
|
685
756
|
// The CLI's own token counts, tagged with which CLI counted them
|
|
686
757
|
// — ADDED, not set: both runTurn calls below share these args.
|
|
687
758
|
onUsage: (u) => {
|
|
@@ -722,6 +793,9 @@ export function createWorkSessionTurns({
|
|
|
722
793
|
onSpawn: (ch) => {
|
|
723
794
|
if (!ch) return;
|
|
724
795
|
spawned.push(ch);
|
|
796
|
+
run.spawned.push(ch);
|
|
797
|
+
// Asked to stop between the check and the spawn.
|
|
798
|
+
if (run.interrupted) killGroup(ch);
|
|
725
799
|
// Keyed to the SESSION it serves — that id is what the machine
|
|
726
800
|
// snapshot charges this child's memory to.
|
|
727
801
|
workChildren.set(ch, job.sessionId);
|
|
@@ -745,7 +819,7 @@ export function createWorkSessionTurns({
|
|
|
745
819
|
}
|
|
746
820
|
},
|
|
747
821
|
};
|
|
748
|
-
out = await
|
|
822
|
+
out = await runCli({
|
|
749
823
|
...turnArgs,
|
|
750
824
|
resume,
|
|
751
825
|
resumeThreadId: codexResumeId || claudeResumeId || undefined,
|
|
@@ -786,7 +860,7 @@ export function createWorkSessionTurns({
|
|
|
786
860
|
!adopting &&
|
|
787
861
|
resumeRetriesFresh({ resume, out, runtime: rt.id, sawInit: Boolean(seenClaudeSession) })
|
|
788
862
|
) {
|
|
789
|
-
out = await
|
|
863
|
+
out = await runCli({ ...turnArgs, resume: false });
|
|
790
864
|
}
|
|
791
865
|
} finally {
|
|
792
866
|
// The CLI has stopped printing, so stop relaying. The LINE itself
|
|
@@ -837,6 +911,21 @@ export function createWorkSessionTurns({
|
|
|
837
911
|
}
|
|
838
912
|
}
|
|
839
913
|
}
|
|
914
|
+
/**
|
|
915
|
+
* A PERSON INTERRUPTED IT. Whatever the CLI printed was cut off, so
|
|
916
|
+
* it is not an answer: the turn settles `interrupted`, with what it
|
|
917
|
+
* spent and did, and the message sent to interrupt it runs next in
|
|
918
|
+
* this same conversation — the markers above already hold its id.
|
|
919
|
+
* Never spawned, it settles `ran: false`, unremembered, so the
|
|
920
|
+
* server's re-offer runs it as it was.
|
|
921
|
+
*/
|
|
922
|
+
if (run.interrupted) {
|
|
923
|
+
const body = { ...attempt, ok: false, interrupted: true, ran: run.spawned.length > 0, answer: 'the turn was interrupted', ...spent() };
|
|
924
|
+
if (body.ran) stoppedIn.set(job.sessionId, now());
|
|
925
|
+
await (body.ran ? settleTurn(job.id, body) : settleWorkTurn(job.id, body));
|
|
926
|
+
ok(`${c.cyan('tab')} ${c.dim('— interrupted')}`);
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
840
929
|
const answer = (out || '').trim();
|
|
841
930
|
// No output at all smells like a dead MCP credential (the lane
|
|
842
931
|
// workers' no-sentinel case) — drop the cached token so the next
|
|
@@ -848,6 +937,7 @@ export function createWorkSessionTurns({
|
|
|
848
937
|
// exactly that; no sessionRef is recorded, so the server keeps
|
|
849
938
|
// offering the adoption and a retry after the user checks is cheap.
|
|
850
939
|
await settleTurn(job.id, {
|
|
940
|
+
...attempt,
|
|
851
941
|
ok: false,
|
|
852
942
|
answer: "Couldn't resume the terminal session — it may have been removed.",
|
|
853
943
|
// Whatever it DID before coming back empty is exactly the
|
|
@@ -897,10 +987,20 @@ export function createWorkSessionTurns({
|
|
|
897
987
|
*/
|
|
898
988
|
const loginFailure = rt.id === 'codex' && !codexSpoke ? codexLoginFailure(answer) : null;
|
|
899
989
|
const refusedLine = rt.id === 'codex' ? codexApprovalSentence(approvalRefused) : null;
|
|
990
|
+
let artifactFailure = null;
|
|
991
|
+
if (artifactScan) {
|
|
992
|
+
artifactScan.reported = true;
|
|
993
|
+
try {
|
|
994
|
+
await artifacts.report({ placeDir: artifactScan.dir, before: artifactScan.before, sessionId: job.sessionId, turnId: job.id });
|
|
995
|
+
} catch (error) {
|
|
996
|
+
artifactFailure = `Artifact delivery is incomplete: ${error.message}`;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
900
999
|
const spoke = answer.length > 0 && !loginFailure;
|
|
901
1000
|
await settleTurn(job.id, {
|
|
902
|
-
|
|
903
|
-
|
|
1001
|
+
...attempt,
|
|
1002
|
+
ok: spoke && !artifactFailure,
|
|
1003
|
+
answer: artifactFailure ? withRelay(artifactFailure, answer) : loginFailure
|
|
904
1004
|
? withRelay(codexLoginSentence(loginFailure), answer)
|
|
905
1005
|
: answer.length > 0
|
|
906
1006
|
? refusedLine
|
|
@@ -921,7 +1021,14 @@ export function createWorkSessionTurns({
|
|
|
921
1021
|
else if (loginFailure) warn('codex answered 401 for this session turn — settled as failed, in words');
|
|
922
1022
|
else warn('session turn produced no output — settled as failed');
|
|
923
1023
|
} catch (e) {
|
|
1024
|
+
if (toolLog) toolLog.state = 'partial';
|
|
1025
|
+
if (run.interrupted) {
|
|
1026
|
+
const body = { ...attempt, ok: false, interrupted: true, ran: run.spawned.length > 0, answer: 'the turn was interrupted', ...spent() };
|
|
1027
|
+
await (body.ran ? settleTurn(job.id, body) : settleWorkTurn(job.id, body));
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
924
1030
|
await settleTurn(job.id, {
|
|
1031
|
+
...attempt,
|
|
925
1032
|
ok: false,
|
|
926
1033
|
// Scrub, like every string that leaves this machine: an exception
|
|
927
1034
|
// routinely quotes command output, and command output can quote a
|
|
@@ -935,6 +1042,7 @@ export function createWorkSessionTurns({
|
|
|
935
1042
|
warn(`session turn failed: ${e?.message ?? e}`);
|
|
936
1043
|
} finally {
|
|
937
1044
|
workAnswering.delete(job.id);
|
|
1045
|
+
runs.delete(job.id);
|
|
938
1046
|
if (startClaim) startingIn.delete(startClaim);
|
|
939
1047
|
// Nothing spawned, or everything already has: releasing twice is the
|
|
940
1048
|
// normal case and costs nothing. A reservation that leaked would
|
|
@@ -950,7 +1058,7 @@ export function createWorkSessionTurns({
|
|
|
950
1058
|
// …and relay what it wrote to SHOW its owner (2026-09-22) — the same
|
|
951
1059
|
// beat, the same reason it is not awaited. A failed turn's half-drawn
|
|
952
1060
|
// page is still what the turn left, and the tab should see it.
|
|
953
|
-
if (artifactScan) {
|
|
1061
|
+
if (artifactScan && !artifactScan.reported) {
|
|
954
1062
|
void artifacts
|
|
955
1063
|
.report({
|
|
956
1064
|
placeDir: artifactScan.dir,
|
|
@@ -965,5 +1073,32 @@ export function createWorkSessionTurns({
|
|
|
965
1073
|
}
|
|
966
1074
|
};
|
|
967
1075
|
|
|
968
|
-
|
|
1076
|
+
/**
|
|
1077
|
+
* THE RUNS A PERSON ASKED TO STOP (`roster.interruptTurns`, read before the
|
|
1078
|
+
* jobs). One this lane is running is stopped — its process groups killed —
|
|
1079
|
+
* and settles `interrupted` when its CLI is gone. One it finished already
|
|
1080
|
+
* keeps its own settle. One it is not running at all is answered `ran:
|
|
1081
|
+
* false`: nothing was running here to stop, and the server puts it back in
|
|
1082
|
+
* the queue.
|
|
1083
|
+
*/
|
|
1084
|
+
const interruptWorkTurns = (ids) => {
|
|
1085
|
+
rosterInterrupts = new Set(Array.isArray(ids) ? ids : []);
|
|
1086
|
+
const at = now();
|
|
1087
|
+
for (const [id, when] of notRunningSaid) if (at - when > 30_000) notRunningSaid.delete(id);
|
|
1088
|
+
for (const id of Array.isArray(ids) ? ids : []) {
|
|
1089
|
+
if (typeof id !== 'string' || !id) continue;
|
|
1090
|
+
const run = runs.get(id);
|
|
1091
|
+
if (run) {
|
|
1092
|
+
if (!run.interrupted) note(`${c.cyan('tab')} ${c.dim('— interrupting the running turn')}`);
|
|
1093
|
+
run.interrupted = true;
|
|
1094
|
+
for (const ch of run.spawned) killGroup(ch);
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
if (pendingWorkReports.has(id) || settledRecently(id) || notRunningSaid.has(id)) continue;
|
|
1098
|
+
notRunningSaid.set(id, at);
|
|
1099
|
+
void Promise.resolve(settleWorkTurn(id, { ok: false, interrupted: true, ran: false, answer: '' })).catch(() => {});
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
return { processWorkTurns, interruptWorkTurns, workAnswering, settledHere };
|
|
969
1104
|
}
|
package/bin/lib/workToolLog.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* the stream. Tested directly (workToolLog.test.mjs).
|
|
9
9
|
*/
|
|
10
10
|
import { toolEventOf } from './runtimeEvents.mjs';
|
|
11
|
-
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
11
|
+
import { scrubCut, scrub as envScrub } from './uplinkScrub.mjs';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* THE TURN'S TOOL LOG — the structured relay behind the transcript's
|
|
@@ -30,14 +30,74 @@ import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
|
30
30
|
* because it is current state rather than scrollback.
|
|
31
31
|
*/
|
|
32
32
|
export function createToolLog(cwd) {
|
|
33
|
-
const toolLog = { ev: [], dropped: 0 };
|
|
34
|
-
|
|
33
|
+
const toolLog = { ev: [], dropped: 0, state: 'unknown' };
|
|
34
|
+
// IDs are correlation keys, never model-authored citations. Keep only bounded
|
|
35
|
+
// metadata; output supplies a fixed category and allowlisted reader diagnostic.
|
|
36
|
+
// Arbitrary output and file contents are discarded.
|
|
37
|
+
const correlated = new Map();
|
|
38
|
+
const refresh = () => {
|
|
39
|
+
toolLog.state = toolLog.state === 'partial' || toolLog.dropped || toolLog.recoveryDropped ? 'partial' :
|
|
40
|
+
toolLog.ev.length && toolLog.ev.every(e => e.outcome && e.outcome !== 'unknown') ? 'complete' : 'unknown';
|
|
41
|
+
const retained = new Set([...toolLog.ev, toolLog.firstFailure, ...(toolLog.recovery ?? [])]);
|
|
42
|
+
for (const [id, row] of correlated) if (!retained.has(row)) correlated.delete(id);
|
|
43
|
+
};
|
|
44
|
+
const observe = (e, meta) => {
|
|
45
|
+
const code = Number.isSafeInteger(meta.exitCode) ? meta.exitCode : undefined;
|
|
46
|
+
const failed = meta.isError === true || (code !== undefined && code !== 0);
|
|
47
|
+
e.outcome = !meta.result ? 'unknown' : failed ? 'failed' :
|
|
48
|
+
meta.runtime === 'codex' && code === undefined ? 'unknown' : 'success';
|
|
49
|
+
if (code !== undefined) e.exitCode = code;
|
|
50
|
+
if (failed) {
|
|
51
|
+
const output = scrubCut(meta.output ?? '', 16_384);
|
|
52
|
+
const guardAt = output.indexOf('flowviant: this turn is read-only, so ');
|
|
53
|
+
const guard = guardAt >= 0 ? output.slice(guardAt).split('\n', 1)[0] : undefined;
|
|
54
|
+
const diagnostic = guard ?? output.split('\n', 80).find(line => /^(?:[\w./ -]{1,160}: ){1,2}(?:No such file or directory|Permission denied|command not found)\.?$/.test(line) || (e.t === 'read' && /^(?:Error: )?(?:File does not exist|File not found|Permission denied)\.?(?: Note: your current working directory is [^\n]{1,400}\.)?$/.test(line)));
|
|
55
|
+
if (diagnostic) {
|
|
56
|
+
e.errorMessage = diagnostic.slice(0, 500);
|
|
57
|
+
if (diagnostic.length > 500) { e.errorTruncated = true; toolLog.state = 'partial'; }
|
|
58
|
+
}
|
|
59
|
+
e.errorCategory = guard && /a pipe|a glob|a redirect|a second command|an expansion|command substitution|brace expansion|a subshell/.test(guard) ? 'syntax_refusal' : /permission|denied|approval|not allowed|refus/i.test(output) ? 'denied' :
|
|
60
|
+
/command not found|not recognized|executable.*not found/i.test(output) ? 'unavailable_tool' :
|
|
61
|
+
/no such file|not found|does not exist|cannot find/i.test(output) ? 'missing_path' : 'tool_error';
|
|
62
|
+
if (!toolLog.firstFailure) {
|
|
63
|
+
toolLog.firstFailure = { ...e };
|
|
64
|
+
toolLog.recovery = [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const pushToolEvent = (name, input, meta = {}) => {
|
|
69
|
+
const id = typeof meta.id === 'string' && meta.id && meta.id.length <= 160 ? meta.id : undefined;
|
|
70
|
+
const existing = id && correlated.get(id);
|
|
71
|
+
if (existing) {
|
|
72
|
+
if (meta.result) observe(existing, meta);
|
|
73
|
+
refresh();
|
|
74
|
+
return existing;
|
|
75
|
+
}
|
|
76
|
+
if (!name) {
|
|
77
|
+
toolLog.dropped = Math.min(1_000_000, toolLog.dropped + 1);
|
|
78
|
+
refresh();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
35
81
|
// envScrub rides INTO the builder, which scrubs over a bounded
|
|
36
82
|
// window BEFORE its caps — scrubbing after the cut both leaked a
|
|
37
83
|
// boundary-straddling secret's prefix and grew a capped field
|
|
38
84
|
// past the server's limits (review, 2026-09-01).
|
|
39
85
|
const e = toolEventOf(name, input, cwd, envScrub);
|
|
40
86
|
if (!e) return;
|
|
87
|
+
if (e.t !== 'plan') {
|
|
88
|
+
if (id) { e.id = scrubCut(id, 160); correlated.set(id, e); }
|
|
89
|
+
if (name === 'Read') for (const key of ['offset', 'limit']) {
|
|
90
|
+
if (Number.isSafeInteger(input?.[key]) && input[key] >= 0) e[key] = input[key];
|
|
91
|
+
}
|
|
92
|
+
if (toolLog.firstFailure) {
|
|
93
|
+
toolLog.recovery.push(e);
|
|
94
|
+
if (toolLog.recovery.length > 12) {
|
|
95
|
+
toolLog.recovery.shift();
|
|
96
|
+
toolLog.recoveryDropped = Math.min(1_000_000, (toolLog.recoveryDropped ?? 0) + 1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
observe(e, meta);
|
|
100
|
+
}
|
|
41
101
|
if (e.t === 'plan') {
|
|
42
102
|
const i = toolLog.ev.findIndex((x) => x.t === 'plan');
|
|
43
103
|
if (i >= 0) toolLog.ev.splice(i, 1);
|
|
@@ -46,6 +106,11 @@ export function createToolLog(cwd) {
|
|
|
46
106
|
const last = toolLog.ev[toolLog.ev.length - 1];
|
|
47
107
|
const sameKey =
|
|
48
108
|
last &&
|
|
109
|
+
!id && !last.id &&
|
|
110
|
+
last.outcome === e.outcome &&
|
|
111
|
+
last.exitCode === e.exitCode &&
|
|
112
|
+
last.errorCategory === e.errorCategory &&
|
|
113
|
+
last.offset === e.offset && last.limit === e.limit &&
|
|
49
114
|
last.t === e.t &&
|
|
50
115
|
last.p === e.p &&
|
|
51
116
|
last.q === e.q &&
|
|
@@ -71,6 +136,8 @@ export function createToolLog(cwd) {
|
|
|
71
136
|
const [shed] = toolLog.ev.splice(i, 1);
|
|
72
137
|
toolLog.dropped = Math.min(1_000_000, toolLog.dropped + (shed?.n ?? 1));
|
|
73
138
|
}
|
|
139
|
+
refresh();
|
|
140
|
+
return e;
|
|
74
141
|
};
|
|
75
142
|
return { toolLog, pushToolEvent };
|
|
76
143
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { discoveryGuide } from './discoveryContract.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* WHAT A TERMINAL TAB'S TURN IS — plain, plan, capture or build — decided
|
|
3
4
|
* once, as data (audit 2026-10-10, F159). Pure: no I/O, no state.
|
|
@@ -145,8 +146,8 @@ const SYSTEMS = { plain: SYSTEM_WORK_PLAIN, capture: SYSTEM_CAPTURE, work: SYSTE
|
|
|
145
146
|
/** The turn's opening message under its contract. The plain kickoff names no
|
|
146
147
|
* session id (WORK_TURN_KICKOFF_PLAIN takes none) — it has no tools to pass
|
|
147
148
|
* one to. */
|
|
148
|
-
export const sessionTurnKickoff = (shape, { sessionId, sessionName, message, askedByName }) =>
|
|
149
|
-
KICKOFFS[shape.kickoff]({ sessionId, sessionName, message, askedByName });
|
|
149
|
+
export const sessionTurnKickoff = (shape, { sessionId, sessionName, message, askedByName, interrupted = false }) =>
|
|
150
|
+
KICKOFFS[shape.kickoff]({ sessionId, sessionName, message, askedByName, interrupted });
|
|
150
151
|
|
|
151
152
|
/**
|
|
152
153
|
* The turn's system prompt: its contract with the project's knowledge
|
|
@@ -154,10 +155,11 @@ export const sessionTurnKickoff = (shape, { sessionId, sessionName, message, ask
|
|
|
154
155
|
* server can show one (`artifactsAccepted`, read by the lane at spawn), and a
|
|
155
156
|
* plan turn's sentence appended.
|
|
156
157
|
*/
|
|
157
|
-
export const sessionTurnSystem = (shape, { knowledgeDir, artifactsAccepted }) => {
|
|
158
|
+
export const sessionTurnSystem = (shape, { knowledgeDir, artifactsAccepted, runtime }) => {
|
|
158
159
|
const contract = withProjectContext(SYSTEMS[shape.systemBase], {
|
|
159
160
|
knowledgeDir,
|
|
160
161
|
artifacts: shape.artifacts && artifactsAccepted,
|
|
161
162
|
});
|
|
162
|
-
|
|
163
|
+
const discovery = shape.systemBase === 'capture' && runtime ? `\n\n${discoveryGuide(runtime, 'capture')}` : '';
|
|
164
|
+
return shape.planSentence ? `${contract}\n\n${PLAN_TURN_SENTENCE}` : contract + discovery;
|
|
163
165
|
};
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.124.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "flowviant",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.124.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"libsodium-wrappers-sumo": "^0.8.4",
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
"bin": {
|
|
16
16
|
"flowviant": "bin/cli.mjs"
|
|
17
17
|
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"three": "0.180.0"
|
|
20
|
+
},
|
|
18
21
|
"engines": {
|
|
19
22
|
"node": ">=20"
|
|
20
23
|
}
|
|
@@ -34,6 +37,13 @@
|
|
|
34
37
|
"libsodium-sumo": "^0.8.0"
|
|
35
38
|
}
|
|
36
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
|
+
},
|
|
37
47
|
"node_modules/ws": {
|
|
38
48
|
"version": "8.21.1",
|
|
39
49
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.124.0",
|
|
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": {
|
|
@@ -35,10 +35,14 @@
|
|
|
35
35
|
"homepage": "https://flowviant.com",
|
|
36
36
|
"repository": {
|
|
37
37
|
"type": "git",
|
|
38
|
-
"url": "git+https://github.com/flowviant
|
|
38
|
+
"url": "git+https://github.com/Flowviant/flowviant.git",
|
|
39
|
+
"directory": "cli"
|
|
39
40
|
},
|
|
40
41
|
"license": "MIT",
|
|
41
42
|
"bugs": {
|
|
42
|
-
"url": "https://github.com/flowviant/
|
|
43
|
+
"url": "https://github.com/Flowviant/flowviant/issues"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"three": "0.180.0"
|
|
43
47
|
}
|
|
44
48
|
}
|