thinkpool-pair 0.7.71 → 0.7.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bridge.mjs CHANGED
@@ -39,7 +39,7 @@ import { createClient } from '@supabase/supabase-js'
39
39
  import { startClaudeSession } from './claude-session.mjs'
40
40
  import { turnInFlight } from './update-gate.mjs'
41
41
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames } from './session-store.mjs'
42
- import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast } from './event-id.mjs'
42
+ import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks } from './event-id.mjs'
43
43
  import { makeThrottledTrack } from './presence.mjs'
44
44
 
45
45
  // Public client creds (the same anon values the web app ships — safe to embed).
@@ -629,6 +629,64 @@ const ownerOutbox = (kind, id) => path.join(MOCKUP_OUTBOX, kind, id)
629
629
  // back to the old "first live session/terminal" heuristic for it alone.
630
630
  watchOutbox(MOCKUP_OUTBOX, () => [...sessions.keys()][0] || attachedId || [...terms.keys()][0])
631
631
 
632
+ // ── agent image hand-off ───────────────────────────────────────────
633
+ // A screenshot the agent produces arrives as a tool_result whose content carries
634
+ // an inline base64 image (100-600KB+). That overflows Realtime's broadcast frame —
635
+ // it can ride neither a live `code-event` nor a replay (boundEventForBroadcast
636
+ // strips it to a text placeholder so delivery survives). So BEFORE the event is
637
+ // logged/broadcast we lift each image OUT: upload the bytes to Storage (mirrors the
638
+ // mockup path → /api/code-image, service-key write) and rewrite the block to a tiny
639
+ // { type:'image', path } ref. The persisted log + every replay then carry the path,
640
+ // never base64; the web client mints a signed URL on render (structured.jsx, same
641
+ // as the mockup thumbs). Spec: docs/specs/2026-06-26-code-agent-images.md
642
+ async function uploadCodeImage(term, cid, im) {
643
+ const headers = { 'Content-Type': 'application/json' }
644
+ if (codeAuthToken) headers.Authorization = `Bearer ${codeAuthToken}`
645
+ const res = await fetch(`${WEB_BASE}/api/code-image`, {
646
+ method: 'POST',
647
+ headers,
648
+ body: JSON.stringify({ code: room, term, cid, idx: im.idx, mediaType: im.mediaType, b64: im.b64 }),
649
+ })
650
+ if (!res.ok) throw new Error(`code-image ${res.status}`)
651
+ const { path } = await res.json()
652
+ if (!path) throw new Error('code-image: no path')
653
+ return path
654
+ }
655
+ // Defer an image-bearing tool_result behind a per-session upload chain (preserves
656
+ // arrival order among images), then log + broadcast the URL-only event through the
657
+ // normal path. pushLog is the single seq-assignment point, so the deferred event
658
+ // still gets a contiguous seq at chain-resolution time; its ts was stamped at
659
+ // arrival, so it sorts into place. On upload failure the block degrades to the same
660
+ // render-safe text placeholder — base64 is NEVER logged, broadcast, or persisted.
661
+ function deferImageEvent(entry, id, evt, imgs, emitTail) {
662
+ entry.imageChain = (entry.imageChain || Promise.resolve()).then(async () => {
663
+ for (const im of imgs) {
664
+ try {
665
+ const path = await uploadCodeImage(id, evt.cid, im)
666
+ evt.content[im.idx] = { type: 'image', path, mediaType: im.mediaType }
667
+ } catch (e) {
668
+ process.stderr.write(`\n ◇ screenshot upload failed (${e?.message || e}).\n`)
669
+ evt.content[im.idx] = { type: 'text', text: '[screenshot omitted — upload failed]' }
670
+ }
671
+ }
672
+ emitTail(evt)
673
+ }).catch(() => {})
674
+ }
675
+ // Fire-and-forget: drop a closed term's screenshots from Storage so they don't
676
+ // outlive the room (COGS). Per-term scope — never touches a sibling term's images.
677
+ function fireImageCleanup(term) {
678
+ if (!term) return
679
+ try {
680
+ const headers = { 'Content-Type': 'application/json' }
681
+ if (codeAuthToken) headers.Authorization = `Bearer ${codeAuthToken}`
682
+ fetch(`${WEB_BASE}/api/code-image-cleanup`, {
683
+ method: 'POST',
684
+ headers,
685
+ body: JSON.stringify({ code: room, term }),
686
+ }).catch(() => { /* best-effort — the TTL sweep mops up orphans */ })
687
+ } catch { /* noop */ }
688
+ }
689
+
632
690
  // ── shared-PTY geometry ───────────────────────────────────────────
633
691
  // The attached PTY is CAPPED at 120×32, never pinned (2026-06-11).
634
692
  // Mangling physics: a PTY *wider/taller* than the host's window forces
