thinkpool-pair 0.7.302 → 0.7.304
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 +17 -14
- package/bridge.mjs +31 -1
- package/package.json +1 -1
- package/presence.mjs +34 -0
- package/side-lane.mjs +21 -0
package/account.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { createClient } from '@supabase/supabase-js'
|
|
|
15
15
|
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
|
-
import { makeThrottledTrack,
|
|
18
|
+
import { makeThrottledTrack, presenceSelfEchoObservation } from './presence.mjs'
|
|
19
19
|
import { publicKeyB64, announceProviders, listProviders, addProvider, addProviderModel, removeProvider, unseal } from './providers.mjs'
|
|
20
20
|
import { supervisorServes, supervisorRoomsToStop } from './serve-consent.mjs'
|
|
21
21
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
@@ -603,16 +603,18 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
603
603
|
// respawn the supervisor every ~2.5 min ("the bridge disconnects and reconnects
|
|
604
604
|
// randomly", 2026-07-09 — nine respawns in an hour, track=ok on every one).
|
|
605
605
|
// Binding sync does two jobs: turns presence delivery ON (the echo becomes real)
|
|
606
|
-
// and stamps presenceSyncSeenAt
|
|
607
|
-
//
|
|
606
|
+
// and stamps presenceSyncSeenAt. acctSubscribedAt bounds the startup case where
|
|
607
|
+
// SUBSCRIBED fires but the first sync never arrives: that is a broken Realtime
|
|
608
|
+
// channel, not an unknowable state, once the listener was attached before join.
|
|
608
609
|
let presenceSyncSeenAt = 0
|
|
610
|
+
let acctSubscribedAt = 0
|
|
609
611
|
acct.on('presence', { event: 'sync' }, () => { presenceSyncSeenAt = Date.now() })
|
|
610
612
|
|
|
611
613
|
// Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
|
|
612
614
|
// (network blip, or a token swap mid-flight) rejoins the channel and must
|
|
613
615
|
// re-announce, or the dashboard would read "no bridge" until the next restart.
|
|
614
616
|
await new Promise((res) => acct.subscribe((st) => {
|
|
615
|
-
if (st === 'SUBSCRIBED') { pushPresence(); res() }
|
|
617
|
+
if (st === 'SUBSCRIBED') { if (!acctSubscribedAt) acctSubscribedAt = Date.now(); pushPresence(); res() }
|
|
616
618
|
else if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
|
|
617
619
|
// Realtime dropped (the 2026-07-08 `realtime CLOSED` blip). The socket normally
|
|
618
620
|
// auto-reconnects, but nudge it so presence — and the claim heartbeat that shares
|
|
@@ -1080,18 +1082,19 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
1080
1082
|
const presenceWatch = setInterval(() => {
|
|
1081
1083
|
if (stopping) return
|
|
1082
1084
|
const now = Date.now()
|
|
1083
|
-
// FAIL-SAFE: escalation requires PROVEN presence delivery on this channel (≥1
|
|
1084
|
-
// sync ever received). An empty presenceState() without it means WE can't see —
|
|
1085
|
-
// not that the track failed; respawning on that is the 0.7.174 false-positive
|
|
1086
|
-
// loop. A real zombie (the bug this watchdog exists for) still escalates:
|
|
1087
|
-
// delivery was proven earlier, then our key vanished from the server's state.
|
|
1088
|
-
if (!presenceSyncSeenAt) { selfEchoMissingSince = 0; return }
|
|
1089
1085
|
let present = false
|
|
1090
1086
|
try { present = !!acct.presenceState?.()[machine] } catch { present = false }
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1087
|
+
const observation = presenceSelfEchoObservation({
|
|
1088
|
+
tracking: claimHeld,
|
|
1089
|
+
subscribedAt: acctSubscribedAt,
|
|
1090
|
+
syncSeenAt: presenceSyncSeenAt,
|
|
1091
|
+
present,
|
|
1092
|
+
missingSince: selfEchoMissingSince,
|
|
1093
|
+
now,
|
|
1094
|
+
})
|
|
1095
|
+
selfEchoMissingSince = observation.missingSince
|
|
1096
|
+
if (!selfEchoMissingSince) { presenceRecycledForMissing = 0; return }
|
|
1097
|
+
const verdict = observation.verdict
|
|
1095
1098
|
if (verdict === 'recycle') {
|
|
1096
1099
|
if (presenceRecycledForMissing === selfEchoMissingSince) return // already recycled this episode — wait out the respawn window
|
|
1097
1100
|
presenceRecycledForMissing = selfEchoMissingSince
|
package/bridge.mjs
CHANGED
|
@@ -124,7 +124,7 @@ import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkRepl
|
|
|
124
124
|
import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
|
|
125
125
|
import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
|
|
126
126
|
import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
|
|
127
|
-
import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, sideContextBlock, sideSnapshot } from './side-lane.mjs'
|
|
127
|
+
import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, dispatchSideContexts, sideContextBlock, sideSnapshot } from './side-lane.mjs'
|
|
128
128
|
import { planMeterLine } from './plan-meters.mjs'
|
|
129
129
|
import { priceForModel } from './model-prices.mjs'
|
|
130
130
|
import { makeThrottledTrack } from './presence.mjs'
|
|
@@ -994,6 +994,32 @@ function advanceStructuredTurn(entry, now = Date.now()) {
|
|
|
994
994
|
return true
|
|
995
995
|
}
|
|
996
996
|
|
|
997
|
+
function dispatchPendingSideContexts(entry) {
|
|
998
|
+
if (!entry?.pendingSideContexts?.length || typeof entry.session?.sendTurn !== 'function') return false
|
|
999
|
+
const outcome = dispatchSideContexts({
|
|
1000
|
+
contexts: entry.pendingSideContexts,
|
|
1001
|
+
busy: entry.session?.turnActive === true,
|
|
1002
|
+
sendTurn: (prompt) => entry.session?.sendTurn(prompt),
|
|
1003
|
+
})
|
|
1004
|
+
entry.pendingSideContexts = outcome.pending
|
|
1005
|
+
if (!outcome.dispatched) return false
|
|
1006
|
+
beginStructuredTurn(entry)
|
|
1007
|
+
entry.flush?.()
|
|
1008
|
+
announce()
|
|
1009
|
+
return true
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function schedulePendingSideContexts(entry) {
|
|
1013
|
+
if (!entry?.pendingSideContexts?.length || entry._sideContextTimer) return
|
|
1014
|
+
entry._sideContextTimer = setTimeout(() => {
|
|
1015
|
+
entry._sideContextTimer = null
|
|
1016
|
+
if (dispatchPendingSideContexts(entry)) {
|
|
1017
|
+
process.stderr.write('\n ◆ started main turn from side-lane handoff.\n')
|
|
1018
|
+
}
|
|
1019
|
+
}, 0)
|
|
1020
|
+
entry._sideContextTimer.unref?.()
|
|
1021
|
+
}
|
|
1022
|
+
|
|
997
1023
|
function stampStructuredTurn(entry, event) {
|
|
998
1024
|
if (event && event.turnRev == null && Number(entry?._turnRev) > 0) event.turnRev = entry._turnRev
|
|
999
1025
|
return event
|
|
@@ -3238,6 +3264,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3238
3264
|
const done = { kind: 'control', text: 'Brought to main.', by: request.by }
|
|
3239
3265
|
stampEvent(done); pushLog(entry, done); bcast('code-event', { term: id, evt: done })
|
|
3240
3266
|
parent.flush?.()
|
|
3267
|
+
schedulePendingSideContexts(parent)
|
|
3241
3268
|
} else {
|
|
3242
3269
|
const failed = { kind: 'control', text: parent ? 'Couldn’t prepare a handoff — try again.' : 'The main terminal is no longer available.', by: request.by }
|
|
3243
3270
|
stampEvent(failed); pushLog(entry, failed); bcast('code-event', { term: id, evt: failed })
|
|
@@ -3252,6 +3279,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3252
3279
|
const stalePermissionIds = [...entry.pending.keys()]
|
|
3253
3280
|
drainPending(entry)
|
|
3254
3281
|
for (const pendingId of stalePermissionIds) bcast('code-perm', { term: id, id: pendingId, decision: 'deny', name: 'agent' })
|
|
3282
|
+
// Bring-to-main never interrupts an active parent turn. If a handoff arrived
|
|
3283
|
+
// while this lane was working, start it on the first idle tick after settle.
|
|
3284
|
+
schedulePendingSideContexts(entry)
|
|
3255
3285
|
const settledAt = Date.now()
|
|
3256
3286
|
if (shouldRecordTurnDone({ entry, subtype: evt.subtype, startedAt, now: settledAt })) {
|
|
3257
3287
|
persistAgentEvent({
|
package/package.json
CHANGED
package/presence.mjs
CHANGED
|
@@ -90,3 +90,37 @@ export function presenceSelfEchoVerdict({ tracking, selfEchoMissingSince, now, g
|
|
|
90
90
|
if (missingMs >= graceMs) return 'recycle'
|
|
91
91
|
return 'ok'
|
|
92
92
|
}
|
|
93
|
+
|
|
94
|
+
// Turn the supervisor's raw account-channel observation into the state consumed by
|
|
95
|
+
// presenceSelfEchoVerdict. A successful SUBSCRIBED callback is enough to start a
|
|
96
|
+
// bounded startup grace period even when Supabase never sends the first presence
|
|
97
|
+
// sync. Without that bound, `syncSeenAt === 0` disabled the watchdog forever: the
|
|
98
|
+
// HTTP claim stayed healthy while account presence and every private pair bus were
|
|
99
|
+
// dead, so a genuinely live partner room was reported as offline indefinitely.
|
|
100
|
+
//
|
|
101
|
+
// Once a sync has been seen, the original fail-safe still applies normally: a
|
|
102
|
+
// present self key clears the episode; a missing key starts at the observation tick.
|
|
103
|
+
export function presenceSelfEchoObservation({
|
|
104
|
+
tracking,
|
|
105
|
+
subscribedAt,
|
|
106
|
+
syncSeenAt,
|
|
107
|
+
present,
|
|
108
|
+
missingSince,
|
|
109
|
+
now,
|
|
110
|
+
graceMs = 60_000,
|
|
111
|
+
respawnMs = 90_000,
|
|
112
|
+
}) {
|
|
113
|
+
if (!tracking || !subscribedAt) return { missingSince: 0, verdict: 'ok' }
|
|
114
|
+
if (syncSeenAt && present) return { missingSince: 0, verdict: 'ok' }
|
|
115
|
+
const nextMissingSince = missingSince || (syncSeenAt ? now : subscribedAt)
|
|
116
|
+
return {
|
|
117
|
+
missingSince: nextMissingSince,
|
|
118
|
+
verdict: presenceSelfEchoVerdict({
|
|
119
|
+
tracking,
|
|
120
|
+
selfEchoMissingSince: nextMissingSince,
|
|
121
|
+
now,
|
|
122
|
+
graceMs,
|
|
123
|
+
respawnMs,
|
|
124
|
+
}),
|
|
125
|
+
}
|
|
126
|
+
}
|
package/side-lane.mjs
CHANGED
|
@@ -8,6 +8,9 @@ Focus first on reading, searching, comparing, and answering the side task. You a
|
|
|
8
8
|
export const SIDE_HANDOFF_PROMPT = `Prepare a compact handoff for the main terminal now.
|
|
9
9
|
Return only the handoff. Include: conclusion, strongest evidence, files changed or artifacts produced, and any unresolved question or recommended next action. Do not continue the investigation and do not address the reader conversationally.`
|
|
10
10
|
|
|
11
|
+
export const SIDE_MAIN_TURN_PROMPT = `A room member chose Bring to main.
|
|
12
|
+
Read the side-lane handoff below, incorporate the relevant findings into the main lane's current work, and respond now. If the handoff recommends a next step that is already authorized and in scope, take it; otherwise explain the concrete impact on the current work.`
|
|
13
|
+
|
|
11
14
|
export function sideSnapshot(log) {
|
|
12
15
|
return buildRecapFromLog(Array.isArray(log) ? log : [], SIDE_RECAP_CAP)
|
|
13
16
|
}
|
|
@@ -40,3 +43,21 @@ export function appendSideContext(contexts, context, cap = 4) {
|
|
|
40
43
|
if (!context) return Array.isArray(contexts) ? contexts.slice(-cap) : []
|
|
41
44
|
return [...(Array.isArray(contexts) ? contexts : []), context].slice(-cap)
|
|
42
45
|
}
|
|
46
|
+
|
|
47
|
+
export function sideMainTurnPrompt(contexts) {
|
|
48
|
+
const handoffs = (Array.isArray(contexts) ? contexts : []).filter(Boolean)
|
|
49
|
+
if (!handoffs.length) return ''
|
|
50
|
+
return `${SIDE_MAIN_TURN_PROMPT}\n\n${handoffs.join('\n\n')}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function dispatchSideContexts({ contexts, busy, sendTurn }) {
|
|
54
|
+
const pending = (Array.isArray(contexts) ? contexts : []).filter(Boolean).slice(-4)
|
|
55
|
+
if (!pending.length || busy || typeof sendTurn !== 'function') return { dispatched: false, pending }
|
|
56
|
+
const prompt = sideMainTurnPrompt(pending)
|
|
57
|
+
try {
|
|
58
|
+
if (sendTurn(prompt) === false) return { dispatched: false, pending }
|
|
59
|
+
} catch {
|
|
60
|
+
return { dispatched: false, pending }
|
|
61
|
+
}
|
|
62
|
+
return { dispatched: true, pending: [] }
|
|
63
|
+
}
|