thinkpool-pair 0.7.176 → 0.7.177

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.
@@ -0,0 +1,131 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ bridge/agent-notify.mjs — the PURE decision layer for agent push events.
3
+
4
+ The bridge is the only process that knows a turn ended or a permission
5
+ card went unanswered. It can't call /api/notify-code directly (that
6
+ endpoint is webhook-secret-gated and this is a published npm package), so
7
+ it writes a compact `__agent` row into code_messages and lets the existing
8
+ push_notify_on_code_message trigger fan it out.
9
+
10
+ Everything that DECIDES whether to write such a row lives here, with no
11
+ I/O, so bridge/agent-notify.test.mjs can exercise it without a room, a
12
+ socket, or a wall clock.
13
+
14
+ Spec: docs/specs/2026-07-09-code-notifications-rework.md (Slice 3)
15
+ ───────────────────────────────────────────────────────────── */
16
+
17
+ // A turn shorter than this means somebody was almost certainly watching it —
18
+ // pushing "done" 4 seconds after they hit enter is noise, not news.
19
+ export const DEFAULT_MIN_TURN_MS = 30_000
20
+
21
+ // How long a permission card may sit unanswered before we assume nobody is
22
+ // looking at the room and push. Short enough to be useful, long enough that
23
+ // the common case (you're right there, you click it) never fires.
24
+ export const DEFAULT_PERM_GRACE_MS = 20_000
25
+
26
+ // Push copy is a banner, not a transcript.
27
+ export const SUMMARY_MAX = 200
28
+
29
+ /* A lane is USER-FACING when a human opened it and is waiting on it.
30
+ - spawnedBy: a Dispatch worker lane an agent fanned out. Its owner is
31
+ another agent; the human never asked for it directly.
32
+ - flowTaskKey: a Flow slice lane. Same reasoning — the conductor collects
33
+ it, and a 12-lane flow would fan 12 banners at one person.
34
+ Both run in inherited/bypass modes almost always, so they also rarely
35
+ raise permission cards. The conductor lane itself (flowSessionId but no
36
+ flowTaskKey) IS user-facing — that's the one a human is watching. */
37
+ export function isUserFacingLane(entry) {
38
+ if (!entry) return false
39
+ return !entry.spawnedBy && !entry.flowTaskKey
40
+ }
41
+
42
+ /* Collapse whitespace, clip, ellipsize. Never throws on non-strings. */
43
+ export function clipSummary(text, max = SUMMARY_MAX) {
44
+ if (text == null) return ''
45
+ const s = String(text).replace(/\s+/g, ' ').trim()
46
+ if (s.length <= max) return s
47
+ return s.slice(0, max).replace(/\s+\S*$/, '').trimEnd() + '…'
48
+ }
49
+
50
+ /* Should a settled turn produce a `turn-done` event row?
51
+
52
+ No when: the lane isn't user-facing · the user pressed Stop (subtype
53
+ 'aborted' — they know it's over, they're the one who ended it) · the turn
54
+ was short enough that somebody was plainly watching · we never saw the
55
+ turn start (startedAt null → a stray `error` event outside any turn, e.g.
56
+ the SDK-regression warning emitted at init).
57
+
58
+ `error` turns DO notify — with the same neutral copy as a success. Max's
59
+ call: "the agent stopped" is the news; the reason is in the room. */
60
+ export function shouldNotifyTurnDone({ entry, subtype, startedAt, now, minTurnMs = DEFAULT_MIN_TURN_MS }) {
61
+ if (!isUserFacingLane(entry)) return false
62
+ if (subtype === 'aborted') return false
63
+ if (!startedAt) return false
64
+ if (!Number.isFinite(now)) return false
65
+ return (now - startedAt) >= minTurnMs
66
+ }
67
+
68
+ /* The one-line "what is it asking me" for a permission card.
69
+ AskUserQuestion cards carry `questions`; ordinary tool cards degrade to
70
+ `<ToolName> <first meaningful arg>`, matching the local stderr line. */
71
+ export function permissionSummary(payload) {
72
+ const p = payload || {}
73
+ const qs = Array.isArray(p.questions) ? p.questions.map(q => q?.question).filter(Boolean) : []
74
+ if (qs.length) return clipSummary(qs.join(' / '))
75
+ if (p.risk === 'plan') return clipSummary(p.plan ? `Plan ready: ${p.plan}` : 'A plan is ready for review')
76
+ const i = p.input
77
+ const arg = i && (i.command || i.file_path || i.path || i.pattern || i.url || i.query || i.description)
78
+ const name = p.toolName || 'A tool'
79
+ return clipSummary(arg ? `${name} ${arg}` : name)
80
+ }
81
+
82
+ /* Per-session permission-card notifier.
83
+
84
+ arm(id, payload) — a card was raised. Start the grace timer.
85
+ resolve(id) — the card was answered (human click, mode-flip
86
+ auto-approval, or a drain-to-deny). If its timer had
87
+ already FIRED we pushed a banner, so retract it.
88
+ clearAll() — turn ended / session died. Every outstanding card is
89
+ dead; retract anything we announced.
90
+
91
+ Timers are injected so the unit test drives them synchronously. onFire /
92
+ onRetract are fire-and-forget (they end up as REST inserts); a throw in
93
+ either must never take the bridge down. */
94
+ export function createPermNotifier({
95
+ graceMs = DEFAULT_PERM_GRACE_MS,
96
+ onFire = () => {},
97
+ onRetract = () => {},
98
+ setTimer = setTimeout,
99
+ clearTimer = clearTimeout,
100
+ } = {}) {
101
+ const cards = new Map() // id → { timer, fired, payload }
102
+ const safe = (fn, id, payload) => { try { fn(id, payload) } catch { /* never break the turn */ } }
103
+
104
+ const settle = (id) => {
105
+ const rec = cards.get(id)
106
+ if (!rec) return
107
+ if (rec.timer) { try { clearTimer(rec.timer) } catch { /* noop */ } }
108
+ cards.delete(id)
109
+ if (rec.fired) safe(onRetract, id, rec.payload)
110
+ }
111
+
112
+ return {
113
+ arm(id, payload) {
114
+ if (!id || cards.has(id)) return
115
+ const rec = { timer: null, fired: false, payload }
116
+ rec.timer = setTimer(() => {
117
+ rec.timer = null
118
+ rec.fired = true
119
+ safe(onFire, id, payload)
120
+ }, graceMs)
121
+ // Never hold the process open on a permission timer.
122
+ try { rec.timer?.unref?.() } catch { /* not a node timer (test double) */ }
123
+ cards.set(id, rec)
124
+ },
125
+ resolve: settle,
126
+ clearAll() { for (const id of [...cards.keys()]) settle(id) },
127
+ // test/debug introspection only
128
+ size: () => cards.size,
129
+ fired: (id) => !!cards.get(id)?.fired,
130
+ }
131
+ }
package/bridge.mjs CHANGED
@@ -39,6 +39,9 @@ import { randomUUID } from 'node:crypto'
39
39
  import { createClient } from '@supabase/supabase-js'
