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
@@ -80,7 +80,23 @@
80
80
  */
81
81
 
82
82
  import { MAX_CONCURRENT, MAX_CONCURRENT_FROM_ENV } from './config.mjs';
83
- import { pressureVerdict } from './resources.mjs';
83
+ import { measurePressure } from './resources.mjs';
84
+ import { pressureVerdict, readPressureThresholds } from './pressurePolicy.mjs';
85
+
86
+ /**
87
+ * THE PRESSURE HALF OF AN ADMISSION, AS THE DAEMON ASKS IT: this box's
88
+ * (briefly cached) measurement, judged against the thresholds the environment
89
+ * states AT THIS CALL. Both are read per decision — an operator who exports
90
+ * `FLOWVIANT_MIN_FREE_MB` into a running daemon's environment is heard on the
91
+ * next spawn. Injectable only so that promise can be pinned; `createAdmission`
92
+ * passes nothing. A guard switched off measures nothing (no `vm_stat` spawn
93
+ * per decision on macOS), as before the split.
94
+ */
95
+ export function livePressureVerdict(level, { measure = measurePressure, env = process.env } = {}) {
96
+ const thresholds = readPressureThresholds(env);
97
+ if (thresholds.off) return null;
98
+ return pressureVerdict(level, measure(), thresholds);
99
+ }
84
100
 
85
101
  /**
86
102
  * ── THE CEILING HAS THREE POSSIBLE AUTHORS (2026-09-17) ──
@@ -127,7 +143,7 @@ export function pickMaxTurns({ env = null, server = null, derived = 1 } = {}) {
127
143
  * A module holder rather than a value threaded through `createWorkManager`,
128
144
  * because the reader and the writer are two files apart and one poll apart: the
129
145
  * POLL learns it (fleet.mjs) and every ADMISSION reads it, including the ones
130
- * work.mjs takes in lanes fleet.mjs never sees. The alternative — passing it
146
+ * the work manager takes in lanes fleet.mjs never sees (workChildren.mjs builds it). The alternative — passing it
131
147
  * down — would mean the number that bound a spawn was whatever was current when
132
148
  * the manager was CONSTRUCTED, which is exactly the frozen-at-startup bug
133
149
  * `getBaseRef` and `getLeaseTtl` are getters to avoid.
@@ -171,7 +187,7 @@ export function createAdmission({
171
187
  * A number is still accepted, and every test here passes one — the whole
172
188
  * point of injecting it. */
173
189
  maxConcurrent = effectiveMaxTurns,
