thinkpool-pair 0.7.92 → 0.7.94
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/account.mjs +42 -1
- package/bridge.mjs +103 -4
- package/claude-session.mjs +8 -0
- package/cross-terminal.mjs +27 -0
- package/package.json +1 -1
- package/session-store.mjs +16 -0
package/account.mjs
CHANGED
|
@@ -230,6 +230,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
230
230
|
const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
|
|
231
231
|
const childBetween = new Map() // room -> bool (between turns now — safe to restart per Contract #1)
|
|
232
232
|
const childPeer = new Map() // room -> bool (a web client is watching this room)
|
|
233
|
+
// Thinkpool Ensemble cross-ROOM router (Tier 1): the supervisor is the only process
|
|
234
|
+
// that knows every room on this machine, so it routes a child's read_session into the
|
|
235
|
+
// target room's child and relays the reply back. roomNames feeds list_sessions; pairPending
|
|
236
|
+
// maps an in-flight peek's reqId → the room that asked, so the target's reply finds its way home.
|
|
237
|
+
const roomNames = new Map() // room -> display name (for list_sessions)
|
|
238
|
+
const pairPending = new Map() // reqId -> asking room code (for read_session reply routing)
|
|
233
239
|
let applyRequested = false // a user clicked "apply" in some room (Slice 3)
|
|
234
240
|
let pendingUpdate = null // newest published version once the poll sees it
|
|
235
241
|
const warned = new Set()
|
|
@@ -384,6 +390,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
384
390
|
continue
|
|
385
391
|
}
|
|
386
392
|
warned.delete(room)
|
|
393
|
+
roomNames.set(room, r.name || null) // feed list_sessions (cross-room roster)
|
|
387
394
|
process.stderr.write(`\n ◆ serving ${room}${r.name ? ` "${r.name}"` : ''} → ${dir}${dirs[room] ? '' : ' (default)'}\n`)
|
|
388
395
|
// Children NEVER self-update (only the supervisor restart re-resolves
|
|
389
396
|
// @latest); they report idle to us over IPC so we can restart at a moment
|
|
@@ -402,8 +409,42 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
402
409
|
if (!m) return
|
|
403
410
|
if (m.t === 'idle') { childIdle.set(room, !!m.idle); childBetween.set(room, m.between != null ? !!m.between : !!m.idle); childPeer.set(room, !!m.webPeer) }
|
|
404
411
|
else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
|
|
412
|
+
// ── Thinkpool Ensemble cross-ROOM routing (Tier 1, read-only) ──────────
|
|
413
|
+
// `room` here is the room that sent the message (this closure is per-child).
|
|
414
|
+
else if (m.t === 'pair-list-req') {
|
|
415
|
+
// The asking room's OTHER sessions on this machine (same account = "your other rooms").
|
|
416
|
+
const off = process.env.TP_CROSSROOM_OFF === '1'
|
|
417
|
+
const others = off ? [] : [...children.keys()].filter((r) => r !== room).map((r) => ({ code: r, name: roomNames.get(r) || null }))
|
|
418
|
+
try { child.send({ t: 'pair-list-res', reqId: m.reqId, rooms: others, ...(off ? { error: 'Cross-session reach is turned off on this bridge.' } : {}) }) } catch { /* child gone */ }
|
|
419
|
+
}
|
|
420
|
+
else if (m.t === 'pair-peek-req') {
|
|
421
|
+
const reply = (extra) => { try { child.send({ t: 'pair-peek-res', reqId: m.reqId, ...extra }) } catch { /* child gone */ } }
|
|
422
|
+
if (process.env.TP_CROSSROOM_OFF === '1') return reply({ error: 'Cross-session reach is turned off on this bridge.' })
|
|
423
|
+
const target = String(m.targetRoom || '').toUpperCase().trim()
|
|
424
|
+
if (!target || target === room) return reply({ error: `${target || 'That'} is not another of your active sessions on this machine.` })
|
|
425
|
+
const tc = children.get(target)
|
|
426
|
+
if (!tc) return reply({ error: `Session ${target} is not running on this machine right now (only sessions this account is actively serving here are reachable).` })
|
|
427
|
+
pairPending.set(m.reqId, room) // remember who asked so the reply routes home
|
|
428
|
+
try { tc.send({ t: 'pair-peek-local', reqId: m.reqId, fromRoom: room, terminal: m.terminal, lines: m.lines }) }
|
|
429
|
+
catch { pairPending.delete(m.reqId); reply({ error: `Could not reach session ${target}.` }) }
|
|
430
|
+
}
|
|
431
|
+
else if (m.t === 'pair-peek-res') {
|
|
432
|
+
// A target child answered — route its reply back to the room that asked.
|
|
433
|
+
const askerRoom = pairPending.get(m.reqId)
|
|
434
|
+
if (askerRoom == null) return
|
|
435
|
+
pairPending.delete(m.reqId)
|
|
436
|
+
const asker = children.get(askerRoom)
|
|
437
|
+
if (asker) { try { asker.send({ t: 'pair-peek-res', reqId: m.reqId, text: m.text, error: m.error, fromRoom: room }) } catch { /* asker gone */ } }
|
|
438
|
+
}
|
|
439
|
+
})
|
|
440
|
+
child.on('exit', () => {
|
|
441
|
+
children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room); roomNames.delete(room) // re-served on the next tick
|
|
442
|
+
// Fail any in-flight cross-room peeks waiting on (or asked by) this dead room.
|
|
443
|
+
for (const [reqId, askerRoom] of pairPending) {
|
|
444
|
+
if (askerRoom !== room) continue
|
|
445
|
+
pairPending.delete(reqId)
|
|
446
|
+
}
|
|
405
447
|
})
|
|
406
|
-
child.on('exit', () => { children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room) }) // re-served on the next tick
|
|
407
448
|
// If an update is already pending, tell the freshly-served child immediately so
|
|
408
449
|
// its room shows the "update ready" chip without waiting for the next poll.
|
|
409
450
|
if (pendingUpdate) { try { child.send({ t: 'update-available', version: pendingUpdate }) } catch { /* child not ready */ } }
|
package/bridge.mjs
CHANGED
|
@@ -39,9 +39,9 @@ import { createClient } from '@supabase/supabase-js'
|
|
|
39
39
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
40
40
|
import { z } from 'zod'
|
|
41
41
|
import { startClaudeSession } from './claude-session.mjs'
|
|
42
|
-
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision } from './cross-terminal.mjs'
|
|
42
|
+
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, CROSSROOM, formatPairRoster } from './cross-terminal.mjs'
|
|
43
43
|
import { turnInFlight } from './update-gate.mjs'
|
|
44
|
-
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage } from './session-store.mjs'
|
|
44
|
+
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
|
|
45
45
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks } from './event-id.mjs'
|
|
46
46
|
import { makeThrottledTrack } from './presence.mjs'
|
|
47
47
|
|
|
@@ -579,10 +579,61 @@ const announce = () =>
|
|
|
579
579
|
// the chosen label instead of defaulting to Normal / unnamed. `spawned` (set
|
|
580
580
|
// when an agent Dispatched this lane via spawn_terminal) lets the client keep
|
|
581
581
|
// it OUT of the top tab strip and show it only in the Ensemble menu.
|
|
582
|
-
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', alive: true, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}) })),
|
|
582
|
+
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', alive: true, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}) })),
|
|
583
583
|
],
|
|
584
584
|
})
|
|
585
585
|
|
|
586
|
+
// ── Thinkpool Ensemble — cross-ROOM reach (Tier 1: same-machine pair peek) ────
|
|
587
|
+
// The in-room Ensemble (read_terminal over sibling terminals) expanded ACROSS the
|
|
588
|
+
// account's OTHER rooms. The account supervisor (account.mjs) is the router: this
|
|
589
|
+
// child asks it over IPC, it forwards to the target room's child, that child runs
|
|
590
|
+
// formatPeek over ITS OWN registries, and the formatted text comes back. Read-only;
|
|
591
|
+
// same-machine only; scoped to rooms this account serves (the supervisor only ever
|
|
592
|
+
// serves rooms the account OWNS — so cross-room reach is "your other sessions", never
|
|
593
|
+
// a stranger's). Cross-machine (a pair bus) + posting are later tiers.
|
|
594
|
+
// Spec: docs/specs/2026-06-30-cross-room-ensemble.md
|
|
595
|
+
const IS_ACCOUNT_CHILD = !!(process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1')
|
|
596
|
+
const pairWaiters = new Map() // reqId -> { resolve, timer } for our OUTBOUND requests
|
|
597
|
+
// Send a cross-room request to the supervisor and resolve with its reply ({ rooms }
|
|
598
|
+
// or { text } / { error }). Fails soft (never throws into the tool handler): a
|
|
599
|
+
// standalone bridge (no supervisor) or a disabled/timed-out request returns { error }.
|
|
600
|
+
function pairRequest(type, payload = {}, timeoutMs = 4000) {
|
|
601
|
+
if (process.env.TP_CROSSROOM_OFF === '1') return Promise.resolve({ error: 'Cross-session reach is turned off in this room.' })
|
|
602
|
+
if (!IS_ACCOUNT_CHILD) return Promise.resolve({ error: 'Cross-session reach needs the ThinkPool account bridge running your sessions (npx thinkpool-pair). This room is running standalone, so it can only see its own terminals — use read_terminal.' })
|
|
603
|
+
const reqId = randomUUID()
|
|
604
|
+
return new Promise((resolve) => {
|
|
605
|
+
const timer = setTimeout(() => { pairWaiters.delete(reqId); resolve({ error: 'The other session did not respond in time.' }) }, timeoutMs)
|
|
606
|
+
timer.unref?.()
|
|
607
|
+
pairWaiters.set(reqId, { resolve, timer })
|
|
608
|
+
try { process.send({ t: type, reqId, ...payload }) }
|
|
609
|
+
catch { clearTimeout(timer); pairWaiters.delete(reqId); resolve({ error: 'Could not reach the account bridge.' }) }
|
|
610
|
+
})
|
|
611
|
+
}
|
|
612
|
+
if (IS_ACCOUNT_CHILD) {
|
|
613
|
+
// A SECOND message listener (Node allows many) dedicated to pair traffic — leaves
|
|
614
|
+
// the account-child idle/token listener (bottom of file) untouched.
|
|
615
|
+
process.on('message', (m) => {
|
|
616
|
+
if (!m || !m.t) return
|
|
617
|
+
// Reply to one of OUR outbound cross-room requests → resolve the waiter.
|
|
618
|
+
if (m.t === 'pair-list-res' || m.t === 'pair-peek-res') {
|
|
619
|
+
const w = pairWaiters.get(m.reqId)
|
|
620
|
+
if (!w) return
|
|
621
|
+
clearTimeout(w.timer); pairWaiters.delete(m.reqId)
|
|
622
|
+
w.resolve(m)
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
// We are the TARGET of another room's read_session — run formatPeek over our own
|
|
626
|
+
// registries (selfId null: the asker is in a DIFFERENT room, so every terminal here
|
|
627
|
+
// is a sibling to show) and reply. Strictly read-only; mutates nothing.
|
|
628
|
+
if (m.t === 'pair-peek-local') {
|
|
629
|
+
let text, error
|
|
630
|
+
try { text = formatPeek({ selfId: null, sessions, terms, names: termNames, terminal: m.terminal, lines: m.lines }) }
|
|
631
|
+
catch { error = 'Could not read that session.' }
|
|
632
|
+
try { process.send({ t: 'pair-peek-res', reqId: m.reqId, text, error }) } catch { /* parent gone */ }
|
|
633
|
+
}
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
|
|
586
637
|
// One flush timer batches every terminal's pending bytes (~35ms cadence).
|
|
587
638
|
//
|
|
588
639
|
// Viewer gate (2026-06-28): pty-out is the single biggest Realtime-message
|
|
@@ -871,7 +922,13 @@ function pushLog(entry, evt) {
|
|
|
871
922
|
// is skipped. entry.id/entry.room are stamped in openStructured; a PTY entry (no id)
|
|
872
923
|
// is a no-op. This is the single place every new event passes through, so nothing is
|
|
873
924
|
// missed and reloaded events (which don't ride pushLog) never double-append.
|
|
874
|
-
if (entry.id && seqable(evt))
|
|
925
|
+
if (entry.id && seqable(evt)) {
|
|
926
|
+
appendDurableEvents(entry.room, entry.id, [evt])
|
|
927
|
+
// First archived event sets the floor for a fresh lane (no seed ran). Lets the
|
|
928
|
+
// client correctly withhold "load earlier" until eviction pushes the loaded window
|
|
929
|
+
// above this floor — a new lane's first turn at seq>1 is NOT older-history.
|
|
930
|
+
if (entry.archiveOldestSeq == null) entry.archiveOldestSeq = evt.seq
|
|
931
|
+
}
|
|
875
932
|
if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
|
|
876
933
|
return evt
|
|
877
934
|
}
|
|
@@ -914,6 +971,10 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
|
|
|
914
971
|
entry.id = id
|
|
915
972
|
entry.room = room
|
|
916
973
|
seedDurableEvents(room, id, entry.log.filter((e) => seqable(e)))
|
|
974
|
+
// Archive floor — the lowest seq durably stored. Drives the client's "load earlier"
|
|
975
|
+
// offer (show only when its oldest loaded seq is BELOW this). null on a brand-new lane
|
|
976
|
+
// with no archive yet; pushLog stamps it from the first appended event.
|
|
977
|
+
entry.archiveOldestSeq = readDurableOldestSeq(room, id)
|
|
917
978
|
sessions.set(id, entry)
|
|
918
979
|
// Close an interrupted turn so a restored terminal comes back IDLE-with-history
|
|
919
980
|
// instead of stuck "thinking" (see restoredTurnOpen). Idempotent: the closer is
|
|
@@ -965,6 +1026,43 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
|
|
|
965
1026
|
return { content: [{ type: 'text', text }] }
|
|
966
1027
|
},
|
|
967
1028
|
),
|
|
1029
|
+
// Tier 1 cross-ROOM peek — list_sessions / read_session. Read-only reach into the
|
|
1030
|
+
// account's OTHER rooms (this session's sibling SESSIONS, not just sibling terminals),
|
|
1031
|
+
// routed through the account supervisor over IPC (pairRequest). Auto-allowed by the
|
|
1032
|
+
// PreToolUse gate (read-only, like read_terminal). Bounded by CROSSROOM.peekPerTurnCap
|
|
1033
|
+
// (reset per human turn alongside the in-room budgets) + TP_CROSSROOM_OFF kill-switch.
|
|
1034
|
+
tool(
|
|
1035
|
+
'list_sessions',
|
|
1036
|
+
'List your OTHER ThinkPool Code sessions (rooms) open on this machine — beyond this one. Returns each room\'s code and name so you can read into one with read_session. This is Thinkpool Ensemble across SESSIONS, not just terminals: it lets you see and pick up work in your other rooms. Read-only.',
|
|
1037
|
+
{},
|
|
1038
|
+
async () => {
|
|
1039
|
+
const okText = (t) => ({ content: [{ type: 'text', text: t }] })
|
|
1040
|
+
entry.pairPeekCount = (entry.pairPeekCount || 0) + 1
|
|
1041
|
+
if (entry.pairPeekCount > CROSSROOM.peekPerTurnCap) return okText(`Cross-session read limit reached for this turn (${CROSSROOM.peekPerTurnCap}). Continue with what you have.`)
|
|
1042
|
+
const res = await pairRequest('pair-list-req', {})
|
|
1043
|
+
return okText(formatPairRoster(res?.rooms, { thisRoom: room, error: res?.error }))
|
|
1044
|
+
},
|
|
1045
|
+
),
|
|
1046
|
+
tool(
|
|
1047
|
+
'read_session',
|
|
1048
|
+
'Read-only view INTO another of your ThinkPool Code sessions on this machine (a room code from list_sessions). Pass `session` (the room code) and optionally `terminal` (a ref/name in that room) to read its recent activity; omit `terminal` to list that room\'s terminals. It never changes the other session — reading only. Use it to check on or pick up work running in a sibling session.',
|
|
1049
|
+
{
|
|
1050
|
+
session: z.string().describe('the room code of the other session (from list_sessions)'),
|
|
1051
|
+
terminal: z.string().optional().describe('ref, id, or name of a terminal in that room; omit to list that room\'s terminals'),
|
|
1052
|
+
lines: z.number().int().positive().max(PEEK.maxLines).optional().describe(`how many recent lines to return (default ${PEEK.defaultLines})`),
|
|
1053
|
+
},
|
|
1054
|
+
async (args) => {
|
|
1055
|
+
const okText = (t) => ({ content: [{ type: 'text', text: t }] })
|
|
1056
|
+
entry.pairPeekCount = (entry.pairPeekCount || 0) + 1
|
|
1057
|
+
if (entry.pairPeekCount > CROSSROOM.peekPerTurnCap) return okText(`Cross-session read limit reached for this turn (${CROSSROOM.peekPerTurnCap}). Continue with what you have.`)
|
|
1058
|
+
const target = String(args?.session || '').toUpperCase().trim()
|
|
1059
|
+
if (!target) return okText('Name a room code to read (call list_sessions to see them).')
|
|
1060
|
+
if (target === room) return okText('That is this room — use read_terminal for terminals in your own room.')
|
|
1061
|
+
const res = await pairRequest('pair-peek-req', { targetRoom: target, terminal: args?.terminal, lines: args?.lines })
|
|
1062
|
+
if (res?.error) return okText(res.error)
|
|
1063
|
+
return okText(res?.text || `Room ${target} returned nothing.`)
|
|
1064
|
+
},
|
|
1065
|
+
),
|
|
968
1066
|
// Tier C — directed cross-lane action. WRITES into a sibling AGENT lane.
|
|
969
1067
|
// The PreToolUse gate (claude-session.mjs) already enforced: precheck passed
|
|
970
1068
|
// + a human approved the card. This handler routes only; it re-asserts the
|
|
@@ -1401,6 +1499,7 @@ channel
|
|
|
1401
1499
|
// speaks again.
|
|
1402
1500
|
s.peekCount = 0
|
|
1403
1501
|
s.postCount = 0
|
|
1502
|
+
s.pairPeekCount = 0 // cross-room read budget resets with the in-room ones (Tier 1)
|
|
1404
1503
|
s.hop = 0
|
|
1405
1504
|
const text = String(payload.text)
|
|
1406
1505
|
// Echo the turn so BOTH readers (and late joiners, via the log) show who
|
package/claude-session.mjs
CHANGED
|
@@ -253,6 +253,13 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
253
253
|
if (toolName === 'mcp__thinkpool__read_terminal') {
|
|
254
254
|
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (read-only ThinkPool cross-terminal read).' } }
|
|
255
255
|
}
|
|
256
|
+
// ThinkPool cross-ROOM READ (Tier 1: list_sessions / read_session) is read-only and
|
|
257
|
+
// reaches ONLY the account's own other rooms on this machine (the supervisor only
|
|
258
|
+
// serves owned rooms) — same trust class as read_terminal, so auto-allow. Posting
|
|
259
|
+
// across rooms is a later tier and will gate on a card at both ends.
|
|
260
|
+
if (toolName === 'mcp__thinkpool__list_sessions' || toolName === 'mcp__thinkpool__read_session') {
|
|
261
|
+
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (read-only ThinkPool cross-session read — your own rooms).' } }
|
|
262
|
+
}
|
|
256
263
|
// ThinkPool cross-terminal CLOSE (Tier C+) — closes only a lane the agent itself
|
|
257
264
|
// spawned (bridge-side spawnedBy check), never a sibling's work nor the host
|
|
258
265
|
// terminal. Low-risk lane management → auto-allow (surfaced as a card for visibility).
|
|
@@ -413,6 +420,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
413
420
|
'CROSS-TERMINAL AWARENESS: this room may have other terminals open alongside yours — other agents working, or shells the people are driving. You have a READ-ONLY tool, read_terminal: call it with no arguments to list the other open terminals, or with a terminal ref/id/command to read that terminal\'s recent activity. Reach for it when your work depends on what another terminal is doing (e.g. someone says "see what the other terminal hit", or you need to coordinate with a sibling agent before acting). It only ever reads — it never changes another terminal. Identify a terminal by its NAME or its ref/id from the roster, never by an on-screen number like "Terminal 2" — those positional labels renumber when a terminal is closed, so they do not reliably point at a lane.',
|
|
414
421
|
'CROSS-TERMINAL HAND-OFF: you also have post_to_terminal(terminal, text) to send a message or task to ANOTHER AGENT terminal in this room (not a plain shell). Use it sparingly and only when the people clearly want the lanes to coordinate — e.g. "tell the backend terminal the API is ready", or to hand a sibling agent a concrete task. Every post requires a person in the room to approve a card before it is delivered, and an agent that was itself reached via a cross-post cannot post onward — so do not rely on it for chit-chat or loops. Prefer read_terminal to understand a sibling before you ever post to it.',
|
|
415
422
|
'CROSS-TERMINAL SPAWN: when a person asks you to fan work out across multiple agents — research lanes, parallel sub-tasks, a swarm — open YOUR OWN fresh lanes with spawn_terminal(name?, task?, model?); do NOT dump the work into siblings that are already busy (a sibling is mid-task unless read_terminal shows it idle, and hijacking it derails their work). spawn_terminal opens a brand-new Claude lane and, if you pass `task`, hands it that task immediately; it appears as a new terminal in the room. A spawned lane INHERITS your current permission mode by default — so if you are in bypass, your lanes run autonomously with no per-action clicking; pass `mode` only to override. Raising a lane to bypass from a non-bypass lane asks the room to confirm once (everything else spawns without a prompt). Collect each lane\'s result later with read_terminal (a spawned lane cannot post back to you), and tidy up with close_terminal once you have what you need — you may only close lanes you spawned yourself. You are bounded: a few new lanes per turn, a per-plan dispatch ceiling on concurrent spawned lanes (Free 3 / Plus 6) that is separate from the human terminals so it never blocks them, an overall room machine cap, and a lane you were spawned/cross-posted into cannot itself spawn (so no runaway). This is how you "open the terminals yourself" instead of asking a person to.',
|
|
423
|
+
'CROSS-SESSION AWARENESS: the Ensemble reaches across your SESSIONS, not just the terminals in this room. You have two READ-ONLY tools for your OTHER ThinkPool Code rooms running on this machine: list_sessions() lists them (room code + name), and read_session(session, terminal?) reads recent activity inside one (omit `terminal` to list that room\'s terminals, or pass a ref/name from it to read that lane). Reach for them when work spans your rooms — "what\'s the other project up to", "pick up where the other session left off", or to check a long-running task in another room before you act here. They only ever read — they never change another session, and they reach ONLY your own rooms (never a stranger\'s). This works only when your sessions run under the ThinkPool account bridge; a standalone room sees just its own terminals. Posting work INTO another session is not available yet — for now, read across sessions and coordinate with the people in the room.',
|
|
416
424
|
'WRITE PLANS INTO THE CHAT: whenever you form or revise a plan — because the room is in Plan mode, or because someone asked you to plan, design, or think it through first — write the actual plan out as a normal message in the room as you develop it: the approach, the concrete steps, the files you will touch, the open questions. The room does NOT surface plan files at all, and the plan-approval card does not reliably carry the plan text, so a plan that lives only in a plan file or only inside ExitPlanMode is INVISIBLE to the people you are working with — they just see "plan ready" with no content. The chat is the canonical place your plan lives; put it there so the room can read and react to it before you proceed.',
|
|
417
425
|
].join(' '),
|
|
418
426
|
// Needed for live thinking-token progress (SDKThinkingTokensMessage) to
|
package/cross-terminal.mjs
CHANGED
|
@@ -202,3 +202,30 @@ export const spawnDecision = ({ hop = 0, spawnCount = 0, spawnedLive = 0, totalL
|
|
|
202
202
|
if (spawnCount >= o.perTurnCap) return { ok: false, reason: `Spawn limit reached for this turn (${o.perTurnCap}).` }
|
|
203
203
|
return { ok: true }
|
|
204
204
|
}
|
|
205
|
+
|
|
206
|
+
// ── Tier 1 cross-ROOM peek (read-only, same-machine) ─────────────────────────
|
|
207
|
+
// Thinkpool Ensemble expanded from cross-TERMINAL (sibling lanes in one room) to
|
|
208
|
+
// cross-SESSION (the account's OTHER rooms served by the same account supervisor).
|
|
209
|
+
// This module only formats the ROSTER of other rooms; the per-room terminal read
|
|
210
|
+
// reuses formatPeek as-is on the REMOTE side (the target room's bridge runs it over
|
|
211
|
+
// its own registries). Cross-room is rarer + costlier than in-room peek (an IPC /
|
|
212
|
+
// future-bus round-trip per call), so the per-turn cap is tighter than PEEK's.
|
|
213
|
+
// Spec: docs/specs/2026-06-30-cross-room-ensemble.md
|
|
214
|
+
export const CROSSROOM = {
|
|
215
|
+
peekPerTurnCap: 5, // list_sessions + read_session calls allowed per turn (vs PEEK.perTurnCap=10 in-room)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Render the list_sessions roster from the supervisor's room list. `rooms` is
|
|
219
|
+
// [{ code, name }]; `thisRoom` is excluded defensively (the supervisor already
|
|
220
|
+
// drops it). Mirrors formatPeek's no-arg roster shape so the agent reads one voice.
|
|
221
|
+
export const formatPairRoster = (rooms, { thisRoom, error } = {}) => {
|
|
222
|
+
if (error) return String(error)
|
|
223
|
+
const seen = new Set()
|
|
224
|
+
const list = (Array.isArray(rooms) ? rooms : []).filter((r) => {
|
|
225
|
+
if (!r || !r.code || r.code === thisRoom || seen.has(r.code)) return false
|
|
226
|
+
seen.add(r.code); return true
|
|
227
|
+
})
|
|
228
|
+
if (!list.length) return 'No other sessions of yours are open on this machine right now. (Cross-session reach is limited to rooms this account is actively running here.)'
|
|
229
|
+
const rows = list.map((r) => `- ${r.code}${r.name ? ` · "${r.name}"` : ''}`)
|
|
230
|
+
return `Your other ThinkPool Code sessions on this machine (call read_session with a room code to look inside one):\n${rows.join('\n')}`
|
|
231
|
+
}
|
package/package.json
CHANGED
package/session-store.mjs
CHANGED
|
@@ -91,6 +91,22 @@ export function seedDurableEvents(room, id, events) {
|
|
|
91
91
|
} catch { /* noop */ }
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// The archive FLOOR: the lowest transcript seq durably stored for this terminal.
|
|
95
|
+
// The client compares its oldest loaded seq against this — only OFFER "load earlier"
|
|
96
|
+
// when there genuinely are older events in the archive (vs a fresh lane whose first
|
|
97
|
+
// turn happens to land at seq > 1, or an evicted-but-unarchived gap). null = no archive.
|
|
98
|
+
export function readDurableOldestSeq(room, id) {
|
|
99
|
+
try {
|
|
100
|
+
const f = eventsLog(room, id)
|
|
101
|
+
if (!fs.existsSync(f)) return null
|
|
102
|
+
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
|
|
103
|
+
if (!line) continue
|
|
104
|
+
try { const e = JSON.parse(line); if (e && e.seq != null) return e.seq } catch { /* skip torn line */ }
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
} catch { return null }
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
// Read a backward page: events with transcript seq < beforeSeq (newest `limit` of
|
|
95
111
|
// them, no-seq chrome interleaved by file order preserved). Returns { events, hasMore }.
|
|
96
112
|
// beforeSeq null → the newest `limit` events (the tail). hasMore = there is older still.
|