40
40
  import { serveDecision, fetchServeRow, refusalMessage, gateFailAction } from './serve-consent.mjs'
41
41
  import { reapTerminalRow as _reapTerminalRow } from './reap-terminal.mjs'
42
+ // Slice 3 — the pure decision layer for agent push events (turn-done / needs-input).
43
+ // Unit-tested in bridge/agent-notify.test.mjs; everything with I/O stays here.
44
+ import { createPermNotifier, shouldNotifyTurnDone, permissionSummary, clipSummary, isUserFacingLane, DEFAULT_MIN_TURN_MS } from './agent-notify.mjs'
42
45
  // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
43
46
  // registered custom provider, or null for the built-in/unknown (leave the default env intact).
44
47
  // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
@@ -763,6 +766,46 @@ async function budgetRpc (fn, args) {
763
766
  const t = await r.text()
764
767
  return t ? JSON.parse(t) : null
765
768
  }
769
+
770
+ // ── Agent push events — spec docs/specs/2026-07-09-code-notifications-rework.md (Slice 3).
771
+ // The bridge is the only process that knows a turn ended or a permission card went
772
+ // unanswered, and it CANNOT call /api/notify-code (webhook-secret-gated; this is a
773
+ // published npm package). So it writes a compact `__agent` row into code_messages with
774
+ // the SAME authed REST path budgetRpc uses; the existing push_notify_on_code_message
775
+ // trigger fans it out and api/notify-code.js routes on payload.__agent.
776
+ //
777
+ // Durable by construction: the row is also the transport-neutral event log the iPhone
778
+ // sender will read, and it feeds get_code_unread_counts for free.
779
+ //
780
+ // Fire-and-forget in the strictest sense — an anon bridge (no codeAuthToken/uid), a
781
+ // dead network, or a 4xx must never surface in the turn. TP_AGENT_NOTIFY_OFF=1 kills
782
+ // the whole path. The web client filters `ae:` rows out of its seed/replay (Slice 2),
783
+ // so an old client paired with a new bridge renders nothing new.
784
+ const AGENT_NOTIFY_OFF = process.env.TP_AGENT_NOTIFY_OFF === '1'
785
+ const MIN_TURN_MS = Number(process.env.TP_NOTIFY_MIN_TURN_MS) > 0
786
+ ? Number(process.env.TP_NOTIFY_MIN_TURN_MS) : DEFAULT_MIN_TURN_MS
787
+ function persistAgentEvent (payload) {
788
+ if (AGENT_NOTIFY_OFF || !codeAuthToken || !myServeUid || !room) return
789
+ const row = {
790
+ id: `ae:${randomUUID()}`,
791
+ session_code: room,
792
+ author_id: myServeUid,
793
+ payload: { __agent: true, ts: Date.now(), ...payload },
794
+ }
795
+ fetch(`${SUPABASE_URL}/rest/v1/code_messages`, {
796
+ method: 'POST',
797
+ headers: {
798
+ apikey: SUPABASE_ANON,
799
+ Authorization: `Bearer ${codeAuthToken}`,
800
+ 'Content-Type': 'application/json',
801
+ Prefer: 'return=minimal',
802
+ },
803
+ body: JSON.stringify(row),
804
+ }).then((r) => {
805
+ if (!r.ok && process.env.TP_DEBUG) process.stderr.write(`\n ◆ agent-event insert ${r.status}\n`)
806
+ }).catch(() => { /* best-effort — a missed banner is not worth a stack trace */ })
807
+ }
808
+
766
809
  // Fire-and-forget: fold a completed turn's output tokens into the room's durable monthly
