flowviant 0.102.2 → 0.104.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.
Files changed (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,93 @@
1
+ /**
2
+ * FILES THE HUMAN ATTACHED, downloaded into the place a turn runs in.
3
+ *
4
+ * Split out of work.mjs (2026-09-26, SOLID F037). One trust boundary — a
5
+ * server-sent name becoming a path on somebody's machine — with its caps, its
6
+ * collision rule and its git exclude, reached only from the session lane.
7
+ * The name rule itself is safeFileName.mjs's.
8
+ */
9
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
12
+ import { excludeInWorktree } from './git.mjs';
13
+ import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
14
+ import { safeFileName } from './safeFileName.mjs';
15
+ import { fleetEndpoint } from './fleetWire.mjs';
16
+
17
+ export function createWorkAttachments() {
18
+ const ATTACHMENT_URL = fleetEndpoint('attachment', FLEET_URL);
19
+ /**
20
+ * FILES THE HUMAN ATTACHED, brought to where a CLI can read them.
21
+ *
22
+ * A screenshot in a chat bubble is useless to an agent; a path is not. So the
23
+ * turn's attachments are downloaded into `.flowviant/uploads/` inside the
24
+ * session's own worktree and the prompt is handed the relative paths.
25
+ *
26
+ * `.flowviant/` rather than the repo proper, and gitignored-or-not it is
27
+ * never committed by us: these are the human's inputs to a conversation, not
28
+ * project files. The name is re-sanitized HERE even though the server already
29
+ * did it — this string becomes a path on someone's machine, and one place
30
+ * doing that check is one deploy away from being zero places.
31
+ */
32
+ const UPLOAD_DIR = '.flowviant/uploads';
33
+ const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
34
+ // `safeFileName` (bin/lib/safeFileName.mjs — the server's rule, one daemon
35
+ // home): THE EXTENSION SURVIVES A CUT, and it is idempotent over its own
36
+ // output, so re-applying it to what the server already sanitized is a no-op
37
+ // and the two sides never disagree about the cut. An empty name is
38
+ // `attachment`, the server's own fallback.
39
+ const safeUploadName = (raw) => safeFileName(raw, 'attachment');
40
+ /** @returns relative paths written, in the order the human attached them. */
41
+ const fetchAttachments = async (wt, attachments) => {
42
+ if (!Array.isArray(attachments) || attachments.length === 0) return [];
43
+ const dir = join(wt, UPLOAD_DIR);
44
+ try {
45
+ mkdirSync(dir, { recursive: true });
46
+ } catch {
47
+ return [];
48
+ }
49
+ // "Never committed by us" has to be true for GIT, not just for this code:
50
+ // an untracked `.flowviant/` makes the whole worktree dirty, which refuses
51
+ // every ship, exempts the tree from closed-tab retirement forever, and
52
+ // shows the human's own uploads in the rail as session changes. Same
53
+ // mechanism the materialized env files used until the vault was deleted —
54
+ // the exclude file git actually reads (git.mjs, where the helper moved when
55
+ // env.mjs shrank), which already skips lines it has written before, so
56
+ // calling it per fetch is idempotent. NARROWED 2026-09-23 to the paths
57
+ // this daemon writes (`FLOWVIANT_OWN_PATHS`, knowledgeLibrary.mjs says why): the
58
+ // whole-directory line also hid a repo's own new `.flowviant/check.json`
59
+ // from its agent's `git add -A`.
60
+ excludeInWorktree(wt, FLOWVIANT_OWN_PATHS);
61
+ const written = [];
62
+ for (const a of attachments.slice(0, 8)) {
63
+ if (!a?.id || typeof a.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(a.id)) continue;
64
+ if (Number(a.size) > ATTACHMENT_MAX_BYTES) continue;
65
+ try {
66
+ const res = await fetch(`${ATTACHMENT_URL}/${a.id}`, {
67
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
68
+ signal: AbortSignal.timeout(60_000),
69
+ });
70
+ if (!res.ok) continue;
71
+ const buf = Buffer.from(await res.arrayBuffer());
72
+ if (buf.byteLength === 0 || buf.byteLength > ATTACHMENT_MAX_BYTES) continue;
73
+ // Collisions are real (two screenshots both named Screenshot.png), and
74
+ // silently overwriting one with the other loses a file the human sent.
75
+ let name = safeUploadName(a.name);
76
+ if (existsSync(join(dir, name))) {
77
+ const dot = name.lastIndexOf('.');
78
+ const stem = dot > 0 ? name.slice(0, dot) : name;
79
+ const ext = dot > 0 ? name.slice(dot) : '';
80
+ name = `${stem}-${String(a.id).slice(0, 6)}${ext}`;
81
+ }
82
+ writeFileSync(join(dir, name), buf);
83
+ written.push(`${UPLOAD_DIR}/${name}`);
84
+ } catch {
85
+ /* one file failing must not fail the turn — the prompt lists what
86
+ actually arrived, so the agent never chases a path that isn't there */
87
+ }
88
+ }
89
+ return written;
90
+ };
91
+
92
+ return { fetchAttachments };
93
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * WHICH BRAIN, AT WHICH EFFORT — the one guard every turn's `--model` and
3
+ * `--effort` pass through before they ride argv.
4
+ *
5
+ * Split out of work.mjs (2026-09-26, SOLID F037). Two lanes read it — the
6
+ * Workbench tab (workSessionTurns.mjs) and the agent turn (workAgentTurns.mjs)
7
+ * — each importing it directly — and it is pure over the job and the runtime
8
+ * table, so its home is a module of its own rather than either lane.
9
+ */
10
+ import { RUNTIMES } from './runtimes.mjs';
11
+
12
+ /**
13
+ * The shape a per-tab model name must have before it rides argv as
14
+ * `--model <name>`. Conservative for the same reason the codex thread id is
15
+ * (below): it comes off the wire and lands in a child process's arguments —
16
+ * alphanumerics plus dot/dash/underscore, at most 40 characters, and NEVER a
17
+ * leading dash, which is an argv that parses as a flag.
18
+ */
19
+ const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
20
+
21
+ /**
22
+ * WHICH BRAIN, AT WHICH EFFORT — the tab's own pick, off the roster.
23
+ *
24
+ * Absent is the resting state and it must stay genuinely absent: every tab ran
25
+ * with no `--model` and no `--effort` until now, so a job that names neither
26
+ * has to produce the byte-identical argv it produced yesterday — Claude falling
27
+ * back to the machine's MODEL pin, codex and agy to their own defaults. Hence
28
+ * an object with the key MISSING rather than one holding null: a null would
29
+ * reach the builders as a value and Claude's `model || MODEL` is the only one
30
+ * that would survive it.
31
+ *
32
+ * A value that fails its guard is DROPPED, not passed through and not an error.
33
+ * The honest outcome of "the server named a model this machine can't spell" is
34
+ * the machine's own default — a turn that runs — rather than a flag no CLI
35
+ * understands and a tab that fails every message.
36
+ */
37
+ /**
38
+ * THE EFFORT IS CHECKED AGAINST THE CLI THAT WILL RUN THE TURN (2026-09-26).
39
+ * One global ladder once accepted `max` for every runtime, and Codex has no
40
+ * `max`: a skewed or previously queued Codex job reached argv as a
41
+ * `model_reasoning_effort` its CLI refuses. The per-CLI list lives beside each
42
+ * runtime's argv builder (`RUNTIMES[id].efforts`, runtime{Claude,Codex,Antigravity}.mjs); a runtime this
43
+ * daemon does not define runs nothing, so it spells no effort either.
44
+ */
45
+ export function brainFor(job, runtimeId) {
46
+ const out = {};
47
+ const model = typeof job?.model === 'string' ? job.model.trim() : '';
48
+ if (model && WORK_MODEL_RE.test(model)) out.model = model;
49
+ const effort = typeof job?.effort === 'string' ? job.effort.trim() : '';
50
+ const efforts = RUNTIMES[runtimeId]?.efforts ?? [];
51
+ if (effort && efforts.includes(effort)) out.effort = effort;
52
+ return out;
53
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * THE MACHINE'S LIVE CLI CHILDREN — the registry every lane adds its spawn
3
+ * to, the teardown that SIGTERMs them, the count the concurrency ceiling is
4
+ * on, and the admission built over that count.
5
+ *
6
+ * Split out of work.mjs (2026-09-26, SOLID F037). Six lanes write
7
+ * `workChildren` (tab turns, agent turns, plans, the check, the pre-review)
8
+ * and three things read it (teardown, `liveTurnCount`, the machine snapshot's
9
+ * per-task RSS), so the map and everything that reads it as "what is running
10
+ * on this box" belong together — and apart from any one lane.
11
+ */
12
+ import { createAdmission } from './admission.mjs';
13
+
14
+ export function createWorkChildren({ extraLiveTurns }) {
15
+ /**
16
+ * Live session-turn CLI children. The daemon's teardown SIGTERMs them: an
17
+ * orphaned CLI keeps editing the session worktree and burning quota after
18
+ * the daemon is gone. Each child's pid-lock is deliberately LEFT IN PLACE —
19
+ * a CLI can trap SIGTERM to finish an in-flight request and outlive this
20
+ * loop by seconds, and removing the lock in the same tick handed the
21
+ * restarted daemon a green light to spawn a second CLI into the same held
22
+ * context. turnLockedByLivePid already covers both outcomes: it waits while
23
+ * the pid lives and clears the lock once it is dead.
24
+ *
25
+ * THE VALUE IS THE ID THE CHILD SERVES, and it used to be the pid-lock path —
26
+ * which nothing in this file has ever read. A write-only value is not free:
27
+ * the machine snapshot's per-task RSS (`/fleet/machine`) is the one readout
28
+ * that answers "which task is holding nine gigabytes", and it was being built
29
+ * from the dispatch-era `workers` map, which nothing has `.set()` since that
30
+ * lane was deleted — so the column its own server handler calls the
31
+ * load-bearing half of the report had never been populated once. This map is
32
+ * the only place that knows both the pid and whose work it is, so it carries
33
+ * both. `null` where there is no id to name (a Deploy press is not a task).
34
+ */
35
+ const workChildren = new Map(); // child process -> sessionId | agentId | null
36
+ /**
37
+ * Children whose whole PROCESS GROUP must go, not just the child.
38
+ *
39
+ * The standing rule is the opposite — teardown SIGTERMs the CLI child and
40
+ * never its group, precisely so an unattended auto-update does not kill the
41
+ * driver's dev server, which a turn started INTO the CLI's group. That rule
42
+ * is about the CLI's group and stays.
43
+ *
44
+ * A project CHECK is a different group entirely: the daemon spawns it itself,
45
+ * `detached` with `shell: true`, so its group holds the check command and
46
+ * nothing else — no dev server of anybody's. And `shell: true` is exactly
47
+ * what makes signalling only the child useless: a compound command like
48
+ * `npm run lint && npm test` leaves `/bin/sh` as the child, so SIGTERM killed
49
+ * the shell and the test runner underneath it carried on holding the
50
+ * worktree — and that place's WRITER lock — through every stop, takeover and
51
+ * auto-update. The check's own ten-minute timer already kills `-child.pid`
52
+ * for this reason; teardown simply did not.
53
+ */
54
+ const groupKillChildren = new Set();
55
+ const shutdownWork = () => {
56
+ for (const [ch] of workChildren) {
57
+ try {
58
+ if (groupKillChildren.has(ch) && ch.pid) process.kill(-ch.pid, 'SIGTERM');
59
+ else ch.kill('SIGTERM');
60
+ } catch {
61
+ // A group that has already gone, or a pid that is no longer a leader.
62
+ try {
63
+ ch.kill('SIGTERM');
64
+ } catch {
65
+ /* best-effort */
66
+ }
67
+ }
68
+ }
69
+ workChildren.clear();
70
+ groupKillChildren.clear();
71
+ };
72
+
73
+ /**
74
+ * HOW MANY CLIs THIS MACHINE IS RUNNING RIGHT NOW — the number
75
+ * `MAX_CONCURRENT` is a ceiling on, and the thing that had no counter.
76
+ *
77
+ * Every lane, because the bound is on the BOX and not on a lane: session
78
+ * turns, an agent's turn, a Deploy press's planner, a project check — all of
79
+ * them land in `workChildren` — plus whatever the caller reports on top of it
80
+ * (the wiki cartographer, wikiRunner.mjs).
81
+ *
82
+ * THE PROJECT CHECK COUNTS, deliberately. It is not a model turn, but it is a
83
+ * full test or build run in a worktree, which is exactly the kind of process
84
+ * this ceiling exists to stop stacking. Nothing gates a check, so counting it
85
+ * cannot deadlock: it only ever delays the NEXT spawn.
86
+ */
87
+ const liveTurnCount = () => {
88
+ const extra = Number(extraLiveTurns() ?? 0);
89
+ return workChildren.size + (Number.isFinite(extra) && extra > 0 ? extra : 0);
90
+ };
91
+
92
+ /** The live turn children with the id each one serves — what the machine
93
+ * snapshot charges its per-task RSS to. Children with no id (the planner)
94
+ * are still counted above; they just have nothing to be charged TO. */
95
+ const liveTurns = () => {
96
+ const out = [];
97
+ for (const [ch, id] of workChildren) if (ch?.pid) out.push({ id: id ?? null, pid: ch.pid });
98
+ return out;
99
+ };
100
+
101
+ /**
102
+ * WHETHER TO START ONE MORE. See admission.mjs for the whole argument: a
103
+ * runaway bound on a machine, read at the spawn, surfaced only as the
104
+ * machine's own measured sentence at the thing that is waiting, and never a
105
+ * reason to settle a job — a deferred job is re-offered next poll.
106
+ */
107
+ const admit = createAdmission({ liveTurnCount });
108
+
109
+ return { workChildren, groupKillChildren, shutdownWork, liveTurnCount, liveTurns, admit };
110
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * THE COMMAND AUDIT — the durable record of every `$ …` a tab's CLI ran on the
3
+ * shared machine, batched to `/fleet/session-commands`.
4
+ *
5
+ * Split out of work.mjs's session turn (2026-09-26, SOLID F037). One per turn;
6
+ * the lane feeds it the stream's activity and flushes it at settle. It carries
7
+ * commands only, scrubbed, and a failed post drops the batch rather than
8
+ * blocking the turn.
9
+ */
10
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
11
+ import { scrub as envScrub } from './uplinkScrub.mjs';
12
+ import { fleetEndpoint } from './fleetWire.mjs';
13
+
14
+ // THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
15
+ // to the server verbatim so an admin can read what actually ran on
16
+ // this box. Same events the narrator renders and forgets; this is
17
+ // the durable copy, and it carries ONLY commands — no prose, no
18
+ // thinking, no file reads (the session stays private; what executed
19
+ // on the shared machine is the machine's own fact to relay).
20
+ // Flushed mid-turn every 25 so a long turn is not one giant loss on
21
+ // a kill, and again at settle. Best-effort: a failed post drops the
22
+ // batch rather than blocking the turn — the surface says it is the
23
+ // machine's report, not a syscall trace.
24
+ export function createCommandAudit({ sessionId, turnId, runtime, cwd }) {
25
+ const SESSION_COMMANDS_URL = fleetEndpoint('session-commands', FLEET_URL);
26
+ const auditBatch = [];
27
+ const flushAudit = () => {
28
+ if (auditBatch.length === 0) return;
29
+ const commands = auditBatch.splice(0, auditBatch.length);
30
+ void fetch(SESSION_COMMANDS_URL, {
31
+ method: 'POST',
32
+ headers: {
33
+ Authorization: `Bearer ${FLEET_TOKEN}`,
34
+ 'User-Agent': USER_AGENT,
35
+ 'Content-Type': 'application/json',
36
+ },
37
+ signal: AbortSignal.timeout(30_000),
38
+ body: JSON.stringify({
39
+ sessionId,
40
+ turnId,
41
+ runtime,
42
+ cwd,
43
+ commands,
44
+ }),
45
+ }).catch(() => {
46
+ /* best-effort — the audit records what reached it */
47
+ });
48
+ };
49
+ const auditCommand = (a) => {
50
+ if (a?.kind !== 'bash' || !a.command) return;
51
+ // Scrubbed like every other string that leaves this box (the
52
+ // narrator label, the settle answer, commit subjects, ship/merge
53
+ // lines, the per-tab process report). A command line is exactly
54
+ // where a secret leaks — `curl -H "authorization: <token>"`,
55
+ // `PGPASSWORD=… psql` — and the audit is stored 30 days and rendered
56
+ // in the admin view, so the one uplink that omitted scrub was the
57
+ // one most likely to carry a plaintext secret.
58
+ auditBatch.push({ command: envScrub(a.command), at: new Date().toISOString() });
59
+ if (auditBatch.length >= 25) flushAudit();
60
+ };
61
+ return { flushAudit, auditCommand };
62
+ }
@@ -1,9 +1,10 @@
1
1
  /** Commit diff requests and their bounded reports. */
2
2
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
3
3
  import { git } from './git.mjs';
4
+ import { fleetEndpoint } from './fleetWire.mjs';
4
5
 
5
6
  export function createWorkDiffs({ repoRoot }) {
6
- const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
7
+ const DIFF_DONE_URL = fleetEndpoint('diff-done', FLEET_URL);
7
8
 
8
9
  /**
9
10
  * COMMIT DIFFS, on request — the one PULL-shaped thing this daemon does.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * THE TAB'S LIVE LINE — the CLI's own stdout relayed while a turn runs, and
3
+ * the machine's one sentence when it defers a turn the tab is waiting on.
4
+ *
5
+ * Split out of work.mjs (2026-09-26, SOLID F037). Both ride
6
+ * `/fleet/session-activity`, both are throttled decoration that must never
7
+ * fail a turn, and both change for the same reason (what the tab shows while
8
+ * nothing has settled). The session lane builds one narrator per turn and
9
+ * clears the deferral clock when a turn actually starts.
10
+ */
11
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
12
+ import { scrub as envScrub } from './uplinkScrub.mjs';
13
+ import { fleetEndpoint } from './fleetWire.mjs';
14
+
15
+ export function createWorkNarration() {
16
+ const ACTIVITY_URL = fleetEndpoint('session-activity', FLEET_URL);
17
+ /**
18
+ * THE TAB'S LIVE NARRATION — the terminal's own stdout, relayed.
19
+ *
20
+ * A turn used to be a spinner: the tab said "working…" for minutes and the
21
+ * only thing that ever appeared was the finished reply. The CLI is printing
22
+ * the whole time (thinking, reads, greps, commands), so the honest fix is to
23
+ * FORWARD that, not to invent a progress model on the server. Flowviant
24
+ * relays; it does not narrate on its own behalf.
25
+ *
26
+ * Best-effort by construction: throttled to one POST per window (a turn can
27
+ * emit hundreds of lines), never awaited by the turn, and every failure is
28
+ * swallowed. A spinner must never be able to fail a build. The server clears
29
+ * the line at settle, so a daemon killed mid-turn cannot leave one stuck.
30
+ */
31
+ const ACTIVITY_MIN_MS = 1_500;
32
+ const ACTIVITY_KEEP = 4; // the last few lines — a tail, not a log
33
+ /** `turnId` scopes the narration to the turn that produced it: a POST
34
+ * already on the wire when the turn settles must not re-stamp a "working…"
35
+ * line over the finished reply — the server drops narration for a turn
36
+ * that is no longer pending. (A session-level pending count can't tell the
37
+ * settled turn's stale line from the queued NEXT turn's fresh one.) */
38
+ const makeNarrator = (sessionId, turnId, getTools) => {
39
+ const recent = [];
40
+ let lastSent = 0;
41
+ let dirty = false;
42
+ let timer = null;
43
+ let sending = false;
44
+ let stopped = false;
45
+ const send = async () => {
46
+ if (sending || stopped) return;
47
+ sending = true;
48
+ dirty = false;
49
+ lastSent = Date.now();
50
+ const lines = recent.slice(-ACTIVITY_KEEP);
51
+ try {
52
+ await fetch(ACTIVITY_URL, {
53
+ method: 'POST',
54
+ headers: {
55
+ Authorization: `Bearer ${FLEET_TOKEN}`,
56
+ 'User-Agent': USER_AGENT,
57
+ 'Content-Type': 'application/json',
58
+ },
59
+ signal: AbortSignal.timeout(10_000),
60
+ body: JSON.stringify({
61
+ sessionId,
62
+ turnId,
63
+ lines,
64
+ // The structured tool log so far, riding the same throttled beat.
65
+ // Same lifecycle as the lines: overwritten as the turn moves,
66
+ // cleared server-side at settle. Absent until something ran.
67
+ ...(getTools ? { tools: getTools() } : {}),
68
+ }),
69
+ });
70
+ } catch {
71
+ /* narration is decoration — a dropped line is not an incident */
72
+ }
73
+ sending = false;
74
+ if (dirty && !stopped) schedule();
75
+ };
76
+ const schedule = () => {
77
+ if (timer || stopped) return;
78
+ const wait = Math.max(0, ACTIVITY_MIN_MS - (Date.now() - lastSent));
79
+ timer = setTimeout(() => {
80
+ timer = null;
81
+ void send();
82
+ }, wait);
83
+ timer.unref?.(); // never hold the process open for a spinner
84
+ };
85
+ return {
86
+ line(label) {
87
+ // Scrub, like every string that leaves this machine: a narration line
88
+ // is the CLI's own stdout — a command echoing an env var, a read of a
89
+ // config file — and it rides the same uplink the final answer does.
90
+ const s = envScrub(String(label ?? ''))
91
+ .replace(/\s+/g, ' ')
92
+ .trim()
93
+ .slice(0, 200);
94
+ if (!s || stopped) return;
95
+ recent.push(s);
96
+ if (recent.length > ACTIVITY_KEEP * 2) recent.shift();
97
+ dirty = true;
98
+ schedule();
99
+ },
100
+ stop() {
101
+ stopped = true;
102
+ if (timer) {
103
+ clearTimeout(timer);
104
+ timer = null;
105
+ }
106
+ },
107
+ };
108
+ };
109
+
110
+ /**
111
+ * TELL THE TAB IT IS WAITING ON THE BOX, not on its Claude.
112
+ *
113
+ * A deferred turn is invisible from a browser: the composer says "working…"
114
+ * and the machine simply does not spawn, which looks exactly like a slow
115
+ * model. So the deferral rides the narration channel the turn would have used
116
+ * anyway — the turn is still pending, so the server accepts the line — and
117
+ * says the measured reason and what happens next. The machine's own voice,
118
+ * for a moment only this side can see; the same shape the planner's "waiting
119
+ * for the checkout" already keeps.
120
+ *
121
+ * ONCE PER SESSION PER WINDOW, because the roster re-offers the same turn on
122
+ * every poll and restating an unchanged sentence every ten seconds is a POST
123
+ * loop, not a readout. The clock is cleared the moment a turn for that session
124
+ * actually starts, so the next stall speaks immediately rather than inheriting
125
+ * a window from an unrelated one.
126
+ */
127
+ const DEFER_SAY_MS = 30_000;
128
+ const lastDeferSaid = new Map(); // sessionId -> ms
129
+ const sayTurnDeferred = (sessionId, turnId, reason) => {
130
+ const now = Date.now();
131
+ if (now - (lastDeferSaid.get(sessionId) ?? 0) < DEFER_SAY_MS) return;
132
+ lastDeferSaid.set(sessionId, now);
133
+ void fetch(ACTIVITY_URL, {
134
+ method: 'POST',
135
+ headers: {
136
+ Authorization: `Bearer ${FLEET_TOKEN}`,
137
+ 'User-Agent': USER_AGENT,
138
+ 'Content-Type': 'application/json',
139
+ },
140
+ signal: AbortSignal.timeout(10_000),
141
+ body: JSON.stringify({
142
+ sessionId,
143
+ turnId,
144
+ lines: [`Deferred — ${reason}. The machine retries on its next poll.`],
145
+ }),
146
+ }).catch(() => {
147
+ /* a readout — a dropped line is not an incident */
148
+ });
149
+ };
150
+
151
+ return { makeNarrator, sayTurnDeferred, lastDeferSaid };
152
+ }