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.
- package/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/flow-context-store.mjs
CHANGED
|
@@ -1,387 +1 @@
|
|
|
1
|
-
|
|
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}
|
package/flow-host-revert.mjs
CHANGED
|
@@ -1,42 +1 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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}
|