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/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
|
|
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:[]}}
|