174
- verdict = pressureVerdict,
190
+ verdict = livePressureVerdict,
175
191
  } = {}) {
176
192
  /** Slots taken by a decision whose process does not exist yet. A Set of
177
193
  * tokens rather than a counter: a double release then costs nothing, which
@@ -27,7 +27,7 @@
27
27
 
28
28
  // THE UPLINK SCRUBBER, as the DEFAULT for the two readers whose every string
29
29
  // goes to the server (2026-09-24, the audit). See `parseTurnResult`.
30
- import { scrub as envScrub } from './env.mjs';
30
+ import { scrub as envScrub } from './uplinkScrub.mjs';
31
31
 
32
32
  /** The biggest a single proposal may be. Bounds on a machine, not a policy:
33
33
  * the server caps these again at its own boundary. */
@@ -277,7 +277,7 @@ function uniqueTempIds(list) {
277
277
  * summary and the wire, and a cap must never run ahead of it. So this is the
278
278
  * same absurdity bound `summary` takes one line up (8000): enough that nothing
279
279
  * a model writes reaches the caller unbounded, far enough above the 1000 the
280
- * wire cuts to that the scrub always sees whole values. `work.mjs` scrubs and
280
+ * wire cuts to that the scrub always sees whole values. `workAgentTurns.mjs` scrubs and
281
281
  * THEN cuts to 1000, and the server clamps again because the wire never trusts
282
282
  * a machine to have clamped.
283
283
  */
@@ -0,0 +1,93 @@
1
+ /**
2
+ * A CARD'S KIND — the daemon's one table of what each kind IS (0.97.0):
3
+ * `code` (the default), `design` and `research`. Every reader of a kind goes
4
+ * through this module: the prompts that print it and pick a contract
5
+ * (prompts.mjs, which keeps the strings), the agent lane that picks a posture,
6
+ * refuses a runtime (workAgentTurnExecution.mjs) and checks the artifact
7
+ * (workAgentTurnOutcome.mjs), and the turn runner that fences the non-code
8
+ * postures (runTurn.mjs and claudePosture.mjs, via turnProfile.mjs).
9
+ *
10
+ * Split out (SOLID F041) because the kind was branched on at five sites — the
11
+ * vocabulary twice in prompts.mjs, the posture and the artifact rule in the
12
+ * agent lane, the posture names again in claude.mjs — so a fourth kind was a
13
+ * coordinated edit whose missed site ran a card under the wrong posture. Now a
14
+ * kind is one entry here, and `agentTaskKinds.test.mjs` proves every entry has
15
+ * a contract, a posture a runtime can declare, and (for a non-code kind) the
16
+ * artifact that proves it delivered.
17
+ *
18
+ * Pure data and pure functions: no imports, no environment, no I/O — prompts
19
+ * imports it, and prompts is strings.
20
+ */
21
+
22
+ /**
23
+ * The kinds. Per kind:
24
+ * - `posture`: the turn profile the card runs under (turnProfile.mjs);
25
+ * - `artifact`: null for code (it delivers commits), else what proves a
26
+ * delivery — `match` over the artifact file name and the `missing`
27
+ * sentence a turn settles `nothing` with when none was written;
28
+ * - `offRuntime(rt)`: the sentence when the chosen CLI cannot run the posture.
29
+ */
30
+ export const AGENT_TASK_KINDS = Object.freeze({
31
+ code: Object.freeze({
32
+ posture: 'build',
33
+ artifact: null,
34
+ offRuntime: (rt) => `this machine cannot run ${rt}`,
35
+ }),
36
+ design: Object.freeze({
37
+ posture: 'design',
38
+ artifact: Object.freeze({
39
+ match: /\.html?$/i,
40
+ missing: 'the turn ended without writing a mockup under .flowviant/artifacts/',
41
+ }),
42
+ offRuntime: () => 'design and research cards run on Claude on this machine',
43
+ }),
44
+ research: Object.freeze({
45
+ posture: 'research',
46
+ artifact: Object.freeze({
47
+ match: /\.md$/i,
48
+ missing: 'the turn ended without writing a write-up under .flowviant/artifacts/',
49
+ }),
50
+ offRuntime: () => 'design and research cards run on Claude on this machine',
51
+ }),
52
+ });
53
+
54
+ const known = (v) => typeof v === 'string' && Object.hasOwn(AGENT_TASK_KINDS, v);
55
+
56
+ /**
57
+ * A CARD'S KIND, READ THE ONE WAY THE DAEMON READS IT.
58
+ *
59
+ * The server sends the key only for non-code kinds, so ABSENT IS CODE, and an
60
+ * unknown string (a newer server, a typo) degrades to code: the one kind that
61
+ * changes nothing about how a card is PRINTED. Every printing reader — the
62
+ * contract selector, the kickoff, the spec — goes through this, so no two of
63
+ * them can disagree about what an odd value means.
64
+ */
65
+ export const agentTaskKindOf = (v) => (known(v) ? v : 'code');
66
+
67
+ /**
68
+ * …EXCEPT WHERE A TURN IS ABOUT TO SPAWN (2026-09-23). "Unknown degrades to
69
+ * code" is right for every reader that only PRINTS the kind, and wrong for the
70
+ * one that picks a POSTURE: code is the build turn, permissions skipped, so a
71
+ * kind a newer server invented for a card that must not touch the code (a
72
+ * fourth kind, a typo, `Design`) would have run as exactly the turn it exists
73
+ * to prevent. The agent lane asks this first and refuses in words, relaying
74
+ * the word it was handed. Absent, null and empty stay code — that is how a
75
+ * code card arrives.
76
+ *
77
+ * @returns {string|null} the unrecognised value, or null when the kind is one
78
+ * this daemon runs.
79
+ */
80
+ export const unknownAgentTaskKind = (v) =>
81
+ v === undefined || v === null || v === '' || known(v)
82
+ ? null
83
+ : String(v).replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 40);
84
+
85
+ /** The table entry for a kind value, read the printing way (unknown → code). */
86
+ export const agentTaskKind = (v) => AGENT_TASK_KINDS[agentTaskKindOf(v)];
87
+
88
+ /** The postures only a card's kind selects (every kind's but code's). */
89
+ export const KIND_POSTURES = Object.freeze(
90
+ Object.values(AGENT_TASK_KINDS)
91
+ .map((k) => k.posture)
92
+ .filter((p) => p !== 'build')
93
+ );
@@ -0,0 +1,158 @@
1
+ /**
2
+ * ANTIGRAVITY (agy) SESSION PRESENCE (split out of localSessions.mjs
3
+ * 2026-09-26, SOLID F062). agy's SQLite store, its cwd cache and its
4
+ * process-name liveness change for reasons of their own, apart from Claude's
5
+ * transcript format (claudeSessions.mjs); the shared fence and report cap are
6
+ * the coordinator's (localSessions.mjs).
7
+ *
8
+ * NOTHING in this file throws.
9
+ */
10
+
11
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { execFileSync } from 'node:child_process';
15
+ import { procPids, scanProcs } from './procScan.mjs';
16
+
17
+ // agy's store is nothing like Claude's: one SQLite db per conversation at
18
+ // ~/.gemini/antigravity-cli/conversations/<uuid>.db (global, not cwd-keyed),
19
+ // no per-pid liveness registry that survives contact (the presence/*.lock
20
+ // files sit untouched by real runs — measured), and the only cwd mapping is
21
+ // cache/last_conversations.json: {cwd → the LAST conversation run there}.
22
+ // So the honest agy report is a SUBSET — the last conversation per directory
23
+ // inside this repo — and that is exactly the one `agy --continue` would give
24
+ // the person at that keyboard, i.e. the one worth offering to adopt.
25
+
26
+ const AGY_DIR = () => join(homedir(), '.gemini', 'antigravity-cli');
27
+ const AGY_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
+
29
+ /** Newest write to the conversation's store — the wal carries recent turns,
30
+ * so its mtime (not the db's) is the real "last active" (measured: a resume
31
+ * touched db+wal, and never the presence lock). 0 = no such conversation. */
32
+ function agyLastWriteMs(id) {
33
+ if (!AGY_UUID_RE.test(id)) return 0;
34
+ let newest = 0;
35
+ for (const suffix of ['.db', '.db-wal']) {
36
+ try {
37
+ const t = statSync(join(AGY_DIR(), 'conversations', `${id}${suffix}`)).mtimeMs;
38
+ if (t > newest) newest = t;
39
+ } catch {
40
+ /* absent half is fine — the db alone still answers */
41
+ }
42
+ }
43
+ return newest;
44
+ }
45
+
46
+ /** Any agy process on the machine right now? /proc comm scan — cheap at the
47
+ * 60s cadence, and the only liveness signal agy leaves (locks are inert).
48
+ * Tri-state on purpose: true (an agy process exists), false (scanned /proc
49
+ * and found none), null (no /proc to scan — macOS — so the question is
50
+ * UNANSWERABLE here, which is a different fact from "no", and the adoption
51
+ * path below treats it differently: unknowable refuses, absent permits). */
52
+ function agyProcessAlive() {
53
+ if (!existsSync('/proc')) {
54
+ // No /proc (macOS): ask pgrep the same question. A MEASURED "none" here
55
+ // matters — answering null instead made every agy conversation on such a
56
+ // machine read live forever, which both invented a state ("live" when the
57
+ // truth was "unmeasured") and permanently killed agy adoption there.
58
+ try {
59
+ execFileSync('pgrep', ['-x', 'agy'], {
60
+ stdio: ['ignore', 'ignore', 'ignore'],
61
+ timeout: 5_000,
62
+ });
63
+ return true; // exit 0 — at least one agy process
64
+ } catch (e) {
65
+ if (e?.status === 1) return false; // pgrep ran and found none
66
+ return null; // pgrep itself unavailable/errored — genuinely unknowable
67
+ }
68
+ }
69
+ // The /proc walk is procScan's (every pid looked at; a bound of ONE row,
70
+ // because the first agy answers the question).
71
+ const pids = procPids();
72
+ if (pids === null) return null; // /proc exists but won't read — still unknowable
73
+ const isAgy = (name) => {
74
+ try {
75
+ return readFileSync(`/proc/${name}/comm`, 'utf8').trim() === 'agy' ? true : null;
76
+ } catch {
77
+ return null; // raced exit — keep scanning
78
+ }
79
+ };
80
+ return scanProcs(pids, isAgy, 1).rows.length > 0;
81
+ }
82
+
83
+ /**
84
+ * Is this agy conversation being driven RIGHT NOW? agy cannot answer
85
+ * per-conversation, so this is the conservative composite: an agy process
86
+ * exists AND this conversation's store was written in the last 10 minutes.
87
+ * Adoption is a MOVE for agy (no fork exists — measured, "trajectory not
88
+ * found" on a renamed copy), so refusing a maybe-live conversation for a few
89
+ * minutes costs a retry; adopting an actually-live one puts two drivers on
90
+ * one store.
91
+ */
92
+ const AGY_LIVE_WINDOW_MS = 10 * 60 * 1000;
93
+ export function isAgyConversationLive(id, { processAlive = agyProcessAlive } = {}) {
94
+ try {
95
+ const up = processAlive();
96
+ // Unknowable is NOT "ended". Without /proc the process half of the
97
+ // composite cannot be measured, and calling that "no process" would make
98
+ // every agy conversation on such a machine adoptable — the move-adoption
99
+ // then puts a second driver on a store someone may still be writing.
100
+ // "Live" here means "refuse adoption", and refusing what we cannot verify
101
+ // is the same conservatism as the composite itself.
102
+ if (up === null) return true;
103
+ if (!up) return false;
104
+ const t = agyLastWriteMs(id);
105
+ return t > 0 && Date.now() - t < AGY_LIVE_WINDOW_MS;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /** The repo's agy conversations, via the cwd registry — see the section
112
+ * comment for why this is deliberately a subset. The coordinator hands over
113
+ * the fence (`ours`, `mine`) and the ended-session `cutoff`; `processAlive`
114
+ * measures liveness (true / false / null = unknowable), asked once and only
115
+ * when there is a registry to read. */
116
+ export function scanAgyConversations({ ours, mine, cutoff, processAlive = agyProcessAlive }) {
117
+ const out = [];
118
+ try {
119
+ const raw = readFileSync(join(AGY_DIR(), 'cache', 'last_conversations.json'), 'utf8');
120
+ const map = JSON.parse(raw);
121
+ if (!map || typeof map !== 'object') return out;
122
+ const processUp = processAlive();
123
+ for (const [cwd, id] of Object.entries(map)) {
124
+ if (typeof id !== 'string' || !AGY_UUID_RE.test(id)) continue;
125
+ // The operator's own agy TAB, whose conversation is registered against
126
+ // the checkout exactly as a terminal one would be.
127
+ if (mine.has(id)) continue;
128
+ let real;
129
+ try {
130
+ real = realpathSync(cwd);
131
+ } catch {
132
+ continue; // the directory is gone — nothing to point a tab at
133
+ }
134
+ if (!ours(real)) continue;
135
+ const lastMs = agyLastWriteMs(id);
136
+ if (!lastMs || lastMs < cutoff) continue;
137
+ out.push({
138
+ id,
139
+ cwd: real,
140
+ // Mirrors isAgyConversationLive exactly, unknowable (null) included:
141
+ // the report must never offer as adoptable a conversation the adopt
142
+ // path will then refuse — an offer wired to a refusal is the dead
143
+ // control this product keeps deleting.
144
+ live: processUp === null ? true : processUp && Date.now() - lastMs < AGY_LIVE_WINDOW_MS,
145
+ lastActiveAt: new Date(lastMs).toISOString(),
146
+ runtime: 'antigravity',
147
+ });
148
+ }
149
+ out.sort(
150
+ (a, b) =>
151
+ (b.lastActiveAt < a.lastActiveAt ? -1 : b.lastActiveAt > a.lastActiveAt ? 1 : 0) ||
152
+ (a.id < b.id ? -1 : 1)
153
+ );
154
+ } catch {
155
+ /* no agy on this machine, or an unreadable registry — nothing to report */
156
+ }
157
+ return out;
158
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * THE ARTIFACT POLICY — GENERATED, DO NOT EDIT BY HAND.
3
+ *
4
+ * Rendered from the server's packages/shared/src/schemas/artifact.schema.ts
5
+ * by flowviant's packages/shared/src/schemas/daemonArtifactPolicy.ts
6
+ * (`bun scripts/write-daemon-artifact-policy.ts` in the app repo). The server
7
+ * is the release source; its parity test fails until this file matches it.
8
+ * artifacts.mjs reads the allowlist and the caps from here, and the server
9
+ * still verifies every upload on its own.
10
+ */
11
+
12
+ export const ARTIFACT_POLICY_SOURCE = 'packages/shared/src/schemas/artifact.schema.ts';
13
+
14
+ /** Per text file and ordinary binary artifact. */
15
+ export const ARTIFACT_MAX_BYTES = 2097152;
16
+ /** Binary model buffers (the extensions below). */
17
+ export const ARTIFACT_BINARY_MODEL_MAX_BYTES = 20971520;
18
+ export const ARTIFACT_BINARY_MODEL_EXTS = ['glb', 'bin'];
19
+ /** Per session and per agent — the server keeps as many per owner. */
20
+ export const ARTIFACT_MAX_PER_OWNER = 40;
21
+
22
+ /** Extension → the one mime the server serves it under; `text` is scrubbed. */
23
+ export const ARTIFACT_TYPES = {
24
+ html: { mime: 'text/html', text: true },
25
+ htm: { mime: 'text/html', text: true },
26
+ md: { mime: 'text/markdown', text: true },
27
+ svg: { mime: 'image/svg+xml', text: true },
28
+ png: { mime: 'image/png', text: false },
29
+ jpg: { mime: 'image/jpeg', text: false },
30
+ jpeg: { mime: 'image/jpeg', text: false },
31
+ gif: { mime: 'image/gif', text: false },
32
+ webp: { mime: 'image/webp', text: false },
33
+ json: { mime: 'application/json', text: true },
34
+ csv: { mime: 'text/csv', text: true },
35
+ txt: { mime: 'text/plain', text: true },
36
+ docx: { mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', text: false },
37
+ pptx: { mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', text: false },
38
+ xlsx: { mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', text: false },
39
+ pdf: { mime: 'application/pdf', text: false },
40
+ gltf: { mime: 'model/gltf+json', text: true },
41
+ obj: { mime: 'model/obj', text: true },
42
+ glb: { mime: 'model/gltf-binary', text: false },
43
+ bin: { mime: 'application/octet-stream', text: false },
44
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * THE DESIGN PREVIEW — a screenshot of an HTML artifact, taken in a browser
3
+ * (2026-09-26, split out of artifacts.mjs — SOLID F051).
4
+ *
5
+ * Browser rendering is its own reason to change: a Chrome flag, a CSP tweak or
6
+ * a viewport size has nothing to do with how the artifact directory is scanned
7
+ * or how an upload is retried. The reporter (`artifactReporter.mjs`) is handed
8
+ * this function rather than importing it, so a test can stand in a renderer
9
+ * that never launches a browser.
10
+ *
11
+ * It renders the SCRUBBED bytes, never the checkout's source, under the
12
+ * artifact box's own network fence (`PREVIEW_CSP`) placed before any
13
+ * model-written markup executes. A failed measurement is explicit
14
+ * (`renderState: 'unavailable'`); a daemon older than this simply sends no
15
+ * `renderState`, which the server reads as unmeasured.
16
+ */
17
+
18
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { tmpdir } from 'node:os';
21
+ import { pathToFileURL } from 'node:url';
22
+ import { captureScreenshot } from './shot.mjs';
23
+
24
+ export const DESIGN_PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
25
+ const PREVIEW_CSP = "default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com; style-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com https://fonts.googleapis.com; img-src data: blob:; font-src data: https://fonts.gstatic.com; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
26
+
27
+ /** Render the scrubbed HTML, never the checkout's unsanitized source. A
28
+ * failed browser measurement is explicit; older daemon reports are absent. */
29
+ export async function renderDesignPreview(bytes, capture = captureScreenshot) {
30
+ const dir = mkdtempSync(join(tmpdir(), 'flowviant-design-'));
31
+ try {
32
+ const html = join(dir, 'design.html');
33
+ const png = join(dir, 'design.png');
34
+ // A fresh Chrome profile has no Flowviant credentials. Put the artifact
35
+ // box's network fence before any model-written markup executes.
36
+ writeFileSync(html, Buffer.concat([
37
+ Buffer.from(`<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">`),
38
+ bytes,
39
+ ]));
40
+ const result = await capture({ url: pathToFileURL(html).href, out: png, width: 720, height: 450, timeoutMs: 15_000 });
41
+ if (!result.ok) return { renderState: 'unavailable' };
42
+ const image = readFileSync(png);
43
+ if (image.byteLength > DESIGN_PREVIEW_MAX_BYTES || !image.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) {
44
+ return { renderState: 'unavailable' };
45
+ }
46
+ return { renderState: 'rendered', preview: image };
47
+ } catch {
48
+ return { renderState: 'unavailable' };
49
+ } finally {
50
+ rmSync(dir, { recursive: true, force: true });
51
+ }
52
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * THE ARTIFACT REPORTER — the queued POST to `/fleet/artifact` and its bounded
3
+ * retries (2026-09-26, split out of artifacts.mjs — SOLID F051).
4
+ *
5
+ * Network delivery is its own reason to change, apart from what is on the disk
6
+ * (artifacts.mjs) and how a page is screenshotted (artifactPreview.mjs). The
7
+ * reporter imports neither: it is HANDED the three functions it runs —
8
+ * `listChanged(placeDir, before)` (what the turn wrote), `buildUpload(entry,
9
+ * owner, scrub, secretIn)` (one scrubbed body) and `renderPreview(bytes)` (the
10
+ * screenshot) — so the delivery rules below are tested against a fake server
11
+ * and a fake renderer, and a new source of bodies would not edit this file.
12
+ *
13
+ * ── DELIVERY ──
14
+ *
15
+ * A 2xx is delivered. A 4xx (other than 408/429) is TREATED AS DELIVERED — an
16
+ * older SERVER 404s the whole route, and holding bodies it will refuse forever
17
+ * would be the trace relay's wedge wearing a retry's clothes; a refusal of one
18
+ * file (ended session, wrong owner) is equally permanent. A 5xx, 408, 429 or a
19
+ * network error keeps the STORED BODY — the fields and the bytes as they were
20
+ * scrubbed — and retries it on the next report beat, the settle's shape, at
21
+ * most `MAX_TRIES` times; a newer copy of the same file replaces a held one.
22
+ *
23
+ * A PREVIEW THAT FAILS NEVER STOPS THE ARTIFACT: an HTML body goes out with
24
+ * `renderState: 'unavailable'` and no preview, the renderer's explicit answer.
25
+ */
26
+
27
+ import { fleetEndpoint } from './fleetWire.mjs';
28
+
29
+ /** A held upload is tried this many times in all, then dropped with a line. */
30
+ const MAX_TRIES = 4;
31
+ /** Held bodies across every owner — each can be two megabytes. */
32
+ const MAX_PENDING = 40;
33
+
34
+ /**
35
+ * The reporter a work manager holds for its life. `report` is called once per
36
+ * finished turn and never awaited by the turn (a slow uplink must not hold the
37
+ * next turn behind a readout); `retryPending` rides the settle retry beat.
38
+ */
39
+ export function createArtifactReporter({
40
+ fleetUrl,
41
+ token,
42
+ userAgent,
43
+ scrub,
44
+ secretIn,
45
+ fetchImpl,
46
+ log = () => {},
47
+ listChanged,
48
+ buildUpload,
49
+ renderPreview,
50
+ }) {
51
+ const url = fleetEndpoint('artifact', fleetUrl);
52
+ const doFetch = fetchImpl ?? ((...a) => fetch(...a));
53
+ const pending = new Map(); // `${owner}:${name}` -> { body, tries }
54
+
55
+ /** true = settled (delivered or permanently refused); false = hold it. */
56
+ const post = async (body) => {
57
+ const form = new FormData();
58
+ for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
59
+ if (body.bytes) form.set('file', new Blob([body.bytes]), body.fields.name);
60
+ if (body.preview) form.set('preview', new Blob([body.preview], { type: 'image/png' }), 'preview.png');
61
+ try {
62
+ const res = await doFetch(url, {
63
+ method: 'POST',
64
+ headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
65
+ signal: AbortSignal.timeout(30_000),
66
+ body: form,
67
+ });
68
+ return (
69
+ res.ok || (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
70
+ );
71
+ } catch {
72
+ return false;
73
+ }
74
+ };
75
+
76
+ const hold = (key, body, tries) => {
77
+ if (tries >= MAX_TRIES) {
78
+ pending.delete(key);
79
+ log(`artifact ${body.fields.name}: not delivered after ${tries} tries — dropped`);
80
+ return;
81
+ }
82
+ pending.delete(key); // re-insert at the tail, so the cap sheds the oldest
83
+ pending.set(key, { body, tries });
84
+ while (pending.size > MAX_PENDING) {
85
+ const shed = pending.keys().next().value;
86
+ pending.delete(shed);
87
+ if (!chains.has(shed)) genOf.delete(shed);
88
+ }
89
+ };
90
+
91
+ // ONE COPY OF A FILE IS IN FLIGHT AT A TIME, AND ONLY THE NEWEST MAY
92
+ // DECIDE. Each report of a (owner, name) takes a fresh generation, and every
93
+ // post for that key is chained behind the previous one — so a slow retry of
94
+ // an OLD body can neither land on the server after the newer copy (the
95
+ // server upserts by name, so the last write wins) nor re-hold itself over
96
+ // the newer one when it fails. A body superseded before it went out is not
97
+ // sent at all; one superseded while in flight has its answer discarded.
98
+ let generation = 0;
99
+ const genOf = new Map(); // key -> the generation allowed to decide
100
+ const chains = new Map(); // key -> the tail of that key's post chain
101
+
102
+ const send = (key, body, tries, gen) => {
103
+ const run = (chains.get(key) ?? Promise.resolve()).then(async () => {
104
+ if (genOf.get(key) !== gen) return; // superseded before it went out
105
+ const ok = await post(body);
106
+ if (genOf.get(key) !== gen) return; // a newer copy decides for this key
107
+ if (ok) pending.delete(key);
108
+ else hold(key, body, tries + 1);
109
+ });
110
+ const tail = run.catch(() => {});
111
+ chains.set(key, tail);
112
+ tail.then(() => {
113
+ if (chains.get(key) !== tail) return;
114
+ chains.delete(key);
115
+ if (!pending.has(key)) genOf.delete(key); // settled: nothing left to order
116
+ });
117
+ return run;
118
+ };
119
+
120
+ let retrying = false;
121
+ const retryPending = async () => {
122
+ if (retrying || pending.size === 0) return;
123
+ retrying = true;
124
+ try {
125
+ for (const [key, held] of [...pending]) {
126
+ if (pending.get(key) !== held) continue; // superseded meanwhile
127
+ await send(key, held.body, held.tries, genOf.get(key));
128
+ }
129
+ } finally {
130
+ retrying = false;
131
+ }
132
+ };
133
+
134
+ /** Upload what this turn changed in `placeDir`. Resolves to the number of
135
+ * bodies SENT this call (delivered or held), for the tests and the log. */
136
+ const report = async ({ placeDir, before, sessionId, agentId, turnId }) => {
137
+ if (!placeDir || (!sessionId && !agentId)) return 0;
138
+ const changed = listChanged(placeDir, before);
139
+ let n = 0;
140
+ for (const entry of changed) {
141
+ const body = buildUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
142
+ if (!body) continue;
143
+ if (body.withheld) {
144
+ log(`artifact ${entry.name}: not uploaded — it contains the value of ${body.withheld} from this machine's environment`);
145
+ continue;
146
+ }
147
+ if (body.bytes && /\.html?$/i.test(entry.name)) {
148
+ const measured = await renderPreview(body.bytes);
149
+ body.fields.renderState = measured.renderState;
150
+ if (measured.preview) body.preview = measured.preview;
151
+ }
152
+ const key = `${sessionId ?? agentId}:${entry.name}`;
153
+ pending.delete(key); // this copy supersedes a held older one
154
+ const gen = ++generation;
155
+ genOf.set(key, gen); // …and one still in flight
156
+ await send(key, body, 0, gen);
157
+ n++;
158
+ }
159
+ return n;
160
+ };
161
+
162
+ return { report, retryPending, pendingCount: () => pending.size };
163
+ }