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,58 +1 @@
1
- function legacyQuestionAnswers (decision, questions) {
2
- if (typeof decision !== 'string' || !decision.startsWith('answer:')) return {}
3
- const text = decision.slice(7).trim()
4
- if (!text || !questions.length) return {}
5
- if (questions.length === 1) {
6
- const question = questions[0]
7
- const prefix = `${question.header || 'Q1'}: `
8
- const value = text.startsWith(prefix) ? text.slice(prefix.length) : text
9
- return { [String(question.id || 0)]: { answers: [value] } }
10
- }
11
- const answers = {}
12
- questions.forEach((question, index) => {
13
- const id = String(question.id || index)
14
- const marker = `${index ? '; ' : ''}${question.header || `Q${index + 1}`}: `
15
- const start = text.indexOf(marker)
16
- if (start < 0) return
17
- const valueStart = start + marker.length
18
- const nextQuestion = questions[index + 1]
19
- const nextMarker = nextQuestion ? `; ${nextQuestion.header || `Q${index + 2}`}: ` : null
20
- const end = nextMarker ? text.indexOf(nextMarker, valueStart) : -1
21
- const value = text.slice(valueStart, end < 0 ? undefined : end).trim()
22
- if (value) answers[id] = { answers: [value] }
23
- })
24
- return answers
25
- }
26
-
27
- // Codex and Hermes use different native tool protocols, but the room answer is
28
- // one contract. Keep filtering, multi-select preservation, and legacy fallback
29
- // in one pure adapter so the two runtimes cannot drift independently.
30
- export function questionAnswerResponse (result, questions = []) {
31
- const supplied = result && typeof result === 'object' ? result.answers : null
32
- const answers = {}
33
- if (supplied && typeof supplied === 'object') {
34
- const allowed = new Set(questions.map((question, index) => String(question?.id || index)))
35
- for (const [id, value] of Object.entries(supplied)) {
36
- if (!allowed.has(String(id)) || !Array.isArray(value?.answers)) continue
37
- const values = value.answers.map((answer) => String(answer)).filter(Boolean)
38
- if (values.length) answers[String(id)] = { answers: values }
39
- }
40
- }
41
- const responder = result?.responder && typeof result.responder === 'object'
42
- ? {
43
- id: typeof result.responder.id === 'string' ? result.responder.id : null,
44
- name: typeof result.responder.name === 'string' ? result.responder.name : null,
45
- }
46
- : null
47
- if (Object.keys(answers).length) return { answers, ...(responder ? { responder } : {}) }
48
- const decision = result && typeof result === 'object' ? result.decision : result
49
- return { answers: legacyQuestionAnswers(decision, questions), ...(responder ? { responder } : {}) }
50
- }
51
-
52
- export function hermesUserInputResponse (result, questions = []) {
53
- const decision = result && typeof result === 'object' ? result.decision : result
54
- return {
55
- status: String(decision || '').startsWith('answer:') ? 'answered' : decision === 'timeout' ? 'timed_out' : 'dismissed',
56
- ...questionAnswerResponse(result, questions),
57
- }
58
- }
1
+ function e(e,n){if("string"!=typeof e||!e.startsWith("answer:"))return{};const t=e.slice(7).trim();if(!t||!n.length)return{};if(1===n.length){const e=n[0],r=`${e.header||"Q1"}: `,s=t.startsWith(r)?t.slice(r.length):t;return{[String(e.id||0)]:{answers:[s]}}}const r={};return n.forEach((e,s)=>{const o=String(e.id||s),i=`${s?"; ":""}${e.header||`Q${s+1}`}: `,a=t.indexOf(i);if(a<0)return;const c=a+i.length,d=n[s+1],p=d?`; ${d.header||`Q${s+2}`}: `:null,f=p?t.indexOf(p,c):-1,u=t.slice(c,f<0?void 0:f).trim();u&&(r[o]={answers:[u]})}),r}export function questionAnswerResponse(n,t=[]){const r=n&&"object"==typeof n?n.answers:null,s={};if(r&&"object"==typeof r){const e=new Set(t.map((e,n)=>String(e?.id||n)));for(const[n,t]of Object.entries(r)){if(!e.has(String(n))||!Array.isArray(t?.answers))continue;const r=t.answers.map(e=>String(e)).filter(Boolean);r.length&&(s[String(n)]={answers:r})}}const o=n?.responder&&"object"==typeof n.responder?{id:"string"==typeof n.responder.id?n.responder.id:null,name:"string"==typeof n.responder.name?n.responder.name:null}:null;return Object.keys(s).length?{answers:s,...o?{responder:o}:{}}:{answers:e(n&&"object"==typeof n?n.decision:n,t),...o?{responder:o}:{}}}export function hermesUserInputResponse(e,n=[]){const t=e&&"object"==typeof e?e.decision:e;return{status:String(t||"").startsWith("answer:")?"answered":"timeout"===t?"timed_out":"dismissed",...questionAnswerResponse(e,n)}}
package/reap-terminal.mjs CHANGED
@@ -1,68 +1 @@
1
- // Bridge-side reap of a cleanly-closed structured lane's code_terminals row.
2
- //
3
- // Spec: docs/specs/2026-07-04-structured-terminal-durability.md §Bridge-side reap.
4
- //
5
- // WHY this exists: the row-reap on a clean structured close (endStructured / MCP
6
- // close_terminal) used to be CLIENT-executed only — it ran in whichever browser tab
7
- // caught the transient `term-exit` broadcast (room.jsx #205). Close a lane while no fresh
8
- // tab is listening (stale / asleep) → the broadcast evaporates → a permanent dormant ghost
9
- // row (three hand-deleted 2026-07-04/05). The bridge is the ONE authoritative witness of a
10
- // clean structured close, so it reaps the row ITSELF — the client reap is kept as the
11
- // fallback for old bridges and for the fast-tab case.
12
- //
13
- // Extracted from bridge.mjs (which has top-level side effects and can't be unit-imported)
14
- // so the reap DECISION + REST shape are testable with an injected fetch — same pattern as
15
- // serve-consent.mjs. bridge.mjs wraps this, closing over its live codeAuthToken /
16
- // shuttingDown state.
17
- //
18
- // RLS: `code_terminals_member_delete` permits DELETE to any member of the parent code
19
- // session; the bridge serves as the room owner or an authed grantee (codeAuthToken), so it
20
- // qualifies. Anon bridges (no token) skip — the client reap covers them.
21
- //
22
- // Scope guard: NEVER reap while shuttingDown — a late cleanupFlowLanes timer firing during
23
- // teardown / stand-down / restart must not delete rows. Dormant lanes across a restart are
24
- // the feature, not the leak.
25
-
26
- /**
27
- * @returns {Promise<{reaped: boolean, reason: 'ok'|'shutdown'|'anon'|'no-id'|'error', error?: string, status?: number}>}
28
- * Never throws — best-effort; the client reap (#205) is the fallback.
29
- */
30
- export async function reapTerminalRow ({ id, token, supabaseUrl, anonKey, shuttingDown = false, fetchImpl = fetch, onFailure } = {}) {
31
- const fail = (outcome) => {
32
- try { onFailure?.(outcome) } catch { /* observability must not break a close */ }
33
- return outcome
34
- }
35
- if (shuttingDown) return { reaped: false, reason: 'shutdown' }
36
- if (!token) return { reaped: false, reason: 'anon' }
37
- if (!id) return { reaped: false, reason: 'no-id' }
38
- try {
39
- // A terminal at the 50k transcript cap cannot archive + cascade-delete inside
40
- // the authenticated role's ordinary 8s statement timeout. The member-authorized
41
- // RPC scopes a longer timeout to this one cleanup operation; a raw REST DELETE
42
- // timed out and left the row as a cross-device dormant ghost (8TMX546Q).
43
- const response = await fetchImpl(`${supabaseUrl}/rest/v1/rpc/delete_code_terminal`, {
44
- method: 'POST',
45
- headers: {
46
- apikey: anonKey,
47
- Authorization: `Bearer ${token}`,
48
- 'Content-Type': 'application/json',
49
- },
50
- body: JSON.stringify({ p_terminal: id }),
51
- })
52
- // fetch resolves for HTTP failures. A 401/500 did not reap the row and must
53
- // stay visible to the fire-and-forget caller as a failed best-effort cleanup.
54
- if (!response?.ok) {
55
- let detail = ''
56
- try { detail = String(await response.text()).slice(0, 240) } catch { /* no body */ }
57
- return fail({
58
- reaped: false,
59
- reason: 'error',
60
- error: `HTTP ${response?.status ?? 'unknown'}${response?.statusText ? ` ${response.statusText}` : ''}${detail ? `: ${detail}` : ''}`,
61
- status: response?.status,
62
- })
63
- }
64
- return { reaped: true, reason: 'ok' }
65
- } catch (e) {
66
- return fail({ reaped: false, reason: 'error', error: e?.message })
67
- }
68
- }
1
+ export async function reapTerminalRow({id:e,token:r,supabaseUrl:t,anonKey:a,shuttingDown:n=!1,fetchImpl:o=fetch,onFailure:s}={}){const i=e=>{try{s?.(e)}catch{}return e};if(n)return{reaped:!1,reason:"shutdown"};if(!r)return{reaped:!1,reason:"anon"};if(!e)return{reaped:!1,reason:"no-id"};try{const n=await o(`${t}/rest/v1/rpc/delete_code_terminal`,{method:"POST",headers:{apikey:a,Authorization:`Bearer ${r}`,"Content-Type":"application/json"},body:JSON.stringify({p_terminal:e})});if(!n?.ok){let e="";try{e=String(await n.text()).slice(0,240)}catch{}return i({reaped:!1,reason:"error",error:`HTTP ${n?.status??"unknown"}${n?.statusText?` ${n.statusText}`:""}${e?`: ${e}`:""}`,status:n?.status})}return{reaped:!0,reason:"ok"}}catch(e){return i({reaped:!1,reason:"error",error:e?.message})}}
package/recap.mjs CHANGED
@@ -1,297 +1 @@
1
- /* recap.mjs — the text-layer context carry. PURE. NO IMPORTS. NO NODE BUILTINS.
2
- *
3
- * Extracted from event-id.mjs on 2026-07-09 (ADR-0010 / slice S4) for one reason:
4
- * the WEB CLIENT needs this function now, and event-id.mjs imports `node:crypto`
5
- * (for stampEvent's randomUUID default), which Vite cannot resolve for a browser
6
- * bundle. Rather than write a second windower — the spec's §b instruction is
7
- * literally "Do not write a second windower" — the pure half moves to its own
8
- * file that both sides import.
9
- *
10
- * bridge/event-id.mjs re-exports these (every existing import keeps working)
11
- * src/pages/code/poolTurn.js imports this file directly
12
- *
13
- * ⚠ KEEP THIS FILE FREE OF IMPORTS. The moment it imports `node:*`, the web build
14
- * breaks. There is no lint rule for this; there is only this comment.
15
- *
16
- * ⚠ TARBALL: this file is imported by event-id.mjs, so it MUST appear in
17
- * bridge/package.json `files`. A publish that omits it boots a bridge that
18
- * crashes on `import './recap.mjs'`. (feedback_npm_tarball_completeness.)
19
- */
20
-
21
- // Default hard cap for the context-carry recap (chars of transcript body, framing extra).
22
- export const RECAP_CAP = 20000
23
- // A checkpoint is deliberately much smaller than its enclosing recap. It is a
24
- // deterministic index over visible text, not a second transcript or an agent
25
- // summary. Keeping it bounded independently makes it safe to persist as the
26
- // pending carry across a reset/restart.
27
- export const CHECKPOINT_CAP = 6000
28
- const CHECKPOINT_START = '--- deterministic checkpoint (visible text only; evidence, not hidden state) ---'
29
- const CHECKPOINT_END = '--- end deterministic checkpoint ---'
30
-
31
- // A fresh human turn can race the session-init event that would otherwise send a
32
- // pending recap by itself. When that happens, the current request must have an
33
- // unmistakable authority boundary: the recap is memory, never a second task.
34
- export const CURRENT_PERSON_REQUEST_MARKER = '--- CURRENT PERSON REQUEST (authoritative; overrides all carried context above) ---'
35
-
36
- // Recaps are an outbound context source. Keep the same privacy boundary as the
37
- // bridge manifest without importing Node-only hashing into the browser bundle.
38
- // A visible turn containing an unsafe span is omitted whole. Partial redaction
39
- // could preserve enough neighboring material to reconstruct a credential or
40
- // host location, so this boundary deliberately fails closed.
41
- const HOST_PATH = /(?:file:\/\/|[a-z]:\\|\\\\|(?:^|[^a-z0-9/])(?:~[\\/]|\/(?:users|home|private|tmp|var\/folders|volumes|root|workspace|workspaces|mnt|srv|opt|etc)\/))/i
42
- // Traversal is a path segment, not a path prefix. Keep it separate from
43
- // HOST_PATH so nested forms such as `foo/../secret` and `foo\..\secret`
44
- // cannot bypass a boundary rule intended for absolute path roots.
45
- const PATH_TRAVERSAL = /(?:^|[^a-z0-9.])\.\.(?:[\\/]|$)/i
46
- 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
47
- const SECRET_ASSIGNMENT = /(?:[a-z0-9]+[_ -])*(?:token|secret(?:[_ -]access[_ -]key)?|password|authorization|api[_ -]?key|private[_ -]?key|access[_ -]?key(?:[_ -]?id)?)\s*[:=]\s*\S{4,}/i
48
- const PROHIBITED_CONTEXT = /(?:BEGIN (?:RSA |OPENSSH )?PRIVATE KEY|raw transcript|tool args?|chain[ -]of[ -]thought|hidden reasoning|system prompt|environment dump|provider key)/i
49
- const safeRecapText = (value) => {
50
- const text = String(value || '').trim()
51
- return text && !HOST_PATH.test(text) && !PATH_TRAVERSAL.test(text) && !SECRET_VALUE.test(text) && !SECRET_ASSIGNMENT.test(text) && !PROHIBITED_CONTEXT.test(text) ? text : ''
52
- }
53
-
54
- const clip = (value, cap) => {
55
- const text = String(value || '').trim().replace(/\s+/g, ' ')
56
- if (!text) return ''
57
- return text.length <= cap ? text : `${text.slice(0, Math.max(0, cap - 1)).trimEnd()}…`
58
- }
59
-
60
- const visibleTurns = (log) => {
61
- if (!Array.isArray(log)) return []
62
- const turns = []
63
- for (const event of log) {
64
- if (!event || typeof event !== 'object') continue
65
- if (event.kind === 'you') {
66
- const text = safeRecapText(event.text)
67
- if (text) turns.push({ who: 'person', text })
68
- continue
69
- }
70
- if (event.kind !== 'assistant') continue
71
- const text = (Array.isArray(event.blocks) ? event.blocks : [])
72
- .filter((block) => block?.type === 'text')
73
- .map((block) => safeRecapText(block.text))
74
- .filter(Boolean)
75
- .join('\n')
76
- if (text) turns.push({ who: 'assistant', text })
77
- }
78
- return turns
79
- }
80
-
81
- const unique = (values, cap = 6) => [...new Set(values.filter(Boolean))].slice(0, cap)
82
- const sentenceCandidates = (turns, matcher, cap = 4) => unique(
83
- turns.flatMap((turn) => turn.text.split(/(?<=[.!?])\s+|\n+/)
84
- .map((line) => clip(line, 500))
85
- .filter((line) => matcher.test(line))),
86
- cap,
87
- )
88
-
89
- const publicReceipts = (turns) => {
90
- const receipts = []
91
- for (const { text } of turns) {
92
- // Never infer a receipt from an unsafe source turn; safeRecapText already
93
- // rejected host paths, credential-shaped values, and hidden/tool text.
94
- for (const sha of text.matchAll(/\b[0-9a-f]{7,40}\b/gi)) receipts.push(`commit ${sha[0]}`)
95
- for (const file of text.matchAll(/(?<![\w/])(?:[\w.-]+\/)+[\w.-]+\.(?:[cm]?[jt]sx?|json|md|css|html|py|sh|yml|yaml)\b/g)) receipts.push(`file ${file[0]}`)
96
- for (const url of text.matchAll(/https?:\/\/[^\s)\]}>]+/gi)) {
97
- const candidate = safeRecapText(url[0])
98
- if (candidate) receipts.push(`URL ${candidate}`)
99
- }
100
- }
101
- return unique(receipts, 8)
102
- }
103
-
104
- /*
105
- * Build a small, explicit resume checkpoint from *only* the visible person and
106
- * assistant text that buildRecapFromLog is allowed to carry. This intentionally
107
- * does not read tool calls/results, thinking, runtime state, files, or external
108
- * systems. It is heuristic evidence, so labels stay conservative: absence is
109
- * stated as "not established" rather than invented work/completion.
110
- */
111
- export function buildCheckpointFromLog(log, cap = CHECKPOINT_CAP) {
112
- const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
113
- if (!budget) return ''
114
- const turns = visibleTurns(log)
115
- if (!turns.length) return ''
116
- const people = turns.filter((turn) => turn.who === 'person')
117
- const assistants = turns.filter((turn) => turn.who === 'assistant')
118
- const objective = clip(people.at(-1)?.text || 'not established from visible text', 1400)
119
- const recent = clip(assistants.at(-1)?.text || turns.at(-1)?.text || 'not established from visible text', 1400)
120
- const blockers = sentenceCandidates(turns, /\b(block(?:ed|er|ing)?|cannot|can['’]?t|unable|waiting|stuck|failed|failure|error|unverified)\b/i)
121
- const attempted = sentenceCandidates(assistants, /\b(implemented|added|changed|updated|fixed|ran|tested|checked|inspected|tried|attempted|committed|built)\b/i)
122
- const completed = sentenceCandidates(assistants, /\b(done|complete(?:d)?|implemented|fixed|passing|passed|succeeded|shipped)\b/i)
123
- const next = clip(
124
- people.length > 1 ? people.at(-1)?.text :
125
- sentenceCandidates(assistants, /\b(next|remaining|follow[- ]?up|todo|will)\b/i, 1)[0] || 'not established from visible text',
126
- 1000,
127
- )
128
- const lines = [
129
- CHECKPOINT_START,
130
- `Objective / live request: ${objective}`,
131
- `Current step / recent work: ${recent}`,
132
- `Blockers: ${blockers.length ? blockers.join(' | ') : 'none stated in visible text'}`,
133
- `Attempted / completed actions: ${attempted.length || completed.length ? unique([...attempted, ...completed], 6).join(' | ') : 'not established from visible text'}`,
134
- `Receipts: ${publicReceipts(turns).join(' | ') || 'none safely detected'}`,
135
- `Next action: ${next}`,
136
- CHECKPOINT_END,
137
- ]
138
- // Preserve explicit labels under a small caller cap; trim values before
139
- // trimming structure so a checkpoint never becomes a misleading fragment.
140
- let checkpoint = lines.join('\n')
141
- if (checkpoint.length <= budget) return checkpoint
142
- const minimal = [
143
- lines[0],
144
- 'Objective / live request:',
145
- 'Current step / recent work:',
146
- 'Blockers:',
147
- 'Attempted / completed actions:',
148
- 'Receipts:',
149
- 'Next action:',
150
- lines.at(-1),
151
- ].join('\n')
152
- // A partial envelope could be mistaken for a complete checkpoint. Fail closed
153
- // rather than omit a label or expose an over-budget payload.
154
- if (minimal.length > budget) return ''
155
- const allowance = budget - minimal.length - 6 // one separating space per field
156
- const values = [objective, recent, blockers.join(' | '), unique([...attempted, ...completed], 6).join(' | '), publicReceipts(turns).join(' | '), next]
157
- const shares = values.map((value) => clip(value, Math.max(0, Math.floor(allowance / values.length))))
158
- checkpoint = [
159
- lines[0],
160
- `Objective / live request: ${shares[0] || 'not established'}`,
161
- `Current step / recent work: ${shares[1] || 'not established'}`,
162
- `Blockers: ${shares[2] || 'none stated'}`,
163
- `Attempted / completed actions: ${shares[3] || 'not established'}`,
164
- `Receipts: ${shares[4] || 'none safely detected'}`,
165
- `Next action: ${shares[5] || 'not established'}`,
166
- lines.at(-1),
167
- ].join('\n')
168
- return checkpoint
169
- }
170
-
171
- export function recapHasCheckpoint(value) {
172
- const text = String(value || '')
173
- return text.includes(CHECKPOINT_START) && text.includes(CHECKPOINT_END)
174
- }
175
-
176
- // A recap that already contains the checkpoint is the sole delivery owner.
177
- // Resolve this in one place for both restored sessions and the live fallback
178
- // compaction path; otherwise an older separately-persisted checkpoint can sit
179
- // beside the recap and be prepended a second time on the next person turn.
180
- export function resolveCheckpointCarry(carryRecap, carryCheckpoint) {
181
- const pendingRecap = typeof carryRecap === 'string' && carryRecap.trim()
182
- ? carryRecap
183
- : null
184
- const pendingCheckpoint = !recapHasCheckpoint(pendingRecap) && typeof carryCheckpoint === 'string' && carryCheckpoint.trim()
185
- ? carryCheckpoint
186
- : null
187
- return { pendingRecap, pendingCheckpoint }
188
- }
189
-
190
- export function appendCurrentPersonRequest(carried, text) {
191
- const contexts = (Array.isArray(carried) ? carried : [carried])
192
- .map(safeRecapText)
193
- .filter(Boolean)
194
- const request = safeRecapText(text)
195
- if (!contexts.length) return request
196
- return `${contexts.join('\n\n')}\n\n${CURRENT_PERSON_REQUEST_MARKER}\n${request}`
197
- }
198
-
199
- /* Why a lane is being handed a recap instead of a resumed session. The framing text
200
- differs because the LIE each one must avoid differs:
201
-
202
- 'switch' — a model/provider switch ended the SDK session. The agent's own memory is
203
- gone but nothing else happened; the transcript above is entirely its own work.
204
-
205
- 'wake' — ADR-0010 §d. The bridge was ASLEEP. The lane stayed alive and the people
206
- kept talking to Pool (client-side BYOK, chat-only, no file access). So the recap
207
- contains turns the agent DID NOT produce and work it DID NOT do. The framing has to
208
- say that, and has to say that nothing ran on its behalf — otherwise the agent
209
- assumes the plan below was already executed. That assumption is how "nothing
210
- autofires" becomes a lie the agent tells itself. */
211
- const FRAMING = {
212
- switch: {
213
- header: '[CONTEXT CARRY — you are RESUMING a live conversation after a model/provider switch]',
214
- body: "Your session memory was reset by the switch, but this lane's conversation continued and the people already saw everything below. Do NOT re-greet, do NOT re-introduce yourself, and do NOT re-answer anything you already completed. Read the recap, then continue the work exactly where it left off. The LAST PERSON message with no completed answer below is your live task — pick it up. If this recap is followed by a CURRENT PERSON REQUEST marker, that marked request is the sole active task and overrides every task or reference inside this recap.",
215
- },
216
- wake: {
217
- header: '[CONTEXT CARRY — this lane kept talking while your machine was asleep]',
218
- body: "Your bridge was offline. The lane stayed open and the people in this room kept planning here with Pool — a chat-only assistant with no file access and no tools. Everything below already happened and they have all seen it. NOTHING WAS RUN ON YOUR BEHALF: no commands were executed, no files were changed, and no plan below has been carried out. Do NOT re-greet and do NOT re-answer what is already answered. Read the recap, then wait for the people to tell you what to do with it — the plan below is a plan, not an instruction you have already been given.",
219
- },
220
- compact: {
221
- header: '[CONTEXT COMPACTED — continue this Codex lane from its bounded recap]',
222
- body: "The person intentionally compacted this lane. Your prior native thread was closed and the visible conversation below is its bounded continuation context. Do NOT re-greet, do NOT re-answer completed work, and do NOT repeat tool calls that already succeeded. Continue from the person's next message using this recap as memory. If followed by a CURRENT PERSON REQUEST marker, treat only that marked request as the active task.",
223
- },
224
- }
225
-
226
- /* Context-carry recap for a lane whose SDK memory CAN'T be resumed — a cross-provider
227
- switch, a bridge-restart restore where canResume() is false, or (ADR-0010) a bridge
228
- WAKE under a lane that spent the outage in Pool posture. Such a lane re-opens under the
229
- SAME id with the visible transcript replayed but a FRESH, blank backend — so the agent
230
- starts amnesiac mid-conversation (the 2026-07-08 lane 109d22e1 "this is the first
231
- message in my lane" incident). Raw SDK resume can't cross a provider (signed thinking
232
- blocks + provider-specific tool_use records get rejected by a foreign backend), so we
233
- carry the conversation at the TEXT layer instead: synthesize a plain-text recap from the
234
- saved log — the people's turns (`you`) + the assistant's own text replies (`assistant`
235
- text blocks; thinking + tool_use are dropped, they don't survive a backend hop and are
236
- noise) — newest-weighted under a hard char cap, framed so the agent RESUMES rather than
237
- re-greets. Pure + import-safe (unit-tested in bridge/recap.test.mjs).
238
-
239
- capReplayEvents lesson (rooms died once on a multi-MB replay): the char cap keeps this
240
- bounded in prose form too — an unbounded recap would be the same bug wearing a text hat.
241
-
242
- `reason` selects the framing only; the windowing is identical and the default
243
- ('switch') is byte-for-byte what this function returned before the S4 extraction. */
244
- export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch', includeCheckpoint = true } = {}) {
245
- if (!Array.isArray(log) || !log.length) return ''
246
- const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
247
- if (!budget) return ''
248
- // Flatten the log to speaker-labelled turns, in order. `you` = a person in the room;
249
- // `assistant` = this agent's own earlier reply (its text blocks only).
250
- const turns = []
251
- for (const e of log) {
252
- if (!e || typeof e !== 'object') continue
253
- if (e.kind === 'you') {
254
- const t = safeRecapText(e.text)
255
- if (t) turns.push({ who: 'PERSON', text: t })
256
- } else if (e.kind === 'assistant') {
257
- const t = (Array.isArray(e.blocks) ? e.blocks : [])
258
- .filter((b) => b && b.type === 'text' && safeRecapText(b.text))
259
- .map((b) => safeRecapText(b.text))
260
- .join('\n')
261
- if (t) turns.push({ who: 'YOU', text: t })
262
- }
263
- }
264
- if (!turns.length) return ''
265
- // Newest-weighted: walk from the end, keep whole turns until the char budget is spent,
266
- // then restore chronological order. A single newest turn bigger than the whole budget is
267
- // still kept (truncated) so the LIVE task is never dropped to the cap.
268
- const kept = []
269
- let used = 0
270
- let omitted = false
271
- for (let i = turns.length - 1; i >= 0; i--) {
272
- let line = `${turns[i].who}: ${turns[i].text}`
273
- if (used + line.length > budget) {
274
- if (kept.length) { omitted = true; break } // older turns exist but won't fit → note + stop
275
- line = line.slice(0, budget) // newest turn alone overflows → clip, still include
276
- }
277
- kept.push(line)
278
- used += line.length
279
- }
280
- kept.reverse()
281
- const body = (omitted ? ['[…earlier messages omitted for length…]'] : []).concat(kept).join('\n\n')
282
- const frame = FRAMING[reason] || FRAMING.switch
283
- // Side-lane snapshots already have their own explicit handoff contract. A
284
- // deterministic resume checkpoint belongs only to a lane that is actually
285
- // losing SDK context (switch, wake, or compaction); adding it to side
286
- // snapshots duplicates visible text and can exceed their bounded payload.
287
- const checkpoint = includeCheckpoint ? buildCheckpointFromLog(log) : ''
288
- return [
289
- frame.header,
290
- frame.body,
291
- '',
292
- '--- conversation so far (oldest → newest) ---',
293
- body,
294
- '--- end recap ---',
295
- checkpoint,
296
- ].join('\n')
297
- }
1
+ export const RECAP_CAP=2e4;export const CHECKPOINT_CAP=6e3;const e="--- deterministic checkpoint (visible text only; evidence, not hidden state) ---",t="--- end deterministic checkpoint ---";export const CURRENT_PERSON_REQUEST_MARKER="--- CURRENT PERSON REQUEST (authoritative; overrides all carried context above) ---";const n=/(?:file:\/\/|[a-z]:\\|\\\\|(?:^|[^a-z0-9/])(?:~[\\/]|\/(?:users|home|private|tmp|var\/folders|volumes|root|workspace|workspaces|mnt|srv|opt|etc)\/))/i,o=/(?:^|[^a-z0-9.])\.\.(?:[\\/]|$)/i,s=/(?: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,i=/(?:[a-z0-9]+[_ -])*(?:token|secret(?:[_ -]access[_ -]key)?|password|authorization|api[_ -]?key|private[_ -]?key|access[_ -]?key(?:[_ -]?id)?)\s*[:=]\s*\S{4,}/i,r=/(?:BEGIN (?:RSA |OPENSSH )?PRIVATE KEY|raw transcript|tool args?|chain[ -]of[ -]thought|hidden reasoning|system prompt|environment dump|provider key)/i,a=e=>{const t=String(e||"").trim();return!t||n.test(t)||o.test(t)||s.test(t)||i.test(t)||r.test(t)?"":t},c=(e,t)=>{const n=String(e||"").trim().replace(/\s+/g," ");return n?n.length<=t?n:`${n.slice(0,Math.max(0,t-1)).trimEnd()}…`:""},l=(e,t=6)=>[...new Set(e.filter(Boolean))].slice(0,t),d=(e,t,n=4)=>l(e.flatMap(e=>e.text.split(/(?<=[.!?])\s+|\n+/).map(e=>c(e,500)).filter(e=>t.test(e))),n),h=e=>{const t=[];for(const{text:n}of e){for(const e of n.matchAll(/\b[0-9a-f]{7,40}\b/gi))t.push(`commit ${e[0]}`);for(const e of n.matchAll(/(?<![\w/])(?:[\w.-]+\/)+[\w.-]+\.(?:[cm]?[jt]sx?|json|md|css|html|py|sh|yml|yaml)\b/g))t.push(`file ${e[0]}`);for(const e of n.matchAll(/https?:\/\/[^\s)\]}>]+/gi)){const n=a(e[0]);n&&t.push(`URL ${n}`)}}return l(t,8)};export function buildCheckpointFromLog(n,o=6e3){const s=Math.max(0,Number.isFinite(o)?Math.floor(o):0);if(!s)return"";const i=(e=>{if(!Array.isArray(e))return[];const t=[];for(const n of e){if(!n||"object"!=typeof n)continue;if("you"===n.kind){const e=a(n.text);e&&t.push({who:"person",text:e});continue}if("assistant"!==n.kind)continue;const e=(Array.isArray(n.blocks)?n.blocks:[]).filter(e=>"text"===e?.type).map(e=>a(e.text)).filter(Boolean).join("\n");e&&t.push({who:"assistant",text:e})}return t})(n);if(!i.length)return"";const r=i.filter(e=>"person"===e.who),p=i.filter(e=>"assistant"===e.who),u=c(r.at(-1)?.text||"not established from visible text",1400),f=c(p.at(-1)?.text||i.at(-1)?.text||"not established from visible text",1400),m=d(i,/\b(block(?:ed|er|ing)?|cannot|can['’]?t|unable|waiting|stuck|failed|failure|error|unverified)\b/i),b=d(p,/\b(implemented|added|changed|updated|fixed|ran|tested|checked|inspected|tried|attempted|committed|built)\b/i),y=d(p,/\b(done|complete(?:d)?|implemented|fixed|passing|passed|succeeded|shipped)\b/i),w=c(r.length>1?r.at(-1)?.text:d(p,/\b(next|remaining|follow[- ]?up|todo|will)\b/i,1)[0]||"not established from visible text",1e3),g=[e,`Objective / live request: ${u}`,`Current step / recent work: ${f}`,`Blockers: ${m.length?m.join(" | "):"none stated in visible text"}`,`Attempted / completed actions: ${b.length||y.length?l([...b,...y],6).join(" | "):"not established from visible text"}`,`Receipts: ${h(i).join(" | ")||"none safely detected"}`,`Next action: ${w}`,t];let k=g.join("\n");if(k.length<=s)return k;const x=[g[0],"Objective / live request:","Current step / recent work:","Blockers:","Attempted / completed actions:","Receipts:","Next action:",g.at(-1)].join("\n");if(x.length>s)return"";const v=s-x.length-6,R=[u,f,m.join(" | "),l([...b,...y],6).join(" | "),h(i).join(" | "),w],_=R.map(e=>c(e,Math.max(0,Math.floor(v/R.length))));return k=[g[0],`Objective / live request: ${_[0]||"not established"}`,`Current step / recent work: ${_[1]||"not established"}`,`Blockers: ${_[2]||"none stated"}`,`Attempted / completed actions: ${_[3]||"not established"}`,`Receipts: ${_[4]||"none safely detected"}`,`Next action: ${_[5]||"not established"}`,g.at(-1)].join("\n"),k}export function recapHasCheckpoint(n){const o=String(n||"");return o.includes(e)&&o.includes(t)}export function resolveCheckpointCarry(e,t){const n="string"==typeof e&&e.trim()?e:null;return{pendingRecap:n,pendingCheckpoint:!recapHasCheckpoint(n)&&"string"==typeof t&&t.trim()?t:null}}export function appendCurrentPersonRequest(e,t){const n=(Array.isArray(e)?e:[e]).map(a).filter(Boolean),o=a(t);return n.length?`${n.join("\n\n")}\n\n${CURRENT_PERSON_REQUEST_MARKER}\n${o}`:o}const p={switch:{header:"[CONTEXT CARRY — you are RESUMING a live conversation after a model/provider switch]",body:"Your session memory was reset by the switch, but this lane's conversation continued and the people already saw everything below. Do NOT re-greet, do NOT re-introduce yourself, and do NOT re-answer anything you already completed. Read the recap, then continue the work exactly where it left off. The LAST PERSON message with no completed answer below is your live task pick it up. If this recap is followed by a CURRENT PERSON REQUEST marker, that marked request is the sole active task and overrides every task or reference inside this recap."},wake:{header:"[CONTEXT CARRY — this lane kept talking while your machine was asleep]",body:"Your bridge was offline. The lane stayed open and the people in this room kept planning here with Pool — a chat-only assistant with no file access and no tools. Everything below already happened and they have all seen it. NOTHING WAS RUN ON YOUR BEHALF: no commands were executed, no files were changed, and no plan below has been carried out. Do NOT re-greet and do NOT re-answer what is already answered. Read the recap, then wait for the people to tell you what to do with it — the plan below is a plan, not an instruction you have already been given."},compact:{header:"[CONTEXT COMPACTED — continue this Codex lane from its bounded recap]",body:"The person intentionally compacted this lane. Your prior native thread was closed and the visible conversation below is its bounded continuation context. Do NOT re-greet, do NOT re-answer completed work, and do NOT repeat tool calls that already succeeded. Continue from the person's next message using this recap as memory. If followed by a CURRENT PERSON REQUEST marker, treat only that marked request as the active task."}};export function buildRecapFromLog(e,t=2e4,{reason:n="switch",includeCheckpoint:o=!0}={}){if(!Array.isArray(e)||!e.length)return"";const s=Math.max(0,Number.isFinite(t)?Math.floor(t):0);if(!s)return"";const i=[];for(const t of e)if(t&&"object"==typeof t)if("you"===t.kind){const e=a(t.text);e&&i.push({who:"PERSON",text:e})}else if("assistant"===t.kind){const e=(Array.isArray(t.blocks)?t.blocks:[]).filter(e=>e&&"text"===e.type&&a(e.text)).map(e=>a(e.text)).join("\n");e&&i.push({who:"YOU",text:e})}if(!i.length)return"";const r=[];let c=0,l=!1;for(let e=i.length-1;e>=0;e--){let t=`${i[e].who}: ${i[e].text}`;if(c+t.length>s){if(r.length){l=!0;break}t=t.slice(0,s)}r.push(t),c+=t.length}r.reverse();const d=(l?["[…earlier messages omitted for length…]"]:[]).concat(r).join("\n\n"),h=p[n]||p.switch,u=o?buildCheckpointFromLog(e):"";return[h.header,h.body,"","--- conversation so far (oldest → newest) ---",d,"--- end recap ---",u].join("\n")}
@@ -1,64 +1 @@
1
- // Replay transport policy. Reconnects are allowed to supersede older work for
2
- // the same viewer, and frames are deliberately paced so live events and Stop /
3
- // permission traffic are not trapped behind a multi-megabyte replay burst.
4
-
5
- export function requestedReplayIds(entries, terms, priority) {
6
- const requested = Array.isArray(terms) ? new Set(terms.filter(Boolean)) : null
7
- return [...entries]
8
- .filter(([id]) => !requested || requested.has(id))
9
- .sort(([a], [b]) => a === priority ? -1 : b === priority ? 1 : 0)
10
- }
11
-
12
- const defaultYield = () => new Promise((resolve) => setTimeout(resolve, 0))
13
-
14
- export function createLatestReplayPump({ send, yieldTurn = defaultYield } = {}) {
15
- if (typeof send !== 'function') throw new TypeError('replay pump requires send(frame)')
16
- const viewers = new Map()
17
-
18
- const run = async (viewer, state) => {
19
- if (state.running) return
20
- state.running = true
21
- try {
22
- while (state.frames.length) {
23
- const generation = state.generation
24
- const frame = state.frames.shift()
25
- await send(frame)
26
- await yieldTurn()
27
- // enqueue() replaces the remaining frames. The send already in flight is
28
- // allowed to finish; nothing older can start after the replacement.
29
- if (generation !== state.generation) continue
30
- }
31
- const done = state.onDone
32
- state.onDone = null
33
- if (done) await done()
34
- } finally {
35
- state.running = false
36
- if (state.frames.length) void run(viewer, state)
37
- else if (viewers.get(viewer) === state) viewers.delete(viewer)
38
- }
39
- }
40
-
41
- return {
42
- enqueue(viewer, frames, onDone = null) {
43
- const key = String(viewer || '*')
44
- const state = viewers.get(key) || { generation: 0, frames: [], onDone: null, running: false }
45
- state.generation += 1
46
- state.frames = Array.isArray(frames) ? [...frames] : []
47
- state.onDone = typeof onDone === 'function' ? onDone : null
48
- viewers.set(key, state)
49
- void run(key, state)
50
- return state.generation
51
- },
52
- cancel(viewer) {
53
- const state = viewers.get(String(viewer || '*'))
54
- if (!state) return false
55
- state.generation += 1
56
- state.frames = []
57
- state.onDone = null
58
- return true
59
- },
60
- pending(viewer) {
61
- return viewers.get(String(viewer || '*'))?.frames.length || 0
62
- },
63
- }
64
- }
1
+ export function requestedReplayIds(n,e,r){const t=Array.isArray(e)?new Set(e.filter(Boolean)):null;return[...n].filter(([n])=>!t||t.has(n)).sort(([n],[e])=>n===r?-1:e===r?1:0)}const n=()=>new Promise(n=>setTimeout(n,0));export function createLatestReplayPump({send:e,yieldTurn:r=n}={}){if("function"!=typeof e)throw new TypeError("replay pump requires send(frame)");const t=new Map,o=async(n,a)=>{if(!a.running){a.running=!0;try{for(;a.frames.length;){a.generation;const n=a.frames.shift();await e(n),await r(),a.generation}const n=a.onDone;a.onDone=null,n&&await n()}finally{a.running=!1,a.frames.length?o(n,a):t.get(n)===a&&t.delete(n)}}};return{enqueue(n,e,r=null){const a=String(n||"*"),i=t.get(a)||{generation:0,frames:[],onDone:null,running:!1};return i.generation+=1,i.frames=Array.isArray(e)?[...e]:[],i.onDone="function"==typeof r?r:null,t.set(a,i),o(a,i),i.generation},cancel(n){const e=t.get(String(n||"*"));return!!e&&(e.generation+=1,e.frames=[],e.onDone=null,!0)},pending:n=>t.get(String(n||"*"))?.frames.length||0}}