peachsim 0.1.899 → 0.1.908

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 (103) hide show
  1. package/README.md +8 -0
  2. package/cli/app-fonts.ts +27 -2
  3. package/cli/commands/box/bundle-plane.ts +13 -4
  4. package/cli/commands/box.ts +22 -1
  5. package/cli/commands/control.ts +29 -16
  6. package/cli/commands/flow.ts +8 -1
  7. package/cli/commands/platform.ts +15 -2
  8. package/cli/commands/skills.ts +125 -10
  9. package/cli/commands/upload.ts +53 -39
  10. package/cli/flow-live-status.ts +11 -6
  11. package/detox/expectations.ts +22 -15
  12. package/detox/index.ts +4 -19
  13. package/dist-lib/agent-daemon-client.cjs +1 -1
  14. package/dist-lib/agent-events.cjs +1 -1
  15. package/dist-lib/agent-identity.cjs +1 -1
  16. package/dist-lib/agent-sessions.cjs +1 -1
  17. package/dist-lib/attached-projects.cjs +1 -1
  18. package/dist-lib/auth/shared-session.cjs +1 -1
  19. package/dist-lib/backend-origin.cjs +1 -1
  20. package/dist-lib/beta.cjs +1 -1
  21. package/dist-lib/beta.mjs +1 -1
  22. package/dist-lib/bridge-constants.cjs +1 -1
  23. package/dist-lib/bridge-contract-input.cjs +1 -1
  24. package/dist-lib/bridge-contract-input.mjs +1 -1
  25. package/dist-lib/bridge-contract.cjs +1 -1
  26. package/dist-lib/bridge-contract.mjs +1 -1
  27. package/dist-lib/bundle-channel.cjs +8 -5
  28. package/dist-lib/capture-contract.cjs +1 -1
  29. package/dist-lib/capture-contract.mjs +1 -1
  30. package/dist-lib/cli-constants.cjs +1 -1
  31. package/dist-lib/cloud-contract.cjs +1 -1
  32. package/dist-lib/cloud-contract.mjs +1 -1
  33. package/dist-lib/cloud-test-args.cjs +1 -1
  34. package/dist-lib/cloud-test-args.mjs +1 -1
  35. package/dist-lib/cloud.cjs +1 -1
  36. package/dist-lib/cloud.mjs +1 -1
  37. package/dist-lib/config.cjs +1 -1
  38. package/dist-lib/detox/index.cjs +15 -18
  39. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  40. package/dist-lib/expo-swift-bundle.cjs +8 -5
  41. package/dist-lib/expo-swift-metadata.cjs +1 -1
  42. package/dist-lib/expo-swift-metadata.mjs +1 -1
  43. package/dist-lib/home-paths.cjs +1 -1
  44. package/dist-lib/host/bridge-host.cjs +8 -5
  45. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  46. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  47. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  48. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  49. package/dist-lib/host/websocket-proxy.cjs +1 -1
  50. package/dist-lib/index.cjs +1 -1
  51. package/dist-lib/jump-to-source-babel.cjs +1 -1
  52. package/dist-lib/jump-to-source-native.cjs +1 -1
  53. package/dist-lib/kotlin-host.cjs +1 -1
  54. package/dist-lib/menu.cjs +1 -1
  55. package/dist-lib/menu.mjs +1 -1
  56. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  57. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  58. package/dist-lib/metro-production-bundle.cjs +1 -1
  59. package/dist-lib/metro-production-bundle.mjs +1 -1
  60. package/dist-lib/metro.cjs +1 -1
  61. package/dist-lib/native-app-identity.cjs +1 -1
  62. package/dist-lib/native-app-identity.mjs +1 -1
  63. package/dist-lib/native-firebase-metadata.cjs +1 -1
  64. package/dist-lib/native-firebase-metadata.mjs +1 -1
  65. package/dist-lib/plist.cjs +1 -1
  66. package/dist-lib/plist.mjs +1 -1
  67. package/dist-lib/profiles.cjs +1 -1
  68. package/dist-lib/public-brand.cjs +1 -1
  69. package/dist-lib/react-native-host-modules.cjs +1 -1
  70. package/dist-lib/react-native-host-modules.mjs +1 -1
  71. package/dist-lib/render-mode.cjs +1 -1
  72. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  73. package/dist-lib/sdk.cjs +1 -1
  74. package/dist-lib/sdk.mjs +1 -1
  75. package/dist-lib/skills.cjs +136 -25
  76. package/dist-lib/swift-compile.cjs +1 -1
  77. package/dist-lib/swift-compile.mjs +1 -1
  78. package/dist-lib/swift-package.cjs +1 -1
  79. package/dist-lib/swift-package.mjs +1 -1
  80. package/dist-lib/swift-submit.cjs +1 -1
  81. package/dist-lib/swift-submit.mjs +1 -1
  82. package/dist-lib/swift-toolchain.cjs +1 -1
  83. package/dist-lib/swift.cjs +1 -1
  84. package/dist-lib/vite-swift.cjs +1 -1
  85. package/dist-lib/vite.cjs +1 -1
  86. package/dist-lib/worklet-executable.cjs +1 -1
  87. package/dist-lib/worklet-executable.mjs +1 -1
  88. package/kotlin/fork/NOTICE +3 -0
  89. package/kotlin/fork/README.md +10 -2
  90. package/kotlin/fork/build.sh +14 -14
  91. package/kotlin/fork/compose-worker-globals.patch +105 -0
  92. package/kotlin/fork/fork.json +3 -3
  93. package/kotlin/fork/publish.ts +8 -1
  94. package/kotlin/fork/skia-android-shaping.patch +30 -0
  95. package/kotlin/fork/skiko-local.init.gradle +10 -0
  96. package/kotlin/fork/skiko-worker-clock.patch +17 -0
  97. package/kotlin/toolchain.json +9 -9
  98. package/package.json +1 -1
  99. package/skills/peach-debug/SKILL.md +15 -153
  100. package/skills/peach-debug/references/accessibility.md +102 -0
  101. package/skills/peach-debug/references/commands.md +56 -0
  102. package/src/expo-swift-host.ts +15 -4
  103. package/src/vite-plugin.ts +3 -3
