flowviant 0.115.0 → 0.117.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 (135) hide show
  1. package/README.md +31 -12
  2. package/bin/cli.mjs +13 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentInternalWait.mjs +83 -0
  5. package/bin/lib/agentPlan.mjs +56 -24
  6. package/bin/lib/agentPublish.mjs +12 -13
  7. package/bin/lib/agentTaskKinds.mjs +4 -7
  8. package/bin/lib/agySessions.mjs +3 -3
  9. package/bin/lib/artifactPreview.mjs +92 -18
  10. package/bin/lib/artifactReporter.mjs +16 -8
  11. package/bin/lib/artifacts.mjs +64 -60
  12. package/bin/lib/authproxy.mjs +2 -1
  13. package/bin/lib/childEnv.mjs +9 -4
  14. package/bin/lib/claude.mjs +10 -20
  15. package/bin/lib/claudePersonal.mjs +71 -6
  16. package/bin/lib/claudePosture.mjs +119 -42
  17. package/bin/lib/claudeSessions.mjs +7 -7
  18. package/bin/lib/claudeStream.mjs +7 -11
  19. package/bin/lib/cliLimit.mjs +2 -2
  20. package/bin/lib/codexHome.mjs +17 -0
  21. package/bin/lib/codexPersonal.mjs +44 -3
  22. package/bin/lib/codexRelay.mjs +21 -5
  23. package/bin/lib/codexResearch.mjs +180 -0
  24. package/bin/lib/config.mjs +37 -24
  25. package/bin/lib/conversationIds.mjs +51 -0
  26. package/bin/lib/credentials.mjs +2 -7
  27. package/bin/lib/daemonLocks.mjs +88 -0
  28. package/bin/lib/daemonState.mjs +5 -5
  29. package/bin/lib/deploy.mjs +6 -9
  30. package/bin/lib/deployCheckout.mjs +26 -22
  31. package/bin/lib/deployRunner.mjs +54 -18
  32. package/bin/lib/deployWire.mjs +32 -23
  33. package/bin/lib/desktopContract.mjs +3 -5
  34. package/bin/lib/env.mjs +104 -35
  35. package/bin/lib/fleet.mjs +7 -9
  36. package/bin/lib/fleetPost.mjs +172 -25
  37. package/bin/lib/fleetReports.mjs +130 -170
  38. package/bin/lib/fleetStartup.mjs +6 -6
  39. package/bin/lib/fleetWire.mjs +42 -0
  40. package/bin/lib/git.mjs +111 -61
  41. package/bin/lib/gitMergeIdentity.mjs +2 -8
  42. package/bin/lib/grant.mjs +23 -7
  43. package/bin/lib/holder.mjs +108 -81
  44. package/bin/lib/hooks/readGuard.mjs +41 -7
  45. package/bin/lib/install.mjs +80 -33
  46. package/bin/lib/instance.mjs +36 -83
  47. package/bin/lib/intakeDraft.mjs +4 -1
  48. package/bin/lib/knowledgeFileSync.mjs +31 -0
  49. package/bin/lib/knowledgeFiles.mjs +28 -83
  50. package/bin/lib/landed.mjs +22 -29
  51. package/bin/lib/launchCommand.mjs +91 -0
  52. package/bin/lib/listeners.mjs +0 -5
  53. package/bin/lib/localSessions.mjs +52 -39
  54. package/bin/lib/login.mjs +22 -12
  55. package/bin/lib/loginPath.mjs +22 -17
  56. package/bin/lib/machineListing.mjs +6 -3
  57. package/bin/lib/mcp-cli.mjs +9 -10
  58. package/bin/lib/noWindowEnv.mjs +23 -3
  59. package/bin/lib/ownedDirs.mjs +99 -0
  60. package/bin/lib/pinnedCapture.mjs +383 -0
  61. package/bin/lib/prWorkflow.mjs +94 -17
  62. package/bin/lib/preflight.mjs +8 -25
  63. package/bin/lib/preview.mjs +19 -25
  64. package/bin/lib/previewGatePolicy.mjs +34 -9
  65. package/bin/lib/previewRegistry.mjs +4 -11
  66. package/bin/lib/printable.mjs +70 -7
  67. package/bin/lib/procRegistry.mjs +33 -13
  68. package/bin/lib/procScan.mjs +2 -3
  69. package/bin/lib/procStat.mjs +76 -0
  70. package/bin/lib/processGroup.mjs +48 -0
  71. package/bin/lib/processes.mjs +10 -24
  72. package/bin/lib/projectToolRuntimes.mjs +101 -16
  73. package/bin/lib/projectTools.mjs +50 -32
  74. package/bin/lib/prompts.mjs +41 -19
  75. package/bin/lib/repoPath.mjs +19 -0
  76. package/bin/lib/repoState.mjs +17 -19
  77. package/bin/lib/runTurn.mjs +268 -210
  78. package/bin/lib/runtimeAntigravity.mjs +16 -5
  79. package/bin/lib/runtimeCapabilities.mjs +140 -20
  80. package/bin/lib/runtimeClaude.mjs +55 -5
  81. package/bin/lib/runtimeCodex.mjs +245 -205
  82. package/bin/lib/runtimeDetection.mjs +2 -2
  83. package/bin/lib/runtimeEvents.mjs +16 -10
  84. package/bin/lib/runtimeLimits.mjs +5 -6
  85. package/bin/lib/runtimeOfferings.mjs +24 -11
  86. package/bin/lib/runtimes.mjs +10 -0
  87. package/bin/lib/shipSweep.mjs +5 -3
  88. package/bin/lib/shot.mjs +91 -17
  89. package/bin/lib/sleep.mjs +11 -0
  90. package/bin/lib/startProject.mjs +5 -5
  91. package/bin/lib/statePaths.mjs +40 -0
  92. package/bin/lib/tty.mjs +25 -15
  93. package/bin/lib/turnLock.mjs +7 -14
  94. package/bin/lib/turnProfile.mjs +1 -1
  95. package/bin/lib/ui.mjs +0 -3
  96. package/bin/lib/uninstall.mjs +30 -26
  97. package/bin/lib/update.mjs +79 -27
  98. package/bin/lib/uplinkScrub.mjs +296 -31
  99. package/bin/lib/vault.mjs +78 -10
  100. package/bin/lib/vaultDiff.mjs +5 -2
  101. package/bin/lib/views.mjs +37 -12
  102. package/bin/lib/wikiRunner.mjs +40 -61
  103. package/bin/lib/work.mjs +17 -7
  104. package/bin/lib/workAdoptCarry.mjs +2 -4
  105. package/bin/lib/workAgentCheck.mjs +104 -57
  106. package/bin/lib/workAgentMerges.mjs +62 -57
  107. package/bin/lib/workAgentPlans.mjs +99 -145
  108. package/bin/lib/workAgentPrecheck.mjs +71 -138
  109. package/bin/lib/workAgentPublish.mjs +6 -8
  110. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  111. package/bin/lib/workAgentTurnExecution.mjs +46 -53
  112. package/bin/lib/workAgentTurnReports.mjs +9 -37
  113. package/bin/lib/workAgentTurns.mjs +4 -3
  114. package/bin/lib/workAttachments.mjs +66 -11
  115. package/bin/lib/workBrain.mjs +9 -4
  116. package/bin/lib/workCappedTurn.mjs +109 -0
  117. package/bin/lib/workChildren.mjs +5 -5
  118. package/bin/lib/workCommandAudit.mjs +11 -2
  119. package/bin/lib/workDiffs.mjs +52 -6
  120. package/bin/lib/workIntake.mjs +35 -60
  121. package/bin/lib/workPlaceReports.mjs +7 -4
  122. package/bin/lib/workPlaces.mjs +96 -30
  123. package/bin/lib/workPreviews.mjs +47 -112
  124. package/bin/lib/workProcesses.mjs +8 -35
  125. package/bin/lib/workPullRequests.mjs +37 -56
  126. package/bin/lib/workReportQueue.mjs +100 -148
  127. package/bin/lib/workRetire.mjs +2 -1
  128. package/bin/lib/workSessionRuntime.mjs +38 -37
  129. package/bin/lib/workSessionTurns.mjs +111 -189
  130. package/bin/lib/workShip.mjs +43 -23
  131. package/bin/lib/workTurnShape.mjs +163 -0
  132. package/npm-shrinkwrap.json +59 -0
  133. package/package.json +3 -2
  134. package/bin/lib/fleetJobs.mjs +0 -120
  135. package/bin/lib/patchRevert.mjs +0 -60
