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-preview.mjs
CHANGED
|
@@ -1,148 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// Each Flow lane builds its slice in its own git worktree (flow-worktree.mjs).
|
|
4
|
-
// The room UI iframes a LIVE preview of that slice, so the worktree dir needs to be
|
|
5
|
-
// reachable over localhost. The MVP artifact is a self-contained index.html (no build
|
|
6
|
-
// step), so a static file server is the universal case — Node http + fs + path, no deps
|
|
7
|
-
// (this is published as the `thinkpool-pair` npm package; new deps are not on the table).
|
|
8
|
-
//
|
|
9
|
-
// Spec: docs/specs/2026-06-29-thinkpool-flow.md (Step 3).
|
|
10
|
-
|
|
11
|
-
import http from 'node:http'
|
|
12
|
-
import fs from 'node:fs'
|
|
13
|
-
import fsp from 'node:fs/promises'
|
|
14
|
-
import path from 'node:path'
|
|
15
|
-
|
|
16
|
-
// Module-level registry of live previews so cleanup (flow cancel / shutdown) can sweep
|
|
17
|
-
// every server without each caller tracking handles. id → { server, url, stop }.
|
|
18
|
-
export const previews = new Map()
|
|
19
|
-
|
|
20
|
-
// Minimal extension → MIME map. Covers the self-contained-app surface + common assets.
|
|
21
|
-
// Unknown extensions fall back to application/octet-stream (the browser downloads it).
|
|
22
|
-
const MIME = {
|
|
23
|
-
'.html': 'text/html; charset=utf-8',
|
|
24
|
-
'.htm': 'text/html; charset=utf-8',
|
|
25
|
-
'.js': 'text/javascript; charset=utf-8',
|
|
26
|
-
'.mjs': 'text/javascript; charset=utf-8',
|
|
27
|
-
'.css': 'text/css; charset=utf-8',
|
|
28
|
-
'.json': 'application/json; charset=utf-8',
|
|
29
|
-
'.png': 'image/png',
|
|
30
|
-
'.jpg': 'image/jpeg',
|
|
31
|
-
'.jpeg': 'image/jpeg',
|
|
32
|
-
'.svg': 'image/svg+xml',
|
|
33
|
-
'.wav': 'audio/wav',
|
|
34
|
-
'.mp3': 'audio/mpeg',
|
|
35
|
-
'.ico': 'image/x-icon'
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function contentType (filePath) {
|
|
39
|
-
return MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream'
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Map a request URL path to a real file under `root`, or null if it would escape `root`
|
|
43
|
-
// (path traversal). Decode + strip query/hash, normalize, then verify the resolved path
|
|
44
|
-
// is still inside root — the relative path between them must not climb out with `..`.
|
|
45
|
-
function resolveUnderRoot (root, urlPath) {
|
|
46
|
-
let pathname
|
|
47
|
-
try {
|
|
48
|
-
pathname = decodeURIComponent(new URL(urlPath, 'http://x').pathname)
|
|
49
|
-
} catch {
|
|
50
|
-
return null // malformed percent-encoding
|
|
51
|
-
}
|
|
52
|
-
const resolved = path.resolve(root, '.' + pathname) // leading '.' keeps it relative to root
|
|
53
|
-
const rel = path.relative(root, resolved)
|
|
54
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) return null
|
|
55
|
-
return resolved
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Is this path a regular file we can read? (false for dirs / missing / errors)
|
|
59
|
-
async function isFile (p) {
|
|
60
|
-
try {
|
|
61
|
-
return (await fsp.stat(p)).isFile()
|
|
62
|
-
} catch {
|
|
63
|
-
return false
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function send (res, status, type, body) {
|
|
68
|
-
res.writeHead(status, {
|
|
69
|
-
'Content-Type': type,
|
|
70
|
-
'Access-Control-Allow-Origin': '*' // local preview only; deliberately NO COOP/COEP
|
|
71
|
-
})
|
|
72
|
-
res.end(body)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Build the request handler bound to a worktree dir. Routing:
|
|
76
|
-
// - traversal outside dir → 403
|
|
77
|
-
// - existing file → that file
|
|
78
|
-
// - '/' or a directory → index.html
|
|
79
|
-
// - unknown path → index.html if present (SPA fallback), else 404
|
|
80
|
-
function makeHandler (dir) {
|
|
81
|
-
const indexPath = path.join(dir, 'index.html')
|
|
82
|
-
return async (req, res) => {
|
|
83
|
-
const resolved = resolveUnderRoot(dir, req.url || '/')
|
|
84
|
-
if (resolved === null) return send(res, 403, 'text/plain; charset=utf-8', 'Forbidden')
|
|
85
|
-
const relative = path.relative(dir, resolved)
|
|
86
|
-
if (relative.split(path.sep)[0] === '.thinkpool-design') {
|
|
87
|
-
return send(res, 404, 'text/plain; charset=utf-8', 'Not found')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// A concrete file hit (and still a real file, not a dir) → serve it.
|
|
91
|
-
if (await isFile(resolved)) {
|
|
92
|
-
try {
|
|
93
|
-
return send(res, 200, contentType(resolved), await fsp.readFile(resolved))
|
|
94
|
-
} catch {
|
|
95
|
-
return send(res, 500, 'text/plain; charset=utf-8', 'Read error')
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Directory, root, or unknown path → fall back to index.html (SPA-friendly).
|
|
100
|
-
if (await isFile(indexPath)) {
|
|
101
|
-
try {
|
|
102
|
-
return send(res, 200, contentType(indexPath), await fsp.readFile(indexPath))
|
|
103
|
-
} catch {
|
|
104
|
-
return send(res, 500, 'text/plain; charset=utf-8', 'Read error')
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return send(res, 404, 'text/plain; charset=utf-8', 'Not found')
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Start a static preview server rooted at `dir`. port 0 → OS assigns a free port; the
|
|
113
|
-
// actual bound port is read back from server.address(). Resolves once listening.
|
|
114
|
-
// Returns { url, port, stop } — stop() is idempotent and async-safe.
|
|
115
|
-
export function startPreview ({ dir, port = 0, host = '127.0.0.1', id } = {}) {
|
|
116
|
-
if (!dir) throw new Error('startPreview: dir is required')
|
|
117
|
-
|
|
118
|
-
const server = http.createServer(makeHandler(dir))
|
|
119
|
-
|
|
120
|
-
// Idempotent stop: close once, no-op on subsequent calls; resolve when fully closed.
|
|
121
|
-
let stopped = false
|
|
122
|
-
const stop = () =>
|
|
123
|
-
new Promise((resolve) => {
|
|
124
|
-
if (id !== undefined) previews.delete(id)
|
|
125
|
-
if (stopped) return resolve()
|
|
126
|
-
stopped = true
|
|
127
|
-
server.close(() => resolve())
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
return new Promise((resolve, reject) => {
|
|
131
|
-
server.once('error', reject)
|
|
132
|
-
server.listen(port, host, () => {
|
|
133
|
-
server.removeListener('error', reject)
|
|
134
|
-
const bound = server.address().port
|
|
135
|
-
const url = `http://${host}:${bound}/`
|
|
136
|
-
const handle = { server, url, stop, dir }
|
|
137
|
-
if (id !== undefined) previews.set(id, handle)
|
|
138
|
-
resolve({ url, port: bound, stop })
|
|
139
|
-
})
|
|
140
|
-
})
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Stop + deregister every live preview. Best-effort; awaits all closes.
|
|
144
|
-
export async function stopAllPreviews () {
|
|
145
|
-
const handles = [...previews.values()]
|
|
146
|
-
previews.clear()
|
|
147
|
-
await Promise.all(handles.map((h) => h.stop()))
|
|
148
|
-
}
|
|
1
|
+
import t from"node:http";import e from"node:fs";import r from"node:fs/promises";import n from"node:path";export const previews=new Map;const i={".html":"text/html; charset=utf-8",".htm":"text/html; charset=utf-8",".js":"text/javascript; charset=utf-8",".mjs":"text/javascript; charset=utf-8",".css":"text/css; charset=utf-8",".json":"application/json; charset=utf-8",".png":"image/png",".jpg":"image/jpeg",".jpeg":"image/jpeg",".svg":"image/svg+xml",".wav":"audio/wav",".mp3":"audio/mpeg",".ico":"image/x-icon"};function o(t){return i[n.extname(t).toLowerCase()]||"application/octet-stream"}async function s(t){try{return(await r.stat(t)).isFile()}catch{return!1}}async function a(t,e,r,n){t.writeHead(e,{"Content-Type":r,"Access-Control-Allow-Origin":"*"}),t.end(n)}export function startPreview({dir:e,port:i=0,host:c="127.0.0.1",id:p}={}){if(!e)throw new Error("startPreview: dir is required");const u=t.createServer(function(t){const e=n.join(t,"index.html");return async(i,c)=>{const p=function(t,e){let r;try{r=decodeURIComponent(new URL(e,"http://x").pathname)}catch{return null}const i=n.resolve(t,"."+r),o=n.relative(t,i);return o.startsWith("..")||n.isAbsolute(o)?null:i}(t,i.url||"/");if(null===p)return a(c,403,"text/plain; charset=utf-8","Forbidden");if(".thinkpool-design"===n.relative(t,p).split(n.sep)[0])return a(c,404,"text/plain; charset=utf-8","Not found");if(await s(p))try{return a(c,200,o(p),await r.readFile(p))}catch{return a(c,500,"text/plain; charset=utf-8","Read error")}if(await s(e))try{return a(c,200,o(e),await r.readFile(e))}catch{return a(c,500,"text/plain; charset=utf-8","Read error")}return a(c,404,"text/plain; charset=utf-8","Not found")}}(e));let l=!1;const f=()=>new Promise(t=>{if(void 0!==p&&previews.delete(p),l)return t();l=!0,u.close(()=>t())});return new Promise((t,r)=>{u.once("error",r),u.listen(i,c,()=>{u.removeListener("error",r);const n=u.address().port,i=`http://${c}:${n}/`,o={server:u,url:i,stop:f,dir:e};void 0!==p&&previews.set(p,o),t({url:i,port:n,stop:f})})})}export async function stopAllPreviews(){const t=[...previews.values()];previews.clear(),await Promise.all(t.map(t=>t.stop()))}
|
package/flow-receipt.mjs
CHANGED
|
@@ -1,122 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// from a private reviewer lane into a shared transcript, so this module only
|
|
3
|
-
// admits the evidence a pair can act on. Prompts, environment, paths, logs, and
|
|
4
|
-
// hidden reasoning never have a field in the public shape.
|
|
5
|
-
|
|
6
|
-
import { normalizeFlowScopeEvidence } from './flow-scope-evidence.mjs'
|
|
7
|
-
|
|
8
|
-
export const FLOW_REVIEW_RECEIPT_KIND = 'flow-review-receipt'
|
|
9
|
-
|
|
10
|
-
const MAX_TASK_KEY = 120
|
|
11
|
-
const MAX_LINE = 180
|
|
12
|
-
const MAX_LINES = 4
|
|
13
|
-
const OUTCOMES = new Set(['pass', 'reject', 'held'])
|
|
14
|
-
const DIGEST = /^[a-f0-9]{64}$/
|
|
15
|
-
const SECRET_VALUE = /(?:sk[_-](?:proj[_-])?[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|AIza[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9_-]{8,}|github_pat_[a-z0-9_]{20,}|glpat-[a-z0-9_-]{8,}|x(?:ox[baprsce]|app)-[a-z0-9_-]{8,}|(?:AKIA|ASIA)[0-9A-Z]{16}|sbp_[a-z0-9_-]{20,}|sb_secret_[a-z0-9_-]{8,}|npm_[a-z0-9]{24,}|(?:sk|rk)_(?:live|test)_[a-z0-9]{8,}|whsec_[a-z0-9]{8,}|eyJ[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|hooks\.slack\.com\/services\/[a-z0-9/_-]{8,})/i
|
|
16
|
-
const SECRET_OR_PRIVATE = /(?:api[_ -]?key|access[_ -]?token|\btoken\s*[=:]|auth(?:orization)?|bearer\s+|secret|password|private[_ -]?key|system\s+prompt|raw\s+prompt|hidden\s+reasoning|chain[ -]of[ -]thought|\benv(?:ironment)?\b|\.env\b)/i
|
|
17
|
-
const HOST_PATH = /(?:^~[\\/]|^\.?[\\/]|(?:^|\s)\/[\w.-]+|[A-Za-z]:\\|\\\\|(?:^|[\\/])(?:Users|home|var|tmp|private|workspace)(?:[\\/]|$)|\.thinkpool[\\/])/i
|
|
18
|
-
const PATH_TRAVERSAL = /(?:^|[^a-z0-9.])\.\.(?:[\\/]|$)/i
|
|
19
|
-
|
|
20
|
-
function cleanText(value, max) {
|
|
21
|
-
if (typeof value !== 'string' || value.length === 0 || value.length > max) return null
|
|
22
|
-
if (/\r|\n/.test(value) || SECRET_VALUE.test(value) || SECRET_OR_PRIVATE.test(value) || HOST_PATH.test(value) || PATH_TRAVERSAL.test(value)) return null
|
|
23
|
-
const text = value.replace(/\s+/g, ' ').trim()
|
|
24
|
-
return text && text.length <= max ? text : null
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function cleanLines(value) {
|
|
28
|
-
if (!Array.isArray(value)) return []
|
|
29
|
-
const out = []
|
|
30
|
-
for (const line of value) {
|
|
31
|
-
const safe = cleanText(line, MAX_LINE)
|
|
32
|
-
if (safe && !out.includes(safe)) out.push(safe)
|
|
33
|
-
if (out.length === MAX_LINES) break
|
|
34
|
-
}
|
|
35
|
-
return out
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function cleanTaskKey(value) {
|
|
39
|
-
const taskKey = cleanText(value, MAX_TASK_KEY)
|
|
40
|
-
if (!taskKey || !/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(taskKey) || taskKey.includes('..')) return null
|
|
41
|
-
return taskKey
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function cleanSha(value) {
|
|
45
|
-
if (typeof value !== 'string') return null
|
|
46
|
-
const sha = value.trim().toLowerCase()
|
|
47
|
-
return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(sha) ? sha : null
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function cleanRounds(value) {
|
|
51
|
-
return Number.isInteger(value) && value >= 1 && value <= 99 ? value : null
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function cleanDigest(value) {
|
|
55
|
-
if (typeof value !== 'string') return null
|
|
56
|
-
const digest = value.trim().toLowerCase()
|
|
57
|
-
return DIGEST.test(digest) ? digest : null
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Build the only wire shape the renderer accepts. Optional fields are omitted
|
|
61
|
-
// rather than represented as null, making absence honest (notably deployment).
|
|
62
|
-
export function createFlowReviewReceipt(input = {}) {
|
|
63
|
-
const taskKey = cleanTaskKey(input.taskKey)
|
|
64
|
-
const outcome = typeof input.outcome === 'string' ? input.outcome.toLowerCase() : ''
|
|
65
|
-
const rounds = cleanRounds(input.rounds)
|
|
66
|
-
if (!taskKey || !OUTCOMES.has(outcome) || rounds == null) {
|
|
67
|
-
throw new TypeError('Invalid flow review receipt identity')
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const receipt = {
|
|
71
|
-
kind: FLOW_REVIEW_RECEIPT_KIND,
|
|
72
|
-
taskKey,
|
|
73
|
-
outcome,
|
|
74
|
-
rounds,
|
|
75
|
-
checks: cleanLines(input.checks),
|
|
76
|
-
reasons: cleanLines(input.reasons),
|
|
77
|
-
}
|
|
78
|
-
const candidateSha = cleanSha(input.candidateSha)
|
|
79
|
-
const acceptanceDigest = cleanDigest(input.acceptanceDigest)
|
|
80
|
-
const scope = normalizeFlowScopeEvidence(input.scope)
|
|
81
|
-
// Presence, not truthiness, is intentional: deployments are never inferred
|
|
82
|
-
// from a pass/reject outcome or from a candidate SHA.
|
|
83
|
-
const deployment = Object.hasOwn(input, 'deployment') ? cleanText(input.deployment, MAX_LINE) : null
|
|
84
|
-
if (candidateSha) receipt.candidateSha = candidateSha
|
|
85
|
-
if (acceptanceDigest) receipt.acceptanceDigest = acceptanceDigest
|
|
86
|
-
receipt.verified = Boolean(candidateSha && acceptanceDigest)
|
|
87
|
-
if (outcome === 'pass' && !receipt.verified) {
|
|
88
|
-
throw new TypeError('Passing flow review receipt requires an exact candidate and acceptance digest')
|
|
89
|
-
}
|
|
90
|
-
if (scope) receipt.scope = scope
|
|
91
|
-
if (outcome === 'pass' && !scope?.held) {
|
|
92
|
-
throw new TypeError('Passing flow review receipt requires a held scope footprint')
|
|
93
|
-
}
|
|
94
|
-
if (deployment) receipt.deployment = deployment
|
|
95
|
-
return receipt
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function isFlowReviewReceipt(value) {
|
|
99
|
-
try {
|
|
100
|
-
const receipt = createFlowReviewReceipt(value)
|
|
101
|
-
return receipt.kind === value?.kind
|
|
102
|
-
} catch {
|
|
103
|
-
return false
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// A review lane may receive the same terminal submission twice (tool retry,
|
|
108
|
-
// double click, or concurrent provider delivery). Serialize the whole verdict
|
|
109
|
-
// transaction—not just receipt emission—so an awaited revert cannot run twice.
|
|
110
|
-
export function createFlowReviewSingleFlight() {
|
|
111
|
-
let pending = null
|
|
112
|
-
return async function runFlowReviewVerdict(operation) {
|
|
113
|
-
if (pending) return pending
|
|
114
|
-
const current = (async () => operation())()
|
|
115
|
-
pending = current
|
|
116
|
-
try {
|
|
117
|
-
return await current
|
|
118
|
-
} finally {
|
|
119
|
-
if (pending === current) pending = null
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
1
|
+
import{normalizeFlowScopeEvidence as e}from"./flow-scope-evidence.mjs";export const FLOW_REVIEW_RECEIPT_KIND="flow-review-receipt";const t=new Set(["pass","reject","held"]),r=/^[a-f0-9]{64}$/,n=/(?:sk[_-](?:proj[_-])?[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|AIza[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9_-]{8,}|github_pat_[a-z0-9_]{20,}|glpat-[a-z0-9_-]{8,}|x(?:ox[baprsce]|app)-[a-z0-9_-]{8,}|(?:AKIA|ASIA)[0-9A-Z]{16}|sbp_[a-z0-9_-]{20,}|sb_secret_[a-z0-9_-]{8,}|npm_[a-z0-9]{24,}|(?:sk|rk)_(?:live|test)_[a-z0-9]{8,}|whsec_[a-z0-9]{8,}|eyJ[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|hooks\.slack\.com\/services\/[a-z0-9/_-]{8,})/i,s=/(?:api[_ -]?key|access[_ -]?token|\btoken\s*[=:]|auth(?:orization)?|bearer\s+|secret|password|private[_ -]?key|system\s+prompt|raw\s+prompt|hidden\s+reasoning|chain[ -]of[ -]thought|\benv(?:ironment)?\b|\.env\b)/i,o=/(?:^~[\\/]|^\.?[\\/]|(?:^|\s)\/[\w.-]+|[A-Za-z]:\\|\\\\|(?:^|[\\/])(?:Users|home|var|tmp|private|workspace)(?:[\\/]|$)|\.thinkpool[\\/])/i,a=/(?:^|[^a-z0-9.])\.\.(?:[\\/]|$)/i;function i(e,t){if("string"!=typeof e||0===e.length||e.length>t)return null;if(/\r|\n/.test(e)||n.test(e)||s.test(e)||o.test(e)||a.test(e))return null;const r=e.replace(/\s+/g," ").trim();return r&&r.length<=t?r:null}function c(e){if(!Array.isArray(e))return[];const t=[];for(const r of e){const e=i(r,180);if(e&&!t.includes(e)&&t.push(e),4===t.length)break}return t}export function createFlowReviewReceipt(n={}){const s=function(e){const t=i(e,120);return t&&/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(t)&&!t.includes("..")?t:null}(n.taskKey),o="string"==typeof n.outcome?n.outcome.toLowerCase():"",a=(l=n.rounds,Number.isInteger(l)&&l>=1&&l<=99?l:null);var l;if(!s||!t.has(o)||null==a)throw new TypeError("Invalid flow review receipt identity");const p={kind:"flow-review-receipt",taskKey:s,outcome:o,rounds:a,checks:c(n.checks),reasons:c(n.reasons)},u=function(e){if("string"!=typeof e)return null;const t=e.trim().toLowerCase();return/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(t)?t:null}(n.candidateSha),f=function(e){if("string"!=typeof e)return null;const t=e.trim().toLowerCase();return r.test(t)?t:null}(n.acceptanceDigest),w=e(n.scope),_=Object.hasOwn(n,"deployment")?i(n.deployment,180):null;if(u&&(p.candidateSha=u),f&&(p.acceptanceDigest=f),p.verified=Boolean(u&&f),"pass"===o&&!p.verified)throw new TypeError("Passing flow review receipt requires an exact candidate and acceptance digest");if(w&&(p.scope=w),"pass"===o&&!w?.held)throw new TypeError("Passing flow review receipt requires a held scope footprint");return _&&(p.deployment=_),p}export function isFlowReviewReceipt(e){try{return createFlowReviewReceipt(e).kind===e?.kind}catch{return!1}}export function createFlowReviewSingleFlight(){let e=null;return async function(t){if(e)return e;const r=(async()=>t())();e=r;try{return await r}finally{e===r&&(e=null)}}}
|
package/flow-redispatch.mjs
CHANGED
|
@@ -1,72 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// When a lane is force-killed mid-tool-call (budget kill-switch, review reject, restart)
|
|
4
|
-
// and re-dispatched, three things must hold before the new lane replays the old work:
|
|
5
|
-
//
|
|
6
|
-
// (a) HEALED TRANSCRIPT — a lane killed mid-tool-call leaves an assistant message with a
|
|
7
|
-
// tool_use block that has NO matching tool_result. On the next SDK replay Anthropic
|
|
8
|
-
// 400s ("tool_use ids were found without tool_result blocks"). sanitizeSession's
|
|
9
|
-
// Heal 3 (bridge/transcript-sanitize.mjs — DONE, do not rewrite) injects a synthetic
|
|
10
|
-
// tool_result placeholder. We CONSUME it here: run it on the dying lane's transcript
|
|
11
|
-
// so a resume is clean. Idempotent — a clean transcript is a no-op.
|
|
12
|
-
//
|
|
13
|
-
// (d) REVERT TARGET PRESERVED — revertLane deletes the lane's branch (`git branch -D`), which
|
|
14
|
-
// destroys the commit the killed lane made. The revert target (commit_sha, recorded
|
|
15
|
-
// room-side on flow_tasks) must survive the re-dispatch so a later reviewer still knows
|
|
16
|
-
// what to roll back to. We carry it in the pending-redispatch record.
|
|
17
|
-
//
|
|
18
|
-
// H41 (realtime collision) — NOT this module's concern, by construction. A re-dispatched lane
|
|
19
|
-
// opens NO realtime channel of its own: the bridge opens exactly two channels ONCE at startup
|
|
20
|
-
// (`tpcode:<room>` presence + `tpflow:<room>` presence-free broadcast), and a re-dispatched
|
|
21
|
-
// lane reuses the existing room-wide `tpflow:<room>` broadcast. With no per-lane subscribe there
|
|
22
|
-
// is no second .subscribe() to race a presence callback (feedback_no_duplicate_realtime_channel,
|
|
23
|
-
// Flow #134/#136), so no duplicate-subscribe collision is possible. Earlier drafts computed a
|
|
24
|
-
// per-lane `flowRedispatchTopic` — it was dead (never read by any subscribe) and was removed so
|
|
25
|
-
// a future reader doesn't mistake it for live realtime coverage.
|
|
26
|
-
//
|
|
27
|
-
// DBOS-idempotent: re-running prepareRedispatch on an already-clean lane heals nothing and
|
|
28
|
-
// re-dispatch of a lane with no pending record resumes nothing — both are no-ops.
|
|
29
|
-
//
|
|
30
|
-
// Pure over an injected `sanitize` so it's unit-testable without touching a real transcript.
|
|
31
|
-
// Spec: docs/specs/2026-06-30-flow-build-s4-clean-redispatch.md.
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Prepare a killed lane for clean re-dispatch. Heals its transcript (so a resume can't 400 on
|
|
35
|
-
* a dangling tool_use) and returns everything the re-dispatch needs to resume cleanly.
|
|
36
|
-
*
|
|
37
|
-
* @param {object} o
|
|
38
|
-
* @param {object} o.lane the killed lane's session entry (needs cwd, session.sessionId, commitSha/flowReviewTarget)
|
|
39
|
-
* @param {function} o.sanitize (cwd, sessionId) => { blocks } — inject sanitizeSession here (the Heal-3 consumer)
|
|
40
|
-
* @returns {{ resumeSessionId: string|null, revertTarget: string|null, healed: number, cwd: string|null }}
|
|
41
|
-
*/
|
|
42
|
-
export function prepareRedispatch ({ lane, sanitize }) {
|
|
43
|
-
const cwd = lane?.cwd || null
|
|
44
|
-
const resumeSessionId = lane?.session?.sessionId || lane?.sessionId || null
|
|
45
|
-
// Revert target survives the branch deletion: prefer an explicitly recorded commit, else
|
|
46
|
-
// any commitSha the lane carried. null when the lane never committed (nothing to preserve).
|
|
47
|
-
const revertTarget = lane?.revertTarget ?? lane?.commitSha ?? null
|
|
48
|
-
|
|
49
|
-
let healed = 0
|
|
50
|
-
// Heal ONLY when there's a transcript to heal (a resumable session). No sessionId → the lane
|
|
51
|
-
// never got a live SDK session, so there is nothing to replay and nothing to heal (no-op).
|
|
52
|
-
if (resumeSessionId && typeof sanitize === 'function') {
|
|
53
|
-
try {
|
|
54
|
-
const r = sanitize(cwd, resumeSessionId)
|
|
55
|
-
healed = (r && typeof r.blocks === 'number') ? r.blocks : 0
|
|
56
|
-
} catch {
|
|
57
|
-
healed = 0 // a sanitize failure must never block re-dispatch — worst case, resume as before
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return { resumeSessionId, revertTarget, healed, cwd }
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* A pending-redispatch registry. When flow-revert kills a lane, it records the prepared
|
|
66
|
-
* re-dispatch (keyed by flowId+taskKey) so the next flow-dispatch wave resumes the healed
|
|
67
|
-
* session on a clean topic with the revert target intact. Idempotent: re-dispatching a task
|
|
68
|
-
* with no pending record (a first, never-killed dispatch) resolves to a fresh lane (no resume).
|
|
69
|
-
*/
|
|
70
|
-
export function redispatchKey (flowId, taskKey) {
|
|
71
|
-
return `${flowId}::${taskKey}`
|
|
72
|
-
}
|
|
1
|
+
export function prepareRedispatch({lane:e,sanitize:t}){const n=e?.cwd||null,s=e?.session?.sessionId||e?.sessionId||null,r=e?.revertTarget??e?.commitSha??null;let o=0;if(s&&"function"==typeof t)try{const e=t(n,s);o=e&&"number"==typeof e.blocks?e.blocks:0}catch{o=0}return{resumeSessionId:s,revertTarget:r,healed:o,cwd:n}}export function redispatchKey(e,t){return`${e}::${t}`}
|