thinkpool-pair 0.7.175 → 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.
- package/agent-notify.mjs +131 -0
- package/bridge.mjs +92 -2
- package/claude-session.mjs +7 -1
- package/event-id.mjs +10 -64
- package/package.json +3 -1
- package/recap.mjs +112 -0
package/agent-notify.mjs
ADDED
|
@@ -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
|
-
|
|
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)
|
package/claude-session.mjs
CHANGED
|
@@ -978,7 +978,13 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
978
978
|
}
|
|
979
979
|
if (best) curModel = best
|
|
980
980
|
}
|
|
981
|
-
|
|
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/event-id.mjs
CHANGED
|
@@ -249,67 +249,13 @@ export function usageReportLine (sessionModel, logEvents) {
|
|
|
249
249
|
return `usage · ${turns} · ${perStr}${total}${cost} · tokens + cost only (no plan/rate-limit meters)`
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
// from the saved log — the people's turns (`you`) + the assistant's own text replies
|
|
263
|
-
// (`assistant` text blocks; thinking + tool_use are dropped, they don't survive a backend
|
|
264
|
-
// hop and are noise) — newest-weighted under a hard char cap, framed so the agent RESUMES
|
|
265
|
-
// rather than re-greets. Pure + import-safe (unit-tested in bridge/recap.test.mjs).
|
|
266
|
-
//
|
|
267
|
-
// capReplayEvents lesson (rooms died once on a multi-MB replay): the char cap keeps this
|
|
268
|
-
// bounded in prose form too — an unbounded recap would be the same bug wearing a text hat.
|
|
269
|
-
export function buildRecapFromLog(log, cap = RECAP_CAP) {
|
|
270
|
-
if (!Array.isArray(log) || !log.length) return ''
|
|
271
|
-
const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
|
|
272
|
-
if (!budget) return ''
|
|
273
|
-
// Flatten the log to speaker-labelled turns, in order. `you` = a person in the room;
|
|
274
|
-
// `assistant` = this agent's own earlier reply (its text blocks only).
|
|
275
|
-
const turns = []
|
|
276
|
-
for (const e of log) {
|
|
277
|
-
if (!e || typeof e !== 'object') continue
|
|
278
|
-
if (e.kind === 'you') {
|
|
279
|
-
const t = String(e.text || '').trim()
|
|
280
|
-
if (t) turns.push({ who: 'PERSON', text: t })
|
|
281
|
-
} else if (e.kind === 'assistant') {
|
|
282
|
-
const t = (Array.isArray(e.blocks) ? e.blocks : [])
|
|
283
|
-
.filter((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.trim())
|
|
284
|
-
.map((b) => b.text.trim())
|
|
285
|
-
.join('\n')
|
|
286
|
-
if (t) turns.push({ who: 'YOU', text: t })
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
if (!turns.length) return ''
|
|
290
|
-
// Newest-weighted: walk from the end, keep whole turns until the char budget is spent,
|
|
291
|
-
// then restore chronological order. A single newest turn bigger than the whole budget is
|
|
292
|
-
// still kept (truncated) so the LIVE task is never dropped to the cap.
|
|
293
|
-
const kept = []
|
|
294
|
-
let used = 0
|
|
295
|
-
let omitted = false
|
|
296
|
-
for (let i = turns.length - 1; i >= 0; i--) {
|
|
297
|
-
let line = `${turns[i].who}: ${turns[i].text}`
|
|
298
|
-
if (used + line.length > budget) {
|
|
299
|
-
if (kept.length) { omitted = true; break } // older turns exist but won't fit → note + stop
|
|
300
|
-
line = line.slice(0, budget) // newest turn alone overflows → clip, still include
|
|
301
|
-
}
|
|
302
|
-
kept.push(line)
|
|
303
|
-
used += line.length
|
|
304
|
-
}
|
|
305
|
-
kept.reverse()
|
|
306
|
-
const body = (omitted ? ['[…earlier messages omitted for length…]'] : []).concat(kept).join('\n\n')
|
|
307
|
-
return [
|
|
308
|
-
'[CONTEXT CARRY — you are RESUMING a live conversation after a model/provider switch]',
|
|
309
|
-
"Your session memory was reset by the switch, but this lane's conversation continued and the people already saw everything below. Do NOT re-greet, do NOT re-introduce yourself, and do NOT re-answer anything you already completed. Read the recap, then continue the work exactly where it left off. The LAST PERSON message with no completed answer below is your live task — pick it up.",
|
|
310
|
-
'',
|
|
311
|
-
'--- conversation so far (oldest → newest) ---',
|
|
312
|
-
body,
|
|
313
|
-
'--- end recap ---',
|
|
314
|
-
].join('\n')
|
|
315
|
-
}
|
|
252
|
+
/* The context-carry recap moved to ./recap.mjs (ADR-0010 / S4, 2026-07-09).
|
|
253
|
+
*
|
|
254
|
+
* Why: the WEB CLIENT needs buildRecapFromLog to carry a lane's bridgeless
|
|
255
|
+
* conversation across a bridge wake, and this module imports `node:crypto` —
|
|
256
|
+
* unresolvable in a browser bundle. The pure half lives in an import-free file
|
|
257
|
+
* both sides can load; these re-exports keep every existing bridge-side import
|
|
258
|
+
* (`from './event-id.mjs'`) working untouched, including bridge/recap.test.mjs.
|
|
259
|
+
*
|
|
260
|
+
* recap.mjs must stay in bridge/package.json `files` — event-id.mjs imports it. */
|
|
261
|
+
export { RECAP_CAP, buildRecapFromLog } from './recap.mjs'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
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,7 +15,9 @@
|
|
|
15
15
|
"claude-session.mjs",
|
|
16
16
|
"turn-stall.mjs",
|
|
17
17
|
"update-gate.mjs",
|
|
18
|
+
"agent-notify.mjs",
|
|
18
19
|
"event-id.mjs",
|
|
20
|
+
"recap.mjs",
|
|
19
21
|
"transcript-sanitize.mjs",
|
|
20
22
|
"session-store.mjs",
|
|
21
23
|
"cross-terminal.mjs",
|
package/recap.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/* recap.mjs — the text-layer context carry. PURE. NO IMPORTS. NO NODE BUILTINS.
|
|
2
|
+
*
|
|
3
|
+
* Extracted from event-id.mjs on 2026-07-09 (ADR-0010 / slice S4) for one reason:
|
|
4
|
+
* the WEB CLIENT needs this function now, and event-id.mjs imports `node:crypto`
|
|
5
|
+
* (for stampEvent's randomUUID default), which Vite cannot resolve for a browser
|
|
6
|
+
* bundle. Rather than write a second windower — the spec's §b instruction is
|
|
7
|
+
* literally "Do not write a second windower" — the pure half moves to its own
|
|
8
|
+
* file that both sides import.
|
|
9
|
+
*
|
|
10
|
+
* bridge/event-id.mjs re-exports these (every existing import keeps working)
|
|
11
|
+
* src/pages/code/poolTurn.js imports this file directly
|
|
12
|
+
*
|
|
13
|
+
* ⚠ KEEP THIS FILE FREE OF IMPORTS. The moment it imports `node:*`, the web build
|
|
14
|
+
* breaks. There is no lint rule for this; there is only this comment.
|
|
15
|
+
*
|
|
16
|
+
* ⚠ TARBALL: this file is imported by event-id.mjs, so it MUST appear in
|
|
17
|
+
* bridge/package.json `files`. A publish that omits it boots a bridge that
|
|
18
|
+
* crashes on `import './recap.mjs'`. (feedback_npm_tarball_completeness.)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Default hard cap for the context-carry recap (chars of transcript body, framing extra).
|
|
22
|
+
export const RECAP_CAP = 20000
|
|
23
|
+
|
|
24
|
+
/* Why a lane is being handed a recap instead of a resumed session. The framing text
|
|
25
|
+
differs because the LIE each one must avoid differs:
|
|
26
|
+
|
|
27
|
+
'switch' — a model/provider switch ended the SDK session. The agent's own memory is
|
|
28
|
+
gone but nothing else happened; the transcript above is entirely its own work.
|
|
29
|
+
|
|
30
|
+
'wake' — ADR-0010 §d. The bridge was ASLEEP. The lane stayed alive and the people
|
|
31
|
+
kept talking to Pool (client-side BYOK, chat-only, no file access). So the recap
|
|
32
|
+
contains turns the agent DID NOT produce and work it DID NOT do. The framing has to
|
|
33
|
+
say that, and has to say that nothing ran on its behalf — otherwise the agent
|
|
34
|
+
assumes the plan below was already executed. That assumption is how "nothing
|
|
35
|
+
autofires" becomes a lie the agent tells itself. */
|
|
36
|
+
const FRAMING = {
|
|
37
|
+
switch: {
|
|
38
|
+
header: '[CONTEXT CARRY — you are RESUMING a live conversation after a model/provider switch]',
|
|
39
|
+
body: "Your session memory was reset by the switch, but this lane's conversation continued and the people already saw everything below. Do NOT re-greet, do NOT re-introduce yourself, and do NOT re-answer anything you already completed. Read the recap, then continue the work exactly where it left off. The LAST PERSON message with no completed answer below is your live task — pick it up.",
|
|
40
|
+
},
|
|
41
|
+
wake: {
|
|
42
|
+
header: '[CONTEXT CARRY — this lane kept talking while your machine was asleep]',
|
|
43
|
+
body: "Your bridge was offline. The lane stayed open and the people in this room kept planning here with Pool — a chat-only assistant with no file access and no tools. Everything below already happened and they have all seen it. NOTHING WAS RUN ON YOUR BEHALF: no commands were executed, no files were changed, and no plan below has been carried out. Do NOT re-greet and do NOT re-answer what is already answered. Read the recap, then wait for the people to tell you what to do with it — the plan below is a plan, not an instruction you have already been given.",
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Context-carry recap for a lane whose SDK memory CAN'T be resumed — a cross-provider
|
|
48
|
+
switch, a bridge-restart restore where canResume() is false, or (ADR-0010) a bridge
|
|
49
|
+
WAKE under a lane that spent the outage in Pool posture. Such a lane re-opens under the
|
|
50
|
+
SAME id with the visible transcript replayed but a FRESH, blank backend — so the agent
|
|
51
|
+
starts amnesiac mid-conversation (the 2026-07-08 lane 109d22e1 "this is the first
|
|
52
|
+
message in my lane" incident). Raw SDK resume can't cross a provider (signed thinking
|
|
53
|
+
blocks + provider-specific tool_use records get rejected by a foreign backend), so we
|
|
54
|
+
carry the conversation at the TEXT layer instead: synthesize a plain-text recap from the
|
|
55
|
+
saved log — the people's turns (`you`) + the assistant's own text replies (`assistant`
|
|
56
|
+
text blocks; thinking + tool_use are dropped, they don't survive a backend hop and are
|
|
57
|
+
noise) — newest-weighted under a hard char cap, framed so the agent RESUMES rather than
|
|
58
|
+
re-greets. Pure + import-safe (unit-tested in bridge/recap.test.mjs).
|
|
59
|
+
|
|
60
|
+
capReplayEvents lesson (rooms died once on a multi-MB replay): the char cap keeps this
|
|
61
|
+
bounded in prose form too — an unbounded recap would be the same bug wearing a text hat.
|
|
62
|
+
|
|
63
|
+
`reason` selects the framing only; the windowing is identical and the default
|
|
64
|
+
('switch') is byte-for-byte what this function returned before the S4 extraction. */
|
|
65
|
+
export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch' } = {}) {
|
|
66
|
+
if (!Array.isArray(log) || !log.length) return ''
|
|
67
|
+
const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
|
|
68
|
+
if (!budget) return ''
|
|
69
|
+
// Flatten the log to speaker-labelled turns, in order. `you` = a person in the room;
|
|
70
|
+
// `assistant` = this agent's own earlier reply (its text blocks only).
|
|
71
|
+
const turns = []
|
|
72
|
+
for (const e of log) {
|
|
73
|
+
if (!e || typeof e !== 'object') continue
|
|
74
|
+
if (e.kind === 'you') {
|
|
75
|
+
const t = String(e.text || '').trim()
|
|
76
|
+
if (t) turns.push({ who: 'PERSON', text: t })
|
|
77
|
+
} else if (e.kind === 'assistant') {
|
|
78
|
+
const t = (Array.isArray(e.blocks) ? e.blocks : [])
|
|
79
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.trim())
|
|
80
|
+
.map((b) => b.text.trim())
|
|
81
|
+
.join('\n')
|
|
82
|
+
if (t) turns.push({ who: 'YOU', text: t })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!turns.length) return ''
|
|
86
|
+
// Newest-weighted: walk from the end, keep whole turns until the char budget is spent,
|
|
87
|
+
// then restore chronological order. A single newest turn bigger than the whole budget is
|
|
88
|
+
// still kept (truncated) so the LIVE task is never dropped to the cap.
|
|
89
|
+
const kept = []
|
|
90
|
+
let used = 0
|
|
91
|
+
let omitted = false
|
|
92
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
93
|
+
let line = `${turns[i].who}: ${turns[i].text}`
|
|
94
|
+
if (used + line.length > budget) {
|
|
95
|
+
if (kept.length) { omitted = true; break } // older turns exist but won't fit → note + stop
|
|
96
|
+
line = line.slice(0, budget) // newest turn alone overflows → clip, still include
|
|
97
|
+
}
|
|
98
|
+
kept.push(line)
|
|
99
|
+
used += line.length
|
|
100
|
+
}
|
|
101
|
+
kept.reverse()
|
|
102
|
+
const body = (omitted ? ['[…earlier messages omitted for length…]'] : []).concat(kept).join('\n\n')
|
|
103
|
+
const frame = FRAMING[reason] || FRAMING.switch
|
|
104
|
+
return [
|
|
105
|
+
frame.header,
|
|
106
|
+
frame.body,
|
|
107
|
+
'',
|
|
108
|
+
'--- conversation so far (oldest → newest) ---',
|
|
109
|
+
body,
|
|
110
|
+
'--- end recap ---',
|
|
111
|
+
].join('\n')
|
|
112
|
+
}
|