thinkpool-pair 0.7.364 → 0.7.366

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 (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. package/worker-completion.mjs +1 -57
@@ -1,387 +1 @@
1
- // Thinkpool Flow — durable per-lane context-offload store.
2
- //
3
- // Three design patterns fused (docs/specs/2026-06-30-flow-build-s1-context-offload.md):
4
- // • DBOS-style durable steps — each artifact write is idempotent + content-addressed by
5
- // (taskKey, name) + sha256; a re-dispatched lane resumes instead of duplicating work.
6
- // • Headroom-style digest ceiling — the conductor reads one-line summary + pointers per
7
- // closed slice, capped at CEILING bytes; never an unbounded transcript.
8
- // • Statewright-style structural containment — a hard path guard rejects any taskKey or
9
- // name that would escape <worktreeDir>/.flow/artifacts/; structural, not an instruction.
10
- // Reuses the approach from bridge/flow-assembly.mjs (inlineSingleHtml resolve) and
11
- // bridge/flow-preview.mjs (resolveUnderRoot) — two existing containment sites.
12
- //
13
- // Pure module — canonical at src/lib/flow/contextStore.js, mirrored byte-identically
14
- // at bridge/flow-context-store.mjs (same reason as taskGraph / flowBudget: the bridge
15
- // ships as thinkpool-pair npm package and can't import across the bridge↔src boundary
16
- // at publish time). Sync: bridge/flow-context-store.sync.test.js.
17
- //
18
- // Node built-ins only (node:crypto, node:fs, node:path) — no new deps.
19
- // All fs calls are injectable ({fs} opts) so logic is unit-testable against temp dirs.
20
-
21
- import { createHash } from 'node:crypto'
22
- import nodeFs from 'node:fs'
23
- import path from 'node:path'
24
-
25
- // Hard ceiling for the conductor's assembled digest context (bytes of serialized JSON).
26
- // When accumulated size would exceed CEILING, each over-budget entry is truncated to
27
- // { taskKey, summary, pointers } — never drops a pointer, never silently grows past this.
28
- export const CEILING = 32 * 1024 // 32 KB
29
-
30
- // ── containment ──────────────────────────────────────────────────────────────
31
- // Resolve `segments` under `base`. Returns the absolute path iff it stays strictly
32
- // inside `base`, otherwise null. Same approach as flow-assembly.mjs (inlineSingleHtml
33
- // resolve) and flow-preview.mjs (resolveUnderRoot) — reused, not re-invented.
34
- //
35
- // STRUCTURAL (not ENOENT-based): every rejection is a pure string-level check that
36
- // fires before any filesystem call. Do not add fs.exists/stat calls here.
37
- function containedPath (base, ...segments) {
38
- for (const seg of segments) {
39
- if (!seg || typeof seg !== 'string') return null
40
- if (path.isAbsolute(seg)) return null
41
- if (seg.includes('\0')) return null
42
- // STRUCTURAL: reject any segment that contains a path separator character.
43
- // Prevents 'real/link/escape' and 'a/../../evil' from normalising into a valid
44
- // sub-path without escaping the per-entry rel-check below. This is the key
45
- // structural gate — no ENOENT dependency.
46
- if (seg.includes('/') || seg.includes('\\')) return null
47
- // STRUCTURAL: reject bare '.' and '..' as full segments (belt-and-suspenders;
48
- // the rel.startsWith('..') check below would also catch '..' after path.join).
49
- if (seg === '..' || seg === '.') return null
50
- // Reject zero-width / Unicode format chars (ZWSP U+200B, ZWNJ U+200C, etc.):
51
- // trim() does NOT strip them; require at least one real identifier char.
52
- if (!/[A-Za-z0-9_.-]/.test(seg)) return null
53
- }
54
- const joined = path.join(...segments)
55
- const abs = path.resolve(base, joined)
56
- const rel = path.relative(base, abs)
57
- // rel === '' means abs === base (the directory itself) — not a valid write target
58
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null
59
- return abs
60
- }
61
-
62
- // ── safeSegmentId ─────────────────────────────────────────────────────────────
63
- // Validate that `val` is a safe single path-segment identifier (used for flowId).
64
- // Rejects: null/non-string, empty, whitespace-only, null bytes, absolute paths,
65
- // path separators (/ or \), bare '.' or '..'. Prevents flowId from escaping the
66
- // digest base directory when interpolated into path.join calls.
67
- function safeSegmentId (val) {
68
- if (!val || typeof val !== 'string') return false
69
- if (!val.trim()) return false
70
- if (val.includes('\0')) return false
71
- if (path.isAbsolute(val)) return false
72
- if (val.includes('/') || val.includes('\\')) return false
73
- if (val === '..' || val === '.') return false
74
- // Reject zero-width / Unicode format chars (ZWSP U+200B, ZWNJ U+200C, etc.):
75
- // trim() does NOT strip them; require at least one real identifier char.
76
- if (!/[A-Za-z0-9_.-]/.test(val)) return false
77
- return true
78
- }
79
-
80
- // ── assertLeafContained ────────────────────────────────────────────────────────
81
- // Leaf-level (final-component) containment for a WRITE target — the artifact file or
82
- // the digest .jsonl. The dir-level realpathSync guards in the callers contain the
83
- // PARENT directory, but fs.writeFileSync / fs.appendFileSync follow a symlink planted
84
- // AT the leaf itself, so a symlink at <dest> would let the write escape an
85
- // already-contained directory. realpathSync(dir) never inspects the leaf's own type —
86
- // only lstatSync does, because it does NOT follow the final component. It reveals the
87
- // leaf as a symlink regardless of what the link points at, which is why one check
88
- // covers all three escape flavors:
89
- // • ENOENT → no leaf yet; the write will create a regular file → safe, return.
90
- // • symlink → reject (absolute-, relative-, AND chained-target symlinks alike —
91
- // lstat stops at the first hop, so a symlink→symlink→victim chain and a
92
- // relative "../../.." target are both caught here).
93
- // • regular → realpathSync must still resolve strictly under `canonicalDir`.
94
- // Must be called BEFORE any idempotency readFileSync of the leaf (which would itself
95
- // follow a leaf symlink). This is the leaf half of containment; the dir half stays in
96
- // the callers (writeLaneArtifact / appendDigest). (B5 breaks 1-3.)
97
- function assertLeafContained (fs, leafPath, canonicalDir) {
98
- let st
99
- try {
100
- st = fs.lstatSync(leafPath)
101
- } catch (e) {
102
- if (e && e.code === 'ENOENT') return // leaf absent — write creates a regular file
103
- throw e
104
- }
105
- if (st.isSymbolicLink()) {
106
- throw new Error(`symlink escape: refusing to write through a symlink at leaf "${leafPath}"`)
107
- }
108
- // Non-symlink leaf: re-verify its real path is still strictly inside the canonical dir.
109
- const real = fs.realpathSync(leafPath)
110
- if (!real.startsWith(canonicalDir + path.sep)) {
111
- throw new Error(`symlink escape: resolved leaf "${real}" escapes canonical dir "${canonicalDir}"`)
112
- }
113
- }
114
-
115
- // ── writeLaneArtifact ─────────────────────────────────────────────────────────
116
- // Write `content` (string | Buffer) under <worktreeDir>/.flow/artifacts/<taskKey>/<name>.
117
- // Returns { path, bytes, sha } where sha is the sha256 hex of the content.
118
- //
119
- // IDEMPOTENT: if (taskKey, name) already holds byte-identical content, skips the write
120
- // and returns the same { path, bytes, sha }. Safe to call multiple times on the same
121
- // artifact — a re-dispatched lane writing what's already there is a no-op.
122
- //
123
- // STRUCTURAL PATH GUARD: rejects any taskKey or name that would escape the artifacts
124
- // base (no '..', no absolute paths, no null bytes, no path separators, no whitespace-only).
125
- // Throws on violation before any filesystem call is made.
126
- //
127
- // REALPATH CONTAINMENT GUARD (directory level): after the structural string checks and
128
- // mkdirSync, resolves the real path of taskDir via fs.realpathSync and verifies it is
129
- // still inside the canonical artifacts root (fs.realpathSync(worktreeDir) +
130
- // '/.flow/artifacts'). Catches symlinks planted at any DIRECTORY level — .flow,
131
- // .flow/artifacts, or the taskKey dir — in one pass.
132
- //
133
- // LEAF CONTAINMENT GUARD (file level): the dir guard cannot see a symlink planted at the
134
- // dest file itself (realpathSync(dir) never inspects the leaf's type). assertLeafContained
135
- // lstat's the dest and rejects a leaf symlink — absolute-, relative-, or chained-target —
136
- // before fs.writeFileSync (or the idempotency readFileSync) can follow it out of the dir.
137
- export function writeLaneArtifact (flowId, taskKey, name, content, { worktreeDir, fs = nodeFs } = {}) {
138
- if (!flowId || typeof flowId !== 'string') throw new Error('flowId required (string)')
139
- if (!taskKey || typeof taskKey !== 'string' || !taskKey.trim()) throw new Error('taskKey required (non-empty string)')
140
- if (!name || typeof name !== 'string' || !name.trim()) throw new Error('name required (non-empty string)')
141
- if (!worktreeDir) throw new Error('worktreeDir required')
142
-
143
- const artifactsBase = path.join(worktreeDir, '.flow', 'artifacts')
144
-
145
- const taskDir = containedPath(artifactsBase, taskKey)
146
- if (!taskDir) throw new Error(`taskKey "${taskKey}" rejected — path escapes artifacts base or is invalid`)
147
-
148
- const dest = containedPath(artifactsBase, taskKey, name)
149
- if (!dest) throw new Error(`name "${name}" rejected — path escapes artifacts base or is invalid`)
150
-
151
- const buf = Buffer.isBuffer(content) ? content : Buffer.from(content)
152
- const sha = createHash('sha256').update(buf).digest('hex')
153
-
154
- fs.mkdirSync(taskDir, { recursive: true })
155
-
156
- // REALPATH CONTAINMENT (directory level): after mkdirSync, resolve the real path of
157
- // taskDir and verify it is still inside the canonical artifacts root.
158
- // fs.realpathSync(worktreeDir) handles the case where the worktree itself lives under a
159
- // legitimately-symlinked path; the canonical root is then built as a plain string (no
160
- // further symlink-following), so a symlink planted at .flow, .flow/artifacts, or the
161
- // taskKey directory is caught in one structural pass — its resolved path escapes the
162
- // canonical root, and we throw.
163
- // Replaces the per-position lstatSync whack-a-mole (was: check taskDir, check dest).
164
- const canonicalWorktreeDir = fs.realpathSync(worktreeDir)
165
- const canonicalRoot = path.join(canonicalWorktreeDir, '.flow', 'artifacts')
166
- const canonicalTaskDir = fs.realpathSync(taskDir)
167
- if (!canonicalTaskDir.startsWith(canonicalRoot + path.sep)) {
168
- throw new Error(
169
- `symlink escape: resolved path "${canonicalTaskDir}" escapes ` +
170
- `canonical artifacts root "${canonicalRoot}"`
171
- )
172
- }
173
-
174
- // LEAF CONTAINMENT (file level): the dir guard above contains the directory, but
175
- // fs.writeFileSync follows a symlink planted AT the dest file itself — a symlink at
176
- // <taskDir>/<name> (absolute-, relative-, or chained-target) would let the write escape
177
- // the contained dir. Must run BEFORE the idempotency read below, which also follows a
178
- // leaf symlink via readFileSync. (B5 breaks 1 & 3.)
179
- assertLeafContained(fs, dest, canonicalTaskDir)
180
-
181
- // Idempotency: if file already exists with identical bytes, skip the write.
182
- // Safe now — assertLeafContained has proven dest is a regular file (or absent).
183
- if (fs.existsSync(dest)) {
184
- const existing = fs.readFileSync(dest)
185
- if (existing.equals(buf)) return { path: dest, bytes: buf.length, sha }
186
- }
187
-
188
- fs.writeFileSync(dest, buf)
189
- return { path: dest, bytes: buf.length, sha }
190
- }
191
-
192
- // ── digestSlice ───────────────────────────────────────────────────────────────
193
- // Pure (no fs): compute a digest entry for a closed task. Returns { taskKey, summary,
194
- // acceptanceProof, pointers }. Callers persist via appendDigest; the conductor reads
195
- // back via loadDigest with the CEILING compression applied.
196
- //
197
- // summary: a single line, <= 120 chars: "[taskKey] title" optionally with the first
198
- // line of acceptanceProof appended. Guaranteed to contain no newlines.
199
- // pointers: [{ taskKey, name, path, sha }] from the artifacts written during the task.
200
- // Artifacts whose taskKey/name/path/sha are missing/empty/undefined are
201
- // silently dropped — prevents String(undefined) = "undefined" from leaking
202
- // into pointer fields (DG-3).
203
- export function digestSlice (task, { acceptanceProof = '', artifacts = [] } = {}) {
204
- if (!task || !task.key) throw new Error('task with .key required')
205
- const base = `[${task.key}] ${task.title ?? task.key}`
206
- const firstLine = acceptanceProof ? String(acceptanceProof).split('\n')[0].trim() : ''
207
- const summary = (firstLine ? `${base} — ${firstLine}` : base).slice(0, 120)
208
- // DG-3: filter out artifacts with any missing/empty/undefined field before mapping.
209
- // (artifacts ?? []) handles an explicit null — destructuring default only fires for
210
- // undefined; passing artifacts:null leaves artifacts===null here, and null.filter throws.
211
- // String(undefined) = "undefined" would produce bogus pointer field values.
212
- const pointers = (artifacts ?? [])
213
- .filter(a =>
214
- a.taskKey != null && String(a.taskKey).trim() &&
215
- a.name != null && String(a.name).trim() &&
216
- a.path != null && String(a.path).trim() &&
217
- a.sha != null && String(a.sha).trim()
218
- )
219
- .map(a => ({
220
- taskKey: String(a.taskKey),
221
- name: String(a.name),
222
- path: String(a.path),
223
- sha: String(a.sha),
224
- }))
225
- return { taskKey: task.key, summary, acceptanceProof: String(acceptanceProof), pointers }
226
- }
227
-
228
- // ── appendDigest ─────────────────────────────────────────────────────────────
229
- // Persist a digest entry (from digestSlice) to <baseDir>/.flow/digests/<flowId>.jsonl.
230
- // Append-only JSONL — insertion order is preserved for loadDigest.
231
- //
232
- // FLOWID CONTAINMENT: flowId is validated as a safe single path segment (no traversal,
233
- // separators, null bytes, or whitespace-only) before being interpolated into the path.
234
- // Prevents appendDigest('../../../victim', ...) from escaping baseDir (DG-1).
235
- //
236
- // LEAF CONTAINMENT: after the dir-level realpath check, assertLeafContained lstat's the
237
- // <flowId>.jsonl file and rejects a leaf symlink (absolute-, relative-, or chained-target)
238
- // before fs.appendFileSync can follow it out of the digests dir (B5 break 2).
239
- //
240
- // ZERO-POINTER guard: rejects any digest with an empty pointers array. Every persisted
241
- // entry must have >=1 artifact pointer (invariant d). A task with no artifacts must not
242
- // call appendDigest — the caller decides whether to skip or synthesize a pointer first.
243
- //
244
- // IDEMPOTENT: if the same (flowId, taskKey) is already persisted, this is a no-op.
245
- // Calling appendDigest twice with the same digest never creates duplicate entries.
246
- export function appendDigest (flowId, digest, { baseDir, fs = nodeFs } = {}) {
247
- if (!safeSegmentId(flowId)) throw new Error(`flowId "${flowId}" rejected — must be a non-empty single path segment (no traversal, separators, or null bytes)`)
248
- if (!baseDir) throw new Error('baseDir required')
249
- if (!digest || !digest.taskKey) throw new Error('digest.taskKey required')
250
- // ZERO-POINTER guard: every persisted digest must carry >=1 valid pointer (invariant d).
251
- if (!digest.pointers || digest.pointers.length === 0) {
252
- throw new Error(
253
- `digest for "${digest.taskKey}" has no pointers — ` +
254
- 'a closed slice must produce >=1 artifact pointer before persisting'
255
- )
256
- }
257
- const dir = path.join(baseDir, '.flow', 'digests')
258
- fs.mkdirSync(dir, { recursive: true })
259
-
260
- // REALPATH CONTAINMENT: verify the resolved digests directory is still under the
261
- // canonical base — same one-pass approach as writeLaneArtifact. Catches symlinks
262
- // planted at .flow or .flow/digests. fs.realpathSync(baseDir) handles a legitimately-
263
- // symlinked base; canonicalDigestsDir is built as a plain string thereafter.
264
- const canonicalBase = fs.realpathSync(baseDir)
265
- const canonicalDigestsDir = path.join(canonicalBase, '.flow', 'digests')
266
- const canonicalDir = fs.realpathSync(dir)
267
- if (canonicalDir !== canonicalDigestsDir) {
268
- throw new Error(
269
- `symlink escape: resolved digests dir "${canonicalDir}" escapes ` +
270
- `canonical digests root "${canonicalDigestsDir}"`
271
- )
272
- }
273
-
274
- const file = path.join(dir, `${flowId}.jsonl`)
275
-
276
- // LEAF CONTAINMENT (file level): the dir guard above contains the digests directory, but
277
- // fs.appendFileSync follows a symlink planted AT the <flowId>.jsonl leaf — a symlink
278
- // there (absolute-, relative-, or chained-target) would let the append escape the
279
- // contained dir. Must run BEFORE the idempotency readFileSync below, which also follows
280
- // a leaf symlink. (B5 break 2.)
281
- assertLeafContained(fs, file, canonicalDir)
282
-
283
- // IDEMPOTENCY: skip if the same taskKey is already persisted for this flowId.
284
- // Re-digesting the same (flowId, taskKey) is a no-op — no duplicate lines.
285
- if (fs.existsSync(file)) {
286
- const lines = String(fs.readFileSync(file, 'utf8')).split('\n').filter(l => l.trim())
287
- for (const line of lines) {
288
- try {
289
- if (JSON.parse(line).taskKey === digest.taskKey) return
290
- } catch { /* skip malformed lines */ }
291
- }
292
- }
293
- fs.appendFileSync(file, JSON.stringify(digest) + '\n')
294
- }
295
-
296
- // ── loadDigest ────────────────────────────────────────────────────────────────
297
- // Load all closed-slice digests for `flowId` from <baseDir>/.flow/digests/<flowId>.jsonl,
298
- // applying the hard CEILING compression:
299
- // • While accumulated serialized size + full-entry length <= CEILING → include full entry
300
- // • Else if slim (no acceptanceProof) fits → include slim
301
- // • Else → drop the entry; never exceed CEILING
302
- //
303
- // FLOWID CONTAINMENT: flowId is validated as a safe single path segment before use
304
- // in path construction — same rule as appendDigest (DG-1).
305
- //
306
- // CEILING tracking is exact: accumulated == JSON.stringify(result).length at all times,
307
- // accounting for the outer '['/']' and comma separators between entries.
308
- //
309
- // Dedup by taskKey (first occurrence wins) and skips zero-pointer entries for safety
310
- // against old data that may pre-date the appendDigest zero-pointer guard.
311
- //
312
- // Returns digests in insertion order. Total JSON.stringify(result).length <= CEILING.
313
- export function loadDigest (flowId, { baseDir, fs = nodeFs } = {}) {
314
- if (!safeSegmentId(flowId)) throw new Error(`flowId "${flowId}" rejected — must be a non-empty single path segment (no traversal, separators, or null bytes)`)
315
- if (!baseDir) throw new Error('baseDir required')
316
- const file = path.join(baseDir, '.flow', 'digests', `${flowId}.jsonl`)
317
- if (!fs.existsSync(file)) return []
318
- const lines = String(fs.readFileSync(file, 'utf8')).split('\n').filter(l => l.trim())
319
- const result = []
320
- const seen = new Set() // dedup by taskKey — first occurrence wins
321
- // Track exact JSON.stringify(result).length:
322
- // '[' entries.join(',') ']' = 2 + sum(entryLen) + (n-1 commas)
323
- // Start at 2 for the outer brackets. Each entry adds (comma if not first) + entryLen.
324
- // Invariant: accumulated === JSON.stringify(result).length after every push.
325
- let accumulated = 2 // '[' + ']'
326
- for (const line of lines) {
327
- let entry
328
- try { entry = JSON.parse(line) } catch { continue }
329
- if (!entry.taskKey) continue
330
- // Dedup: appendDigest should prevent duplicates, but guard here for old data
331
- if (seen.has(entry.taskKey)) continue
332
- // Safety: skip zero-pointer entries (appendDigest now rejects them, but old JSONL may have them)
333
- if (!entry.pointers || entry.pointers.length === 0) continue
334
- seen.add(entry.taskKey)
335
- const slim = { taskKey: entry.taskKey, summary: entry.summary, pointers: entry.pointers }
336
- const comma = result.length > 0 ? 1 : 0 // comma separator before every entry except the first
337
- const fullLen = line.length
338
- const slimLen = JSON.stringify(slim).length
339
- if (accumulated + comma + fullLen <= CEILING) {
340
- result.push(entry)
341
- accumulated += comma + fullLen
342
- } else if (accumulated + comma + slimLen <= CEILING) {
343
- // Full would exceed ceiling: emit the slim form (never drop the pointer)
344
- result.push(slim)
345
- accumulated += comma + slimLen
346
- }
347
- // else: neither full nor slim fits within CEILING — drop this entry.
348
- // The hard guarantee: JSON.stringify(result).length <= CEILING always holds.
349
- }
350
- return result
351
- }
352
-
353
- // ── resumeLane ────────────────────────────────────────────────────────────────
354
- // Re-read a lane's own prior artifacts from <worktreeDir>/.flow/artifacts/.
355
- // Returns an array of { taskKey, name, path, bytes, sha } — identical to what
356
- // writeLaneArtifact would have returned for each artifact originally written.
357
- //
358
- // IDEMPOTENT: calling multiple times on the same worktree gives the same result.
359
- // A re-dispatched lane calls this on startup to reconstruct its prior state without
360
- // redoing any writes. Artifacts whose files are unreadable are silently skipped.
361
- export function resumeLane (flowId, { worktreeDir, fs = nodeFs } = {}) {
362
- if (!flowId) throw new Error('flowId required')
363
- if (!worktreeDir) throw new Error('worktreeDir required')
364
- const artifactsBase = path.join(worktreeDir, '.flow', 'artifacts')
365
- if (!fs.existsSync(artifactsBase)) return []
366
- // [SCOPE: accepted] The read path here is intentionally NOT realpath-checked.
367
- // resumeLane reads artifacts but does not write; traversing a symlink-redirected path
368
- // cannot exfiltrate data the caller couldn't already read by other means. The worktree
369
- // is trusted, non-shared, and single-user by design (git worktree, browser-side flow).
370
- // The WRITE path in writeLaneArtifact IS realpath-checked — that's where containment
371
- // matters. If the threat model changes (multi-user, shared fs), add realpath here too.
372
- const artifacts = []
373
- for (const taskKey of fs.readdirSync(artifactsBase)) {
374
- const taskDirPath = path.join(artifactsBase, taskKey)
375
- let stat
376
- try { stat = fs.statSync(taskDirPath) } catch { continue }
377
- if (!stat.isDirectory()) continue
378
- for (const name of fs.readdirSync(taskDirPath)) {
379
- const filePath = path.join(taskDirPath, name)
380
- let buf
381
- try { buf = fs.readFileSync(filePath) } catch { continue }
382
- const sha = createHash('sha256').update(buf).digest('hex')
383
- artifacts.push({ taskKey, name, path: filePath, bytes: buf.length, sha })
384
- }
385
- }
386
- return artifacts
387
- }
1
+ import{createHash as t}from"node:crypto";import e from"node:fs";import r from"node:path";export const CEILING=32768;function n(t,...e){for(const t of e){if(!t||"string"!=typeof t)return null;if(r.isAbsolute(t))return null;if(t.includes("\0"))return null;if(t.includes("/")||t.includes("\\"))return null;if(".."===t||"."===t)return null;if(!/[A-Za-z0-9_.-]/.test(t))return null}const n=r.join(...e),i=r.resolve(t,n),s=r.relative(t,i);return""===s||s.startsWith("..")||r.isAbsolute(s)?null:i}function i(t){return!(!t||"string"!=typeof t||!t.trim()||t.includes("\0")||r.isAbsolute(t)||t.includes("/")||t.includes("\\")||".."===t||"."===t||!/[A-Za-z0-9_.-]/.test(t))}function s(t,e,n){let i;try{i=t.lstatSync(e)}catch(t){if(t&&"ENOENT"===t.code)return;throw t}if(i.isSymbolicLink())throw new Error(`symlink escape: refusing to write through a symlink at leaf "${e}"`);const s=t.realpathSync(e);if(!s.startsWith(n+r.sep))throw new Error(`symlink escape: resolved leaf "${s}" escapes canonical dir "${n}"`)}export function writeLaneArtifact(i,o,a,c,{worktreeDir:l,fs:f=e}={}){if(!i||"string"!=typeof i)throw new Error("flowId required (string)");if(!o||"string"!=typeof o||!o.trim())throw new Error("taskKey required (non-empty string)");if(!a||"string"!=typeof a||!a.trim())throw new Error("name required (non-empty string)");if(!l)throw new Error("worktreeDir required");const u=r.join(l,".flow","artifacts"),p=n(u,o);if(!p)throw new Error(`taskKey "${o}" rejected — path escapes artifacts base or is invalid`);const y=n(u,o,a);if(!y)throw new Error(`name "${a}" rejected — path escapes artifacts base or is invalid`);const h=Buffer.isBuffer(c)?c:Buffer.from(c),d=t("sha256").update(h).digest("hex");f.mkdirSync(p,{recursive:!0});const w=f.realpathSync(l),m=r.join(w,".flow","artifacts"),g=f.realpathSync(p);if(!g.startsWith(m+r.sep))throw new Error(`symlink escape: resolved path "${g}" escapes canonical artifacts root "${m}"`);return s(f,y,g),f.existsSync(y)&&f.readFileSync(y).equals(h)||f.writeFileSync(y,h),{path:y,bytes:h.length,sha:d}}export function digestSlice(t,{acceptanceProof:e="",artifacts:r=[]}={}){if(!t||!t.key)throw new Error("task with .key required");const n=`[${t.key}] ${t.title??t.key}`,i=e?String(e).split("\n")[0].trim():"",s=(i?`${n} — ${i}`:n).slice(0,120),o=(r??[]).filter(t=>null!=t.taskKey&&String(t.taskKey).trim()&&null!=t.name&&String(t.name).trim()&&null!=t.path&&String(t.path).trim()&&null!=t.sha&&String(t.sha).trim()).map(t=>({taskKey:String(t.taskKey),name:String(t.name),path:String(t.path),sha:String(t.sha)}));return{taskKey:t.key,summary:s,acceptanceProof:String(e),pointers:o}}export function appendDigest(t,n,{baseDir:o,fs:a=e}={}){if(!i(t))throw new Error(`flowId "${t}" rejected — must be a non-empty single path segment (no traversal, separators, or null bytes)`);if(!o)throw new Error("baseDir required");if(!n||!n.taskKey)throw new Error("digest.taskKey required");if(!n.pointers||0===n.pointers.length)throw new Error(`digest for "${n.taskKey}" has no pointers — a closed slice must produce >=1 artifact pointer before persisting`);const c=r.join(o,".flow","digests");a.mkdirSync(c,{recursive:!0});const l=a.realpathSync(o),f=r.join(l,".flow","digests"),u=a.realpathSync(c);if(u!==f)throw new Error(`symlink escape: resolved digests dir "${u}" escapes canonical digests root "${f}"`);const p=r.join(c,`${t}.jsonl`);if(s(a,p,u),a.existsSync(p)){const t=String(a.readFileSync(p,"utf8")).split("\n").filter(t=>t.trim());for(const e of t)try{if(JSON.parse(e).taskKey===n.taskKey)return}catch{}}a.appendFileSync(p,JSON.stringify(n)+"\n")}export function loadDigest(t,{baseDir:n,fs:s=e}={}){if(!i(t))throw new Error(`flowId "${t}" rejected — must be a non-empty single path segment (no traversal, separators, or null bytes)`);if(!n)throw new Error("baseDir required");const o=r.join(n,".flow","digests",`${t}.jsonl`);if(!s.existsSync(o))return[];const a=String(s.readFileSync(o,"utf8")).split("\n").filter(t=>t.trim()),c=[],l=new Set;let f=2;for(const t of a){let e;try{e=JSON.parse(t)}catch{continue}if(!e.taskKey)continue;if(l.has(e.taskKey))continue;if(!e.pointers||0===e.pointers.length)continue;l.add(e.taskKey);const r={taskKey:e.taskKey,summary:e.summary,pointers:e.pointers},n=c.length>0?1:0,i=t.length,s=JSON.stringify(r).length;f+n+i<=32768?(c.push(e),f+=n+i):f+n+s<=32768&&(c.push(r),f+=n+s)}return c}export function resumeLane(n,{worktreeDir:i,fs:s=e}={}){if(!n)throw new Error("flowId required");if(!i)throw new Error("worktreeDir required");const o=r.join(i,".flow","artifacts");if(!s.existsSync(o))return[];const a=[];for(const e of s.readdirSync(o)){const n=r.join(o,e);let i;try{i=s.statSync(n)}catch{continue}if(i.isDirectory())for(const i of s.readdirSync(n)){const o=r.join(n,i);let c;try{c=s.readFileSync(o)}catch{continue}const l=t("sha256").update(c).digest("hex");a.push({taskKey:e,name:i,path:o,bytes:c.length,sha:l})}}return a}
@@ -1,42 +1 @@
1
- // Host-owned Flow revert lifecycle. Supabase broadcast channels use self:false,
2
- // so a reviewer on this bridge must call this handler directly; remote broadcasts
3
- // delegate to the same function.
4
- export async function executeFlowRevert({
5
- payload,
6
- bridgeName,
7
- lanes = [],
8
- prepareLane,
9
- rememberRedispatch,
10
- endLane,
11
- stopPreview,
12
- revert,
13
- broadcastReverted,
14
- wait = () => Promise.resolve(),
15
- onPrepError = () => {},
16
- } = {}) {
17
- if (!payload?.flowId || !payload?.taskKey) return { ok: false, ignored: 'invalid' }
18
- if (payload.host && payload.host !== bridgeName) return { ok: false, ignored: 'wrong-host' }
19
- let killed = false
20
- for (const [laneId, entry] of lanes) {
21
- if (entry?.flowSessionId !== payload.flowId || entry?.flowTaskKey !== payload.taskKey || entry?.flowDone) continue
22
- try {
23
- const prepared = prepareLane?.(entry, payload)
24
- if (prepared) rememberRedispatch?.(prepared, entry, payload)
25
- } catch (error) { onPrepError(error, entry, payload) }
26
- entry.flowDone = true
27
- endLane?.(laneId, entry)
28
- stopPreview?.(payload.flowId, laneId)
29
- killed = true
30
- }
31
- if (killed) await wait()
32
- const reverted = await revert(payload)
33
- const result = {
34
- ok: true,
35
- flowId: payload.flowId,
36
- taskKey: payload.taskKey,
37
- reviewTaskKey: payload.reviewTaskKey || null,
38
- branch: reverted?.branch || null,
39
- }
40
- await broadcastReverted(result)
41
- return result
42
- }
1
+ export async function executeFlowRevert({payload:e,bridgeName:o,lanes:r=[],prepareLane:a,rememberRedispatch:t,endLane:n,stopPreview:s,revert:i,broadcastReverted:l,wait:w=()=>Promise.resolve(),onPrepError:d=()=>{}}={}){if(!e?.flowId||!e?.taskKey)return{ok:!1,ignored:"invalid"};if(e.host&&e.host!==o)return{ok:!1,ignored:"wrong-host"};let f=!1;for(const[o,i]of r)if(i?.flowSessionId===e.flowId&&i?.flowTaskKey===e.taskKey&&!i?.flowDone){try{const o=a?.(i,e);o&&t?.(o,i,e)}catch(o){d(o,i,e)}i.flowDone=!0,n?.(o,i),s?.(e.flowId,o),f=!0}f&&await w();const c=await i(e),k={ok:!0,flowId:e.flowId,taskKey:e.taskKey,reviewTaskKey:e.reviewTaskKey||null,branch:c?.branch||null};return await l(k),k}
package/flow-models.mjs CHANGED
@@ -1,139 +1 @@
1
- // Runtime-aware model routing for Flow and ordinary room cascades.
2
- // Pure over the supplied environment/catalog so the bridge never sends a model slug
3
- // that the selected runtime did not advertise.
4
-
5
- const CLAUDE_TIERS = {
6
- scaffold: 'sonnet',
7
- feature: 'opus',
8
- fix: 'opus',
9
- review: 'opus',
10
- }
11
-
12
- const CODEX_SCAFFOLD = ['gpt-5.6-luna', 'gpt-5.4-mini', 'gpt-5.3-codex-spark']
13
- const CODEX_BALANCED = ['gpt-5.6-terra', 'gpt-5.4']
14
- const HERMES_REVIEW = ['nous:anthropic/claude-sonnet-4.6', 'nous:anthropic/claude-sonnet-4.5', 'nous:anthropic/claude-sonnet-4']
15
- const HERMES_MODEL_ALIASES = Object.freeze({
16
- fable: 'nous:anthropic/claude-fable-5',
17
- })
18
-
19
- export function normalizeFlowRuntime(runtime, fallback = null) {
20
- if (runtime === 'claude' || runtime === 'codex' || runtime === 'hermes') return runtime
21
- return fallback === 'claude' || fallback === 'codex' || fallback === 'hermes' ? fallback : null
22
- }
23
-
24
- export function modelCatalogValues(catalog = []) {
25
- return new Set((Array.isArray(catalog) ? catalog : []).map((model) => (
26
- typeof model === 'string' ? model : model?.value || model?.slug
27
- )).filter(Boolean))
28
- }
29
-
30
- const exactHermesModelId = (value) => /^[A-Za-z0-9._-]+:[A-Za-z0-9._-]+\/[A-Za-z0-9._:/-]+$/.test(value)
31
- const exactNousModelId = (value) => /^nous:/i.test(value) && exactHermesModelId(value)
32
-
33
- const expandHermesModelAlias = (value) => {
34
- const named = HERMES_MODEL_ALIASES[value.toLowerCase()]
35
- if (named) return named
36
- if (/^glm[-_.]/i.test(value)) return `nous:z-ai/${value.toLowerCase()}`
37
- const shorthand = value.match(/^([^:/]+):([^/]+)$/)
38
- if (!shorthand) return value
39
- const provider = shorthand[1].toLowerCase() === 'zai' ? 'z-ai' : shorthand[1].toLowerCase()
40
- return `nous:${provider}/${shorthand[2]}`
41
- }
42
-
43
- /**
44
- * Resolve a Hermes model for any bridge opener. A Hermes parent has a live ACP
45
- * catalog and remains strict. Other runtimes may use a full id or unambiguous
46
- * shorthand; the child session acknowledges session/set_model before inference.
47
- * Omitting a model intentionally selects the isolated Hermes profile default.
48
- */
49
- export function resolveHermesOpenModel(model, catalog = [], { strictCatalog = false } = {}) {
50
- const requested = String(model || '').trim()
51
- if (!requested) return { ok: true, model: undefined, validatedBy: 'default' }
52
- const values = [...modelCatalogValues(catalog)]
53
- if (values.includes(requested) && exactNousModelId(requested)) return { ok: true, model: requested, validatedBy: 'catalog' }
54
-
55
- const expanded = expandHermesModelAlias(requested)
56
- const normalized = (value) => String(value).toLowerCase().replace(/[^a-z0-9]+/g, '')
57
- const requestedKey = normalized(expanded).replace(/^nous/, '')
58
- const aliases = values.filter((value) => normalized(value).replace(/^nous/, '') === requestedKey)
59
- if (aliases.length === 1 && exactNousModelId(aliases[0])) return { ok: true, model: aliases[0], validatedBy: 'catalog' }
60
- if (strictCatalog && values.length) return { ok: false, error: `Could not open a Hermes terminal on ${JSON.stringify(requested)} — that model is not in this session's ACP catalog.` }
61
- if (!exactNousModelId(expanded)) return { ok: false, error: `Could not resolve Hermes model ${JSON.stringify(requested)}. ThinkPool Hermes only routes through Nous Portal; use a full ACP model ID such as "nous:z-ai/glm-5.2".` }
62
- return { ok: true, model: expanded, validatedBy: 'child-runtime' }
63
- }
64
-
65
- function firstVisible(candidates, catalog) {
66
- const visible = modelCatalogValues(catalog)
67
- for (const candidate of candidates) if (visible.has(candidate)) return candidate
68
- if (candidates === CODEX_SCAFFOLD) {
69
- for (const model of visible) if (/spark/i.test(model)) return model
70
- }
71
- return undefined
72
- }
73
-
74
- function firstVisibleMatching(catalog, patterns) {
75
- const visible = modelCatalogValues(catalog)
76
- for (const pattern of patterns) for (const model of visible) if (pattern.test(model)) return model
77
- return undefined
78
- }
79
-
80
- export function flowLaneModelFor({ sliceType, runtime = 'claude', catalog = [], env = process.env } = {}) {
81
- if (normalizeFlowRuntime(runtime, 'claude') === 'codex') {
82
- const override = env.TP_FLOW_CODEX_LANE_MODEL
83
- if (override === 'inherit') return undefined
84
- if (override) return modelCatalogValues(catalog).has(override) ? override : undefined
85
- return firstVisible(sliceType === 'scaffold' ? CODEX_SCAFFOLD : CODEX_BALANCED, catalog)
86
- }
87
- if (normalizeFlowRuntime(runtime, 'claude') === 'hermes') {
88
- const override = env.TP_FLOW_HERMES_LANE_MODEL
89
- const visible = modelCatalogValues(catalog)
90
- if (override === 'inherit') return undefined
91
- if (override) return visible.has(override) ? override : undefined
92
- if (sliceType === 'review') return firstVisible(HERMES_REVIEW, catalog)
93
- || firstVisibleMatching(catalog, [/terra/i, /sonnet/i, /balanced/i])
94
- if (sliceType === 'scaffold') return firstVisibleMatching(catalog, [/(?:luna|mini)/i])
95
- return firstVisibleMatching(catalog, [/terra/i, /(?:luna|mini)/i])
96
- }
97
- const override = env.TP_FLOW_CLAUDE_LANE_MODEL || env.TP_FLOW_LANE_MODEL
98
- if (override === 'inherit') return undefined
99
- if (override) return override
100
- return CLAUDE_TIERS[sliceType] || 'opus'
101
- }
102
-
103
- export function flowConductorModelFor({ runtime = 'claude', originModel, catalog = [], env = process.env } = {}) {
104
- if (normalizeFlowRuntime(runtime, 'claude') === 'codex') {
105
- const override = env.TP_FLOW_CODEX_CONDUCTOR_MODEL
106
- if (override === 'inherit') return undefined
107
- const visible = modelCatalogValues(catalog)
108
- if (override) return visible.has(override) ? override : undefined
109
- return originModel && visible.has(originModel) ? originModel : undefined
110
- }
111
- if (normalizeFlowRuntime(runtime, 'claude') === 'hermes') {
112
- const override = env.TP_FLOW_HERMES_CONDUCTOR_MODEL
113
- const visible = modelCatalogValues(catalog)
114
- if (override === 'inherit') return undefined
115
- if (override) return visible.has(override) ? override : undefined
116
- return originModel && visible.has(originModel) ? originModel : undefined
117
- }
118
- const override = env.TP_FLOW_CLAUDE_CONDUCTOR_MODEL || env.TP_FLOW_CONDUCTOR_MODEL
119
- if (override === 'inherit') return undefined
120
- // Claude non-regression: the conductor historically inherited the host default,
121
- // not the invoking lane's selected model.
122
- return override || undefined
123
- }
124
-
125
- export function spawnedLaneModelFor({ explicitModel, sliceType, runtime = 'claude', catalog = [], env = process.env } = {}) {
126
- return explicitModel || flowLaneModelFor({ sliceType, runtime, catalog, env })
127
- }
128
-
129
- export function resolveCodexModel(requested, catalog = []) {
130
- if (!requested) return undefined
131
- return modelCatalogValues(catalog).has(requested) ? requested : undefined
132
- }
133
-
134
- export function assertRuntimeModelCompatible({ runtime = 'claude', provider = null, model } = {}) {
135
- if (runtime === 'claude' && (!provider || provider === 'anthropic') && /^gpt-/i.test(String(model || ''))) {
136
- throw new Error(`Codex model ${JSON.stringify(model)} cannot run on the built-in Claude provider`)
137
- }
138
- return true
139
- }
1
+ const e={scaffold:"sonnet",feature:"opus",fix:"opus",review:"opus"},o=["gpt-5.6-luna","gpt-5.4-mini","gpt-5.3-codex-spark"],t=["gpt-5.6-terra","gpt-5.4"],n=["nous:anthropic/claude-sonnet-4.6","nous:anthropic/claude-sonnet-4.5","nous:anthropic/claude-sonnet-4"],r=Object.freeze({fable:"nous:anthropic/claude-fable-5"});export function normalizeFlowRuntime(e,o=null){return"claude"===e||"codex"===e||"hermes"===e?e:"claude"===o||"codex"===o||"hermes"===o?o:null}export function modelCatalogValues(e=[]){return new Set((Array.isArray(e)?e:[]).map(e=>"string"==typeof e?e:e?.value||e?.slug).filter(Boolean))}const a=e=>/^nous:/i.test(e)&&(e=>/^[A-Za-z0-9._-]+:[A-Za-z0-9._-]+\/[A-Za-z0-9._:/-]+$/.test(e))(e);export function resolveHermesOpenModel(e,o=[],{strictCatalog:t=!1}={}){const n=String(e||"").trim();if(!n)return{ok:!0,model:void 0,validatedBy:"default"};const i=[...modelCatalogValues(o)];if(i.includes(n)&&a(n))return{ok:!0,model:n,validatedBy:"catalog"};const l=(e=>{const o=r[e.toLowerCase()];if(o)return o;if(/^glm[-_.]/i.test(e))return`nous:z-ai/${e.toLowerCase()}`;const t=e.match(/^([^:/]+):([^/]+)$/);return t?`nous:${"zai"===t[1].toLowerCase()?"z-ai":t[1].toLowerCase()}/${t[2]}`:e})(n),s=e=>String(e).toLowerCase().replace(/[^a-z0-9]+/g,""),u=s(l).replace(/^nous/,""),c=i.filter(e=>s(e).replace(/^nous/,"")===u);return 1===c.length&&a(c[0])?{ok:!0,model:c[0],validatedBy:"catalog"}:t&&i.length?{ok:!1,error:`Could not open a Hermes terminal on ${JSON.stringify(n)} — that model is not in this session's ACP catalog.`}:a(l)?{ok:!0,model:l,validatedBy:"child-runtime"}:{ok:!1,error:`Could not resolve Hermes model ${JSON.stringify(n)}. ThinkPool Hermes only routes through Nous Portal; use a full ACP model ID such as "nous:z-ai/glm-5.2".`}}function i(e,t){const n=modelCatalogValues(t);for(const o of e)if(n.has(o))return o;if(e===o)for(const e of n)if(/spark/i.test(e))return e}function l(e,o){const t=modelCatalogValues(e);for(const e of o)for(const o of t)if(e.test(o))return o}export function flowLaneModelFor({sliceType:r,runtime:a="claude",catalog:s=[],env:u=process.env}={}){if("codex"===normalizeFlowRuntime(a,"claude")){const e=u.TP_FLOW_CODEX_LANE_MODEL;if("inherit"===e)return;return e?modelCatalogValues(s).has(e)?e:void 0:i("scaffold"===r?o:t,s)}if("hermes"===normalizeFlowRuntime(a,"claude")){const e=u.TP_FLOW_HERMES_LANE_MODEL,o=modelCatalogValues(s);if("inherit"===e)return;return e?o.has(e)?e:void 0:"review"===r?i(n,s)||l(s,[/terra/i,/sonnet/i,/balanced/i]):l(s,"scaffold"===r?[/(?:luna|mini)/i]:[/terra/i,/(?:luna|mini)/i])}const c=u.TP_FLOW_CLAUDE_LANE_MODEL||u.TP_FLOW_LANE_MODEL;if("inherit"!==c)return c||e[r]||"opus"}export function flowConductorModelFor({runtime:e="claude",originModel:o,catalog:t=[],env:n=process.env}={}){if("codex"===normalizeFlowRuntime(e,"claude")){const e=n.TP_FLOW_CODEX_CONDUCTOR_MODEL;if("inherit"===e)return;const r=modelCatalogValues(t);return e?r.has(e)?e:void 0:o&&r.has(o)?o:void 0}if("hermes"===normalizeFlowRuntime(e,"claude")){const e=n.TP_FLOW_HERMES_CONDUCTOR_MODEL,r=modelCatalogValues(t);if("inherit"===e)return;return e?r.has(e)?e:void 0:o&&r.has(o)?o:void 0}const r=n.TP_FLOW_CLAUDE_CONDUCTOR_MODEL||n.TP_FLOW_CONDUCTOR_MODEL;if("inherit"!==r)return r||void 0}export function spawnedLaneModelFor({explicitModel:e,sliceType:o,runtime:t="claude",catalog:n=[],env:r=process.env}={}){return e||flowLaneModelFor({sliceType:o,runtime:t,catalog:n,env:r})}export function resolveCodexModel(e,o=[]){if(e)return modelCatalogValues(o).has(e)?e:void 0}export function assertRuntimeModelCompatible({runtime:e="claude",provider:o=null,model:t}={}){if("claude"===e&&(!o||"anthropic"===o)&&/^gpt-/i.test(String(t||"")))throw new Error(`Codex model ${JSON.stringify(t)} cannot run on the built-in Claude provider`);return!0}