@@ -851,10 +909,23 @@ function openStructured({ id, model, resume, log, commands, mode }) {
851
909
  entry.compacting = false
852
910
  bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
853
911
  }
854
- if (!chrome) pushLog(entry, evt)
855
- bcast('code-event', { term: id, evt })
856
- printLocal(evt)
857
- if (!chrome) persist()
912
+ // Single tail: stamp into the log (transcript kinds only), broadcast live,
913
+ // print locally, persist. Shared so a deferred image event re-enters it once
914
+ // its base64 has been lifted to a Storage path (see deferImageEvent).
915
+ const emitTail = (e) => {
916
+ if (!chrome) pushLog(entry, e)
917
+ bcast('code-event', { term: id, evt: e })
918
+ printLocal(e)
919
+ if (!chrome) persist()
920
+ }
921
+ // A tool_result carrying an inline base64 image can't ride a broadcast frame
922
+ // (live OR replay). Lift it to Storage FIRST, then emit the URL-only event —
923
+ // so the persisted log + replay carry a path, never base64. Order/seq stay
924
+ // intact: pushLog (inside emitTail) is the single seq point, called when the
925
+ // upload resolves. Other kinds emit synchronously, unchanged.
926
+ const imgs = inlineImageBlocks(evt)
927
+ if (imgs.length) { deferImageEvent(entry, id, evt, imgs, emitTail); return }
928
+ emitTail(evt)
858
929
  },
