thinkpool-pair 0.7.290 → 0.7.292
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/bridge.mjs +87 -29
- package/hermes-session.mjs +3 -1
- package/launcher.mjs +38 -34
- package/package.json +2 -1
- package/replay-transport.mjs +64 -0
- package/service.mjs +133 -10
package/bridge.mjs
CHANGED
|
@@ -120,6 +120,7 @@ import { supersedeDispatchLease } from './dispatch-lease.mjs'
|
|
|
120
120
|
import { turnInFlight } from './update-gate.mjs'
|
|
121
121
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
122
122
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
123
|
+
import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
|
|
123
124
|
import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
|
|
124
125
|
import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
|
|
125
126
|
import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, sideContextBlock, sideSnapshot } from './side-lane.mjs'
|
|
@@ -251,7 +252,9 @@ if (argv[0] === 'restart-service') {
|
|
|
251
252
|
const arg1 = argv[1] || ''
|
|
252
253
|
const svcRoom = (!arg1 || arg1.startsWith('-')) ? null : arg1.toUpperCase().trim()
|
|
253
254
|
const svc = await import('./service.mjs')
|
|
254
|
-
const ok = argv.includes('--current')
|
|
255
|
+
const ok = argv.includes('--current')
|
|
256
|
+
? svc.restartService(svcRoom)
|
|
257
|
+
: await svc.updateAndConfirmService(svcRoom)
|
|
255
258
|
process.exit(ok ? 0 : 1)
|
|
256
259
|
}
|
|
257
260
|
|
|
@@ -345,8 +348,12 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
345
348
|
installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
|
|
346
349
|
uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
|
|
347
350
|
restartService: ({ room = null } = {}) => { svc.restartService(room) },
|
|
348
|
-
//
|
|
349
|
-
|
|
351
|
+
// The menu waits for OS-level proof before claiming success. In-service web
|
|
352
|
+
// updates keep using updateService(), whose non-blocking handoff is required
|
|
353
|
+
// because replacing the service tears down its own process tree.
|
|
354
|
+
restartUpdateService: async ({ room = null } = {}) => process.platform === 'win32'
|
|
355
|
+
? svc.updateService(room)
|
|
356
|
+
: svc.updateAndConfirmService(room),
|
|
350
357
|
login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
|
|
351
358
|
// Provider config is NOT terminal — it writes provider.json and returns to the
|
|
352
359
|
// menu so you can pick a model, switch back, or do something else. (It used to
|
|
@@ -574,6 +581,7 @@ if (process.stdin.isTTY && !headless && process.env.THINKPOOL_PAIR_AUTOUPDATE !=
|
|
|
574
581
|
}
|
|
575
582
|
const name = process.env.TP_NAME || os.userInfo().username || 'host'
|
|
576
583
|
const BRIDGE_ID = (randomUUID?.().slice(0,8)) || 'bridgexx'
|
|
584
|
+
const BRIDGE_STARTED_AT = Date.now()
|
|
577
585
|
// host: this machine's short label — os.hostname() with any DNS/domain suffix
|
|
578
586
|
// stripped and capped ~24 chars. A /code room can be served by different bridges
|
|
579
587
|
// over time (Max's Mac vs Conrad's Linux box); the client uses this to show WHICH
|
|
@@ -957,6 +965,13 @@ function beginStructuredTurn(entry, now = Date.now()) {
|
|
|
957
965
|
return true
|
|
958
966
|
}
|
|
959
967
|
|
|
968
|
+
function advanceStructuredTurn(entry, now = Date.now()) {
|
|
969
|
+
entry._turnRev = (Number(entry._turnRev) || 0) + 1
|
|
970
|
+
entry._turnStart = now
|
|
971
|
+
entry._busyAnn = true
|
|
972
|
+
return true
|
|
973
|
+
}
|
|
974
|
+
|
|
960
975
|
function stampStructuredTurn(entry, event) {
|
|
961
976
|
if (event && event.turnRev == null && Number(entry?._turnRev) > 0) event.turnRev = entry._turnRev
|
|
962
977
|
return event
|
|
@@ -1064,17 +1079,21 @@ const defendFrame = (event, payload) => {
|
|
|
1064
1079
|
return p
|
|
1065
1080
|
}
|
|
1066
1081
|
|
|
1067
|
-
const
|
|
1082
|
+
const bcastAwait = async (event, payload, ch = channel) => {
|
|
1068
1083
|
if (event === 'pty-out' || event === 'code-event') lastActivity = Date.now()
|
|
1069
1084
|
payload = defendFrame(event, payload)
|
|
1070
1085
|
try {
|
|
1071
1086
|
if (ch.channelAdapter?.canPush?.() ?? true) {
|
|
1072
|
-
ch.send({ type: 'broadcast', event, payload })
|
|
1087
|
+
return await ch.send({ type: 'broadcast', event, payload })
|
|
1073
1088
|
} else {
|
|
1074
|
-
ch.httpSend(event, payload)
|
|
1089
|
+
return await ch.httpSend(event, payload)
|
|
1075
1090
|
}
|
|
1076
|
-
} catch { /*
|
|
1091
|
+
} catch { return null /* offline — replay covers it */ }
|
|
1077
1092
|
}
|
|
1093
|
+
const bcast = (event, payload, ch = channel) => { void bcastAwait(event, payload, ch) }
|
|
1094
|
+
const replayPump = createLatestReplayPump({
|
|
1095
|
+
send: ({ event, payload }) => bcastAwait(event, payload),
|
|
1096
|
+
})
|
|
1078
1097
|
|
|
1079
1098
|
// Per-room terminal display names (id -> label), set by the web's `term-rename`.
|
|
1080
1099
|
// Persisted on the host so a rename is cross-device + survives a bridge restart;
|
|
@@ -1093,7 +1112,7 @@ const announce = () => {
|
|
|
1093
1112
|
const provNames = providerNameMap()
|
|
1094
1113
|
const rev = ++announceRev
|
|
1095
1114
|
return bcast('bridge', {
|
|
1096
|
-
v: 2, name, bridge_id: BRIDGE_ID, rev, repo: repoLabel, branch: readBranch(),
|
|
1115
|
+
v: 2, name, bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT, rev, repo: repoLabel, branch: readBranch(),
|
|
1097
1116
|
// sdkWarn: the auto-pulled agent SDK failed its boot compatibility smoke test —
|
|
1098
1117
|
// surfaced so the room can show a banner (turns may misbehave; pin a good SDK).
|
|
1099
1118
|
...(sdkStatus.ok === false ? { sdkWarn: `${sdkStatus.version}: ${sdkStatus.reason}` } : {}),
|
|
@@ -2876,6 +2895,20 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2876
2895
|
// per-machine default (provider.mjs/applyProviderEnv). null for built-in/unknown → the
|
|
2877
2896
|
// default Claude env is left exactly as-is (unchanged path).
|
|
2878
2897
|
env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), ...(resolveProviderEnv(provider) || {}), TP_MOCKUP_OUTBOX: mockupOutbox },
|
|
2898
|
+
onTurnStart: (options = {}) => {
|
|
2899
|
+
// Hermes promotes /queue items internally, without a second code-turn.
|
|
2900
|
+
// Advance the lifecycle before its first output and publish the deferred
|
|
2901
|
+
// human line under the new turn revision, keeping all viewers converged.
|
|
2902
|
+
if (entry._busyAnn === true) advanceStructuredTurn(entry)
|
|
2903
|
+
else beginStructuredTurn(entry)
|
|
2904
|
+
const queuedEcho = options._thinkpoolQueuedEcho
|
|
2905
|
+
if (queuedEcho) {
|
|
2906
|
+
const evt = { kind: 'you', ...queuedEcho }
|
|
2907
|
+
pushLog(entry, evt)
|
|
2908
|
+
bcast('code-event', { term: id, evt })
|
|
2909
|
+
}
|
|
2910
|
+
announce()
|
|
2911
|
+
},
|
|
2879
2912
|
onEvent: (evt) => {
|
|
2880
2913
|
if (!classifyCodeEvent(evt).known) {
|
|
2881
2914
|
// Never archive or replay an unknown provider payload as a known fact. Do
|
|
@@ -2933,7 +2966,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2933
2966
|
// replay-union dedupes ONLY by cid, and SDK events carry none — without an
|
|
2934
2967
|
// id, an event that arrives both live AND in a reconnect replay renders
|
|
2935
2968
|
// twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
|
|
2936
|
-
const
|
|
2969
|
+
const continuesQueued = runtime === 'hermes' && (evt.kind === 'result' || evt.kind === 'error') && (entry.session?.queuedDepth || 0) > 0
|
|
2970
|
+
if (continuesQueued) evt.continuesQueued = true
|
|
2971
|
+
const busyChanged = continuesQueued ? false : syncStructuredTurn(entry)
|
|
2937
2972
|
stampStructuredTurn(entry, evt)
|
|
2938
2973
|
stampEvent(evt)
|
|
2939
2974
|
const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
|
|
@@ -3651,14 +3686,17 @@ channel
|
|
|
3651
3686
|
// Flush pending bytes first so the replay is complete up to "now"; both
|
|
3652
3687
|
// ride the same ordered socket, so the client sees replay-then-live.
|
|
3653
3688
|
flushAll()
|
|
3654
|
-
|
|
3689
|
+
const to = payload?.to ?? null
|
|
3690
|
+
const requestId = String(payload?.requestId || randomUUID?.() || `${Date.now()}`)
|
|
3691
|
+
const frames = []
|
|
3692
|
+
for (const [id, t] of requestedReplayIds(terms, payload?.terms, payload?.priority)) {
|
|
3655
3693
|
if (!t.scrollback) continue
|
|
3656
3694
|
// Cap scrollback too — a huge PTY buffer would blow the same frame limit.
|
|
3657
3695
|
const sb = t.scrollback.length > 100000 ? t.scrollback.slice(-100000) : t.scrollback
|
|
3658
|
-
|
|
3659
|
-
to
|
|
3696
|
+
frames.push({ event: 'pty-replay', payload: {
|
|
3697
|
+
to, term: id, requestId,
|
|
3660
3698
|
b64: Buffer.from(sb, 'utf8').toString('base64'),
|
|
3661
|
-
})
|
|
3699
|
+
} })
|
|
3662
3700
|
}
|
|
3663
3701
|
// Structured sessions replay their event log (reader rebuilds from it).
|
|
3664
3702
|
// C1 (RT-2): send only the tail past the client's per-term cursor (seqHi) when
|
|
@@ -3669,14 +3707,13 @@ channel
|
|
|
3669
3707
|
// cursor) still gets the full log.
|
|
3670
3708
|
// Hydrate the viewed lane first. Other terminals still warm in the background,
|
|
3671
3709
|
// but cannot queue ahead of the transcript the person is waiting to see.
|
|
3672
|
-
const replaySessions =
|
|
3673
|
-
a === payload?.priority ? -1 : b === payload?.priority ? 1 : 0)
|
|
3710
|
+
const replaySessions = requestedReplayIds(sessions, payload?.terms, payload?.priority)
|
|
3674
3711
|
for (const [id, s] of replaySessions) {
|
|
3675
3712
|
// An explicit empty replay is an acknowledgement, not data. It lets mixed
|
|
3676
3713
|
// clients clear their loading cover even if they missed the announce's
|
|
3677
3714
|
// hasTranscript:false state.
|
|
3678
3715
|
if (!s.log.length) {
|
|
3679
|
-
|
|
3716
|
+
frames.push({ event: 'code-replay', payload: { to, term: id, requestId, chunkIndex: 0, chunkCount: 1, events: [], empty: true } })
|
|
3680
3717
|
continue
|
|
3681
3718
|
}
|
|
3682
3719
|
const from = Number(payload?.cursors?.[id]) || 0
|
|
@@ -3716,10 +3753,14 @@ channel
|
|
|
3716
3753
|
// trimmedBefore rides the FIRST (oldest) chunk only — the later chunks are contiguous
|
|
3717
3754
|
// with it, so a stamp there would read as a second, phantom gap. Same rule as
|
|
3718
3755
|
// lastUsage above and hasMore on history-page below.
|
|
3719
|
-
let
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3756
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
3757
|
+
const events = chunks[chunkIndex]
|
|
3758
|
+
frames.push({ event: 'code-replay', payload: {
|
|
3759
|
+
to, term: id, requestId, chunkIndex, chunkCount: chunks.length, events,
|
|
3760
|
+
...(ahead ? { reset: true } : {}),
|
|
3761
|
+
...(chunkIndex === 0 && trimmedBefore != null ? { trimmedBefore } : {}),
|
|
3762
|
+
...(chunkIndex === 0 && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}),
|
|
3763
|
+
} })
|
|
3723
3764
|
}
|
|
3724
3765
|
}
|
|
3725
3766
|
// Re-send any still-pending permission/question cards. They ride a one-shot
|
|
@@ -3728,10 +3769,15 @@ channel
|
|
|
3728
3769
|
// (unanswered) tool row — the AskUserQuestion "vanished on reconnect" bug. Only
|
|
3729
3770
|
// truly-unresolved cards remain in `pending` (resolved ones are deleted), and
|
|
3730
3771
|
// the client dedupes code-perm-req by id, so this can't resurrect an answered one.
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3772
|
+
// One awaited frame per event-loop turn. A newer request from this viewer
|
|
3773
|
+
// replaces every unsent frame from the older one, so visibility/online races
|
|
3774
|
+
// cannot multiply a cold replay into several concurrent megabyte bursts.
|
|
3775
|
+
replayPump.enqueue(to || '*', frames, async () => {
|
|
3776
|
+
for (const [, s] of sessions) {
|
|
3777
|
+
for (const [, p] of s.pending) await bcastAwait('code-perm-req', p.payload)
|
|
3778
|
+
}
|
|
3779
|
+
announce()
|
|
3780
|
+
})
|
|
3735
3781
|
})
|
|
3736
3782
|
// "Load earlier history": serve an OLDER page from the durable JSONL archive (the
|
|
3737
3783
|
// events the in-memory 2000-cap evicted). beforeSeq anchors the page at the client's
|
|
@@ -3750,11 +3796,12 @@ channel
|
|
|
3750
3796
|
if (!servesHistoryPage({ payloadHost: payload?.host, bridgeName: name, hasSession: sessions.has(id), hasArchive: hasDurableArchive(room, id) })) return
|
|
3751
3797
|
const { events, hasMore } = readDurablePage(room, id, Number(payload?.beforeSeq) || null, 200)
|
|
3752
3798
|
const to = payload?.to ?? null
|
|
3753
|
-
|
|
3799
|
+
const requestId = String(payload?.requestId || randomUUID?.() || `${Date.now()}`)
|
|
3800
|
+
if (!events.length) { bcast('history-page', { to, term: id, requestId, chunkIndex: 0, chunkCount: 1, events: [], hasMore: false }); return }
|
|
3754
3801
|
const chunks = chunkReplayEvents(events.map((e) => boundEventForBroadcast(e)))
|
|
3755
3802
|
// hasMore rides only the FIRST (oldest) chunk so the client sets the floor flag once
|
|
3756
3803
|
// from the true page boundary; later chunks are just more of the same page.
|
|
3757
|
-
chunks.forEach((evs, i) => bcast('history-page', { to, term: id, events: evs, hasMore: i === 0 ? hasMore : true }))
|
|
3804
|
+
chunks.forEach((evs, i) => bcast('history-page', { to, term: id, requestId, chunkIndex: i, chunkCount: chunks.length, events: evs, hasMore: i === 0 ? hasMore : true }))
|
|
3758
3805
|
})
|
|
3759
3806
|
.on('broadcast', { event: 'file-put' }, ({ payload }) => {
|
|
3760
3807
|
if (!payload?.id || !payload?.url) return
|
|
@@ -4030,13 +4077,24 @@ channel
|
|
|
4030
4077
|
const nativeImages = s.runtime === 'codex' || s.runtime === 'hermes'
|
|
4031
4078
|
? await waitForNativeImages(payload.files, { updir: UPDIR })
|
|
4032
4079
|
: []
|
|
4080
|
+
const deferHermesQueueEcho = s.runtime === 'hermes' && s.session.turnActive && /^\s*\/queue\s+/i.test(text)
|
|
4081
|
+
const turnOptions = {
|
|
4082
|
+
...(nativeImages.length ? { images: nativeImages } : {}),
|
|
4083
|
+
...(deferHermesQueueEcho && !payload.silent ? { _thinkpoolQueuedEcho: {
|
|
4084
|
+
text: payload.body != null ? String(payload.body) : text,
|
|
4085
|
+
cid: payload.cid,
|
|
4086
|
+
by: payload.by,
|
|
4087
|
+
...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}),
|
|
4088
|
+
...(Array.isArray(payload.pastes) && payload.pastes.length ? { pastes: payload.pastes } : {}),
|
|
4089
|
+
} } : {}),
|
|
4090
|
+
}
|
|
4033
4091
|
// Sample the runtime before dispatch so a missed prior falling edge cannot
|
|
4034
4092
|
// make this genuinely new turn inherit the previous turn's revision.
|
|
4035
4093
|
syncStructuredTurn(s)
|
|
4036
|
-
const accepted = s.session.sendTurn(sendText,
|
|
4094
|
+
const accepted = s.session.sendTurn(sendText, Object.keys(turnOptions).length ? turnOptions : undefined)
|
|
4037
4095
|
if (accepted === false) syncStructuredTurn(s)
|
|
4038
4096
|
else beginStructuredTurn(s)
|
|
4039
|
-
echoYou()
|
|
4097
|
+
if (!deferHermesQueueEcho || accepted === false) echoYou()
|
|
4040
4098
|
if (accepted === false) {
|
|
4041
4099
|
// A runtime that did not accept a turn must still close the optimistic
|
|
4042
4100
|
// user-line lifecycle. Without this boundary the client truthfully shows
|
|
@@ -4312,7 +4370,7 @@ channel
|
|
|
4312
4370
|
.subscribe(async status => {
|
|
4313
4371
|
if (status === 'SUBSCRIBED') {
|
|
4314
4372
|
realtimeHealthy = true; brokenSince = 0
|
|
4315
|
-
trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID })
|
|
4373
|
+
trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT })
|
|
4316
4374
|
const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
|
|
4317
4375
|
if (startCmd && !terms.size && !sessions.size) {
|
|
4318
4376
|
// Claude + structured mode → Agent SDK session; everything else → PTY.
|
package/hermes-session.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export function startHermesSession({
|
|
|
68
68
|
command = HERMES_COMMAND, args = ['acp'], clientFactory = createAcpClient,
|
|
69
69
|
mcpHttpFactory = startCodexMcpHttp, lazy = false, hermesRole = null,
|
|
70
70
|
crossPostGate = null, didSpawnTarget = null, crossRoomPostGate = null, effort = 'high',
|
|
71
|
-
admitStart = null,
|
|
71
|
+
admitStart = null, onTurnStart = null,
|
|
72
72
|
} = {}) {
|
|
73
73
|
let activeCwd = cwd
|
|
74
74
|
const requestedModel = model || null
|
|
@@ -503,6 +503,7 @@ export function startHermesSession({
|
|
|
503
503
|
const next = queuedTurns.shift()
|
|
504
504
|
const turnId = ++activeTurnId
|
|
505
505
|
turnActive = true
|
|
506
|
+
try { onTurnStart?.(next.options || {}) } catch { /* observer cannot break the FIFO */ }
|
|
506
507
|
try { await runPrompt(next.text, next.options, { steering: false, turnId, promptIndex: next.promptIndex, forceFull: next.forceFull }) }
|
|
507
508
|
catch (error) { turnActive = false; emit({ kind: 'error', message: `Hermes queued turn failed: ${error?.message || error}`, recoverable: true }) }
|
|
508
509
|
}
|
|
@@ -514,6 +515,7 @@ export function startHermesSession({
|
|
|
514
515
|
return {
|
|
515
516
|
get sessionId() { return sessionId },
|
|
516
517
|
get turnActive() { return turnActive },
|
|
518
|
+
get queuedDepth() { return queuedTurns.length },
|
|
517
519
|
get canSteer() { return !!client?.alive },
|
|
518
520
|
get started() { return started },
|
|
519
521
|
get models() { return [] },
|
package/launcher.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import path from 'node:path'
|
|
|
16
16
|
import { execSync } from 'node:child_process'
|
|
17
17
|
import { detectProvider, fetchModels, ANTHROPIC_COMPATIBLE, gatewayHint } from './byok-detect.mjs'
|
|
18
18
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
19
|
-
import { darwinServiceRunning } from './service.mjs'
|
|
19
|
+
import { darwinServiceRunning, serviceRuntimeVersion } from './service.mjs'
|
|
20
20
|
|
|
21
21
|
const HOME = os.homedir()
|
|
22
22
|
const CFG_DIR = path.join(HOME, '.thinkpool-pair')
|
|
@@ -28,16 +28,8 @@ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./pack
|
|
|
28
28
|
|
|
29
29
|
export const KNOWN_AGENTS = [
|
|
30
30
|
{ label: 'Claude Code', cmd: 'claude', resume: ['--continue'] },
|
|
31
|
-
{ label: 'Codex
|
|
32
|
-
{ label: 'Hermes
|
|
33
|
-
{ label: 'Gemini CLI', cmd: 'gemini' },
|
|
34
|
-
{ label: 'Aider', cmd: 'aider' },
|
|
35
|
-
{ label: 'Cursor CLI', cmd: 'cursor-agent' },
|
|
36
|
-
{ label: 'opencode', cmd: 'opencode' },
|
|
37
|
-
{ label: 'Copilot CLI', cmd: 'copilot' },
|
|
38
|
-
{ label: 'Goose', cmd: 'goose' },
|
|
39
|
-
{ label: 'Crush', cmd: 'crush' },
|
|
40
|
-
{ label: 'Qwen Code', cmd: 'qwen' },
|
|
31
|
+
{ label: 'Codex', cmd: 'codex' },
|
|
32
|
+
{ label: 'Hermes', cmd: 'thinkpool' },
|
|
41
33
|
]
|
|
42
34
|
|
|
43
35
|
function onPath(c) {
|
|
@@ -82,6 +74,7 @@ export function detectState() {
|
|
|
82
74
|
const isCustom = !!provider?.provider && provider.provider !== 'anthropic'
|
|
83
75
|
const hermesInstalled = onPath('hermes')
|
|
84
76
|
const hermesReady = onPath('thinkpool') && probeHermesRuntime().available
|
|
77
|
+
const accountSvc = serviceLoaded(null)
|
|
85
78
|
return {
|
|
86
79
|
loggedIn: !!auth?.refresh_token,
|
|
87
80
|
email: auth?.email || auth?.user?.email || null,
|
|
@@ -90,7 +83,9 @@ export function detectState() {
|
|
|
90
83
|
agents: KNOWN_AGENTS.filter(a => onPath(a.cmd) && (a.cmd !== 'thinkpool' || hermesReady)),
|
|
91
84
|
hermesInstalled,
|
|
92
85
|
hermesReady,
|
|
93
|
-
accountSvc
|
|
86
|
+
accountSvc,
|
|
87
|
+
serviceVersion: accountSvc ? serviceRuntimeVersion(null) : null,
|
|
88
|
+
platform: process.platform,
|
|
94
89
|
cwd: process.cwd(),
|
|
95
90
|
version: VERSION,
|
|
96
91
|
}
|
|
@@ -107,7 +102,19 @@ const C = {
|
|
|
107
102
|
// reflects the change (real bridge passes detectState; tests omit it to keep the
|
|
108
103
|
// injected state stable).
|
|
109
104
|
export async function runLauncher({ actions, io, state = detectState(), refresh = null }) {
|
|
110
|
-
const
|
|
105
|
+
const normalizeState = (next) => ({
|
|
106
|
+
...next,
|
|
107
|
+
agents: Array.isArray(next?.agents) ? next.agents : [],
|
|
108
|
+
loggedIn: !!next?.loggedIn,
|
|
109
|
+
accountSvc: !!next?.accountSvc,
|
|
110
|
+
serviceVersion: typeof next?.serviceVersion === 'string' && next.serviceVersion ? next.serviceVersion : null,
|
|
111
|
+
platform: next?.platform || process.platform,
|
|
112
|
+
provider: next?.provider || 'Anthropic (default)',
|
|
113
|
+
cwd: next?.cwd || process.cwd(),
|
|
114
|
+
version: next?.version || VERSION,
|
|
115
|
+
})
|
|
116
|
+
state = normalizeState(state)
|
|
117
|
+
const resync = () => { if (refresh) state = normalizeState(refresh()) }
|
|
111
118
|
const askChoice = async (prompt, options, def = 1) => {
|
|
112
119
|
options.forEach((o, i) => io.print(` ${C.cyan(String(i + 1))}) ${o.label}${o.hint ? ' ' + C.dim(o.hint) : ''}`))
|
|
113
120
|
const a = await io.ask(`\n ${prompt} ${C.dim(`[${def}]`)} ${C.cyan('▸')} `)
|
|
@@ -121,12 +128,17 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
121
128
|
|
|
122
129
|
const header = () => {
|
|
123
130
|
io.print('')
|
|
124
|
-
io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') +
|
|
131
|
+
io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + C.dim(' ' + '─'.repeat(40)))
|
|
132
|
+
io.print(` ${C.dim('│ launcher ')} ${state.version ? `v${state.version}` : 'version unavailable'}`)
|
|
125
133
|
io.print(` ${C.dim('│ account ')} ${state.loggedIn ? C.green((state.email || 'linked') + ' ✓') : C.yellow('not linked')}`)
|
|
126
|
-
io.print(` ${C.dim('│
|
|
127
|
-
io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none on PATH')}`)
|
|
134
|
+
io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none ready')}`)
|
|
128
135
|
io.print(` ${C.dim('│ directory')} ${C.dim(state.cwd)}`)
|
|
129
|
-
|
|
136
|
+
const bridgeStatus = !state.accountSvc
|
|
137
|
+
? 'not installed'
|
|
138
|
+
: state.platform === 'win32'
|
|
139
|
+
? C.green('startup entry installed')
|
|
140
|
+
: C.green(`running${state.serviceVersion ? ` v${state.serviceVersion}` : ' (version unavailable)'}`)
|
|
141
|
+
io.print(` ${C.dim('│ bridge ')} ${bridgeStatus}`)
|
|
130
142
|
io.print(' ' + C.dim('└' + '─'.repeat(54)) + '\n')
|
|
131
143
|
}
|
|
132
144
|
|
|
@@ -135,7 +147,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
135
147
|
const pairRoom = async () => {
|
|
136
148
|
const room = (await io.ask(`\n Room code ${C.dim('(from /code in the web app)')} ${C.cyan('▸')} `)).toUpperCase().trim()
|
|
137
149
|
if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
|
|
138
|
-
if (!state.agents.length) { io.print('\n ' + C.yellow('No
|
|
150
|
+
if (!state.agents.length) { io.print('\n ' + C.yellow('No supported agent runtime is ready (Claude Code, Codex, or Hermes).')); return }
|
|
139
151
|
let agent = state.agents[0]
|
|
140
152
|
if (state.agents.length > 1) { io.print('\n Share which agent?'); agent = state.agents[await askChoice('agent', state.agents.map(a => ({ label: a.label })))] }
|
|
141
153
|
io.print('\n Run mode?')
|
|
@@ -217,30 +229,20 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
217
229
|
const settingsMenu = async () => {
|
|
218
230
|
for (;;) {
|
|
219
231
|
const options = [
|
|
220
|
-
{ key: 'provider', label: '
|
|
232
|
+
{ key: 'provider', label: 'Provider', hint: `current: ${state.provider}` },
|
|
221
233
|
{ key: 'account', label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
222
|
-
{ key: 'restart', label: 'Restart & update the bridge', hint: state.accountSvc ? 'pull the newest published version, then restart — sessions resume' : 'none installed' },
|
|
223
|
-
{ key: 'remove', label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
|
|
224
234
|
]
|
|
225
|
-
if (state.hermesInstalled) options.push({ key: 'hermes', label: 'Hermes
|
|
226
|
-
options.push({ key: 'back', label: '
|
|
235
|
+
if (state.hermesInstalled) options.push({ key: 'hermes', label: state.hermesReady ? 'Hermes profile' : 'Set up Hermes', hint: state.hermesReady ? 'isolated profile ready' : 'set up isolated profile + delegation guard' })
|
|
236
|
+
options.push({ key: 'back', label: 'Back to main menu', hint: '' })
|
|
227
237
|
const picked = options[await askChoice('\n change', options, options.length)]
|
|
228
238
|
if (picked.key === 'back') return
|
|
229
239
|
if (picked.key === 'provider') await providerMenu()
|
|
230
240
|
else if (picked.key === 'account') { await actions.login(); resync(); return }
|
|
231
|
-
else if (picked.key === 'restart') {
|
|
232
|
-
if (state.accountSvc) { await actions.restartUpdateService({ room: null }); resync() }
|
|
233
|
-
else io.print(' ' + C.yellow('no background service installed'))
|
|
234
|
-
}
|
|
235
|
-
else if (picked.key === 'remove') {
|
|
236
|
-
if (state.accountSvc) { await actions.uninstallService({ room: null }); resync() }
|
|
237
|
-
else io.print(' ' + C.yellow('no background service installed'))
|
|
238
|
-
}
|
|
239
241
|
else if (picked.key === 'hermes') {
|
|
240
242
|
const setup = await askChoice('\n Hermes profile', [
|
|
241
243
|
{ label: 'Clone active profile', hint: 'copies provider/config explicitly; fresh ThinkPool session history' },
|
|
242
244
|
{ label: 'Create clean profile', hint: 'no copied credentials or bundled skills; configure provider afterward' },
|
|
243
|
-
{ label: '
|
|
245
|
+
{ label: 'Back', hint: '' },
|
|
244
246
|
], 3)
|
|
245
247
|
if (setup !== 2) { await actions.setupHermes({ mode: setup === 0 ? 'clone' : 'clean' }); resync() }
|
|
246
248
|
}
|
|
@@ -261,14 +263,16 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
261
263
|
// Slots 1+2 swap on whether the always-on service is already installed.
|
|
262
264
|
const items = state.accountSvc
|
|
263
265
|
? [
|
|
264
|
-
|
|
266
|
+
state.platform === 'win32'
|
|
267
|
+
? { key: 'restart', label: 'Update startup entry', hint: 'installs the latest version; relaunch the bridge window to apply' }
|
|
268
|
+
: { key: 'restart', label: 'Restart & update bridge', hint: 'install the latest published version; sessions resume' },
|
|
265
269
|
{ key: 'uninstall', label: 'Remove background service', hint: 'stop serving your sessions on this device' },
|
|
266
270
|
]
|
|
267
271
|
: [
|
|
268
272
|
{ key: 'serve', label: 'Serve all my sessions', hint: 'runs here, Ctrl-C stops it' },
|
|
269
273
|
{ key: 'service', label: 'Always-on background service', hint: 'recommended — survives reboot, restarts on crash' },
|
|
270
274
|
]
|
|
271
|
-
items.push({ key: 'settings', label: 'Settings', hint:
|
|
275
|
+
items.push({ key: 'settings', label: 'Settings', hint: `provider · account${state.hermesInstalled ? ' · Hermes profile' : ''}` })
|
|
272
276
|
items.push({ key: 'quit', label: 'Quit', hint: '' })
|
|
273
277
|
const pick = items[await askChoice('choose', items)]
|
|
274
278
|
if (pick.key === 'quit') return
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.292",
|
|
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": {
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"pair-control-authority.mjs",
|
|
50
50
|
"event-id.mjs",
|
|
51
51
|
"event-bounds.mjs",
|
|
52
|
+
"replay-transport.mjs",
|
|
52
53
|
"plan-meters.mjs",
|
|
53
54
|
"recap.mjs",
|
|
54
55
|
"transcript-sanitize.mjs",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Replay transport policy. Reconnects are allowed to supersede older work for
|
|
2
|
+
// the same viewer, and frames are deliberately paced so live events and Stop /
|
|
3
|
+
// permission traffic are not trapped behind a multi-megabyte replay burst.
|
|
4
|
+
|
|
5
|
+
export function requestedReplayIds(entries, terms, priority) {
|
|
6
|
+
const requested = Array.isArray(terms) ? new Set(terms.filter(Boolean)) : null
|
|
7
|
+
return [...entries]
|
|
8
|
+
.filter(([id]) => !requested || requested.has(id))
|
|
9
|
+
.sort(([a], [b]) => a === priority ? -1 : b === priority ? 1 : 0)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const defaultYield = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
13
|
+
|
|
14
|
+
export function createLatestReplayPump({ send, yieldTurn = defaultYield } = {}) {
|
|
15
|
+
if (typeof send !== 'function') throw new TypeError('replay pump requires send(frame)')
|
|
16
|
+
const viewers = new Map()
|
|
17
|
+
|
|
18
|
+
const run = async (viewer, state) => {
|
|
19
|
+
if (state.running) return
|
|
20
|
+
state.running = true
|
|
21
|
+
try {
|
|
22
|
+
while (state.frames.length) {
|
|
23
|
+
const generation = state.generation
|
|
24
|
+
const frame = state.frames.shift()
|
|
25
|
+
await send(frame)
|
|
26
|
+
await yieldTurn()
|
|
27
|
+
// enqueue() replaces the remaining frames. The send already in flight is
|
|
28
|
+
// allowed to finish; nothing older can start after the replacement.
|
|
29
|
+
if (generation !== state.generation) continue
|
|
30
|
+
}
|
|
31
|
+
const done = state.onDone
|
|
32
|
+
state.onDone = null
|
|
33
|
+
if (done) await done()
|
|
34
|
+
} finally {
|
|
35
|
+
state.running = false
|
|
36
|
+
if (state.frames.length) void run(viewer, state)
|
|
37
|
+
else if (viewers.get(viewer) === state) viewers.delete(viewer)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
enqueue(viewer, frames, onDone = null) {
|
|
43
|
+
const key = String(viewer || '*')
|
|
44
|
+
const state = viewers.get(key) || { generation: 0, frames: [], onDone: null, running: false }
|
|
45
|
+
state.generation += 1
|
|
46
|
+
state.frames = Array.isArray(frames) ? [...frames] : []
|
|
47
|
+
state.onDone = typeof onDone === 'function' ? onDone : null
|
|
48
|
+
viewers.set(key, state)
|
|
49
|
+
void run(key, state)
|
|
50
|
+
return state.generation
|
|
51
|
+
},
|
|
52
|
+
cancel(viewer) {
|
|
53
|
+
const state = viewers.get(String(viewer || '*'))
|
|
54
|
+
if (!state) return false
|
|
55
|
+
state.generation += 1
|
|
56
|
+
state.frames = []
|
|
57
|
+
state.onDone = null
|
|
58
|
+
return true
|
|
59
|
+
},
|
|
60
|
+
pending(viewer) {
|
|
61
|
+
return viewers.get(String(viewer || '*'))?.frames.length || 0
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
package/service.mjs
CHANGED
|
@@ -217,7 +217,7 @@ StandardError=append:${log}
|
|
|
217
217
|
[Install]
|
|
218
218
|
WantedBy=default.target
|
|
219
219
|
`
|
|
220
|
-
return { file, content, logDir, post: ['systemctl --user daemon-reload', `systemctl --user enable --
|
|
220
|
+
return { file, content, logDir, post: ['systemctl --user daemon-reload', `systemctl --user enable ${label(room)}.service`, `systemctl --user restart ${label(room)}.service`], note: 'Enabled as a systemd --user service. Run `loginctl enable-linger $USER` once to keep it running after logout.' }
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
if (platform === 'win32') {
|
|
@@ -242,8 +242,40 @@ WantedBy=default.target
|
|
|
242
242
|
// Parse both legacy npx commands (`thinkpool-pair@<ver>`) and stable runtime commands
|
|
243
243
|
// (`.../runtimes/<ver>/...`).
|
|
244
244
|
export function parseRunningPairVersions(out) {
|
|
245
|
-
const versions = String(out).match(/(?:thinkpool-pair@|runtimes
|
|
246
|
-
return new Set(versions.map((s) => s.match(/\d+\.\d+\.\d
|
|
245
|
+
const versions = String(out).match(/(?:thinkpool-pair@|runtimes[\\/])(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/g) || []
|
|
246
|
+
return new Set(versions.map((s) => s.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/)?.[0]).filter(Boolean))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Return a version only when the OS service manager proves that THIS service is
|
|
250
|
+
// both live and configured to execute one unambiguous pair runtime. This is
|
|
251
|
+
// deliberately stricter than isServiceInstalled(): a plist/unit on disk, or a
|
|
252
|
+
// process list that happens to contain thinkpool-pair, is not update evidence.
|
|
253
|
+
// Windows' Startup-folder tier has no service-manager identity to query, so an
|
|
254
|
+
// exact running version cannot be attributed safely and is therefore unproven.
|
|
255
|
+
export function serviceRuntimeSnapshot(room, { platform = process.platform, exec = execSync } = {}) {
|
|
256
|
+
const exactVersion = (output) => {
|
|
257
|
+
const versions = parseRunningPairVersions(output)
|
|
258
|
+
return versions.size === 1 ? [...versions][0] : null
|
|
259
|
+
}
|
|
260
|
+
const id = label(room)
|
|
261
|
+
try {
|
|
262
|
+
if (platform === 'darwin') {
|
|
263
|
+
const out = exec(`launchctl print gui/$(id -u)/${id}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: '/bin/bash' })
|
|
264
|
+
const pid = String(out).match(/(^|\n)[ \t]*pid = (\d+)[ \t]*($|\n)/)?.[2]
|
|
265
|
+
return darwinServiceRunning(out) ? { version: exactVersion(out), pid: pid || null } : null
|
|
266
|
+
}
|
|
267
|
+
if (platform === 'linux') {
|
|
268
|
+
const out = exec(`systemctl --user show ${id}.service --property=ActiveState --property=MainPID --property=ExecStart`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
269
|
+
const pid = String(out).match(/^MainPID=([1-9]\d*)$/m)?.[1]
|
|
270
|
+
if (!/^ActiveState=active$/m.test(String(out)) || !pid) return null
|
|
271
|
+
return { version: exactVersion(out), pid }
|
|
272
|
+
}
|
|
273
|
+
} catch { /* service manager could not prove a live runtime */ }
|
|
274
|
+
return null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function serviceRuntimeVersion(room, options) {
|
|
278
|
+
return serviceRuntimeSnapshot(room, options)?.version || null
|
|
247
279
|
}
|
|
248
280
|
|
|
249
281
|
// A macOS update can be invoked FROM a ThinkPool Code room hosted by the very account
|
|
@@ -282,7 +314,7 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
|
|
|
282
314
|
'launchctl enable "$dom/$target" 2>/dev/null || true',
|
|
283
315
|
'for i in $(seq 1 30); do launchctl bootstrap "$dom" "$target_file" 2>/dev/null && break; sleep 0.3; done',
|
|
284
316
|
'for i in $(seq 1 60); do if launchctl print "$dom/$target" 2>/dev/null | grep -F -- "$expected" >/dev/null && launchctl print "$dom/$target" 2>/dev/null | grep -F "state = running" >/dev/null; then ok=1; break; fi; sleep 0.5; done',
|
|
285
|
-
`if [ "$ok" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: true, version: String(version) })}' > "$tmp"; else rollback=0; if [ "$had_previous" = 1 ]; then launchctl bootout "$dom/$target" 2>/dev/null || true; for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done; cp "$backup_file" "$target_file"; launchctl enable "$dom/$target" 2>/dev/null || true; for i in $(seq 1 30); do if launchctl bootstrap "$dom" "$target_file" 2>/dev/null; then rollback=1; break; fi; sleep 0.3; done; fi; if [ "$rollback" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), error: 'new runtime not confirmed; previous service restored', rolledBack: true })}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), error: 'new runtime not confirmed and rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
|
|
317
|
+
`if [ "$ok" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: true, version: String(version), target: String(targetLabel) })}' > "$tmp"; else rollback=0; if [ "$had_previous" = 1 ]; then launchctl bootout "$dom/$target" 2>/dev/null || true; for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done; cp "$backup_file" "$target_file"; launchctl enable "$dom/$target" 2>/dev/null || true; for i in $(seq 1 30); do if launchctl bootstrap "$dom" "$target_file" 2>/dev/null; then rollback=1; break; fi; sleep 0.3; done; fi; if [ "$rollback" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed; previous service restored', rolledBack: true })}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed and rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
|
|
286
318
|
'mv "$tmp" "$status"',
|
|
287
319
|
'rm -f "$staged_file" "$backup_file" "$helper_file"',
|
|
288
320
|
'launchctl bootout "$dom/$helper" >/dev/null 2>&1 || true',
|
|
@@ -472,21 +504,109 @@ export function updateService(room, { exec = execSync, install = installService,
|
|
|
472
504
|
return install(room, [], { version: target, staleProof: true }) !== false
|
|
473
505
|
}
|
|
474
506
|
|
|
507
|
+
// Menu/CLI-only update path. updateService intentionally returns as soon as it has
|
|
508
|
+
// safely staged an update: on macOS it must do that so an independent launchd helper
|
|
509
|
+
// can replace the service without killing its caller mid-transaction. Consumers that
|
|
510
|
+
// need to say "updated" rather than "staged" must use this primitive instead.
|
|
511
|
+
//
|
|
512
|
+
// All dependencies are injectable so this contract can be tested without a service,
|
|
513
|
+
// a clock, or npm. The default timeout exceeds the launchd handoff's bounded reload
|
|
514
|
+
// and verification loops, but is still finite.
|
|
515
|
+
export async function updateAndConfirmService(room, {
|
|
516
|
+
platform = process.platform,
|
|
517
|
+
exec = execSync,
|
|
518
|
+
update = updateService,
|
|
519
|
+
install = installService,
|
|
520
|
+
active = isServiceInstalled,
|
|
521
|
+
snapshot = serviceRuntimeSnapshot,
|
|
522
|
+
readStatus = () => {
|
|
523
|
+
try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.thinkpool-pair', 'update-status.json'), 'utf8')) } catch { return null }
|
|
524
|
+
},
|
|
525
|
+
now = Date.now,
|
|
526
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
527
|
+
timeoutMs = 55000,
|
|
528
|
+
pollMs = 250,
|
|
529
|
+
stderr = process.stderr,
|
|
530
|
+
} = {}) {
|
|
531
|
+
const before = snapshot(room, { platform, exec })
|
|
532
|
+
let target = null
|
|
533
|
+
const captureTarget = (...args) => {
|
|
534
|
+
const result = exec(...args)
|
|
535
|
+
const resolved = String(result).trim()
|
|
536
|
+
if (/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(resolved)) target = resolved
|
|
537
|
+
return result
|
|
538
|
+
}
|
|
539
|
+
const staged = update(room, { exec: captureTarget, install, active }) !== false
|
|
540
|
+
if (!staged || !target) {
|
|
541
|
+
stderr.write(' ⚠ update was not confirmed: staging the published runtime failed; the existing service was left unchanged.\n')
|
|
542
|
+
return false
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const confirmation = () => {
|
|
546
|
+
const live = snapshot(room, { platform, exec })
|
|
547
|
+
const versionChanged = !!before && before.version !== target
|
|
548
|
+
const processChanged = !!before?.pid && !!live?.pid && before.pid !== live.pid
|
|
549
|
+
if (live?.version === target && (versionChanged || processChanged)) {
|
|
550
|
+
stderr.write(` ✓ bridge restart confirmed — running v${target}.\n`)
|
|
551
|
+
return { ok: true, live }
|
|
552
|
+
}
|
|
553
|
+
return { ok: false, live }
|
|
554
|
+
}
|
|
555
|
+
const printUnconfirmed = ({ live } = {}) => {
|
|
556
|
+
if (live?.version && live.version !== target) stderr.write(` ⚠ update v${target} was not confirmed: live service is still v${live.version}.\n`)
|
|
557
|
+
else if (live?.version === target) stderr.write(` ⚠ update v${target} was not confirmed: the service process did not restart.\n`)
|
|
558
|
+
else stderr.write(` ⚠ update v${target} was not confirmed: the service manager cannot prove the new runtime is running.\n`)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// systemd's restart is synchronous. The Windows Startup-folder tier has no
|
|
562
|
+
// authoritative live-service identity, so callers use updateService directly
|
|
563
|
+
// and label it as a next-launch update rather than a completed restart.
|
|
564
|
+
if (platform !== 'darwin') {
|
|
565
|
+
const proof = confirmation()
|
|
566
|
+
if (!proof.ok) printUnconfirmed(proof)
|
|
567
|
+
return proof.ok
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const deadline = now() + Math.max(0, Number(timeoutMs) || 0)
|
|
571
|
+
const expectedTarget = label(room)
|
|
572
|
+
let lastProof = null
|
|
573
|
+
for (;;) {
|
|
574
|
+
const status = readStatus()
|
|
575
|
+
if (status && typeof status === 'object' && status.target === expectedTarget) {
|
|
576
|
+
if (status.ok === false && status.version === target) {
|
|
577
|
+
stderr.write(` ⚠ update v${target} was not confirmed: ${status.error || 'the launchd handoff failed'}.\n`)
|
|
578
|
+
return false
|
|
579
|
+
}
|
|
580
|
+
if (status.ok === true && status.version === target) {
|
|
581
|
+
lastProof = confirmation()
|
|
582
|
+
if (lastProof.ok) return true
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (now() >= deadline) {
|
|
586
|
+
if (lastProof) printUnconfirmed(lastProof)
|
|
587
|
+
else stderr.write(` ⚠ update v${target} timed out waiting for launchd confirmation; the service may have rolled back.\n`)
|
|
588
|
+
return false
|
|
589
|
+
}
|
|
590
|
+
await sleep(Math.max(1, Number(pollMs) || 1))
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
475
594
|
// Ground truth: is the OS service manager ACTUALLY running our service right now?
|
|
476
595
|
// A leftover plist/unit on disk does NOT mean it's loaded — `launchctl load` can
|
|
477
596
|
// silently no-op (the 2026-06-26 "says installed but it's off" bug), and a unit can
|
|
478
597
|
// be left disabled. Ask the manager, don't trust file presence. room falsy → account.
|
|
479
|
-
export function serviceActive(room) {
|
|
480
|
-
const plat =
|
|
598
|
+
export function serviceActive(room, { platform = process.platform, exec = execSync } = {}) {
|
|
599
|
+
const plat = platform
|
|
481
600
|
const id = label(room)
|
|
482
601
|
try {
|
|
483
602
|
if (plat === 'darwin') {
|
|
484
|
-
const out =
|
|
603
|
+
const out = exec(`launchctl print gui/$(id -u)/${id}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
485
604
|
return darwinServiceRunning(out)
|
|
486
605
|
}
|
|
487
|
-
if (plat === 'linux') {
|
|
488
|
-
//
|
|
489
|
-
|
|
606
|
+
if (plat === 'linux') { exec(`systemctl --user is-active --quiet ${id}.service`); return true }
|
|
607
|
+
// A Startup entry is install evidence, never proof that its console process
|
|
608
|
+
// is alive. Windows has no per-user manager we can query authoritatively.
|
|
609
|
+
return false
|
|
490
610
|
} catch { return false }
|
|
491
611
|
}
|
|
492
612
|
|
|
@@ -495,6 +615,9 @@ export function serviceActive(room) {
|
|
|
495
615
|
// service. Reflects launchd/systemd reality, not just file existence (so a plist that
|
|
496
616
|
// failed to load reads as not-installed → the menu re-offers install, which self-heals).
|
|
497
617
|
export function isServiceInstalled(room) {
|
|
618
|
+
if (process.platform === 'win32') {
|
|
619
|
+
try { return fs.existsSync(buildArtifact('win32', { room }).file) } catch { return false }
|
|
620
|
+
}
|
|
498
621
|
return serviceActive(room)
|
|
499
622
|
}
|
|
500
623
|
|