thinkpool-pair 0.7.207 → 0.7.208

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
@@ -98,7 +98,7 @@ const flowRedispatch = new Map()
98
98
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
99
99
  // broadcasts; without persistent state the cap can never bite.
100
100
  const flowBudgets = new Map()
101
- import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint } from './cross-terminal.mjs'
101
+ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, laneStatusOf } from './cross-terminal.mjs'
102
102
  import { armInterruptedResume, sendInterruptedContinue, supersedeInterruptedResume } from './interrupted-resume.mjs'
103
103
  import { turnInFlight } from './update-gate.mjs'
104
104
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
@@ -1034,18 +1034,17 @@ const announce = () => {
1034
1034
  // cols/rows: the PTY's one true size — web viewers render this grid and
1035
1035
  // scale it to their own page instead of voting to reflow it.
1036
1036
  terms: [
1037
- ...[...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows, name: termNames[id] || undefined })),
1037
+ ...[...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows, name: termNames[id] || undefined, ...laneStatusOf(t) })),
1038
1038
  // Structured sessions advertise kind:'structured' so the web renders the
1039
1039
  // reader (not xterm) and drives them with code-turn / code-perm. `mode` +
1040
1040
  // `name` ride along so a second device shows the live permission mode and
1041
1041
  // the chosen label instead of defaulting to Normal / unnamed. `spawned` (set
1042
1042
  // when an agent Dispatched this lane via spawn_terminal) lets the client keep
1043
1043
  // it OUT of the top tab strip and show it only in the Ensemble menu.
1044
- // busy: this structured session is mid-turn right now (the SDK's
1045
- // turnActive). Authoritative — the bridge owns the turn — so the /code
1046
- // dashboard reads "N agents working" off the roster instead of sniffing
1047
- // the live code-event stream (which a passive dashboard channel misses).
1048
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, busy: s.session?.turnActive ?? false, 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 || [] } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1044
+ // laneStatusOf: authoritative busy/idle + last-action timestamp/age +
1045
+ // STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
1046
+ // every roster consumer reads one status instead of reconstructing it.
1047
+ ...[...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 || [] } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1049
1048
  // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
1050
1049
  // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
1051
1050
  // built-in/default Claude path (no badge). Makes the lane's provider badge +
@@ -1096,6 +1095,7 @@ function raiseCard(entry, req) {
1096
1095
  if (!entry?.pending) return resolve('deny')
1097
1096
  const payload = { term: entry.id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk }
1098
1097
  entry.pending.set(req.id, { resolve, payload })
1098
+ announce()
1099
1099
  bcast('code-perm-req', payload)
1100
1100
  process.stderr.write(`\n ● incoming cross-room task for ${String(entry.id).slice(0, 8)} — approve in the room.\n`)
1101
1101
  })
@@ -1404,12 +1404,13 @@ function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
1404
1404
  rows: rows || (attached ? ad.rows : (PIN_ROWS || 32)),
1405
1405
  cwd: process.cwd(), env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
1406
1406
  })
1407
- const entry = { term, cmd, attached, scrollback: '', buf: '', mockupWatcher: watchOutbox(mockupOutbox, () => id) }
1407
+ const entry = { term, cmd, attached, scrollback: '', buf: '', lastActionAt: null, mockupWatcher: watchOutbox(mockupOutbox, () => id) }
1408
1408
  terms.set(id, entry)
1409
1409
  if (attached) attachedId = id
1410
1410
  term.onData(d => {
1411
1411
  if (attached) process.stdout.write(d)
1412
1412
  entry.buf += d
1413
+ entry.lastActionAt = Date.now()
1413
1414
  entry.scrollback = (entry.scrollback + d).slice(-SCROLLBACK_MAX)
1414
1415
  })