767
810
  // ledger. output_tokens = the billed reasoning+output spend the indicator already tracks.
768
811
  function recordRoomSpend (tokens) {
@@ -1542,6 +1585,13 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1542
1585
  // order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
1543
1586
  // first transcript event ts, so even the first post-fix restart is ordered right.
1544
1587
  openedAt: openedAt || (Array.isArray(log) ? (log.find((e) => e?.ts)?.ts || 0) : 0) || Date.now() }
1588
+ // Slice 3 — a permission card left unanswered past the grace window pushes
1589
+ // "<lane> — needs you: <what>"; answering it anywhere retracts the banner
1590
+ // everywhere. Worker/flow lanes are excluded at arm() time (isUserFacingLane).
1591
+ entry.permNotifier = createPermNotifier({
1592
+ onFire: (_cardId, summary) => persistAgentEvent({ kind: 'needs-input', term: id, termName: termNames[id] || null, summary }),
1593
+ onRetract: (_cardId) => persistAgentEvent({ kind: 'needs-resolved', term: id, termName: termNames[id] || null }),
1594
+ })
1545
1595
  // C1 (RT-2): per-term contiguous seq counter. Seeded from the restored log's max
1546
1596
  // so a bridge restart resumes ABOVE every persisted seq — fresh events never reuse
1547
1597
  // an old seq, which is what keeps the between-turns restart dup-free (see
