thinkpool-pair 0.7.364 → 0.7.366
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/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/launcher.mjs
CHANGED
|
@@ -1,354 +1 @@
|
|
|
1
|
-
/* launcher.mjs — the one-command interactive menu for `thinkpool-pair`.
|
|
2
|
-
Spec: docs/specs/2026-06-19-bridge-unified-launcher.md (Part 1).
|
|
3
|
-
|
|
4
|
-
ONE implementation, three callers, via dependency injection:
|
|
5
|
-
• bridge.mjs → real `actions` (spawn a room bridge / installService / runLogin / …)
|
|
6
|
-
• setup-prototype → dry-run `actions` (print the command instead of running it)
|
|
7
|
-
• test-launcher → mock `actions` + scripted `io` (assert menu choice → action + args)
|
|
8
|
-
|
|
9
|
-
detectState() is real + read-only (auth.json / provider.json / PATH agents / service
|
|
10
|
-
artifact presence). runLauncher() renders the menu and calls actions.* — it NEVER runs
|
|
11
|
-
anything itself, so it is safe to unit-test and safe to dry-run. */
|
|
12
|
-
|
|
13
|
-
import os from 'node:os'
|
|
14
|
-
import fs from 'node:fs'
|
|
15
|
-
import path from 'node:path'
|
|
16
|
-
import { execSync } from 'node:child_process'
|
|
17
|
-
import { detectProvider, fetchModels, ANTHROPIC_COMPATIBLE, gatewayHint } from './byok-detect.mjs'
|
|
18
|
-
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
19
|
-
import { darwinServiceRunning, serviceRuntimeVersion } from './service.mjs'
|
|
20
|
-
import { loadKeepAwakePreference } from './keep-awake.mjs'
|
|
21
|
-
|
|
22
|
-
const HOME = os.homedir()
|
|
23
|
-
const CFG_DIR = path.join(HOME, '.thinkpool-pair')
|
|
24
|
-
|
|
25
|
-
// Version of THIS running launcher (read from the package it shipped with). Shown in the
|
|
26
|
-
// header so the running build is visible — and so a stale npx cache is obvious at a glance
|
|
27
|
-
// (header says 0.7.75 while npm @latest is 0.7.78 → time to "Restart & update the bridge").
|
|
28
|
-
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
|
|
29
|
-
|
|
30
|
-
export const KNOWN_AGENTS = [
|
|
31
|
-
{ label: 'Claude Code', cmd: 'claude', resume: ['--continue'] },
|
|
32
|
-
{ label: 'Codex', cmd: 'codex' },
|
|
33
|
-
{ label: 'Hermes', cmd: 'thinkpool' },
|
|
34
|
-
]
|
|
35
|
-
|
|
36
|
-
function onPath(c) {
|
|
37
|
-
const exts = process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : ['']
|
|
38
|
-
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
39
|
-
if (!dir) continue
|
|
40
|
-
for (const ext of exts) { try { if (fs.existsSync(path.join(dir, c + ext))) return true } catch { /* noop */ } }
|
|
41
|
-
}
|
|
42
|
-
return false
|
|
43
|
-
}
|
|
44
|
-
function readJson(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } }
|
|
45
|
-
|
|
46
|
-
function serviceFile(room) {
|
|
47
|
-
const label = room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.thinkpool.pair.account'
|
|
48
|
-
if (process.platform === 'darwin') return path.join(HOME, 'Library', 'LaunchAgents', `${label}.plist`)
|
|
49
|
-
if (process.platform === 'linux') return path.join(HOME, '.config', 'systemd', 'user', `${label}.service`)
|
|
50
|
-
const startup = path.join(process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
|
|
51
|
-
return path.join(startup, `thinkpool-pair-${room || 'account'}.cmd`)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Ground truth for the header: is the service actually RUNNING, not just registered?
|
|
55
|
-
// `launchctl load` can silently no-op, leaving a plist that never loads — file-existence
|
|
56
|
-
// would report "installed" while the bridge is off (the 2026-06-26 bug). A registered
|
|
57
|
-
// job can also be inactive with no pid, so inspect launchd's live state, not print's exit.
|
|
58
|
-
function serviceLoaded(room) {
|
|
59
|
-
const label = room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.thinkpool.pair.account'
|
|
60
|
-
try {
|
|
61
|
-
if (process.platform === 'darwin') {
|
|
62
|
-
const out = execSync(`launchctl print gui/$(id -u)/${label}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
63
|
-
return darwinServiceRunning(out)
|
|
64
|
-
}
|
|
65
|
-
if (process.platform === 'linux') { execSync(`systemctl --user is-active --quiet ${label}.service`); return true }
|
|
66
|
-
return fs.existsSync(serviceFile(room)) // Windows: no daemon, file presence is the only proxy
|
|
67
|
-
} catch { return false }
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function detectState() {
|
|
71
|
-
const auth = readJson(path.join(CFG_DIR, 'auth.json'))
|
|
72
|
-
const provider = readJson(path.join(CFG_DIR, 'provider.json'))
|
|
73
|
-
// provider.json is written with { provider, baseUrl, model } (see provider.mjs).
|
|
74
|
-
// Anything whose `provider` field is set and not "anthropic" is a custom endpoint.
|
|
75
|
-
const isCustom = !!provider?.provider && provider.provider !== 'anthropic'
|
|
76
|
-
const hermesInstalled = onPath('hermes')
|
|
77
|
-
const hermesReady = onPath('thinkpool') && probeHermesRuntime().available
|
|
78
|
-
const accountSvc = serviceLoaded(null)
|
|
79
|
-
const reconnectRequired = !!auth?.reconnect_required
|
|
80
|
-
return {
|
|
81
|
-
loggedIn: !!auth?.refresh_token && !reconnectRequired,
|
|
82
|
-
reconnectRequired,
|
|
83
|
-
email: auth?.email || auth?.user?.email || null,
|
|
84
|
-
provider: isCustom ? `Custom (${provider.baseUrl || 'endpoint'})` : 'Anthropic (default)',
|
|
85
|
-
providerModel: isCustom ? (provider.model || null) : null,
|
|
86
|
-
agents: KNOWN_AGENTS.filter(a => onPath(a.cmd) && (a.cmd !== 'thinkpool' || hermesReady)),
|
|
87
|
-
hermesInstalled,
|
|
88
|
-
hermesReady,
|
|
89
|
-
accountSvc,
|
|
90
|
-
keepAwake: loadKeepAwakePreference(),
|
|
91
|
-
serviceVersion: accountSvc ? serviceRuntimeVersion(null) : null,
|
|
92
|
-
platform: process.platform,
|
|
93
|
-
cwd: process.cwd(),
|
|
94
|
-
version: VERSION,
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ── colors ──
|
|
99
|
-
const C = {
|
|
100
|
-
dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
101
|
-
cyan: (s) => `\x1b[36m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// io: { ask(prompt)->Promise<string>, print(s) }. actions: real | dry-run | mock.
|
|
105
|
-
// refresh: optional () => state — re-read disk after a mutating action so the header
|
|
106
|
-
// reflects the change (real bridge passes detectState; tests omit it to keep the
|
|
107
|
-
// injected state stable).
|
|
108
|
-
export async function runLauncher({ actions, io, state = detectState(), refresh = null }) {
|
|
109
|
-
const normalizeState = (next) => ({
|
|
110
|
-
...next,
|
|
111
|
-
agents: Array.isArray(next?.agents) ? next.agents : [],
|
|
112
|
-
loggedIn: !!next?.loggedIn,
|
|
113
|
-
reconnectRequired: !!next?.reconnectRequired,
|
|
114
|
-
accountSvc: !!next?.accountSvc,
|
|
115
|
-
keepAwake: next?.keepAwake === true,
|
|
116
|
-
serviceVersion: typeof next?.serviceVersion === 'string' && next.serviceVersion ? next.serviceVersion : null,
|
|
117
|
-
platform: next?.platform || process.platform,
|
|
118
|
-
provider: next?.provider || 'Anthropic (default)',
|
|
119
|
-
cwd: next?.cwd || process.cwd(),
|
|
120
|
-
version: next?.version || VERSION,
|
|
121
|
-
})
|
|
122
|
-
state = normalizeState(state)
|
|
123
|
-
const resync = () => { if (refresh) state = normalizeState(refresh()) }
|
|
124
|
-
const askChoice = async (prompt, options, def = 1) => {
|
|
125
|
-
options.forEach((o, i) => io.print(` ${C.cyan(String(i + 1))}) ${o.label}${o.hint ? ' ' + C.dim(o.hint) : ''}`))
|
|
126
|
-
const a = await io.ask(`\n ${prompt} ${C.dim(`[${def}]`)} ${C.cyan('▸')} `)
|
|
127
|
-
const n = parseInt(a, 10)
|
|
128
|
-
return Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : def - 1
|
|
129
|
-
}
|
|
130
|
-
const askYesNo = async (q, def = true) => {
|
|
131
|
-
const a = (await io.ask(` ${q} ${C.dim(def ? '[Y/n]' : '[y/N]')} ${C.cyan('▸')} `)).toLowerCase()
|
|
132
|
-
return a ? a[0] === 'y' : def
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const header = () => {
|
|
136
|
-
io.print('')
|
|
137
|
-
io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + C.dim(' ' + '─'.repeat(40)))
|
|
138
|
-
io.print(` ${C.dim('│ launcher ')} ${state.version ? `v${state.version}` : 'version unavailable'}`)
|
|
139
|
-
io.print(` ${C.dim('│ account ')} ${state.reconnectRequired ? C.yellow('reconnect required') : state.loggedIn ? C.green((state.email || 'linked') + ' ✓') : C.yellow('not linked')}`)
|
|
140
|
-
io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none ready: install Claude Code, Codex, or Hermes')}`)
|
|
141
|
-
io.print(` ${C.dim('│ directory')} ${C.dim(state.cwd)}`)
|
|
142
|
-
const bridgeStatus = !state.accountSvc
|
|
143
|
-
? 'not installed'
|
|
144
|
-
: state.platform === 'win32'
|
|
145
|
-
? C.green('startup entry installed')
|
|
146
|
-
: C.green(`running${state.serviceVersion ? ` v${state.serviceVersion}` : ' (version unavailable)'}`)
|
|
147
|
-
io.print(` ${C.dim('│ bridge ')} ${bridgeStatus}`)
|
|
148
|
-
io.print(` ${C.dim('│ keep awake')} ${state.keepAwake ? C.green('on') + C.dim(', display may still dim') : 'off'}`)
|
|
149
|
-
io.print(' ' + C.dim('└' + '─'.repeat(54)) + '\n')
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// KEPT for the `npx thinkpool-pair <ROOM>` argv path (bridge.mjs) — no longer a main-menu
|
|
153
|
-
// item (single-session serve was retired), but the helper stays so nothing else breaks.
|
|
154
|
-
const pairRoom = async () => {
|
|
155
|
-
const room = (await io.ask(`\n Room code ${C.dim('(from /code in the web app)')} ${C.cyan('▸')} `)).toUpperCase().trim()
|
|
156
|
-
if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
|
|
157
|
-
if (!state.agents.length) { io.print('\n ' + C.yellow('No supported agent runtime is ready (Claude Code, Codex, or Hermes).')); return }
|
|
158
|
-
let agent = state.agents[0]
|
|
159
|
-
if (state.agents.length > 1) { io.print('\n Share which agent?'); agent = state.agents[await askChoice('agent', state.agents.map(a => ({ label: a.label })))] }
|
|
160
|
-
io.print('\n Run mode?')
|
|
161
|
-
const headless = (await askChoice('mode', [
|
|
162
|
-
{ label: 'Attached', hint: 'use it like your own terminal, mirrored to the web' },
|
|
163
|
-
{ label: 'Headless', hint: 'pure relay, drive entirely from the web' },
|
|
164
|
-
])) === 1
|
|
165
|
-
let cont = false
|
|
166
|
-
if (!headless && agent.resume) cont = await askYesNo(`\n Continue your latest ${agent.label} session in this directory?`, true)
|
|
167
|
-
await actions.pairRoom({ room, agentCmd: agent.cmd, resumeArgs: cont ? agent.resume : [], headless })
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Login gate shared by both "serve" paths (foreground + install service). Both serve
|
|
171
|
-
// EVERY hosted session, so both require an account link first. Returns true only once
|
|
172
|
-
// logged in — offers to link on the spot, re-reads disk, and refuses to serve if still not.
|
|
173
|
-
const ensureLoggedIn = async () => {
|
|
174
|
-
if (state.loggedIn) return true
|
|
175
|
-
io.print('\n ' + C.yellow(state.reconnectRequired
|
|
176
|
-
? 'This bridge lost its Thinkpool account link.'
|
|
177
|
-
: 'You are not linked to a ThinkPool account yet.'))
|
|
178
|
-
if (await askYesNo(' Link this device now?', true)) { await actions.login(); resync() }
|
|
179
|
-
if (!state.loggedIn) { io.print(' ' + C.yellow('login needed to serve your sessions — back to menu')); return false }
|
|
180
|
-
return true
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// A provider key is credentials, not an executable agent. Stop new installs
|
|
184
|
-
// before they create an apparently healthy but unusable background bridge.
|
|
185
|
-
const ensureAgentReady = async () => {
|
|
186
|
-
if (state.agents.length) return true
|
|
187
|
-
io.print('\n ' + C.yellow('No coding agent is ready on this computer'))
|
|
188
|
-
io.print(' Thinkpool connects an agent already installed on this machine. It does not')
|
|
189
|
-
io.print(' include one. Install and configure at least one of:')
|
|
190
|
-
io.print(`\n ${C.cyan('Claude Code')} command: claude`)
|
|
191
|
-
io.print(` ${C.cyan('Codex')} command: codex`)
|
|
192
|
-
io.print(` ${C.cyan('Hermes')} command: hermes, then set up its Thinkpool profile here`)
|
|
193
|
-
io.print('\n ' + C.bold('BYOK does not replace the agent. It only gives an installed agent'))
|
|
194
|
-
io.print(' credentials for a model provider.')
|
|
195
|
-
io.print('\n Install one, then reopen this launcher.')
|
|
196
|
-
return false
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const providerMenu = async () => {
|
|
200
|
-
const cur = state.provider + (state.providerModel ? `, model ${state.providerModel}` : '')
|
|
201
|
-
io.print('\n ' + C.dim('Provider settings supply model credentials. Claude Code, Codex, or Hermes must still be installed locally.'))
|
|
202
|
-
const p = await askChoice('\n provider', [
|
|
203
|
-
{ label: 'Anthropic (default)', hint: 'regular Claude login' },
|
|
204
|
-
{ label: 'Paste a key, pick a model', hint: 'auto-detects the provider · OpenRouter = any model' },
|
|
205
|
-
{ label: 'Custom endpoint', hint: 'enter a base url + token by hand' },
|
|
206
|
-
{ label: '← Back', hint: `current: ${cur}` },
|
|
207
|
-
], 4) // Enter = Back
|
|
208
|
-
if (p === 3) return
|
|
209
|
-
if (p === 0) { await actions.setProvider({ kind: 'anthropic' }); resync(); io.print('\n ' + C.green('✓ back on regular Claude.')); return }
|
|
210
|
-
|
|
211
|
-
if (p === 1) {
|
|
212
|
-
// Paste a key → detect provider → auto base url → fetch model list → pick.
|
|
213
|
-
const key = (await io.ask(`\n Paste your API key ${C.dim('(blank to cancel)')} ${C.cyan('▸')} `)).trim()
|
|
214
|
-
if (!key) { io.print(' ' + C.yellow('cancelled — provider unchanged')); return }
|
|
215
|
-
const det = detectProvider(key)
|
|
216
|
-
if (!det.compatible) { io.print('\n ' + C.yellow(gatewayHint(det.name))); return }
|
|
217
|
-
const cfg = ANTHROPIC_COMPATIBLE[det.id]
|
|
218
|
-
io.print(`\n ${C.green('✓ detected ' + cfg.name)} ${C.dim('— fetching models…')}`)
|
|
219
|
-
const models = await fetchModels({ modelsUrl: cfg.models, key, auth: cfg.auth })
|
|
220
|
-
let model
|
|
221
|
-
if (models.length === 0) {
|
|
222
|
-
model = (await io.ask(` Couldn't list models. Model id ${C.dim('(blank = endpoint default)')} ${C.cyan('▸')} `)).trim() || undefined
|
|
223
|
-
} else if (models.length <= 40) {
|
|
224
|
-
const idx = await askChoice('\n model', models.map((m) => ({ label: m })), 1)
|
|
225
|
-
model = models[idx]
|
|
226
|
-
} else {
|
|
227
|
-
io.print(` ${C.dim(models.length + ' models available, e.g. ' + models.slice(0, 3).join(', ') + '…')}`)
|
|
228
|
-
for (;;) {
|
|
229
|
-
const q = (await io.ask(` Type a model id or part of it ${C.dim('(blank to cancel)')} ${C.cyan('▸')} `)).trim()
|
|
230
|
-
if (!q) { io.print(' ' + C.yellow('cancelled — provider unchanged')); return }
|
|
231
|
-
const matches = models.filter((m) => m.toLowerCase().includes(q.toLowerCase()))
|
|
232
|
-
if (matches.length === 0) { io.print(' ' + C.yellow('no match — try again')); continue }
|
|
233
|
-
if (matches.length === 1) { model = matches[0]; break }
|
|
234
|
-
if (matches.length <= 40) { const i = await askChoice('\n pick', matches.map((m) => ({ label: m })), 1); model = matches[i]; break }
|
|
235
|
-
io.print(` ${C.yellow(matches.length + ' matches — narrow it down')}`)
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
await actions.setProvider({ kind: 'custom', base: cfg.base, token: key, model })
|
|
239
|
-
resync()
|
|
240
|
-
io.print(`\n ${C.green('✓ provider set.')}\n ${cfg.name}\n model: ${model || '(endpoint default)'}`)
|
|
241
|
-
return
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// p === 2 — manual custom endpoint (kept as a fallback; blank line aborts each step).
|
|
245
|
-
const base = (await io.ask(`\n Base url ${C.dim('(blank to cancel · SDK appends /v1/messages)')} ${C.cyan('▸')} `)).trim()
|
|
246
|
-
if (!base) { io.print(' ' + C.yellow('cancelled — provider unchanged')); return }
|
|
247
|
-
const token = (await io.ask(` API token ${C.dim('(blank to cancel)')} ${C.cyan('▸')} `)).trim()
|
|
248
|
-
if (!token) { io.print(' ' + C.yellow('cancelled — provider unchanged')); return }
|
|
249
|
-
const model = (await io.ask(` Model ${C.dim('(blank = endpoint default · e.g. glm-4.6)')} ${C.cyan('▸')} `)).trim()
|
|
250
|
-
await actions.setProvider({ kind: 'custom', base, token, model: model || undefined })
|
|
251
|
-
resync()
|
|
252
|
-
io.print(`\n ${C.green('✓ provider set to custom.')}\n base url: ${state.provider}\n model: ${state.providerModel || '(endpoint default)'}`)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const settingsMenu = async () => {
|
|
256
|
-
for (;;) {
|
|
257
|
-
const options = [
|
|
258
|
-
{ key: 'provider', label: 'Provider', hint: `current: ${state.provider}` },
|
|
259
|
-
{ key: 'account', label: 'Account', hint: state.reconnectRequired ? 'reconnect required' : state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
260
|
-
{ key: 'awake', label: 'Keep computer awake', hint: `${state.keepAwake ? 'ON' : 'off'} · the screen can still dim, lock, and turn off` },
|
|
261
|
-
]
|
|
262
|
-
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' })
|
|
263
|
-
options.push({ key: 'back', label: 'Back to main menu', hint: '' })
|
|
264
|
-
const picked = options[await askChoice('\n change', options, options.length)]
|
|
265
|
-
if (picked.key === 'back') return
|
|
266
|
-
if (picked.key === 'provider') await providerMenu()
|
|
267
|
-
else if (picked.key === 'account') { await actions.login(); resync(); return }
|
|
268
|
-
else if (picked.key === 'awake') {
|
|
269
|
-
if (state.keepAwake) {
|
|
270
|
-
const changed = await actions.setKeepAwake({ enabled: false })
|
|
271
|
-
if (changed === false) continue
|
|
272
|
-
state = { ...state, keepAwake: false }
|
|
273
|
-
resync()
|
|
274
|
-
io.print('\n ' + C.green('✓ keep-awake is off. The computer may sleep normally.'))
|
|
275
|
-
} else {
|
|
276
|
-
io.print('\n ' + C.yellow('Important: keep-awake changes this computer’s power behavior'))
|
|
277
|
-
io.print(' While the bridge runs, Thinkpool will prevent the computer itself from')
|
|
278
|
-
io.print(' automatically sleeping. This can use substantially more battery and may')
|
|
279
|
-
io.print(' also block suspend requests on some Linux systems.')
|
|
280
|
-
io.print('\n ' + C.green('It does NOT keep the screen on. Display dimming, screen locking, and'))
|
|
281
|
-
io.print(' display sleep continue to work normally. Closing a laptop lid may still')
|
|
282
|
-
io.print(' sleep it, depending on the operating system and power settings.')
|
|
283
|
-
if (await askYesNo('\n Enable keep-awake while the Thinkpool bridge runs?', false)) {
|
|
284
|
-
const changed = await actions.setKeepAwake({ enabled: true })
|
|
285
|
-
if (changed === false) continue
|
|
286
|
-
state = { ...state, keepAwake: true }
|
|
287
|
-
resync()
|
|
288
|
-
io.print('\n ' + C.green('✓ keep-awake is on. Your screen is still free to dim and turn off.'))
|
|
289
|
-
} else io.print('\n ' + C.yellow('unchanged: keep-awake remains off.'))
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
else if (picked.key === 'hermes') {
|
|
293
|
-
const setup = await askChoice('\n Hermes profile', [
|
|
294
|
-
{ label: 'Clone active profile', hint: 'copies provider/config explicitly; fresh ThinkPool session history' },
|
|
295
|
-
{ label: 'Create clean profile', hint: 'no copied credentials or bundled skills; configure provider afterward' },
|
|
296
|
-
{ label: 'Back', hint: '' },
|
|
297
|
-
], 3)
|
|
298
|
-
if (setup !== 2) { await actions.setupHermes({ mode: setup === 0 ? 'clone' : 'clean' }); resync() }
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ── menu loop ──
|
|
304
|
-
// account-foreground runs forever, so that choice never returns here. Everything else
|
|
305
|
-
// (install / restart / uninstall / settings / provider) does its work, prints its notes,
|
|
306
|
-
// and RETURNS — so the loop always drops you back at the menu. The trailing "press Enter"
|
|
307
|
-
// lands on every returning path. Slots 1+2 are the ONLY two things this device does:
|
|
308
|
-
// before a service exists → the two ways to start serving all your sessions; once the
|
|
309
|
-
// service exists → maintain it (update/restart or remove). No single-room / per-room
|
|
310
|
-
// serve is offered anymore (that was `npx thinkpool-pair <ROOM>`, still on the argv path).
|
|
311
|
-
for (;;) {
|
|
312
|
-
header()
|
|
313
|
-
io.print(' ' + C.bold('What do you want to do?') + '\n')
|
|
314
|
-
// Slots 1+2 swap on whether the always-on service is already installed.
|
|
315
|
-
const items = state.accountSvc
|
|
316
|
-
? [
|
|
317
|
-
state.platform === 'win32'
|
|
318
|
-
? { key: 'restart', label: 'Update startup entry', hint: 'installs the latest version; relaunch the bridge window to apply' }
|
|
319
|
-
: { key: 'restart', label: 'Restart & update bridge', hint: 'install the latest published version; sessions resume' },
|
|
320
|
-
{ key: 'uninstall', label: 'Remove background service', hint: 'stop serving your sessions on this device' },
|
|
321
|
-
]
|
|
322
|
-
: [
|
|
323
|
-
{ key: 'serve', label: 'Serve all my sessions', hint: 'runs here, Ctrl-C stops it' },
|
|
324
|
-
{ key: 'service', label: 'Always-on background service', hint: 'recommended — survives reboot, restarts on crash' },
|
|
325
|
-
]
|
|
326
|
-
items.push({ key: 'settings', label: 'Settings', hint: `provider · account · keep-awake ${state.keepAwake ? 'ON' : 'off'}${state.hermesInstalled ? ' · Hermes profile' : ''}` })
|
|
327
|
-
items.push({ key: 'quit', label: 'Quit', hint: '' })
|
|
328
|
-
const pick = items[await askChoice('choose', items)]
|
|
329
|
-
if (pick.key === 'quit') return
|
|
330
|
-
if (pick.key === 'serve') { if (await ensureAgentReady() && await ensureLoggedIn()) await actions.serveAccountForeground() }
|
|
331
|
-
else if (pick.key === 'service') {
|
|
332
|
-
if (await ensureAgentReady() && await ensureLoggedIn()) {
|
|
333
|
-
const installed = await actions.installService({ room: null })
|
|
334
|
-
if (installed === true) {
|
|
335
|
-
if (!actions.relaunchLauncher || await actions.relaunchLauncher()) return
|
|
336
|
-
}
|
|
337
|
-
resync()
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
else if (pick.key === 'restart') {
|
|
341
|
-
const updated = await actions.restartUpdateService({ room: null })
|
|
342
|
-
// Keep the person inside the launcher. A confirmed service update may have
|
|
343
|
-
// installed a newer package than this process has in memory, so hand the same
|
|
344
|
-
// TTY to the exact verified runtime instead of dumping back to the shell.
|
|
345
|
-
if (updated === true) {
|
|
346
|
-
if (!actions.relaunchLauncher || await actions.relaunchLauncher()) return
|
|
347
|
-
}
|
|
348
|
-
resync()
|
|
349
|
-
}
|
|
350
|
-
else if (pick.key === 'uninstall') { await actions.uninstallService({ room: null }); resync() }
|
|
351
|
-
else if (pick.key === 'settings') await settingsMenu()
|
|
352
|
-
await io.ask('\n ' + C.dim('press Enter to return to the menu…'))
|
|
353
|
-
}
|
|
354
|
-
}
|
|
1
|
+
import e from"node:os";import n from"node:fs";import t from"node:path";import{execSync as r}from"node:child_process";import{detectProvider as i,fetchModels as o,ANTHROPIC_COMPATIBLE as a,gatewayHint as s}from"./byok-detect.mjs";import{probeHermesRuntime as l}from"./hermes-probe.mjs";import{darwinServiceRunning as c,serviceRuntimeVersion as d}from"./service.mjs";import{loadKeepAwakePreference as p}from"./keep-awake.mjs";const u=e.homedir(),m=t.join(u,".thinkpool-pair"),h=(()=>{try{return JSON.parse(n.readFileSync(new URL("./package.json",import.meta.url),"utf8")).version}catch{return null}})();export const KNOWN_AGENTS=[{label:"Claude Code",cmd:"claude",resume:["--continue"]},{label:"Codex",cmd:"codex"},{label:"Hermes",cmd:"thinkpool"}];function k(e){const r="win32"===process.platform?(process.env.PATHEXT||".EXE;.CMD;.BAT;.COM").split(";"):[""];for(const i of(process.env.PATH||"").split(t.delimiter))if(i)for(const o of r)try{if(n.existsSync(t.join(i,e+o)))return!0}catch{}return!1}function f(e){try{return JSON.parse(n.readFileSync(e,"utf8"))}catch{return null}}export function detectState(){const e=f(t.join(m,"auth.json")),i=f(t.join(m,"provider.json")),o=!!i?.provider&&"anthropic"!==i.provider,a=k("hermes"),s=k("thinkpool")&&l().available,v=function(e){const i=e?`io.thinkpool.pair.${e.toLowerCase()}`:"io.thinkpool.pair.account";try{if("darwin"===process.platform){const e=r(`launchctl print gui/$(id -u)/${i}`,{encoding:"utf8",stdio:["ignore","pipe","ignore"]});return c(e)}return"linux"===process.platform?(r(`systemctl --user is-active --quiet ${i}.service`),!0):n.existsSync(function(e){const n=e?`io.thinkpool.pair.${e.toLowerCase()}`:"io.thinkpool.pair.account";if("darwin"===process.platform)return t.join(u,"Library","LaunchAgents",`${n}.plist`);if("linux"===process.platform)return t.join(u,".config","systemd","user",`${n}.service`);const r=t.join(process.env.APPDATA||t.join(u,"AppData","Roaming"),"Microsoft","Windows","Start Menu","Programs","Startup");return t.join(r,`thinkpool-pair-${e||"account"}.cmd`)}(e))}catch{return!1}}(null),g=!!e?.reconnect_required;return{loggedIn:!!e?.refresh_token&&!g,reconnectRequired:g,email:e?.email||e?.user?.email||null,provider:o?`Custom (${i.baseUrl||"endpoint"})`:"Anthropic (default)",providerModel:o&&i.model||null,agents:KNOWN_AGENTS.filter(e=>k(e.cmd)&&("thinkpool"!==e.cmd||s)),hermesInstalled:a,hermesReady:s,accountSvc:v,keepAwake:p(),serviceVersion:v?d(null):null,platform:process.platform,cwd:process.cwd(),version:h}}const v=e=>`[2m${e}[0m`,g=e=>`[1m${e}[0m`,y=e=>`[36m${e}[0m`,w=e=>`[32m${e}[0m`,b=e=>`[33m${e}[0m`;export async function runLauncher({actions:e,io:n,state:t=detectState(),refresh:r=null}){const l=e=>({...e,agents:Array.isArray(e?.agents)?e.agents:[],loggedIn:!!e?.loggedIn,reconnectRequired:!!e?.reconnectRequired,accountSvc:!!e?.accountSvc,keepAwake:!0===e?.keepAwake,serviceVersion:"string"==typeof e?.serviceVersion&&e.serviceVersion?e.serviceVersion:null,platform:e?.platform||process.platform,provider:e?.provider||"Anthropic (default)",cwd:e?.cwd||process.cwd(),version:e?.version||h});t=l(t);const c=()=>{r&&(t=l(r()))},d=async(e,t,r=1)=>{t.forEach((e,t)=>n.print(` ${y(String(t+1))}) ${e.label}${e.hint?" "+v(e.hint):""}`));const i=await n.ask(`\n ${e} ${v(`[${r}]`)} ${y("▸")} `),o=parseInt(i,10);return Number.isInteger(o)&&o>=1&&o<=t.length?o-1:r-1},p=async(e,t=!0)=>{const r=(await n.ask(` ${e} ${v(t?"[Y/n]":"[y/N]")} ${y("▸")} `)).toLowerCase();return r?"y"===r[0]:t},u=()=>{n.print(""),n.print(" "+v("┌ ")+g("thinkpool-pair")+v(" "+"─".repeat(40))),n.print(` ${v("│ launcher ")} ${t.version?`v${t.version}`:"version unavailable"}`),n.print(` ${v("│ account ")} ${t.reconnectRequired?b("reconnect required"):t.loggedIn?w((t.email||"linked")+" ✓"):b("not linked")}`),n.print(` ${v("│ agents ")} ${t.agents.length?t.agents.map(e=>e.label).join(", "):b("none ready: install Claude Code, Codex, or Hermes")}`),n.print(` ${v("│ directory")} ${v(t.cwd)}`);const e=t.accountSvc?"win32"===t.platform?w("startup entry installed"):w("running"+(t.serviceVersion?` v${t.serviceVersion}`:" (version unavailable)")):"not installed";n.print(` ${v("│ bridge ")} ${e}`),n.print(` ${v("│ keep awake")} ${t.keepAwake?w("on")+v(", display may still dim"):"off"}`),n.print(" "+v("└"+"─".repeat(54))+"\n")},m=async()=>!!t.loggedIn||(n.print("\n "+b(t.reconnectRequired?"This bridge lost its Thinkpool account link.":"You are not linked to a ThinkPool account yet.")),await p(" Link this device now?",!0)&&(await e.login(),c()),!!t.loggedIn||(n.print(" "+b("login needed to serve your sessions — back to menu")),!1)),k=async()=>!!t.agents.length||(n.print("\n "+b("No coding agent is ready on this computer")),n.print(" Thinkpool connects an agent already installed on this machine. It does not"),n.print(" include one. Install and configure at least one of:"),n.print(`\n ${y("Claude Code")} command: claude`),n.print(` ${y("Codex")} command: codex`),n.print(` ${y("Hermes")} command: hermes, then set up its Thinkpool profile here`),n.print("\n "+g("BYOK does not replace the agent. It only gives an installed agent")),n.print(" credentials for a model provider."),n.print("\n Install one, then reopen this launcher."),!1),f=async()=>{const r=t.provider+(t.providerModel?`, model ${t.providerModel}`:"");n.print("\n "+v("Provider settings supply model credentials. Claude Code, Codex, or Hermes must still be installed locally."));const l=await d("\n provider",[{label:"Anthropic (default)",hint:"regular Claude login"},{label:"Paste a key, pick a model",hint:"auto-detects the provider · OpenRouter = any model"},{label:"Custom endpoint",hint:"enter a base url + token by hand"},{label:"← Back",hint:`current: ${r}`}],4);if(3===l)return;if(0===l)return await e.setProvider({kind:"anthropic"}),c(),void n.print("\n "+w("✓ back on regular Claude."));if(1===l){const t=(await n.ask(`\n Paste your API key ${v("(blank to cancel)")} ${y("▸")} `)).trim();if(!t)return void n.print(" "+b("cancelled — provider unchanged"));const r=i(t);if(!r.compatible)return void n.print("\n "+b(s(r.name)));const l=a[r.id];n.print(`\n ${w("✓ detected "+l.name)} ${v("— fetching models…")}`);const p=await o({modelsUrl:l.models,key:t,auth:l.auth});let u;if(0===p.length)u=(await n.ask(` Couldn't list models. Model id ${v("(blank = endpoint default)")} ${y("▸")} `)).trim()||void 0;else if(p.length<=40){const e=await d("\n model",p.map(e=>({label:e})),1);u=p[e]}else for(n.print(` ${v(p.length+" models available, e.g. "+p.slice(0,3).join(", ")+"…")}`);;){const e=(await n.ask(` Type a model id or part of it ${v("(blank to cancel)")} ${y("▸")} `)).trim();if(!e)return void n.print(" "+b("cancelled — provider unchanged"));const t=p.filter(n=>n.toLowerCase().includes(e.toLowerCase()));if(0!==t.length){if(1===t.length){u=t[0];break}if(t.length<=40){const e=await d("\n pick",t.map(e=>({label:e})),1);u=t[e];break}n.print(` ${b(t.length+" matches — narrow it down")}`)}else n.print(" "+b("no match — try again"))}return await e.setProvider({kind:"custom",base:l.base,token:t,model:u}),c(),void n.print(`\n ${w("✓ provider set.")}\n ${l.name}\n model: ${u||"(endpoint default)"}`)}const p=(await n.ask(`\n Base url ${v("(blank to cancel · SDK appends /v1/messages)")} ${y("▸")} `)).trim();if(!p)return void n.print(" "+b("cancelled — provider unchanged"));const u=(await n.ask(` API token ${v("(blank to cancel)")} ${y("▸")} `)).trim();if(!u)return void n.print(" "+b("cancelled — provider unchanged"));const m=(await n.ask(` Model ${v("(blank = endpoint default · e.g. glm-4.6)")} ${y("▸")} `)).trim();await e.setProvider({kind:"custom",base:p,token:u,model:m||void 0}),c(),n.print(`\n ${w("✓ provider set to custom.")}\n base url: ${t.provider}\n model: ${t.providerModel||"(endpoint default)"}`)},$=async()=>{for(;;){const r=[{key:"provider",label:"Provider",hint:`current: ${t.provider}`},{key:"account",label:"Account",hint:t.reconnectRequired?"reconnect required":t.loggedIn?`linked: ${t.email||"yes"}`:"not linked"},{key:"awake",label:"Keep computer awake",hint:(t.keepAwake?"ON":"off")+" · the screen can still dim, lock, and turn off"}];t.hermesInstalled&&r.push({key:"hermes",label:t.hermesReady?"Hermes profile":"Set up Hermes",hint:t.hermesReady?"isolated profile ready":"set up isolated profile + delegation guard"}),r.push({key:"back",label:"Back to main menu",hint:""});const i=r[await d("\n change",r,r.length)];if("back"===i.key)return;if("provider"===i.key)await f();else{if("account"===i.key)return await e.login(),void c();if("awake"===i.key)if(t.keepAwake){if(!1===await e.setKeepAwake({enabled:!1}))continue;t={...t,keepAwake:!1},c(),n.print("\n "+w("✓ keep-awake is off. The computer may sleep normally."))}else if(n.print("\n "+b("Important: keep-awake changes this computer’s power behavior")),n.print(" While the bridge runs, Thinkpool will prevent the computer itself from"),n.print(" automatically sleeping. This can use substantially more battery and may"),n.print(" also block suspend requests on some Linux systems."),n.print("\n "+w("It does NOT keep the screen on. Display dimming, screen locking, and")),n.print(" display sleep continue to work normally. Closing a laptop lid may still"),n.print(" sleep it, depending on the operating system and power settings."),await p("\n Enable keep-awake while the Thinkpool bridge runs?",!1)){if(!1===await e.setKeepAwake({enabled:!0}))continue;t={...t,keepAwake:!0},c(),n.print("\n "+w("✓ keep-awake is on. Your screen is still free to dim and turn off."))}else n.print("\n "+b("unchanged: keep-awake remains off."));else if("hermes"===i.key){const n=await d("\n Hermes profile",[{label:"Clone active profile",hint:"copies provider/config explicitly; fresh ThinkPool session history"},{label:"Create clean profile",hint:"no copied credentials or bundled skills; configure provider afterward"},{label:"Back",hint:""}],3);2!==n&&(await e.setupHermes({mode:0===n?"clone":"clean"}),c())}}}};for(;;){u(),n.print(" "+g("What do you want to do?")+"\n");const r=t.accountSvc?["win32"===t.platform?{key:"restart",label:"Update startup entry",hint:"installs the latest version; relaunch the bridge window to apply"}:{key:"restart",label:"Restart & update bridge",hint:"install the latest published version; sessions resume"},{key:"uninstall",label:"Remove background service",hint:"stop serving your sessions on this device"}]:[{key:"serve",label:"Serve all my sessions",hint:"runs here, Ctrl-C stops it"},{key:"service",label:"Always-on background service",hint:"recommended — survives reboot, restarts on crash"}];r.push({key:"settings",label:"Settings",hint:`provider · account · keep-awake ${t.keepAwake?"ON":"off"}${t.hermesInstalled?" · Hermes profile":""}`}),r.push({key:"quit",label:"Quit",hint:""});const i=r[await d("choose",r)];if("quit"===i.key)return;if("serve"===i.key)await k()&&await m()&&await e.serveAccountForeground();else if("service"===i.key){if(await k()&&await m()){if(!0===await e.installService({room:null})&&(!e.relaunchLauncher||await e.relaunchLauncher()))return;c()}}else if("restart"===i.key){if(!0===await e.restartUpdateService({room:null})&&(!e.relaunchLauncher||await e.relaunchLauncher()))return;c()}else"uninstall"===i.key?(await e.uninstallService({room:null}),c()):"settings"===i.key&&await $();await n.ask("\n "+v("press Enter to return to the menu…"))}}
|
package/mcp-flight-recorder.mjs
CHANGED
|
@@ -1,79 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// diagnose a tool path (tool identity, lifecycle, timing, and outcome), but it
|
|
3
|
-
// intentionally never retains arguments, result bodies, prompts, or host paths.
|
|
4
|
-
// The recorder is runtime-agnostic because every structured runtime normalizes
|
|
5
|
-
// MCP activity into the common assistant/tool_result event contract.
|
|
6
|
-
|
|
7
|
-
export const MCP_FLIGHT_MAX = 200
|
|
8
|
-
|
|
9
|
-
const mcpToolUse = (event) => (Array.isArray(event?.blocks) ? event.blocks : [])
|
|
10
|
-
.filter((block) => block?.type === 'tool_use' && typeof block?.name === 'string' && block.name.startsWith('mcp__'))
|
|
11
|
-
|
|
12
|
-
const safeToolName = (name) => String(name || '')
|
|
13
|
-
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
14
|
-
.slice(0, 160)
|
|
15
|
-
|
|
16
|
-
const safeId = (id) => typeof id === 'string' || typeof id === 'number'
|
|
17
|
-
? String(id).slice(0, 200)
|
|
18
|
-
: null
|
|
19
|
-
|
|
20
|
-
export function normalizeMcpFlight (value, { max = MCP_FLIGHT_MAX } = {}) {
|
|
21
|
-
const cap = Math.max(1, Math.min(MCP_FLIGHT_MAX, Number(max) || MCP_FLIGHT_MAX))
|
|
22
|
-
if (!Array.isArray(value)) return []
|
|
23
|
-
return value.slice(-cap).flatMap((item) => {
|
|
24
|
-
if (!item || typeof item !== 'object') return []
|
|
25
|
-
const toolUseId = safeId(item.toolUseId)
|
|
26
|
-
const tool = safeToolName(item.tool)
|
|
27
|
-
const startedAt = Number(item.startedAt)
|
|
28
|
-
if (!toolUseId || !tool || !Number.isFinite(startedAt)) return []
|
|
29
|
-
return [{
|
|
30
|
-
toolUseId,
|
|
31
|
-
tool,
|
|
32
|
-
startedAt,
|
|
33
|
-
finishedAt: Number.isFinite(Number(item.finishedAt)) ? Number(item.finishedAt) : null,
|
|
34
|
-
durationMs: Number.isFinite(Number(item.durationMs)) ? Math.max(0, Number(item.durationMs)) : null,
|
|
35
|
-
outcome: item.outcome === 'error' ? 'error' : item.outcome === 'ok' ? 'ok' : 'pending',
|
|
36
|
-
// Deliberately a count only. Never persist or return argument names/values:
|
|
37
|
-
// even a key can reveal a secret type or private filesystem layout.
|
|
38
|
-
argumentCount: Math.max(0, Math.min(1000, Number(item.argumentCount) || 0)),
|
|
39
|
-
}]
|
|
40
|
-
})
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const argumentCount = (input) => input && typeof input === 'object' && !Array.isArray(input)
|
|
44
|
-
? Object.keys(input).length
|
|
45
|
-
: input == null ? 0 : 1
|
|
46
|
-
|
|
47
|
-
/** Record an already-normalized room event, returning a bounded safe trace. */
|
|
48
|
-
export function recordMcpFlightEvent (flight, event, { now = Date.now(), max = MCP_FLIGHT_MAX } = {}) {
|
|
49
|
-
const next = normalizeMcpFlight(flight, { max })
|
|
50
|
-
for (const block of mcpToolUse(event)) {
|
|
51
|
-
const toolUseId = safeId(block.id)
|
|
52
|
-
const tool = safeToolName(block.name)
|
|
53
|
-
if (!toolUseId || !tool) continue
|
|
54
|
-
next.push({ toolUseId, tool, startedAt: now, finishedAt: null, durationMs: null, outcome: 'pending', argumentCount: argumentCount(block.input) })
|
|
55
|
-
}
|
|
56
|
-
if (event?.kind === 'tool_result') {
|
|
57
|
-
const toolUseId = safeId(event.toolUseId)
|
|
58
|
-
const item = toolUseId ? [...next].reverse().find((entry) => entry.toolUseId === toolUseId && entry.outcome === 'pending') : null
|
|
59
|
-
if (item) {
|
|
60
|
-
item.finishedAt = now
|
|
61
|
-
item.durationMs = Number.isFinite(Number(event.durationMs)) ? Math.max(0, Number(event.durationMs)) : Math.max(0, now - item.startedAt)
|
|
62
|
-
item.outcome = event.isError ? 'error' : 'ok'
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return normalizeMcpFlight(next, { max })
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function formatMcpFlight (flight, { limit = 40 } = {}) {
|
|
69
|
-
const safe = normalizeMcpFlight(flight)
|
|
70
|
-
const boundedLimit = Math.max(1, Math.min(100, Number(limit) || 40))
|
|
71
|
-
const rows = safe.slice(-boundedLimit)
|
|
72
|
-
if (!rows.length) return 'MCP flight recorder: no MCP tool calls recorded in this terminal yet. It records tool name, timing, and outcome only; arguments and results are never retained.'
|
|
73
|
-
const lines = rows.map((row) => {
|
|
74
|
-
const elapsed = row.durationMs == null ? 'running' : `${row.durationMs}ms`
|
|
75
|
-
return `${new Date(row.startedAt).toISOString()} ${row.outcome.toUpperCase().padEnd(7)} ${row.tool} ${elapsed} args omitted (${row.argumentCount})`
|
|
76
|
-
})
|
|
77
|
-
return `MCP flight recorder — ${rows.length}/${safe.length} recent call${safe.length === 1 ? '' : 's'}\n` +
|
|
78
|
-
'Privacy: arguments, result bodies, prompts, secrets, and host paths are never recorded.\n\n' + lines.join('\n')
|
|
79
|
-
}
|
|
1
|
+
export const MCP_FLIGHT_MAX=200;const t=t=>String(t||"").replace(/[^a-zA-Z0-9_-]/g,"_").slice(0,160),e=t=>"string"==typeof t||"number"==typeof t?String(t).slice(0,200):null;export function normalizeMcpFlight(r,{max:n=200}={}){const o=Math.max(1,Math.min(200,Number(n)||200));return Array.isArray(r)?r.slice(-o).flatMap(r=>{if(!r||"object"!=typeof r)return[];const n=e(r.toolUseId),o=t(r.tool),i=Number(r.startedAt);return n&&o&&Number.isFinite(i)?[{toolUseId:n,tool:o,startedAt:i,finishedAt:Number.isFinite(Number(r.finishedAt))?Number(r.finishedAt):null,durationMs:Number.isFinite(Number(r.durationMs))?Math.max(0,Number(r.durationMs)):null,outcome:"error"===r.outcome?"error":"ok"===r.outcome?"ok":"pending",argumentCount:Math.max(0,Math.min(1e3,Number(r.argumentCount)||0))}]:[]}):[]}const r=t=>t&&"object"==typeof t&&!Array.isArray(t)?Object.keys(t).length:null==t?0:1;export function recordMcpFlightEvent(n,o,{now:i=Date.now(),max:s=200}={}){const a=normalizeMcpFlight(n,{max:s});for(const n of(t=>(Array.isArray(t?.blocks)?t.blocks:[]).filter(t=>"tool_use"===t?.type&&"string"==typeof t?.name&&t.name.startsWith("mcp__")))(o)){const o=e(n.id),s=t(n.name);o&&s&&a.push({toolUseId:o,tool:s,startedAt:i,finishedAt:null,durationMs:null,outcome:"pending",argumentCount:r(n.input)})}if("tool_result"===o?.kind){const t=e(o.toolUseId),r=t?[...a].reverse().find(e=>e.toolUseId===t&&"pending"===e.outcome):null;r&&(r.finishedAt=i,r.durationMs=Number.isFinite(Number(o.durationMs))?Math.max(0,Number(o.durationMs)):Math.max(0,i-r.startedAt),r.outcome=o.isError?"error":"ok")}return normalizeMcpFlight(a,{max:s})}export function formatMcpFlight(t,{limit:e=40}={}){const r=normalizeMcpFlight(t),n=Math.max(1,Math.min(100,Number(e)||40)),o=r.slice(-n);if(!o.length)return"MCP flight recorder: no MCP tool calls recorded in this terminal yet. It records tool name, timing, and outcome only; arguments and results are never retained.";const i=o.map(t=>{const e=null==t.durationMs?"running":`${t.durationMs}ms`;return`${new Date(t.startedAt).toISOString()} ${t.outcome.toUpperCase().padEnd(7)} ${t.tool} ${e} args omitted (${t.argumentCount})`});return`MCP flight recorder — ${o.length}/${r.length} recent call${1===r.length?"":"s"}\nPrivacy: arguments, result bodies, prompts, secrets, and host paths are never recorded.\n\n`+i.join("\n")}
|
package/mockup-delivery.mjs
CHANGED
|
@@ -1,57 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// A render can finish several tool calls before the agent's final answer. Sending
|
|
4
|
-
// its card immediately makes the answer land below it, forcing a phone user to
|
|
5
|
-
// scroll back up. Keep active-turn artifacts private until the transcript's real
|
|
6
|
-
// terminal boundary has been emitted, then deliver them in render order.
|
|
7
|
-
export function completeMockupManifest(manifest, { isReadyFile } = {}) {
|
|
8
|
-
if (typeof isReadyFile !== 'function') throw new TypeError('completeMockupManifest requires isReadyFile')
|
|
9
|
-
if (manifest?.sourceKind === 'preview' && (manifest.deliveryIntent !== 'card' || manifest.readinessValidated !== true)) return false
|
|
10
|
-
const files = [manifest?.desktop, manifest?.mobile, manifest?.snapshot || manifest?.source || manifest?.html]
|
|
11
|
-
return files.every((file) => !!file && isReadyFile(file))
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function isMockupDeliveryBoundary(event, { turnActive = false } = {}) {
|
|
15
|
-
if (!event || event.continuesQueued) return false
|
|
16
|
-
if (event.kind === 'result') return true
|
|
17
|
-
return event.kind === 'error' && !turnActive
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class MockupDeliveryQueue {
|
|
21
|
-
constructor({ deliver } = {}) {
|
|
22
|
-
if (typeof deliver !== 'function') throw new TypeError('MockupDeliveryQueue requires deliver')
|
|
23
|
-
this.deliver = deliver
|
|
24
|
-
this.pending = new Map()
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
enqueue(owner, item, { active = false, key = null } = {}) {
|
|
28
|
-
if (!owner || !active) return { queued: false, delivery: Promise.resolve().then(() => this.deliver(item)) }
|
|
29
|
-
const queue = this.pending.get(owner) || []
|
|
30
|
-
const existing = key == null ? -1 : queue.findIndex((entry) => entry.key === key)
|
|
31
|
-
if (existing >= 0) {
|
|
32
|
-
queue[existing] = { item, key }
|
|
33
|
-
this.pending.set(owner, queue)
|
|
34
|
-
return { queued: true, replaced: true, delivery: null }
|
|
35
|
-
}
|
|
36
|
-
queue.push({ item, key })
|
|
37
|
-
this.pending.set(owner, queue)
|
|
38
|
-
return { queued: true, replaced: false, delivery: null }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
count(owner) {
|
|
42
|
-
return (this.pending.get(owner) || []).length
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async flush(owner) {
|
|
46
|
-
const queue = this.pending.get(owner) || []
|
|
47
|
-
this.pending.delete(owner)
|
|
48
|
-
for (const entry of queue) await this.deliver(entry.item)
|
|
49
|
-
return queue.length
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
clear(owner) {
|
|
53
|
-
const count = this.count(owner)
|
|
54
|
-
this.pending.delete(owner)
|
|
55
|
-
return count
|
|
56
|
-
}
|
|
57
|
-
}
|
|
1
|
+
export function completeMockupManifest(e,{isReadyFile:t}={}){if("function"!=typeof t)throw new TypeError("completeMockupManifest requires isReadyFile");return("preview"!==e?.sourceKind||"card"===e.deliveryIntent&&!0===e.readinessValidated)&&[e?.desktop,e?.mobile,e?.snapshot||e?.source||e?.html].every(e=>!!e&&t(e))}export function isMockupDeliveryBoundary(e,{turnActive:t=!1}={}){return!(!e||e.continuesQueued||"result"!==e.kind&&("error"!==e.kind||t))}export class MockupDeliveryQueue{constructor({deliver:e}={}){if("function"!=typeof e)throw new TypeError("MockupDeliveryQueue requires deliver");this.deliver=e,this.pending=new Map}enqueue(e,t,{active:n=!1,key:i=null}={}){if(!e||!n)return{queued:!1,delivery:Promise.resolve().then(()=>this.deliver(t))};const r=this.pending.get(e)||[],u=null==i?-1:r.findIndex(e=>e.key===i);return u>=0?(r[u]={item:t,key:i},this.pending.set(e,r),{queued:!0,replaced:!0,delivery:null}):(r.push({item:t,key:i}),this.pending.set(e,r),{queued:!0,replaced:!1,delivery:null})}count(e){return(this.pending.get(e)||[]).length}async flush(e){const t=this.pending.get(e)||[];this.pending.delete(e);for(const e of t)await this.deliver(e.item);return t.length}clear(e){const t=this.count(e);return this.pending.delete(e),t}}
|