thinkpool-pair 0.7.297 → 0.7.299

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/agent-notify.mjs CHANGED
@@ -58,11 +58,18 @@ export function clipSummary(text, max = SUMMARY_MAX) {
58
58
  `error` turns DO notify — with the same neutral copy as a success. Max's
59
59
  call: "the agent stopped" is the news; the reason is in the room. */
60
60
  export function shouldNotifyTurnDone({ entry, subtype, startedAt, now, minTurnMs = DEFAULT_MIN_TURN_MS }) {
61
+ if (!shouldRecordTurnDone({ entry, subtype, startedAt, now })) return false
62
+ return (now - startedAt) >= minTurnMs
63
+ }
64
+
65
+ // Every user-facing settle is durable realtime evidence for the in-app rail.
66
+ // The older 30s threshold remains only a PUSH eligibility rule; using it as the
67
+ // write gate made short turns in another session invisible to the web app.
68
+ export function shouldRecordTurnDone({ entry, subtype, startedAt, now }) {
61
69
  if (!isUserFacingLane(entry)) return false
62
70
  if (subtype === 'aborted') return false
63
71
  if (!startedAt) return false
64
- if (!Number.isFinite(now)) return false
65
- return (now - startedAt) >= minTurnMs
72
+ return Number.isFinite(now)
66
73
  }
67
74
 
68
75
  /* The one-line "what is it asking me" for a permission card.
@@ -93,8 +100,10 @@ export function permissionSummary(payload) {
93
100
  either must never take the bridge down. */
94
101
  export function createPermNotifier({
95
102
  graceMs = DEFAULT_PERM_GRACE_MS,
103
+ onArm = () => {},
96
104
  onFire = () => {},
97
105
  onRetract = () => {},
106
+ onResolve = () => {},
98
107
  setTimer = setTimeout,
99
108
  clearTimer = clearTimeout,
100
109
  } = {}) {
@@ -107,12 +116,14 @@ export function createPermNotifier({
107
116
  if (rec.timer) { try { clearTimer(rec.timer) } catch { /* noop */ } }
108
117
  cards.delete(id)
109
118
  if (rec.fired) safe(onRetract, id, rec.payload)
119
+ safe(onResolve, id, rec.payload)
110
120
  }
111
121
 
112
122
  return {
113
123
  arm(id, payload) {
114
124
  if (!id || cards.has(id)) return
115
125
  const rec = { timer: null, fired: false, payload }
126
+ safe(onArm, id, payload)
116
127
  rec.timer = setTimer(() => {
117
128
  rec.timer = null
118
129
  rec.fired = true
package/bridge.mjs CHANGED
@@ -43,7 +43,7 @@ import { reconcileTerminalRows as _reconcileTerminalRows } from './terminal-row-
43
43
  import { cancelDurableDispatchPermissions, cancelDurablePermissions } from './dispatch-permission-cleanup.mjs'
44
44
  // Slice 3 — the pure decision layer for agent push events (turn-done / needs-input).
45
45
  // Unit-tested in bridge/agent-notify.test.mjs; everything with I/O stays here.
46
- import { createPermNotifier, shouldNotifyTurnDone, permissionSummary, clipSummary, isUserFacingLane, DEFAULT_MIN_TURN_MS } from './agent-notify.mjs'
46
+ import { createPermNotifier, shouldNotifyTurnDone, shouldRecordTurnDone, permissionSummary, clipSummary, isUserFacingLane, DEFAULT_MIN_TURN_MS } from './agent-notify.mjs'
47
47
  // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
48
48
  // registered custom provider, or null for the built-in/unknown (leave the default env intact).
49
49
  // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
@@ -114,7 +114,7 @@ const flowRedispatch = new Map()
114
114
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
115
115
  // broadcasts; without persistent state the cap can never bite.
116
116
  const flowBudgets = new Map()
117
- import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneStatusOf, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
117
+ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneBusyOf, laneStatusOf, settleLaneBusy, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
118
118
  import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
119
119
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
120
120
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
@@ -1212,7 +1212,7 @@ const announce = () => {
1212
1212
  // laneStatusOf: authoritative busy/idle + last-action timestamp/age +
1213
1213
  // STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
1214
1214
  // every roster consumer reads one status instead of reconstructing it.
1215
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), turnRev: Number(s._turnRev) || 0, ...(s.session?.turnActive && s._turnStart ? { turnStartedAt: s._turnStart } : {}), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { 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' } : {}),
1215
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), turnRev: Number(s._turnRev) || 0, ...(laneBusyOf(s) && s._turnStart ? { turnStartedAt: s._turnStart } : {}), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { 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' } : {}),
1216
1216
  // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
1217
1217
  // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
1218
1218
  // built-in/default Claude path (no badge). Makes the lane's provider badge +
@@ -1497,9 +1497,25 @@ function pumpDesign(term) {
1497
1497
  by: next.by,
1498
1498
  design: true,
1499
1499
  }
1500
+ // Design bypasses the ordinary `code-turn` handler, so it must perform the
1501
+ // same lifecycle handshake itself. Open the revision BEFORE logging the human
1502
+ // line: its stamped `turnRev` lets every room client reconcile stale
1503
+ // `busy:false` immediately, without waiting several seconds for first model
1504
+ // output. A rejected dispatch gets a real terminal boundary below.
1505
+ syncStructuredTurn(lane)
1506
+ let accepted = false
1507
+ try {
1508
+ accepted = lane.session.sendTurn(designPrompt({ record: next.record, request: next.request, by: next.by, restore: !!next.restoreRecord, priorRecord: next.restoreRecord })) !== false
1509
+ } catch { accepted = false }
1510
+ if (accepted) beginStructuredTurn(lane)
1500
1511
  stampEvent(visible); pushLog(lane, visible); bcast('code-event', { term, evt: visible })
1501
- try { lane.session.sendTurn(designPrompt({ record: next.record, request: next.request, by: next.by, restore: !!next.restoreRecord, priorRecord: next.restoreRecord })) }
1502
- catch { finishDesign(term, 'failed', { message: 'The producing lane could not start the edit.' }) }
1512
+ if (!accepted) {
1513
+ syncStructuredTurn(lane)
1514
+ const failed = { kind: 'error', message: 'The producing lane could not start the edit.', recoverable: true }
1515
+ pushLog(lane, failed); bcast('code-event', { term, evt: failed })
1516
+ finishDesign(term, 'failed', { message: 'The producing lane could not start the edit.' })
1517
+ }
1518
+ announce()
1503
1519
  }
1504
1520
  // Push one manifest file into the room, attributed to `term`. The owner is
1505
1521
  // resolved by the WATCHER, not guessed here — each session/terminal writes to
@@ -1956,8 +1972,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
1956
1972
  // "<lane> — needs you: <what>"; answering it anywhere retracts the banner
1957
1973
  // everywhere. Worker/flow lanes are excluded at arm() time (isUserFacingLane).
1958
1974
  entry.permNotifier = createPermNotifier({
1959
- onFire: (_cardId, summary) => persistAgentEvent({ kind: 'needs-input', term: id, termName: termNames[id] || null, summary }),
1960
- onRetract: (_cardId) => persistAgentEvent({ kind: 'needs-resolved', term: id, termName: termNames[id] || null }),
1975
+ // The in-app rail gets the card immediately. OS push keeps the 20s grace:
1976
+ // the later push-eligible row shares permissionId, so the browser dedupes it
1977
+ // while the server may still fan out a banner to unattended devices.
1978
+ onArm: (cardId, summary) => persistAgentEvent({ kind: 'needs-input', term: id, termName: termNames[id] || null, model: entry.model || null, permissionId: cardId, summary, pushEligible: false }),
1979
+ onFire: (cardId, summary) => persistAgentEvent({ kind: 'needs-input', term: id, termName: termNames[id] || null, model: entry.model || null, permissionId: cardId, summary, pushEligible: true }),
1980
+ onResolve: (cardId) => persistAgentEvent({ kind: 'needs-resolved', term: id, termName: termNames[id] || null, model: entry.model || null, permissionId: cardId }),
1961
1981
  })
1962
1982
  // C1 (RT-2): per-term contiguous seq counter. Seeded from the restored log's max
1963
1983
  // so a bridge restart resumes ABOVE every persisted seq — fresh events never reuse
@@ -2997,7 +3017,16 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2997
3017
  // twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
2998
3018
  const continuesQueued = runtime === 'hermes' && (evt.kind === 'result' || evt.kind === 'error') && (entry.session?.queuedDepth || 0) > 0
2999
3019
  if (continuesQueued) evt.continuesQueued = true
3000
- const busyChanged = continuesQueued ? false : syncStructuredTurn(entry)
3020
+ // A result transcript boundary is the bridge's definitive falling edge.
3021
+ // Do not re-read a runtime's mutable turnActive getter here: some adapters
3022
+ // finish clearing it after delivering the result, which previously left the
3023
+ // last broadcast busy until a tab click/refocus triggered another announce.
3024
+ // `error` is not universally terminal (a runtime may recover and continue),
3025
+ // so error-only paths keep using the runtime edge below.
3026
+ // A queued Hermes turn is one continuous busy interval, so it settles only
3027
+ // after the final queued boundary.
3028
+ const terminalBoundary = !continuesQueued && evt.kind === 'result'
3029
+ const busyChanged = terminalBoundary ? settleLaneBusy(entry) : syncStructuredTurn(entry)
3001
3030
  stampStructuredTurn(entry, evt)
3002
3031
  stampEvent(evt)
3003
3032
  const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
@@ -3223,13 +3252,18 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3223
3252
  const stalePermissionIds = [...entry.pending.keys()]
3224
3253
  drainPending(entry)
3225
3254
  for (const pendingId of stalePermissionIds) bcast('code-perm', { term: id, id: pendingId, decision: 'deny', name: 'agent' })
3226
- if (shouldNotifyTurnDone({ entry, subtype: evt.subtype, startedAt, now: Date.now(), minTurnMs: MIN_TURN_MS })) {
3255
+ const settledAt = Date.now()
3256
+ if (shouldRecordTurnDone({ entry, subtype: evt.subtype, startedAt, now: settledAt })) {
3227
3257
  persistAgentEvent({
3228
3258
  kind: 'turn-done',
3229
3259
  term: id,
3230
3260
  termName: termNames[id] || null,
3261
+ model: entry.model || null,
3231
3262
  summary: clipSummary(evt.kind === 'result' ? evt.resultText : evt.message),
3232
3263
  subtype: evt.subtype || (evt.kind === 'error' ? 'error' : null),
3264
+ // Push remains conservative; the in-app rail is allowed to surface
3265
+ // every unseen settle because exact watched terminals are suppressed.
3266
+ pushEligible: shouldNotifyTurnDone({ entry, subtype: evt.subtype, startedAt, now: settledAt, minTurnMs: MIN_TURN_MS }),
3233
3267
  })
3234
3268
  }
3235
3269
  }
@@ -3915,7 +3949,7 @@ channel
3915
3949
  // Display the clean body (web sends `body` when the agent `text` carries host
3916
3950
  // file paths) + the uploaded attachments, so the partner shows the image — not
3917
3951
  // a raw /var path. The agent already got the full `text` via sendTurn above.
3918
- const evt = { kind: 'you', text: payload.body != null ? String(payload.body) : text, cid: payload.cid, by: payload.by, ...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}), ...(Array.isArray(payload.pastes) && payload.pastes.length ? { pastes: payload.pastes } : {}) }
3952
+ const evt = { kind: 'you', text: payload.body != null ? String(payload.body) : text, cid: payload.cid, by: payload.by, ...(payload.authorId ? { authorId: payload.authorId } : {}), ...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}), ...(Array.isArray(payload.pastes) && payload.pastes.length ? { pastes: payload.pastes } : {}) }
3919
3953
  pushLog(s, evt)
3920
3954
  bcast('code-event', { term: payload.term, evt })
3921
3955
  }
@@ -51,6 +51,23 @@ const recentFailureCount = (log, limits = LANE_STATUS) => {
51
51
  return failures
52
52
  }
53
53
 
54
+ // Structured runtimes expose a mutable `turnActive` getter, but the bridge owns
55
+ // the roster edges. A terminal result can reach the bridge before a runtime has
56
+ // finished clearing that getter; reading it back here used to re-open a finished
57
+ // lane until the next WHO/refocus announcement. Once the bridge has observed an
58
+ // edge, its latch is authoritative until beginStructuredTurn/syncStructuredTurn
59
+ // records the next one. `busy` remains the explicit override for wire fixtures
60
+ // and raw terminals.
61
+ export const laneBusyOf = (entry = {}) => typeof entry?.busy === 'boolean'
62
+ ? entry.busy
63
+ : (typeof entry?._busyAnn === 'boolean' ? entry._busyAnn : (entry?.session?.turnActive ?? false))
64
+
65
+ export const settleLaneBusy = (entry) => {
66
+ if (!entry || entry._busyAnn === false) return false
67
+ entry._busyAnn = false
68
+ return true
69
+ }
70
+
54
71
  export const laneStatusOf = (entry = {}, now = Date.now(), limits = LANE_STATUS) => {
55
72
  const baseLog = Array.isArray(entry?.log) ? entry.log : []
56
73
  const log = baseLog.slice(-limits.failureWindow)
@@ -62,7 +79,7 @@ export const laneStatusOf = (entry = {}, now = Date.now(), limits = LANE_STATUS)
62
79
  }
63
80
  const lastActionAt = Number.isFinite(entry?.lastActionAt) ? entry.lastActionAt : (lastLogAt ?? null)
64
81
  const lastActionAgeMs = lastActionAt == null ? null : Math.max(0, now - lastActionAt)
65
- const busy = typeof entry?.busy === 'boolean' ? entry.busy : (entry?.session?.turnActive ?? false)
82
+ const busy = laneBusyOf(entry)
66
83
  const pendingCount = entry?.pending instanceof Map
67
84
  ? entry.pending.size
68
85
  : Math.max(0, Number(entry?.pendingCount) || 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.297",
3
+ "version": "0.7.299",
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": {