thinkpool-pair 0.7.213 → 0.7.215

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/bridge.mjs CHANGED
@@ -104,6 +104,7 @@ import { armInterruptedResume, sendInterruptedContinue, supersedeInterruptedResu
104
104
  import { turnInFlight } from './update-gate.mjs'
105
105
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
106
106
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
107
+ import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, sideContextBlock, sideSnapshot } from './side-lane.mjs'
107
108
  import { planMeterLine } from './plan-meters.mjs'
108
109
  import { makeThrottledTrack } from './presence.mjs'
109
110
  import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
@@ -1045,7 +1046,7 @@ const announce = () => {
1045
1046
  // laneStatusOf: authoritative busy/idle + last-action timestamp/age +
1046
1047
  // STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
1047
1048
  // every roster consumer reads one status instead of reconstructing it.
1048
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1049
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1049
1050
  // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
1050
1051
  // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
1051
1052
  // built-in/default Claude path (no badge). Makes the lane's provider badge +
@@ -1542,7 +1543,7 @@ function worktreeSnapshot(cwd) {
1542
1543
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1543
1544
  // persist to the host file; tool calls round-trip through the perm card; the
1544
1545
  // rolling log replays to joiners and survives bridge restarts (session-store).
1545
- function openStructured({ id, runtime = 'claude', model, effort, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
1546
+ function openStructured({ id, runtime = 'claude', model, effort, resume, log, commands, mode, spawnedBy, sideParent, sideTask, pendingSideContexts, rolePrompt, flowSessionId, flowTaskKey, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
1546
1547
  if (sessions.has(id)) return
1547
1548
  runtime = runtime === 'codex' ? 'codex' : 'claude'
1548
1549
  // No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
@@ -1579,7 +1580,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1579
1580
  : (!provider || provider === 'anthropic')
1580
1581
  ? (laneModel || null)
1581
1582
  : (laneModel || providerNameMap()[provider] || provider),
1582
- provider: provider || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
1583
+ provider: provider || null, spawnedBy: spawnedBy || undefined, sideParent: sideParent || undefined, sideTask: sideTask || undefined, pendingSideContexts: Array.isArray(pendingSideContexts) ? pendingSideContexts.filter(Boolean).slice(-4) : [], flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
1583
1584
  // Stable creation order — persisted so a bridge restart restores tabs in the SAME
1584
1585
  // order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
1585
1586
  // first transcript event ts, so even the first post-fix restart is ordered right.
@@ -1773,7 +1774,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1773
1774
  // restart. Without this, sessionData omitted it → on restart the resumed session
1774
1775
  // re-launched on the host default (Opus) regardless of the last switch, and the
1775
1776
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
1776
- const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
1777
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
1777
1778
  const persist = () => saveSession(room, id, sessionData())
1778
1779
  // Synchronous flush of this session's record. Used on open (so a brand-new session
1779
1780
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -2216,7 +2217,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2216
2217
  try { entry.mockupWatcher?.close() } catch { /* noop */ }
2217
2218
  try { void entry.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
2218
2219
  sessions.delete(id)
2219
- openStructured({ id, runtime: entry.runtime, model, log: entry.log, commands: entry.commands, mode: entry.mode })
2220
+ openStructured({ id, runtime: entry.runtime, model, log: entry.log, commands: entry.commands, mode: entry.mode, provider: entry.provider, spawnedBy: entry.spawnedBy, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, rolePrompt: entry.rolePrompt, cwd: entry.cwd, managedWorktree: entry.managedWorktree })
2220
2221
  return
2221
2222
  }
2222
2223
  // Stamp a wall-clock ts AND a stable cid on every transcript event before
@@ -2391,6 +2392,26 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2391
2392
  // _turnStart is cleared unconditionally so a stray post-settle event can't
2392
2393
  // re-notify off a stale stamp.
2393
2394
  if (evt.kind === 'result' || evt.kind === 'error') {
2395
+ if (entry.sideHandback) {
2396
+ const request = entry.sideHandback
2397
+ entry.sideHandback = null
2398
+ const parent = sessions.get(request.parentId)
2399
+ const successful = evt.kind === 'result' && (!evt.subtype || evt.subtype === 'success')
2400
+ const summary = successful ? (String(evt.resultText || '').trim() || assistantTextSince(entry.log, request.afterSeq)) : ''
2401
+ if (parent && summary) {
2402
+ const context = sideContextBlock({ sideId: id, sideName: termNames[id], task: entry.sideTask, summary })
2403
+ parent.pendingSideContexts = appendSideContext(parent.pendingSideContexts, context)
2404
+ const handoff = { kind: 'side-handback', sideId: id, sideName: termNames[id] || 'Side lane', task: entry.sideTask || '', text: summary, by: request.by }
2405
+ stampEvent(handoff); pushLog(parent, handoff); bcast('code-event', { term: request.parentId, evt: handoff })
2406
+ const done = { kind: 'control', text: 'Brought to main.', by: request.by }
2407
+ stampEvent(done); pushLog(entry, done); bcast('code-event', { term: id, evt: done })
2408
+ parent.flush?.()
2409
+ } else {
2410
+ const failed = { kind: 'control', text: parent ? 'Couldn’t prepare a handoff — try again.' : 'The main terminal is no longer available.', by: request.by }
2411
+ stampEvent(failed); pushLog(entry, failed); bcast('code-event', { term: id, evt: failed })
2412
+ }
2413
+ entry.flush?.(); announce()
2414
+ }
2394
2415
  const startedAt = entry._turnStart
2395
2416
  entry._turnStart = null
2396
2417
  // A canceled/failed App Server request can settle the turn without the
@@ -2589,7 +2610,7 @@ function respawnStructured(id, provider) {
2589
2610
  // openStructured seed from the TARGET provider's configured model, which is the
2590
2611
  // only model this lane was ever asked for. A same-env model change never reaches
2591
2612
  // here — that path is an in-place setModel (see provider-switch).
2592
- const { runtime, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
2613
+ const { runtime, log, commands, mode, spawnedBy, sideParent, sideTask, pendingSideContexts, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
2593
2614
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
2594
2615
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
2595
2616
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -2608,7 +2629,7 @@ function respawnStructured(id, provider) {
2608
2629
  // sessionData() (provider included) synchronously on open, so a bridge restart
2609
2630
  // restores the lane on its CURRENT provider, not the original — and its next
2610
2631
  // announce carries the new provider badge (additive {id,name} projection).
2611
- openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt, carryRecap })
2632
+ openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, sideParent, sideTask, pendingSideContexts, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt, carryRecap })
2612
2633
  return true
2613
2634
  }
2614
2635
 
@@ -2660,7 +2681,7 @@ function endStructured(id) {
2660
2681
  // Cascade-close its direct children (spawnedBy === this id — a real terminal id, not a
2661
2682
  // `flow:<id>` marker, so flow lanes are untouched; maxHop:1 means no deeper recursion).
2662
2683
  for (const [cid, ce] of [...sessions]) {
2663
- if (ce.spawnedBy === id) endStructured(cid)
2684
+ if (ce.spawnedBy === id || ce.sideParent === id) endStructured(cid)
2664
2685
  }
2665
2686
  }
2666
2687
  deleteSession(room, id) // remove <id>.json so loadAll() won't bring it back
@@ -2771,11 +2792,60 @@ channel
2771
2792
  // previous terminal's model (and the first uses the provider default). Falls
2772
2793
  // through to the SDK default when absent. Stamped on the entry so the announce
2773
2794
  // carries it at open (chip shows the model immediately, not the branch).
2774
- const runtime = structuredRuntime(payload.cmd)
2795
+ let runtime = structuredRuntime(payload.cmd)
2796
+ let sideParent = null
2797
+ let parent = null
2798
+ if (payload.sideParent) {
2799
+ const requested = sessions.get(payload.sideParent)
2800
+ sideParent = requested?.sideParent || payload.sideParent
2801
+ parent = sessions.get(sideParent)
2802
+ if (!parent || !String(payload.sideTask || '').trim()) {
2803
+ bcast('side-open-failed', { id: payload.id, parent: sideParent, message: parent ? 'Add a task after /side.' : 'The main terminal is not live.' })
2804
+ return
2805
+ }
2806
+ runtime = parent.runtime || runtime
2807
+ if (payload.name) { termNames[payload.id] = String(payload.name).slice(0, 80); saveNames(room, termNames) }
2808
+ }
2775
2809
  // Opening a tab is not work. Paint and announce it immediately, then cold-boot the
2776
2810
  // expensive Claude/Codex runtime on its first real turn. This keeps a fresh terminal
2777
2811
  // instant and prevents abandoned tabs from becoming permanent resident processes.
2778
- openStructured({ id: payload.id, runtime, mode: payload.mode, model: payload.model || undefined, provider: runtime === 'codex' ? undefined : payload.provider || undefined, defer: true })
2812
+ openStructured({
2813
+ id: payload.id,
2814
+ runtime,
2815
+ mode: parent?.mode || payload.mode,
2816
+ model: parent?.model || payload.model || undefined,
2817
+ provider: runtime === 'codex' ? undefined : (parent?.provider || payload.provider || undefined),
2818
+ cwd: parent ? (parent.cwd || process.cwd()) : undefined,
2819
+ sideParent: sideParent || undefined,
2820
+ sideTask: sideParent ? String(payload.sideTask).trim() : undefined,
2821
+ rolePrompt: sideParent ? SIDE_ROLE_PROMPT : undefined,
2822
+ defer: true,
2823
+ })
2824
+ if (sideParent) {
2825
+ const child = sessions.get(payload.id)
2826
+ const task = String(payload.sideTask).trim()
2827
+ const by = String(payload.by || 'A room member')
2828
+ const sideName = termNames[payload.id] || 'Side lane'
2829
+ if (!child) {
2830
+ bcast('side-open-failed', { id: payload.id, parent: sideParent, message: 'The side lane could not start.' })
2831
+ return
2832
+ }
2833
+ child.hop = 0
2834
+ const childEvent = { kind: 'you', text: task, by }
2835
+ stampEvent(childEvent); pushLog(child, childEvent); bcast('code-event', { term: payload.id, evt: childEvent })
2836
+ const parentEvent = { kind: 'side-started', sideId: payload.id, sideName, task, by }
2837
+ stampEvent(parentEvent); pushLog(parent, parentEvent); bcast('code-event', { term: sideParent, evt: parentEvent })
2838
+ const snapshot = sideSnapshot(parent.log.filter((event) => event.cid !== parentEvent.cid))
2839
+ const prompt = snapshot ? `${snapshot}\n\n--- side task ---\n${task}` : task
2840
+ try { child.session.sendTurn(prompt) } catch {
2841
+ bcast('side-open-failed', { id: payload.id, parent: sideParent, message: 'The side agent could not start.' })
2842
+ endStructured(payload.id)
2843
+ return
2844
+ }
2845
+ child.flush?.(); parent.flush?.(); announce()
2846
+ process.stderr.write(`\n ◆ ${by} opened side lane ${String(payload.id).slice(0, 8)} under ${String(sideParent).slice(0, 8)}.\n`)
2847
+ return
2848
+ }
2779
2849
  process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}${payload.model ? `, model ${payload.model}` : ''}).\n`)
2780
2850
  return
2781
2851
  }
@@ -2924,6 +2994,27 @@ channel
2924
2994
  if (payload?.host && payload.host !== name) return
2925
2995
  openStructured({ id: payload?.id || randomUUID(), runtime: payload?.runtime, model: payload?.model, provider: payload?.runtime === 'codex' ? undefined : payload?.provider, resume: payload?.resume, mode: payload?.mode, defer: true })
2926
2996
  })
2997
+ .on('broadcast', { event: 'side-handback-request' }, ({ payload }) => {
2998
+ const side = payload?.term && sessions.get(payload.term)
2999
+ if (!side?.sideParent || side.sideParent !== payload?.parent) return
3000
+ const by = String(payload?.by || 'A room member')
3001
+ if (side.sideHandback || side.session?.turnActive) {
3002
+ const busy = { kind: 'control', text: 'Finish or stop the current side-lane turn before bringing it to main.', by }
3003
+ stampEvent(busy); pushLog(side, busy); bcast('code-event', { term: payload.term, evt: busy })
3004
+ return
3005
+ }
3006
+ side.sideHandback = { parentId: side.sideParent, by, afterSeq: maxSeq(side.log) }
3007
+ const preparing = { kind: 'control', text: 'Preparing a compact handoff for main…', by }
3008
+ stampEvent(preparing); pushLog(side, preparing); bcast('code-event', { term: payload.term, evt: preparing })
3009
+ side.flush?.(); announce()
3010
+ try { side.session.sendTurn(SIDE_HANDOFF_PROMPT) }
3011
+ catch {
3012
+ side.sideHandback = null
3013
+ const failed = { kind: 'control', text: 'Couldn’t start the handoff — try again.', by }
3014
+ stampEvent(failed); pushLog(side, failed); bcast('code-event', { term: payload.term, evt: failed })
3015
+ side.flush?.(); announce()
3016
+ }
3017
+ })
2927
3018
  .on('broadcast', { event: 'code-turn' }, async ({ payload }) => {
2928
3019
  const s = payload?.term && sessions.get(payload.term)
2929
3020
  if (!s || payload.text == null) return
@@ -3069,7 +3160,10 @@ channel
3069
3160
  // — a separate recap turn would race + double-fire) and clear the flag so the init handler
3070
3161
  // no-ops. The visible `you` echo is unchanged; the recap is context for the agent only.
3071
3162
  let sendText = text
3072
- if (s.pendingRecap) { sendText = `${s.pendingRecap}\n\n--- the person's next message ---\n${text}`; s.pendingRecap = null; s.flush?.() }
3163
+ const carried = []
3164
+ if (s.pendingRecap) { carried.push(s.pendingRecap); s.pendingRecap = null }
3165
+ if (s.pendingSideContexts?.length) { carried.push(...s.pendingSideContexts); s.pendingSideContexts = [] }
3166
+ if (carried.length) { sendText = `${carried.join('\n\n')}\n\n--- the person's next message ---\n${text}`; s.flush?.() }
3073
3167
  // The browser's `hostPath` is display metadata, not host authority. Rebuild
3074
3168
  // image paths from the bridge-owned UPDIR + deterministic attachment fields,
3075
3169
  // then cover the separate file-put/code-turn download race with a bounded
@@ -3312,7 +3406,7 @@ channel
3312
3406
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
3313
3407
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
3314
3408
  // lanes keep their worktree cwd + the ability to mark done.
3315
- openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, effort: rec.effort, provider: rec.runtime === 'codex' ? undefined : rec.provider || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: rec.carryRecap,
3409
+ openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, effort: rec.effort, provider: rec.runtime === 'codex' ? undefined : rec.provider || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: rec.carryRecap,
3316
3410
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
3317
3411
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
3318
3412
  // (mid-turn needs auto-resume; flow needs its lane live).
package/codex-session.mjs CHANGED
@@ -479,7 +479,6 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
479
479
  })
480
480
  appServerThreadReady = true
481
481
  mapper.push({ type: 'thread.started', thread_id: sessionId })
482
- note(`Codex App Server active (${appServerGateResult.version || 'compatible version'}).`)
483
482
  return true
484
483
  } catch (error) {
485
484
  // Fallback is safe only here: no turn/start request has been accepted, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.213",
3
+ "version": "0.7.215",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,7 @@
30
30
  "recap.mjs",
31
31
  "transcript-sanitize.mjs",
32
32
  "session-store.mjs",
33
+ "side-lane.mjs",
33
34
  "cross-terminal.mjs",
34
35
  "interrupted-resume.mjs",
35
36
  "flow-conductor.mjs",
package/side-lane.mjs ADDED
@@ -0,0 +1,42 @@
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 function sideSnapshot(log) {
12
+ return buildRecapFromLog(Array.isArray(log) ? log : [], SIDE_RECAP_CAP)
13
+ }
14
+
15
+ export function assistantTextSince(log, afterSeq = 0) {
16
+ const chunks = []
17
+ for (const event of Array.isArray(log) ? log : []) {
18
+ if (event?.kind !== 'assistant') continue
19
+ if (Number.isFinite(afterSeq) && Number.isFinite(event.seq) && event.seq <= afterSeq) continue
20
+ for (const block of Array.isArray(event.blocks) ? event.blocks : []) {
21
+ if (block?.type === 'text' && String(block.text || '').trim()) chunks.push(String(block.text).trim())
22
+ }
23
+ }
24
+ return chunks.join('\n\n').trim()
25
+ }
26
+
27
+ export function sideContextBlock({ sideId, sideName, task, summary }) {
28
+ const label = String(sideName || 'Side lane').trim()
29
+ const ref = String(sideId || '').slice(0, 8)
30
+ const lines = [
31
+ `--- side-lane handoff: ${label}${ref ? ` (${ref})` : ''} ---`,
32
+ task ? `Task: ${String(task).trim()}` : '',
33
+ String(summary || '').trim(),
34
+ '--- end side-lane handoff ---',
35
+ ]
36
+ return lines.filter(Boolean).join('\n')
37
+ }
38
+
39
+ export function appendSideContext(contexts, context, cap = 4) {
40
+ if (!context) return Array.isArray(contexts) ? contexts.slice(-cap) : []
41
+ return [...(Array.isArray(contexts) ? contexts : []), context].slice(-cap)
42
+ }