1415
1416
  term.onExit(() => {
@@ -1479,6 +1480,8 @@ function pushLog(entry, evt) {
1479
1480
  // A real ts makes ordering wall-clock — immune to eviction AND /clear seq resets.
1480
1481
  // stampEvent is idempotent, so the callers that already stamp are unaffected.
1481
1482
  stampEvent(evt)
1483
+ entry.lastActionAt = evt.ts
1484
+ entry.lastEvent = evt
1482
1485
  // Only TRANSCRIPT events consume a seq (seqable) — replay-chrome kinds the web
1483
1486
  // filters out (system/thinking_tokens/effort) ride the log seq-less so they don't
1484
1487
  // hole the client's applied sequence. See event-id.mjs NO_SEQ_KINDS.
@@ -1783,7 +1786,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1783
1786
  ...createViewportTools({ tool, z, manager: entry.viewport }),
1784
1787
  tool(
1785
1788
  'read_terminal',
1786
- 'Read-only view of ANOTHER terminal in this ThinkPool Code room (a sibling agent or a shell the people are using). Call with no arguments to list the other open terminals; call with `terminal` (a ref, id, or command from that list) to read its recent activity. It never changes another terminal — reading only. Use it when your work depends on what another terminal is doing.',
1789
+ 'Read-only view of ANOTHER terminal in this ThinkPool Code room (a sibling agent or a shell the people are using). Call with no arguments to list every open terminal with busy/idle state, last-action age, and any STUCK/BLOCKED flag; call with `terminal` (a ref, id, or command from that list) to read its recent activity. It never changes another terminal — reading only. Use the one-read roster to identify a lane that needs attention before opening its transcript.',
1787
1790
  {
1788
1791
  terminal: z.string().optional().describe('ref, id, or command of the terminal to read; omit to list the open terminals'),
1789
1792
  lines: z.number().int().positive().max(PEEK.maxLines).optional().describe(`how many recent lines to return (default ${PEEK.defaultLines})`),
@@ -2216,6 +2219,10 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2216
2219
  // id, an event that arrives both live AND in a reconnect replay renders
2217
2220
  // twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
2218
2221
  stampEvent(evt)
2222
+ const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
2223
+ entry.stalled = evt.kind === 'stalled'
2224
+ entry.lastEvent = evt
2225
+ if (evt.kind !== 'stalled') entry.lastActionAt = evt.ts
2219
2226
  // Re-announce the roster on THIS session's busy EDGE — turn start (turnActive
2220
2227
  // flips true on the turn's first event) and turn end (false on result/error).
2221
2228
  // Edge-triggered: one announce per transition, not per token event. This is what
@@ -2227,7 +2234,9 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2227
2234
  // bridge sees "this lane just began working". `result`/`error` reads it to decide
2228
2235
  // whether the turn was long enough that nobody was watching (Slice 3).
2229
2236
  { const busyNow = entry.session?.turnActive ?? false
2230
- if (busyNow !== entry._busyAnn) { if (busyNow) entry._turnStart = Date.now(); entry._busyAnn = busyNow; announce() } }
2237
+ const busyChanged = busyNow !== entry._busyAnn
2238
+ if (busyChanged) { if (busyNow) entry._turnStart = Date.now(); entry._busyAnn = busyNow }
2239
+ if (busyChanged || stalledChanged) announce() }
2231
2240
  // Capture this session's current MODEL (resolved full id, with version — from
2232
2241
  // system/result `model` or usage `ctx.model`) and the SDK's supported-model
2233
2242
  // LIST (from the `models` event) so the announce carries them to EVERY device.
@@ -2416,6 +2425,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2416
2425
  // (replay-request handler) — pending cards never enter the replayed event log.
2417
2426
  const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions }
2418
2427
  entry.pending.set(req.id, { resolve, payload })
2428
+ announce()
2419
2429
  bcast('code-perm-req', payload)
2420
2430
  // Slice 3 — arm the grace timer. Fires a "needs you" push only if the card is
2421
2431
  // STILL unanswered 20s from now (you're not in the room). Every resolve path —
@@ -3040,11 +3050,11 @@ channel
3040
3050
  // use run/accept/keep (claude-session interprets). Default deny on missing.
3041
3051
  // Answered from a device → retract the "needs you" banner on every OTHER device
3042
3052
  // (permNotifier.resolve is a no-op unless its grace timer already fired).
3043
- if (p) { s.pending.delete(payload.id); s.permNotifier?.resolve(payload.id); p.resolve(payload.decision || 'deny') }
3053
+ if (p) { s.pending.delete(payload.id); s.permNotifier?.resolve(payload.id); p.resolve(payload.decision || 'deny'); announce() }
3044
3054
  })
3045
3055
  .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
3046
3056
  const s = payload?.term && sessions.get(payload.term)
3047
- if (s) { drainPending(s); s.session.abort() } // settle any open permission card so the hook doesn't hang
3057
+ if (s) { drainPending(s); s.session.abort(); announce() } // settle any open permission card so the hook doesn't hang
3048
3058
  })
3049
3059
  .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
3050
3060
  const s = payload?.term && sessions.get(payload.term)
@@ -3067,6 +3077,7 @@ channel
3067
3077
  if (payload.mode === 'bypassPermissions') allowPending(s)
3068
3078
  // Flipping to acceptEdits = "auto-approve writes" — clear the write cards already raised.
3069
3079
  else if (payload.mode === 'acceptEdits') acceptEditsPending(s)
3080
+ announce()
3070
3081
  }
3071
3082
  })
