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
package/session-store.mjs CHANGED
@@ -1,484 +1 @@
1
- /* ─────────────────────────────────────────────────────────────
2
- session-store.mjs — durable scrollback for structured Claude
3
- sessions, "file now" tier (no DB). Each structured session's
4
- event log + its SDK sessionId are written to a per-room file on
5
- the host (~/.thinkpool-pair/<ROOM>/<id>.json), debounced. On a
6
- bridge restart the latest session is reloaded: the transcript
7
- replays immediately, and (if recent) the SDK session resumes so
8
- the live context survives too. A DB-backed tier comes later.
9
- ───────────────────────────────────────────────────────────── */
10
-
11
- import os from 'node:os'
12
- import fs from 'node:fs'
13
- import path from 'node:path'
14
- import { SPAWN } from './cross-terminal.mjs'
15
- import { boundStructuredLog } from './event-bounds.mjs'
16
-
17
- // TP_PAIR_ROOT override exists for tests + sandboxes so they never touch the real
18
- // ~/.thinkpool-pair store. Read LIVE per call (not captured at module load) so a test
19
- // can point each case at a fresh tempdir after importing this module. Production leaves
20
- // it unset → the real home-dir store.
21
- const ROOT = () => process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair')
22
- // Security (audit 2026-07-02): `room` is an external value (a session/code id)
23
- // used as a filesystem path segment. sessions.id is unconstrained TEXT in the
24
- // DB and client-generated, so a crafted id like '../../foo' would otherwise let
25
- // a hostile room write session files OUTSIDE ~/.thinkpool-pair. Contain EVERY
26
- // derived path under ROOT: resolve and refuse if the result escapes. Pair of
27
- // the bridge.mjs startup format check — belt + suspenders.
28
- const dir = (room) => {
29
- const root = path.resolve(ROOT())
30
- const target = path.resolve(root, room || 'default')
31
- if (target !== root && !target.startsWith(root + path.sep)) {
32
- throw new Error(`refused: room path escapes sandbox: ${JSON.stringify(room)}`)
33
- }
34
- return target
35
- }
36
- const archiveDir = (room) => path.join(dir(room), '.archive')
37
- // Only resume the live SDK context for recent sessions — an expired session id
38
- // fails ("No conversation found"); past this window we restore transcript only.
39
- const RESUME_MAX_AGE_MS = 12 * 60 * 60 * 1000
40
- // A live snapshot exists precisely so a live structured lane can survive a bridge
41
- // restart. Keep every lane the bridge can admit. Explicitly closed lanes move to
42
- // .archive/, so closed/history retention stays separate from this live set.
43
- export const LIVE_SESSION_MAX = SPAWN.machineMax
44
-
45
- function ensureDir(room) { fs.mkdirSync(dir(room), { recursive: true }) }
46
-
47
- // Commit a replacement through a sibling file so a restart never observes a torn
48
- // final record. The temp file is fsynced before the same-directory rename: readers
49
- // see the complete previous value or the complete replacement, never a half-write.
50
- // This is shared by session JSON plus the restart-critical names and PTY metadata.
51
- let tempSerial = 0
52
- let beforeAtomicRenameForTest = null
53
- function atomicCommit(file, contents) {
54
- const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp.${process.pid}.${Date.now()}.${tempSerial++}`)
55
- let fd = null
56
- try {
57
- fd = fs.openSync(tmp, 'wx', 0o600)
58
- // fs.writeSync may legally write fewer bytes than requested. Never fsync and
59
- // promote a partial temp file over the last good snapshot.
60
- const bytes = Buffer.from(contents)
61
- for (let offset = 0; offset < bytes.length;) {
62
- const written = fs.writeSync(fd, bytes, offset, bytes.length - offset)
63
- if (written <= 0) throw new Error(`short write while committing ${file}`)
64
- offset += written
65
- }
66
- fs.fsyncSync(fd)
67
- fs.closeSync(fd); fd = null
68
- beforeAtomicRenameForTest?.({ file, tmp })
69
- fs.renameSync(tmp, file)
70
- return true
71
- } catch (error) {
72
- try { if (fd != null) fs.closeSync(fd) } catch { /* best effort */ }
73
- try { fs.rmSync(tmp, { force: true }) } catch { /* best effort */ }
74
- throw error
75
- }
76
- }
77
- // Direct persistence tests inject a crash-equivalent immediately before rename.
78
- // Production never configures this hook.
79
- export function setAtomicCommitHookForTest(hook) {
80
- beforeAtomicRenameForTest = typeof hook === 'function' ? hook : null
81
- }
82
- function persistenceError(action, file, error) {
83
- console.error(`[thinkpool-pair session-store] ${action} failed for ${file}: ${error?.message || error}`)
84
- }
85
- // Move a session file into <room>/.archive/ instead of unlinking it. Archived files
86
- // are OUTSIDE the readdirSync(dir) path loadAll/listRecs read, so a bridge restart
87
- // never resurrects them — but they survive on disk for manual recovery. This is the
88
- // data-loss backstop: a pruned/closed terminal's transcript is always recoverable.
89
- // (2026-06-26: prune used to unlinkSync, permanently destroying older terminals —
90
- // "Blightborne" was lost this way. Named terminals are now additionally immune.)
91
- function archive(room, file) {
92
- try {
93
- fs.mkdirSync(archiveDir(room), { recursive: true })
94
- const base = path.basename(file)
95
- const dst = path.join(archiveDir(room), `${base}.${Date.now()}`)
96
- fs.renameSync(file, dst)
97
- return true
98
- } catch (error) {
99
- persistenceError('archive session snapshot', file, error)
100
- return false
101
- }
102
- }
103
- function listRecs(room) {
104
- try {
105
- return fs.readdirSync(dir(room)).filter((f) => f.endsWith('.json'))
106
- .map((f) => {
107
- const p = path.join(dir(room), f)
108
- try {
109
- const r = JSON.parse(fs.readFileSync(p, 'utf8'))
110
- // _bt: the rec FILE's creation time — a reliable, monotonic proxy for when the
111
- // terminal was first opened (overwrites on save don't change it). Used to restore
112
- // tabs in stable creation order; the persisted openedAt is unreliable because a
113
- // truncated log's first event ts drifts far from real creation. Load-time only.
114
- try { const s = fs.statSync(p); r._bt = s.birthtimeMs || s.mtimeMs || 0 } catch { r._bt = 0 }
115
- return r
116
- } catch (error) {
117
- // Keep the damaged file in place for recovery; never silently make a
118
- // restart look like a deliberately closed lane.
119
- persistenceError('parse session snapshot', p, error)
120
- return null
121
- }
122
- })
123
- .filter(Boolean)
124
- } catch { return [] }
125
- }
126
- function prune(room) {
127
- try {
128
- const names = loadNames(room) // { id: label } — NAMED terminals are immune
129
- const namedId = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')).id } catch { return null } }
130
- const files = fs.readdirSync(dir(room)).filter((f) => f.endsWith('.json'))
131
- .map((f) => { const p = path.join(dir(room), f); let m = 0; try { m = JSON.parse(fs.readFileSync(p, 'utf8')).savedAt || 0 } catch { /* noop */ } return { p, f, m } })
132
- .sort((a, b) => b.m - a.m)
133
- // Only UNNAMED sessions are eligible for prune. A named terminal is deliberate
134
- // user intent (a project) and must never be silently destroyed by churn.
135
- const prunable = files.filter((x) => { const id = namedId(x.p); return !id || !names[id] })
136
- prunable.slice(LIVE_SESSION_MAX).forEach((x) => archive(room, x.p))
137
- } catch (error) { persistenceError('prune live session snapshots', dir(room), error) }
138
- }
139
-
140
- // ── Durable append-only event log (unbounded history; the <id>.json snapshot is
141
- // still capped at STRUCTURED_LOG_MAX for fast restart, this is the full archive). ──
142
- // One JSONL per terminal: every transcript event appended once, O(1), never rewritten.
143
- // Powers "load earlier history" paging without holding the whole log in memory.
144
- const eventsLog = (room, id) => path.join(dir(room), `${id}.events.jsonl`)
145
-
146
- // Append events to the durable log. Caller passes only the events it wants kept
147
- // (transcript kinds) — chrome/thinking noise is filtered upstream. Sync append keeps
148
- // strict on-disk order; writes are tiny so the blocking cost is negligible.
149
- export function appendDurableEvents(room, id, events) {
150
- if (!room || !id || !Array.isArray(events) || !events.length) return
151
- try {
152
- ensureDir(room)
153
- fs.appendFileSync(eventsLog(room, id), events.map((e) => JSON.stringify(e)).join('\n') + '\n')
154
- } catch { /* noop */ }
155
- }
156
-
157
- // One-time seed: if the durable log doesn't exist yet (a terminal that predates this
158
- // feature, or a fresh restart before any new event), write the loaded snapshot as the
159
- // base so the retained window is immediately pageable. No-op once the file exists, so
160
- // reloaded events never double-append (only genuinely-new events ride pushLog → append).
161
- export function seedDurableEvents(room, id, events) {
162
- if (!room || !id || !Array.isArray(events) || !events.length) return
163
- try {
164
- ensureDir(room)
165
- const f = eventsLog(room, id)
166
- if (fs.existsSync(f)) return
167
- fs.writeFileSync(f, events.map((e) => JSON.stringify(e)).join('\n') + '\n')
168
- } catch { /* noop */ }
169
- }
170
-
171
- // Does this bridge hold a durable archive for `id`? The capability half of the
172
- // history-page-request guard (a foreign bridge in a multi-bridge room has no file).
173
- export function hasDurableArchive(room, id) {
174
- try { return !!room && !!id && fs.existsSync(eventsLog(room, id)) } catch { return false }
175
- }
176
-
177
- // Should THIS bridge answer a history-page-request? (pure — tested in test-history-ab.mjs)
178
- //
179
- // Two independent gates, both required:
180
- // · `payloadHost` — the same targeting guard every other broadcast handler carries
181
- // (term-open, file-put, code-open, flow-*): a targeted request is for ONE machine.
182
- // · ownership — no client build to date STAMPS a host on this event, so targeting
183
- // alone leaves an untargeted request answerable by every bridge in the room. The
184
- // bridge that doesn't serve the terminal has no archive, replies
185
- // `{ events: [], hasMore: false }`, and the client reads that first empty page as
186
- // "no older history" — the load-earlier pill dies and recoverable history looks
187
- // gone. Only the bridge that actually HOLDS the terminal may speak: a live session,
188
- // or (a closed lane whose transcript is still on screen) its durable archive.
189
- export function servesHistoryPage({ payloadHost, bridgeName, hasSession, hasArchive }) {
190
- if (payloadHost && payloadHost !== bridgeName) return false
191
- return !!(hasSession || hasArchive)
192
- }
193
-
194
- // ── Epochs: /clear resets the per-session seq counter (bridge.mjs) but the durable
195
- // archive is append-only and keeps every era. So one file can hold seq 1..500 (pre-clear)
196
- // followed by seq 1..30 (post-clear) — colliding keys. `findIndex(e.seq >= beforeSeq)`
197
- // then anchors in the WRONG era and a page splices two conversations together.
198
- //
199
- // A seq that does not strictly increase over its predecessor is, by construction, a
200
- // counter reset: within one bridge run the counter only climbs, and a restart reseeds it
201
- // from maxSeq(restored log). So the last regression marks the start of the CURRENT epoch,
202
- // and only that epoch is pageable. Everything older is a conversation the user explicitly
203
- // cleared — surfacing it under colliding seqs would be wrong twice over.
204
- //
205
- // This is read-side only: it needs no migration and fixes archives already on disk
206
- // (every user's, right now) as well as ones written from here on.
207
- function epochStart(all) {
208
- let start = 0, last = -Infinity
209
- for (let i = 0; i < all.length; i++) {
210
- const s = all[i]?.seq
211
- if (typeof s !== 'number') continue
212
- if (s <= last) { start = i; last = s; continue } // counter went backwards → new era
213
- last = s
214
- }
215
- return start
216
- }
217
-
218
- function readArchive(room, id) {
219
- const f = eventsLog(room, id)
220
- if (!fs.existsSync(f)) return null
221
- const all = []
222
- for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
223
- if (!line) continue
224
- try { all.push(JSON.parse(line)) } catch { /* skip a torn line */ }
225
- }
226
- return all
227
- }
228
-
229
- // The archive FLOOR: the lowest transcript seq durably stored for this terminal *in the
230
- // current epoch*. The client compares its oldest loaded seq against this — only OFFER
231
- // "load earlier" when there genuinely are older events in the archive (vs a fresh lane
232
- // whose first turn happens to land at seq > 1, or an evicted-but-unarchived gap).
233
- // null = no archive.
234
- export function readDurableOldestSeq(room, id) {
235
- try {
236
- const all = readArchive(room, id)
237
- if (!all) return null
238
- for (let i = epochStart(all); i < all.length; i++) {
239
- if (all[i] && all[i].seq != null) return all[i].seq
240
- }
241
- return null
242
- } catch { return null }
243
- }
244
-
245
- // Read a backward page: events with transcript seq < beforeSeq (newest `limit` of
246
- // them, no-seq chrome interleaved by file order preserved). Returns { events, hasMore }.
247
- // beforeSeq null → the newest `limit` events (the tail). hasMore = there is older still.
248
- // Never reads below the current epoch — see epochStart.
249
- export function readDurablePage(room, id, beforeSeq, limit = 200) {
250
- try {
251
- const all = readArchive(room, id)
252
- if (!all) return { events: [], hasMore: false }
253
- const from = epochStart(all)
254
- let bound = all.length
255
- if (beforeSeq != null) {
256
- const i = all.findIndex((e, idx) => idx >= from && e && e.seq != null && e.seq >= beforeSeq)
257
- if (i >= 0) bound = i
258
- }
259
- const start = Math.max(from, bound - limit)
260
- return { events: all.slice(start, Math.max(start, bound)), hasMore: start > from }
261
- } catch { return { events: [], hasMore: false } }
262
- }
263
-
264
- const timers = new Map()
265
- function write(room, id, data) {
266
- const file = path.join(dir(room), `${id}.json`)
267
- try {
268
- ensureDir(room)
269
- const log = Array.isArray(data?.log) ? boundStructuredLog(data.log) : data?.log
270
- atomicCommit(file, JSON.stringify({ ...data, ...(log ? { log } : {}), id, savedAt: Date.now() }))
271
- prune(room)
272
- return true
273
- } catch (error) {
274
- persistenceError('write session snapshot', file, error)
275
- return false
276
- }
277
- }
278
- // Debounced — events arrive in bursts; one write per ~1.5s is plenty.
279
- export function saveSession(room, id, data) {
280
- clearTimeout(timers.get(id))
281
- timers.set(id, setTimeout(() => write(room, id, data), 1500))
282
- }
283
- export function flushSession(room, id, data) { clearTimeout(timers.get(id)); return write(room, id, data) }
284
- // User-initiated close: cancel any pending debounced save and move the on-disk
285
- // record to .archive/ so a later loadAll() (bridge restart) can't resurrect a
286
- // closed terminal — but the transcript stays recoverable. PTY ids never have an
287
- // <id>.json (they use the .pty-id dotfile), so this no-ops harmlessly for them.
288
- export function deleteSession(room, id) {
289
- clearTimeout(timers.get(id)); timers.delete(id)
290
- const p = path.join(dir(room), `${id}.json`)
291
- try { return !fs.existsSync(p) || archive(room, p) }
292
- catch (error) { persistenceError('archive closed session snapshot', p, error); return false }
293
- }
294
-
295
- // Scheduled outcomes use a separate host outbox from live session snapshots.
296
- // A person may close the visible terminal while its idempotent database finish
297
- // is retrying; keeping the intent here lets a restart complete that write without
298
- // loadAll() resurrecting the deliberately closed terminal.
299
- const scheduledOutcomesFile = (room) => path.join(dir(room), '.scheduled-outcomes')
300
- const safeTerminalId = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/.test(value)
301
- const safeRunId = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/.test(value)
302
- const readScheduledOutcomeMap = (room) => {
303
- try {
304
- const value = JSON.parse(fs.readFileSync(scheduledOutcomesFile(room), 'utf8'))
305
- return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
306
- } catch { return {} }
307
- }
308
- // Pending entries from the original format are raw intents. Once the database
309
- // finish and recorded session snapshot are both durable, replace that raw entry
310
- // with a versioned acknowledgement tombstone before attempting deletion. The
311
- // tombstone is intentionally independent of the live/archived session record:
312
- // restart recovery can therefore identify an acknowledged exact run and retry
313
- // deletion only, without issuing another finish RPC or reclassifying its result.
314
- const scheduledOutcomeRecord = (value) => {
315
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null
316
- if (value.v === 1 && value.state === 'acknowledged') {
317
- const intent = value.intent
318
- return intent && typeof intent === 'object' && !Array.isArray(intent) &&
319
- safeRunId(intent.runId) && value.runId === intent.runId
320
- ? { intent, acknowledged: true }
321
- : null
322
- }
323
- return safeRunId(value.runId) ? { intent: value, acknowledged: false } : null
324
- }
325
- export function savePendingScheduledOutcome(room, terminalId, intent) {
326
- if (!safeTerminalId(terminalId) || !intent || typeof intent !== 'object' || Array.isArray(intent) || !safeRunId(intent.runId)) return false
327
- try {
328
- ensureDir(room)
329
- const pending = readScheduledOutcomeMap(room)
330
- pending[terminalId] = intent
331
- atomicCommit(scheduledOutcomesFile(room), JSON.stringify(pending))
332
- return true
333
- } catch (error) {
334
- persistenceError('write scheduled outcome outbox', scheduledOutcomesFile(room), error)
335
- return false
336
- }
337
- }
338
- export function loadPendingScheduledOutcomes(room) {
339
- return Object.entries(readScheduledOutcomeMap(room))
340
- .map(([terminalId, value]) => ({ terminalId, record: scheduledOutcomeRecord(value) }))
341
- .filter(({ terminalId, record }) => safeTerminalId(terminalId) && record)
342
- .map(({ terminalId, record }) => ({
343
- terminalId,
344
- intent: record.intent,
345
- ...(record.acknowledged ? { acknowledged: true } : {}),
346
- }))
347
- .slice(0, LIVE_SESSION_MAX)
348
- }
349
- export function loadPendingScheduledOutcome(room, terminalId) {
350
- if (!safeTerminalId(terminalId)) return null
351
- return scheduledOutcomeRecord(readScheduledOutcomeMap(room)[terminalId])?.intent || null
352
- }
353
- export function acknowledgePendingScheduledOutcome(room, terminalId, runId) {
354
- if (!safeTerminalId(terminalId) || !safeRunId(runId)) return false
355
- try {
356
- const pending = readScheduledOutcomeMap(room)
357
- const record = scheduledOutcomeRecord(pending[terminalId])
358
- if (!record || record.intent.runId !== runId) return false
359
- if (record.acknowledged) return true
360
- pending[terminalId] = {
361
- v: 1,
362
- state: 'acknowledged',
363
- runId,
364
- intent: record.intent,
365
- }
366
- atomicCommit(scheduledOutcomesFile(room), JSON.stringify(pending))
367
- return true
368
- } catch (error) {
369
- persistenceError('acknowledge scheduled outcome outbox', scheduledOutcomesFile(room), error)
370
- return false
371
- }
372
- }
373
- export function deletePendingScheduledOutcome(room, terminalId) {
374
- if (!safeTerminalId(terminalId)) return false
375
- try {
376
- const pending = readScheduledOutcomeMap(room)
377
- if (!(terminalId in pending)) return true
378
- delete pending[terminalId]
379
- if (Object.keys(pending).length) atomicCommit(scheduledOutcomesFile(room), JSON.stringify(pending))
380
- else fs.rmSync(scheduledOutcomesFile(room), { force: true })
381
- return true
382
- } catch (error) {
383
- persistenceError('delete scheduled outcome outbox', scheduledOutcomesFile(room), error)
384
- return false
385
- }
386
- }
387
-
388
- // A database acknowledgement is not locally committed until the corresponding
389
- // live-session snapshot has synchronously recorded scheduleOutcomeRecorded=true
390
- // AND the exact outbox is durably marked acknowledged. Keep deletion behind both
391
- // barriers so a restart always sees one of:
392
- // 1. pending outbox intent to replay idempotently;
393
- // 2. an acknowledged outbox tombstone to delete only; or
394
- // 3. a recorded session snapshot with no outbox.
395
- // `flushRecordedSnapshot` is intentionally injected by bridge.mjs because only
396
- // the live entry can produce its complete session payload.
397
- export function commitRecordedScheduledOutcome(room, terminalId, flushRecordedSnapshot) {
398
- let snapshotRecorded = false
399
- try {
400
- snapshotRecorded = typeof flushRecordedSnapshot === 'function' && flushRecordedSnapshot() === true
401
- } catch (error) {
402
- persistenceError('record scheduled outcome in session snapshot', path.join(dir(room), `${terminalId}.json`), error)
403
- }
404
- if (!snapshotRecorded) return { ok: false, snapshotRecorded: false, outboxAcknowledged: false, outboxDeleted: false }
405
- const intent = loadPendingScheduledOutcome(room, terminalId)
406
- const outboxAcknowledged = acknowledgePendingScheduledOutcome(room, terminalId, intent?.runId)
407
- if (!outboxAcknowledged) return { ok: false, snapshotRecorded: true, outboxAcknowledged: false, outboxDeleted: false }
408
- const outboxDeleted = deletePendingScheduledOutcome(room, terminalId)
409
- return { ok: outboxDeleted, snapshotRecorded: true, outboxAcknowledged: true, outboxDeleted }
410
- }
411
-
412
- // Most-recently-saved structured session for the room (drives attached restore).
413
- export function loadLatest(room) {
414
- const recs = listRecs(room).sort((a, b) => (b.savedAt || 0) - (a.savedAt || 0))
415
- return recs[0] || null
416
- }
417
- export function canResume(rec) {
418
- return !!(rec && rec.sessionId && rec.savedAt && (Date.now() - rec.savedAt) < RESUME_MAX_AGE_MS)
419
- }
420
-
421
- // EVERY saved session for the room, oldest→newest (so tabs reappear in order).
422
- // Drives resume-ALL on a bridge restart — without this only the latest session
423
- // came back and every backgrounded terminal died on restart despite its state
424
- // sitting on disk. Bounded by LIVE_SESSION_MAX, exactly the admission capacity.
425
- export function loadAll(room) {
426
- return listRecs(room).sort((a, b) => (a.savedAt || 0) - (b.savedAt || 0))
427
- }
428
-
429
- // Closed terminal snapshots stay recoverable under .archive/. Past-work search
430
- // reads only these already-bounded snapshots, never the unbounded JSONL logs.
431
- export function loadArchived(room, limit = 40) {
432
- try {
433
- return fs.readdirSync(archiveDir(room))
434
- .map((name) => {
435
- const file = path.join(archiveDir(room), name)
436
- try {
437
- const stat = fs.statSync(file)
438
- if (!stat.isFile()) return null
439
- const record = JSON.parse(fs.readFileSync(file, 'utf8'))
440
- return record && Array.isArray(record.log) ? { ...record, _archivedAt: stat.mtimeMs || 0 } : null
441
- } catch { return null }
442
- })
443
- .filter(Boolean)
444
- .sort((a, b) => (b._archivedAt || b.savedAt || 0) - (a._archivedAt || a.savedAt || 0))
445
- .slice(0, Math.max(1, Math.min(100, Number(limit) || 40)))
446
- } catch {
447
- return []
448
- }
449
- }
450
-
451
- // Last PTY terminal id auto-opened for an attached command. A reconnecting
452
- // bridge reuses it so it re-attaches to the SAME terminal tab instead of
453
- // minting a fresh UUID and breeding a new dormant "Terminal N" each restart.
454
- // Stored as a dotfile (NOT *.json) so it never lands in listRecs/loadLatest.
455
- const ptyFile = (room) => path.join(dir(room), '.pty-id')
456
- export function loadPtyId(room) {
457
- try { return fs.readFileSync(ptyFile(room), 'utf8').trim() || null } catch { return null }
458
- }
459
- export function savePtyId(room, id) {
460
- const file = ptyFile(room)
461
- try { ensureDir(room); atomicCommit(file, String(id)); return true }
462
- catch (error) { persistenceError('write PTY id', file, error); return false }
463
- }
464
-
465
- // Per-room terminal display names (terminal id -> label), set via the web's
466
- // `term-rename` broadcast. This is what makes a rename CROSS-DEVICE + survive a
467
- // bridge restart: names were previously web-localStorage only + a live broadcast,
468
- // so a device that joined later (or a second machine) saw none of them. The bridge
469
- // stores them here and includes them in every announce. Stored as a single dotfile
470
- // (NOT *.json) so it never lands in listRecs/loadAll.
471
- const namesFile = (room) => path.join(dir(room), '.names')
472
- export function loadNames(room) {
473
- const file = namesFile(room)
474
- try { return JSON.parse(fs.readFileSync(file, 'utf8')) || {} }
475
- catch (error) {
476
- if (error?.code !== 'ENOENT') persistenceError('parse terminal names', file, error)
477
- return {}
478
- }
479
- }
480
- export function saveNames(room, names) {
481
- const file = namesFile(room)
482
- try { ensureDir(room); atomicCommit(file, JSON.stringify(names || {})); return true }
483
- catch (error) { persistenceError('write terminal names', file, error); return false }
484
- }
1
+ import e from"node:os";import t from"node:fs";import n from"node:path";import{SPAWN as r}from"./cross-terminal.mjs";import{boundStructuredLog as o}from"./event-bounds.mjs";const c=t=>{const r=n.resolve(process.env.TP_PAIR_ROOT||n.join(e.homedir(),".thinkpool-pair")),o=n.resolve(r,t||"default");if(o!==r&&!o.startsWith(r+n.sep))throw new Error(`refused: room path escapes sandbox: ${JSON.stringify(t)}`);return o},s=e=>n.join(c(e),".archive");export const LIVE_SESSION_MAX=r.machineMax;function i(e){t.mkdirSync(c(e),{recursive:!0})}let a=0,u=null;function l(e,r){const o=n.join(n.dirname(e),`.${n.basename(e)}.tmp.${process.pid}.${Date.now()}.${a++}`);let c=null;try{c=t.openSync(o,"wx",384);const n=Buffer.from(r);for(let r=0;r<n.length;){const o=t.writeSync(c,n,r,n.length-r);if(o<=0)throw new Error(`short write while committing ${e}`);r+=o}return t.fsyncSync(c),t.closeSync(c),c=null,u?.({file:e,tmp:o}),t.renameSync(o,e),!0}catch(e){try{null!=c&&t.closeSync(c)}catch{}try{t.rmSync(o,{force:!0})}catch{}throw e}}export function setAtomicCommitHookForTest(e){u="function"==typeof e?e:null}function d(e,t,n){console.error(`[thinkpool-pair session-store] ${e} failed for ${t}: ${n?.message||n}`)}function f(e,r){try{t.mkdirSync(s(e),{recursive:!0});const o=n.basename(r),c=n.join(s(e),`${o}.${Date.now()}`);return t.renameSync(r,c),!0}catch(e){return d("archive session snapshot",r,e),!1}}function y(e){try{return t.readdirSync(c(e)).filter(e=>e.endsWith(".json")).map(r=>{const o=n.join(c(e),r);try{const e=JSON.parse(t.readFileSync(o,"utf8"));try{const n=t.statSync(o);e._bt=n.birthtimeMs||n.mtimeMs||0}catch{e._bt=0}return e}catch(e){return d("parse session snapshot",o,e),null}}).filter(Boolean)}catch{return[]}}const h=(e,t)=>n.join(c(e),`${t}.events.jsonl`);export function appendDurableEvents(e,n,r){if(e&&n&&Array.isArray(r)&&r.length)try{i(e),t.appendFileSync(h(e,n),r.map(e=>JSON.stringify(e)).join("\n")+"\n")}catch{}}export function seedDurableEvents(e,n,r){if(e&&n&&Array.isArray(r)&&r.length)try{i(e);const o=h(e,n);if(t.existsSync(o))return;t.writeFileSync(o,r.map(e=>JSON.stringify(e)).join("\n")+"\n")}catch{}}export function hasDurableArchive(e,n){try{return!!e&&!!n&&t.existsSync(h(e,n))}catch{return!1}}export function servesHistoryPage({payloadHost:e,bridgeName:t,hasSession:n,hasArchive:r}){return!(e&&e!==t||!n&&!r)}function p(e){let t=0,n=-1/0;for(let r=0;r<e.length;r++){const o=e[r]?.seq;"number"==typeof o&&(o<=n?(t=r,n=o):n=o)}return t}function m(e,n){const r=h(e,n);if(!t.existsSync(r))return null;const o=[];for(const e of t.readFileSync(r,"utf8").split("\n"))if(e)try{o.push(JSON.parse(e))}catch{}return o}export function readDurableOldestSeq(e,t){try{const n=m(e,t);if(!n)return null;for(let e=p(n);e<n.length;e++)if(n[e]&&null!=n[e].seq)return n[e].seq;return null}catch{return null}}export function readDurablePage(e,t,n,r=200){try{const o=m(e,t);if(!o)return{events:[],hasMore:!1};const c=p(o);let s=o.length;if(null!=n){const e=o.findIndex((e,t)=>t>=c&&e&&null!=e.seq&&e.seq>=n);e>=0&&(s=e)}const i=Math.max(c,s-r);return{events:o.slice(i,Math.max(i,s)),hasMore:i>c}}catch{return{events:[],hasMore:!1}}}const S=new Map;function g(e,r,s){const a=n.join(c(e),`${r}.json`);try{i(e);const u=Array.isArray(s?.log)?o(s.log):s?.log;return l(a,JSON.stringify({...s,...u?{log:u}:{},id:r,savedAt:Date.now()})),function(e){try{const r=loadNames(e),o=e=>{try{return JSON.parse(t.readFileSync(e,"utf8")).id}catch{return null}};t.readdirSync(c(e)).filter(e=>e.endsWith(".json")).map(r=>{const o=n.join(c(e),r);let s=0;try{s=JSON.parse(t.readFileSync(o,"utf8")).savedAt||0}catch{}return{p:o,f:r,m:s}}).sort((e,t)=>t.m-e.m).filter(e=>{const t=o(e.p);return!t||!r[t]}).slice(LIVE_SESSION_MAX).forEach(t=>f(e,t.p))}catch(t){d("prune live session snapshots",c(e),t)}}(e),!0}catch(e){return d("write session snapshot",a,e),!1}}export function saveSession(e,t,n){clearTimeout(S.get(t)),S.set(t,setTimeout(()=>g(e,t,n),1500))}export function flushSession(e,t,n){return clearTimeout(S.get(t)),g(e,t,n)}export function deleteSession(e,r){clearTimeout(S.get(r)),S.delete(r);const o=n.join(c(e),`${r}.json`);try{return!t.existsSync(o)||f(e,o)}catch(e){return d("archive closed session snapshot",o,e),!1}}const x=e=>n.join(c(e),".scheduled-outcomes"),A=e=>"string"==typeof e&&/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/.test(e),v=e=>"string"==typeof e&&/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/.test(e),O=e=>{try{const n=JSON.parse(t.readFileSync(x(e),"utf8"));return n&&"object"==typeof n&&!Array.isArray(n)?n:{}}catch{return{}}},w=e=>{if(!e||"object"!=typeof e||Array.isArray(e))return null;if(1===e.v&&"acknowledged"===e.state){const t=e.intent;return t&&"object"==typeof t&&!Array.isArray(t)&&v(t.runId)&&e.runId===t.runId?{intent:t,acknowledged:!0}:null}return v(e.runId)?{intent:e,acknowledged:!1}:null};export function savePendingScheduledOutcome(e,t,n){if(!A(t)||!n||"object"!=typeof n||Array.isArray(n)||!v(n.runId))return!1;try{i(e);const r=O(e);return r[t]=n,l(x(e),JSON.stringify(r)),!0}catch(t){return d("write scheduled outcome outbox",x(e),t),!1}}export function loadPendingScheduledOutcomes(e){return Object.entries(O(e)).map(([e,t])=>({terminalId:e,record:w(t)})).filter(({terminalId:e,record:t})=>A(e)&&t).map(({terminalId:e,record:t})=>({terminalId:e,intent:t.intent,...t.acknowledged?{acknowledged:!0}:{}})).slice(0,LIVE_SESSION_MAX)}export function loadPendingScheduledOutcome(e,t){return A(t)&&w(O(e)[t])?.intent||null}export function acknowledgePendingScheduledOutcome(e,t,n){if(!A(t)||!v(n))return!1;try{const r=O(e),o=w(r[t]);return!(!o||o.intent.runId!==n||(o.acknowledged||(r[t]={v:1,state:"acknowledged",runId:n,intent:o.intent},l(x(e),JSON.stringify(r))),0))}catch(t){return d("acknowledge scheduled outcome outbox",x(e),t),!1}}export function deletePendingScheduledOutcome(e,n){if(!A(n))return!1;try{const r=O(e);return!(n in r)||(delete r[n],Object.keys(r).length?l(x(e),JSON.stringify(r)):t.rmSync(x(e),{force:!0}),!0)}catch(t){return d("delete scheduled outcome outbox",x(e),t),!1}}export function commitRecordedScheduledOutcome(e,t,r){let o=!1;try{o="function"==typeof r&&!0===r()}catch(r){d("record scheduled outcome in session snapshot",n.join(c(e),`${t}.json`),r)}if(!o)return{ok:!1,snapshotRecorded:!1,outboxAcknowledged:!1,outboxDeleted:!1};const s=loadPendingScheduledOutcome(e,t);if(!acknowledgePendingScheduledOutcome(e,t,s?.runId))return{ok:!1,snapshotRecorded:!0,outboxAcknowledged:!1,outboxDeleted:!1};const i=deletePendingScheduledOutcome(e,t);return{ok:i,snapshotRecorded:!0,outboxAcknowledged:!0,outboxDeleted:i}}export function loadLatest(e){return y(e).sort((e,t)=>(t.savedAt||0)-(e.savedAt||0))[0]||null}export function canResume(e){return!!(e&&e.sessionId&&e.savedAt&&Date.now()-e.savedAt<432e5)}export function loadAll(e){return y(e).sort((e,t)=>(e.savedAt||0)-(t.savedAt||0))}export function loadArchived(e,r=40){try{return t.readdirSync(s(e)).map(r=>{const o=n.join(s(e),r);try{const e=t.statSync(o);if(!e.isFile())return null;const n=JSON.parse(t.readFileSync(o,"utf8"));return n&&Array.isArray(n.log)?{...n,_archivedAt:e.mtimeMs||0}:null}catch{return null}}).filter(Boolean).sort((e,t)=>(t._archivedAt||t.savedAt||0)-(e._archivedAt||e.savedAt||0)).slice(0,Math.max(1,Math.min(100,Number(r)||40)))}catch{return[]}}const b=e=>n.join(c(e),".pty-id");export function loadPtyId(e){try{return t.readFileSync(b(e),"utf8").trim()||null}catch{return null}}export function savePtyId(e,t){const n=b(e);try{return i(e),l(n,String(t)),!0}catch(e){return d("write PTY id",n,e),!1}}const j=e=>n.join(c(e),".names");export function loadNames(e){const n=j(e);try{return JSON.parse(t.readFileSync(n,"utf8"))||{}}catch(e){return"ENOENT"!==e?.code&&d("parse terminal names",n,e),{}}}export function saveNames(e,t){const n=j(e);try{return i(e),l(n,JSON.stringify(t||{})),!0}catch(e){return d("write terminal names",n,e),!1}}
package/side-lane.mjs CHANGED
@@ -1,63 +1 @@
1
- import { buildRecapFromLog } from './recap.mjs'
2
-
3
- export const SIDE_RECAP_CAP = 12_000
4
-
5
- export const SIDE_ROLE_PROMPT = `You are a ThinkPool side lane attached to a main terminal.
6
- Focus first on reading, searching, comparing, and answering the side task. You are a full agent and share the main terminal's working directory, so coordinate through ThinkPool Peer before changing files that another lane may be editing. Do not make edits unless a person explicitly asks for them in this side lane. Keep the investigation self-contained; the main terminal receives nothing until a person chooses Bring to main.`
7
-
8
- export const SIDE_HANDOFF_PROMPT = `Prepare a compact handoff for the main terminal now.
9
- Return only the handoff. Include: conclusion, strongest evidence, files changed or artifacts produced, and any unresolved question or recommended next action. Do not continue the investigation and do not address the reader conversationally.`
10
-
11
- export const SIDE_MAIN_TURN_PROMPT = `A room member chose Bring to main.
12
- Read the side-lane handoff below, incorporate the relevant findings into the main lane's current work, and respond now. If the handoff recommends a next step that is already authorized and in scope, take it; otherwise explain the concrete impact on the current work.`
13
-
14
- export function sideSnapshot(log) {
15
- return buildRecapFromLog(Array.isArray(log) ? log : [], SIDE_RECAP_CAP, { includeCheckpoint: false })
16
- }
17
-
18
- export function assistantTextSince(log, afterSeq = 0) {
19
- const chunks = []
20
- for (const event of Array.isArray(log) ? log : []) {
21
- if (event?.kind !== 'assistant') continue
22
- if (Number.isFinite(afterSeq) && Number.isFinite(event.seq) && event.seq <= afterSeq) continue
23
- for (const block of Array.isArray(event.blocks) ? event.blocks : []) {
24
- if (block?.type === 'text' && String(block.text || '').trim()) chunks.push(String(block.text).trim())
25
- }
26
- }
27
- return chunks.join('\n\n').trim()
28
- }
29
-
30
- export function sideContextBlock({ sideId, sideName, task, summary }) {
31
- const label = String(sideName || 'Side lane').trim()
32
- const ref = String(sideId || '').slice(0, 8)
33
- const lines = [
34
- `--- side-lane handoff: ${label}${ref ? ` (${ref})` : ''} ---`,
35
- task ? `Task: ${String(task).trim()}` : '',
36
- String(summary || '').trim(),
37
- '--- end side-lane handoff ---',
38
- ]
39
- return lines.filter(Boolean).join('\n')
40
- }
41
-
42
- export function appendSideContext(contexts, context, cap = 4) {
43
- if (!context) return Array.isArray(contexts) ? contexts.slice(-cap) : []
44
- return [...(Array.isArray(contexts) ? contexts : []), context].slice(-cap)
45
- }
46
-
47
- export function sideMainTurnPrompt(contexts) {
48
- const handoffs = (Array.isArray(contexts) ? contexts : []).filter(Boolean)
49
- if (!handoffs.length) return ''
50
- return `${SIDE_MAIN_TURN_PROMPT}\n\n${handoffs.join('\n\n')}`
51
- }
52
-
53
- export function dispatchSideContexts({ contexts, busy, sendTurn }) {
54
- const pending = (Array.isArray(contexts) ? contexts : []).filter(Boolean).slice(-4)
55
- if (!pending.length || busy || typeof sendTurn !== 'function') return { dispatched: false, pending }
56
- const prompt = sideMainTurnPrompt(pending)
57
- try {
58
- if (sendTurn(prompt) === false) return { dispatched: false, pending }
59
- } catch {
60
- return { dispatched: false, pending }
61
- }
62
- return { dispatched: true, pending: [] }
63
- }
1
+ import{buildRecapFromLog as e}from"./recap.mjs";export const SIDE_RECAP_CAP=12e3;export const SIDE_ROLE_PROMPT="You are a ThinkPool side lane attached to a main terminal.\nFocus first on reading, searching, comparing, and answering the side task. You are a full agent and share the main terminal's working directory, so coordinate through ThinkPool Peer before changing files that another lane may be editing. Do not make edits unless a person explicitly asks for them in this side lane. Keep the investigation self-contained; the main terminal receives nothing until a person chooses Bring to main.";export const SIDE_HANDOFF_PROMPT="Prepare a compact handoff for the main terminal now.\nReturn only the handoff. Include: conclusion, strongest evidence, files changed or artifacts produced, and any unresolved question or recommended next action. Do not continue the investigation and do not address the reader conversationally.";export const SIDE_MAIN_TURN_PROMPT="A room member chose Bring to main.\nRead the side-lane handoff below, incorporate the relevant findings into the main lane's current work, and respond now. If the handoff recommends a next step that is already authorized and in scope, take it; otherwise explain the concrete impact on the current work.";export function sideSnapshot(n){return e(Array.isArray(n)?n:[],12e3,{includeCheckpoint:!1})}export function assistantTextSince(e,n=0){const t=[];for(const r of Array.isArray(e)?e:[])if("assistant"===r?.kind&&!(Number.isFinite(n)&&Number.isFinite(r.seq)&&r.seq<=n))for(const e of Array.isArray(r.blocks)?r.blocks:[])"text"===e?.type&&String(e.text||"").trim()&&t.push(String(e.text).trim());return t.join("\n\n").trim()}export function sideContextBlock({sideId:e,sideName:n,task:t,summary:r}){const i=String(n||"Side lane").trim(),o=String(e||"").slice(0,8);return[`--- side-lane handoff: ${i}${o?` (${o})`:""} ---`,t?`Task: ${String(t).trim()}`:"",String(r||"").trim(),"--- end side-lane handoff ---"].filter(Boolean).join("\n")}export function appendSideContext(e,n,t=4){return n?[...Array.isArray(e)?e:[],n].slice(-t):Array.isArray(e)?e.slice(-t):[]}export function sideMainTurnPrompt(e){const n=(Array.isArray(e)?e:[]).filter(Boolean);return n.length?`${SIDE_MAIN_TURN_PROMPT}\n\n${n.join("\n\n")}`:""}export function dispatchSideContexts({contexts:e,busy:n,sendTurn:t}){const r=(Array.isArray(e)?e:[]).filter(Boolean).slice(-4);if(!r.length||n||"function"!=typeof t)return{dispatched:!1,pending:r};const i=sideMainTurnPrompt(r);try{if(!1===t(i))return{dispatched:!1,pending:r}}catch{return{dispatched:!1,pending:r}}return{dispatched:!0,pending:[]}}