@@ -6,16 +6,19 @@
6
6
  * Split out of work.mjs (2026-09-26, SOLID F037). The queue is the skip-guard
7
7
  * that stops a side-effecting turn being re-run over a dropped 200, so its
8
8
  * delivery rules (ok / terminal / reject / retry) have exactly one home; the
9
- * session lane settles through `settleWorkTurn`, the ship lane through
10
- * `settleShip`, and the plan and check lanes through `postBestEffort`. The
11
- * attempts counter is the session lane's, handed in by name, because a
12
- * terminal delivery is what may forget it.
9
+ * session lane settles through `settleWorkTurn` and the ship lane through
10
+ * `settleShip`; the plan, check and intake lanes, which have no queue, settle
11
+ * through fleetPost.mjs's `postBestEffort` (it closed over nothing of this
12
+ * queue's and moved there, audit 2026-10-10 F136). The attempts counter is
13
+ * the session lane's, handed in by name, because a terminal delivery is what
14
+ * may forget it.
13
15
  */
14
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
16
+ import { FLEET_URL } from './config.mjs';
15
17
  import { git } from './git.mjs';
16
18
  import { sweepMergedBranch } from './shipSweep.mjs';
17
19
  import { note } from './ui.mjs';
18
- import { fleetEndpoint } from './fleetWire.mjs';
20
+ import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
21
+ import { fleetRequest } from './fleetPost.mjs';
19
22
 
