peachsim 0.1.577 → 0.1.579

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.
Files changed (178) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +11 -9
  3. package/cli/app-catalog.ts +77 -0
  4. package/cli/app-fonts.ts +4 -20
  5. package/cli/app-splash.ts +2 -18
  6. package/cli/bridge-flow-runner.ts +505 -139
  7. package/cli/cloud-client.ts +1 -4
  8. package/cli/cloud-session.ts +1 -4
  9. package/cli/cloud-test-result-files.ts +3 -0
  10. package/cli/cloud-test-tunnel.ts +2 -5
  11. package/cli/command-registry.ts +2 -0
  12. package/cli/commands/agent.ts +149 -23
  13. package/cli/commands/assert.ts +1 -4
  14. package/cli/commands/box/peach-command.ts +62 -0
  15. package/cli/commands/box/plane-share.ts +0 -70
  16. package/cli/commands/box/rnx-command.ts +1 -60
  17. package/cli/commands/box/search-command.ts +14 -0
  18. package/cli/commands/box.ts +2 -2
  19. package/cli/commands/control.ts +121 -27
  20. package/cli/commands/cpu-profile.ts +23 -10
  21. package/cli/commands/daemon.ts +1 -9
  22. package/cli/commands/detox.ts +26 -18
  23. package/cli/commands/device.ts +129 -1
  24. package/cli/commands/film.ts +25 -16
  25. package/cli/commands/flow.ts +9 -2
  26. package/cli/commands/inspect/actions.ts +75 -1
  27. package/cli/commands/inspect/core.ts +81 -2
  28. package/cli/commands/inspect/describe.ts +2 -6
  29. package/cli/commands/inspect/env.ts +1 -1
  30. package/cli/commands/inspect/settling.ts +20 -3
  31. package/cli/commands/inspect/shared.ts +1 -4
  32. package/cli/commands/inspect/sleep.ts +1 -1
  33. package/cli/commands/inspect.ts +16 -49
  34. package/cli/commands/install-desktop.ts +8 -6
  35. package/cli/commands/login.ts +3 -5
  36. package/cli/commands/maestro-generate.ts +1 -4
  37. package/cli/commands/maestro.ts +38 -21
  38. package/cli/commands/no-bridge-hint.ts +0 -14
  39. package/cli/commands/permissions.ts +2 -2
  40. package/cli/commands/platform.ts +13 -6
  41. package/cli/commands/screenshot.ts +6 -1
  42. package/cli/commands/skills.ts +1 -1
  43. package/cli/commands/test.ts +6 -2
  44. package/cli/commands/three-mode.ts +162 -19
  45. package/cli/commands/upload.ts +3 -18
  46. package/cli/current-sim.ts +0 -1
  47. package/cli/desktop-companion.ts +40 -8
  48. package/cli/drivers/playwright-provisioning.ts +1 -4
  49. package/cli/flow-export.ts +1 -8
  50. package/cli/flow-session.ts +0 -4
  51. package/cli/hints.ts +1 -16
  52. package/cli/inspect-notice-state.ts +0 -4
  53. package/cli/main.ts +2 -4
  54. package/cli/outbound-endpoints.ts +1 -24
  55. package/cli/parse-args.ts +1 -2
  56. package/cli/rnx-in-process.ts +3 -1
  57. package/cli/run-rnx-core.ts +4 -2
  58. package/cli/run-rnx.ts +18 -10
  59. package/cli/send-to-box.ts +1 -4
  60. package/cli/telemetry.ts +1 -1
  61. package/cli/ws-bridge.ts +1 -5
  62. package/detox/gestures.ts +20 -6
  63. package/detox/index.ts +379 -123
  64. package/detox/navigation-lifecycle.ts +22 -13
  65. package/dist-lib/agent-daemon-client.cjs +21 -15
  66. package/dist-lib/agent-events.cjs +1 -1
  67. package/dist-lib/agent-identity.cjs +3 -17
  68. package/dist-lib/agent-sessions.cjs +5 -4
  69. package/dist-lib/attached-projects.cjs +1 -77
  70. package/dist-lib/auth/shared-session.cjs +1 -1
  71. package/dist-lib/backend-origin.cjs +11 -1
  72. package/dist-lib/beta.cjs +1 -1
  73. package/dist-lib/beta.mjs +1 -1
  74. package/dist-lib/bridge-constants.cjs +1 -1
  75. package/dist-lib/bridge-contract-input.cjs +7 -5
  76. package/dist-lib/bridge-contract-input.mjs +7 -5
  77. package/dist-lib/bridge-contract.cjs +1 -1
  78. package/dist-lib/bridge-contract.mjs +1 -1
  79. package/dist-lib/capture-contract.cjs +30 -7
  80. package/dist-lib/capture-contract.mjs +30 -7
  81. package/dist-lib/cli-constants.cjs +1 -1
  82. package/dist-lib/cloud-contract.cjs +1 -1
  83. package/dist-lib/cloud-contract.mjs +1 -1
  84. package/dist-lib/cloud-test-args.cjs +1 -1
  85. package/dist-lib/cloud-test-args.mjs +1 -1
  86. package/dist-lib/cloud.cjs +17 -11
  87. package/dist-lib/cloud.mjs +17 -11
  88. package/dist-lib/config.cjs +1 -1
  89. package/dist-lib/detox/index.cjs +321 -122
  90. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  91. package/dist-lib/home-paths.cjs +1 -1
  92. package/dist-lib/host/bridge-host.cjs +427 -212
  93. package/dist-lib/host/fetch-proxy-handler.cjs +5 -28
  94. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  95. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  96. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  97. package/dist-lib/host/websocket-proxy.cjs +1 -1
  98. package/dist-lib/index.cjs +315 -58
  99. package/dist-lib/jump-to-source-babel.cjs +1 -1
  100. package/dist-lib/jump-to-source-native.cjs +1 -1
  101. package/dist-lib/menu.cjs +67 -31
  102. package/dist-lib/menu.mjs +64 -28
  103. package/dist-lib/metro-fingerprint-registry.cjs +8 -4
  104. package/dist-lib/metro-fingerprint-registry.mjs +6 -4
  105. package/dist-lib/metro-production-bundle.cjs +2 -2
  106. package/dist-lib/metro-production-bundle.mjs +2 -2
  107. package/dist-lib/metro.cjs +8 -4
  108. package/dist-lib/profiles.cjs +1 -1
  109. package/dist-lib/public-brand.cjs +7 -4
  110. package/dist-lib/react-native-host-modules.cjs +1 -1
  111. package/dist-lib/react-native-host-modules.mjs +1 -1
  112. package/dist-lib/render-mode.cjs +3 -13
  113. package/dist-lib/scripts/dev-server-scanner.cjs +5 -17
  114. package/dist-lib/sdk.cjs +129 -10
  115. package/dist-lib/sdk.mjs +127 -10
  116. package/dist-lib/skills.cjs +1635 -1121
  117. package/dist-lib/swift-compile.cjs +277 -70
  118. package/dist-lib/swift-compile.mjs +275 -70
  119. package/dist-lib/swift-package.cjs +17 -5
  120. package/dist-lib/swift-package.mjs +15 -4
  121. package/dist-lib/swift-submit.cjs +32 -8
  122. package/dist-lib/swift-submit.mjs +29 -7
  123. package/dist-lib/swift.cjs +30912 -577
  124. package/dist-lib/vite-kotlin.cjs +356 -0
  125. package/dist-lib/vite-swift.cjs +1743 -0
  126. package/dist-lib/vite.cjs +208 -1039
  127. package/kotlin/README.md +18 -0
  128. package/kotlin/Sources/androidx/annotation/Keep.kt +4 -0
  129. package/kotlin/Sources/dev/onejs/one/OneNativeSourceDispatch.kt +7 -0
  130. package/kotlin/Sources/dev/onejs/one/source/OneModule.kt +4 -0
  131. package/kotlin/Sources/org/json/JSON.kt +244 -0
  132. package/kotlin/toolchain.json +19 -0
  133. package/package.json +14 -2
  134. package/scripts/dev-server-scanner.ts +0 -18
  135. package/scripts/optional-demo-registry.ts +0 -17
  136. package/skills/contrast/SKILL.md +2 -2
  137. package/skills/peach-setup/SKILL.md +16 -0
  138. package/src/agent-daemon-client.ts +36 -22
  139. package/src/agent-identity.ts +0 -15
  140. package/src/agent-prompt.ts +12 -0
  141. package/src/attached-projects.ts +0 -72
  142. package/src/backend-origin.ts +10 -0
  143. package/src/bridge-contract-input.ts +2 -5
  144. package/src/bridge-contract.ts +2 -0
  145. package/src/capture-contract.ts +53 -11
  146. package/src/cloud.ts +4 -9
  147. package/src/dev-server-open.ts +0 -99
  148. package/src/home-paths.ts +2 -3
  149. package/src/host/agent-host.ts +82 -36
  150. package/src/host/bridge-host.ts +5 -11
  151. package/src/host/fetch-proxy-handler.ts +19 -43
  152. package/src/is-record.ts +4 -0
  153. package/src/menu.ts +82 -37
  154. package/src/metro-fingerprint-registry.ts +1 -4
  155. package/src/metro-production-bundle.ts +1 -1
  156. package/src/plist.ts +1 -4
  157. package/src/public-brand.ts +5 -3
  158. package/src/render-mode.ts +0 -12
  159. package/src/runtime-assets.ts +9 -2
  160. package/src/screenshots/compose.ts +7 -1
  161. package/src/screenshots/frame-compose.ts +21 -10
  162. package/src/screenshots/schema.ts +1 -4
  163. package/src/sim-client.ts +2 -4
  164. package/src/skills/builtin/maestro-test.ts +1 -4
  165. package/src/sleep.ts +4 -0
  166. package/src/swift-compile.ts +209 -47
  167. package/src/swift-icu.ts +140 -0
  168. package/src/swift-package.ts +26 -2
  169. package/src/swift-submit.ts +84 -4
  170. package/src/swift.ts +2 -0
  171. package/src/vite-plugin-kotlin.ts +376 -0
  172. package/src/vite-plugin-one.ts +224 -36
  173. package/src/vite-plugin-swift.ts +382 -96
  174. package/src/vite-plugin.ts +121 -134
  175. package/src/vite.d.ts +10 -0
  176. package/cli/resolve-assets.ts +0 -63
  177. package/src/rolldown-plugin-swift.ts +0 -244
  178. package/src/team-machine-prompt.ts +0 -73
