thinkpool-pair 0.7.176 → 0.7.178
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/account.mjs +12 -1
- package/agent-notify.mjs +131 -0
- package/bridge.mjs +119 -11
- package/claude-session.mjs +19 -2
- package/event-id.mjs +8 -1
- package/package.json +2 -1
- package/providers.mjs +86 -0
package/account.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import os from 'node:os'
|
|
|
16
16
|
import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
18
|
import { makeThrottledTrack, presenceSelfEchoVerdict } from './presence.mjs'
|
|
19
|
-
import { publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
|
|
19
|
+
import { publicKeyB64, announceProviders, listProviders, addProvider, addProviderModel, removeProvider, unseal } from './providers.mjs'
|
|
20
20
|
import { supervisorServes } from './serve-consent.mjs'
|
|
21
21
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
22
22
|
import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
|
|
@@ -579,6 +579,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
579
579
|
if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ provider added (${String(fields?.name || '').slice(0, 40)}) — re-announced.\n`) }
|
|
580
580
|
provReply('provider-add-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
|
|
581
581
|
})
|
|
582
|
+
// provider-add-model {id, model, name?, nonce, jwt} → clone the source row's
|
|
583
|
+
// baseUrl + key HOST-SIDE onto a new model, re-announce. Deliberately carries NO
|
|
584
|
+
// `sealed` envelope: the key never leaves the bridge for this op, so there is
|
|
585
|
+
// nothing to decrypt. Owner-authed exactly like its siblings.
|
|
586
|
+
acct.on('broadcast', { event: 'provider-add-model' }, async ({ payload }) => {
|
|
587
|
+
const nonce = payload?.nonce
|
|
588
|
+
if (!(await isOwner(payload?.jwt))) return provReply('provider-add-model-res', { nonce, ok: false, error: 'unauthorized' })
|
|
589
|
+
const r = addProviderModel({ id: payload?.id, model: payload?.model, name: payload?.name })
|
|
590
|
+
if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ model added to an existing provider (${String(payload?.model || '').slice(0, 40)}) — re-announced.\n`) }
|
|
591
|
+
provReply('provider-add-model-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
|
|
592
|
+
})
|
|
582
593
|
// provider-remove {id, nonce, jwt} → remove (built-in refuses), re-announce.
|
|
583
594
|
acct.on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
|
|
584
595
|
const nonce = payload?.nonce
|
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,10 +39,13 @@ 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.
|
|
45
|
-
import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
|
|
48
|
+
import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel } from './providers.mjs'
|
|
46
49
|
import { validateProviderSwitch, BUILTIN_PROVIDER } from './switch-provider.mjs'
|
|
47
50
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
48
51
|
import { z } from 'zod'
|
|
@@ -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) {
|
|
@@ -1525,23 +1568,40 @@ function worktreeSnapshot(cwd) {
|
|
|
1525
1568
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
1526
1569
|
function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider, carryRecap }) {
|
|
1527
1570
|
if (sessions.has(id)) return
|
|
1571
|
+
// The lane's EFFECTIVE model — computed ONCE and used for BOTH the truthful display
|
|
1572
|
+
// label (entry.model) and the SDK's `model` option below. These used to be computed
|
|
1573
|
+
// separately: the label resolved the provider's configured model while the SDK got the
|
|
1574
|
+
// RAW inherited `model`, which OVERRIDES ANTHROPIC_MODEL from resolveProviderEnv. A GLM
|
|
1575
|
+
// lane opened while focused on Fable therefore showed `glm-4.6` and sent
|
|
1576
|
+
// `claude-fable-5` → 400 [1211][Unknown Model]. One value, one truth.
|
|
1577
|
+
// undefined → omit the SDK option entirely and let the provider env/endpoint default.
|
|
1578
|
+
const laneModel = effectiveLaneModel({ provider, model, configuredModel: resolveProviderEnv(provider)?.ANTHROPIC_MODEL })
|
|
1528
1579
|
// spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
|
|
1529
1580
|
// session store so the Ensemble flag survives a bridge restart (else a respin
|
|
1530
1581
|
// stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
|
|
1531
1582
|
const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode,
|
|
1532
|
-
// model: truthful active-model label
|
|
1533
|
-
//
|
|
1534
|
-
//
|
|
1535
|
-
//
|
|
1536
|
-
//
|
|
1583
|
+
// model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
|
|
1584
|
+
// chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
|
|
1585
|
+
// registered provider the SDK id is impersonated (see the onEvent guard below), so
|
|
1586
|
+
// laneModel resolves from THAT provider's CONFIGURED model — glm-4.6, not a Claude label.
|
|
1587
|
+
// No configured model → the provider's NAME, never a false Claude id (an inherited
|
|
1588
|
+
// claudeish id was dropped upstream by effectiveLaneModel; it must not sneak back in as a
|
|
1589
|
+
// label fallback here). Built-in Anthropic keeps the passed model.
|
|
1537
1590
|
model: (!provider || provider === 'anthropic')
|
|
1538
|
-
? (
|
|
1539
|
-
: (
|
|
1591
|
+
? (laneModel || null)
|
|
1592
|
+
: (laneModel || providerNameMap()[provider] || provider),
|
|
1540
1593
|
provider: provider || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null,
|
|
1541
1594
|
// Stable creation order — persisted so a bridge restart restores tabs in the SAME
|
|
1542
1595
|
// order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
|
|
1543
1596
|
// first transcript event ts, so even the first post-fix restart is ordered right.
|
|
1544
1597
|
openedAt: openedAt || (Array.isArray(log) ? (log.find((e) => e?.ts)?.ts || 0) : 0) || Date.now() }
|
|
1598
|
+
// Slice 3 — a permission card left unanswered past the grace window pushes
|
|
1599
|
+
// "<lane> — needs you: <what>"; answering it anywhere retracts the banner
|
|
1600
|
+
// everywhere. Worker/flow lanes are excluded at arm() time (isUserFacingLane).
|
|
1601
|
+
entry.permNotifier = createPermNotifier({
|
|
1602
|
+
onFire: (_cardId, summary) => persistAgentEvent({ kind: 'needs-input', term: id, termName: termNames[id] || null, summary }),
|
|
1603
|
+
onRetract: (_cardId) => persistAgentEvent({ kind: 'needs-resolved', term: id, termName: termNames[id] || null }),
|
|
1604
|
+
})
|
|
1545
1605
|
// C1 (RT-2): per-term contiguous seq counter. Seeded from the restored log's max
|
|
1546
1606
|
// so a bridge restart resumes ABOVE every persisted seq — fresh events never reuse
|
|
1547
1607
|
// an old seq, which is what keeps the between-turns restart dup-free (see
|
|
@@ -2045,7 +2105,15 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
2045
2105
|
// is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
|
|
2046
2106
|
entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
|
|
2047
2107
|
entry.session = startClaudeSession({
|
|
2048
|
-
|
|
2108
|
+
// laneModel, NOT the raw `model` param: the SDK's `model` option OVERRIDES the
|
|
2109
|
+
// ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
|
|
2110
|
+
// Claude id here defeats the whole per-provider env. undefined → option omitted.
|
|
2111
|
+
cwd: cwd || process.cwd(), model: laneModel, resume, mode, rolePrompt,
|
|
2112
|
+
// Haiku composer-prefill is an Anthropic-subscription freebie. This lane's env carries a
|
|
2113
|
+
// CUSTOM provider's base URL + auth token, so on a non-anthropic lane that call would bill
|
|
2114
|
+
// the user's own BYOK key for a feature they didn't ask for (and 400 on strict providers).
|
|
2115
|
+
// Same lane-provider key as the model-truth guard in onEvent below.
|
|
2116
|
+
suggest: !provider || provider === 'anthropic',
|
|
2049
2117
|
// Live ROOM NOW tail appended to every turn's <system-reminder>: sibling lanes
|
|
2050
2118
|
// (same registries the read_terminal roster uses) + active git worktrees (only
|
|
2051
2119
|
// when >1 — a solo checkout has nothing to coordinate about). Keeps every lane
|
|
@@ -2135,8 +2203,11 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
2135
2203
|
// roster broadcast (delivered to every subscriber) instead of sniffing the live
|
|
2136
2204
|
// code-event stream a passive dashboard channel misses. Covers every turn path
|
|
2137
2205
|
// (normal, spawn, flow lane, cross-room) since they all flow through onEvent.
|
|
2206
|
+
// The RISING edge is also where a turn's wall clock starts — the only place the
|
|
2207
|
+
// bridge sees "this lane just began working". `result`/`error` reads it to decide
|
|
2208
|
+
// whether the turn was long enough that nobody was watching (Slice 3).
|
|
2138
2209
|
{ const busyNow = entry.session?.turnActive ?? false
|
|
2139
|
-
if (busyNow !== entry._busyAnn) { entry._busyAnn = busyNow; announce() } }
|
|
2210
|
+
if (busyNow !== entry._busyAnn) { if (busyNow) entry._turnStart = Date.now(); entry._busyAnn = busyNow; announce() } }
|
|
2140
2211
|
// Capture this session's current MODEL (resolved full id, with version — from
|
|
2141
2212
|
// system/result `model` or usage `ctx.model`) and the SDK's supported-model
|
|
2142
2213
|
// LIST (from the `models` event) so the announce carries them to EVERY device.
|
|
@@ -2247,6 +2318,30 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
2247
2318
|
bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
2248
2319
|
entry.compactBy = null
|
|
2249
2320
|
}
|
|
2321
|
+
// ── Slice 3: the turn settled. Two things happen, in this order.
|
|
2322
|
+
// 1. Every outstanding permission card on this lane is now dead (the SDK
|
|
2323
|
+
// won't ask again for a turn that ended) — clearAll retracts any
|
|
2324
|
+
// "needs you" banner we already pushed. Do this even for aborted turns
|
|
2325
|
+
// and non-user-facing lanes: a stale card can never fire later.
|
|
2326
|
+
// 2. If the turn ran long enough that nobody was plausibly watching, and
|
|
2327
|
+
// the lane is one a human opened, and they didn't press Stop — write a
|
|
2328
|
+
// `turn-done` event row. An `error` turn uses the same neutral copy.
|
|
2329
|
+
// _turnStart is cleared unconditionally so a stray post-settle event can't
|
|
2330
|
+
// re-notify off a stale stamp.
|
|
2331
|
+
if (evt.kind === 'result' || evt.kind === 'error') {
|
|
2332
|
+
const startedAt = entry._turnStart
|
|
2333
|
+
entry._turnStart = null
|
|
2334
|
+
entry.permNotifier?.clearAll()
|
|
2335
|
+
if (shouldNotifyTurnDone({ entry, subtype: evt.subtype, startedAt, now: Date.now(), minTurnMs: MIN_TURN_MS })) {
|
|
2336
|
+
persistAgentEvent({
|
|
2337
|
+
kind: 'turn-done',
|
|
2338
|
+
term: id,
|
|
2339
|
+
termName: termNames[id] || null,
|
|
2340
|
+
summary: clipSummary(evt.kind === 'result' ? evt.resultText : evt.message),
|
|
2341
|
+
subtype: evt.subtype || (evt.kind === 'error' ? 'error' : null),
|
|
2342
|
+
})
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2250
2345
|
// Single tail: stamp into the log (transcript kinds only), broadcast live,
|
|
2251
2346
|
// print locally, persist. Shared so a deferred image event re-enters it once
|
|
2252
2347
|
// its base64 has been lifted to a Storage path (see deferImageEvent).
|
|
@@ -2281,6 +2376,13 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
2281
2376
|
const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions }
|
|
2282
2377
|
entry.pending.set(req.id, { resolve, payload })
|
|
2283
2378
|
bcast('code-perm-req', payload)
|
|
2379
|
+
// Slice 3 — arm the grace timer. Fires a "needs you" push only if the card is
|
|
2380
|
+
// STILL unanswered 20s from now (you're not in the room). Every resolve path —
|
|
2381
|
+
// the code-perm broadcast, drainPending, and the bypass/acceptEdits mode-flip
|
|
2382
|
+
// auto-approvals — calls permNotifier.resolve(id), which retracts the banner
|
|
2383
|
+
// if it had fired. Worker + flow-slice lanes are skipped: their cards belong to
|
|
2384
|
+
// an agent's fan-out, and a 12-lane flow would fan 12 banners at one person.
|
|
2385
|
+
if (isUserFacingLane(entry)) entry.permNotifier?.arm(req.id, permissionSummary(payload))
|
|
2284
2386
|
process.stderr.write(req.risk === 'plan'
|
|
2285
2387
|
? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
|
|
2286
2388
|
: req.risk === 'ask'
|
|
@@ -2310,6 +2412,8 @@ function drainPending(s) {
|
|
|
2310
2412
|
if (!s?.pending) return
|
|
2311
2413
|
for (const [, p] of s.pending) { try { p.resolve('deny') } catch { /* noop */ } }
|
|
2312
2414
|
s.pending.clear()
|
|
2415
|
+
// Every card just died → retract any "needs you" banner they raised (Slice 3).
|
|
2416
|
+
s.permNotifier?.clearAll()
|
|
2313
2417
|
}
|
|
2314
2418
|
|
|
2315
2419
|
// Switching a terminal to bypassPermissions must RETROACTIVELY clear the cards the
|
|
@@ -2329,6 +2433,7 @@ function allowPending(s) {
|
|
|
2329
2433
|
if (p?.payload?.risk === 'plan' || p?.payload?.risk === 'ask') continue
|
|
2330
2434
|
try { p.resolve('allow') } catch { /* noop */ }
|
|
2331
2435
|
s.pending.delete(key)
|
|
2436
|
+
s.permNotifier?.resolve(key) // auto-approved → retract the "needs you" banner
|
|
2332
2437
|
}
|
|
2333
2438
|
}
|
|
2334
2439
|
|
|
@@ -2347,6 +2452,7 @@ function acceptEditsPending(s) {
|
|
|
2347
2452
|
if (!ACCEPT_EDIT_TOOLS.has(tn)) continue
|
|
2348
2453
|
try { p.resolve('allow') } catch { /* noop */ }
|
|
2349
2454
|
s.pending.delete(key)
|
|
2455
|
+
s.permNotifier?.resolve(key) // auto-approved → retract the "needs you" banner
|
|
2350
2456
|
}
|
|
2351
2457
|
}
|
|
2352
2458
|
|
|
@@ -2742,7 +2848,9 @@ channel
|
|
|
2742
2848
|
const p = s && payload.id && s.pending.get(payload.id)
|
|
2743
2849
|
// Pass the raw decision through — normal tools use allow/deny, plan cards
|
|
2744
2850
|
// use run/accept/keep (claude-session interprets). Default deny on missing.
|
|
2745
|
-
|
|
2851
|
+
// Answered from a device → retract the "needs you" banner on every OTHER device
|
|
2852
|
+
// (permNotifier.resolve is a no-op unless its grace timer already fired).
|
|
2853
|
+
if (p) { s.pending.delete(payload.id); s.permNotifier?.resolve(payload.id); p.resolve(payload.decision || 'deny') }
|
|
2746
2854
|
})
|
|
2747
2855
|
.on('broadcast', { event: 'code-abort' }, ({ payload }) => {
|
|
2748
2856
|
const s = payload?.term && sessions.get(payload.term)
|
package/claude-session.mjs
CHANGED
|
@@ -176,6 +176,8 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
|
|
|
176
176
|
* @param {(req)=>Promise<'allow'|'deny'>} o.requestPermission
|
|
177
177
|
* called for EVERY tool call with { id, toolName, input, risk };
|
|
178
178
|
* resolve 'allow'/'deny'. (Caller implements any auto-allow policy.)
|
|
179
|
+
* @param {boolean=} o.suggest allow the Haiku composer-prefill fallback.
|
|
180
|
+
* MUST be false on a lane running a custom provider — see haikuSuggest.
|
|
179
181
|
* @returns {{ sendTurn(text), abort(), end(), readonly sessionId }}
|
|
180
182
|
*/
|
|
181
183
|
const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
@@ -203,7 +205,7 @@ const TP_ROOM_REMINDER = [
|
|
|
203
205
|
'Verify before claiming done — show runtime evidence you produced, not "should work, go test it".',
|
|
204
206
|
].join(' ')
|
|
205
207
|
|
|
206
|
-
export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null }) {
|
|
208
|
+
export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true }) {
|
|
207
209
|
// Per-turn reminder + live ROOM NOW tail. roomContext (bridge-supplied) returns the
|
|
208
210
|
// room's CURRENT state — sibling lanes, active git worktrees — or null. The static
|
|
209
211
|
// rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
|
|
@@ -746,7 +748,16 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
746
748
|
// (opts.env carries the OAuth — no API key needed), but as a BARE model call: no
|
|
747
749
|
// settingSources, no MCP, no tools — so it's a fast cold call, not a full session boot.
|
|
748
750
|
// Fed only `lastAssistantText` (trimmed). Never blocks a turn; any failure is silent.
|
|
751
|
+
//
|
|
752
|
+
// BUILT-IN ANTHROPIC PATH ONLY (`suggest`). On a lane running a registered custom
|
|
753
|
+
// provider, opts.env carries that provider's ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN —
|
|
754
|
+
// so this hardcoded `claude-haiku-4-5` call would spend the USER'S OWN BYOK credit on a
|
|
755
|
+
// composer prefill they never asked for, at whatever (wrong, flagship) model the endpoint
|
|
756
|
+
// maps the unknown id to. z.ai silently serves glm for any `claude-*` id, which is exactly
|
|
757
|
+
// why this went unnoticed; a stricter provider 400s. This is an Anthropic-subscription
|
|
758
|
+
// freebie — it does not get a per-provider small model, it gets switched off.
|
|
749
759
|
const haikuSuggest = async () => {
|
|
760
|
+
if (!suggest) return
|
|
750
761
|
const seed = (lastAssistantText || '').trim().slice(-1500)
|
|
751
762
|
if (!seed || closed) return
|
|
752
763
|
const ac2 = new AbortController()
|
|
@@ -978,7 +989,13 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
978
989
|
}
|
|
979
990
|
if (best) curModel = best
|
|
980
991
|
}
|
|
981
|
-
|
|
992
|
+
// resultText: the SDK's final assistant text, clipped. The bridge folds it
|
|
993
|
+
// into the `turn-done` push body ("<lane> — done: <summary>") so a banner
|
|
994
|
+
// says what happened, not just that something did. Clipped + whitespace-
|
|
995
|
+
// collapsed here so neither the broadcast frame nor the persisted log grows
|
|
996
|
+
// by a full turn's prose. Absent on aborted/error results — nothing to say.
|
|
997
|
+
{ const rt = typeof m.result === 'string' ? m.result.replace(/\s+/g, ' ').trim().slice(0, 200) : ''
|
|
998
|
+
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
999
|
// Arm the Haiku fallback: if Claude's own prompt_suggestion doesn't arrive within
|
|
983
1000
|
// the window, generate one from the last assistant reply. (Skip aborted turns.)
|
|
984
1001
|
if (sugTimer) clearTimeout(sugTimer)
|
package/event-id.mjs
CHANGED
|
@@ -215,6 +215,14 @@ export function summarizeUsage(logEvents) {
|
|
|
215
215
|
return { turns: results.length, per: [...per.values()], totalIn, totalOut, totalCost, haveCost }
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// Does this model id name a Claude/Anthropic model? Used by two callers with the
|
|
219
|
+
// same question and NO room for a second opinion:
|
|
220
|
+
// • usageReportLine — token/cost reporting is Claude-only.
|
|
221
|
+
// • providers.mjs/effectiveLaneModel — a lane on a CUSTOM (non-anthropic) provider
|
|
222
|
+
// must never be handed an inherited Claude id; the endpoint 400s on it.
|
|
223
|
+
// Exported (not a local const) precisely so there is exactly one definition.
|
|
224
|
+
export const claudeish = (m) => /claude|anthropic|opus|sonnet|haiku|fable/i.test(String(m || ''))
|
|
225
|
+
|
|
218
226
|
// model id → friendly label for the /usage report: claude-opus-4-8 → "Opus 4.8".
|
|
219
227
|
// Bridge-local twin of the web's prettyModel (structured.jsx); falls back to the raw id.
|
|
220
228
|
export function prettyModelId (m) {
|
|
@@ -229,7 +237,6 @@ export function prettyModelId (m) {
|
|
|
229
237
|
// handler is a thin wrapper: ctlLine(usageReportLine(s.model, s.log)). `sessionModel` is
|
|
230
238
|
// the resolved model id the /model state tracks; `logEvents` the session's retained log.
|
|
231
239
|
export function usageReportLine (sessionModel, logEvents) {
|
|
232
|
-
const claudeish = (m) => /claude|anthropic|opus|sonnet|haiku|fable/i.test(String(m || ''))
|
|
233
240
|
const sum = summarizeUsage(logEvents)
|
|
234
241
|
// Gate (Max's ask: "make /usage work if users bridge is on claude llms"). The Code bridge
|
|
235
242
|
// runs the Claude Agent SDK, so a normal lane IS a Claude model — but a session pointed at a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.178",
|
|
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",
|
package/providers.mjs
CHANGED
|
@@ -42,6 +42,7 @@ import os from 'node:os'
|
|
|
42
42
|
import fs from 'node:fs'
|
|
43
43
|
import path from 'node:path'
|
|
44
44
|
import crypto from 'node:crypto'
|
|
45
|
+
import { claudeish } from './event-id.mjs'
|
|
45
46
|
|
|
46
47
|
const DIR = path.join(os.homedir(), '.thinkpool-pair')
|
|
47
48
|
const REG_FILE = path.join(DIR, 'providers.json')
|
|
@@ -180,6 +181,59 @@ export function addProvider(fields) {
|
|
|
180
181
|
return { ok: true, id }
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Add ANOTHER model to an already-registered provider — without the user ever
|
|
186
|
+
* retyping the key (Max, 2026-07-09). The registry's unit is a {provider,model}
|
|
187
|
+
* pair, not a provider, and `buildSwitchMenu` already groups rows by brand
|
|
188
|
+
* ("Z.ai GLM 4.5" / "Z.ai GLM Air" collapse under one "z.ai" header) — so a new
|
|
189
|
+
* model is simply a CLONE of the source row with a fresh id and a new model.
|
|
190
|
+
* No schema change, no new menu shape.
|
|
191
|
+
*
|
|
192
|
+
* The clone copies `key` + `baseUrl` HOST-SIDE from the row that already holds
|
|
193
|
+
* them, so no key material crosses the wire for this operation — the caller's
|
|
194
|
+
* RPC carries no `sealed` envelope at all (see account.mjs `provider-add-model`).
|
|
195
|
+
*
|
|
196
|
+
* @param {{id:string, model:string, name?:string}} fields id = the SOURCE provider
|
|
197
|
+
* @returns {{ok:true,id:string} | {ok:false,error:string}}
|
|
198
|
+
*/
|
|
199
|
+
export function addProviderModel({ id, model, name } = {}) {
|
|
200
|
+
if (!id || id === BUILTIN_ID) return { ok: false, error: 'the built-in Anthropic provider has no key to reuse' }
|
|
201
|
+
if (!model || typeof model !== 'string' || !model.trim()) return { ok: false, error: 'model is required' }
|
|
202
|
+
const arr = loadProviders()
|
|
203
|
+
const src = arr.find((p) => p.id === id)
|
|
204
|
+
if (!src) return { ok: false, error: 'no such provider' }
|
|
205
|
+
|
|
206
|
+
const nextModel = String(model).trim()
|
|
207
|
+
// An endpoint+model pair is the identity of a row — a second row for the same
|
|
208
|
+
// pair would render as a duplicate menu entry that switches to nothing new.
|
|
209
|
+
if (arr.some((p) => p.baseUrl === src.baseUrl && (p.model || '') === nextModel)) {
|
|
210
|
+
return { ok: false, error: `${nextModel} is already registered on this provider` }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const nextName = (name && String(name).trim()) || `${brandOf(src.name)} ${nextModel}`.trim()
|
|
214
|
+
const newId = crypto.randomUUID()
|
|
215
|
+
arr.push({
|
|
216
|
+
id: newId,
|
|
217
|
+
name: nextName,
|
|
218
|
+
baseUrl: src.baseUrl, // copied host-side
|
|
219
|
+
model: nextModel,
|
|
220
|
+
key: src.key, // copied host-side — never re-sent by the client
|
|
221
|
+
addedAt: Date.now(),
|
|
222
|
+
})
|
|
223
|
+
saveProviders(arr)
|
|
224
|
+
return { ok: true, id: newId }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* First name token — the brand, matching code-constants.js `brandKey`. "Z.ai GLM
|
|
229
|
+
* 4.5" → "Z.ai". Used to derive a default name for a cloned row so the switch
|
|
230
|
+
* menu groups it under the SAME header as its source.
|
|
231
|
+
*/
|
|
232
|
+
function brandOf(name) {
|
|
233
|
+
const first = String(name || '').trim().split(/\s+/)[0]
|
|
234
|
+
return first || 'Provider'
|
|
235
|
+
}
|
|
236
|
+
|
|
183
237
|
/**
|
|
184
238
|
* Remove a provider by id. The built-in `anthropic` refuses.
|
|
185
239
|
* @returns {{ok:true} | {ok:false,error:string}}
|
|
@@ -244,3 +298,35 @@ export function resolveProviderEnv(id) {
|
|
|
244
298
|
if (p.model) env.ANTHROPIC_MODEL = p.model
|
|
245
299
|
return env
|
|
246
300
|
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The ONE model id a lane actually runs on — used for BOTH the display label and the
|
|
304
|
+
* SDK's `model` option, which is the whole point: they drifted, and the SDK option
|
|
305
|
+
* silently OVERRIDES the ANTHROPIC_MODEL env resolveProviderEnv() supplies.
|
|
306
|
+
*
|
|
307
|
+
* The bug this closes: a new lane inherits the focused lane's model (client-side), so
|
|
308
|
+
* opening a GLM lane while on Fable sent `model: 'claude-fable-5'` to
|
|
309
|
+
* https://api.z.ai/api/anthropic → `API Error: 400 [1211][Unknown Model]`, while the
|
|
310
|
+
* chip truthfully read `glm-4.6`. Label and wire disagreed; the wire lost.
|
|
311
|
+
*
|
|
312
|
+
* Rules (custom = any registered, non-built-in provider):
|
|
313
|
+
* • built-in anthropic / no provider → passthrough, unchanged. Default path is sacred.
|
|
314
|
+
* • custom + model equals the configured model → honour it.
|
|
315
|
+
* • custom + model is a Claude id (claudeish) → DROP it. It leaked across a provider
|
|
316
|
+
* boundary and the endpoint cannot serve it. Fall back to the configured model, or
|
|
317
|
+
* to undefined (no `model` option → the env / endpoint default decides).
|
|
318
|
+
* • custom + explicit non-Claude model (glm-4.5, deepseek-…) → honour it. This is a
|
|
319
|
+
* deliberate pick (/model, spawn_terminal({provider, model})), not an inheritance leak.
|
|
320
|
+
*
|
|
321
|
+
* NEVER returns an inherited Claude id on a custom provider — that is the invariant.
|
|
322
|
+
* Pure: `configuredModel` is passed in (not read from disk) so it is directly testable.
|
|
323
|
+
*
|
|
324
|
+
* @param {{provider?:string|null, model?:string|null, configuredModel?:string|null}} a
|
|
325
|
+
* @returns {string|undefined} the model to send; undefined → omit the SDK `model` option
|
|
326
|
+
*/
|
|
327
|
+
export function effectiveLaneModel({ provider, model, configuredModel } = {}) {
|
|
328
|
+
if (!provider || provider === BUILTIN_ID) return model || undefined
|
|
329
|
+
if (model && model === configuredModel) return model
|
|
330
|
+
if (model && !claudeish(model)) return model
|
|
331
|
+
return configuredModel || undefined
|
|
332
|
+
}
|