859
930
  requestPermission: (req) => new Promise((resolve) => {
860
931
  // Keep the broadcast payload with the resolver so a reconnect can re-send it
@@ -902,6 +973,7 @@ function endStructured(id) {
902
973
  try { s.mockupWatcher?.close() } catch { /* noop */ }
903
974
  sessions.delete(id)
904
975
  bcast('term-exit', { id })
976
+ fireImageCleanup(id) // drop this term's screenshots from Storage (COGS)
905
977
  }
906
978
  deleteSession(room, id) // remove <id>.json so loadAll() won't bring it back
907
979
  if (s) announce()
package/event-id.mjs CHANGED
@@ -157,3 +157,23 @@ export function boundEventForBroadcast(evt, cap = PER_EVENT_CAP) {
157
157
  out._bounded = true
158
158
  return out
159
159
  }
160
+
161
+ // Detect inline base64 IMAGE blocks on a tool_result event (a screenshot the agent
162
+ // produced — content:[{type:'image', source:{type:'base64', media_type, data}}]).
163
+ // Returns [{ idx, b64, mediaType }] per base64 image block, or [] when none. Pure +
164
+ // import-safe so bridge/test-seq.mjs can exercise it. The bridge uses this to lift
165
+ // images OUT of the event (upload to Storage, swap to a {type:'image', path} ref)
166
+ // BEFORE the base64 ever enters the persisted log or a broadcast frame — so a
167
+ // screenshot renders live AND in replay instead of being stripped to a placeholder
168
+ // by boundEventForBroadcast (which stays the fallback for any block we don't lift).
169
+ export function inlineImageBlocks(evt) {
170
+ if (!evt || evt.kind !== 'tool_result' || !Array.isArray(evt.content)) return []
171
+ const out = []
172
+ evt.content.forEach((b, idx) => {
173
+ if (b && b.type === 'image' && b.source && b.source.type === 'base64' &&
174
+ typeof b.source.data === 'string' && b.source.data.length) {
175
+ out.push({ idx, b64: b.source.data, mediaType: b.source.media_type || 'image/png' })
176
+ }
177
+ })
178
+ return out
179
+ }
package/launcher.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
  import os from 'node:os'
14
14
  import fs from 'node:fs'
15
15
  import path from 'node:path'
16
+ import { execSync } from 'node:child_process'
16
17
 
17
18
  const HOME = os.homedir()
18
19
  const CFG_DIR = path.join(HOME, '.thinkpool-pair')
@@ -48,6 +49,18 @@ function serviceFile(room) {
48
49
  return path.join(startup, `thinkpool-pair-${room || 'account'}.cmd`)
49
50
  }
50
51
 
52
+ // Ground truth for the header: is the service actually LOADED, not just a plist on disk?
53
+ // `launchctl load` can silently no-op, leaving a plist that never loads — file-existence
54
+ // would report "installed" while the bridge is off (the 2026-06-26 bug). Query the manager.
55
+ function serviceLoaded(room) {
56
+ const label = room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.thinkpool.pair.account'
57
+ try {
58
+ if (process.platform === 'darwin') { execSync(`launchctl print gui/$(id -u)/${label}`, { stdio: 'ignore' }); return true }
59
+ if (process.platform === 'linux') { execSync(`systemctl --user is-active --quiet ${label}.service`); return true }
60
+ return fs.existsSync(serviceFile(room)) // Windows: no daemon, file presence is the only proxy
61
+ } catch { return false }
62
+ }
63
+
51
64
  export function detectState() {
52
65
  const auth = readJson(path.join(CFG_DIR, 'auth.json'))
53
66
  const provider = readJson(path.join(CFG_DIR, 'provider.json'))
@@ -60,7 +73,7 @@ export function detectState() {
60
73
  provider: isCustom ? `Custom (${provider.baseUrl || 'endpoint'})` : 'Anthropic (default)',
61
74
  providerModel: isCustom ? (provider.model || null) : null,
62
75
  agents: KNOWN_AGENTS.filter(a => onPath(a.cmd)),
63
- accountSvc: fs.existsSync(serviceFile(null)),
76
+ accountSvc: serviceLoaded(null),
64
77
  cwd: process.cwd(),
65
78
  }
66
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.71",
3
+ "version": "0.7.73",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
package/service.mjs CHANGED
@@ -79,7 +79,13 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
79
79
  <key>StandardErrorPath</key><string>${xml(log)}</string>
80
80
  <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || '')}</string>${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}</dict>
81
81
  </dict></plist>\n`
82
- return { file, content, logDir, post: [`launchctl unload "${file}" 2>/dev/null; launchctl load "${file}"`], note: 'Loaded as a LaunchAgent — starts at login, restarts on crash.' }
82
+ // Modern bootstrap/enable, NOT the deprecated `launchctl load`. On current macOS
83
+ // (Darwin 24/25) `load` silently no-ops for a GUI LaunchAgent — the plist lands on
84
+ // disk but never loads, so the bridge is "installed" yet off (the 2026-06-26 bug).
85
+ // bootout first (idempotent clean reinstall), enable to clear any stale `disabled`
86
+ // override left by a prior bootout (the account-storm cleanup), then bootstrap = load.
87
+ const dom = `gui/$(id -u)`
88
+ return { file, content, logDir, post: [`launchctl bootout ${dom}/${label(room)} 2>/dev/null; launchctl enable ${dom}/${label(room)}; launchctl bootstrap ${dom} "${file}"`], note: 'Loaded as a LaunchAgent — starts at login, restarts on crash.' }
83
89
  }
84
90
 
85
91
  if (platform === 'linux') {
@@ -173,18 +179,34 @@ export function restartService(room) {
173
179
  }
174
180
  }
175
181
 
176
- // Is a boot-persistent service already installed? (artifact file present.)
177
- // room falsy → checks the ACCOUNT service. Lets the main run skip the offer +
178
- // avoid starting a second bridge that would race the service.
182
+ // Ground truth: is the OS service manager ACTUALLY running our service right now?
183
+ // A leftover plist/unit on disk does NOT mean it's loaded — `launchctl load` can
184
+ // silently no-op (the 2026-06-26 "says installed but it's off" bug), and a unit can
185
+ // be left disabled. Ask the manager, don't trust file presence. room falsy → account.
186
+ export function serviceActive(room) {
187
+ const plat = process.platform
188
+ const id = label(room)
189
+ try {
190
+ if (plat === 'darwin') { execSync(`launchctl print gui/$(id -u)/${id}`, { stdio: 'ignore' }); return true }
191
+ if (plat === 'linux') { execSync(`systemctl --user is-active --quiet ${id}.service`); return true }
192
+ // Windows has no managed daemon — the Startup-folder script's presence is the only proxy.
193
+ return fs.existsSync(buildArtifact(plat, { room }).file)
194
+ } catch { return false }
195
+ }
196
+
197
+ // Is a boot-persistent service installed AND loaded? room falsy → the ACCOUNT service.
198
+ // Lets the main run skip the offer + avoid starting a second bridge that races the
199
+ // service. Reflects launchd/systemd reality, not just file existence (so a plist that
200
+ // failed to load reads as not-installed → the menu re-offers install, which self-heals).
179
201
  export function isServiceInstalled(room) {
180
- try { return fs.existsSync(buildArtifact(process.platform, { room }).file) } catch { return false }
202
+ return serviceActive(room)
181
203
  }
182
204
 
183
205
  export function uninstallService(room) {
184
206
  const plat = process.platform
185
207
  let a
186
208
  try { a = buildArtifact(plat, { room }) } catch (e) { process.stderr.write(` ⚠ ${e.message}\n`); return }
187
- if (plat === 'darwin') { try { execSync(`launchctl unload "${a.file}" 2>/dev/null`) } catch { /* noop */ } }
209
+ if (plat === 'darwin') { try { execSync(`launchctl bootout gui/$(id -u)/${label(room)} 2>/dev/null`) } catch { /* noop */ } }
188
210
  if (plat === 'linux') { try { execSync(`systemctl --user disable --now ${label(room)}.service 2>/dev/null`) } catch { /* noop */ } }
189
211
  try { fs.rmSync(a.file, { force: true }); process.stderr.write(` ◆ removed ${a.file}\n`) } catch (e) { process.stderr.write(` ⚠ ${e.message}\n`) }
190
212
  }