sootsim 0.1.83 → 0.1.84
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/README.md +0 -1
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/expectations.ts +477 -0
- package/detox/gestures.ts +442 -0
- package/detox/index.ts +1436 -0
- package/detox/jest-environment.ts +86 -0
- package/detox/jest-preset.cjs +50 -0
- package/detox/matchers.ts +29 -0
- package/detox/navigation.ts +43 -0
- package/detox/run-test.ts +113 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-cli/bin.js +3 -3
- package/dist-cli/chunks/{agent-MQ7GLVIB.js → agent-2CWD6W6P.js} +2 -2
- package/dist-cli/chunks/{agent-wrapper-7KAFDQCN.js → agent-wrapper-5W3LOX6S.js} +2 -2
- package/dist-cli/chunks/{assert-TV46GUNU.js → assert-ZOMAMKRT.js} +2 -2
- package/dist-cli/chunks/auto-bootstrap-NYYSMTIM.js +2 -0
- package/dist-cli/chunks/beta-4K2SQACK.js +2 -0
- package/dist-cli/chunks/chunk-3HXQ7MJK.js +79 -0
- package/dist-cli/chunks/{chunk-4LS5MZAI.js → chunk-4K7BH2D4.js} +3 -3
- package/dist-cli/chunks/{chunk-FJYT7XL2.js → chunk-4OPRODFA.js} +2 -2
- package/dist-cli/chunks/{chunk-DP7O5MHK.js → chunk-4OWVPRZV.js} +2 -2
- package/dist-cli/chunks/{chunk-PM5NVKLP.js → chunk-5XCXOLG2.js} +2 -2
- package/dist-cli/chunks/chunk-67ZZ2CM5.js +1 -0
- package/dist-cli/chunks/{chunk-WN7M3QON.js → chunk-73UZXB4B.js} +2 -2
- package/dist-cli/chunks/{chunk-5DJXZIFZ.js → chunk-7NWNTUJF.js} +1 -1
- package/dist-cli/chunks/{chunk-Y2VJBRSP.js → chunk-7YHDJLO2.js} +1 -1
- package/dist-cli/chunks/{chunk-6NN2D4EJ.js → chunk-AJVTY6KY.js} +1 -1
- package/dist-cli/chunks/chunk-AWSQUOAS.js +67 -0
- package/dist-cli/chunks/{chunk-CJY3AVI7.js → chunk-BCBNVJVG.js} +1 -1
- package/dist-cli/chunks/{chunk-OYMFNU3M.js → chunk-BKBL6K2G.js} +1 -1
- package/dist-cli/chunks/{chunk-IBNRRAES.js → chunk-C3DPQZ4J.js} +2 -2
- package/dist-cli/chunks/chunk-D3ZSBIIY.js +2 -0
- package/dist-cli/chunks/{chunk-2AWQ7OB2.js → chunk-D4HUVLZR.js} +1 -1
- package/dist-cli/chunks/{chunk-F3HP444U.js → chunk-DUUSJDES.js} +1 -1
- package/dist-cli/chunks/{chunk-277XAALA.js → chunk-ELJLF4SG.js} +3 -3
- package/dist-cli/chunks/{chunk-RH4F2TF7.js → chunk-EQ7TFQ2F.js} +1 -1
- package/dist-cli/chunks/{chunk-HNWEELAE.js → chunk-EQCKGC4B.js} +1 -1
- package/dist-cli/chunks/chunk-FUCGLWNN.js +1 -0
- package/dist-cli/chunks/{chunk-FRM355UL.js → chunk-HYPJW65U.js} +2 -2
- package/dist-cli/chunks/chunk-IILJQCZA.js +2 -0
- package/dist-cli/chunks/{chunk-Y4BUVURT.js → chunk-KU6MSPAH.js} +2 -2
- package/dist-cli/chunks/{chunk-DM6WT7QM.js → chunk-OOOR7NT2.js} +1 -1
- package/dist-cli/chunks/{chunk-HAWOAQAG.js → chunk-P7WDNKOS.js} +3 -3
- package/dist-cli/chunks/{chunk-6TNANCQC.js → chunk-PPKKA5VW.js} +2 -2
- package/dist-cli/chunks/{chunk-JQ7ZXOXJ.js → chunk-PS2G44GT.js} +2 -2
- package/dist-cli/chunks/{chunk-ECJBV65H.js → chunk-QMSJR5R2.js} +2 -2
- package/dist-cli/chunks/{chunk-J2GYISVJ.js → chunk-RF4R2U46.js} +2 -2
- package/dist-cli/chunks/{chunk-VMXWC2JO.js → chunk-RIXUH3NK.js} +2 -2
- package/dist-cli/chunks/{chunk-2PY3UZVO.js → chunk-SFGUPL2X.js} +2 -2
- package/dist-cli/chunks/{chunk-572VSFNP.js → chunk-SQX5CAYG.js} +1 -1
- package/dist-cli/chunks/{chunk-NXATOWWF.js → chunk-SQZAC7C4.js} +1 -1
- package/dist-cli/chunks/{chunk-WTKTOL3C.js → chunk-SV7FOGJ3.js} +2 -2
- package/dist-cli/chunks/{chunk-JHJNODXN.js → chunk-TK3OJSEO.js} +2 -2
- package/dist-cli/chunks/{chunk-KASUZ5XV.js → chunk-TL7SIZ7S.js} +1 -1
- package/dist-cli/chunks/{chunk-6XZOEBTZ.js → chunk-V2GQ4WXJ.js} +2 -2
- package/dist-cli/chunks/{chunk-IP3QJLRH.js → chunk-VH7F45CN.js} +1 -1
- package/dist-cli/chunks/chunk-WNVNU2OW.js +4 -0
- package/dist-cli/chunks/{chunk-YUELRHGB.js → chunk-XQ2OBHBE.js} +2 -2
- package/dist-cli/chunks/{chunk-CYV6Y6YV.js → chunk-YCIA4BHJ.js} +2 -2
- package/dist-cli/chunks/chunk-ZSMMJMPA.js +1 -0
- package/dist-cli/chunks/cli-version-QB4VH24H.js +2 -0
- package/dist-cli/chunks/{compat-QLLWBTS3.js → compat-FWSEEGEH.js} +3 -3
- package/dist-cli/chunks/{config-2DSLDCXV.js → config-CYI2WAGP.js} +2 -2
- package/dist-cli/chunks/control-UXY7YQVX.js +2 -0
- package/dist-cli/chunks/{cpu-profile-GEIKHCPC.js → cpu-profile-IKAE3KTY.js} +2 -2
- package/dist-cli/chunks/{daemon-4EBUFN4D.js → daemon-ZUMF53YB.js} +2 -2
- package/dist-cli/chunks/{debug-WGD6XWOF.js → debug-P6KULKKS.js} +3 -3
- package/dist-cli/chunks/{detox-LNKGRZU6.js → detox-SPWAZCYG.js} +2 -2
- package/dist-cli/chunks/{device-AYKXKVIQ.js → device-JWEPK6I2.js} +2 -2
- package/dist-cli/chunks/{diagnose-TMXSDOOC.js → diagnose-IZODTXV2.js} +2 -2
- package/dist-cli/chunks/drivers-MK6WJKBC.js +2 -0
- package/dist-cli/chunks/{electron-QFPF7TBY.js → electron-R5GP6RVB.js} +3 -3
- package/dist-cli/chunks/flow-6O4GEOPJ.js +2 -0
- package/dist-cli/chunks/{hints-MXKRR4TG.js → hints-DYDNYX7N.js} +2 -2
- package/dist-cli/chunks/{home-paths-REMWQDAO.js → home-paths-GLMX5OKL.js} +2 -2
- package/dist-cli/chunks/{inspect-XGSQNFV7.js → inspect-FJOPCTY2.js} +3 -3
- package/dist-cli/chunks/install-A3TUGGHN.js +2 -0
- package/dist-cli/chunks/{install-desktop-NQG3RZSA.js → install-desktop-YPJZMZM5.js} +3 -3
- package/dist-cli/chunks/{keys-5QZWXL3F.js → keys-GSYPHWNY.js} +2 -2
- package/dist-cli/chunks/{launch-SBXOZWKO.js → launch-4G2PKW5X.js} +3 -3
- package/dist-cli/chunks/{login-EACQXE24.js → login-KJQGHA64.js} +4 -4
- package/dist-cli/chunks/{logout-IBQLMUML.js → logout-XM2SYH5C.js} +2 -2
- package/dist-cli/chunks/{maestro-LFYXUX7O.js → maestro-EOWGI7DG.js} +2 -2
- package/dist-cli/chunks/{preview-U4SBOEGQ.js → preview-F73TKK37.js} +2 -2
- package/dist-cli/chunks/{profile-GWS5ECMY.js → profile-22FDKBUO.js} +2 -2
- package/dist-cli/chunks/{react-QDHLMVYL.js → react-5L6VPFUP.js} +2 -2
- package/dist-cli/chunks/{record-BUEUWPDI.js → record-JZXCQ4IN.js} +2 -2
- package/dist-cli/chunks/runtime-EEBX7CFV.js +2 -0
- package/dist-cli/chunks/{runtime-delivery-G7L6RVZ7.js → runtime-delivery-LXUM3R4A.js} +2 -2
- package/dist-cli/chunks/{screenshot-T2HBA3VI.js → screenshot-HDRRG33Q.js} +2 -2
- package/dist-cli/chunks/{screenshot-mode-EG5HMIH3.js → screenshot-mode-WY63LZIX.js} +2 -2
- package/dist-cli/chunks/{screenshots-S52AFHTV.js → screenshots-MPV2ENL5.js} +2 -2
- package/dist-cli/chunks/{server-MFFVYUGG.js → server-5LBMCJ3G.js} +2 -2
- package/dist-cli/chunks/setup-repo-SZSYNKNI.js +2 -0
- package/dist-cli/chunks/{skills-HQGWBS2O.js → skills-BQ73YOBF.js} +2 -2
- package/dist-cli/chunks/{start-E3DRYY7W.js → start-2WU4W6ZU.js} +4 -4
- package/dist-cli/chunks/store-RE45SUBF.js +2 -0
- package/dist-cli/chunks/telemetry-DG6GJLCP.js +2 -0
- package/dist-cli/chunks/{test-ZY3EF62K.js → test-OVO4CQTG.js} +3 -3
- package/dist-cli/chunks/{three-mode-WSPKQCJ5.js → three-mode-BKM3KFM7.js} +2 -2
- package/dist-cli/chunks/{timeline-3XAB5EWZ.js → timeline-MDXGEDQL.js} +2 -2
- package/dist-cli/chunks/{upgrade-WNENPFM5.js → upgrade-JGQABWVF.js} +2 -2
- package/dist-cli/chunks/upload-UJNUA4ZV.js +2 -0
- package/dist-cli/chunks/{web-D2AOZY44.js → web-WYFAYQ72.js} +2 -2
- package/dist-cli/chunks/{what-happened-F43KNSG6.js → what-happened-PZW2KW6A.js} +2 -2
- package/dist-cli/chunks/{whoami-T22VBR7C.js → whoami-7ATWJQS6.js} +2 -2
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +44 -0
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1770 -0
- package/dist-lib/detox/jest-preset.cjs +50 -0
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +1 -1
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/index.cjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/demo-app-registry.cjs +809 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1269 -0
- package/dist-lib/skills.cjs +8322 -0
- package/dist-lib/vite-base.cjs +3 -3
- package/dist-lib/vite.cjs +1 -1
- package/package.json +39 -10
- package/scripts/demo-app-registry.ts +989 -0
- package/scripts/dev-server-scanner.ts +674 -0
- package/src/agent-daemon-client.ts +390 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +572 -0
- package/src/attached-projects.ts +536 -0
- package/src/auth/shared-session.ts +199 -0
- package/src/backend-origin.ts +49 -0
- package/src/beta.ts +21 -0
- package/src/bridge-constants.ts +10 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +30 -0
- package/src/codex-client.ts +215 -0
- package/src/config.ts +110 -0
- package/src/dev-bundle-resolution.ts +180 -0
- package/src/home-paths.ts +382 -0
- package/src/host/agent-host.ts +576 -0
- package/src/host/bridge-host.ts +2293 -0
- package/src/host/fetch-proxy-handler.ts +288 -0
- package/src/host/fetch-proxy-overrides.ts +39 -0
- package/src/host/open-url.ts +234 -0
- package/src/index.ts +9 -0
- package/src/metro-plugin.ts +207 -0
- package/src/native-dev-bundle-url.ts +62 -0
- package/src/native-seam-manifest.ts +313 -0
- package/src/profiles.ts +179 -0
- package/src/render-mode.ts +27 -0
- package/src/runtime-delivery.ts +334 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +438 -0
- package/src/screenshots/orchestrate.ts +244 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/skills/builtin/a11y-review.ts +126 -0
- package/src/skills/builtin/compat-check.ts +104 -0
- package/src/skills/builtin/perf-profile.ts +84 -0
- package/src/skills/builtin/screenshot-all.ts +46 -0
- package/src/skills/builtin/test-flow.ts +118 -0
- package/src/skills/builtin/visual-diff.ts +94 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/vite-plugin-one.ts +187 -0
- package/src/vite-plugin.ts +1381 -0
- package/src/worklets-babel.ts +132 -0
- package/dist-cli/chunks/auto-bootstrap-FQS4ZD2K.js +0 -2
- package/dist-cli/chunks/beta-VG7CDY2U.js +0 -2
- package/dist-cli/chunks/chunk-2OIBDYHW.js +0 -1
- package/dist-cli/chunks/chunk-6BNLVMXA.js +0 -1
- package/dist-cli/chunks/chunk-6XD6CBJM.js +0 -2
- package/dist-cli/chunks/chunk-CHQTO426.js +0 -1
- package/dist-cli/chunks/chunk-FAPYGVIU.js +0 -4
- package/dist-cli/chunks/chunk-PEHFE3LG.js +0 -64
- package/dist-cli/chunks/chunk-RXH2SLKF.js +0 -2
- package/dist-cli/chunks/chunk-UXQWC5ZR.js +0 -79
- package/dist-cli/chunks/chunk-XFQL74PF.js +0 -5
- package/dist-cli/chunks/cli-version-PWF6I6LY.js +0 -2
- package/dist-cli/chunks/control-UIOXGYXU.js +0 -2
- package/dist-cli/chunks/demo-app-registry-G3BDOFWC.js +0 -2
- package/dist-cli/chunks/drivers-IDQF34HP.js +0 -2
- package/dist-cli/chunks/flow-3JN3Y7RF.js +0 -2
- package/dist-cli/chunks/install-2N3YOOSN.js +0 -2
- package/dist-cli/chunks/runtime-PVB4VGUH.js +0 -2
- package/dist-cli/chunks/setup-repo-YOF7NV5D.js +0 -2
- package/dist-cli/chunks/store-MAI6D3UO.js +0 -2
- package/dist-cli/chunks/telemetry-RCQKCJTH.js +0 -2
- package/dist-cli/chunks/upload-YLJ4RA73.js +0 -2
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
// session lifecycle shared between the CLI (`sootsim agent ...`) and electron
|
|
2
|
+
// main (IPC handlers). both contexts spawn the same `sootsim agent-wrapper`
|
|
3
|
+
// subcommand, mutate the same AttachedProjects store, and subscribe to the
|
|
4
|
+
// same events.out FIFO.
|
|
5
|
+
//
|
|
6
|
+
// this file is the canonical implementation; thin wrappers in the CLI and
|
|
7
|
+
// electron layers call these functions for their UX needs.
|
|
8
|
+
|
|
9
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
10
|
+
import { randomUUID } from 'node:crypto'
|
|
11
|
+
import fs, { constants as fsConstants } from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import readline from 'node:readline'
|
|
14
|
+
import { parseAgentEventLine, type AgentEvent } from './agent-events.ts'
|
|
15
|
+
import { encodeAgentPromptEnvelope, type AgentPromptEnvelope } from './agent-prompt.ts'
|
|
16
|
+
import {
|
|
17
|
+
findProjectById,
|
|
18
|
+
findSessionById,
|
|
19
|
+
getUserDataDir,
|
|
20
|
+
listSessions,
|
|
21
|
+
updateSessionStatus,
|
|
22
|
+
upsertSession,
|
|
23
|
+
type AgentSession,
|
|
24
|
+
} from './attached-projects.ts'
|
|
25
|
+
|
|
26
|
+
export type Provider = 'codex' | 'claude'
|
|
27
|
+
|
|
28
|
+
// --- fs layout ---
|
|
29
|
+
|
|
30
|
+
export function sessionDir(sessionId: string): string {
|
|
31
|
+
return path.join(getUserDataDir(), 'sessions', sessionId)
|
|
32
|
+
}
|
|
33
|
+
export function promptFifoPath(sessionId: string): string {
|
|
34
|
+
return path.join(sessionDir(sessionId), 'prompt.in')
|
|
35
|
+
}
|
|
36
|
+
export function eventsFifoPath(sessionId: string): string {
|
|
37
|
+
return path.join(sessionDir(sessionId), 'events.out')
|
|
38
|
+
}
|
|
39
|
+
export function transcriptPath(sessionId: string): string {
|
|
40
|
+
return path.join(getUserDataDir(), 'transcripts', `${sessionId}.log`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- pid helpers ---
|
|
44
|
+
|
|
45
|
+
/** returns true iff a process with this pid is alive AND owned by us. pid
|
|
46
|
+
* recycling means kill(0) can return true for an unrelated process that
|
|
47
|
+
* happened to take the same number — we also require the sentinel file
|
|
48
|
+
* written by the wrapper at startup to still exist. */
|
|
49
|
+
export function pidIsAlive(pid: number | undefined, sessionId?: string): boolean {
|
|
50
|
+
if (!pid) return false
|
|
51
|
+
try {
|
|
52
|
+
process.kill(pid, 0)
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
if (sessionId) {
|
|
57
|
+
// sentinel: the events.out fifo's session dir. if the session was ended
|
|
58
|
+
// via cmdEnd, the dir was removed; kill(0) returning true on that pid
|
|
59
|
+
// would be a recycled pid from an unrelated process.
|
|
60
|
+
if (!fs.existsSync(sessionDir(sessionId))) return false
|
|
61
|
+
}
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- invocation resolution ---
|
|
66
|
+
|
|
67
|
+
/** figure out how to re-invoke the sootsim CLI to run `agent-wrapper`.
|
|
68
|
+
* priority:
|
|
69
|
+
* 1. SOOTSIM_BIN env var (explicit override)
|
|
70
|
+
* 2. electron prod: bundled binary under process.resourcesPath/bin/
|
|
71
|
+
* 3. workspace build artifacts (dist-bin/<binary> or dist-cli/bin.js)
|
|
72
|
+
* — shared between electron dev, the vite-plugin-hosted daemon, and
|
|
73
|
+
* any other node process that isn't launched with the CLI entry
|
|
74
|
+
* script directly (e.g. argv[1] = .../node_modules/.bin/vite).
|
|
75
|
+
* 4. CLI dev (argv[1] points at the entry script): re-use argv[0] + argv[1]
|
|
76
|
+
* 5. CLI standalone: argv[0] is the compiled binary, no prefix needed
|
|
77
|
+
*/
|
|
78
|
+
export function resolveSootsimInvocation(): { cmd: string; prefixArgs: string[] } {
|
|
79
|
+
if (process.env.SOOTSIM_BIN) {
|
|
80
|
+
return { cmd: process.env.SOOTSIM_BIN, prefixArgs: [] }
|
|
81
|
+
}
|
|
82
|
+
// electron prod: packaged binary lives under Resources/bin/.
|
|
83
|
+
if (process.versions.electron) {
|
|
84
|
+
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string })
|
|
85
|
+
.resourcesPath
|
|
86
|
+
if (resourcesPath) {
|
|
87
|
+
const candidates = [
|
|
88
|
+
path.join(resourcesPath, 'bin', 'sootsim'),
|
|
89
|
+
path.join(resourcesPath, 'bin', `sootsim-${process.platform}-${process.arch}`),
|
|
90
|
+
]
|
|
91
|
+
for (const c of candidates) {
|
|
92
|
+
if (fs.existsSync(c)) return { cmd: c, prefixArgs: [] }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// workspace build artifacts — tried for every non-CLI-entry node context
|
|
98
|
+
// (electron dev, vite-plugin-hosted daemon, random script) before falling
|
|
99
|
+
// back to argv. prefers the compiled bun binary because `bun dev` runs
|
|
100
|
+
// `watch:cli:binary` and that stays fresh; dist-cli/bin.js is the node
|
|
101
|
+
// fallback but only `watch:cli` (not in the default dev graph) refreshes
|
|
102
|
+
// it, so we warn when it's older than agent-wrapper.ts.
|
|
103
|
+
const workspace = tryWorkspaceSootsim()
|
|
104
|
+
if (workspace) return workspace
|
|
105
|
+
|
|
106
|
+
// argv fallback. only trust it when argv[1] looks like a real entry
|
|
107
|
+
// script — `.js`/`.ts`/`.mjs` etc. launching vite gives us argv[1] =
|
|
108
|
+
// `…/node_modules/.bin/vite` (no extension), which would make us try
|
|
109
|
+
// to run agent-wrapper under the vite shim and silently fail.
|
|
110
|
+
const argv0 = process.argv[0]
|
|
111
|
+
const argv1 = process.argv[1]
|
|
112
|
+
if (argv1 && /\.(ts|tsx|mjs|cjs|js)$/.test(argv1)) {
|
|
113
|
+
return { cmd: argv0, prefixArgs: [argv1] }
|
|
114
|
+
}
|
|
115
|
+
if (!argv1 || argv1.includes('/.bin/')) {
|
|
116
|
+
// bin-shim case falls through to the error below instead of silently
|
|
117
|
+
// running `node` without args.
|
|
118
|
+
throw new Error(
|
|
119
|
+
'sootsim CLI not found. set SOOTSIM_BIN to the path of the sootsim binary, ' +
|
|
120
|
+
'or build the workspace CLI via `bun run --cwd packages/sootsim build:cli`.',
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
return { cmd: argv0, prefixArgs: [] }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function tryWorkspaceSootsim(): { cmd: string; prefixArgs: string[] } | null {
|
|
127
|
+
try {
|
|
128
|
+
const sootsimDir = resolveSootsimPackageDir()
|
|
129
|
+
if (!sootsimDir) return null
|
|
130
|
+
const binaryName = `sootsim-${process.platform}-${process.arch}`
|
|
131
|
+
const distBinary = path.join(sootsimDir, 'dist-bin', binaryName)
|
|
132
|
+
if (fs.existsSync(distBinary)) return { cmd: distBinary, prefixArgs: [] }
|
|
133
|
+
const distBin = path.join(sootsimDir, 'dist-cli', 'bin.js')
|
|
134
|
+
if (fs.existsSync(distBin)) {
|
|
135
|
+
try {
|
|
136
|
+
const src = path.join(sootsimDir, 'cli', 'commands', 'agent-wrapper.ts')
|
|
137
|
+
if (fs.existsSync(src)) {
|
|
138
|
+
const srcMtime = fs.statSync(src).mtimeMs
|
|
139
|
+
const buildMtime = fs.statSync(distBin).mtimeMs
|
|
140
|
+
if (buildMtime < srcMtime) {
|
|
141
|
+
console.warn(
|
|
142
|
+
`[sootsim] dist-cli/bin.js is older than agent-wrapper.ts — ` +
|
|
143
|
+
`rebuild with \`bun run --cwd packages/sootsim build:cli\` ` +
|
|
144
|
+
`(watch:cli:binary builds dist-bin/ instead).`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch {}
|
|
149
|
+
return { cmd: process.execPath, prefixArgs: [distBin] }
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
} catch {
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** locate the workspace sootsim package directory. tries `require.resolve`
|
|
158
|
+
* first (works in most contexts), then walks up from this module file
|
|
159
|
+
* looking for `packages/sootsim/package.json` (works when sootsim is
|
|
160
|
+
* loaded via vite's native TS resolution, where require.resolve doesn't
|
|
161
|
+
* know about the workspace). */
|
|
162
|
+
function resolveSootsimPackageDir(): string | null {
|
|
163
|
+
try {
|
|
164
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
165
|
+
const resolved = require.resolve('sootsim/package.json')
|
|
166
|
+
return path.dirname(resolved)
|
|
167
|
+
} catch {}
|
|
168
|
+
// walk up from this file: .../packages/sootsim/src/agent-sessions.ts
|
|
169
|
+
// → .../packages/sootsim/. the module path is whatever the runtime
|
|
170
|
+
// gave us; when bundled by esbuild this will be the bundle path, not
|
|
171
|
+
// the source path, which is why we try require.resolve first.
|
|
172
|
+
const here = fileFromImportMeta()
|
|
173
|
+
if (!here) return null
|
|
174
|
+
let cur = path.dirname(here)
|
|
175
|
+
for (let i = 0; i < 8; i++) {
|
|
176
|
+
const pkg = path.join(cur, 'package.json')
|
|
177
|
+
try {
|
|
178
|
+
if (fs.existsSync(pkg)) {
|
|
179
|
+
const parsed = JSON.parse(fs.readFileSync(pkg, 'utf8')) as { name?: string }
|
|
180
|
+
if (parsed.name === 'sootsim') return cur
|
|
181
|
+
}
|
|
182
|
+
} catch {}
|
|
183
|
+
const parent = path.dirname(cur)
|
|
184
|
+
if (parent === cur) break
|
|
185
|
+
cur = parent
|
|
186
|
+
}
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function fileFromImportMeta(): string | null {
|
|
191
|
+
try {
|
|
192
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
193
|
+
const url = (import.meta as unknown as { url?: string }).url
|
|
194
|
+
if (!url || !url.startsWith('file://')) return null
|
|
195
|
+
return decodeURIComponent(url.slice('file://'.length))
|
|
196
|
+
} catch {
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- lockfile for start races ---
|
|
202
|
+
|
|
203
|
+
async function withStartLock<T>(
|
|
204
|
+
projectId: string,
|
|
205
|
+
provider: Provider,
|
|
206
|
+
fn: () => Promise<T>,
|
|
207
|
+
): Promise<T> {
|
|
208
|
+
const lockDir = path.join(getUserDataDir(), 'locks')
|
|
209
|
+
fs.mkdirSync(lockDir, { recursive: true })
|
|
210
|
+
try {
|
|
211
|
+
fs.chmodSync(lockDir, 0o700)
|
|
212
|
+
} catch {}
|
|
213
|
+
const lockPath = path.join(lockDir, `start-${projectId}-${provider}.lock`)
|
|
214
|
+
const deadline = Date.now() + 4000
|
|
215
|
+
let fd: number | null = null
|
|
216
|
+
while (fd === null) {
|
|
217
|
+
try {
|
|
218
|
+
fd = fs.openSync(
|
|
219
|
+
lockPath,
|
|
220
|
+
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL,
|
|
221
|
+
0o600,
|
|
222
|
+
)
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
225
|
+
// staleness check: if the pid in the lockfile is dead, steal it
|
|
226
|
+
try {
|
|
227
|
+
const stale = Number(fs.readFileSync(lockPath, 'utf8').trim())
|
|
228
|
+
if (stale && !isProcessAlive(stale)) {
|
|
229
|
+
fs.unlinkSync(lockPath)
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
} catch {}
|
|
233
|
+
if (Date.now() > deadline) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`another start is in progress for project=${projectId} provider=${provider} ` +
|
|
236
|
+
`(lock: ${lockPath})`,
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
fs.writeFileSync(fd, String(process.pid))
|
|
244
|
+
return await fn()
|
|
245
|
+
} finally {
|
|
246
|
+
try {
|
|
247
|
+
fs.closeSync(fd)
|
|
248
|
+
} catch {}
|
|
249
|
+
try {
|
|
250
|
+
fs.unlinkSync(lockPath)
|
|
251
|
+
} catch {}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isProcessAlive(pid: number): boolean {
|
|
256
|
+
try {
|
|
257
|
+
process.kill(pid, 0)
|
|
258
|
+
return true
|
|
259
|
+
} catch {
|
|
260
|
+
return false
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- FIFO helpers ---
|
|
265
|
+
|
|
266
|
+
export function mkfifoSync(p: string): void {
|
|
267
|
+
const parent = path.dirname(p)
|
|
268
|
+
fs.mkdirSync(parent, { recursive: true })
|
|
269
|
+
try {
|
|
270
|
+
fs.chmodSync(parent, 0o700)
|
|
271
|
+
} catch {}
|
|
272
|
+
if (fs.existsSync(p)) {
|
|
273
|
+
try {
|
|
274
|
+
const stat = fs.statSync(p)
|
|
275
|
+
if (stat.isFIFO()) {
|
|
276
|
+
try {
|
|
277
|
+
fs.chmodSync(p, 0o600)
|
|
278
|
+
} catch {}
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
fs.unlinkSync(p)
|
|
282
|
+
} catch {
|
|
283
|
+
fs.unlinkSync(p)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const result = spawnSync('mkfifo', ['-m', '600', p])
|
|
287
|
+
if (result.status !== 0) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`mkfifo(${p}) failed: ${result.stderr?.toString().trim() || 'unknown error'}`,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- session lifecycle ---
|
|
295
|
+
|
|
296
|
+
export interface StartSessionOpts {
|
|
297
|
+
projectId: string
|
|
298
|
+
provider?: Provider
|
|
299
|
+
codexBin?: string
|
|
300
|
+
claudeBin?: string
|
|
301
|
+
freshThread?: boolean
|
|
302
|
+
readyTimeoutMs?: number
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface StartSessionResult {
|
|
306
|
+
session: AgentSession
|
|
307
|
+
/** child pid — the same value stored on session.wrapperPid */
|
|
308
|
+
wrapperPid: number
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export class AgentSessionError extends Error {
|
|
312
|
+
code: string
|
|
313
|
+
constructor(code: string, message: string) {
|
|
314
|
+
super(message)
|
|
315
|
+
this.code = code
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function startSession(opts: StartSessionOpts): Promise<StartSessionResult> {
|
|
320
|
+
const project = findProjectById(opts.projectId)
|
|
321
|
+
if (!project) {
|
|
322
|
+
throw new AgentSessionError('NO_PROJECT', `no project with id=${opts.projectId}`)
|
|
323
|
+
}
|
|
324
|
+
const provider: Provider = opts.provider || project.preferredProvider || 'codex'
|
|
325
|
+
|
|
326
|
+
return withStartLock(project.id, provider, async () => {
|
|
327
|
+
// re-check inside the lock. a concurrent start that slipped through the
|
|
328
|
+
// pre-check can't slip through both the check AND the lock.
|
|
329
|
+
const existingLive = listSessions(project.id).find(
|
|
330
|
+
(s) =>
|
|
331
|
+
s.provider === provider && s.status !== 'ended' && pidIsAlive(s.wrapperPid, s.id),
|
|
332
|
+
)
|
|
333
|
+
if (existingLive) {
|
|
334
|
+
throw new AgentSessionError(
|
|
335
|
+
'ALREADY_RUNNING',
|
|
336
|
+
`session already running for project=${project.id} provider=${provider} ` +
|
|
337
|
+
`(session ${existingLive.id}, pid ${existingLive.wrapperPid}). ` +
|
|
338
|
+
'end it first with `sootsim agent end <sessionId>`.',
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// generate the claude session uuid once and persist it so successive
|
|
343
|
+
// wrapper restarts resume the same `~/.claude/projects/<cwd>/<uuid>.jsonl`
|
|
344
|
+
// file. codex has its own thread-id persistence via `thread/list`, so we
|
|
345
|
+
// only need this for claude.
|
|
346
|
+
const claudeSessionUuid = provider === 'claude' ? randomUUID() : undefined
|
|
347
|
+
|
|
348
|
+
const session = upsertSession({
|
|
349
|
+
projectId: project.id,
|
|
350
|
+
provider,
|
|
351
|
+
transport: 'pty',
|
|
352
|
+
cwd: project.cwd,
|
|
353
|
+
status: 'idle',
|
|
354
|
+
claudeSessionUuid,
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
const promptIn = promptFifoPath(session.id)
|
|
358
|
+
const eventsOut = eventsFifoPath(session.id)
|
|
359
|
+
const transcript = transcriptPath(session.id)
|
|
360
|
+
mkfifoSync(promptIn)
|
|
361
|
+
mkfifoSync(eventsOut)
|
|
362
|
+
// transcripts dir locked too
|
|
363
|
+
const transcriptDir = path.dirname(transcript)
|
|
364
|
+
fs.mkdirSync(transcriptDir, { recursive: true })
|
|
365
|
+
try {
|
|
366
|
+
fs.chmodSync(transcriptDir, 0o700)
|
|
367
|
+
} catch {}
|
|
368
|
+
|
|
369
|
+
const { cmd, prefixArgs } = resolveSootsimInvocation()
|
|
370
|
+
const wrapperArgs = [
|
|
371
|
+
...prefixArgs,
|
|
372
|
+
'agent-wrapper',
|
|
373
|
+
'--session-id',
|
|
374
|
+
session.id,
|
|
375
|
+
'--project-id',
|
|
376
|
+
project.id,
|
|
377
|
+
'--provider',
|
|
378
|
+
provider,
|
|
379
|
+
'--cwd',
|
|
380
|
+
project.cwd,
|
|
381
|
+
'--prompt-in',
|
|
382
|
+
promptIn,
|
|
383
|
+
'--events-out',
|
|
384
|
+
eventsOut,
|
|
385
|
+
'--transcript',
|
|
386
|
+
transcript,
|
|
387
|
+
]
|
|
388
|
+
if (opts.codexBin) wrapperArgs.push('--codex-bin', opts.codexBin)
|
|
389
|
+
if (opts.claudeBin) wrapperArgs.push('--claude-bin', opts.claudeBin)
|
|
390
|
+
if (opts.freshThread) wrapperArgs.push('--fresh-thread')
|
|
391
|
+
if (claudeSessionUuid) {
|
|
392
|
+
wrapperArgs.push('--claude-session-uuid', claudeSessionUuid)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const child = spawn(cmd, wrapperArgs, {
|
|
396
|
+
detached: true,
|
|
397
|
+
stdio: 'ignore',
|
|
398
|
+
env: {
|
|
399
|
+
...process.env,
|
|
400
|
+
SOOTSIM_USER_DATA_DIR: getUserDataDir(),
|
|
401
|
+
},
|
|
402
|
+
})
|
|
403
|
+
child.unref()
|
|
404
|
+
|
|
405
|
+
const readyTimeout = opts.readyTimeoutMs ?? 6000
|
|
406
|
+
const boot = await waitForFirstEvent(
|
|
407
|
+
eventsOut,
|
|
408
|
+
(e) => e.type === 'ready' || e.type === 'error',
|
|
409
|
+
readyTimeout,
|
|
410
|
+
)
|
|
411
|
+
if (!boot || boot.type === 'error') {
|
|
412
|
+
if (child.pid) {
|
|
413
|
+
try {
|
|
414
|
+
process.kill(child.pid, 'SIGTERM')
|
|
415
|
+
} catch {}
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
fs.rmSync(sessionDir(session.id), { recursive: true, force: true })
|
|
419
|
+
} catch {}
|
|
420
|
+
updateSessionStatus(session.id, { status: 'ended' })
|
|
421
|
+
const reason =
|
|
422
|
+
boot && boot.type === 'error'
|
|
423
|
+
? boot.message
|
|
424
|
+
: `no ready event within ${readyTimeout}ms`
|
|
425
|
+
throw new AgentSessionError('WRAPPER_FAILED', reason)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
updateSessionStatus(session.id, {
|
|
429
|
+
wrapperPid: child.pid,
|
|
430
|
+
status: 'idle',
|
|
431
|
+
})
|
|
432
|
+
const updated = findSessionById(session.id)!
|
|
433
|
+
return { session: updated, wrapperPid: child.pid! }
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function sendPrompt(
|
|
438
|
+
sessionId: string,
|
|
439
|
+
prompt: AgentPromptEnvelope,
|
|
440
|
+
): Promise<void> {
|
|
441
|
+
const session = findSessionById(sessionId)
|
|
442
|
+
if (!session) {
|
|
443
|
+
throw new AgentSessionError('NO_SESSION', `no session with id=${sessionId}`)
|
|
444
|
+
}
|
|
445
|
+
if (!pidIsAlive(session.wrapperPid, sessionId)) {
|
|
446
|
+
updateSessionStatus(sessionId, { status: 'ended' })
|
|
447
|
+
throw new AgentSessionError(
|
|
448
|
+
'NOT_ALIVE',
|
|
449
|
+
`session wrapper is not alive (pid=${session.wrapperPid}). start a new session.`,
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
const fifo = promptFifoPath(sessionId)
|
|
453
|
+
if (!fs.existsSync(fifo)) {
|
|
454
|
+
throw new AgentSessionError('NO_FIFO', `prompt FIFO missing: ${fifo}`)
|
|
455
|
+
}
|
|
456
|
+
const fd = fs.openSync(fifo, fsConstants.O_WRONLY)
|
|
457
|
+
try {
|
|
458
|
+
const wireText = encodeAgentPromptEnvelope(prompt)
|
|
459
|
+
if (!wireText) {
|
|
460
|
+
throw new AgentSessionError('EMPTY_PROMPT', 'prompt text is empty')
|
|
461
|
+
}
|
|
462
|
+
fs.writeSync(fd, wireText + '\n')
|
|
463
|
+
} finally {
|
|
464
|
+
fs.closeSync(fd)
|
|
465
|
+
}
|
|
466
|
+
updateSessionStatus(sessionId, {
|
|
467
|
+
lastPrompt: prompt.displayText ?? prompt.text,
|
|
468
|
+
status: 'working',
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function endSession(sessionId: string): Promise<void> {
|
|
473
|
+
const session = findSessionById(sessionId)
|
|
474
|
+
if (!session) {
|
|
475
|
+
throw new AgentSessionError('NO_SESSION', `no session with id=${sessionId}`)
|
|
476
|
+
}
|
|
477
|
+
if (pidIsAlive(session.wrapperPid, sessionId)) {
|
|
478
|
+
try {
|
|
479
|
+
process.kill(session.wrapperPid!, 'SIGTERM')
|
|
480
|
+
} catch {}
|
|
481
|
+
}
|
|
482
|
+
// guard rm: only delete paths we fully own, derived from getUserDataDir()
|
|
483
|
+
const dir = sessionDir(sessionId)
|
|
484
|
+
const base = getUserDataDir()
|
|
485
|
+
if (dir.startsWith(base)) {
|
|
486
|
+
try {
|
|
487
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
488
|
+
} catch {}
|
|
489
|
+
}
|
|
490
|
+
updateSessionStatus(sessionId, { status: 'ended', wrapperPid: undefined })
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// --- event subscription ---
|
|
494
|
+
|
|
495
|
+
/** open events.out and stream events to onEvent. returns an unsubscribe fn.
|
|
496
|
+
* this is a single-reader channel — only ONE caller (the bridge daemon's
|
|
497
|
+
* AgentHost) should hold a live subscription at a time. every other
|
|
498
|
+
* consumer (electron, cli watch, browser shell) routes through the daemon
|
|
499
|
+
* via the agent:* ws protocol, where the daemon fans events out to N
|
|
500
|
+
* subscribers without re-reading the FIFO. */
|
|
501
|
+
export function subscribeEvents(
|
|
502
|
+
sessionId: string,
|
|
503
|
+
onEvent: (event: AgentEvent) => void,
|
|
504
|
+
): () => void {
|
|
505
|
+
const fifo = eventsFifoPath(sessionId)
|
|
506
|
+
if (!fs.existsSync(fifo)) {
|
|
507
|
+
throw new AgentSessionError('NO_FIFO', `events FIFO missing: ${fifo}`)
|
|
508
|
+
}
|
|
509
|
+
// O_RDWR keeps the FIFO open even if every writer closes — without it,
|
|
510
|
+
// the wrapper's first flush-then-close between turns would EOF the
|
|
511
|
+
// read stream. autoClose: true lets stream.destroy() close the fd
|
|
512
|
+
// synchronously, so unsubscribe doesn't have to race a separate
|
|
513
|
+
// fs.closeSync against an in-flight fs.read syscall (which wedges on
|
|
514
|
+
// bun under O_RDWR FIFOs).
|
|
515
|
+
const fd = fs.openSync(fifo, fsConstants.O_RDWR)
|
|
516
|
+
const stream = fs.createReadStream('', { fd, autoClose: true })
|
|
517
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
|
|
518
|
+
rl.on('line', (line) => {
|
|
519
|
+
const event = parseAgentEventLine(line)
|
|
520
|
+
if (event) onEvent(event)
|
|
521
|
+
})
|
|
522
|
+
let closed = false
|
|
523
|
+
return () => {
|
|
524
|
+
if (closed) return
|
|
525
|
+
closed = true
|
|
526
|
+
try {
|
|
527
|
+
rl.close()
|
|
528
|
+
} catch {}
|
|
529
|
+
try {
|
|
530
|
+
stream.destroy()
|
|
531
|
+
} catch {}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// --- wait for a specific event (used by startSession + watch tools) ---
|
|
536
|
+
|
|
537
|
+
async function waitForFirstEvent(
|
|
538
|
+
fifo: string,
|
|
539
|
+
predicate: (e: AgentEvent) => boolean,
|
|
540
|
+
timeoutMs: number,
|
|
541
|
+
): Promise<AgentEvent | null> {
|
|
542
|
+
const fd = fs.openSync(fifo, fsConstants.O_RDWR | fsConstants.O_NONBLOCK)
|
|
543
|
+
const buf = Buffer.alloc(8192)
|
|
544
|
+
let leftover = ''
|
|
545
|
+
const deadline = Date.now() + timeoutMs
|
|
546
|
+
try {
|
|
547
|
+
while (Date.now() < deadline) {
|
|
548
|
+
let n = 0
|
|
549
|
+
try {
|
|
550
|
+
n = fs.readSync(fd, buf, 0, buf.length, null)
|
|
551
|
+
} catch (err) {
|
|
552
|
+
if ((err as NodeJS.ErrnoException).code !== 'EAGAIN') throw err
|
|
553
|
+
n = 0
|
|
554
|
+
}
|
|
555
|
+
if (n > 0) {
|
|
556
|
+
leftover += buf.subarray(0, n).toString('utf8')
|
|
557
|
+
let idx: number
|
|
558
|
+
while ((idx = leftover.indexOf('\n')) >= 0) {
|
|
559
|
+
const line = leftover.slice(0, idx)
|
|
560
|
+
leftover = leftover.slice(idx + 1)
|
|
561
|
+
const event = parseAgentEventLine(line)
|
|
562
|
+
if (event && predicate(event)) return event
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return null
|
|
569
|
+
} finally {
|
|
570
|
+
fs.closeSync(fd)
|
|
571
|
+
}
|
|
572
|
+
}
|