flowviant 0.116.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 +71 -139
  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
@@ -8,7 +8,7 @@
8
8
  * blocking the turn.
9
9
  */
10
10
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
11
- import { scrub as envScrub } from './uplinkScrub.mjs';
11
+ import { scrubCut } from './uplinkScrub.mjs';
12
12
  import { fleetEndpoint } from './fleetWire.mjs';
13
13
 
14
14
  // THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
@@ -21,6 +21,10 @@ import { fleetEndpoint } from './fleetWire.mjs';
21
21
  // a kill, and again at settle. Best-effort: a failed post drops the
22
22
  // batch rather than blocking the turn — the surface says it is the
23
23
  // machine's report, not a syscall trace.
24
+ /** The server's own cap on one audited command (sessionCommands.ts
25
+ * `MAX_COMMAND_CHARS`), applied here AFTER the scrub. */
26
+ const MAX_COMMAND_CHARS = 2000;
27
+
24
28
  export function createCommandAudit({ sessionId, turnId, runtime, cwd }) {
25
29
  const SESSION_COMMANDS_URL = fleetEndpoint('session-commands', FLEET_URL);
26
30
  const auditBatch = [];
@@ -55,7 +59,12 @@ export function createCommandAudit({ sessionId, turnId, runtime, cwd }) {
55
59
  // `PGPASSWORD=… psql` — and the audit is stored 30 days and rendered
56
60
  // in the admin view, so the one uplink that omitted scrub was the
57
61
  // one most likely to carry a plaintext secret.
58
- auditBatch.push({ command: envScrub(a.command), at: new Date().toISOString() });
62
+ //
63
+ // SCRUBBED WHOLE, THEN CUT (2026-10-10, the audit's F128): the parsers
64
+ // used to cut the command at 2000 before it reached this scrub, so a
65
+ // secret straddling that character kept its prefix. They now hand a
66
+ // bounded window, and the server's cap is applied here, last.
67
+ auditBatch.push({ command: scrubCut(a.command, MAX_COMMAND_CHARS), at: new Date().toISOString() });
59
68
  if (auditBatch.length >= 25) flushAudit();
60
69
  };
61
70
  return { flushAudit, auditCommand };
@@ -1,7 +1,25 @@
1
1
  /** Commit diff requests and their bounded reports. */
2
2
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
3
- import { git } from './git.mjs';
3
+ import { git, GIT_WIDE_BUFFER, OBJECT_ID_RE } from './git.mjs';
4
4
  import { fleetEndpoint } from './fleetWire.mjs';
5
+ import { scrub, scrubCut } from './uplinkScrub.mjs';
6
+
7
+ /**
8
+ * A PATCH BIGGER THAN NODE'S 1 MiB DEFAULT BUFFER (2026-10-10, the audit's
9
+ * F150). `git()` (git.mjs) defaults to Node's 1 MiB `maxBuffer`, so any
10
+ * commit whose patch passed 1 MiB failed `ENOBUFS` and the viewer was told it
11
+ * errored, when it should have been shown the first 256 KB marked truncated.
12
+ * The same call with room for GIT_WIDE_BUFFER (8 MiB); past that, what git
13
+ * printed up to the bound is still a patch to truncate, never an error.
14
+ */
15
+ function gitBig(args, cwd) {
16
+ try {
17
+ return git(args, cwd, { maxBuffer: GIT_WIDE_BUFFER });
18
+ } catch (e) {
19
+ if (e?.code === 'ENOBUFS' && typeof e.stdout === 'string' && e.stdout) return e.stdout;
20
+ throw e;
21
+ }
22
+ }
5
23
 
6
24
  export function createWorkDiffs({ repoRoot }) {
7
25
  const DIFF_DONE_URL = fleetEndpoint('diff-done', FLEET_URL);
@@ -23,6 +41,18 @@ export function createWorkDiffs({ repoRoot }) {
23
41
  * machine" is a real answer, and a viewer spinning forever is the worst thing
24
42
  * this can do. The one thing never sent is a guess — an empty patch would
25
43
  * tell a reader the commit changed nothing.
44
+ *
45
+ * ONLY A COMMIT, AND SCRUBBED (2026-10-10, the audit's F150). The sha is a
46
+ * server-named hex string, and `git show <sha>` prints whatever object it
47
+ * names — a blob, a tree, a tag — so it is first resolved as
48
+ * `<sha>^{commit}` and anything else is answered "no such commit on this
49
+ * machine" with no content. (The hex check below already keeps it from
50
+ * reading as an option, so no `--end-of-options`, which git before 2.30
51
+ * does not take in `rev-parse`.) No reachability check: the run page asks
52
+ * for unpushed agent commits on load, and a remote-branch bound would refuse
53
+ * them. And the patch is the one repository uplink that skipped `scrub`; it
54
+ * now passes through it WHOLE, before the line-boundary cut — the
55
+ * scrub-then-cut order every other lane keeps.
26
56
  */
27
57
  const MAX_PATCH_BYTES = 256 * 1024;
28
58
  const servedDiffs = new Set();
@@ -48,7 +78,7 @@ export function createWorkDiffs({ repoRoot }) {
48
78
  const sha = String(job?.sha || '').toLowerCase();
49
79
  // A sha becomes a `git` argument. The server validates too; one place
50
80
  // doing this check is one deploy away from being zero places.
51
- if (!/^[0-9a-f]{7,64}$/.test(sha)) continue;
81
+ if (!OBJECT_ID_RE.test(sha)) continue;
52
82
  // In-flight guard, not a cache: the server stops offering a sha the
53
83
  // moment it settles, so this only stops the SAME poll's job being
54
84
  // started twice while its `git show` is still running.
@@ -56,6 +86,22 @@ export function createWorkDiffs({ repoRoot }) {
56
86
  servedDiffs.add(sha);
57
87
  void (async () => {
58
88
  try {
89
+ let commit = '';
90
+ try {
91
+ commit = gitBig(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], repoRoot);
92
+ } catch (e) {
93
+ // Exit 1 is `--verify --quiet`'s "not a commit here" (absent,
94
+ // ambiguous, or another object type); anything else is git's own
95
+ // failure, relayed in its words as before.
96
+ if (e?.status !== 1) {
97
+ await postDiff({ sha, error: scrubCut(e?.message || e, 500) });
98
+ return;
99
+ }
100
+ }
101
+ if (!/^[0-9a-f]{40,64}$/.test(commit)) {
102
+ await postDiff({ sha, error: 'no such commit on this machine' });
103
+ return;
104
+ }
59
105
  let patch = '';
60
106
  const files = [];
61
107
  try {
@@ -63,13 +109,13 @@ export function createWorkDiffs({ repoRoot }) {
63
109
  // date already reached the card on the worktree sweep, and
64
110
  // repeating them inside the patch would put a second copy above
65
111
  // every hunk.
66
- patch = git(['show', '--patch', '--format=', sha], repoRoot);
112
+ patch = scrub(gitBig(['show', '--patch', '--format=', commit], repoRoot));
67
113
  } catch (e) {
68
- await postDiff({ sha, error: String(e?.message || e).slice(0, 500) });
114
+ await postDiff({ sha, error: scrubCut(e?.message || e, 500) });
69
115
  return;
70
116
  }
71
117
  try {
72
- const raw = git(['show', '--numstat', '--format=', sha], repoRoot);
118
+ const raw = gitBig(['show', '--numstat', '--format=', commit], repoRoot);
73
119
  for (const line of raw.split('\n')) {
74
120
  if (!line.trim()) continue;
75
121
  const [a, d, ...rest] = line.split('\t');
@@ -77,7 +123,7 @@ export function createWorkDiffs({ repoRoot }) {
77
123
  if (!path) continue;
78
124
  const binary = a === '-' || d === '-';
79
125
  files.push({
80
- path: path.slice(0, 300),
126
+ path: scrubCut(path, 300),
81
127
  added: binary ? 0 : Number(a) || 0,
82
128
  deleted: binary ? 0 : Number(d) || 0,
83
129
  ...(binary ? { binary: true } : {}),
@@ -1,9 +1,10 @@
1
1
  /** One leased incoming ticket, drafted by this machine's read-only CLI. */
2
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
2
+ import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
3
3
  import { fleetEndpoint } from './fleetWire.mjs';
4
+ import { claimFleetLease } from './fleetPost.mjs';
4
5
  import { runTurn as defaultRunTurn } from './runTurn.mjs';
5
6
  import { pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
6
- import { removeProbeTranscript } from './runtimeCapabilities.mjs';
7
+ import { runCappedTurn } from './workCappedTurn.mjs';
7
8
  import { REPO_PLACE } from './workPlaces.mjs';
8
9
  import { SYSTEM_INTAKE, INTAKE_KICKOFF } from './prompts.mjs';
9
10
  import { parseIntakeDraft } from './intakeDraft.mjs';
@@ -31,25 +32,12 @@ export function createWorkIntake({
31
32
  const done = fleetEndpoint(INTAKE_DONE_ENDPOINT, FLEET_URL);
32
33
  const intake = new Set();
33
34
 
34
- const claim = async (id) => {
35
- try {
36
- const res = await fetch(claiming, {
37
- method: 'POST',
38
- headers: {
39
- Authorization: `Bearer ${FLEET_TOKEN}`,
40
- 'User-Agent': USER_AGENT,
41
- 'Content-Type': 'application/json',
42
- },
43
- signal: AbortSignal.timeout(15_000),
44
- body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
45
- });
46
- // A 409 means another holder (or a dismissed/landed ticket). No CLI and
47
- // no settle: this machine never acquired the right to answer for it.
48
- return res.status === 200 && (await res.json().catch(() => null))?.ok === true;
49
- } catch {
50
- return false;
51
- }
52
- };
35
+ // The intake route answers `{ ok: true }` on a 200, not `data.claimed`
36
+ // (machineIntake.routes.ts) — the same lease rule with its own winner test.
37
+ // A 409 means another holder (or a dismissed/landed ticket). No CLI and no
38
+ // settle: this machine never acquired the right to answer for it.
39
+ const claim = (id) =>
40
+ claimFleetLease(claiming, { id }, { won: (r) => r.status === 200 && r.json?.ok === true });
53
41
 
54
42
  const report = async (body) => {
55
43
  await postBestEffort(done, { ...body, instance: DAEMON_INSTANCE });
@@ -68,56 +56,43 @@ export function createWorkIntake({
68
56
  }
69
57
 
70
58
  let out = '';
71
- let child = null;
72
- let session = null;
73
- let timer = null;
74
59
  let wedged = false;
60
+ let spawned = false;
75
61
  let usage = null;
76
62
  // A spawned CLI can fail before reporting counters. Null records that the
77
63
  // spend is unmeasured; four zeroes would falsely say it spent nothing.
78
- const spent = () => child ? { usage } : {};
64
+ const spent = () => spawned ? { usage } : {};
79
65
  try {
80
66
  await inPlace(REPO_PLACE, false, async () => {
81
- let stopWaiting = () => {};
82
- const capped = new Promise((resolve) => { stopWaiting = resolve; });
83
- const turn = runTurn({
84
- prompt: INTAKE_KICKOFF(job),
85
- system: SYSTEM_INTAKE,
86
- profile: 'consult',
87
- cwd: repoRoot,
88
- runtime,
89
- streamJson: true,
90
- answerFromResult: true,
91
- label: c.cyan('[intake]'),
92
- // There is no MCP principal for a ticket, and no model or effort pin:
93
- // this one-shot reader uses the machine's consult defaults.
94
- onInit: (i) => {
95
- if (typeof i?.sessionId === 'string' && i.sessionId.trim()) session = i.sessionId.trim();
96
- },
97
- onUsage: (u) => { usage = addUsage(usage, { ...u, runtime }); },
98
- onSpawn: (ch) => {
99
- child = ch;
100
- workChildren.set(ch, null);
101
- releaseSlot();
102
- // Start the cap at spawn, never while a checkout writer holds the
103
- // reader lock; SIGKILL and resolve even if a grandchild holds stdio.
104
- timer = setTimeout(() => {
105
- wedged = true;
106
- try { ch.kill('SIGKILL'); } catch { /* already gone */ }
107
- stopWaiting('');
108
- }, 10 * 60_000);
109
- timer.unref?.();
67
+ // One capped consult turn (workCappedTurn.mjs): the cap starts at
68
+ // spawn, never while a checkout writer holds the reader lock, and
69
+ // SIGKILLs the child and resolves even if a grandchild holds stdio.
70
+ ({ out, wedged } = await runCappedTurn({
71
+ runTurn,
72
+ capMs: 10 * 60_000,
73
+ workChildren,
74
+ releaseSlot,
75
+ transcriptCwd: repoRoot,
76
+ turnArgs: {
77
+ prompt: INTAKE_KICKOFF(job),
78
+ system: SYSTEM_INTAKE,
79
+ profile: 'consult',
80
+ cwd: repoRoot,
81
+ runtime,
82
+ answerFromResult: true,
83
+ label: c.cyan('[intake]'),
84
+ // There is no MCP principal for a ticket, and no model or effort pin:
85
+ // this one-shot reader uses the machine's consult defaults.
86
+ onUsage: (u) => { usage = addUsage(usage, { ...u, runtime }); },
110
87
  },
111
- });
112
- out = await Promise.race([turn, capped]);
88
+ // Marked by the lane itself, so a turn that throws after its spawn
89
+ // still reports what it measured.
90
+ onSpawn: () => { spawned = true; },
91
+ }));
113
92
  });
114
93
  } catch (e) {
115
94
  await report({ id, outcome: INTAKE_OUTCOMES.nothing, error: envScrub(String(e?.message || e)).slice(0, 500), ...spent() });
116
95
  return;
117
- } finally {
118
- if (timer) clearTimeout(timer);
119
- if (child) workChildren.delete(child);
120
- if (session) setTimeout(() => removeProbeTranscript(repoRoot, session), 750).unref?.();
121
96
  }
122
97
 
123
98
  if (wedged) {
@@ -12,14 +12,15 @@
12
12
  */
13
13
  import { readFileSync } from 'node:fs';
14
14
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT, MACHINE_HOST } from './config.mjs';
15
- import { gitNetAsync, isSafePathSegment } from './git.mjs';
15
+ import { gitNetAsync } from './git.mjs';
16
16
  import { measureListeners, listenersSupported } from './listeners.mjs';
17
17
  import { processesSupported } from './processes.mjs';
18
18
  import { titleForSession } from './claudeSessions.mjs';
19
+ import { RESUME_MARKERS } from './conversationIds.mjs';
19
20
  import { worktreeDiff } from './worktreeDiff.mjs';
20
21
  import { myPubB64 } from './boxIdentity.mjs';
21
22
  import { fleetEndpoint } from './fleetWire.mjs';
22
- import { REPO_PLACE } from './workPlaces.mjs';
23
+ import { isValidPlace } from './workPlaces.mjs';
23
24
 
24
25
  export function createWorkPlaceReports({
25
26
  repoRoot,
@@ -94,7 +95,9 @@ export function createWorkPlaceReports({
94
95
  // The session's PLACE, not its name: a tab in the checkout is measured in
95
96
  // the checkout, and a tab sharing another tab's worktree is measured there.
96
97
  const place = placeOf(sessionId);
97
- if (place !== REPO_PLACE && !isSafePathSegment(place)) return null;
98
+ // The one validity rule (`isValidPlace`, workPlaces.mjs, audit 2026-10-10
99
+ // F141) — not a third spelling of it.
100
+ if (!isValidPlace(place)) return null;
98
101
  const wt = placeDir(sessionId);
99
102
  const d = worktreeDiff(wt, baseRef());
100
103
  if (!d) return null;
@@ -138,7 +141,7 @@ export function createWorkPlaceReports({
138
141
  // exactly as it found it.
139
142
  let title = null;
140
143
  try {
141
- const marker = sessionMetaPath(wt, 'flowviant-claude-session', sessionId);
144
+ const marker = sessionMetaPath(wt, RESUME_MARKERS.claude, sessionId);
142
145
  if (marker) title = titleForSession(wt, readFileSync(marker, 'utf8').trim());
143
146
  } catch {
144
147
  /* no marker yet — this tab has not spoken, or is not Claude */
@@ -7,18 +7,62 @@
7
7
  * one of these four questions, and they have to agree: a tab in the checkout
8
8
  * measured against `sessions/<id>` reports nothing, and a marker written
9
9
  * unscoped hands one tab another's conversation. One home keeps them one
10
- * answer. The trust boundary (`isSafePathSegment` / `REPO_PLACE`) lives here
11
- * with the map it guards; the session lane is the only other writer and keeps
12
- * the same check (its comment says why).
10
+ * answer. The trust boundary (`isValidPlace`) lives here with the map it
11
+ * guards, and `notePlace` is the map's ONLY writer: the roster's intake and
12
+ * the session lane's turn intake both go through it (audit 2026-10-10, F141),
13
+ * so a lane that learns a place cannot store one unvalidated.
13
14
  */
14
15
  import { existsSync } from 'node:fs';
15
16
  import { join } from 'node:path';
16
- import { git, isSafePathSegment, hasCommits, gitFailure } from './git.mjs';
17
+ import { git, isSafePathSegment, hasCommits, gitFailure, sessionBranch } from './git.mjs';
17
18
 
18
19
  /** The place id meaning "the checkout", not a worktree. Must match the
19
- * server's REPO_PLACE — it is a wire value, not a local convention. */
20
+ * server's REPO_PLACE — it is a wire value, not a local convention. The
21
+ * release parity gate holds it there (2026-10-10, audit F163). */
20
22
  export const REPO_PLACE = 'repo';
21
23
 
24
+ /** A PERSON's place starts with this — the server's `userPlace(userId)` is
25
+ * `u-<userId>` — which is how the worktree sweep tells a person's manual copy
26
+ * (fast-forwarded) from an agent's or a session's (never touched). A wire
27
+ * value too, held to `userPlace` by the release parity gate (2026-10-10,
28
+ * audit F163). */
29
+ export const PERSON_PLACE_PREFIX = 'u-';
30
+
31
+ /**
32
+ * A PLACE THIS MACHINE WILL RESOLVE: the checkout, or a safe path segment.
33
+ *
34
+ * The value becomes the directory a turn is SPAWNED in and a preview port is
35
+ * measured against — the security boundary for the whole preview feature. The
36
+ * server resolves place to an enum (never a path), but a server bug or
37
+ * compromise sending `../../etc` would otherwise point a session's measured
38
+ * directory anywhere on the box. REPO_PLACE resolves to repoRoot, so it is
39
+ * allowed through despite not being a path segment.
40
+ */
41
+ export function isValidPlace(place) {
42
+ return typeof place === 'string' && (place === REPO_PLACE || isSafePathSegment(place));
43
+ }
44
+
45
+ /**
46
+ * A PLACE'S OWN WORKTREE DIRECTORY — `sessions/<place>` under the worktrees
47
+ * root — or null for an id that is not a safe path segment. Pure.
48
+ *
49
+ * No REPO_PLACE arm, on purpose: the agent lanes hold an AGENT's place, which
50
+ * is never the checkout, so a stray `'repo'` there keeps naming a directory
51
+ * that does not exist rather than becoming the operator's checkout.
52
+ */
53
+ export function worktreePath(baseDir, place) {
54
+ return isSafePathSegment(place) ? join(baseDir, 'sessions', place) : null;
55
+ }
56
+
57
+ /**
58
+ * THE DIRECTORY A PLACE RESOLVES TO: the checkout for REPO_PLACE, its own
59
+ * worktree otherwise, null for an unsafe id. Pure — `placeDir` is this over
60
+ * the session map, and nothing else spells the rule.
61
+ */
62
+ export function placePath({ repoRoot, baseDir }, place) {
63
+ return place === REPO_PLACE ? repoRoot : worktreePath(baseDir, place);
64
+ }
65
+
22
66
  export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged }) {
23
67
  /**
24
68
  * WHERE EACH SESSION WORKS, learned from the turns we are handed.
@@ -66,36 +110,59 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
66
110
  * Absence of the whole map still means "an older server said nothing", which
67
111
  * is the only thing absence can safely mean.
68
112
  */
113
+ /**
114
+ * THE MAP'S ONE WRITER — validate, then store; false (and nothing stored)
115
+ * for a place this machine refuses.
116
+ *
117
+ * VALIDATED AT THE TRUST BOUNDARY (`isValidPlace` says why). There were two
118
+ * writers — the roster's intake below and the session lane's turn intake —
119
+ * each spelling the check, with a comment keeping them in step by hand:
120
+ * validating one intake and not the other is one deploy away from
121
+ * validating neither. Now there is one door, and the map is not handed out.
122
+ */
123
+ const notePlace = (sessionId, place) => {
124
+ if (!isValidPlace(place)) return false;
125
+ sessionPlaces.set(sessionId, place);
126
+ return true;
127
+ };
69
128
  const learnPlaces = (map) => {
70
129
  if (!map || typeof map !== 'object') return;
71
130
  for (const [sid, place] of Object.entries(map)) {
72
- // VALIDATE AT THE TRUST BOUNDARY. `placeDir` joins this value straight
73
- // into `sessions/<place>` and it is the directory a turn is SPAWNED in
74
- // and a preview port is measured against — the security boundary for the
75
- // whole preview feature. The server resolves place to an enum (never a
76
- // path), but a server bug or compromise sending `../../etc` would
77
- // otherwise point a session's measured directory anywhere on the box and
78
- // defeat the port attribution. `sessionWorktreeReport` and `placeWtFor`
79
- // already reject an unsafe segment; checking at the intake covers the
80
- // consumers that do not (the preview-claim `placeDir` did not re-check).
81
- // NOT the only writer: `processWorkTurns` stores a turn job's place too,
82
- // and keeps the same check — validating one intake and not the other is
83
- // one deploy away from validating neither. REPO_PLACE resolves to
84
- // repoRoot, so it is allowed through despite not being a path segment.
85
- if (typeof place === 'string' && place && (place === REPO_PLACE || isSafePathSegment(place)))
86
- sessionPlaces.set(sid, place);
131
+ // `sessionWorktreeReport` and `placeWtFor` already reject an unsafe
132
+ // segment; checking at the intake covers the consumers that do not (the
133
+ // preview-claim `placeDir` did not re-check).
87
134
  // null / '' / an unsafe value: the server is telling us this tab is in its
88
135
  // OWN worktree (or is malformed). Falling back to the default requires
89
136
  // forgetting, not ignoring.
90
- else sessionPlaces.delete(sid);
137
+ if (!notePlace(sid, place)) sessionPlaces.delete(sid);
91
138
  }
92
139
  };
140
+ /** The directory a PLACE resolves to, on this machine's roots. */
141
+ const dirOfPlace = (place) => placePath({ repoRoot, baseDir }, place);
93
142
  /** The DIRECTORY a session works in. Every path that used to build
94
143
  * `sessions/<id>` by hand goes through here, or a tab in the checkout gets
95
- * measured against a directory that does not exist. */
96
- const placeDir = (sessionId) => {
97
- const place = placeOf(sessionId);
98
- return place === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', place);
144
+ * measured against a directory that does not exist. Null for a session id
145
+ * that is not a safe segment and has learnt no place. */
146
+ const placeDir = (sessionId) => dirOfPlace(placeOf(sessionId));
147
+
148
+ /**
149
+ * THE BRANCH STANDING IN `wt` — what is actually checked out, not what we
150
+ * named it at birth — as `{ branch }`, or `{ detached: true }` when HEAD
151
+ * names no branch. A directory that is gone (a retired or closed tab) cannot
152
+ * be asked, so `fallback` is the answer: the one case where the recorded
153
+ * name is the only thing there is.
154
+ *
155
+ * Moved verbatim out of ship and the PR lane (audit 2026-10-10, F141), which
156
+ * spelled it twice so that "which branch would ship" and "which branch would
157
+ * open a PR" agreed only by hand.
158
+ */
159
+ const branchAt = (wt, fallback) => {
160
+ if (!existsSync(wt)) return { branch: fallback };
161
+ try {
162
+ return { branch: git(['symbolic-ref', '--short', 'HEAD'], wt) };
163
+ } catch {
164
+ return { detached: true };
165
+ }
99
166
  };
100
167
 
101
168
  /**
@@ -132,12 +199,11 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
132
199
  // caller may treat this as a newly-cut branch.
133
200
  return { wt: repoRoot, fresh: false };
134
201
  }
135
- if (!isSafePathSegment(placeId)) return null;
136
- const sessionId = placeId;
137
- const wt = join(baseDir, 'sessions', sessionId);
202
+ const wt = worktreePath(baseDir, placeId);
203
+ if (!wt) return null;
138
204
  const fresh = !existsSync(wt);
139
205
  if (fresh) {
140
- const branch = `session/${sessionId}`;
206
+ const branch = sessionBranch(placeId);
141
207
  // `baseAt` is the adoption override: a tab born from a terminal session
142
208
  // branches from THAT checkout's HEAD, because the conversation being
143
209
  // resumed was had against those commits — putting it on the project base
@@ -224,5 +290,5 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
224
290
  }
225
291
  };
226
292
 
227
- return { sessionPlaces, placeOf, learnPlaces, placeDir, placeWtFor, sessionMetaPath };
293
+ return { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath };
228
294
  }