thinkpool-pair 0.7.207 → 0.7.209
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 +31 -14
- package/codex-session.mjs +15 -0
- package/cross-terminal.mjs +79 -9
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './
|
|
|
50
50
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
51
51
|
import { z } from 'zod'
|
|
52
52
|
import { startClaudeSession } from './claude-session.mjs'
|
|
53
|
-
import { startCodexSession, readCodexDefaultModel, readCodexModels, codexConfigForMode } from './codex-session.mjs'
|
|
53
|
+
import { startCodexSession, readCodexDefaultModel, readCodexModels, codexConfigForMode, defaultModeForRuntime } from './codex-session.mjs'
|
|
54
54
|
import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
|
|
55
55
|
import { commandOnPath } from './agent-detect.mjs'
|
|
56
56
|
|
|
@@ -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
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
|
|
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.
|
|
@@ -1541,7 +1544,13 @@ function worktreeSnapshot(cwd) {
|
|
|
1541
1544
|
function openStructured({ id, runtime = 'claude', model, effort, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
|
|
1542
1545
|
if (sessions.has(id)) return
|
|
1543
1546
|
runtime = runtime === 'codex' ? 'codex' : 'claude'
|
|
1544
|
-
|
|
1547
|
+
// No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
|
|
1548
|
+
// codex → bypassPermissions, so a freshly-opened codex terminal can fetch /
|
|
1549
|
+
// advance / ship instead of hitting the no-network wall; claude → default.
|
|
1550
|
+
// Only fires when no mode was passed — a person's pick, a resume (persisted
|
|
1551
|
+
// entry.mode), a flow lane, and spawn_terminal (inherits the parent's mode +
|
|
1552
|
+
// its own bypass-escalation gate) all pass an explicit mode and skip this.
|
|
1553
|
+
mode = STRUCTURED_MODES.has(mode) ? mode : defaultModeForRuntime(runtime)
|
|
1545
1554
|
// The lane's EFFECTIVE model — computed ONCE and used for BOTH the truthful display
|
|
1546
1555
|
// label (entry.model) and the SDK's `model` option below. These used to be computed
|
|
1547
1556
|
// separately: the label resolved the provider's configured model while the SDK got the
|
|
@@ -1783,7 +1792,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
1783
1792
|
...createViewportTools({ tool, z, manager: entry.viewport }),
|
|
1784
1793
|
tool(
|
|
1785
1794
|
'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
|
|
1795
|
+
'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
1796
|
{
|
|
1788
1797
|
terminal: z.string().optional().describe('ref, id, or command of the terminal to read; omit to list the open terminals'),
|
|
1789
1798
|
lines: z.number().int().positive().max(PEEK.maxLines).optional().describe(`how many recent lines to return (default ${PEEK.defaultLines})`),
|
|
@@ -2216,6 +2225,10 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2216
2225
|
// id, an event that arrives both live AND in a reconnect replay renders
|
|
2217
2226
|
// twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
|
|
2218
2227
|
stampEvent(evt)
|
|
2228
|
+
const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
|
|
2229
|
+
entry.stalled = evt.kind === 'stalled'
|
|
2230
|
+
entry.lastEvent = evt
|
|
2231
|
+
if (evt.kind !== 'stalled') entry.lastActionAt = evt.ts
|
|
2219
2232
|
// Re-announce the roster on THIS session's busy EDGE — turn start (turnActive
|
|
2220
2233
|
// flips true on the turn's first event) and turn end (false on result/error).
|
|
2221
2234
|
// Edge-triggered: one announce per transition, not per token event. This is what
|
|
@@ -2227,7 +2240,9 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2227
2240
|
// bridge sees "this lane just began working". `result`/`error` reads it to decide
|
|
2228
2241
|
// whether the turn was long enough that nobody was watching (Slice 3).
|
|
2229
2242
|
{ const busyNow = entry.session?.turnActive ?? false
|
|
2230
|
-
|
|
2243
|
+
const busyChanged = busyNow !== entry._busyAnn
|
|
2244
|
+
if (busyChanged) { if (busyNow) entry._turnStart = Date.now(); entry._busyAnn = busyNow }
|
|
2245
|
+
if (busyChanged || stalledChanged) announce() }
|
|
2231
2246
|
// Capture this session's current MODEL (resolved full id, with version — from
|
|
2232
2247
|
// system/result `model` or usage `ctx.model`) and the SDK's supported-model
|
|
2233
2248
|
// LIST (from the `models` event) so the announce carries them to EVERY device.
|
|
@@ -2416,6 +2431,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2416
2431
|
// (replay-request handler) — pending cards never enter the replayed event log.
|
|
2417
2432
|
const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions }
|
|
2418
2433
|
entry.pending.set(req.id, { resolve, payload })
|
|
2434
|
+
announce()
|
|
2419
2435
|
bcast('code-perm-req', payload)
|
|
2420
2436
|
// Slice 3 — arm the grace timer. Fires a "needs you" push only if the card is
|
|
2421
2437
|
// STILL unanswered 20s from now (you're not in the room). Every resolve path —
|
|
@@ -3040,11 +3056,11 @@ channel
|
|
|
3040
3056
|
// use run/accept/keep (claude-session interprets). Default deny on missing.
|
|
3041
3057
|
// Answered from a device → retract the "needs you" banner on every OTHER device
|
|
3042
3058
|
// (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') }
|
|
3059
|
+
if (p) { s.pending.delete(payload.id); s.permNotifier?.resolve(payload.id); p.resolve(payload.decision || 'deny'); announce() }
|
|
3044
3060
|
})
|
|
3045
3061
|
.on('broadcast', { event: 'code-abort' }, ({ payload }) => {
|
|
3046
3062
|
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
|
|
3063
|
+
if (s) { drainPending(s); s.session.abort(); announce() } // settle any open permission card so the hook doesn't hang
|
|
3048
3064
|
})
|
|
3049
3065
|
.on('broadcast', { event: 'code-mode' }, ({ payload }) => {
|
|
3050
3066
|
const s = payload?.term && sessions.get(payload.term)
|
|
@@ -3067,6 +3083,7 @@ channel
|
|
|
3067
3083
|
if (payload.mode === 'bypassPermissions') allowPending(s)
|
|
3068
3084
|
// Flipping to acceptEdits = "auto-approve writes" — clear the write cards already raised.
|
|
3069
3085
|
else if (payload.mode === 'acceptEdits') acceptEditsPending(s)
|
|
3086
|
+
announce()
|
|
3070
3087
|
}
|
|
3071
3088
|
})
|
|
3072
3089
|
.on('broadcast', { event: 'code-effort' }, ({ payload }) => {
|
package/codex-session.mjs
CHANGED
|
@@ -44,6 +44,21 @@ export function codexConfigForMode(mode) {
|
|
|
44
44
|
return CODEX_MODE_CONFIG[mode] || CODEX_MODE_CONFIG.default
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The permission mode a FRESHLY-OPENED terminal gets when none was specified
|
|
49
|
+
* (the person clicked "+ New terminal" without picking a mode). Codex defaults
|
|
50
|
+
* to bypassPermissions: its workspace-write sandbox has NO network, so a
|
|
51
|
+
* default-mode codex lane can't fetch / advance git metadata / push / publish
|
|
52
|
+
* (the 2026-07-13 "sandbox won't let it advance Git metadata" wall) and is
|
|
53
|
+
* hobbled out-of-the-box. bypass gives a person-opened codex terminal parity
|
|
54
|
+
* with how Claude lanes run. Claude keeps 'default' — its permission modes don't
|
|
55
|
+
* gate network. Explicit modes (a person's pick, resume's persisted entry.mode,
|
|
56
|
+
* a flow lane, spawn_terminal's inherited mode) always override this.
|
|
57
|
+
*/
|
|
58
|
+
export function defaultModeForRuntime(runtime) {
|
|
59
|
+
return runtime === 'codex' ? 'bypassPermissions' : 'default'
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
export function readCodexDefaultModel({ home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), fsImpl = fs } = {}) {
|
|
48
63
|
try {
|
|
49
64
|
const text = fsImpl.readFileSync(path.join(home, 'config.toml'), 'utf8')
|
package/cross-terminal.mjs
CHANGED
|
@@ -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')}`)
|