thinkpool-pair 0.7.336 → 0.7.338

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/README.md CHANGED
@@ -17,6 +17,11 @@ not require an inbound port, tunnel, or public IP.
17
17
  - Hermes
18
18
  - The provider login or API credentials required by that runtime
19
19
 
20
+ **The bridge does not include a coding agent.** Claude Code, Codex, or Hermes
21
+ must already be installed and runnable on the bridge machine. BYOK only supplies
22
+ model-provider credentials to an installed runtime; a provider key does not
23
+ install or replace the agent itself.
24
+
20
25
  ## Start the bridge
21
26
 
22
27
  Run the launcher from the project directory the agents should use:
@@ -60,6 +65,35 @@ when that trade-off is intentional:
60
65
  npx thinkpool-pair@latest install-service --auto-update
61
66
  ```
62
67
 
68
+ ### Optional: keep the bridge computer awake
69
+
70
+ The launcher’s **Settings → Keep computer awake** option prevents system sleep
71
+ while the bridge is running. The same choice is available from the CLI:
72
+
73
+ ```bash
74
+ # Foreground, for this run only
75
+ npx thinkpool-pair@latest --keep-awake
76
+
77
+ # Persist the choice and install the background service
78
+ npx thinkpool-pair@latest install-service --keep-awake
79
+
80
+ # Persistently turn it back off while reinstalling/updating the service
81
+ npx thinkpool-pair@latest install-service --no-keep-awake
82
+ ```
83
+
84
+ **Important:** keep-awake can substantially increase battery use. It prevents
85
+ the computer from sleeping; it does **not** keep the display illuminated.
86
+ Display dimming, screen locking, and display sleep continue normally. Closing a
87
+ laptop lid may still put it to sleep depending on the operating system and its
88
+ power settings. On some Linux systems the sleep inhibitor can also block an
89
+ explicit suspend request while the bridge runs.
90
+
91
+ The implementation is process-bound and releases automatically when the bridge
92
+ stops: macOS uses `caffeinate -i`, Linux uses `systemd-inhibit --what=sleep`,
93
+ and Windows uses a system-only power request without `ES_DISPLAY_REQUIRED`.
94
+ If the platform helper is unavailable, the bridge warns and continues without
95
+ keep-awake rather than failing startup.
96
+
63
97
  Service implementation by platform:
64
98
 
65
99
  - macOS: LaunchAgent
@@ -231,9 +265,10 @@ log before reinstalling; repeated installation can hide the original failure.
231
265
 
232
266
  ### No runtimes are available
233
267
 
234
- Install/sign in to Claude, Codex, or Hermes on the host, then restart the
235
- launcher. Runtime availability is detected from the host; the web room cannot
236
- install a missing CLI for you.
268
+ Install and configure Claude Code, Codex, or Hermes on the host, then restart
269
+ the launcher. Runtime availability is detected from the host; the web room
270
+ cannot install a missing CLI. BYOK configures provider credentials only and
271
+ does not replace the local agent runtime.
237
272
 
238
273
  ### A custom model fails immediately
239
274
 
package/bridge.mjs CHANGED
@@ -68,6 +68,7 @@ import { hermesUserInputResponse } from './question-response.mjs'
68
68
  import { hostMemoryAdmission } from './host-memory.mjs'
69
69
  import { queueAbortBarrier, waitForAbortBarrier } from './abort-turn-barrier.mjs'
70
70
  import { PAIR_CLI, pairCli } from './command-guidance.mjs'
71
+ import { explicitKeepAwakeChoice, keepAwakeEnabled, saveKeepAwakePreference, startKeepAwake } from './keep-awake.mjs'
71
72
 
72
73
  const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'review', 'bypassPermissions'])
73
74
  import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, FLOW_CODEX_CONDUCTOR_PROMPT, FLOW_CODEX_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
@@ -236,6 +237,17 @@ const pickAgent = (installed) => new Promise((resolve) => {
236
237
 
237
238
  const argv = process.argv.slice(2)
238
239
 
240
+ // CLI service flags also set the durable machine preference. Service updates read
241
+ // the same preference, so a later pinned-runtime update cannot silently turn the
242
+ // requested power behavior off. Foreground `--keep-awake` remains a one-run override.
243
+ if (argv[0] === 'install-service' || argv[0] === 'restart-service') {
244
+ const choice = explicitKeepAwakeChoice(argv)
245
+ if (choice !== null && !saveKeepAwakePreference(choice)) {
246
+ process.stderr.write(' ⚠ could not save the keep-awake setting; service left unchanged.\n')
247
+ process.exit(1)
248
+ }
249
+ }
250
+
239
251
  if (argv[0] === 'privacy-report') {
240
252
  const { runPrivacyReport } = await import('./privacy-report.mjs')
241
253
  runPrivacyReport()
@@ -362,7 +374,10 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
362
374
  const child = spawn(process.execPath, args, { stdio: 'inherit' })
363
375
  child.on('exit', (code) => process.exit(code == null ? 0 : code))
364
376
  }),
365
- serveAccountForeground: async () => { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) },
377
+ serveAccountForeground: async () => {
378
+ startKeepAwake({ enabled: keepAwakeEnabled([]) })
379
+ const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON)
380
+ },
366
381
  // Confirmed installs/updates continue in the exact runtime just proven live. This
367
382
  // keeps the person inside the npx menu without letting the stale installer claim
368
383
  // its in-memory VERSION matches the newly installed managed service.
@@ -407,6 +422,17 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
407
422
  : ['anthropic']
408
423
  await runProvider(args)
409
424
  },
425
+ setKeepAwake: async ({ enabled }) => {
426
+ if (!saveKeepAwakePreference(enabled)) {
427
+ io.print('\n ⚠ could not save the keep-awake setting; nothing was changed.')
428
+ return false
429
+ }
430
+ // A managed Unix service must restart to acquire/release its process-bound
431
+ // inhibitor. Windows' Startup entry has no daemon control; restartService
432
+ // prints the honest close-and-relaunch instruction instead.
433
+ if (svc.isServiceInstalled(null)) svc.restartService(null)
434
+ return true
435
+ },
410
436
  setupHermes: async ({ mode }) => {
411
437
  const { setupHermesRuntime } = await import('./hermes-setup.mjs')
412
438
  const result = setupHermesRuntime({ mode })
@@ -461,7 +487,10 @@ function checkSdkCompat() {
461
487
  }
462
488
  }
463
489
 
464
- if (!argv[0] || argv[0].startsWith('-')) { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) }
490
+ if (!argv[0] || argv[0].startsWith('-')) {
491
+ startKeepAwake({ enabled: keepAwakeEnabled(argv) })
492
+ const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON)
493
+ }
465
494
 
466
495
  const room = (argv[0] || '').toUpperCase().trim()
467
496
  if (!room) { console.error(`usage: ${pairCli('<ROOM>', '[--headless]', '[--continue|--fresh]', '[-- <command…>]')} | ${PAIR_CLI} (account mode)`); process.exit(1) }
@@ -533,6 +562,11 @@ if (_superOwn.includes('--supervise') || _superOwn.includes('--keep-alive')) {
533
562
  if (shouldHoldMachineLock(process.env)) process.on('exit', () => { try { releaseMachineLock() } catch { /* noop */ } })
534
563
  }
535
564
 
565
+ // Direct single-room mode owns one inhibitor in its actual serving process. Account
566
+ // supervisor children deliberately skip this (the account parent already owns it),
567
+ // and the --supervise wrapper above never reaches this line—only its child does.
568
+ startKeepAwake({ enabled: keepAwakeEnabled(argv) })
569
+
536
570
  const headless = argv.includes('--headless')
537
571
  // Account-mode children pass --auto=<agent> so a served session opens a live
538
572
  // terminal automatically (headless, no TTY/picker) instead of an empty room.
@@ -2746,11 +2780,11 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2746
2780
  // conductor mains, spawned workers, Side lanes, and Flow lanes cannot use it.
2747
2781
  ...(canOpenMainConductor ? [tool(
2748
2782
  'open_main_terminal',
2749
- 'Open a separate independent MAIN terminal to conduct a Cascade on any supported runtime: Claude (including a connected Anthropic-compatible BYOK provider), Codex, or Hermes. Use this whenever a person explicitly asks to open, launch, start, or spawn a new Cascade/conductor terminal. This is the top-level room lifecycle, NOT Ensemble dispatch: the new terminal has no spawnedBy owner and appears as a main terminal. Never substitute spawn_terminal, which creates worker sub-terminals only. Give the conductor its full initial task; it will use spawn_terminal for its workers.',
2783
+ 'Open a separate independent MAIN terminal on any supported runtime: Claude (including a connected Anthropic-compatible BYOK provider), Codex, or Hermes. Use this whenever a person asks to open, launch, start, spawn, or create a new, separate, independent, top-level, or main terminal—even for ordinary work or a handoff and even without the words Cascade or conductor. If the wording includes new or separate terminal, main-terminal routing wins. This is the top-level room lifecycle, NOT Ensemble dispatch: the new terminal has no spawnedBy owner and appears as a main terminal. Never substitute spawn_terminal, which creates worker sub-terminals only. Give the main terminal its full initial task; it can use spawn_terminal for bounded workers.',
2750
2784
  {
2751
2785
  name: z.string().max(80).optional().describe('short label for the main conductor, e.g. "Cascade · Multi-device chaos"'),
2752
- task: z.string().min(1).describe('complete initial Cascade brief for the conductor'),
2753
- model: z.string().optional().describe('optional conductor model, e.g. opus / gpt-5.6-sol / nous:z-ai/glm-5.2'),
2786
+ task: z.string().min(1).describe('complete initial brief for the independent main terminal'),
2787
+ model: z.string().optional().describe('optional main-terminal model, e.g. opus / gpt-5.6-sol / nous:z-ai/glm-5.2'),
2754
2788
  runtime: z.enum(['claude', 'codex', 'hermes']).optional().describe('agent runtime; every top-level terminal can open every supported runtime, defaulting to its own'),
2755
2789
  provider: z.string().optional().describe(`optional registered Claude-compatible provider name or id; omit for built-in Anthropic${registeredProviderChoices() ? `. Available now: ${registeredProviderChoices()}` : ''}`),
2756
2790
  mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional().describe('permission mode; defaults to inheriting this main terminal, except plan falls back to default'),
@@ -4411,21 +4445,24 @@ channel
4411
4445
  ctlLine('nothing to compact — context unchanged')
4412
4446
  return
4413
4447
  }
4448
+ const preTokens = s.lastUsage?.ctx?.used || null
4449
+ bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'start', ts: Date.now() } })
4414
4450
  const nativeCompacted = await s.session.compactContext?.()
4415
4451
  if (nativeCompacted) {
4416
- const ce = { kind: 'compaction', trigger: 'manual', preTokens: s.lastUsage?.ctx?.used || null, by: payload.by, native: true }
4452
+ const ce = { kind: 'compaction', trigger: 'manual', preTokens, by: payload.by, native: true }
4417
4453
  pushLog(s, ce)
4418
4454
  bcast('code-event', { term: payload.term, evt: ce })
4419
- s.lastUsage = null
4455
+ bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
4420
4456
  s.flush?.()
4421
4457
  return
4422
4458
  }
4423
4459
  if (s.session.clearContext?.() === false) {
4460
+ bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
4424
4461
  ctlLine('Codex context compaction unavailable right now')
4425
4462
  return
4426
4463
  }
4427
4464
  s.pendingRecap = recap
4428
- const ce = { kind: 'compaction', trigger: 'manual', preTokens: s.lastUsage?.ctx?.used || null, by: payload.by }
4465
+ const ce = { kind: 'compaction', trigger: 'manual', preTokens, by: payload.by }
4429
4466
  pushLog(s, ce)
4430
4467
  bcast('code-event', { term: payload.term, evt: ce })
4431
4468
  // The new native thread has only the bounded recap queued for the next
@@ -4437,6 +4474,7 @@ channel
4437
4474
  s.lastUsage = usage
4438
4475
  bcast('code-event', { term: payload.term, evt: usage })
4439
4476
  }
4477
+ bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
4440
4478
  s.flush?.()
4441
4479
  return
4442
4480
  }
@@ -239,9 +239,9 @@ const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
239
239
  const TP_ROOM_REMINDER = [
240
240
  'You are Claude in a ThinkPool Code room, driven live from a phone or browser — NOT a local terminal. Keep using the room\'s features.',
241
241
  THINKPOOL_RUNTIME_TURN_REMINDER,
242
- 'TERMINAL HIERARCHY: obey your authoritative TERMINAL ROLE. Conductors are independent main terminals; Ensemble lanes are workers only. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal — NEVER spawn_terminal. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Only conductor-capable roles fan worker slices out with spawn_terminal. Leaf, worker, Side, and managed Flow lanes work directly. Never use built-in invisible Task/Agent subagents or hijack a busy sibling.',
242
+ 'TERMINAL HIERARCHY: obey your authoritative TERMINAL ROLE. A person-authored request to open, launch, start, spawn, or create a new, separate, independent, top-level, or main terminal uses open_main_terminal — NEVER spawn_terminal — even for ordinary work or a handoff. If the wording includes new or separate terminal, main-terminal routing wins. Use spawn_terminal only for agent-decided bounded worker slices or when the person explicitly asks for a worker, sub-terminal, Ensemble lane, or delegated slice. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Leaf, worker, Side, and managed Flow lanes work directly. Never use built-in invisible Task/Agent subagents or hijack a busy sibling.',
243
243
  'WORKTREES: parallel lanes share one repo — before code edits run `git worktree list`; if linked worktrees exist, take your OWN worktree + branch, never the shared checkout or a branch another lane is on.',
244
- 'BUILD WORKFLOW (default, no magic word): right-size within your TERMINAL ROLE — a trivial ask or delegated slice you just do; a conductor-capable role with a genuinely decomposable build FIRST writes a short plan in chat, THEN fans worker slices into visible spawn_terminal lanes and verifies them. Worker/leaf/Side/managed Flow roles do not fan out. A requested separate conductor uses open_main_terminal. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list.',
244
+ 'BUILD WORKFLOW (default, no magic word): right-size within your TERMINAL ROLE — a trivial ask or delegated slice you just do; a conductor-capable role with a genuinely decomposable build FIRST writes a short plan in chat, THEN fans worker slices into visible spawn_terminal lanes and verifies them. Worker/leaf/Side/managed Flow roles do not fan out. A person-requested new or separate terminal uses open_main_terminal. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list.',
245
245
  ].join(' ')
246
246
 
247
247
  export function startClaudeSession({ cwd, model, effort: initialEffort = 'high', resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, terminalRolePrompt, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true, prepareCwd = null, admitStart = null }) {
@@ -749,10 +749,10 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
749
749
  ...THINKPOOL_REMOTE_DELIVERY_RULES,
750
750
  'CROSS-TERMINAL AWARENESS: ROOM NOW is the default roster and already satisfies the room check when it has enough detail. Do not repeat it with a no-argument read_terminal call unless it is missing or truncated. Use a targeted read_terminal call only when the current task depends on a specific lane’s detailed activity; never poll. Identify a terminal by its NAME or stable ref/id, never by an on-screen number like "Terminal 2" — positional labels renumber when a terminal is closed. The tool is read-only and its optional roster lookup is budgeted separately from bounded targeted transcript reads.',
751
751
  'CROSS-TERMINAL HAND-OFF: you also have post_to_terminal(terminal, text) to send a message or task to ANOTHER AGENT terminal in this room (not a plain shell). Use it sparingly and only when the people clearly want the lanes to coordinate — e.g. "tell the backend terminal the API is ready", or to hand a sibling agent a concrete task. Every post requires a person in the room to approve a card before it is delivered, and an agent that was itself reached via a cross-post cannot post onward — so do not rely on it for chit-chat or loops. Prefer read_terminal to understand a sibling before you ever post to it.',
752
- 'TERMINAL CREATION CONTRACT: conductors and workers use different tools. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal(name?, task?, model?) — it creates an independent MAIN terminal with no Ensemble owner. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for bounded WORKER SUB-TERMINALS when your authoritative role permits delegation. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers are always autonomous in bypassPermissions and never ask the room for an approval card; the bridge still enforces room caps, hop limits, the kill-switch, and isolated linked worktrees. Wait for ROOM NOW or a completion signal instead of polling; after a worker finishes, collect it with one targeted read_terminal call and close_terminal immediately. Main conductors are independent terminals, keep their requested permission mode, and are not owned/closed through Ensemble.',
752
+ 'TERMINAL CREATION CONTRACT: main terminals and workers use different tools. A person-authored request to open, launch, start, spawn, or create a new, separate, independent, top-level, or main terminal uses open_main_terminal(name?, task?, model?) — even for ordinary work or a handoff and even without the words Cascade or conductor. If the wording includes new or separate terminal, main-terminal routing wins. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for agent-decided bounded WORKER SUB-TERMINALS or when the person explicitly asks for a worker, sub-terminal, Ensemble lane, or delegated slice. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers are always autonomous in bypassPermissions and never ask the room for an approval card; the bridge still enforces room caps, hop limits, the kill-switch, and isolated linked worktrees. Wait for ROOM NOW or a completion signal instead of polling; after a worker finishes, collect it with one targeted read_terminal call and close_terminal immediately. Main terminals are independent, keep their requested permission mode, and are not owned/closed through Ensemble.',
753
753
  'CROSS-SESSION AWARENESS: the Ensemble reaches across your SESSIONS, not just the terminals in this room. list_sessions() lists your OTHER ThinkPool Code rooms — both your own rooms running on this machine AND your partner\'s rooms in the same pair, reachable over the per-pair bus (a room on the partner\'s machine shows its host). read_session(session, terminal?) reads recent activity inside one (omit `terminal` to list that room\'s terminals, or pass a ref/name to read that lane). Both are READ-ONLY — they never change another session, and they reach ONLY your own rooms and rooms you share with your partner, never a stranger\'s. Reach for them when work spans rooms — "what\'s the other project up to", "pick up where the other session left off", or to check a long-running task elsewhere before you act here.',
754
754
  'CROSS-SESSION HAND-OFF: post_to_session(session, text, terminal?) sends a task or message to an agent in ANOTHER of your rooms — your own, or your partner\'s over the pair bus. Use it sparingly and only when the people clearly want the rooms to coordinate — e.g. hand the API room\'s agent a concrete follow-up once the frontend is ready. It is dual-consent: a person in YOUR room approves sending, and a person in the TARGET room approves receiving, before anything is delivered — so never rely on it for chit-chat or loops, and an agent that was itself reached via a cross-room post cannot post onward to a third room. It spends real model tokens in the other room (maybe on the other person\'s machine), so prefer read_session to understand a room before you ever post into it, and only post one concrete hand-off at a time. Outbound list/read/post tools need the ThinkPool account bridge; a standalone owner room can still receive a paired hand-off directly and will always raise its own approval card before delivery.',
755
- 'SUBAGENT POLICY: in this room, a main conductor delegates worker slices through visible spawn_terminal Ensemble lanes. A requested separate conductor is created with open_main_terminal, never Ensemble. Worker, leaf, Side, and managed Flow lanes do their assigned work directly. Do NOT reach for built-in Task/Agent subagents: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble.',
755
+ 'SUBAGENT POLICY: in this room, a main terminal delegates worker slices through visible spawn_terminal Ensemble lanes. A person-requested new or separate terminal is created with open_main_terminal, never Ensemble. Worker, leaf, Side, and managed Flow lanes do their assigned work directly. Do NOT reach for built-in Task/Agent subagents: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble.',
756
756
  'RESEARCH LANE: you have a `research` tool that runs a REAL multi-source web search + adversarial verification and returns each claim marked HELD or REJECTED with citations. Reach for it when the people would genuinely benefit from looking something external up or settling a question of current fact — pricing, "is X still maintained / deprecated", "is that benchmark real", a debate over facts you are not sure of. Do NOT run it unprompted or for things you already know: first OFFER in plain language ("want me to spawn a research lane on that and check it?"), and only call `research(question)` once they agree — it spends real budget (plan-gated Free 5 / Plus 100 runs a month) and takes ~a minute. When it returns, present the held/rejected findings clearly and invite both people to weigh the sources, flagging any held claim that rests on a source they might not trust — that shared scrutiny is the point.',
757
757
  'WORKTREES: parallel lanes share one machine and usually one repo. Run `git worktree list` before your first code edit; if linked worktrees exist, the shared main checkout is contended (and may be guard-blocked) — do your work in your OWN worktree on your OWN branch (`git worktree add <dir> -b <branch>`), and never edit a checkout or ride a branch another lane is using.',
758
758
  'WRITE PLANS INTO THE CHAT: whenever you form or revise a plan — because the room is in Plan mode, or because someone asked you to plan, design, or think it through first — write the actual plan out as a normal message in the room as you develop it: the approach, the concrete steps, the files you will touch, the open questions. The room does NOT surface plan files at all, and the plan-approval card does not reliably carry the plan text, so a plan that lives only in a plan file or only inside ExitPlanMode is INVISIBLE to the people you are working with — they just see "plan ready" with no content. The chat is the canonical place your plan lives; put it there so the room can read and react to it before you proceed.',
package/codex-session.mjs CHANGED
@@ -192,6 +192,25 @@ export function readCodexThreadUsage(sessionId, {
192
192
  return null
193
193
  }
194
194
 
195
+ // App Server publishes the authoritative current-window count during native
196
+ // compaction (and while turns run). Its field names differ from the rollout
197
+ // token_count rows, so normalize both sources to the one snapshot shape used by
198
+ // CodexEventMapper and the bridge context meter.
199
+ export function codexUsageSnapshotFromAppServer(tokenUsage) {
200
+ const used = Number(tokenUsage?.last?.totalTokens)
201
+ const max = Number(tokenUsage?.modelContextWindow)
202
+ if (!Number.isFinite(used) || used < 0 || !Number.isFinite(max) || max <= 0) return null
203
+ const total = tokenUsage?.total || {}
204
+ return {
205
+ context: { used: Math.floor(used), max: Math.floor(max) },
206
+ total: {
207
+ input_tokens: Math.max(0, Number(total.inputTokens) || 0),
208
+ cached_input_tokens: Math.max(0, Number(total.cachedInputTokens) || 0),
209
+ output_tokens: Math.max(0, Number(total.outputTokens) || 0),
210
+ },
211
+ }
212
+ }
213
+
195
214
  export function normalizeCodexSandbox(value) {
196
215
  return SAFE_SANDBOXES.has(value) ? value : DEFAULT_SANDBOX
197
216
  }
@@ -218,7 +237,7 @@ export function codexPublishAccess({ cwd, env = process.env, tmpdir = os.tmpdir(
218
237
  }
219
238
 
220
239
  export const CODEX_ROOM_CASCADE_REMINDER = [
221
- 'THINKPOOL ROOM ROLE ENFORCEMENT: obey the authoritative TERMINAL ROLE above. Conductors are independent main terminals; Ensemble lanes are workers only. An explicitly requested separate Cascade/conductor uses open_main_terminal — NEVER spawn_terminal. Use spawn_terminal only for bounded worker slices from a role permitted to delegate. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Leaf, worker, Side, and managed Flow lanes work directly and must not delegate. Never use hidden in-process subagents.',
240
+ 'THINKPOOL ROOM ROLE ENFORCEMENT: obey the authoritative TERMINAL ROLE above. A person-authored request for a new, separate, independent, top-level, or main terminal uses open_main_terminal — NEVER spawn_terminal — even for ordinary work or a handoff. If the wording includes new or separate terminal, main-terminal routing wins. Use spawn_terminal only for agent-decided bounded worker slices or when the person explicitly asks for a worker, sub-terminal, Ensemble lane, or delegated slice. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Leaf, worker, Side, and managed Flow lanes work directly and must not delegate. Never use hidden in-process subagents.',
222
241
  ].join(' ')
223
242
 
224
243
  export function buildCodexPrompt({ text, terminalRolePrompt, rolePrompt, roomContext, firstTurn = false, promptIndex = 0, forceFullReminder = false }) {
@@ -415,6 +434,8 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
415
434
  let appServer = null
416
435
  let appServerThreadReady = false
417
436
  let activeTurnId = null
437
+ let compactStartWaiter = null
438
+ let compactActive = false
418
439
  // App Server notifications are asynchronous to turn/completed. In production a
419
440
  // stopped turn emitted its durable `aborted` boundary, then delivered a queued
420
441
  // item/started 89ms later and a Bash result 159.9s after that. Without a native
@@ -442,7 +463,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
442
463
  usageSnapshotForSession: (id) => {
443
464
  const snapshot = readCodexThreadUsage(id)
444
465
  if (snapshot) latestUsageSnapshot = snapshot
445
- return snapshot
466
+ return snapshot || latestUsageSnapshot
446
467
  },
447
468
  })
448
469
 
@@ -499,9 +520,29 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
499
520
  }
500
521
 
501
522
  function appServerNotification(method, params = {}) {
523
+ if (method === 'thread/tokenUsage/updated') {
524
+ if (params.threadId && sessionId && String(params.threadId) !== String(sessionId)) return
525
+ const snapshot = codexUsageSnapshotFromAppServer(params.tokenUsage)
526
+ if (!snapshot) return
527
+ latestUsageSnapshot = snapshot
528
+ // Ordinary turns publish one settled meter at turn/completed. Native
529
+ // compaction is not an ordinary sendTurn, so this notification is its only
530
+ // immediate post-summary meter and must reach every connected room client.
531
+ if (compactActive) {
532
+ const { used, max } = snapshot.context
533
+ relayEvent({ kind: 'usage', ctx: { used, max, pct: Math.min(100, Math.max(0, Math.round((used / max) * 100))), over: used > max, model: activeModel } })
534
+ }
535
+ return
536
+ }
502
537
  if (method === 'turn/started' && params.turn?.id) {
503
538
  const turnId = String(params.turn.id)
504
539
  if (ended || aborted) { closeAppServerTurn(turnId); return }
540
+ if (compactStartWaiter) {
541
+ const waiter = compactStartWaiter
542
+ compactStartWaiter = null
543
+ waiter.resolve(turnId)
544
+ return
545
+ }
505
546
  if (!activeTurnId) activeTurnId = turnId
506
547
  touchActivity()
507
548
  return
@@ -996,7 +1037,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
996
1037
  return {
997
1038
  get sessionId() { return sessionId },
998
1039
  get usageSnapshot() { return latestUsageSnapshot },
999
- get turnActive() { return turnActive },
1040
+ get turnActive() { return turnActive || compactActive },
1000
1041
  get canSteer() { return !!(appServer && appServer.alive !== false && appServerThreadReady && !appServerDisabled) },
1001
1042
  get started() { return turnNo > 0 || turnActive },
1002
1043
  sendTurn(text, options = {}) {
@@ -1128,13 +1169,32 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
1128
1169
  return true
1129
1170
  },
1130
1171
  async compactContext() {
1131
- if (turnActive || !await ensureAppServer()) return false
1172
+ if (turnActive || compactActive || !await ensureAppServer()) return false
1173
+ compactActive = true
1174
+ let startTimer = null
1132
1175
  try {
1176
+ const compactTurn = new Promise((resolve, reject) => {
1177
+ startTimer = setTimeout(() => {
1178
+ compactStartWaiter = null
1179
+ reject(new Error('Codex did not start the compaction turn'))
1180
+ }, 5000)
1181
+ compactStartWaiter = { resolve, reject }
1182
+ })
1133
1183
  await appServer.compact({ threadId: sessionId })
1134
- return true
1184
+ const turnId = await compactTurn
1185
+ if (startTimer) clearTimeout(startTimer)
1186
+ const completed = await appServer.waitForTurn(turnId)
1187
+ closeAppServerTurn(completed?.turn?.id || turnId)
1188
+ const status = completed?.turn?.status
1189
+ if (status === 'completed') return true
1190
+ throw new Error(completed?.turn?.error?.message || `compaction ${status || 'failed'}`)
1135
1191
  } catch (error) {
1136
1192
  note(`Native Codex compaction unavailable; using bounded recap fallback: ${error?.message || error}`)
1137
1193
  return false
1194
+ } finally {
1195
+ if (startTimer) clearTimeout(startTimer)
1196
+ compactStartWaiter = null
1197
+ compactActive = false
1138
1198
  }
1139
1199
  },
1140
1200
  async accountUsage() {
package/keep-awake.mjs ADDED
@@ -0,0 +1,148 @@
1
+ /* keep-awake.mjs — one explicit, machine-wide bridge preference with a
2
+ platform-native implementation. The assertion is SYSTEM-only: it never asks
3
+ any platform to keep the display lit, so normal dimming, display sleep, and
4
+ screen locking remain available.
5
+
6
+ The helper is deliberately tied to the bridge PID. A crash, Ctrl-C, update,
7
+ or service-manager replacement therefore releases the assertion without a
8
+ sticky machine-level power-policy change. */
9
+
10
+ import os from 'node:os'
11
+ import fs from 'node:fs'
12
+ import path from 'node:path'
13
+ import { spawn } from 'node:child_process'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ const SETTINGS_FILE = 'settings.json'
17
+
18
+ function settingsPath(home = os.homedir()) {
19
+ return path.join(home, '.thinkpool-pair', SETTINGS_FILE)
20
+ }
21
+
22
+ export function loadKeepAwakePreference({ home = os.homedir(), fsImpl = fs } = {}) {
23
+ try { return JSON.parse(fsImpl.readFileSync(settingsPath(home), 'utf8')).keepAwake === true }
24
+ catch { return false }
25
+ }
26
+
27
+ export function saveKeepAwakePreference(enabled, { home = os.homedir(), fsImpl = fs } = {}) {
28
+ const file = settingsPath(home)
29
+ const dir = path.dirname(file)
30
+ let current = {}
31
+ try { current = JSON.parse(fsImpl.readFileSync(file, 'utf8')) || {} } catch { /* first setting */ }
32
+ const next = { ...current, keepAwake: enabled === true }
33
+ const tmp = `${file}.tmp.${process.pid}.${Date.now()}`
34
+ try {
35
+ fsImpl.mkdirSync(dir, { recursive: true })
36
+ fsImpl.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 })
37
+ fsImpl.renameSync(tmp, file)
38
+ return true
39
+ } catch {
40
+ try { fsImpl.rmSync(tmp, { force: true }) } catch { /* noop */ }
41
+ return false
42
+ }
43
+ }
44
+
45
+ export function explicitKeepAwakeChoice(argv = []) {
46
+ const dash = argv.indexOf('--')
47
+ const own = dash >= 0 ? argv.slice(0, dash) : argv
48
+ if (own.includes('--no-keep-awake')) return false
49
+ if (own.includes('--keep-awake')) return true
50
+ return null
51
+ }
52
+
53
+ export function keepAwakeEnabled(argv = [], { env = process.env, loadPreference = loadKeepAwakePreference } = {}) {
54
+ const explicit = explicitKeepAwakeChoice(argv)
55
+ if (explicit !== null) return explicit
56
+ if (env.THINKPOOL_PAIR_KEEP_AWAKE === '1') return true
57
+ if (env.THINKPOOL_PAIR_KEEP_AWAKE === '0') return false
58
+ return loadPreference() === true
59
+ }
60
+
61
+ const WINDOWS_HELPER = (parentPid) => [
62
+ '$ErrorActionPreference = "Stop"',
63
+ 'Add-Type -TypeDefinition \'using System; using System.Runtime.InteropServices; public static class ThinkpoolPower { [DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint flags); }\'',
64
+ // ES_CONTINUOUS | ES_SYSTEM_REQUIRED. ES_DISPLAY_REQUIRED (0x2) is intentionally absent.
65
+ '[ThinkpoolPower]::SetThreadExecutionState([uint32]0x80000001) | Out-Null',
66
+ `try { while (Get-Process -Id ${parentPid} -ErrorAction SilentlyContinue) { Start-Sleep -Seconds 5 } }`,
67
+ 'finally { [ThinkpoolPower]::SetThreadExecutionState([uint32]0x80000000) | Out-Null }',
68
+ ].join('; ')
69
+
70
+ // Pure command construction keeps the display-sleep contract testable on any host.
71
+ export function keepAwakeCommand(platform, parentPid, { node = process.execPath, modulePath = fileURLToPath(import.meta.url) } = {}) {
72
+ if (!Number.isInteger(parentPid) || parentPid <= 0) throw new Error('keep-awake requires a live bridge pid')
73
+ if (platform === 'darwin') {
74
+ // -i is the idle SYSTEM-sleep assertion. Do not add -d (display).
75
+ // -w makes caffeinate release automatically when the bridge PID exits.
76
+ return { command: '/usr/bin/caffeinate', args: ['-i', '-w', String(parentPid)], adapter: 'caffeinate' }
77
+ }
78
+ if (platform === 'linux') {
79
+ // Inhibit the sleep operation, not desktop "idle" handling: an idle inhibitor
80
+ // can also suppress screen blanking in some desktop environments. The tiny Node
81
+ // waiter exits with the parent, releasing systemd-logind's inhibitor lock.
82
+ return {
83
+ command: 'systemd-inhibit',
84
+ args: ['--what=sleep', '--mode=block', '--who=Thinkpool', '--why=Keep the Thinkpool bridge reachable', '--', node, modulePath, '--wait-for', String(parentPid)],
85
+ adapter: 'systemd-inhibit',
86
+ }
87
+ }
88
+ if (platform === 'win32') {
89
+ return {
90
+ command: 'powershell.exe',
91
+ args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', WINDOWS_HELPER(parentPid)],
92
+ adapter: 'Windows power request',
93
+ }
94
+ }
95
+ return null
96
+ }
97
+
98
+ export function startKeepAwake({
99
+ enabled,
100
+ platform = process.platform,
101
+ parentPid = process.pid,
102
+ spawnImpl = spawn,
103
+ stderr = process.stderr,
104
+ accountChild = process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1',
105
+ } = {}) {
106
+ if (!enabled || accountChild) return null
107
+ let spec
108
+ try { spec = keepAwakeCommand(platform, parentPid) }
109
+ catch (error) { stderr.write(` ⚠ keep-awake unavailable: ${error?.message || error}\n`); return null }
110
+ if (!spec) {
111
+ stderr.write(` ⚠ keep-awake is not supported on ${platform}; bridge startup will continue normally.\n`)
112
+ return null
113
+ }
114
+
115
+ let child
116
+ const startedAt = Date.now()
117
+ try {
118
+ child = spawnImpl(spec.command, spec.args, { stdio: 'ignore', windowsHide: true })
119
+ child.once?.('spawn', () => {
120
+ stderr.write(` ◆ keep-awake ON via ${spec.adapter}: system sleep is inhibited while this bridge runs.\n Your screen can still dim, lock, and turn off normally.\n`)
121
+ })
122
+ child.once?.('error', (error) => {
123
+ stderr.write(` ⚠ keep-awake could not start (${spec.adapter}: ${error?.message || error}); bridge startup will continue normally.\n`)
124
+ })
125
+ child.once?.('exit', (code) => {
126
+ if (code && Date.now() - startedAt < 30_000) stderr.write(` ⚠ keep-awake helper exited early (${spec.adapter}, code ${code}); the computer may sleep normally.\n`)
127
+ })
128
+ child.unref?.()
129
+ return child
130
+ } catch (error) {
131
+ stderr.write(` ⚠ keep-awake could not start (${spec.adapter}: ${error?.message || error}); bridge startup will continue normally.\n`)
132
+ return null
133
+ }
134
+ }
135
+
136
+ async function waitForProcess(pid) {
137
+ for (;;) {
138
+ try { process.kill(pid, 0) } catch { return }
139
+ await new Promise((resolve) => setTimeout(resolve, 5_000))
140
+ }
141
+ }
142
+
143
+ // Linux systemd-inhibit owns this helper as its COMMAND. It holds no power
144
+ // assertion itself; its lifetime simply matches the bridge process.
145
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--wait-for') {
146
+ const pid = Number(process.argv[3])
147
+ if (Number.isInteger(pid) && pid > 0) await waitForProcess(pid)
148
+ }
package/launcher.mjs CHANGED
@@ -17,6 +17,7 @@ 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
19
  import { darwinServiceRunning, serviceRuntimeVersion } from './service.mjs'
20
+ import { loadKeepAwakePreference } from './keep-awake.mjs'
20
21
 
21
22
  const HOME = os.homedir()
22
23
  const CFG_DIR = path.join(HOME, '.thinkpool-pair')
@@ -86,6 +87,7 @@ export function detectState() {
86
87
  hermesInstalled,
87
88
  hermesReady,
88
89
  accountSvc,
90
+ keepAwake: loadKeepAwakePreference(),
89
91
  serviceVersion: accountSvc ? serviceRuntimeVersion(null) : null,
90
92
  platform: process.platform,
91
93
  cwd: process.cwd(),
@@ -110,6 +112,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
110
112
  loggedIn: !!next?.loggedIn,
111
113
  reconnectRequired: !!next?.reconnectRequired,
112
114
  accountSvc: !!next?.accountSvc,
115
+ keepAwake: next?.keepAwake === true,
113
116
  serviceVersion: typeof next?.serviceVersion === 'string' && next.serviceVersion ? next.serviceVersion : null,
114
117
  platform: next?.platform || process.platform,
115
118
  provider: next?.provider || 'Anthropic (default)',
@@ -134,7 +137,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
134
137
  io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + C.dim(' ' + '─'.repeat(40)))
135
138
  io.print(` ${C.dim('│ launcher ')} ${state.version ? `v${state.version}` : 'version unavailable'}`)
136
139
  io.print(` ${C.dim('│ account ')} ${state.reconnectRequired ? C.yellow('reconnect required') : state.loggedIn ? C.green((state.email || 'linked') + ' ✓') : C.yellow('not linked')}`)
137
- io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none ready')}`)
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')}`)
138
141
  io.print(` ${C.dim('│ directory')} ${C.dim(state.cwd)}`)
