thinkpool-pair 0.7.298 → 0.7.300
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 +13 -2
- package/bridge.mjs +26 -8
- package/cross-terminal.mjs +18 -1
- package/package.json +1 -1
- package/thinkpool-capabilities.json +3 -3
- package/viewport.mjs +18 -3
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
|
-
|
|
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
|
|
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 +
|
|
@@ -1972,8 +1972,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
1972
1972
|
// "<lane> — needs you: <what>"; answering it anywhere retracts the banner
|
|
1973
1973
|
// everywhere. Worker/flow lanes are excluded at arm() time (isUserFacingLane).
|
|
1974
1974
|
entry.permNotifier = createPermNotifier({
|
|
1975
|
-
|
|
1976
|
-
|
|
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 }),
|
|
1977
1981
|
})
|
|
1978
1982
|
// C1 (RT-2): per-term contiguous seq counter. Seeded from the restored log's max
|
|
1979
1983
|
// so a bridge restart resumes ABOVE every persisted seq — fresh events never reuse
|
|
@@ -3013,7 +3017,16 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3013
3017
|
// twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
|
|
3014
3018
|
const continuesQueued = runtime === 'hermes' && (evt.kind === 'result' || evt.kind === 'error') && (entry.session?.queuedDepth || 0) > 0
|
|
3015
3019
|
if (continuesQueued) evt.continuesQueued = true
|
|
3016
|
-
|
|
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)
|
|
3017
3030
|
stampStructuredTurn(entry, evt)
|
|
3018
3031
|
stampEvent(evt)
|
|
3019
3032
|
const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
|
|
@@ -3239,13 +3252,18 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3239
3252
|
const stalePermissionIds = [...entry.pending.keys()]
|
|
3240
3253
|
drainPending(entry)
|
|
3241
3254
|
for (const pendingId of stalePermissionIds) bcast('code-perm', { term: id, id: pendingId, decision: 'deny', name: 'agent' })
|
|
3242
|
-
|
|
3255
|
+
const settledAt = Date.now()
|
|
3256
|
+
if (shouldRecordTurnDone({ entry, subtype: evt.subtype, startedAt, now: settledAt })) {
|
|
3243
3257
|
persistAgentEvent({
|
|
3244
3258
|
kind: 'turn-done',
|
|
3245
3259
|
term: id,
|
|
3246
3260
|
termName: termNames[id] || null,
|
|
3261
|
+
model: entry.model || null,
|
|
3247
3262
|
summary: clipSummary(evt.kind === 'result' ? evt.resultText : evt.message),
|
|
3248
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 }),
|
|
3249
3267
|
})
|
|
3250
3268
|
}
|
|
3251
3269
|
}
|
|
@@ -3931,7 +3949,7 @@ channel
|
|
|
3931
3949
|
// Display the clean body (web sends `body` when the agent `text` carries host
|
|
3932
3950
|
// file paths) + the uploaded attachments, so the partner shows the image — not
|
|
3933
3951
|
// a raw /var path. The agent already got the full `text` via sendTurn above.
|
|
3934
|
-
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 } : {}) }
|
|
3935
3953
|
pushLog(s, evt)
|
|
3936
3954
|
bcast('code-event', { term: payload.term, evt })
|
|
3937
3955
|
}
|
package/cross-terminal.mjs
CHANGED
|
@@ -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 =
|
|
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
|
"schemaVersion": 1,
|
|
3
|
-
"bundleVersion":
|
|
3
|
+
"bundleVersion": 10,
|
|
4
4
|
"contracts": [
|
|
5
5
|
{
|
|
6
6
|
"id": "room-coordination",
|
|
@@ -88,13 +88,13 @@
|
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
"id": "visual-proof",
|
|
91
|
-
"version":
|
|
91
|
+
"version": 2,
|
|
92
92
|
"routes": [
|
|
93
93
|
{
|
|
94
94
|
"id": "visual-proof",
|
|
95
95
|
"tools": ["preview_start", "preview_capture", "preview_inspect", "preview_stop"],
|
|
96
96
|
"trigger": "\\b(ui|ux|visual|design|frontend|html|css|page|route|mockup|screenshot|responsive|desktop|mobile|preview)\\b",
|
|
97
|
-
"prompt": "For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. Surface PNG evidence only when no interactive source-backed Design card is displayed."
|
|
97
|
+
"prompt": "For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. preview_capture uses a fresh isolated browser, so authenticated app routes require an authenticated visual harness; if a room route resolves to the signed-out invitation, the capture is rejected and no mockup card is created. Surface PNG evidence only when no interactive source-backed Design card is displayed."
|
|
98
98
|
}
|
|
99
99
|
],
|
|
100
100
|
"impact": [
|
package/viewport.mjs
CHANGED
|
@@ -64,6 +64,12 @@ export function safeSlug(value = 'viewport') {
|
|
|
64
64
|
.slice(0, 56) || 'viewport'
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
export function isSignedOutRoomPreview(route, snapshot) {
|
|
68
|
+
const parsed = new URL(normalizeRoute(route), 'http://127.0.0.1')
|
|
69
|
+
if (!parsed.searchParams.get('r')) return false
|
|
70
|
+
return /\bid\s*=\s*["']invitation-room-title["']/i.test(String(snapshot || ''))
|
|
71
|
+
}
|
|
72
|
+
|
|
67
73
|
export function findBrowserExecutable({ env = process.env, platform = process.platform, exists = fs.existsSync } = {}) {
|
|
68
74
|
const candidates = [
|
|
69
75
|
env.TP_BROWSER_PATH,
|
|
@@ -425,6 +431,17 @@ export class ViewportManager {
|
|
|
425
431
|
if (names.some((name) => !DEFAULT_VIEWPORTS[name])) throw new Error('viewports must be "both", "desktop", or "mobile".')
|
|
426
432
|
await fsp.mkdir(this.outbox, { recursive: true })
|
|
427
433
|
const slug = `${safeSlug(title)}-${Date.now().toString(36)}`
|
|
434
|
+
// A preview browser is deliberately isolated and cannot inherit the room
|
|
435
|
+
// member's Supabase session. Catch the production failure mode where an
|
|
436
|
+
// agent requests /?r=ROOM expecting Code, but actually renders the public
|
|
437
|
+
// invitation landing and publishes it under an unrelated feature title.
|
|
438
|
+
// Validate before writing screenshots or a manifest so no misleading card
|
|
439
|
+
// can enter the room.
|
|
440
|
+
const snapshotViewport = names.includes('desktop') ? DEFAULT_VIEWPORTS.desktop : DEFAULT_VIEWPORTS[names[0]]
|
|
441
|
+
const snapshot = await this.browser.snapshot({ url, viewport: snapshotViewport, waitMs })
|
|
442
|
+
if (isSignedOutRoomPreview(normalizedRoute, snapshot)) {
|
|
443
|
+
throw new Error('Preview resolved to the signed-out room invitation instead of the authenticated Code room. preview_capture uses a fresh isolated browser and cannot inherit a person\'s login. Use an authenticated visual harness or signed-in browser evidence; no mockup card was created.')
|
|
444
|
+
}
|
|
428
445
|
const captures = {}
|
|
429
446
|
for (const name of names) {
|
|
430
447
|
const result = await this.browser.capture({ url, viewport: DEFAULT_VIEWPORTS[name], fullPage, waitMs })
|
|
@@ -432,8 +449,6 @@ export class ViewportManager {
|
|
|
432
449
|
await fsp.writeFile(file, result.png)
|
|
433
450
|
captures[name] = { ...result, file }
|
|
434
451
|
}
|
|
435
|
-
const snapshotViewport = names.includes('desktop') ? DEFAULT_VIEWPORTS.desktop : DEFAULT_VIEWPORTS[names[0]]
|
|
436
|
-
const snapshot = await this.browser.snapshot({ url, viewport: snapshotViewport, waitMs })
|
|
437
452
|
const snapshotPath = path.join(this.outbox, `${slug}--source.html`)
|
|
438
453
|
await fsp.writeFile(snapshotPath, snapshot)
|
|
439
454
|
const workspace = await fsp.realpath(this.workspaceRoot)
|
|
@@ -487,7 +502,7 @@ export function createViewportTools({ tool, z, manager }) {
|
|
|
487
502
|
),
|
|
488
503
|
tool(
|
|
489
504
|
'preview_capture',
|
|
490
|
-
'Capture this lane preview at exact desktop (1440×900) and mobile (390×844) CSS viewports. Defaults to both and full-page.
|
|
505
|
+
'Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Defaults to both and full-page. Authenticated app routes require an authenticated visual harness; a room route that resolves to the signed-out invitation is rejected. Successful captures return PNGs to your vision context and surface a mockup card in the ThinkPool room.',
|
|
491
506
|
{
|
|
492
507
|
path: z.string().max(500).optional().describe('route within the preview, e.g. / or /settings; never a full URL'),
|
|
493
508
|
viewports: z.enum(['both', 'desktop', 'mobile']).optional(),
|