@@ -97,9 +97,6 @@ export function setUserDataDir(dir: string | null): void {
97
97
  overrideDir = dir
98
98
  }
99
99
 
100
- /** back-compat alias retained for existing test imports. */
101
- export const __setUserDataDirForTests = setUserDataDir
102
-
103
100
  /** resolve the user-data directory where attached-projects.json lives.
104
101
  * precedence:
105
102
  * 1. explicit override from setUserDataDir() (tests, CLI bootstrap)
@@ -205,10 +202,6 @@ function newSessionId(): string {
205
202
  return `s_${randomBytes(10).toString('hex')}`
206
203
  }
207
204
 
208
- function newPreviewId(): string {
209
- return `pa_${randomBytes(10).toString('hex')}`
210
- }
211
-
212
205
  // --- project CRUD ---
213
206
 
214
207
  /** upsert by canonicalized cwd — cwd is the unique index. `input.id` is
@@ -271,30 +264,6 @@ export function findProjectById(id: string): AttachedProject | null {
271
264
  return loadStore().attachedProjects.find((p) => p.id === id) ?? null
272
265
  }
273
266
 
274
- export function findProjectByCwd(cwd: string): AttachedProject | null {
275
- const resolved = path.resolve(cwd)
276
- return loadStore().attachedProjects.find((p) => p.cwd === resolved) ?? null
277
- }
278
-
279
- export function findProjectByBundleUrl(bundleUrl: string): AttachedProject | null {
280
- // first exact match, then prefix match. the prefix path handles metro query
281
- // param drift (?platform=ios&dev=true) so the same base URL binds.
282
- const store = loadStore()
283
- const exact = store.attachedProjects.find((p) => p.knownBundleUrls.includes(bundleUrl))
284
- if (exact) return exact
285
- const base = stripQuery(bundleUrl)
286
- return (
287
- store.attachedProjects.find((p) =>
288
- p.knownBundleUrls.some((u) => stripQuery(u) === base),
289
- ) ?? null
290
- )
291
- }
292
-
293
- function stripQuery(url: string): string {
294
- const q = url.indexOf('?')
295
- return q >= 0 ? url.slice(0, q) : url
296
- }
297
-
298
267
  export function listProjects(): AttachedProject[] {
299
268
  return loadStore().attachedProjects
300
269
  }
@@ -452,47 +421,6 @@ export function deleteSession(id: string): void {
452
421
  })
453
422
  }
454
423
 
455
- // --- preview attachment CRUD ---
456
-
457
- export function upsertPreviewAttachment(
458
- input: Partial<PreviewAttachment> & { bundleUrl: string; simId: string },
459
- ): PreviewAttachment {
460
- let result!: PreviewAttachment
461
- mutateStore((store) => {
462
- const existing = store.previewAttachments.find(
463
- (pa) => pa.bundleUrl === input.bundleUrl && pa.simId === input.simId,
464
- )
465
- if (existing) {
466
- const merged: PreviewAttachment = {
467
- ...existing,
468
- ...input,
469
- lastSeenAt: Date.now(),
470
- }
471
- const idx = store.previewAttachments.indexOf(existing)
472
- store.previewAttachments[idx] = merged
473
- result = merged
474
- return
475
- }
476
- const created: PreviewAttachment = {
477
- id: input.id ?? newPreviewId(),
478
- projectId: input.projectId ?? null,
479
- bundleUrl: input.bundleUrl,
480
- simId: input.simId,
481
- deviceModel: input.deviceModel ?? 'unknown',
482
- status: input.status ?? 'connecting',
483
- lastSeenAt: Date.now(),
484
- }
485
- store.previewAttachments.push(created)
486
- result = created
487
- })
488
- return result
489
- }
490
-
491
- export function listPreviewAttachments(projectId?: string): PreviewAttachment[] {
492
- const all = loadStore().previewAttachments
493
- return projectId ? all.filter((pa) => pa.projectId === projectId) : all
494
- }
495
-
496
424
  export function deletePreviewAttachment(id: string): void {
497
425
  mutateStore((store) => {
498
426
  store.previewAttachments = store.previewAttachments.filter((pa) => pa.id !== id)
@@ -29,6 +29,16 @@ export function isLoopbackHost(hostname: string): boolean {
29
29
  )
30
30
  }
31
31
 
32
+ // the origin of a url whose host is loopback, or null for any other or unparsable url
33
+ export function loopbackOriginOf(value: string): string | null {
34
+ try {
35
+ const url = new URL(value)
36
+ return isLoopbackHost(url.hostname) ? url.origin : null
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
32
42
  export function buildBrowserAssetProxyUrl(targetUrl: string): string {
33
43
  const proxyPath = isLoopbackHost(new URL(targetUrl).hostname)
34
44
  ? '/__bundle-proxy'
@@ -13,6 +13,7 @@ import type {
13
13
  } from './bridge-contract'
14
14
  import type { SimPerformStep, SimScreenshotOptions } from './sim-client'
15
15
  import type { SootSimReplayTarget } from '@peach/globals'
16
+ import { isRecord } from './is-record'
16
17
 
17
18
  /** the caller sent a value this bridge argument cannot be read from. */