3072
3083
  .on('broadcast', { event: 'code-effort' }, ({ payload }) => {
@@ -18,6 +18,76 @@ export const PEEK = {
18
18
  rosterPreview: 100, // last-line preview length in the roster listing
19
19
  }
20
20
 
21
+ // One shared lane-status classifier for every roster consumer: the bridge wire,
22
+ // read_terminal, and ROOM NOW. Timestamps stay absolute on the wire so clients can
23
+ // keep the displayed age current without a broadcast every second.
24
+ export const LANE_STATUS = {
25
+ stuckAfterMs: 90_000, // matches the structured-turn stall watchdog
26
+ repeatedFailureCount: 2,
27
+ failureWindow: 12,
28
+ }
29
+
30
+ const failureEvent = (e) => !!e && (
31
+ e.kind === 'error'
32
+ || (e.kind === 'tool_result' && e.isError)
33
+ || (e.kind === 'result' && e.subtype && e.subtype !== 'success')
34
+ )
35
+
36
+ const recentFailureCount = (log, limits = LANE_STATUS) => {
37
+ let failures = 0, seen = 0
38
+ for (let i = (Array.isArray(log) ? log.length : 0) - 1; i >= 0 && seen < limits.failureWindow; i--) {
39
+ const e = log[i]
40
+ if (!e || typeof e !== 'object') continue
41
+ seen++
42
+ // A clean completion or a fresh human task closes the prior failure streak.
43
+ if ((e.kind === 'result' && (!e.subtype || e.subtype === 'success')) || e.kind === 'you') break
44
+ if (failureEvent(e)) failures++
45
+ }
46
+ return failures
47
+ }
48
+
49
+ export const laneStatusOf = (entry = {}, now = Date.now(), limits = LANE_STATUS) => {
50
+ const baseLog = Array.isArray(entry?.log) ? entry.log : []
51
+ const log = baseLog.slice(-limits.failureWindow)
52
+ if (entry?.lastEvent && entry.lastEvent !== baseLog[baseLog.length - 1]
53
+ && (!entry.lastEvent.cid || entry.lastEvent.cid !== baseLog[baseLog.length - 1]?.cid)) log.push(entry.lastEvent)
54
+ let lastLogAt = null
55
+ for (let i = log.length - 1; i >= 0; i--) {
56
+ if (Number.isFinite(log[i]?.ts)) { lastLogAt = log[i].ts; break }
57
+ }
58
+ const lastActionAt = Number.isFinite(entry?.lastActionAt) ? entry.lastActionAt : (lastLogAt ?? null)
59
+ const lastActionAgeMs = lastActionAt == null ? null : Math.max(0, now - lastActionAt)
60
+ const busy = typeof entry?.busy === 'boolean' ? entry.busy : (entry?.session?.turnActive ?? false)
61
+ const pendingCount = entry?.pending instanceof Map
62
+ ? entry.pending.size
63
+ : Math.max(0, Number(entry?.pendingCount) || 0)
64
+ const failures = recentFailureCount(log, limits)
65
+
66
+ let statusFlag = null, statusReason = null
67
+ if (pendingCount > 0) {
68
+ statusFlag = 'BLOCKED'; statusReason = 'waiting for input'
69
+ } else if (entry?.stalled || (busy && !entry?.compacting && lastActionAgeMs != null && lastActionAgeMs >= limits.stuckAfterMs)) {
70
+ statusFlag = 'STUCK'; statusReason = 'no events while busy'
71
+ } else if (failures >= limits.repeatedFailureCount) {
72
+ statusFlag = 'BLOCKED'; statusReason = 'repeated errors'
73
+ }
74
+
75
+ return { busy: !!busy, lastActionAt, lastActionAgeMs, statusFlag, statusReason }
76
+ }
77
+
78
+ export const formatLaneAge = (ageMs) => {
79
+ if (!Number.isFinite(ageMs)) return 'no activity'
80
+ const s = Math.max(0, Math.floor(ageMs / 1000))
81
+ if (s < 60) return `${s}s ago`
82
+ const m = Math.floor(s / 60)
83
+ if (m < 60) return `${m}m ago`
84
+ const h = Math.floor(m / 60)
85
+ if (h < 24) return `${h}h ago`
86
+ return `${Math.floor(h / 24)}d ago`
87
+ }
88
+
89
+ const laneStatusText = (x) => `${x.busy ? 'busy' : 'idle'} · ${formatLaneAge(x.lastActionAgeMs)}${x.statusFlag ? ` · ${x.statusFlag}` : ''}`
90
+
21
91
  const clip = (s, cap) => {
22
92
  const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
23
93
  return t.length > cap ? t.slice(0, cap - 1) + '…' : t
@@ -67,16 +137,16 @@ export const siblingRef = (id) => String(id).slice(0, 8)
67
137
  // is the bridge's id→label map (termNames) — the HUMAN names people actually use
68
138
  // ("Pivot", "Grug"); without it the roster is just hashes and an agent can't map
69
139
  // "what is Pivot doing" to a lane.
70
- export const siblingsOf = ({ selfId, sessions, terms, names = {} } = {}) => {
140
+ export const siblingsOf = ({ selfId, sessions, terms, names = {}, now = Date.now() } = {}) => {
71
141
  const nm = (id) => (names && names[id]) || ''
72
142
  const sib = []
73
143
  for (const [id, s] of (sessions || new Map())) {
74
144
  if (id === selfId) continue
75
- sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'agent', cmd: s?.cmd || 'claude', log: Array.isArray(s?.log) ? s.log : [] })
145
+ sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'agent', cmd: s?.cmd || 'claude', log: Array.isArray(s?.log) ? s.log : [], ...laneStatusOf(s, now) })
76
146
  }
77
147
  for (const [id, t] of (terms || new Map())) {
78
148
  if (id === selfId) continue
79
- sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'shell', cmd: t?.cmd || 'shell', buf: t?.buf || '' })
149
+ sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'shell', cmd: t?.cmd || 'shell', buf: t?.buf || '', ...laneStatusOf(t, now) })
80
150
  }