20
23
  export function createWorkReportQueue({
21
24
  repoRoot,
@@ -58,137 +61,102 @@ export function createWorkReportQueue({
58
61
  * hammered a refused body forever.
59
62
  * - 'retry' — network errors, 408, 429 and 5xx: nothing was decided. */
60
63
  const postSettle = async (url, body, terminalStatuses) => {
61
- try {
62
- const res = await fetch(url, {
63
- method: 'POST',
64
- headers: {
65
- Authorization: `Bearer ${FLEET_TOKEN}`,
66
- 'User-Agent': USER_AGENT,
67
- 'Content-Type': 'application/json',
68
- },
69
- signal: AbortSignal.timeout(30_000),
70
- body: JSON.stringify(body),
71
- });
72
- if (res.ok) return 'ok';
73
- if (terminalStatuses.includes(res.status)) return 'terminal';
74
- if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
75
- return 'reject';
76
- return 'retry';
77
- } catch {
78
- return 'retry';
79
- }
64
+ const r = await fleetRequest(url, { body, timeoutMs: 30_000 });
65
+ if (r.error) return 'retry';
66
+ if (r.ok) return 'ok';
67
+ if (terminalStatuses.includes(r.status)) return 'terminal';
68
+ if (isConsideredRefusal(r.status)) return 'reject';
69
+ return 'retry';
80
70
  };
81
71
 
82
- /** Retry-After, in ms — seconds or an HTTP-date — capped so one bad or
83
- * hostile header cannot stall a caller for an hour. Falls back to the
84
- * caller's own backoff when the header is absent or unparseable. */
85
- const retryAfterMs = (res, fallbackMs) => {
86
- const h = res?.headers?.get?.('retry-after');
87
- if (h == null) return fallbackMs;
88
- const secs = Number(h);
89
- if (Number.isFinite(secs)) return Math.max(0, Math.min(secs * 1000, 60_000));
90
- const at = Date.parse(h);
91
- return Number.isFinite(at) ? Math.max(0, Math.min(at - Date.now(), 60_000)) : fallbackMs;
92
- };
72
+ /** How long a REJECTED report sits out before re-offering its body — long
73
+ * enough that a deploy-skew 400 costs a handful of POSTs a day, short
74
+ * enough that a server fix picks the report up the same morning. */
75
+ const REJECT_RETRY_MS = 10 * 60 * 1000;
76
+ // Keyed `${queue}:${id}` — a turn id and a session id are different
77
+ // namespaces, and one queue's backoff must never hold the other's report.
78
+ const reportBackoff = new Map(); // `work:<turnId>` | `ship:<sessionId>` -> earliest next attempt
93
79
 
94
80
  /**
95
- * A best-effort settle POST, AWAITED INLINE by a caller with no per-poll
96
- * queue of its own to retry from — unlike `postSettle` above, whose callers
97
- * requeue a 'retry'/'reject' outcome themselves. Bounded attempts: 408/429
98
- * retry honouring Retry-After, 5xx retries on a short fixed backoff, and
99
- * any OTHER 4xx is the server's considered answer — retrying it would just
100
- * spend the same refusal again, so it counts as delivered, the existing
101
- * trace convention. A network error retries the same way and is then
102
- * swallowed: unsettled, and the server expires the job so the asker is
103
- * told, never spun.
81
+ * WHAT A DELIVERED SHIP REPORT SETS OFF — on BOTH paths, the immediate settle
82
+ * and the per-poll flush (audit 2026-10-10, F158). The flush used to run only
83
+ * the sweep, so a ship whose first ship-done POST hit a 502 left the rail
84
+ * rendering the whole pre-ship diff, the Repository block counting a deleted
85
+ * branch and card closes waiting on the 3-minute fetch. These follow the
86
+ * MERGE, not the report's punctuality: an action that changes what the
87
+ * machine would measure must cause a new measurement.
104
88
  */
105
- const postBestEffort = async (url, body, { attempts = 4, timeoutMs = 30_000 } = {}) => {
106
- for (let i = 0; i < attempts; i += 1) {
107
- let res;
108
- try {
109
- res = await fetch(url, {
110
- method: 'POST',
111
- headers: {
112
- Authorization: `Bearer ${FLEET_TOKEN}`,
113
- 'User-Agent': USER_AGENT,
114
- 'Content-Type': 'application/json',
115
- },
116
- signal: AbortSignal.timeout(timeoutMs),
117
- body: JSON.stringify(body),
118
- });
119
- } catch {
120
- if (i === attempts - 1) return false;
121
- await new Promise((r) => setTimeout(r, 2_000 * (i + 1)));
122
- continue;
123
- }
124
- if (res.ok) return true;
125
- if (res.status === 408 || res.status === 429) {
126
- if (i === attempts - 1) return false;
127
- await new Promise((r) => setTimeout(r, retryAfterMs(res, 2_000 * (i + 1))));
128
- continue;
129
- }
130
- if (res.status >= 500) {
131
- if (i === attempts - 1) return false;
132
- await new Promise((r) => setTimeout(r, 2_000 * (i + 1)));
133
- continue;
134
- }
135
- return true; // any other 4xx — the server's considered answer
136
- }
137
- return false;
89
+ const shipDelivered = (sessionId) => {
90
+ // The report has landed, so the idempotency path no longer needs the
91
+ // branch to exist. See `sweepMergedSessionBranch`.
92
+ sweepMergedSessionBranch(sessionId);
93
+ // AND THE DIFFSTAT IS NOW WRONG BY DEFINITION. A ship folds base in,
94
+ // merges the tip out and deletes the branch, so a fresh measurement reads
95
+ // `ahead: 0` with an empty diffstat — and without this the rail keeps
96
+ // rendering the ENTIRE pre-ship diff while the transcript two hundred
97
+ // pixels away says "Shipped to main". The ship button re-arms over a
98
+ // branch that is already merged. Same rule the kill path just learned:
99
+ // an action that changes what the machine would measure must cause a new
100
+ // measurement, and the 60s sweep is not that.
101
+ void reportPlaceWorktrees(sessionId).catch(() => {});
102
+ burstListeners(sessionId);
103
+ // …and the REPO picture changed too: the session branch is gone and base
104
+ // moved. Without this the Repository block keeps counting a branch the
105
+ // ship just deleted.
106
+ onRepoChanged();
107
+ // The ship's push moved the local origin/<base> ref — observe now, so
108
+ // the landed report (and anything trailered a ship carried) lands on
109
+ // this beat rather than the next 3-minute fetch.
110
+ void landed.observe().catch(() => {});
138
111
  };
139
112
 
140
- /** How long a REJECTED report sits out before re-offering its body — long
141
- * enough that a deploy-skew 400 costs a handful of POSTs a day, short
142
- * enough that a server fix picks the report up the same morning. */
143
- const REJECT_RETRY_MS = 10 * 60 * 1000;
144
- const reportBackoff = new Map(); // turnId|sessionId -> earliest next attempt
145
- const settleWorkTurn = async (turnId, payload) => {
146
- const body = { turnId, ...payload };
147
- const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
148
- if (r === 'retry' || r === 'reject') {
149
- pendingWorkReports.set(turnId, body);
150
- if (r === 'reject') reportBackoff.set(turnId, Date.now() + REJECT_RETRY_MS);
151
- } else {
152
- pendingWorkReports.delete(turnId);
153
- workAttempts.delete(turnId);
154
- reportBackoff.delete(turnId);
155
- }
156
- return r;
113
+ /** EACH QUEUE, DESCRIBED ONCE — its endpoint, the explicit statuses under
114
+ * which the server will never re-offer the job, the id the body carries,
115
+ * and what a delivery (on time or late) forgets or sets off. The settle
116
+ * and the flush both deliver through `deliver`, so the two paths cannot
117
+ * drift apart again. */
118
+ const QUEUES = {
119
+ work: {
120
+ name: 'work',
121
+ url: WORK_DONE_URL,
122
+ terminal: [403, 404],
123
+ pending: pendingWorkReports,
124
+ idKey: 'turnId',
125
+ delivered: (id) => workAttempts.delete(id),
126
+ },
127
+ ship: {
128
+ name: 'ship',
129
+ url: SHIP_DONE_URL,
130
+ terminal: [403, 409],
131
+ pending: pendingShipReports,
132
+ idKey: 'sessionId',
133
+ delivered: shipDelivered,
134
+ },
157
135
  };
158
- const settleShip = async (sessionId, payload) => {
159
- const body = { sessionId, ...payload };
160
- const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
136
+ const backoffKey = (q, id) => `${q.name}:${id}`;
137
+
138
+ /** POST one queue's body and apply the outcome: 'retry' and 'reject' keep
139
+ * it queued (the skip-guard) and only 'reject' backs off; 'ok' and
140
+ * 'terminal' forget it — and its backoff — and run the queue's delivered
141
+ * effects. */
142
+ const deliver = async (q, id, body) => {
143
+ const r = await postSettle(q.url, body, q.terminal);
161
144
  if (r === 'retry' || r === 'reject') {
162
- pendingShipReports.set(sessionId, body);
163
- if (r === 'reject') reportBackoff.set(sessionId, Date.now() + REJECT_RETRY_MS);
145
+ q.pending.set(id, body);
146
+ if (r === 'reject') reportBackoff.set(backoffKey(q, id), Date.now() + REJECT_RETRY_MS);
164
147
  } else {
165
- pendingShipReports.delete(sessionId);
166
- reportBackoff.delete(sessionId);
167
- // The report has landed, so the idempotency path no longer needs the
168
- // branch to exist. See `sweepMergedSessionBranch`.
169
- sweepMergedSessionBranch(sessionId);
170
- // AND THE DIFFSTAT IS NOW WRONG BY DEFINITION. A ship folds base in,
171
- // merges the tip out and deletes the branch, so a fresh measurement reads
172
- // `ahead: 0` with an empty diffstat — and without this the rail keeps
173
- // rendering the ENTIRE pre-ship diff while the transcript two hundred
174
- // pixels away says "Shipped to main". The ship button re-arms over a
175
- // branch that is already merged. Same rule the kill path just learned:
176
- // an action that changes what the machine would measure must cause a new
177
- // measurement, and the 60s sweep is not that.
178
- void reportPlaceWorktrees(sessionId).catch(() => {});
179
- burstListeners(sessionId);
180
- // …and the REPO picture changed too: the session branch is gone and base
181
- // moved. Without this the Repository block keeps counting a branch the
182
- // ship just deleted.
183
- onRepoChanged();
184
- // The ship's push moved the local origin/<base> ref — observe now, so
185
- // the landed report (and anything trailered a ship carried) lands on
186
- // this beat rather than the next 3-minute fetch.
187
- void landed.observe().catch(() => {});
148
+ q.pending.delete(id);
149
+ reportBackoff.delete(backoffKey(q, id));
150
+ q.delivered(id);
188
151
  }
189
152
  return r;
190
153
  };
191
154
 
155
+ const settleWorkTurn = (turnId, payload) =>
156
+ deliver(QUEUES.work, turnId, { [QUEUES.work.idKey]: turnId, ...payload });
157
+ const settleShip = (sessionId, payload) =>
158
+ deliver(QUEUES.ship, sessionId, { [QUEUES.ship.idKey]: sessionId, ...payload });
159
+
192
160
  /** See `shipSweep.mjs`. Bound to this manager's repo, base and report queue. */
193
161
  const sweepMergedSessionBranch = (sessionId) =>
194
162
  sweepMergedBranch(sessionId, {
@@ -210,30 +178,15 @@ export function createWorkReportQueue({
210
178
  if (pendingWorkReports.size === 0 && pendingShipReports.size === 0) return;
211
179
  flushingReports = true;
212
180
  try {
213
- for (const [id, body] of [...pendingWorkReports]) {
214
- // A rejected body sits out its backoff; the queued entry itself stays
215
- // — it is the skip-guard against re-running a turn whose side effects
216
- // already happened.
217
- if ((reportBackoff.get(id) ?? 0) > Date.now()) continue;
218
- const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
219
- if (r === 'reject') reportBackoff.set(id, Date.now() + REJECT_RETRY_MS);
220
- else if (r !== 'retry') {
221
- pendingWorkReports.delete(id);
222
- workAttempts.delete(id);
223
- reportBackoff.delete(id);
224
- }
225
- }
226
- for (const [id, body] of [...pendingShipReports]) {
227
- if ((reportBackoff.get(id) ?? 0) > Date.now()) continue;
228
- const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
229
- if (r === 'reject') reportBackoff.set(id, Date.now() + REJECT_RETRY_MS);
230
- else if (r !== 'retry') {
231
- pendingShipReports.delete(id);
232
- reportBackoff.delete(id);
233
- // Delivered late is still delivered — same sweep as the immediate
234
- // path, and it must be here too or a report that needed a retry
235
- // would leave its branch behind forever.
236
- sweepMergedSessionBranch(id);
181
+ for (const q of [QUEUES.work, QUEUES.ship]) {
182
+ for (const [id, body] of [...q.pending]) {
183
+ // A rejected body sits out its backoff; the queued entry itself
184
+ // stays — it is the skip-guard against re-running a turn whose side
185
+ // effects already happened. Delivered late is still delivered: the
186
+ // same effects as the immediate path (a ship's sweep, re-measure,
187
+ // repo refresh and landed observation).
188
+ if ((reportBackoff.get(backoffKey(q, id)) ?? 0) > Date.now()) continue;
189
+ await deliver(q, id, body);
237
190
  }
238
191
  }
239
192
  } finally {
@@ -244,7 +197,6 @@ export function createWorkReportQueue({
244
197
  return {
245
198
  pendingWorkReports,
246
199
  pendingShipReports,
247
- postBestEffort,
248
200
  REJECT_RETRY_MS,
249
201
  settleWorkTurn,
250
202
  settleShip,
@@ -11,6 +11,7 @@
11
11
  import { existsSync, readdirSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { git, isSafePathSegment } from './git.mjs';
14
+ import { PERSON_PLACE_PREFIX } from './workPlaces.mjs';
14
15
 
15
16
  export function createWorkRetire({
16
17
  repoRoot,
@@ -169,7 +170,7 @@ export function createWorkRetire({
169
170
  // Only a PERSON's manual place. An agent's own worktree (`a-<id>`) is
170
171
  // deliberately not touched: its branch is the reviewable unit, and
171
172
  // moving it under a review would change what somebody is deciding about.
172
- if (!place.startsWith('u-') || !isSafePathSegment(place)) continue;
173
+ if (!place.startsWith(PERSON_PLACE_PREFIX) || !isSafePathSegment(place)) continue;
173
174
  if (placeLocks.has(place)) continue;
174
175
  const wt = join(baseDir, 'sessions', place);
175
176
  try {
@@ -1,32 +1,24 @@
1
1
  /**
2
- * WHICH CLI A TAB SPEAKS, AND WHICH CONVERSATION IT RESUMES — the runtime pin
3
- * and the shapes a resume id must have before it rides argv.
2
+ * WHICH CLI A TAB SPEAKS — the runtime pin, and agy's cwd registry the first
3
+ * turn learns its conversation from.
4
4
  *
5
- * Split out of work.mjs (2026-09-26, SOLID F037). The pin and the id guards
6
- * change together (a new CLI brings both), and none of it is about running a
7
- * turn: the session lane asks `sessionRuntime` before it spawns. The two id
8
- * guards are pure and module-level: both turn lanes import them, so the
9
- * manager never carries a constant between lanes.
5
+ * Split out of work.mjs (2026-09-26, SOLID F037). None of it is about running
6
+ * a turn: the session lane asks `sessionRuntime` before it spawns.
7
+ *
8
+ * THE ID SHAPES MOVED to a leaf (audit 2026-10-10, F157): conversationIds.mjs
9
+ * owns every CLI's conversation-id guard and marker name, because the
10
+ * machine readers below the lanes (runtimeLimits, the session scanners) need
11
+ * them too and a lane module is not theirs to import. The two guards the
12
+ * agent lane imports from here are re-exported unchanged.
10
13
  */
11
14
  import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
12
15
  import { join } from 'node:path';
13
16
  import { homedir } from 'node:os';
14
- import { canRun, RUNTIMES } from './runtimes.mjs';
17
+ import { canRun, RUNTIMES, runtimeById, runtimeLabel } from './runtimes.mjs';
15
18
  import { detectRuntimes } from './runtimeDetection.mjs';
19
+ import { AGY_CONV_RE, CODEX_THREAD_RE } from './conversationIds.mjs';
16
20
 
17
- /**
18
- * The shape a codex thread id must have before it is written to disk or —
19
- * decisive — pushed into argv as `resume <id>`. Conservative on purpose:
20
- * alphanumeric plus dash/underscore, never a leading dash (an argv that
21
- * parses as a flag), never whitespace. Anything else is dropped and the
22
- * session simply runs fresh in its own worktree.
23
- */
24
- export const CODEX_THREAD_RE = /^[0-9a-zA-Z][0-9a-zA-Z_-]{7,63}$/;
25
-
26
- /** agy conversation ids are plain UUIDs (the db filename IS the identity —
27
- * measured: a renamed copy fails "trajectory not found"). Guarded the same
28
- * way as the codex id: it rides in argv as `--conversation <id>`. */
29
- export const AGY_CONV_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
+ export { AGY_CONV_RE, CODEX_THREAD_RE };
30
22
 
31
23
  export function createWorkSessionRuntime({ sessionMetaPath }) {
32
24
  /**
@@ -52,15 +44,19 @@ export function createWorkSessionRuntime({ sessionMetaPath }) {
52
44
  * { mismatch: { pin, runtime } } (labels, for the caller's sentence).
53
45
  *
54
46
  * SESSION-CAPABLE means rt.mcp is truthy — the session tools ride a real
55
- * per-invocation MCP config — OR the runtime runs tabs PLAIN (Antigravity):
56
- * no MCP at all, no cards, no streaming; the final answer is delivered by
57
- * the daemon's own report and ship-time reconciliation keeps the ledger
58
- * whole. `pickRuntimeFor('build')` is still the WRONG question here — it
59
- * says yes to the mediated DISPATCH path without saying how a tab would
60
- * speak, and a session pinned by it once threw in mcpFor on every turn.
47
+ * per-invocation MCP config — OR the runtime's row runs tabs PLAIN
48
+ * (`tab: 'plain'`, Antigravity's): no MCP at all, no cards, no streaming;
49
+ * the final answer is delivered by the daemon's own report and ship-time
50
+ * reconciliation keeps the ledger whole. The row says so, and the turn's
51
+ * shape reads the same fact (workTurnShape.mjs). `pickRuntimeFor('build')`
52
+ * is still the WRONG question here — it says yes to the mediated DISPATCH
53
+ * path without saying how a tab would speak, and a session pinned by it
54
+ * once threw in mcpFor on every turn.
61
55
  */
62
- const sessionCapable = (rid) =>
63
- (Boolean(RUNTIMES[rid]?.mcp) || rid === 'antigravity') && canRun(RUNTIMES[rid], 'build');
56
+ const sessionCapable = (rid) => {
57
+ const rt = runtimeById(rid);
58
+ return (Boolean(rt?.mcp) || rt?.tab === 'plain') && canRun(rt, 'build');
59
+ };
64
60
  const sessionRuntime = (wt, jobRuntime, sessionId) => {
65
61
  // SCOPED: two tabs standing in one directory may run different CLIs, and an
66
62
  // unscoped pin would hand the second one the first one's runtime.
@@ -73,12 +69,17 @@ export function createWorkSessionRuntime({ sessionMetaPath }) {
73
69
  /* unreadable marker — re-pin below */
74
70
  }
75
71
  }
76
- if (pinned && RUNTIMES[pinned]) {
72
+ // A marker naming no runtime this daemon declares — torn, hand-edited, or
73
+ // a prototype key like `constructor` — is no pin at all: it is re-picked
74
+ // exactly like a missing one (audit 2026-10-10, F125). It used to enter
75
+ // this branch on truthiness and settle every turn as "unsupported
76
+ // constructor".
77
+ if (pinned && runtimeById(pinned)) {
77
78
  if (jobRuntime && jobRuntime !== pinned) {
78
79
  return {
79
80
  mismatch: {
80
- pin: RUNTIMES[pinned].label || pinned,
81
- runtime: RUNTIMES[jobRuntime]?.label || jobRuntime,
81
+ pin: runtimeLabel(pinned),
82
+ runtime: runtimeLabel(jobRuntime),
82
83
  },
83
84
  };
84
85
  }
@@ -86,18 +87,18 @@ export function createWorkSessionRuntime({ sessionMetaPath }) {
86
87
  // the caller, not silently re-picked: re-picking would hand the held
87
88
  // context to a different brain, which is the exact substitution the pin
88
89
  // exists to prevent.
89
- if (!sessionCapable(pinned)) return { unsupported: RUNTIMES[pinned].label || pinned };
90
+ if (!sessionCapable(pinned)) return { unsupported: runtimeLabel(pinned) };
90
91
  const installed = detectRuntimes().find((r) => r.id === pinned)?.installed;
91
- return installed ? { id: pinned } : { missing: RUNTIMES[pinned].label || pinned };
92
+ return installed ? { id: pinned } : { missing: runtimeLabel(pinned) };
92
93
  }
93
94
  // First turn, and the server named the brain: that IS the pick, gated the
94
95
  // same two ways as a pin — not session-capable and not installed both
95
96
  // settle honestly via the caller's existing paths, never substituted.
96
97
  if (jobRuntime) {
97
98
  if (!sessionCapable(jobRuntime))
98
- return { unsupported: RUNTIMES[jobRuntime]?.label || jobRuntime };
99
+ return { unsupported: runtimeLabel(jobRuntime) };
99
100
  const installed = detectRuntimes().find((r) => r.id === jobRuntime)?.installed;
100
- if (!installed) return { missing: RUNTIMES[jobRuntime]?.label || jobRuntime };
101
+ if (!installed) return { missing: runtimeLabel(jobRuntime) };
101
102
  if (marker) {
102
103
  try {
103
104
  writeFileSync(marker, jobRuntime);
@@ -114,7 +115,7 @@ export function createWorkSessionRuntime({ sessionMetaPath }) {
114
115
  // when the server names it, never handed out as a default.
115
116
  const rows = detectRuntimes();
116
117
  const okFor = (rid) =>
117
- Boolean(RUNTIMES[rid]?.mcp) &&
118
+ Boolean(runtimeById(rid)?.mcp) &&
118
119
  sessionCapable(rid) &&
119
120
  Boolean(rows.find((r) => r.id === rid)?.installed);
120
121
  const id = okFor('claude') ? 'claude' : (Object.keys(RUNTIMES).find(okFor) ?? null);