package/README.md CHANGED
@@ -215,6 +215,14 @@ bridge always addresses the **shell worker**, never an undifferentiated sim.
215
215
  own dev server. It is used to build the engine/shell and to load external RN
216
216
  apps — it is **not** the published `peachsim/vite` export.
217
217
 
218
+ Preview uploads retain one response snapshot in the simulator and transfer
219
+ its base64 bodies in bounded slices, including authenticated API responses.
220
+ The CLI removes that temporary snapshot after transfer. A completed upload
221
+ publishes its trace URL through a fresh bridge connection because compression
222
+ and HTTP upload can outlive the interactive socket's idle lifetime.
223
+ The finished test duration uses the last execution status timestamp; publishing
224
+ the later trace URL notifies viewers without extending that duration.
225
+
218
226
  ## Key components
219
227
 
220
228
  | component | role | key files |
package/cli/app-fonts.ts CHANGED
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module'
4
4
  import path from 'node:path'
5
5
  import {
6
6
  expoFontPathsFromPlugins,
7
+ nativeLinkedFontSpecsFromPaths,
7
8
  metroFontSpecsFromPaths,
8
9
  type ConfigPluginFontSpec,
9
10
  } from 'peach-engine/engine/config-plugin-fonts'
@@ -27,7 +28,14 @@ export function discoverNativeLinkedAppFonts(opts: {
27
28
  }): DiscoveredAppFontSpec[] {
28
29
  const metroOrigin = loopbackOriginOf(opts.bundleUrl)
29
30
  if (!metroOrigin) return []
31
+ return metroFontSpecsFromPaths(discoverNativeLinkedAppFontPaths(opts), metroOrigin)
32
+ }
30
33
 