81
151
  return sib
82
152
  }
@@ -121,9 +191,9 @@ export const resolveSibling = (siblings, terminal) => {
121
191
 
122
192
  const label = (x) => `${x.ref}${x.name ? ` · "${x.name}"` : ''}`
123
193
 
124
- export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, lines } = {}) => {
194
+ export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, lines, now = Date.now() } = {}) => {
125
195
  const n = Math.min(PEEK.maxLines, Math.max(1, Number(lines) || PEEK.defaultLines))
126
- const sib = siblingsOf({ selfId, sessions, terms, names })
196
+ const sib = siblingsOf({ selfId, sessions, terms, names, now })
127
197
  if (!sib.length) return 'No other terminals are open in this room right now.'
128
198
 
129
199
  if (!terminal) {
@@ -131,7 +201,7 @@ export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, line
131
201
  let preview
132
202
  if (x.kind === 'agent') { const ls = x.log.map(e => digestLogEvent(e, PEEK.rosterPreview)).filter(Boolean); preview = ls[ls.length - 1] || '(no activity yet)' }
133
203
  else { preview = cleanPtyTail(x.buf, 1)[0] || '(no output yet)' }
134
- return `- ${label(x)} · ${x.kind} · ${x.cmd} — ${clip(preview, PEEK.rosterPreview)}`
204
+ return `- ${label(x)} · ${x.kind} · ${x.cmd} · ${laneStatusText(x)} — ${clip(preview, PEEK.rosterPreview)}`
135
205
  })
136
206
  return `Other terminals in this room (call read_terminal with a ref or name to read one):\n${rows.join('\n')}`
137
207
  }
@@ -162,8 +232,8 @@ export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, line
162
232
  // when there is nothing to show (the reminder then carries no tail at all).
163
233
  // Spec ask (2026-07-02): "terminals need to be completely aware of the session."
164
234
  export const ROOMNOW = { laneCap: 8, previewLen: 70, wtCap: 10 }
165
- export const formatRoomNow = ({ selfId, sessions, terms, names = {}, worktrees = [] } = {}, limits = ROOMNOW) => {
166
- const sib = siblingsOf({ selfId, sessions, terms, names })
235
+ export const formatRoomNow = ({ selfId, sessions, terms, names = {}, worktrees = [], now = Date.now() } = {}, limits = ROOMNOW) => {
236
+ const sib = siblingsOf({ selfId, sessions, terms, names, now })
167
237
  const out = []
168
238
  if (sib.length) {
169
239
  const shown = sib.slice(0, limits.laneCap)
@@ -171,7 +241,7 @@ export const formatRoomNow = ({ selfId, sessions, terms, names = {}, worktrees =
171
241
  let preview
172
242
  if (x.kind === 'agent') { const ls = x.log.map(e => digestLogEvent(e, limits.previewLen)).filter(Boolean); preview = ls[ls.length - 1] || '(no activity yet)' }
173
243
  else preview = cleanPtyTail(x.buf, 1)[0] || '(no output yet)'
174
- return `- ${label(x)} · ${x.kind} — ${clip(preview, limits.previewLen)}`
244
+ return `- ${label(x)} · ${x.kind} · ${laneStatusText(x)} — ${clip(preview, limits.previewLen)}`
175
245
  })
176
246
  if (sib.length > shown.length) rows.push(`- … +${sib.length - shown.length} more (read_terminal lists all)`)
177
247
  out.push(`Other lanes in this room right now (read_terminal for detail):\n${rows.join('\n')}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.207",
3
+ "version": "0.7.208",
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": {