thinkpool-pair 0.7.358 → 0.7.360

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/service.mjs CHANGED
@@ -26,6 +26,7 @@ import { hostMemoryAdmission } from './host-memory.mjs'
26
26
  import { readSupervisorReady, supervisorReadyMatches } from './supervisor-ready.mjs'
27
27
  import { pairCli } from './command-guidance.mjs'
28
28
  import { loadKeepAwakePreference } from './keep-awake.mjs'
29
+ import { scheduledRunsEnabled } from './scheduled-runs.mjs'
29
30
 
30
31
  // Service identity. Account mode has no room → a single stable id so there's
31
32
  // exactly one account service per machine (a second install replaces it).
@@ -131,7 +132,7 @@ export function provisionRuntime(version, { exec = execSync, root = path.join(os
131
132
 
132
133
  // Pure artifact builder — returned shape is testable without side effects.
133
134
  // room falsy → ACCOUNT service (bare `thinkpool-pair`, auto-serves all sessions).
134
- export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate = false, version = VERSION, staleProof = false, runtimeEntry = null } = {}) {
135
+ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate = false, version = VERSION, staleProof = false, runtimeEntry = null, scheduledRuns = scheduledRunsEnabled() } = {}) {
135
136
  const npx = npxPath(platform)
136
137
  // Account mode discovers each room's own bound directory, so its supervisor must use
137
138
  // a stable home cwd. Persisting the caller's managed worktree here makes the service
@@ -161,6 +162,11 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
161
162
  // In-process self-update (account.mjs / bridge.mjs) is gated on this env — only on when
162
163
  // the user explicitly opted into auto-update.
163
164
  const autoUpdateEnv = autoUpdate ? { THINKPOOL_PAIR_AUTOUPDATE: '1' } : {}
165
+ // Persistent services rebuild a deliberately tiny environment. Preserve this
166
+ // activation only as the exact boolean true; arbitrary caller values are omitted.
167
+ const scheduledRunsEnv = scheduledRuns === true
168
+ ? { darwin: '<key>TP_SCHEDULED_RUNS_ENABLED</key><string>1</string>', linux: 'Environment=TP_SCHEDULED_RUNS_ENABLED=1\n', win32: 'set "TP_SCHEDULED_RUNS_ENABLED=1"\r\n' }
169
+ : { darwin: '', linux: '', win32: '' }
164
170
 
165
171
  if (platform === 'darwin') {
166
172
  // launchd KeepAlive supervises. Stable runtimes execute directly; the legacy npx
@@ -187,7 +193,7 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
187
193
  <key>WorkingDirectory</key><string>${xml(cwd)}</string>
188
194
  <key>StandardOutPath</key><string>${xml(log)}</string>
189
195
  <key>StandardErrorPath</key><string>${xml(log)}</string>
190
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(servicePath)}</string>${runtimeEntry ? '' : `<key>NPM_CONFIG_CACHE</key><string>${xml(npmCache)}</string>`}${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}</dict>
196
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(servicePath)}</string>${runtimeEntry ? '' : `<key>NPM_CONFIG_CACHE</key><string>${xml(npmCache)}</string>`}${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}${scheduledRunsEnv.darwin}</dict>
191
197
  </dict></plist>\n`
192
198
  // The destructive reload is intentionally NOT represented as an inline `post`
193
199
  // command. installService stages this plist and hands the transaction to an
@@ -223,7 +229,7 @@ RestartSec=2
223
229
  RestartPreventExitStatus=0
224
230
  WorkingDirectory=${cwd}
225
231
  Environment=PATH=${servicePath}
226
- ${runtimeEntry ? '' : `Environment=NPM_CONFIG_CACHE=${npmCache}\n`}${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}StandardOutput=append:${log}
232
+ ${runtimeEntry ? '' : `Environment=NPM_CONFIG_CACHE=${npmCache}\n`}${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}${scheduledRunsEnv.linux}StandardOutput=append:${log}
227
233
  StandardError=append:${log}
228
234
 
229
235
  [Install]
@@ -244,7 +250,7 @@ WantedBy=default.target
244
250
  : room
245
251
  ? ['npx', '-y', ...onlineFlag, verSpec, room, '--supervise', ...tail].join(' ')
246
252
  : ['npx', '-y', ...onlineFlag, verSpec].join(' ')
247
- const content = `@echo off\r\ntitle thinkpool-pair ${id}\r\n${runtimeEntry ? '' : `set "NPM_CONFIG_CACHE=${npmCache}"\r\n`}${inner}\r\n`
253
+ const content = `@echo off\r\ntitle thinkpool-pair ${id}\r\n${runtimeEntry ? '' : `set "NPM_CONFIG_CACHE=${npmCache}"\r\n`}${scheduledRunsEnv.win32}${inner}\r\n`
248
254
  return { file, content, logDir, post: [], note: 'Installed to the Startup folder — runs at login' + (room ? ' with --supervise (auto-restart on crash).' : ' (account mode).') + ' Start it now without rebooting by double-clicking the .cmd, or run it from a terminal.' }
249
255
  }
250
256
 
package/session-store.mjs CHANGED
@@ -292,6 +292,123 @@ export function deleteSession(room, id) {
292
292
  catch (error) { persistenceError('archive closed session snapshot', p, error); return false }
293
293
  }
294
294
 
295
+ // Scheduled outcomes use a separate host outbox from live session snapshots.
296
+ // A person may close the visible terminal while its idempotent database finish
297
+ // is retrying; keeping the intent here lets a restart complete that write without
298
+ // loadAll() resurrecting the deliberately closed terminal.
299
+ const scheduledOutcomesFile = (room) => path.join(dir(room), '.scheduled-outcomes')
300
+ const safeTerminalId = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/.test(value)
301
+ const safeRunId = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/.test(value)
302
+ const readScheduledOutcomeMap = (room) => {
303
+ try {
304
+ const value = JSON.parse(fs.readFileSync(scheduledOutcomesFile(room), 'utf8'))
305
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
306
+ } catch { return {} }
307
+ }
308
+ // Pending entries from the original format are raw intents. Once the database
309
+ // finish and recorded session snapshot are both durable, replace that raw entry
310
+ // with a versioned acknowledgement tombstone before attempting deletion. The
311
+ // tombstone is intentionally independent of the live/archived session record:
312
+ // restart recovery can therefore identify an acknowledged exact run and retry
313
+ // deletion only, without issuing another finish RPC or reclassifying its result.
314
+ const scheduledOutcomeRecord = (value) => {
315
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
316
+ if (value.v === 1 && value.state === 'acknowledged') {
317
+ const intent = value.intent
318
+ return intent && typeof intent === 'object' && !Array.isArray(intent) &&
319
+ safeRunId(intent.runId) && value.runId === intent.runId
320
+ ? { intent, acknowledged: true }
321
+ : null
322
+ }
323
+ return safeRunId(value.runId) ? { intent: value, acknowledged: false } : null
324
+ }
325
+ export function savePendingScheduledOutcome(room, terminalId, intent) {
326
+ if (!safeTerminalId(terminalId) || !intent || typeof intent !== 'object' || Array.isArray(intent) || !safeRunId(intent.runId)) return false
327
+ try {
328
+ ensureDir(room)
329
+ const pending = readScheduledOutcomeMap(room)
330
+ pending[terminalId] = intent
331
+ atomicCommit(scheduledOutcomesFile(room), JSON.stringify(pending))
332
+ return true
333
+ } catch (error) {
334
+ persistenceError('write scheduled outcome outbox', scheduledOutcomesFile(room), error)
335
+ return false
336
+ }
337
+ }
338
+ export function loadPendingScheduledOutcomes(room) {
339
+ return Object.entries(readScheduledOutcomeMap(room))
340
+ .map(([terminalId, value]) => ({ terminalId, record: scheduledOutcomeRecord(value) }))
341
+ .filter(({ terminalId, record }) => safeTerminalId(terminalId) && record)
342
+ .map(({ terminalId, record }) => ({
343
+ terminalId,
344
+ intent: record.intent,
345
+ ...(record.acknowledged ? { acknowledged: true } : {}),
346
+ }))
347
+ .slice(0, LIVE_SESSION_MAX)
348
+ }
349
+ export function loadPendingScheduledOutcome(room, terminalId) {
350
+ if (!safeTerminalId(terminalId)) return null
351
+ return scheduledOutcomeRecord(readScheduledOutcomeMap(room)[terminalId])?.intent || null
352
+ }
353
+ export function acknowledgePendingScheduledOutcome(room, terminalId, runId) {
354
+ if (!safeTerminalId(terminalId) || !safeRunId(runId)) return false
355
+ try {
356
+ const pending = readScheduledOutcomeMap(room)
357
+ const record = scheduledOutcomeRecord(pending[terminalId])
358
+ if (!record || record.intent.runId !== runId) return false
359
+ if (record.acknowledged) return true
360
+ pending[terminalId] = {
361
+ v: 1,
362
+ state: 'acknowledged',
363
+ runId,
364
+ intent: record.intent,
365
+ }
366
+ atomicCommit(scheduledOutcomesFile(room), JSON.stringify(pending))
367
+ return true
368
+ } catch (error) {
369
+ persistenceError('acknowledge scheduled outcome outbox', scheduledOutcomesFile(room), error)
370
+ return false
371
+ }
372
+ }
373
+ export function deletePendingScheduledOutcome(room, terminalId) {
374
+ if (!safeTerminalId(terminalId)) return false
375
+ try {
376
+ const pending = readScheduledOutcomeMap(room)
377
+ if (!(terminalId in pending)) return true
378
+ delete pending[terminalId]
379
+ if (Object.keys(pending).length) atomicCommit(scheduledOutcomesFile(room), JSON.stringify(pending))
380
+ else fs.rmSync(scheduledOutcomesFile(room), { force: true })
381
+ return true
382
+ } catch (error) {
383
+ persistenceError('delete scheduled outcome outbox', scheduledOutcomesFile(room), error)
384
+ return false
385
+ }
386
+ }
387
+
388
+ // A database acknowledgement is not locally committed until the corresponding
389
+ // live-session snapshot has synchronously recorded scheduleOutcomeRecorded=true
390
+ // AND the exact outbox is durably marked acknowledged. Keep deletion behind both
391
+ // barriers so a restart always sees one of:
392
+ // 1. pending outbox intent to replay idempotently;
393
+ // 2. an acknowledged outbox tombstone to delete only; or
394
+ // 3. a recorded session snapshot with no outbox.
395
+ // `flushRecordedSnapshot` is intentionally injected by bridge.mjs because only
396
+ // the live entry can produce its complete session payload.
397
+ export function commitRecordedScheduledOutcome(room, terminalId, flushRecordedSnapshot) {
398
+ let snapshotRecorded = false
399
+ try {
400
+ snapshotRecorded = typeof flushRecordedSnapshot === 'function' && flushRecordedSnapshot() === true
401
+ } catch (error) {
402
+ persistenceError('record scheduled outcome in session snapshot', path.join(dir(room), `${terminalId}.json`), error)
403
+ }
404
+ if (!snapshotRecorded) return { ok: false, snapshotRecorded: false, outboxAcknowledged: false, outboxDeleted: false }
405
+ const intent = loadPendingScheduledOutcome(room, terminalId)
406
+ const outboxAcknowledged = acknowledgePendingScheduledOutcome(room, terminalId, intent?.runId)
407
+ if (!outboxAcknowledged) return { ok: false, snapshotRecorded: true, outboxAcknowledged: false, outboxDeleted: false }
408
+ const outboxDeleted = deletePendingScheduledOutcome(room, terminalId)
409
+ return { ok: outboxDeleted, snapshotRecorded: true, outboxAcknowledged: true, outboxDeleted }
410
+ }
411
+
295
412
  // Most-recently-saved structured session for the room (drives attached restore).
296
413
  export function loadLatest(room) {
297
414
  const recs = listRecs(room).sort((a, b) => (b.savedAt || 0) - (a.savedAt || 0))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 18,
3
+ "bundleVersion": 20,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -88,13 +88,13 @@
88
88
  },
89
89
  {
90
90
  "id": "visual-proof",
91
- "version": 5,
91
+ "version": 6,
92
92
  "routes": [
93
93
  {
94
94
  "id": "visual-proof",
95
95
  "tools": ["preview_start", "preview_capture", "preview_inspect", "preview_stop"],
96
96
  "trigger": "\\b(ui|ux|visual|design|frontend|html|css|page|route|mockup|screenshot|responsive|desktop|mobile|preview)\\b",
97
- "prompt": "For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. For an intentional application-preview Design card, use the project's source-aware Design build command when one exists (for example npm run build:design); otherwise build normally and keep selector-based source matching labeled as fallback. preview_capture uses a fresh isolated browser and is inline verification evidence by default; it must not create a transcript card. Set card=true only when the person asked for a mockup/Design artifact or the visual itself is an intentional deliverable that should remain editable in the transcript. A card requires settled meaningful content at both desktop and mobile, rejects loading/public-auth fallback shells, and is delivered after the final agent response. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, ordinary room broadcasts, and refresh-token access are blocked, while non-mutating roster/transcript snapshot requests are allowed. Surface PNG evidence only when no interactive source-backed Design card is displayed."
97
+ "prompt": "For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. Omit the preview path to render the current authenticated room; pass / explicitly only when the public landing page is intended. For an intentional application-preview Design card, use the project's source-aware Design build command when one exists (for example npm run build:design); otherwise build normally and keep selector-based source matching labeled as fallback. preview_capture uses a fresh isolated browser and is inline verification evidence by default; it must not create a transcript card. Set card=true only when the person asked for a mockup/Design artifact or the visual itself is an intentional deliverable that should remain editable in the transcript. A card requires settled meaningful content at both desktop and mobile, rejects loading/public-auth fallback shells, and is delivered after the final agent response. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, ordinary room broadcasts, and refresh-token access are blocked, while non-mutating roster/transcript snapshot requests are allowed. Surface PNG evidence only when no interactive source-backed Design card is displayed."
98
98
  }
99
99
  ],
100
100
  "impact": [
@@ -188,9 +188,9 @@
188
188
  },
189
189
  {
190
190
  "id": "design-workspace",
191
- "version": 7,
192
- "interactionPrompt": "DESIGN EDITING MODEL: every complete bridge preview_capture card and every trusted source-backed mockup card offers Edit in Design. The bridge freezes a safe rendered-DOM snapshot for application previews; Design edits are then applied by the producing lane to the real application source and verified by a fresh correlated desktop+mobile capture. For source-aware application previews, the bridge validates each opaque source identity against its contained host-only map and the current source-file hash before sharing an exact source location privately with the producing lane; uninstrumented elements keep the selector-based fallback, while stale or mismatched identities fail closed. For authored HTML, the producing lane edits the canonical authored HTML source directly. Edit in Design—not ordinary Preview or unrelated artifact delivery—arms a persistent room-level Design workspace, opens the artifact directly in editable mode, and adds Design · Page name beneath the producing terminal in its Ensemble row for both partners; it creates no terminal, agent runtime, or worker slot. The virtual lane can be closed from that Ensemble row without deleting the artifact. Once armed, the Design lane reopens the active artifact at its latest verified revision without another agent turn, history search, HTML/path request, or re-selection. Design is the default canvas there and Preview is only a temporary interaction mode. Direct text, supported movement, and selected-image changes auto-stage as optimistic drafts in the bounded changes queue; queued edits can be reopened, revised, or removed before Apply. Apply changes sends the ordered batch once to the producing lane, and a successful correlated desktop+mobile render advances the verified revision for the room. Never call a staged draft saved or live. When a person asks for many mockups or options that can share a surface, prefer one source-backed multi-option board or gallery in one file and one card so they can compare together; create separate files/cards only when the person explicitly requests independently editable artifacts or the options cannot be represented faithfully together.",
193
- "turnReminder": "DESIGN ROUTE: intentional application-preview deliverables use preview_capture with card=true; correlated Design recaptures are recognized automatically. Authored HTML uses the source-backed render helper. Both must produce editable Thinkpool Design cards with verified desktop and mobile renders. Ordinary verification captures stay inline evidence and must not create cards. Cards are delivered after the final agent response so they remain the newest transcript item. Edit in Design explicitly arms the persistent Design workspace and adds its virtual Design · Page beneath the producing terminal in the Ensemble row; Preview alone does not arm it. For large direction sets, consolidate compatible options into one source-backed comparison board/gallery and one card; use separate files/cards only when independent editing is explicitly requested or technically necessary. Do not duplicate the card renders as inline PNGs. Use inline PNG evidence only when no interactive Design artifact is available.",
191
+ "version": 8,
192
+ "interactionPrompt": "DESIGN EDITING MODEL: every complete bridge preview_capture card and every trusted source-backed mockup card offers Edit in Design. The bridge freezes a safe rendered-DOM snapshot for application previews; Design edits are then applied by the producing lane to the real application source and verified by a fresh correlated desktop+mobile capture. For source-aware application previews, the bridge validates each opaque source identity against its contained host-only map and the current source-file hash before sharing an exact source location privately with the producing lane; uninstrumented elements keep the selector-based fallback, while stale or mismatched identities fail closed. For authored HTML, the producing lane edits the canonical authored HTML source directly. Edit in Design—not ordinary Preview or unrelated artifact delivery—arms a persistent room-level Design workspace, opens the artifact directly in editable mode, and adds Design · Page name beneath the producing terminal in its Ensemble row for both partners; it creates no terminal, agent runtime, or worker slot. The virtual lane can be closed from that Ensemble row without deleting the artifact. Once armed, the Design lane reopens the active artifact at its latest verified revision without another agent turn, history search, HTML/path request, or re-selection. Design is the default canvas there and Preview is only a temporary interaction mode. A remote discrete selection may show one bounded, normalized location pulse in the partner's color; it never grants authority, persists an event, or loops. Direct text, supported movement, and selected-image changes auto-stage as optimistic drafts in the bounded changes queue; queued edits can be reopened, revised, or removed before Apply. Apply changes sends the ordered batch once to the producing lane, and a successful correlated desktop+mobile render advances the verified revision for the room. Compare is available only for the exact immutable verified parent/current revision pair and uses its captured desktop or mobile images; staged, stale, cross-page, or incomplete pairs fail closed. Design visual runtimes honor live reduced motion, suspend when hidden or off-screen, cap DPR, and tear down deterministically. Never call a staged draft saved or live. When a person asks for many mockups or options that can share a surface, prefer one source-backed multi-option board or gallery in one file and one card so they can compare together; create separate files/cards only when the person explicitly requests independently editable artifacts or the options cannot be represented faithfully together.",
193
+ "turnReminder": "DESIGN ROUTE: intentional application-preview deliverables use preview_capture with card=true; correlated Design recaptures are recognized automatically. Authored HTML uses the source-backed render helper. Both must produce editable Thinkpool Design cards with verified desktop and mobile renders. Ordinary verification captures stay inline evidence and must not create cards. Cards are delivered after the final agent response so they remain the newest transcript item. Edit in Design explicitly arms the persistent Design workspace and adds its virtual Design · Page beneath the producing terminal in the Ensemble row; Preview alone does not arm it. Compare may expose only the exact immutable verified parent/current revision pair; never label a staged or incomplete capture verified. Remote selection pulses are one-shot collaboration receipts, not authority or ambient decoration. For large direction sets, consolidate compatible options into one source-backed comparison board/gallery and one card; use separate files/cards only when independent editing is explicitly requested or technically necessary. Do not duplicate the card renders as inline PNGs. Use inline PNG evidence only when no interactive Design artifact is available.",
194
194
  "impact": [
195
195
  {"path": "src/pages/code/design/"},
196
196
  {"path": "src/pages/code/structured.jsx", "diffPattern": "Edit in Design|openMockup|tp-mockup-view|sourceKnown"},
@@ -203,6 +203,8 @@
203
203
  {"path": "src/pages/code/room.jsx", "pattern": "designLaneSelected"},
204
204
  {"path": "src/pages/code/design/DesignViewer.jsx", "pattern": "createPortal\\(surface, laneHost\\)"},
205
205
  {"path": "src/pages/code/design/DesignViewer.jsx", "pattern": "Apply \\${pendingEditCount}"},
206
+ {"path": "src/pages/code/design/workspace.js", "pattern": "resolveVerifiedDesignComparison"},
207
+ {"path": "src/pages/code/design/visual-runtime.js", "pattern": "DESIGN_VISUAL_RUNTIME_CONTRACT"},
206
208
  {"path": "src/pages/code/design/queue.js", "pattern": "designBatchPayload"},
207
209
  {"path": "bridge/design-edit.mjs", "pattern": "validateDesignBatchRequest"}
208
210
  ]
package/viewport.mjs CHANGED
@@ -383,6 +383,8 @@ export class CdpBrowser {
383
383
  expression: `(() => {
384
384
  if (document.querySelector('#invitation-room-title')) return true;
385
385
  if (document.querySelector('#boot, .boot-loader')) return false;
386
+ const room = document.querySelector('[data-tp-preview-room-status]');
387
+ if (room?.dataset.tpPreviewRoomStatus === 'pending') return false;
386
388
  const loading = [...document.querySelectorAll('span')].some((node) => /^(loading session|restoring terminals…|connecting(?:…|\.\.\.))$/i.test((node.textContent || '').trim()));
387
389
  return !loading && (document.body?.innerText || '').trim().length > 1;
388
390
  })()`,
@@ -405,6 +407,7 @@ export class CdpBrowser {
405
407
  loadingSession: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim() === 'Loading session'),
406
408
  connecting: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim().toLowerCase() === 'connecting…'),
407
409
  invitation: !!document.querySelector('#invitation-room-title'),
410
+ roomStatus: document.querySelector('[data-tp-preview-room-status]')?.dataset.tpPreviewRoomStatus || null,
408
411
  heldLocks: locks.held?.length || 0,
409
412
  pendingLocks: locks.pending?.length || 0,
410
413
  };
@@ -413,7 +416,7 @@ export class CdpBrowser {
413
416
  returnByValue: true,
414
417
  }, sessionId).catch(() => ({ result: { value: {} } }))
415
418
  const d = diagnostic.result?.value || {}
416
- throw new Error(`Authenticated preview did not leave its loading state within 15 seconds (session=${d.sessionPresent ? 'present' : 'missing'}, inlineBoot=${d.inlineBoot ? 'yes' : 'no'}, loadingSession=${d.loadingSession ? 'yes' : 'no'}, connecting=${d.connecting ? 'yes' : 'no'}, authLocks=${d.heldLocks || 0}/${d.pendingLocks || 0}, invitation=${d.invitation ? 'yes' : 'no'}, reads=${authResponses.join(',') || 'none'}, local=${localResponses.join(',') || 'none'}, runtime=${runtimeExceptions.join(' | ') || 'none'}); no screenshot was created.`)
419
+ throw new Error(`Authenticated preview did not leave its loading state within 15 seconds (session=${d.sessionPresent ? 'present' : 'missing'}, inlineBoot=${d.inlineBoot ? 'yes' : 'no'}, loadingSession=${d.loadingSession ? 'yes' : 'no'}, connecting=${d.connecting ? 'yes' : 'no'}, roomStatus=${d.roomStatus || 'none'}, authLocks=${d.heldLocks || 0}/${d.pendingLocks || 0}, invitation=${d.invitation ? 'yes' : 'no'}, reads=${authResponses.join(',') || 'none'}, local=${localResponses.join(',') || 'none'}, runtime=${runtimeExceptions.join(' | ') || 'none'}); no screenshot was created.`)
417
420
  }
418
421
  }
419
422
  const settle = auth
@@ -585,9 +588,10 @@ export class CdpBrowser {
585
588
  export const sharedViewportBrowser = new CdpBrowser()
586
589
 
587
590
  export class ViewportManager {
588
- constructor({ workspaceRoot, ownerId, outbox, browser = sharedViewportBrowser, startPreviewImpl = startPreview, designContext = null, authContext = null } = {}) {
591
+ constructor({ workspaceRoot, ownerId, roomCode, outbox, browser = sharedViewportBrowser, startPreviewImpl = startPreview, designContext = null, authContext = null } = {}) {
589
592
  this.workspaceRoot = path.resolve(workspaceRoot || process.cwd())
590
593
  this.ownerId = ownerId || randomUUID()
594
+ this.roomCode = typeof roomCode === 'string' && roomCode.trim() ? roomCode.trim() : null
591
595
  this.outbox = outbox || path.join(os.tmpdir(), 'thinkpool-viewport-captures', this.ownerId)
592
596
  this.browser = browser
593
597
  this.startPreviewImpl = startPreviewImpl
@@ -623,6 +627,14 @@ export class ViewportManager {
623
627
  return this.preview
624
628
  }
625
629
 
630
+ defaultRoute() {
631
+ return this.roomCode ? `/?r=${encodeURIComponent(this.roomCode)}` : '/'
632
+ }
633
+
634
+ resolveRoute(route) {
635
+ return normalizeRoute(route ?? this.defaultRoute())
636
+ }
637
+
626
638
  pageUrl(route) {
627
639
  const preview = this.requirePreview()
628
640
  return new URL(normalizeRoute(route), preview.url).href
@@ -642,8 +654,8 @@ export class ViewportManager {
642
654
  }
643
655
  }
644
656
 
645
- async capture({ route = '/', viewports = 'both', title = 'Viewport capture', fullPage = true, waitMs = 300, card = false } = {}) {
646
- const normalizedRoute = normalizeRoute(route)
657
+ async capture({ route, viewports = 'both', title = 'Viewport capture', fullPage = true, waitMs = 300, card = false } = {}) {
658
+ const normalizedRoute = this.resolveRoute(route)
647
659
  const url = this.pageUrl(normalizedRoute)
648
660
  const names = viewports === 'both' ? ['desktop', 'mobile'] : [viewports]
649
661
  if (names.some((name) => !DEFAULT_VIEWPORTS[name])) throw new Error('viewports must be "both", "desktop", or "mobile".')
@@ -704,12 +716,12 @@ export class ViewportManager {
704
716
  await fsp.writeFile(tmp, JSON.stringify(manifest))
705
717
  await fsp.rename(tmp, manifestPath)
706
718
  }
707
- return { url, slug, manifestPath, captures, snapshotPath, cardReady }
719
+ return { url, route: normalizedRoute, slug, manifestPath, captures, snapshotPath, cardReady }
708
720
  }
709
721
 
710
- async inspect({ route = '/', viewport = 'mobile', selector, waitMs = 300 } = {}) {
722
+ async inspect({ route, viewport = 'mobile', selector, waitMs = 300 } = {}) {
711
723
  if (!DEFAULT_VIEWPORTS[viewport]) throw new Error('viewport must be "desktop" or "mobile".')
712
- const normalizedRoute = normalizeRoute(route)
724
+ const normalizedRoute = this.resolveRoute(route)
713
725
  return this.browser.inspect({
714
726
  url: this.pageUrl(normalizedRoute), viewport: DEFAULT_VIEWPORTS[viewport], selector, waitMs,
715
727
  auth: await this.previewAuth(normalizedRoute),
@@ -742,9 +754,9 @@ export function createViewportTools({ tool, z, manager }) {
742
754
  ),
743
755
  tool(
744
756
  'preview_capture',
745
- 'Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Defaults to both and full-page. Captures are inline verification evidence by default. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, and room broadcasts are blocked, apart from non-mutating roster/transcript snapshot requests. Set card=true only for a user-requested mockup or visual deliverable that should remain editable in the transcript; complete desktop+mobile settled content is then required and the card arrives after the final response.',
757
+ 'Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Omit path to preview the current authenticated room; pass / explicitly for the public landing page. Defaults to both and full-page. Captures are inline verification evidence by default. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, and room broadcasts are blocked, apart from non-mutating roster/transcript snapshot requests. Set card=true only for a user-requested mockup or visual deliverable that should remain editable in the transcript; complete desktop+mobile settled content is then required and the card arrives after the final response.',
746
758
  {
747
- path: z.string().max(500).optional().describe('route within the preview, e.g. / or /settings; never a full URL'),
759
+ path: z.string().max(500).optional().describe('route within the preview; omit for the current authenticated room, or pass / for the public landing page; never a full URL'),
748
760
  viewports: z.enum(['both', 'desktop', 'mobile']).optional(),
749
761
  title: z.string().max(120).optional(),
750
762
  fullPage: z.boolean().optional(),
@@ -754,13 +766,13 @@ export function createViewportTools({ tool, z, manager }) {
754
766
  async (args) => {
755
767
  try {
756
768
  const result = await manager.capture({
757
- route: args?.path || '/', viewports: args?.viewports || 'both', title: args?.title || 'Viewport capture',
769
+ route: args?.path, viewports: args?.viewports || 'both', title: args?.title || 'Viewport capture',
758
770
  fullPage: args?.fullPage !== false, waitMs: args?.waitMs ?? 300, card: args?.card === true,
759
771
  })
760
772
  const delivery = result.cardReady
761
773
  ? 'The complete room preview card is queued after your final response.'
762
774
  : 'This is verification evidence only; no room card was created.'
763
- const content = [{ type: 'text', text: `Captured ${Object.keys(result.captures).join(' + ')} for ${args?.path || '/'}. ${delivery}` }]
775
+ const content = [{ type: 'text', text: `Captured ${Object.keys(result.captures).join(' + ')} for ${result.route}. ${delivery}` }]
764
776
  for (const [name, capture] of Object.entries(result.captures)) {
765
777
  content.push({ type: 'text', text: `${name}: ${capture.width}×${capture.height}${capture.capped ? ' (height capped)' : ''}` })
766
778
  content.push({ type: 'image', data: capture.png.toString('base64'), mimeType: 'image/png' })
@@ -771,16 +783,16 @@ export function createViewportTools({ tool, z, manager }) {
771
783
  ),
772
784
  tool(
773
785
  'preview_inspect',
774
- 'Inspect rendered DOM state in this lane preview at an exact desktop or mobile viewport. Returns page text, document dimensions, and optional selector text/geometry without changing the page.',
786
+ 'Inspect rendered DOM state in this lane preview at an exact desktop or mobile viewport. Omit path for the current authenticated room; pass / explicitly for the public landing page. Returns page text, document dimensions, and optional selector text/geometry without changing the page.',
775
787
  {
776
- path: z.string().max(500).optional().describe('route within the preview; never a full URL'),
788
+ path: z.string().max(500).optional().describe('route within the preview; omit for the current authenticated room, or pass / for the public landing page; never a full URL'),
777
789
  viewport: z.enum(['desktop', 'mobile']).optional(),
778
790
  selector: z.string().max(500).optional(),
779
791
  waitMs: z.number().int().min(0).max(MAX_SETTLE_MS).optional(),
780
792
  },
781
793
  async (args) => {
782
794
  try {
783
- const result = await manager.inspect({ route: args?.path || '/', viewport: args?.viewport || 'mobile', selector: args?.selector, waitMs: args?.waitMs ?? 300 })
795
+ const result = await manager.inspect({ route: args?.path, viewport: args?.viewport || 'mobile', selector: args?.selector, waitMs: args?.waitMs ?? 300 })
784
796
  return textResult(JSON.stringify(result, null, 2))
785
797
  } catch (error) { return errorResult(error) }
786
798
  },