18
19
  export class BridgeInputError extends Error {
@@ -28,12 +29,8 @@ function validationFailure(path: string, expectation: string): never {
28
29
  throw new BridgeInputError(`${path} ${expectation}`)
29
30
  }
30
31
 
31
- function isRecordValue(value: unknown): value is Record<string, unknown> {
32
- return value !== null && typeof value === 'object' && !Array.isArray(value)
33
- }
34
-
35
32
  function recordValue(value: unknown, path: string): Record<string, unknown> {
36
- if (!isRecordValue(value)) {
33
+ if (!isRecord(value)) {
37
34
  validationFailure(path, 'must be an object')
38
35
  }
39
36
  return value
@@ -471,6 +471,8 @@ export interface SimSemanticNode {
471
471
  role?: string
472
472
  label?: string
473
473
  pressable?: true
474
+ /** painted but hidden from the accessibility tree, as VoiceOver sees it */
475
+ accessibilityHidden?: true
474
476
  geometry: SimSemanticGeometry
475
477
  children?: SimSemanticNode[]
476
478
  }
@@ -243,13 +243,14 @@ export function rnxRasterFrames(
243
243
  width: node.layout.width,
244
244
  height: node.layout.height,
245
245
  }
246
- const liquidGlass = node.style._liquidGlass
247
- const capturePadding =
248
- liquidGlass &&
249
- typeof liquidGlass === 'object' &&
250
- typeof Reflect.get(liquidGlass, 'capturePadding') === 'number'
251
- ? Reflect.get(liquidGlass, 'capturePadding')
252
- : 0
246
+ // a region is what the node paints, and nothing else: a producer that crops
247
+ // a composited screenshot copies every pixel inside it, so any margin past
248
+ // the paint carries neighbours (a header title, a hairline, a list row)
249
+ // frozen at their own positions over the ones the tree redraws. glass
250
+ // paints its shape plus the box shadows the material casts (shadow,
251
+ // tallShadow, contour). its capturePadding is the backdrop the shader
252
+ // samples for refraction, which it never paints, so it is not an outset.
253
+ const glassOutset = liquidGlassPaintOutset(node.style._liquidGlass, frame)
253
254
  const shadowRadius =
254
255
  typeof node.style.shadowRadius === 'number' ? node.style.shadowRadius : 0
255
256
  const shadowOffset = node.style.shadowOffset
@@ -267,19 +268,21 @@ export function rnxRasterFrames(
267
268
  : 0
268
269
  const left = Math.max(
269
270
  0,
270
- frame.x - Math.max(capturePadding, shadowRadius - shadowOffsetX),
271
+ frame.x - Math.max(glassOutset.x, shadowRadius - shadowOffsetX, 0),
271
272
  )
272
273
  const top = Math.max(
273
274
  0,
274
- frame.y - Math.max(capturePadding, shadowRadius - shadowOffsetY),
275
+ frame.y - Math.max(glassOutset.top, shadowRadius - shadowOffsetY, 0),
275
276
  )
276
277
  const right = Math.min(
277
278
  tree.width,
278
- frame.x + frame.width + Math.max(capturePadding, shadowRadius + shadowOffsetX),
279
+ frame.x + frame.width + Math.max(glassOutset.x, shadowRadius + shadowOffsetX, 0),
279
280
  )
280
281
  const bottom = Math.min(
281
282
  tree.height,
282
- frame.y + frame.height + Math.max(capturePadding, shadowRadius + shadowOffsetY),
283
+ frame.y +
284
+ frame.height +
285
+ Math.max(glassOutset.bottom, shadowRadius + shadowOffsetY, 0),
283
286
  )
284
287
  if (right > left && bottom > top) {
285
288
  frames.set(node.nodeId, {
@@ -293,6 +296,45 @@ export function rnxRasterFrames(
293
296
  return frames
294
297
  }
295
298
 
299
+ // how far a liquid glass material paints past its shape: each box shadow it
300
+ // casts (liquid-glass.ts drawLiquidGlassBoxShadow) is its rrect grown by spread,
301
+ // moved by offsetY, and blurred to about three sigma. the tall shadow only
302
+ // draws once the minor axis passes 62pt.
303
+ function liquidGlassPaintOutset(
304
+ glass: unknown,
305
+ frame: RnxCaptureRect,
306
+ ): { x: number; top: number; bottom: number } {
307
+ const outset = { x: 0, top: 0, bottom: 0 }
308
+ if (!glass || typeof glass !== 'object') return outset
309
+ const contour = Reflect.get(glass, 'contour')
310
+ const shadows = [
311
+ Reflect.get(glass, 'shadow'),
312
+ Math.min(frame.width, frame.height) > 62 ? Reflect.get(glass, 'tallShadow') : null,
313
+ ...(Array.isArray(contour) ? contour : []),
314
+ ]
315
+ for (const shadow of shadows) {
316
+ if (!shadow || typeof shadow !== 'object') continue
317
+ const sigma = Reflect.get(shadow, 'sigma')
318
+ if (typeof sigma !== 'number') continue
319
+ const offsetY = Reflect.get(shadow, 'offsetY')
320
+ const spread = Reflect.get(shadow, 'spread')
321
+ const [spreadX, spreadY] =
322
+ typeof spread === 'number'
323
+ ? [spread, spread]
324
+ : Array.isArray(spread) &&
325
+ typeof spread[0] === 'number' &&
326
+ typeof spread[1] === 'number'
327
+ ? [spread[0], spread[1]]
328
+ : [0, 0]
329
+ const dy = typeof offsetY === 'number' ? offsetY : 0
330
+ const blur = sigma * 3
331
+ outset.x = Math.max(outset.x, spreadX + blur)
332
+ outset.top = Math.max(outset.top, spreadY + blur - dy)
333
+ outset.bottom = Math.max(outset.bottom, spreadY + blur + dy)
334
+ }
335
+ return outset
336
+ }
337
+
296
338
  export function rnxCaptureRasterRegions(
297
339
  capture: RnxScreenCapture,
298
340
  ): RnxCaptureRegionRequest[] {
package/src/cloud.ts CHANGED
@@ -25,7 +25,9 @@ import {
25
25
  type RnxCloudCreateReceipt,
26
26
  type RnxCloudScopedToken,
27
27
  } from './cloud-contract'
28
+ import { isRecord } from './is-record'
28
29
  import { rnxPublicBrand } from './public-brand'
30
+ import { sleep } from './sleep'
29
31
  import type { SimStateOptions } from './bridge-contract'
30
32
 
31
33
  export {
@@ -121,10 +123,6 @@ interface RemoteSimulatorSession {
121
123
  scopes: readonly RnxCloudCommandScope[]
122
124
  }
123
125
 
124
- function isRecord(value: unknown): value is Record<string, unknown> {
125
- return value !== null && typeof value === 'object' && !Array.isArray(value)
126
- }
127
-
128
126
  function normalizeCloudOrigin(source: string): string {
129
127
  let parsed: URL
130
128
  try {
@@ -210,10 +208,6 @@ function readRetryAfterMs(response: Response, text: string): number | null {
210
208
  return null
211
209
  }
212
210
 
213
- function sleep(ms: number): Promise<void> {
214
- return new Promise((resolve) => setTimeout(resolve, ms))
215
- }
216
-
217
211
  // one fetch with the cloud retry policy: `rate_limited` waits out its
218
212
  // `retry-after` and 5xx goes again at once, each at most twice, and nothing
219
213
  // else is retried. the body is buffered only on a retryable status, so every
@@ -320,7 +314,8 @@ function readSimulatorCreate(
320
314
  claim: RnxCloudClaim
321
315
  artifact: RnxCloudBoxCreateReceipt['artifact']
322
316
  } {
323
- if (!isRecord(value)) throw new Error('Peach Cloud create returned an invalid simulator')
317
+ if (!isRecord(value))
318
+ throw new Error('Peach Cloud create returned an invalid simulator')
324
319
  const claim = readClaim(value.claim)
325
320
  if (
326
321
  typeof value.simId !== 'string' ||
@@ -1,14 +1,11 @@
1
1
  import { spawn, type ChildProcess } from 'node:child_process'
2
2
  import { createHash } from 'node:crypto'
3
3
  import fs from 'node:fs'
4
- import http from 'node:http'
5
4
  import path from 'node:path'
6
5
  import { fileURLToPath } from 'node:url'
7
6
 
8
7
  export type RnxDesktopOpenOption = boolean | { appName: string }
9
8
 
10
- const scheduledMetroOpens = new Set<string>()
11
-
12
9
  export function resolveRnxDesktopAppName(
13
10
  open: RnxDesktopOpenOption | undefined,
14
11
  projectRoot: string,
@@ -100,99 +97,3 @@ export function launchRnxDesktop(bundleUrl: string, appName: string): void {
100
97
  )
101
98
  observeRnxDesktopLaunch(child, appName)
102
99
  }
103
-
104
- interface MetroDesktopOpenOptions {
105
- port: number
106
- bundleUrl: string
107
- appName: string
108
- projectRoot: string
109
- serverId: string
110
- intervalMs?: number
111
- timeoutMs?: number
112
- launch?: (bundleUrl: string, appName: string) => void
113
- }
114
-
115
- export function openRnxDesktopWhenMetroIsReady({
116
- port,
117
- bundleUrl,
118
- appName,
119
- projectRoot,
120
- serverId,
121
- intervalMs = 100,
122
- timeoutMs = 30_000,
123
- launch = launchRnxDesktop,
124
- }: MetroDesktopOpenOptions): Promise<boolean> {
125
- const key = `${projectRoot}\0${port}`
126
- if (scheduledMetroOpens.has(key)) return Promise.resolve(false)
127
- scheduledMetroOpens.add(key)
128
- const deadline = Date.now() + timeoutMs
129
-
130
- return new Promise((resolve) => {
131
- let finished = false
132
- let retryTimer: ReturnType<typeof setTimeout> | undefined
133
- const finish = (opened: boolean) => {
134
- if (finished) return
135
- finished = true
136
- clearTimeout(retryTimer)
137
- scheduledMetroOpens.delete(key)
138
- resolve(opened)
139
- }
140
- const retry = () => {
141
- if (finished) return
142
- if (Date.now() >= deadline) {
143
- console.error(
144
- `[peach] could not open ${appName}: Metro did not become ready on port ${port}`,
145
- )
146
- finish(false)
147
- return
148
- }
149
- retryTimer = setTimeout(check, intervalMs)
150
- }
151
- const check = () => {
152
- const request = http.get(
153
- `http://127.0.0.1:${port}/__server-scan`,
154
- { headers: { Accept: 'application/json' } },
155
- (response) => {
156
- let body = ''
157
- response.setEncoding('utf8')
158
- response.on('data', (chunk: string) => {
159
- body = `${body}${chunk}`.slice(0, 65_536)
160
- })
161
- response.once('end', () => {
162
- try {
163
- const scan: unknown = JSON.parse(body)
164
- const matches =
165
- response.statusCode === 200 &&
166
- Array.isArray(scan) &&
167
- scan.some((entry: unknown) => {
168
- if (!entry || typeof entry !== 'object') return false
169
- const scannedPort = Reflect.get(entry, 'port')
170
- const projectName = Reflect.get(entry, 'projectName')
171
- const scannedServerId = Reflect.get(entry, 'rnxServerId')
172
- const scannedBundle = Reflect.get(entry, 'bundleUrl')
173
- if (
174
- scannedPort !== port ||
175
- projectName !== appName ||
176
- scannedServerId !== serverId ||
177
- typeof scannedBundle !== 'string'
178
- ) {
179
- return false
180
- }
181
- return new URL(scannedBundle).searchParams.get('rnxAppName') === appName
182
- })
183
- if (matches) {
184
- launch(bundleUrl, appName)
185
- finish(true)
186
- return
187
- }
188
- } catch {}
189
- retry()
190
- })
191
- },
192
- )
193
- request.setTimeout(Math.min(1_000, timeoutMs), () => request.destroy())
194
- request.once('error', retry)
195
- }
196
- check()
197
- })
198
- }
package/src/home-paths.ts CHANGED
@@ -432,9 +432,8 @@ export interface DaemonLockfile {
432
432
  /** epoch-ms of last heartbeat. daemons update this every ~5s; readers
433
433
  * treat the lockfile as stale if now - heartbeatAt > DAEMON_HEARTBEAT_STALE_MS. */
434
434
  heartbeatAt: number
435
- /** true while the daemon is still fetching/activating its runtime on first
436
- * boot. clients (electron splash, cli) should wait for this to become
437
- * false before treating the daemon as ready to serve. */
435
+ /** true while the daemon has no usable runtime and is fetching one on first
436
+ * boot. an installed runtime serves while an update downloads. */
438
437
  bootstrapping?: boolean
439
438
  }
440
439
 
@@ -13,6 +13,7 @@
13
13
  // { type: 'agent:event', sessionId, event }
14
14
  // { type: 'agent:session-status', session }
15
15
 
16
+ import { randomUUID } from 'node:crypto'
16
17
  import fs from 'node:fs'
17
18
  import path from 'node:path'
18
19
  import {
@@ -43,12 +44,8 @@ import {
43
44
  type AgentSession,
44
45
  type AttachedProject,
45
46
  } from '../attached-projects.ts'
46
- import {
47
- sendPromptToTeamMachineSession,
48
- teamMachineSessionIdFromCliIdentity,
49
- } from '../team-machine-prompt.ts'
50
47
  import type { AgentEvent } from '../agent-events.ts'
51
- import type { AgentPromptEnvelope } from '../agent-prompt.ts'
48
+ import type { AgentInspectMessage, AgentPromptEnvelope } from '../agent-prompt.ts'
52
49
  import type { WebSocket } from 'ws'
53
50
 
54
51
  // ws.readyState constant — avoid importing the module just for this enum.
@@ -70,15 +67,12 @@ export interface AgentHostOptions {
70
67
  * the rest of the repo uses, so `peach serve` standalone does the
71
68
  * right thing. */
72
69
  getExcludePorts?: () => number[]
73
- /** returns the active CLI lease for a sim. used only for prompt routing to
74
- * an external Team Machine agent that claimed the tab. */
75
- resolveCliLease?: (simId: string) => AgentCliLeaseSnapshot | null
76
70
  }
77
71
 
78
- export interface AgentCliLeaseSnapshot {
79
- kind: 'cli' | 'user-active'
80
- cliIdentityKey: string
81
- expiresAt: number
72
+ interface InspectListener {
73
+ socket: WebSocket
74
+ resolve: (message: AgentInspectMessage) => void
75
+ reject: (error: Error) => void
82
76
  }
83
77
 
84
78
  /** default excludes keep the dev-server scanner from hitting Contrast's own
@@ -105,6 +99,8 @@ export class AgentHost {
105
99
  // pushes so a CLI `peach agent sessions --watch` can react to state
106
100
  // changes driven by another client.
107
101
  private allSockets = new Set<WebSocket>()
102
+ private inspectQueues = new Map<string, AgentInspectMessage[]>()
103
+ private inspectListeners = new Map<string, InspectListener[]>()
108
104
  // agent wrappers also emit prompt-received from the FIFO reader, but the
109
105
  // shell/daemon already knows the user-facing display text when send-prompt
110
106
  // is accepted. emit the friendly prompt immediately and suppress the raw
@@ -139,6 +135,18 @@ export class AgentHost {
139
135
  }
140
136
  this.sessionsBySocket.delete(ws)
141
137
  }
138
+ for (const [simId, listeners] of this.inspectListeners) {
139
+ const remaining: InspectListener[] = []
140
+ for (const listener of listeners) {
141
+ if (listener.socket === ws) {
142
+ listener.reject(new AgentSessionError('DISCONNECT', 'listener disconnected'))
143
+ } else {
144
+ remaining.push(listener)
145
+ }
146
+ }
147
+ if (remaining.length > 0) this.inspectListeners.set(simId, remaining)
148
+ else this.inspectListeners.delete(simId)
149
+ }
142
150
  this.allSockets.delete(ws)
143
151
  }
144
152
 
@@ -188,6 +196,13 @@ export class AgentHost {
188
196
  }
189
197
  this.subscriptions.clear()
190
198
  this.sessionsBySocket.clear()
199
+ for (const listeners of this.inspectListeners.values()) {
200
+ for (const listener of listeners) {
201
+ listener.reject(new AgentSessionError('DISCONNECT', 'daemon closed'))
202
+ }
203
+ }
204
+ this.inspectListeners.clear()
205
+ this.inspectQueues.clear()
191
206
  this.allSockets.clear()
192
207
  }
193
208
 
@@ -208,8 +223,17 @@ export class AgentHost {
208
223
  return listSessions(msg.projectId ? String(msg.projectId) : undefined)
209
224
  case 'agent:start-session':
210
225
  return this.doStartSession(msg.input ?? {})
211
- case 'agent:send-claimed-prompt':
212
- return this.sendClaimedPrompt(msg)
226
+ case 'agent:listen-inspect':
227
+ return this.listenForInspect(ws, this.requireSimId(msg, type))
228
+ case 'agent:get-inspect-listener-status': {
229
+ const simId = this.requireSimId(msg, type)
230
+ return { simId, listening: (this.inspectListeners.get(simId)?.length ?? 0) > 0 }
231
+ }
232
+ case 'agent:send-inspect-message':
233
+ return this.sendInspectMessage(
234
+ this.requireSimId(msg, type),
235
+ this.normalizePromptEnvelope(msg),
236
+ )
213
237
  case 'agent:send-prompt': {
214
238
  const sessionId = String(msg.sessionId)
215
239
  const session = findSessionById(sessionId)
@@ -243,31 +267,53 @@ export class AgentHost {
243
267
 
244
268
  // --- operation impls ---
245
269
 
246
- private async sendClaimedPrompt(msg: any): Promise<{
247
- ok: true
248
- routed: 'team-machine'
249
- sessionId: string
250
- }> {
270
+ private requireSimId(msg: any, type: string): string {
251
271
  const simId = typeof msg.simId === 'string' ? msg.simId.trim() : ''
252
- if (!simId) {
253
- throw new AgentSessionError('NO_SIM', 'agent:send-claimed-prompt requires simId')
254
- }
255
- const lease = this.opts.resolveCliLease?.(simId) ?? null
256
- if (!lease || lease.kind !== 'cli' || lease.expiresAt <= Date.now()) {
257
- throw new AgentSessionError('NO_CLAIM', `sim ${simId} has no active CLI claim`)
258
- }
259
- const sessionId = teamMachineSessionIdFromCliIdentity(lease.cliIdentityKey)
260
- if (!sessionId) {
261
- throw new AgentSessionError(
262
- 'UNSUPPORTED_CLAIM',
263
- `sim ${simId} is claimed by a CLI identity that is not promptable`,
264
- )
272
+ if (!simId) throw new AgentSessionError('NO_SIM', `${type} requires simId`)
273
+ return simId
274
+ }
275
+
276
+ private listenForInspect(
277
+ socket: WebSocket,
278
+ simId: string,
279
+ ): Promise<AgentInspectMessage> {
280
+ const queue = this.inspectQueues.get(simId)
281
+ const queued = queue?.shift()
282
+ if (queued && queue) {
283
+ if (queue.length === 0) this.inspectQueues.delete(simId)
284
+ return Promise.resolve(queued)
265
285
  }
266
- await sendPromptToTeamMachineSession({
267
- sessionId,
268
- prompt: this.normalizePromptEnvelope(msg),
286
+ return new Promise((resolve, reject) => {
287
+ const listeners = this.inspectListeners.get(simId) ?? []
288
+ listeners.push({ socket, resolve, reject })
289
+ this.inspectListeners.set(simId, listeners)
269
290
  })
270
- return { ok: true, routed: 'team-machine', sessionId }
291
+ }
292
+
293
+ private sendInspectMessage(
294
+ simId: string,
295
+ prompt: AgentPromptEnvelope,
296
+ ): { ok: true; queued: boolean; messageId: string } {
297
+ if (!prompt.text.trim()) {
298
+ throw new AgentSessionError('EMPTY_PROMPT', 'inspect message cannot be empty')
299
+ }
300
+ const message: AgentInspectMessage = {
301
+ id: `inspect-${randomUUID()}`,
302
+ simId,
303
+ createdAt: new Date().toISOString(),
304
+ prompt,
305
+ }
306
+ const listeners = this.inspectListeners.get(simId)
307
+ const listener = listeners?.shift()
308
+ if (listener && listeners) {
309
+ if (listeners.length === 0) this.inspectListeners.delete(simId)
310
+ listener.resolve(message)
311
+ return { ok: true, queued: false, messageId: message.id }
312
+ }
313
+ const queue = this.inspectQueues.get(simId) ?? []
314
+ queue.push(message)
315
+ this.inspectQueues.set(simId, queue)
316
+ return { ok: true, queued: true, messageId: message.id }
271
317
  }
272
318
 
273
319
  private async doStartSession(input: {
@@ -484,17 +484,6 @@ export class SootSimBridgeHost {
484
484
  this.openUrlHandler = opts.openUrl
485
485
  this.agentHost = new AgentHost({
486
486
  getExcludePorts: opts.agentScanExcludes,
487
- resolveCliLease: (simId) => {
488
- const sim = this.sims.get(simId)
489
- const lease = sim ? this.getActiveLease(sim) : null
490
- return lease
491
- ? {
492
- kind: lease.kind,
493
- cliIdentityKey: lease.cliIdentityKey,
494
- expiresAt: lease.expiresAt,
495
- }
496
- : null
497
- },
498
487
  })
499
488
  this.contrastOrigin = opts.contrastOrigin?.replace(/\/$/, '') || null
500
489
  this.simIdleReapTtlMs =
@@ -535,6 +524,11 @@ export class SootSimBridgeHost {
535
524
  // seed active runtime state from disk so the http routes + lockfile
536
525
  // reflect reality at boot. we reread this on runtime:use messages.
537
526
  this.refreshActiveRuntime()
527
+ // an installed runtime can serve while the startup update runs. only a
528
+ // machine without usable assets needs to wait for that download.
529
+ this.bootstrapping =
530
+ !this.activeRuntimeDirPath ||
531
+ !fs.existsSync(path.join(this.activeRuntimeDirPath, 'index.html'))
538
532
 
539
533
  for (let attempt = 0; attempt < this.portFallbackCount; attempt++) {
540
534
  const candidate = this.preferredPort + attempt