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/account.mjs +3 -1
- package/bridge.mjs +289 -17
- package/package.json +3 -1
- package/scheduled-run-admission.mjs +364 -0
- package/scheduled-runs.mjs +268 -0
- package/service.mjs +10 -4
- package/session-store.mjs +117 -0
- package/thinkpool-capabilities.json +8 -6
- package/viewport.mjs +26 -14
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":
|
|
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":
|
|
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":
|
|
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
|
|
646
|
-
const normalizedRoute =
|
|
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
|
|
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 =
|
|
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,
|
|
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
|
|
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 ${
|
|
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
|
|
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
|
},
|