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,576 @@
|
|
|
1
|
+
// agent routing extension for SootSimBridgeHost.
|
|
2
|
+
//
|
|
3
|
+
// owns the single FIFO reader per agent session (closes the
|
|
4
|
+
// "single-reader channel" gap that agent-sessions.ts explicitly warns
|
|
5
|
+
// about), and fans events out to every WS subscriber — CLI `sootsim
|
|
6
|
+
// agent watch`, electron main, and browser shells all consume the same
|
|
7
|
+
// stream.
|
|
8
|
+
//
|
|
9
|
+
// message shapes follow the existing ws-bridge convention:
|
|
10
|
+
// client → daemon: { id, type: 'agent:…', …payload }
|
|
11
|
+
// daemon → client: { id, result } | { id, error, code? }
|
|
12
|
+
// plus server-initiated pushes (no id):
|
|
13
|
+
// { type: 'agent:event', sessionId, event }
|
|
14
|
+
// { type: 'agent:session-status', session }
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import {
|
|
19
|
+
scanDevServers,
|
|
20
|
+
type DiscoveredServer,
|
|
21
|
+
} from '../../scripts/dev-server-scanner.ts'
|
|
22
|
+
import {
|
|
23
|
+
AgentSessionError,
|
|
24
|
+
endSession as endAgentSession,
|
|
25
|
+
sendPrompt as sendAgentPrompt,
|
|
26
|
+
startSession as startAgentSession,
|
|
27
|
+
subscribeEvents as subscribeAgentEvents,
|
|
28
|
+
transcriptPath,
|
|
29
|
+
type Provider,
|
|
30
|
+
} from '../agent-sessions.ts'
|
|
31
|
+
import {
|
|
32
|
+
deleteProject,
|
|
33
|
+
findProjectById,
|
|
34
|
+
findSessionById,
|
|
35
|
+
getUserDataDir,
|
|
36
|
+
listProjects,
|
|
37
|
+
listSessions,
|
|
38
|
+
recordTurnTelemetry,
|
|
39
|
+
seedFromDemoAppRegistry,
|
|
40
|
+
updateSessionStatus,
|
|
41
|
+
upsertProject,
|
|
42
|
+
type AgentSession,
|
|
43
|
+
type AttachedProject,
|
|
44
|
+
} from '../attached-projects.ts'
|
|
45
|
+
import type { AgentEvent } from '../agent-events.ts'
|
|
46
|
+
import type { AgentPromptEnvelope } from '../agent-prompt.ts'
|
|
47
|
+
import type { WebSocket } from 'ws'
|
|
48
|
+
|
|
49
|
+
// ws.readyState constant — avoid importing the module just for this enum.
|
|
50
|
+
const WS_OPEN = 1
|
|
51
|
+
|
|
52
|
+
interface FanoutSubscription {
|
|
53
|
+
unsubscribe: () => void
|
|
54
|
+
refCount: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface PendingPromptEcho {
|
|
58
|
+
sentAt: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AgentHostOptions {
|
|
62
|
+
/** callback so hosts with a soot-env context (electron, dev middleware)
|
|
63
|
+
* can inject their own exclude list. defaults to the env-derived list
|
|
64
|
+
* the rest of the repo uses, so `sootsim server` standalone does the
|
|
65
|
+
* right thing. */
|
|
66
|
+
getExcludePorts?: () => number[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** default excludes keep the dev-server scanner from hitting soot's own
|
|
70
|
+
* web / zero / postgres / r2 ports. mirrored from
|
|
71
|
+
* sootsim-engine/src/dev-scan-excludes.ts — duplicated rather than
|
|
72
|
+
* imported because sootsim is a dependency of sootsim-engine, not the
|
|
73
|
+
* other way around. */
|
|
74
|
+
function defaultExcludePorts(): number[] {
|
|
75
|
+
return [
|
|
76
|
+
Number(process.env.VITE_PORT_WEB || process.env.PORT || 3000),
|
|
77
|
+
Number(process.env.VITE_PORT_ZERO || 7849),
|
|
78
|
+
Number(process.env.VITE_PORT_POSTGRES || 7432),
|
|
79
|
+
Number(process.env.VITE_PORT_R2 || 9500),
|
|
80
|
+
].filter((p) => Number.isFinite(p) && p > 0)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class AgentHost {
|
|
84
|
+
// live fan-out, keyed by sessionId. refcount tracks how many sockets have
|
|
85
|
+
// subscribed; the FIFO reader stays open as long as refcount > 0.
|
|
86
|
+
private subscriptions = new Map<string, FanoutSubscription>()
|
|
87
|
+
// per-socket subscription set so ws.close can clean up without scanning
|
|
88
|
+
// every session.
|
|
89
|
+
private sessionsBySocket = new Map<WebSocket, Set<string>>()
|
|
90
|
+
// every connected socket (regardless of role) — gets session-status
|
|
91
|
+
// pushes so a CLI `sootsim agent sessions --watch` can react to state
|
|
92
|
+
// changes driven by another client.
|
|
93
|
+
private allSockets = new Set<WebSocket>()
|
|
94
|
+
// agent wrappers also emit prompt-received from the FIFO reader, but the
|
|
95
|
+
// shell/daemon already knows the user-facing display text when send-prompt
|
|
96
|
+
// is accepted. emit the friendly prompt immediately and suppress the raw
|
|
97
|
+
// wrapper echoes that follow a moment later.
|
|
98
|
+
private pendingPromptEchoes = new Map<string, PendingPromptEcho[]>()
|
|
99
|
+
// accepted prompts serialize inside the wrapper, so a second send while the
|
|
100
|
+
// session is already working becomes queued follow-up work. keep a small
|
|
101
|
+
// in-memory count so session status stays `working` until that backlog
|
|
102
|
+
// truly drains.
|
|
103
|
+
private pendingTurns = new Map<string, number>()
|
|
104
|
+
private opts: AgentHostOptions
|
|
105
|
+
|
|
106
|
+
constructor(opts: AgentHostOptions = {}) {
|
|
107
|
+
this.opts = opts
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
registerSocket(ws: WebSocket): void {
|
|
111
|
+
this.allSockets.add(ws)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
unregisterSocket(ws: WebSocket): void {
|
|
115
|
+
const sessions = this.sessionsBySocket.get(ws)
|
|
116
|
+
if (sessions) {
|
|
117
|
+
for (const sessionId of sessions) {
|
|
118
|
+
this.decrementSubscription(sessionId)
|
|
119
|
+
}
|
|
120
|
+
this.sessionsBySocket.delete(ws)
|
|
121
|
+
}
|
|
122
|
+
this.allSockets.delete(ws)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** handle an agent:* message. returns true iff the message was recognized
|
|
126
|
+
* as an agent message (so the caller knows to stop dispatching). */
|
|
127
|
+
async handleMessage(ws: WebSocket, msg: any): Promise<boolean> {
|
|
128
|
+
const type = msg?.type
|
|
129
|
+
if (typeof type !== 'string' || !type.startsWith('agent:')) return false
|
|
130
|
+
const id = msg.id
|
|
131
|
+
try {
|
|
132
|
+
const result = await this.dispatch(ws, type, msg)
|
|
133
|
+
this.respond(ws, id, result)
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (err instanceof AgentSessionError) {
|
|
136
|
+
this.respondError(ws, id, err.message, err.code)
|
|
137
|
+
} else {
|
|
138
|
+
this.respondError(ws, id, err instanceof Error ? err.message : String(err))
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** run once on daemon boot. idempotent — `seedFromDemoAppRegistry` no-ops
|
|
145
|
+
* when the store already has projects. */
|
|
146
|
+
async seedOnBoot(): Promise<void> {
|
|
147
|
+
try {
|
|
148
|
+
await seedFromDemoAppRegistry()
|
|
149
|
+
} catch (err) {
|
|
150
|
+
process.stderr.write(
|
|
151
|
+
`[sootsim-agent] seedFromDemoAppRegistry failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** terminate every subscription and drop every socket reference. called
|
|
157
|
+
* by the bridge host during shutdown. */
|
|
158
|
+
close(): void {
|
|
159
|
+
for (const sub of this.subscriptions.values()) {
|
|
160
|
+
try {
|
|
161
|
+
sub.unsubscribe()
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
this.subscriptions.clear()
|
|
165
|
+
this.sessionsBySocket.clear()
|
|
166
|
+
this.allSockets.clear()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- dispatch ---
|
|
170
|
+
|
|
171
|
+
private async dispatch(ws: WebSocket, type: string, msg: any): Promise<unknown> {
|
|
172
|
+
switch (type) {
|
|
173
|
+
case 'agent:list-projects':
|
|
174
|
+
return listProjects()
|
|
175
|
+
case 'agent:upsert-project':
|
|
176
|
+
return upsertProject(msg.input ?? {})
|
|
177
|
+
case 'agent:delete-project':
|
|
178
|
+
deleteProject(String(msg.projectId))
|
|
179
|
+
return { ok: true }
|
|
180
|
+
case 'agent:auto-attach-for-url':
|
|
181
|
+
return this.autoAttachForUrl(msg.input ?? {})
|
|
182
|
+
case 'agent:list-sessions':
|
|
183
|
+
return listSessions(msg.projectId ? String(msg.projectId) : undefined)
|
|
184
|
+
case 'agent:start-session':
|
|
185
|
+
return this.doStartSession(msg.input ?? {})
|
|
186
|
+
case 'agent:send-prompt': {
|
|
187
|
+
const sessionId = String(msg.sessionId)
|
|
188
|
+
const session = findSessionById(sessionId)
|
|
189
|
+
if (!session) {
|
|
190
|
+
throw new AgentSessionError('NO_SESSION', `no session: ${sessionId}`)
|
|
191
|
+
}
|
|
192
|
+
const prompt = this.normalizePromptEnvelope(msg)
|
|
193
|
+
await sendAgentPrompt(sessionId, prompt)
|
|
194
|
+
return this.notePromptAccepted(sessionId, prompt, session.status === 'working')
|
|
195
|
+
}
|
|
196
|
+
case 'agent:end-session':
|
|
197
|
+
this.dropSessionFanout(String(msg.sessionId))
|
|
198
|
+
await endAgentSession(String(msg.sessionId))
|
|
199
|
+
const ended = findSessionById(String(msg.sessionId))
|
|
200
|
+
if (ended) {
|
|
201
|
+
this.broadcastSessionStatus(ended)
|
|
202
|
+
}
|
|
203
|
+
return { ok: true }
|
|
204
|
+
case 'agent:get-transcript':
|
|
205
|
+
return this.getTranscript(String(msg.sessionId))
|
|
206
|
+
case 'agent:get-paths':
|
|
207
|
+
return this.getPaths()
|
|
208
|
+
case 'agent:subscribe-events':
|
|
209
|
+
return this.subscribeSocket(ws, String(msg.sessionId))
|
|
210
|
+
case 'agent:unsubscribe-events':
|
|
211
|
+
return this.unsubscribeSocket(ws, String(msg.sessionId))
|
|
212
|
+
default:
|
|
213
|
+
throw new AgentSessionError('UNKNOWN_AGENT_MSG', `unknown agent message: ${type}`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- operation impls ---
|
|
218
|
+
|
|
219
|
+
private async doStartSession(input: {
|
|
220
|
+
projectId: string
|
|
221
|
+
provider?: Provider
|
|
222
|
+
codexBin?: string
|
|
223
|
+
claudeBin?: string
|
|
224
|
+
freshThread?: boolean
|
|
225
|
+
}): Promise<{ session: AgentSession; wrapperPid: number }> {
|
|
226
|
+
const project = findProjectById(input.projectId)
|
|
227
|
+
if (!project) {
|
|
228
|
+
throw new AgentSessionError('NO_PROJECT', `no project: ${input.projectId}`)
|
|
229
|
+
}
|
|
230
|
+
const result = await startAgentSession(input)
|
|
231
|
+
this.broadcastSessionStatus(result.session)
|
|
232
|
+
return result
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private async autoAttachForUrl(input: {
|
|
236
|
+
bundleUrl?: string
|
|
237
|
+
provider?: Provider
|
|
238
|
+
}): Promise<{ project: AttachedProject | null }> {
|
|
239
|
+
const bundleUrl = input.bundleUrl ?? ''
|
|
240
|
+
const targetPort = (() => {
|
|
241
|
+
try {
|
|
242
|
+
return new URL(bundleUrl).port || null
|
|
243
|
+
} catch {
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
})()
|
|
247
|
+
if (!targetPort) return { project: null }
|
|
248
|
+
const excludePorts = this.opts.getExcludePorts?.() ?? defaultExcludePorts()
|
|
249
|
+
const servers = await scanDevServers({ excludePorts })
|
|
250
|
+
const match = servers.find((s) => String(s.port) === targetPort)
|
|
251
|
+
if (!match || !match.cwd) return { project: null }
|
|
252
|
+
const existing = listProjects().find((p) => p.cwd === match.cwd) ?? null
|
|
253
|
+
const knownBundleUrls = Array.from(
|
|
254
|
+
new Set([...(existing?.knownBundleUrls ?? []), match.bundleUrl, bundleUrl]),
|
|
255
|
+
)
|
|
256
|
+
const project = upsertProject({
|
|
257
|
+
cwd: match.cwd,
|
|
258
|
+
name: match.projectName ?? path.basename(match.cwd),
|
|
259
|
+
preferredProvider: input.provider ?? existing?.preferredProvider,
|
|
260
|
+
sourceRoots: existing?.sourceRoots ?? [match.cwd],
|
|
261
|
+
knownBundleUrls,
|
|
262
|
+
framework: existing?.framework ?? mapFrameworkToProjectFramework(match.framework),
|
|
263
|
+
bundleId: match.bundleId ?? existing?.bundleId,
|
|
264
|
+
})
|
|
265
|
+
return { project }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private getTranscript(sessionId: string): string | { error: string; code: string } {
|
|
269
|
+
const p = transcriptPath(sessionId)
|
|
270
|
+
if (!fs.existsSync(p)) {
|
|
271
|
+
return { error: 'transcript not found', code: 'NO_TRANSCRIPT' }
|
|
272
|
+
}
|
|
273
|
+
return fs.readFileSync(p, 'utf8')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private getPaths() {
|
|
277
|
+
const dir = getUserDataDir()
|
|
278
|
+
return {
|
|
279
|
+
userDataDir: dir,
|
|
280
|
+
storeFile: path.join(dir, 'attached-projects.json'),
|
|
281
|
+
sessionsDir: path.join(dir, 'sessions'),
|
|
282
|
+
transcriptsDir: path.join(dir, 'transcripts'),
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// --- subscription management ---
|
|
287
|
+
|
|
288
|
+
private subscribeSocket(
|
|
289
|
+
ws: WebSocket,
|
|
290
|
+
sessionId: string,
|
|
291
|
+
): { ok: true; refCount: number } {
|
|
292
|
+
let sockets = this.sessionsBySocket.get(ws)
|
|
293
|
+
if (!sockets) {
|
|
294
|
+
sockets = new Set()
|
|
295
|
+
this.sessionsBySocket.set(ws, sockets)
|
|
296
|
+
}
|
|
297
|
+
if (sockets.has(sessionId)) {
|
|
298
|
+
return { ok: true, refCount: this.subscriptions.get(sessionId)?.refCount ?? 1 }
|
|
299
|
+
}
|
|
300
|
+
sockets.add(sessionId)
|
|
301
|
+
const existing = this.subscriptions.get(sessionId)
|
|
302
|
+
if (existing) {
|
|
303
|
+
existing.refCount++
|
|
304
|
+
return { ok: true, refCount: existing.refCount }
|
|
305
|
+
}
|
|
306
|
+
const unsubscribe = subscribeAgentEvents(sessionId, (event) => {
|
|
307
|
+
const coalesced = this.coalescePromptEcho(sessionId, event)
|
|
308
|
+
if (coalesced) {
|
|
309
|
+
this.applySessionEvent(sessionId, coalesced)
|
|
310
|
+
this.fanOutEvent(sessionId, coalesced)
|
|
311
|
+
}
|
|
312
|
+
// recordTurnTelemetry mirrors what electron main used to do — keep it
|
|
313
|
+
// here so cost tracking happens regardless of which client subscribed.
|
|
314
|
+
if (event.type === 'turn-completed') {
|
|
315
|
+
const session = findSessionById(sessionId)
|
|
316
|
+
if (session) {
|
|
317
|
+
try {
|
|
318
|
+
recordTurnTelemetry(session.projectId, {
|
|
319
|
+
usd: event.costUsd,
|
|
320
|
+
ts: event.ts,
|
|
321
|
+
})
|
|
322
|
+
} catch (err) {
|
|
323
|
+
process.stderr.write(
|
|
324
|
+
`[sootsim-agent] recordTurnTelemetry failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
this.subscriptions.set(sessionId, { unsubscribe, refCount: 1 })
|
|
331
|
+
return { ok: true, refCount: 1 }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private unsubscribeSocket(
|
|
335
|
+
ws: WebSocket,
|
|
336
|
+
sessionId: string,
|
|
337
|
+
): { ok: true; refCount: number } {
|
|
338
|
+
const sockets = this.sessionsBySocket.get(ws)
|
|
339
|
+
if (!sockets || !sockets.has(sessionId)) return { ok: true, refCount: 0 }
|
|
340
|
+
sockets.delete(sessionId)
|
|
341
|
+
return this.decrementSubscription(sessionId)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private decrementSubscription(sessionId: string): { ok: true; refCount: number } {
|
|
345
|
+
const existing = this.subscriptions.get(sessionId)
|
|
346
|
+
if (!existing) return { ok: true, refCount: 0 }
|
|
347
|
+
existing.refCount--
|
|
348
|
+
if (existing.refCount <= 0) {
|
|
349
|
+
try {
|
|
350
|
+
existing.unsubscribe()
|
|
351
|
+
} catch {}
|
|
352
|
+
this.subscriptions.delete(sessionId)
|
|
353
|
+
return { ok: true, refCount: 0 }
|
|
354
|
+
}
|
|
355
|
+
return { ok: true, refCount: existing.refCount }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** end-session tears the FIFO down, so drop our reader before it
|
|
359
|
+
* disappears regardless of remaining subscriber refcount. */
|
|
360
|
+
private dropSessionFanout(sessionId: string): void {
|
|
361
|
+
const existing = this.subscriptions.get(sessionId)
|
|
362
|
+
if (existing) {
|
|
363
|
+
try {
|
|
364
|
+
existing.unsubscribe()
|
|
365
|
+
} catch {}
|
|
366
|
+
this.subscriptions.delete(sessionId)
|
|
367
|
+
}
|
|
368
|
+
for (const sockets of this.sessionsBySocket.values()) {
|
|
369
|
+
sockets.delete(sessionId)
|
|
370
|
+
}
|
|
371
|
+
this.clearPromptTracking(sessionId)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// --- wire pushes + responses ---
|
|
375
|
+
|
|
376
|
+
private normalizePromptEnvelope(msg: any): AgentPromptEnvelope {
|
|
377
|
+
if (msg?.prompt && typeof msg.prompt === 'object') {
|
|
378
|
+
const prompt = msg.prompt as Record<string, unknown>
|
|
379
|
+
return {
|
|
380
|
+
text: String(prompt.text ?? ''),
|
|
381
|
+
...(typeof prompt.displayText === 'string'
|
|
382
|
+
? { displayText: prompt.displayText }
|
|
383
|
+
: {}),
|
|
384
|
+
...(typeof prompt.inspectSummary === 'string'
|
|
385
|
+
? { inspectSummary: prompt.inspectSummary }
|
|
386
|
+
: {}),
|
|
387
|
+
...(typeof prompt.inspectTrace === 'string'
|
|
388
|
+
? { inspectTrace: prompt.inspectTrace }
|
|
389
|
+
: {}),
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
text: String(msg?.text ?? ''),
|
|
394
|
+
...(typeof msg?.displayText === 'string' ? { displayText: msg.displayText } : {}),
|
|
395
|
+
...(typeof msg?.inspectSummary === 'string'
|
|
396
|
+
? { inspectSummary: msg.inspectSummary }
|
|
397
|
+
: {}),
|
|
398
|
+
...(typeof msg?.inspectTrace === 'string'
|
|
399
|
+
? { inspectTrace: msg.inspectTrace }
|
|
400
|
+
: {}),
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private notePromptAccepted(
|
|
405
|
+
sessionId: string,
|
|
406
|
+
prompt: AgentPromptEnvelope,
|
|
407
|
+
assumeQueued: boolean,
|
|
408
|
+
): { ok: true; queued: boolean; pendingTurns: number; queueDepth: number } {
|
|
409
|
+
const now = Date.now()
|
|
410
|
+
const echoes = this.pendingPromptEchoes.get(sessionId) ?? []
|
|
411
|
+
echoes.push({ sentAt: now })
|
|
412
|
+
this.pendingPromptEchoes.set(sessionId, echoes)
|
|
413
|
+
const pendingTurns =
|
|
414
|
+
Math.max(this.pendingTurns.get(sessionId) ?? 0, assumeQueued ? 1 : 0) + 1
|
|
415
|
+
this.pendingTurns.set(sessionId, pendingTurns)
|
|
416
|
+
const promptText = prompt.displayText ?? prompt.text
|
|
417
|
+
this.patchSession(sessionId, {
|
|
418
|
+
lastPrompt: promptText,
|
|
419
|
+
status: 'working',
|
|
420
|
+
needsAttention: false,
|
|
421
|
+
})
|
|
422
|
+
this.fanOutEvent(sessionId, {
|
|
423
|
+
type: 'prompt-received',
|
|
424
|
+
text: promptText,
|
|
425
|
+
...(prompt.inspectSummary ? { inspectSummary: prompt.inspectSummary } : {}),
|
|
426
|
+
...(prompt.inspectTrace ? { inspectTrace: prompt.inspectTrace } : {}),
|
|
427
|
+
ts: now,
|
|
428
|
+
} as AgentEvent)
|
|
429
|
+
return {
|
|
430
|
+
ok: true,
|
|
431
|
+
queued: pendingTurns > 1,
|
|
432
|
+
pendingTurns,
|
|
433
|
+
queueDepth: Math.max(0, pendingTurns - 1),
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private applySessionEvent(sessionId: string, event: AgentEvent): void {
|
|
438
|
+
switch (event.type) {
|
|
439
|
+
case 'prompt-received':
|
|
440
|
+
case 'turn-started':
|
|
441
|
+
this.patchSession(sessionId, {
|
|
442
|
+
status: 'working',
|
|
443
|
+
needsAttention: false,
|
|
444
|
+
})
|
|
445
|
+
return
|
|
446
|
+
case 'turn-completed': {
|
|
447
|
+
const pendingTurns = this.consumeSettledTurn(sessionId)
|
|
448
|
+
this.patchSession(sessionId, {
|
|
449
|
+
status: pendingTurns > 0 ? 'working' : 'idle',
|
|
450
|
+
needsAttention: false,
|
|
451
|
+
lastTurnFiles: event.filesTouched,
|
|
452
|
+
currentlyEditing: undefined,
|
|
453
|
+
})
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
case 'approval-needed':
|
|
457
|
+
this.patchSession(sessionId, {
|
|
458
|
+
status: 'needs-attention',
|
|
459
|
+
needsAttention: true,
|
|
460
|
+
})
|
|
461
|
+
return
|
|
462
|
+
case 'error': {
|
|
463
|
+
const pendingTurns = this.consumeSettledTurn(sessionId)
|
|
464
|
+
this.patchSession(sessionId, {
|
|
465
|
+
status: pendingTurns > 0 ? 'working' : 'needs-attention',
|
|
466
|
+
needsAttention: pendingTurns <= 0,
|
|
467
|
+
currentlyEditing: undefined,
|
|
468
|
+
})
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
case 'exited':
|
|
472
|
+
this.clearPromptTracking(sessionId)
|
|
473
|
+
this.patchSession(sessionId, {
|
|
474
|
+
status: 'ended',
|
|
475
|
+
needsAttention: false,
|
|
476
|
+
wrapperPid: undefined,
|
|
477
|
+
currentlyEditing: undefined,
|
|
478
|
+
})
|
|
479
|
+
return
|
|
480
|
+
case 'ready':
|
|
481
|
+
case 'turn-reasoning':
|
|
482
|
+
case 'turn-message':
|
|
483
|
+
case 'turn-plan':
|
|
484
|
+
case 'tool-call':
|
|
485
|
+
case 'file-edited':
|
|
486
|
+
case 'file-diff-delta':
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private patchSession(sessionId: string, patch: Partial<AgentSession>): void {
|
|
492
|
+
updateSessionStatus(sessionId, patch)
|
|
493
|
+
const updated = findSessionById(sessionId)
|
|
494
|
+
if (updated) {
|
|
495
|
+
this.broadcastSessionStatus(updated)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private coalescePromptEcho(sessionId: string, event: AgentEvent): AgentEvent | null {
|
|
500
|
+
if (event.type !== 'prompt-received') return event
|
|
501
|
+
const pending = this.pendingPromptEchoes.get(sessionId)
|
|
502
|
+
if (!pending || pending.length === 0) return event
|
|
503
|
+
while (pending.length > 0 && Date.now() - pending[0]!.sentAt > 15_000) {
|
|
504
|
+
pending.shift()
|
|
505
|
+
}
|
|
506
|
+
if (pending.length === 0) {
|
|
507
|
+
this.pendingPromptEchoes.delete(sessionId)
|
|
508
|
+
return event
|
|
509
|
+
}
|
|
510
|
+
pending.shift()
|
|
511
|
+
if (pending.length === 0) {
|
|
512
|
+
this.pendingPromptEchoes.delete(sessionId)
|
|
513
|
+
} else {
|
|
514
|
+
this.pendingPromptEchoes.set(sessionId, pending)
|
|
515
|
+
}
|
|
516
|
+
return null
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private consumeSettledTurn(sessionId: string): number {
|
|
520
|
+
const pendingTurns = Math.max(0, (this.pendingTurns.get(sessionId) ?? 1) - 1)
|
|
521
|
+
if (pendingTurns > 0) {
|
|
522
|
+
this.pendingTurns.set(sessionId, pendingTurns)
|
|
523
|
+
} else {
|
|
524
|
+
this.pendingTurns.delete(sessionId)
|
|
525
|
+
}
|
|
526
|
+
return pendingTurns
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private clearPromptTracking(sessionId: string): void {
|
|
530
|
+
this.pendingPromptEchoes.delete(sessionId)
|
|
531
|
+
this.pendingTurns.delete(sessionId)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private fanOutEvent(sessionId: string, event: AgentEvent): void {
|
|
535
|
+
const payload = JSON.stringify({ type: 'agent:event', sessionId, event })
|
|
536
|
+
for (const [ws, sessions] of this.sessionsBySocket) {
|
|
537
|
+
if (!sessions.has(sessionId)) continue
|
|
538
|
+
if (ws.readyState !== WS_OPEN) continue
|
|
539
|
+
try {
|
|
540
|
+
ws.send(payload)
|
|
541
|
+
} catch {}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private broadcastSessionStatus(session: AgentSession): void {
|
|
546
|
+
const payload = JSON.stringify({ type: 'agent:session-status', session })
|
|
547
|
+
for (const ws of this.allSockets) {
|
|
548
|
+
if (ws.readyState !== WS_OPEN) continue
|
|
549
|
+
try {
|
|
550
|
+
ws.send(payload)
|
|
551
|
+
} catch {}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private respond(ws: WebSocket, id: unknown, result: unknown): void {
|
|
556
|
+
if (ws.readyState !== WS_OPEN) return
|
|
557
|
+
try {
|
|
558
|
+
ws.send(JSON.stringify({ id, result }))
|
|
559
|
+
} catch {}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private respondError(ws: WebSocket, id: unknown, error: string, code?: string): void {
|
|
563
|
+
if (ws.readyState !== WS_OPEN) return
|
|
564
|
+
try {
|
|
565
|
+
ws.send(JSON.stringify({ id, error, ...(code ? { code } : {}) }))
|
|
566
|
+
} catch {}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function mapFrameworkToProjectFramework(
|
|
571
|
+
fw: DiscoveredServer['framework'],
|
|
572
|
+
): 'expo' | 'one' | 'rock' | 'unknown' {
|
|
573
|
+
if (fw === 'expo') return 'expo'
|
|
574
|
+
if (fw === 'one' || fw === 'vxrn') return 'one'
|
|
575
|
+
return 'unknown'
|
|
576
|
+
}
|