139
142
  const bridgeStatus = !state.accountSvc
140
143
  ? 'not installed'
@@ -142,6 +145,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
142
145
  ? C.green('startup entry installed')
143
146
  : C.green(`running${state.serviceVersion ? ` v${state.serviceVersion}` : ' (version unavailable)'}`)
144
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'}`)
145
149
  io.print(' ' + C.dim('└' + '─'.repeat(54)) + '\n')
146
150
  }
147
151
 
@@ -176,8 +180,25 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
176
180
  return true
177
181
  }
178
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
+
179
199
  const providerMenu = async () => {
180
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.'))
181
202
  const p = await askChoice('\n provider', [
182
203
  { label: 'Anthropic (default)', hint: 'regular Claude login' },
183
204
  { label: 'Paste a key, pick a model', hint: 'auto-detects the provider · OpenRouter = any model' },
@@ -236,6 +257,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
236
257
  const options = [
237
258
  { key: 'provider', label: 'Provider', hint: `current: ${state.provider}` },
238
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` },
239
261
  ]
240
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' })
241
263
  options.push({ key: 'back', label: 'Back to main menu', hint: '' })
@@ -243,6 +265,30 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
243
265
  if (picked.key === 'back') return
244
266
  if (picked.key === 'provider') await providerMenu()
245
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
+ }
246
292
  else if (picked.key === 'hermes') {
247
293
  const setup = await askChoice('\n Hermes profile', [
248
294
  { label: 'Clone active profile', hint: 'copies provider/config explicitly; fresh ThinkPool session history' },
@@ -277,13 +323,13 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
277
323
  { key: 'serve', label: 'Serve all my sessions', hint: 'runs here, Ctrl-C stops it' },
278
324
  { key: 'service', label: 'Always-on background service', hint: 'recommended — survives reboot, restarts on crash' },
279
325
  ]
280
- items.push({ key: 'settings', label: 'Settings', hint: `provider · account${state.hermesInstalled ? ' · Hermes profile' : ''}` })
326
+ items.push({ key: 'settings', label: 'Settings', hint: `provider · account · keep-awake ${state.keepAwake ? 'ON' : 'off'}${state.hermesInstalled ? ' · Hermes profile' : ''}` })
281
327
  items.push({ key: 'quit', label: 'Quit', hint: '' })
282
328
  const pick = items[await askChoice('choose', items)]
283
329
  if (pick.key === 'quit') return
284
- if (pick.key === 'serve') { if (await ensureLoggedIn()) await actions.serveAccountForeground() }
330
+ if (pick.key === 'serve') { if (await ensureAgentReady() && await ensureLoggedIn()) await actions.serveAccountForeground() }
285
331
  else if (pick.key === 'service') {
286
- if (await ensureLoggedIn()) {
332
+ if (await ensureAgentReady() && await ensureLoggedIn()) {
287
333
  const installed = await actions.installService({ room: null })
288
334
  if (installed === true) {
289
335
  if (!actions.relaunchLauncher || await actions.relaunchLauncher()) return
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.336",
4
- "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
3
+ "version": "0.7.338",
4
+ "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "thinkpool-pair": "bridge.mjs"
@@ -12,6 +12,7 @@
12
12
  "command-guidance.mjs",
13
13
  "abort-turn-barrier.mjs",
14
14
  "host-memory.mjs",
15
+ "keep-awake.mjs",
15
16
  "sdk-smoke.mjs",
16
17
  "sdk-admission.mjs",
17
18
  "sdk-admission.mjs",
package/service.mjs CHANGED
@@ -25,6 +25,7 @@ import { execSync } from 'node:child_process'
25
25
  import { hostMemoryAdmission } from './host-memory.mjs'
26
26
  import { readSupervisorReady, supervisorReadyMatches } from './supervisor-ready.mjs'
27
27
  import { pairCli } from './command-guidance.mjs'
28
+ import { loadKeepAwakePreference } from './keep-awake.mjs'
28
29
 
29
30
  // Service identity. Account mode has no room → a single stable id so there's
30
31
  // exactly one account service per machine (a second install replaces it).
@@ -591,7 +592,11 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
591
592
  : `PINNED to ${version} (stable local runtime; restarts do not depend on npx or the network). To update: use "Restart & update the bridge" (or run ${pairCli('install-service', room || undefined)}). Pass --auto-update to track @latest instead.`
592
593
  const removeArg = room ? ` ${room}` : ''
593
594
  const what = room ? `room ${room}` : 'your account (auto-serves every session)'
594
- process.stderr.write(` ◆ ${what}\n ◆ ${process.platform === 'darwin' ? 'Launchd is completing and verifying the reload independently.' : a.note}\n ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: ${pairCli('uninstall-service')}${removeArg}\n\n`)
595
+ const awakeNote = loadKeepAwakePreference()
596
+ ? ' ◆ Keep-awake on: system sleep is inhibited while the bridge runs (higher battery use); the display may still dim, lock, and turn off.\n'
597
+ : ''
598
+ const runtimeNote = ' ◆ Prerequisite: Claude Code, Codex, or Hermes must be installed on this machine. BYOK supplies model credentials; it does not install an agent.\n'
599
+ process.stderr.write(` ◆ ${what}\n ◆ ${process.platform === 'darwin' ? 'Launchd is completing and verifying the reload independently.' : a.note}\n${runtimeNote}${awakeNote} ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: ${pairCli('uninstall-service')}${removeArg}\n\n`)
595
600
  return true
596
601
  }
597
602
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 16,
3
+ "bundleVersion": 17,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -41,18 +41,18 @@
41
41
  },
42
42
  {
43
43
  "id": "work-routing",
44
- "version": 6,
44
+ "version": 7,
45
45
  "providerRoutingContract": "A Claude lane may select a connected Anthropic-compatible provider by durable id, unique display name, or unique configured model. Unknown or ambiguous references fail closed and must never fall through to built-in Claude.",
46
46
  "openerParityContract": "Every top-level terminal may use spawn_terminal or open_main_terminal to open every supported structured runtime: Claude, Codex, or Hermes. Runtime-specific provider/model validation remains authoritative and occurs before inference.",
47
47
  "routes": [
48
48
  {
49
49
  "id": "work-routing",
50
50
  "tools": ["spawn_terminal", "open_main_terminal", "close_terminal"],
51
- "trigger": "\\b(parallel|delegate|worker|sub[- ]?terminal|conductor|cascade|spawn_terminal|open_main_terminal|close_terminal)\\b",
52
- "prompt": "For genuinely decomposable work in a conductor-capable role, open visible worker slices with spawn_terminal, collect and verify each result with one targeted read_terminal call after completion, then close_terminal immediately. Do not poll workers or repeat the ROOM NOW roster. An explicitly requested separate Cascade/conductor uses open_main_terminal, never spawn_terminal. Review slices are structurally read-only and must use the review slice type; they never inherit autonomous bypass authority."
51
+ "trigger": "\\b(?:open|launch|start|spawn|create)\\b.{0,40}\\b(?:new|separate|independent|main|top[- ]?level)?\\s*terminals?\\b|\\b(?:new|separate|independent|main|top[- ]?level)\\s+terminals?\\b|\\b(parallel|delegate|worker|sub[- ]?terminal|ensemble|conductor|cascade|spawn_terminal|open_main_terminal|close_terminal)\\b",
52
+ "prompt": "A person-authored request to open, launch, start, spawn, or create a new, separate, independent, top-level, or main terminal uses open_main_terminal, even for ordinary work or a handoff and even when they do not say Cascade or conductor. If the wording includes new or separate terminal, main-terminal routing wins. Use spawn_terminal only for agent-decided bounded worker slices or when the person explicitly asks for a worker, sub-terminal, Ensemble lane, or delegated slice. For genuinely decomposable work in a conductor-capable role, collect and verify each worker result with one targeted read_terminal call after completion, then close_terminal immediately. Do not poll workers or repeat the ROOM NOW roster. Review slices are structurally read-only and must use the review slice type; they never inherit autonomous bypass authority."
53
53
  }
54
54
  ],
55
- "expandedPrompt": "CASCADE WORKFLOW (default for non-trivial work that genuinely decomposes; mandatory when the people ask for Cascade, tiered lanes, one-shot flow, or unattended multi-slice execution): first investigate enough to name evidence-backed slices and their dependencies, then post a short plan in the room. The current top-level/conductor terminal keeps decomposition, integration, and final judgment; use spawn_terminal only for bounded worker slices, while an explicitly requested separate conductor uses open_main_terminal. Parallelize only disjoint slices and encode dependencies instead of holding them in your head. Choose sliceType=scaffold for mechanical/search work, feature or fix for implementation, and review with a balanced capable tier for adversarial verification. Review slices are structurally read-only: they resolve to a native review/Plan mode before launch and can never inherit autonomous bypass authority. Every worker brief names the evidence/diagnosis, exact scope, observable acceptance proof, relevant repo gates, whether merge/publish is required, and a safe-skip escape hatch. ROOM NOW is the default roster and completion signals wake the conductor; do not spend the bounded read allowance polling or repeating a no-argument roster. After dispatch, remain responsible: once an owned worker is finished, make one targeted read_terminal call to collect its result, verify its claim, close_terminal immediately, and dispatch newly unblocked work. Preserve enough targeted reads for every open worker and the final adversarial review; never declare the Cascade done or yield a final result while owned workers remain uncollected. Check origin/main CI before merge-bearing waves, coordinate shared fixtures/publishers, and treat sibling pushes as context rather than proof. Run an adversarial review after builders (or pipeline review behind completed phases), finish with production-condition evidence, verify every reported SHA/version, and close every worker you opened.",
55
+ "expandedPrompt": "MAIN TERMINAL ROUTING (authoritative): a person-authored request to open, launch, start, spawn, or create a new, separate, independent, top-level, or main terminal uses open_main_terminal, even for ordinary work or a handoff and even when they do not say Cascade or conductor. If the wording includes new or separate terminal, main-terminal routing wins. spawn_terminal is only for agent-decided bounded worker slices or when the person explicitly asks for a worker, sub-terminal, Ensemble lane, or delegated slice. CASCADE WORKFLOW (default for non-trivial work that genuinely decomposes; mandatory when the people ask for Cascade, tiered lanes, one-shot flow, or unattended multi-slice execution): first investigate enough to name evidence-backed slices and their dependencies, then post a short plan in the room. The current top-level/conductor terminal keeps decomposition, integration, and final judgment; use spawn_terminal only for bounded worker slices. Parallelize only disjoint slices and encode dependencies instead of holding them in your head. Choose sliceType=scaffold for mechanical/search work, feature or fix for implementation, and review with a balanced capable tier for adversarial verification. Review slices are structurally read-only: they resolve to a native review/Plan mode before launch and can never inherit autonomous bypass authority. Every worker brief names the evidence/diagnosis, exact scope, observable acceptance proof, relevant repo gates, whether merge/publish is required, and a safe-skip escape hatch. ROOM NOW is the default roster and completion signals wake the conductor; do not spend the bounded read allowance polling or repeating a no-argument roster. After dispatch, remain responsible: once an owned worker is finished, make one targeted read_terminal call to collect its result, verify its claim, close_terminal immediately, and dispatch newly unblocked work. Preserve enough targeted reads for every open worker and the final adversarial review; never declare the Cascade done or yield a final result while owned workers remain uncollected. Check origin/main CI before merge-bearing waves, coordinate shared fixtures/publishers, and treat sibling pushes as context rather than proof. Run an adversarial review after builders (or pipeline review behind completed phases), finish with production-condition evidence, verify every reported SHA/version, and close every worker you opened.",
56
56
  "impact": [
57
57
  {"path": "bridge/bridge.mjs", "diffPattern": "spawn_terminal|open_main_terminal|close_terminal|cascadeRole|spawnDepth"},
58
58
  {"path": "bridge/lane-lifecycle.mjs"},
@@ -143,7 +143,7 @@ export function buildTerminalRolePrompt({ spawnedBy = null, spawnDepth = 0, casc
143
143
  return `TERMINAL ROLE (authoritative): You are a MISCLASSIFIED ENSEMBLE SUB-TERMINAL at spawn depth ${Math.max(1, depth)}, owned by parent terminal ${parentRef}. A spawned lane can never be a Cascade conductor. Work directly as a worker, do not spawn terminals, and report that the parent must use open_main_terminal for a separate conductor.`
144
144
  }
145
145
  if (!spawnedBy && depth === 0) {
146
- return 'TERMINAL ROLE (authoritative): You are an independent TOP-LEVEL ThinkPool terminal opened directly in the room. You are not a spawned sub-terminal or worker lane. You may conduct the current task here and open worker SUB-TERMINALS with spawn_terminal. When a person explicitly asks you to open, launch, or start a separate Cascade/conductor terminal, use open_main_terminal — never spawn_terminal. Conductors are main terminals; Ensemble-spawned lanes are workers only.'
146
+ return 'TERMINAL ROLE (authoritative): You are an independent TOP-LEVEL ThinkPool terminal opened directly in the room. You are not a spawned sub-terminal or worker lane. You may conduct the current task here and open worker SUB-TERMINALS with spawn_terminal. A person-authored request to open, launch, start, spawn, or create a new, separate, independent, top-level, or main terminal uses open_main_terminal — never spawn_terminal — even for ordinary work or a handoff. If the wording includes new or separate terminal, main-terminal routing wins. Use spawn_terminal only for agent-decided bounded worker slices or when the person explicitly asks for a worker, sub-terminal, Ensemble lane, or delegated slice.'
147
147
  }
148
148
  if (depth >= 2) {
149
149
  return `TERMINAL ROLE (authoritative): You are a LEAF SUB-TERMINAL at spawn depth ${depth}, owned by parent terminal ${parentRef}. You are not top-level and not a conductor. Complete the assigned slice directly, do not spawn another terminal, and hand evidence/results back to your parent.`