@@ -2135,8 +2185,11 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
2135
2185
  // roster broadcast (delivered to every subscriber) instead of sniffing the live
2136
2186
  // code-event stream a passive dashboard channel misses. Covers every turn path
2137
2187
  // (normal, spawn, flow lane, cross-room) since they all flow through onEvent.
2188
+ // The RISING edge is also where a turn's wall clock starts — the only place the
2189
+ // bridge sees "this lane just began working". `result`/`error` reads it to decide
2190
+ // whether the turn was long enough that nobody was watching (Slice 3).
2138
2191
  { const busyNow = entry.session?.turnActive ?? false
2139
- if (busyNow !== entry._busyAnn) { entry._busyAnn = busyNow; announce() } }
2192
+ if (busyNow !== entry._busyAnn) { if (busyNow) entry._turnStart = Date.now(); entry._busyAnn = busyNow; announce() } }
2140
2193
  // Capture this session's current MODEL (resolved full id, with version — from
2141
2194
  // system/result `model` or usage `ctx.model`) and the SDK's supported-model
2142
2195
  // LIST (from the `models` event) so the announce carries them to EVERY device.
@@ -2247,6 +2300,30 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
2247
2300
  bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
2248
2301
  entry.compactBy = null
2249
2302
  }
2303
+ // ── Slice 3: the turn settled. Two things happen, in this order.
2304
+ // 1. Every outstanding permission card on this lane is now dead (the SDK
2305
+ // won't ask again for a turn that ended) — clearAll retracts any
2306
+ // "needs you" banner we already pushed. Do this even for aborted turns
2307
+ // and non-user-facing lanes: a stale card can never fire later.
2308
+ // 2. If the turn ran long enough that nobody was plausibly watching, and
2309
+ // the lane is one a human opened, and they didn't press Stop — write a
2310
+ // `turn-done` event row. An `error` turn uses the same neutral copy.
2311
+ // _turnStart is cleared unconditionally so a stray post-settle event can't
2312
+ // re-notify off a stale stamp.
2313
+ if (evt.kind === 'result' || evt.kind === 'error') {
2314
+ const startedAt = entry._turnStart
2315
+ entry._turnStart = null
2316
+ entry.permNotifier?.clearAll()
2317
+ if (shouldNotifyTurnDone({ entry, subtype: evt.subtype, startedAt, now: Date.now(), minTurnMs: MIN_TURN_MS })) {
2318
+ persistAgentEvent({
2319
+ kind: 'turn-done',
2320
+ term: id,
2321
+ termName: termNames[id] || null,
2322
+ summary: clipSummary(evt.kind === 'result' ? evt.resultText : evt.message),
2323
+ subtype: evt.subtype || (evt.kind === 'error' ? 'error' : null),
2324
+ })
2325
+ }
2326
+ }
2250
2327
  // Single tail: stamp into the log (transcript kinds only), broadcast live,
2251
2328
  // print locally, persist. Shared so a deferred image event re-enters it once
2252
2329
  // its base64 has been lifted to a Storage path (see deferImageEvent).
@@ -2281,6 +2358,13 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
2281
2358
  const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions }
2282
2359
  entry.pending.set(req.id, { resolve, payload })
2283
2360
  bcast('code-perm-req', payload)