34
+ function discoverNativeLinkedAppFontPaths(opts: {
35
+ projectDir: string
36
+ platform?: string
37
+ bundleUrl?: string
38
+ }): string[] {
31
39
  const project = resolveAppProject(opts.projectDir)
32
40
  const selectedAppDir = project?.appDir ?? null
33
41
  if (!selectedAppDir) return []
@@ -142,7 +150,7 @@ export function discoverNativeLinkedAppFonts(opts: {
142
150
  // the bundle path is the entry relative to metro's root, so a bundle at
143
151
  // /apps/app/index.bundle served for /repo/apps/app puts that root at /repo.
144
152
  const bundleDir = path.posix
145
- .dirname(new URL(opts.bundleUrl).pathname)
153
+ .dirname(opts.bundleUrl ? new URL(opts.bundleUrl).pathname : '/')
146
154
  .replace(/^\/+/, '')
147
155
  const appRelativeDir = bundleDir.split('/').join(path.sep)
148
156
  const metroRootCandidates = [selectedAppDir]
@@ -258,7 +266,24 @@ export function discoverNativeLinkedAppFonts(opts: {
258
266
  } catch {}
259
267
  }
260
268
 
261
- return metroFontSpecsFromPaths(fontPaths, metroOrigin)
269
+ return [...new Set(fontPaths)]
270
+ }
271
+
272
+ // prebuilt guests cannot discover files linked into their native binary.
273
+ // carry the same declarations as self-contained asset urls beside their JS.
274
+ export function readNativeLinkedAppFontsWire(opts: {
275
+ projectDir: string
276
+ platform?: string
277
+ }): string {
278
+ if (!resolveAppProject(opts.projectDir)) {
279
+ throw new Error(`native font project is unavailable: ${opts.projectDir}`)
280
+ }
281
+ return encodeAppFontsWire(
282
+ nativeLinkedFontSpecsFromPaths(discoverNativeLinkedAppFontPaths(opts), (fontPath) => {
283
+ const bytes = fs.readFileSync(path.resolve(opts.projectDir, fontPath))
284
+ return `data:${fontContentType(fontPath)};base64,${bytes.toString('base64')}`
285
+ }),
286
+ )
262
287
  }
263
288
 
264
289
  // resolve a metro dev server's project root. Expo's middleware advertises it
@@ -10,6 +10,7 @@
10
10
 
11
11
  import {
12
12
  PREBUILT_BUNDLE_DEVICE_PATH,
13
+ prebuiltAppFontsPartPath,
13
14
  PREBUILT_BUNDLE_PART_BYTES,
14
15
  prebuiltBundlePartPath,
15
16
  } from '@peach/box/template'
@@ -19,16 +20,24 @@ export class BundleFilePlane implements ProjectFilePlane {
19
20
  private readonly parts = new Map<string, Uint8Array>()
20
21
  private readonly entries = new Map<string, ProjectFileMeta>()
21
22
 
22
- constructor(bundle: Uint8Array, device: string | null) {
23
+ constructor(bundle: Uint8Array, device: string | null, appFonts = '') {
23
24
  if (bundle.byteLength === 0) throw new Error('a Box bundle cannot be empty')
25
+ if (appFonts)
26
+ this.recordParts(new TextEncoder().encode(appFonts), prebuiltAppFontsPartPath)
24
27
  if (device) this.record(PREBUILT_BUNDLE_DEVICE_PATH, new TextEncoder().encode(device))
28
+ this.recordParts(bundle, prebuiltBundlePartPath)
29
+ }
30
+
31
+ private recordParts(content: Uint8Array, partPath: (index: number) => string): void {
25
32
  for (
26
33
  let offset = 0, index = 0;
27
- offset < bundle.byteLength;
34
+ offset < content.byteLength;
28
35
  offset += PREBUILT_BUNDLE_PART_BYTES, index++
29
36
  ) {
30
- const part = bundle.subarray(offset, offset + PREBUILT_BUNDLE_PART_BYTES)
31
- this.record(prebuiltBundlePartPath(index), part)
37
+ this.record(
38
+ partPath(index),
39
+ content.subarray(offset, offset + PREBUILT_BUNDLE_PART_BYTES),
40
+ )
32
41
  }
33
42
  }
34
43
 
@@ -30,8 +30,10 @@ import {
30
30
  heartbeatSession,
31
31
  resolveCloudBoxReference,
32
32
  } from 'peach-cloud-box/client'
33
+ import { getRuntimePlatform, isDeviceModel } from 'peach-engine/settings/devices'
33
34
  import { readDefaultBox, writeDefaultBox } from '../../src/home-paths'
34
35
  import { rnxPublicBrand } from '../../src/public-brand'
36
+ import { readNativeLinkedAppFontsWire } from '../app-fonts'
35
37
  import { authHeaderOrExit, cloudAccountIdOrExit, type CliAuth } from '../auth'
36
38
  import { produceCloudBoxArtifact, type CloudBoxArtifactProduction } from '../cloud-client'
37
39
  import { openUrl } from '../open-url'
@@ -523,6 +525,7 @@ function cloudBoxClient(options: {
523
525
  */
524
526
  export async function createPrebuiltBundleBox(options: {
525
527
  bundle: Uint8Array
528
+ nativeProjectDir?: string
526
529
  // the simulator's device model, or null for the Box page's default
527
530
  device: string | null
528
531
  name: string
@@ -536,7 +539,19 @@ export async function createPrebuiltBundleBox(options: {
536
539
  name: options.name,
537
540
  stack: prebuiltBundleBoxTemplate.stack,
538
541
  template: BOX_TEMPLATES.resolve({ name: prebuiltBundleBoxTemplate.name }).provenance,
539
- source: new BundleFilePlane(options.bundle, options.device),
542
+ source: new BundleFilePlane(
543
+ options.bundle,
544
+ options.device,
545
+ options.nativeProjectDir
546
+ ? readNativeLinkedAppFontsWire({
547
+ projectDir: options.nativeProjectDir,
548
+ platform:
549
+ options.device && isDeviceModel(options.device)
550
+ ? getRuntimePlatform(options.device)
551
+ : 'ios',
552
+ })
553
+ : '',
554
+ ),
540
555
  ...(options.secrets ? { secrets: options.secrets } : {}),
541
556
  })
542
557
  }
@@ -576,6 +591,11 @@ async function runCloudBoxCreate(
576
591
  // --bundle IS the template choice: the box runs an app that is already
577
592
  // built, so naming a second template would ask for two different boxes.
578
593
  const bundlePath = flag(args, 'bundle')
594
+ const nativeProjectDir = flag(args, 'native-project')
595
+ if (nativeProjectDir && !bundlePath) {
596
+ console.error(` ${peach} box create: --native-project requires --bundle.`)
597
+ return 1
598
+ }
579
599
  const namedTemplate = flag(args, 'template')
580
600
  if (bundlePath && namedTemplate) {
581
601
  console.error(
@@ -687,6 +707,7 @@ async function runCloudBoxCreate(
687
707
  ? await createPrebuiltBundleBox({
688
708
  ...connection,
689
709
  bundle: source.bundle,
710
+ nativeProjectDir,
690
711
  device: null,
691
712
  name,
692
713
  ...(secrets ? { secrets } : {}),
@@ -241,19 +241,30 @@ export async function ensureBridgeForOpen(
241
241
  }
242
242
 
243
243
  const { executable, prefixArgs } = rnxSelfInvocation()
244
- const child = spawn(
245
- executable,
246
- [...prefixArgs, 'serve', '--quiet', '--port', String(preferredPort)],
247
- {
248
- detached: true,
249
- stdio: 'ignore',
250
- // this bridge does not supply the shell, so it needs no runtime updates.
251
- env:
252
- runtimeSource === 'external'
253
- ? { ...process.env, PEACH_UPDATE_CHECK: 'off' }
254
- : process.env,
255
- },
244
+ // the bridge outlives open, so it must not hold the command's captured stderr.
245
+ const bridgeLogPath = path.join(
246
+ os.tmpdir(),
247
+ `peach-bridge-${Date.now()}-${process.pid}.log`,
256
248
  )
249
+ const bridgeLogFd = fs.openSync(bridgeLogPath, 'a')
250
+ let child: ReturnType<typeof spawn>
251
+ try {
252
+ child = spawn(
253
+ executable,
254
+ [...prefixArgs, 'serve', '--quiet', '--port', String(preferredPort)],
255
+ {
256
+ detached: true,
257
+ stdio: ['ignore', 'ignore', bridgeLogFd],
258
+ // this bridge does not supply the shell, so it needs no runtime updates.
259
+ env:
260
+ runtimeSource === 'external'
261
+ ? { ...process.env, PEACH_UPDATE_CHECK: 'off' }
262
+ : process.env,
263
+ },
264
+ )
265
+ } finally {
266
+ fs.closeSync(bridgeLogFd)
267
+ }
257
268
  if (resources) {
258
269
  resources.bridge = child
259
270
  resources.port = preferredPort
@@ -298,14 +309,16 @@ export async function ensureBridgeForOpen(
298
309
  }
299
310
  return selectedPort
300
311
  }
301
- if (childExit) {
302
- throw new Error(`peach bridge exited before becoming ready (exit ${childExit})`)
303
- }
312
+ if (childExit) break
304
313
  await sleep(150)
305
314
  }
306
315
 
316
+ const reason = childExit
317
+ ? `peach bridge exited before becoming ready (exit ${childExit})`
318
+ : `peach bridge did not start within ${Math.round(OPEN_BRIDGE_SPAWN_TIMEOUT_MS / 1000)}s`
319
+ const bridgeLog = fs.readFileSync(bridgeLogPath, 'utf8').trim().slice(-2000)
307
320
  throw new Error(
308
- `peach bridge did not start within ${Math.round(OPEN_BRIDGE_SPAWN_TIMEOUT_MS / 1000)}s`,
321
+ `${reason}${bridgeLog ? `\n${bridgeLog}` : ''}\nbridge log: ${bridgeLogPath}`,
309
322
  )
310
323
  }
311
324
 
@@ -955,7 +955,14 @@ flow extension:
955
955
  }
956
956
  const { runUpload } = await import('./upload')
957
957
  lastPreviewUploadResult = await runUpload(uploadArgs, {})
958
- await driver.liveStatus?.preview(lastPreviewUploadResult.previewUrl)
958
+ // upload can outlive the command socket's idle lifetime. publish its
959
+ // completed trace over a fresh connection to the same bridge world.
960
+ const resultBridge = createBridgeFromParsed(parsedBridgeArgs)
961
+ try {
962
+ await driver.liveStatus?.preview(lastPreviewUploadResult.previewUrl, resultBridge)
963
+ } finally {
964
+ resultBridge.close()
965
+ }
959
966
  } catch (err: any) {
960
967
  lastPreviewUploadError =
961
968
  err instanceof PeachExit && err.reason ? err.reason : String(err?.message || err)
@@ -29,7 +29,12 @@ function isDeviceModel(value: string): value is DeviceModel {
29
29
  }
30
30
 
31
31
  // flags this command reads for itself rather than treating as the bundle.
32
- const REMOTE_VALUE_FLAGS = new Set(['--device', '--name', '--endpoint'])
32
+ const REMOTE_VALUE_FLAGS = new Set([
33
+ '--device',
34
+ '--name',
35
+ '--endpoint',
36
+ '--native-project',
37
+ ])
33
38
 
34
39
  function remoteInputFromArgs(
35
40
  args: string[],
@@ -37,11 +42,13 @@ function remoteInputFromArgs(
37
42
  ): {
38
43
  bundlePath: string
39
44
  name: string | null
45
+ nativeProjectDir: string | undefined
40
46
  reuse: boolean
41
47
  fresh: boolean
42
48
  } {
43
49
  const positional: string[] = []
44
50
  let name: string | null = null
51
+ let nativeProjectDir: string | undefined
45
52
  for (let index = 0; index < args.length; index++) {
46
53
  const arg = args[index]
47
54
  if (arg === '--remote' || arg === '--nano' || arg === '--reuse' || arg === '--new') {
@@ -51,6 +58,7 @@ function remoteInputFromArgs(
51
58
  const value = args[++index] ?? null
52
59
  if (!value) throw new Error(`peach remote ${platform} ${arg} requires a value`)
53
60
  if (arg === '--name') name = value
61
+ if (arg === '--native-project') nativeProjectDir = value
54
62
  continue
55
63
  }
56
64
  const assigned = arg.match(/^(--[a-z-]+)=(.*)$/)
@@ -59,6 +67,7 @@ function remoteInputFromArgs(
59
67
  if (!value)
60
68
  throw new Error(`peach remote ${platform} ${assigned[1]} requires a value`)
61
69
  if (assigned[1] === '--name') name = value
70
+ if (assigned[1] === '--native-project') nativeProjectDir = value
62
71
  continue
63
72
  }
64
73
  if (
@@ -88,7 +97,7 @@ function remoteInputFromArgs(
88
97
  if (reuse && fresh) {
89
98
  throw new Error(`peach remote ${platform} cannot combine --reuse and --new`)
90
99
  }
91
- return { bundlePath: positional[0], name, reuse, fresh }
100
+ return { bundlePath: positional[0], name, nativeProjectDir, reuse, fresh }
92
101
  }
93
102
 
94
103
  /**
@@ -242,6 +251,8 @@ async function runRemoteCreate(
242
251
  selectedDevice: string,
243
252
  ): Promise<number> {
244
253
  const input = remoteInputFromArgs(args, platform)
254
+ if (input.nativeProjectDir)
255
+ throw new Error('--native-project requires a prebuilt micro Box')
245
256
  const [
246
257
  { authHeaderOrExit, cloudAccountIdOrExit },
247
258
  {
@@ -404,6 +415,7 @@ async function runRemoteBox(
404
415
 
405
416
  const existingSession = readCloudSession()
406
417
  if (
418
+ !input.nativeProjectDir &&
407
419
  existingSession?.service === 'box' &&
408
420
  !input.fresh &&
409
421
  existingSession.apiOrigin === endpoint &&
@@ -454,6 +466,7 @@ async function runRemoteBox(
454
466
  }
455
467
  const created = await box.createPrebuiltBundleBox({
456
468
  bundle: new Uint8Array(artifact.bytes),
469
+ nativeProjectDir: input.nativeProjectDir,
457
470
  device: selectedDevice,
458
471
  name: input.name ?? boxNameForBundle(input.bundlePath),
459
472
  endpoint,
@@ -137,9 +137,117 @@ function targetFromHome(envName: string, fallbackDir: string): string {
137
137
  return path.join(home, 'skills')
138
138
  }
139
139
 
140
+ function codexTargets(): InstallTarget[] {
141
+ const homes = new Map<string, string>()
142
+ const userHome = process.env.HOME || os.homedir()
143
+
144
+ // 1. Default CODEX_HOME or ~/.codex
145
+ const primaryCodexHome = process.env.CODEX_HOME || path.join(userHome, '.codex')
146
+ homes.set(path.resolve(primaryCodexHome), 'codex')
147
+
148
+ // 2. TM_CODEX_HOMES env var
149
+ const tmCodexHomes = process.env.TM_CODEX_HOMES
150
+ if (tmCodexHomes) {
151
+ for (const h of tmCodexHomes.split(path.delimiter)) {
152
+ if (h.trim()) {
153
+ const resolved = path.resolve(h.trim())
154
+ if (!homes.has(resolved)) {
155
+ homes.set(resolved, path.basename(resolved).replace(/^\./, ''))
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ // 3. Engine configuration from ~/.team-machine/engine.yaml or $TM_HOME/engine.yaml
162
+ const tmHome = process.env.TM_HOME || path.join(userHome, '.team-machine')
163
+ const enginePath = path.join(tmHome, 'engine.yaml')
164
+ if (fs.existsSync(enginePath)) {
165
+ try {
166
+ const content = fs.readFileSync(enginePath, 'utf8')
167
+ let inCodex = false
168
+ let inAccounts = false
169
+ let currentAccount = ''
170
+ for (const line of content.split('\n')) {
171
+ const indent = line.search(/\S/)
172
+ const trimmed = line.trim()
173
+ if (indent === -1) continue
174
+
175
+ if (indent === 4) {
176
+ inCodex = trimmed === 'codex:'
177
+ inAccounts = false
178
+ currentAccount = ''
179
+ continue
180
+ }
181
+
182
+ if (!inCodex) continue
183
+
184
+ if (indent === 6 && trimmed === 'accounts:') {
185
+ inAccounts = true
186
+ currentAccount = ''
187
+ continue
188
+ }
189
+
190
+ if (!inAccounts) continue
191
+
192
+ if (indent === 8 && trimmed.endsWith(':')) {
193
+ currentAccount = trimmed.slice(0, -1).trim()
194
+ continue
195
+ }
196
+
197
+ if (indent >= 10 && currentAccount) {
198
+ const match = /^home:\s*(.+)$/.exec(trimmed)
199
+ if (match) {
200
+ let homeDir = match[1].trim()
201
+ if (homeDir.startsWith('~')) {
202
+ homeDir = path.join(userHome, homeDir.slice(1))
203
+ }
204
+ const resolved = path.resolve(homeDir)
205
+ if (!homes.has(resolved)) {
206
+ homes.set(resolved, `codex-${currentAccount}`)
207
+ }
208
+ }
209
+ }
210
+ }
211
+ } catch {
212
+ // ignore read error
213
+ }
214
+ }
215
+
216
+ // 4. Native Codex profile directories in home matching .codex-*
217
+ try {
218
+ for (const entry of fs.readdirSync(userHome)) {
219
+ if (entry.startsWith('.codex-')) {
220
+ const fullPath = path.join(userHome, entry)
221
+ try {
222
+ const stat = fs.statSync(fullPath)
223
+ if (stat.isDirectory()) {
224
+ const resolved = path.resolve(fullPath)
225
+ if (!homes.has(resolved)) {
226
+ homes.set(resolved, entry.replace(/^\./, ''))
227
+ }
228
+ }
229
+ } catch {
230
+ // ignore
231
+ }
232
+ }
233
+ }
234
+ } catch {
235
+ // ignore
236
+ }
237
+
238
+ const targets: InstallTarget[] = []
239
+ for (const [homePath, label] of homes) {
240
+ targets.push({
241
+ label,
242
+ dir: path.join(homePath, 'skills'),
243
+ })
244
+ }
245
+ return targets
246
+ }
247
+
140
248
  function defaultAgentTargets(): InstallTarget[] {
141
249
  return [
142
- { label: 'codex', dir: targetFromHome('CODEX_HOME', '.codex') },
250
+ ...codexTargets(),
143
251
  { label: 'claude', dir: targetFromHome('CLAUDE_CONFIG_DIR', '.claude') },
144
252
  ]
145
253
  }
@@ -162,7 +270,7 @@ export function hasInstalledPeachSkills(): boolean {
162
270
  function agentTarget(target: string): InstallTarget[] {
163
271
  if (target === 'all') return defaultAgentTargets()
164
272
  if (target === 'codex') {
165
- return [{ label: 'codex', dir: targetFromHome('CODEX_HOME', '.codex') }]
273
+ return codexTargets()
166
274
  }
167
275
  if (target === 'claude') {
168
276
  return [{ label: 'claude', dir: targetFromHome('CLAUDE_CONFIG_DIR', '.claude') }]
@@ -289,14 +397,21 @@ function symlinkPointsTo(dest: string, src: string): boolean {
289
397
  // holding a byte-identical copy of the skill. only our installer creates
290
398
  // those shapes, so replacing them can't lose user work.
291
399
  function isOwnLegacyInstall(stat: fs.Stats, dest: string, srcDir: string): boolean {
292
- // a dangling link into a removed checkout's skills dir is our own install too
400
+ // a dangling link into a removed checkout's skills dir or a link into an obsolete worktree is our own install too
293
401
  if (stat.isSymbolicLink()) {
294
- return (
295
- !fs.existsSync(dest) &&
296
- fs
297
- .readlinkSync(dest)
298
- .endsWith(`${path.sep}skills${path.sep}${path.basename(srcDir)}`)
299
- )
402
+ try {
403
+ const link = fs.readlinkSync(dest)
404
+ const isPeachSkill = link.endsWith(
405
+ `${path.sep}skills${path.sep}${path.basename(srcDir)}`,
406
+ )
407
+ if (isPeachSkill) {
408
+ if (!fs.existsSync(dest)) return true
409
+ if (link.includes(`${path.sep}.worktrees${path.sep}`)) return true
410
+ }
411
+ } catch {
412
+ return false
413
+ }
414
+ return false
300
415
  }
301
416
  if (!stat.isDirectory()) return false
302
417
  let entries: string[]
@@ -350,7 +465,7 @@ const LEGACY_BUNDLED_SKILL_DIRS = [
350
465
  'sootsim-debug',
351
466
  'sootsim-perf',
352
467
  'sootsim-test',
353
- 'peach-visual',
468
+ 'sootsim-visual',
354
469
  'rnx-setup',
355
470
  'rnx-debug',
356
471
  'rnx-perf',
@@ -5,7 +5,7 @@
5
5
  // uploads are unlisted-public (unguessable id) but require a signed-in
6
6
  // desktop session so the share is associated with the user's account.
7
7
 
8
- import { createHash } from 'crypto'
8
+ import { createHash, randomUUID } from 'crypto'
9
9
  import { readFileSync } from 'fs'
10
10
  import path from 'path'
11
11
  import { gzipSync, gunzipSync } from 'zlib'
@@ -609,45 +609,59 @@ export async function runUpload(
609
609
  ` ${crossOriginStats.count} recorded cross-origin responses ` +
610
610
  `(${(crossOriginStats.totalBytes / 1024).toFixed(1)} KiB)`,
611
611
  )
612
- // fetch the full records (with bodies). limit to ~20 MiB total —
613
- // if we're over, warn and truncate. most dev sessions are well
614
- // under this.
615
- // also pull bundle-origin-keyed api responses with bodies so the
616
- // upload can use them instead of re-fetching from the dev server
617
- // (which fails with 401 because the cli has no auth context). main
618
- // bundle + static assets stay on the manifest re-fetch path.
619
- const dumped = await evalInBridge<{
620
- main: RecordedFetch[]
621
- worker: RecordedFetch[]
622
- }>(
623
- bridge,
624
- `(async () => {
625
- const rec = window.__sootsimPreviewRecorder
626
- const workerDump = window.__sootsimDumpWorkerFetchRecorder
627
- const bundleUrl = ${JSON.stringify(snapshot.bundleUrl)}
628
- await rec?.flush?.()
629
- const keep = (r) => {
630
- try {
631
- const u = new URL(r.url)
632
- if (u.origin !== ${JSON.stringify(bundleOrigin)}) return true
633
- // bundle-origin api responses came from the rewritten
634
- // /__app-api proxy at record time (re-keyed by the
635
- // recorder's unwrap step). they need bodies attached the
636
- // same way as cross-origin records — direct re-fetch from
637
- // the dev server lacks the bundle's session cookies.
638
- if (r.url !== bundleUrl && u.pathname.startsWith('/api/')) return true
639
- return false
640
- } catch { return false }
641
- }
642
- return {
643
- main: rec?.dump ? rec.dump().filter(keep) : [],
644
- worker: typeof workerDump === 'function'
645
- ? (await workerDump()).filter(keep)
646
- : [],
612
+ // retain the snapshot in the page and pull bodies in bounded slices.
613
+ // a complete base64 dump can exceed the bridge's 100 mib frame cap.
614
+ const cacheKey = `__peachCliUploadFetches_${randomUUID()}`
615
+ try {
616
+ const entries = await evalInBridge<
617
+ Array<RecordedEntry & { index: number; bodyBase64Length: number }>
618
+ >(
619
+ bridge,
620
+ `(async () => {
621
+ const rec = window.__sootsimPreviewRecorder
622
+ const workerDump = window.__sootsimDumpWorkerFetchRecorder
623
+ const bundleUrl = ${JSON.stringify(snapshot.bundleUrl)}
624
+ await rec?.flush?.()
625
+ const keep = (r) => {
626
+ try {
627
+ const u = new URL(r.url)
628
+ if (u.origin !== ${JSON.stringify(bundleOrigin)}) return true
629
+ return r.url !== bundleUrl && u.pathname.startsWith('/api/')
630
+ } catch { return false }
631
+ }
632
+ const records = [
633
+ ...(rec?.dump ? rec.dump().filter(keep) : []),
634
+ ...(typeof workerDump === 'function'
635
+ ? (await workerDump()).filter(keep)
636
+ : []),
637
+ ]
638
+ window[${JSON.stringify(cacheKey)}] = records
639
+ return records.map(({ bodyBase64, ...entry }, index) => ({
640
+ ...entry, index, bodyBase64Length: bodyBase64.length,
641
+ }))
642
+ })()`,
643
+ )
644
+ const dumped: RecordedFetch[] = []
645
+ const chunkChars = 1_000_000
646
+ for (const { index, bodyBase64Length, ...entry } of entries) {
647
+ let bodyBase64 = ''
648
+ for (let offset = 0; offset < bodyBase64Length; offset += chunkChars) {
649
+ const end = Math.min(offset + chunkChars, bodyBase64Length)
650
+ const chunk = await evalInBridge(
651
+ bridge,
652
+ `window[${JSON.stringify(cacheKey)}]?.[${index}]?.bodyBase64.slice(${offset}, ${end})`,
653
+ )
654
+ if (typeof chunk !== 'string' || chunk.length !== end - offset) {
655
+ throw new Error('recorded response snapshot changed during upload')
656
+ }
657
+ bodyBase64 += chunk
647
658
  }
648
- })()`,
649
- )
650
- recordedFetches = mergeRecordedEntries([...dumped.main, ...dumped.worker])
659
+ dumped.push({ ...entry, bodyBase64 })
660
+ }
661
+ recordedFetches = mergeRecordedEntries(dumped)
662
+ } finally {
663
+ await evalInBridge(bridge, `delete window[${JSON.stringify(cacheKey)}]`)
664
+ }
651
665
  if (assetsOnly) {
652
666
  const before = recordedFetches.length
653
667
  recordedFetches = recordedFetches.filter((r) =>
@@ -47,8 +47,9 @@ export class FlowLiveStatusReporter {
47
47
  private async send(
48
48
  payload: Record<string, unknown>,
49
49
  simId: string | undefined,
50
+ bridge: WsBridge = this.bridge,
50
51
  ): Promise<unknown> {
51
- const reply: unknown = await this.bridge.send({
52
+ const reply: unknown = await bridge.send({
52
53
  type: 'flowStatus',
53
54
  simId,
54
55
  status: { runId: this.runId, flowName: this.flowName, ...payload },
@@ -57,7 +58,10 @@ export class FlowLiveStatusReporter {
57
58
  return reply
58
59
  }
59
60
 
60
- private async push(payload: Record<string, unknown>): Promise<unknown> {
61
+ private async push(
62
+ payload: Record<string, unknown>,
63
+ bridge: WsBridge = this.bridge,
64
+ ): Promise<unknown> {
61
65
  if (this.disabled) return undefined
62
66
  try {
63
67
  const simId = this.getSimId?.()
@@ -75,13 +79,14 @@ export class FlowLiveStatusReporter {
75
79
  source: this.flowSource,
76
80
  },
77
81
  simId,
82
+ bridge,
78
83
  )
79
84
  for (const step of this.completedSteps.values()) {
80
- await this.send({ phase: 'step', state: 'running', step }, simId)
85
+ await this.send({ phase: 'step', state: 'running', step }, simId, bridge)
81
86
  }
82
87
  }
83
88
  this.lastSimId = simId
84
- return await this.send(payload, simId)
89
+ return await this.send(payload, simId, bridge)
85
90
  } catch (error) {
86
91
  this.disabled = true
87
92
  console.log(
@@ -165,8 +170,8 @@ export class FlowLiveStatusReporter {
165
170
 
166
171
  // the finished run's cloud trace url, once --preview has uploaded it. the
167
172
  // devtools test pane's Open Trace button opens it.
168
- async preview(previewUrl: string): Promise<void> {
169
- await this.push({ phase: 'preview', previewUrl })
173
+ async preview(previewUrl: string, bridge: WsBridge): Promise<void> {
174
+ await this.push({ phase: 'preview', previewUrl }, bridge)
170
175
  }
171
176
 
172
177
  // park while the devtools pause toggle is on. checked between top-level