2361
+ // Slice 3 — arm the grace timer. Fires a "needs you" push only if the card is
2362
+ // STILL unanswered 20s from now (you're not in the room). Every resolve path —
2363
+ // the code-perm broadcast, drainPending, and the bypass/acceptEdits mode-flip
2364
+ // auto-approvals — calls permNotifier.resolve(id), which retracts the banner
2365
+ // if it had fired. Worker + flow-slice lanes are skipped: their cards belong to
2366
+ // an agent's fan-out, and a 12-lane flow would fan 12 banners at one person.
2367
+ if (isUserFacingLane(entry)) entry.permNotifier?.arm(req.id, permissionSummary(payload))
2284
2368
  process.stderr.write(req.risk === 'plan'
2285
2369
  ? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
2286
2370
  : req.risk === 'ask'
@@ -2310,6 +2394,8 @@ function drainPending(s) {
2310
2394
  if (!s?.pending) return
2311
2395
  for (const [, p] of s.pending) { try { p.resolve('deny') } catch { /* noop */ } }
2312
2396
  s.pending.clear()
2397
+ // Every card just died → retract any "needs you" banner they raised (Slice 3).
2398
+ s.permNotifier?.clearAll()
2313
2399
  }
2314
2400
 
2315
2401
  // Switching a terminal to bypassPermissions must RETROACTIVELY clear the cards the
@@ -2329,6 +2415,7 @@ function allowPending(s) {
2329
2415
  if (p?.payload?.risk === 'plan' || p?.payload?.risk === 'ask') continue
2330
2416
  try { p.resolve('allow') } catch { /* noop */ }
2331
2417
  s.pending.delete(key)
2418
+ s.permNotifier?.resolve(key) // auto-approved → retract the "needs you" banner
2332
2419
  }
2333
2420
  }
2334
2421
 
@@ -2347,6 +2434,7 @@ function acceptEditsPending(s) {
2347
2434
  if (!ACCEPT_EDIT_TOOLS.has(tn)) continue
2348
2435
  try { p.resolve('allow') } catch { /* noop */ }
2349
2436
  s.pending.delete(key)
2437
+ s.permNotifier?.resolve(key) // auto-approved → retract the "needs you" banner
2350
2438
  }
2351
2439
  }
2352
2440
 
@@ -2742,7 +2830,9 @@ channel
2742
2830
  const p = s && payload.id && s.pending.get(payload.id)
2743
2831
  // Pass the raw decision through — normal tools use allow/deny, plan cards
2744
2832
  // use run/accept/keep (claude-session interprets). Default deny on missing.
2745
- if (p) { s.pending.delete(payload.id); p.resolve(payload.decision || 'deny') }
2833
+ // Answered from a device → retract the "needs you" banner on every OTHER device
2834
+ // (permNotifier.resolve is a no-op unless its grace timer already fired).
2835
+ if (p) { s.pending.delete(payload.id); s.permNotifier?.resolve(payload.id); p.resolve(payload.decision || 'deny') }
2746
2836
  })
2747
2837
  .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
2748
2838
  const s = payload?.term && sessions.get(payload.term)
@@ -978,7 +978,13 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
978
978
  }
979
979
  if (best) curModel = best
980
980
  }
981
- emit({ kind: 'result', subtype: m.subtype, sessionId, model: curModel, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns, durationMs: m.duration_ms ?? null, denials: Array.isArray(m.permission_denials) ? m.permission_denials.length : 0 })
981
+ // resultText: the SDK's final assistant text, clipped. The bridge folds it
982
+ // into the `turn-done` push body ("<lane> — done: <summary>") so a banner
983
+ // says what happened, not just that something did. Clipped + whitespace-
984
+ // collapsed here so neither the broadcast frame nor the persisted log grows
985
+ // by a full turn's prose. Absent on aborted/error results — nothing to say.
986
+ { const rt = typeof m.result === 'string' ? m.result.replace(/\s+/g, ' ').trim().slice(0, 200) : ''
987
+ emit({ kind: 'result', subtype: m.subtype, sessionId, model: curModel, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns, durationMs: m.duration_ms ?? null, denials: Array.isArray(m.permission_denials) ? m.permission_denials.length : 0, resultText: rt || null }) }
982
988
  // Arm the Haiku fallback: if Claude's own prompt_suggestion doesn't arrive within
983
989
  // the window, generate one from the last assistant reply. (Skip aborted turns.)
984
990
  if (sugTimer) clearTimeout(sugTimer)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.176",
3
+ "version": "0.7.177",
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": {
@@ -15,6 +15,7 @@
15
15
  "claude-session.mjs",
16
16
  "turn-stall.mjs",
17
17
  "update-gate.mjs",
18
+ "agent-notify.mjs",
18
19
  "event-id.mjs",
19
20
  "recap.mjs",
20
21
  "transcript-sanitize.mjs",