@juspay/shooter 1.21.0 → 1.22.0
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/.claude/hooks/notifier.cjs +94 -1
- package/build/client/_app/immutable/assets/2.JWRrnR-w.css +1 -0
- package/build/client/_app/immutable/assets/2.JWRrnR-w.css.br +0 -0
- package/build/client/_app/immutable/assets/2.JWRrnR-w.css.gz +0 -0
- package/build/client/_app/immutable/chunks/BfbPKMXz.js +3 -0
- package/build/client/_app/immutable/chunks/BfbPKMXz.js.br +0 -0
- package/build/client/_app/immutable/chunks/BfbPKMXz.js.gz +0 -0
- package/build/client/_app/immutable/chunks/C4Hns_Wl.js +1 -0
- package/build/client/_app/immutable/chunks/C4Hns_Wl.js.br +0 -0
- package/build/client/_app/immutable/chunks/C4Hns_Wl.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{BmfLecb1.js → CZg4kn4E.js} +1 -1
- package/build/client/_app/immutable/chunks/CZg4kn4E.js.br +0 -0
- package/build/client/_app/immutable/chunks/CZg4kn4E.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{EqMAkEha.js → DhK7PwI_.js} +1 -1
- package/build/client/_app/immutable/chunks/DhK7PwI_.js.br +0 -0
- package/build/client/_app/immutable/chunks/DhK7PwI_.js.gz +0 -0
- package/build/client/_app/immutable/entry/{app.CeSxgGat.js → app.CTqz33nP.js} +2 -2
- package/build/client/_app/immutable/entry/app.CTqz33nP.js.br +0 -0
- package/build/client/_app/immutable/entry/app.CTqz33nP.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js +1 -0
- package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js.br +2 -0
- package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{0.oaPwxh1O.js → 0.Qn7Ktiht.js} +1 -1
- package/build/client/_app/immutable/nodes/0.Qn7Ktiht.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.Qn7Ktiht.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{1.DMPyoM-M.js → 1.BxWOfNlo.js} +1 -1
- package/build/client/_app/immutable/nodes/1.BxWOfNlo.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.BxWOfNlo.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{10.Cbm7nQKK.js → 10.BGPYD1s1.js} +1 -1
- package/build/client/_app/immutable/nodes/10.BGPYD1s1.js.br +0 -0
- package/build/client/_app/immutable/nodes/10.BGPYD1s1.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{11.CKmZjP_a.js → 11.BxY1PUjC.js} +1 -1
- package/build/client/_app/immutable/nodes/11.BxY1PUjC.js.br +0 -0
- package/build/client/_app/immutable/nodes/{11.CKmZjP_a.js.gz → 11.BxY1PUjC.js.gz} +0 -0
- package/build/client/_app/immutable/nodes/2.Bc2qALkX.js +23 -0
- package/build/client/_app/immutable/nodes/2.Bc2qALkX.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.Bc2qALkX.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{3.BgLpGnzb.js → 3.N2-A8noI.js} +1 -1
- package/build/client/_app/immutable/nodes/3.N2-A8noI.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.N2-A8noI.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{5.Avc1-gVb.js → 5.DziEu9rx.js} +1 -1
- package/build/client/_app/immutable/nodes/5.DziEu9rx.js.br +0 -0
- package/build/client/_app/immutable/nodes/5.DziEu9rx.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{6.Dw2wEssJ.js → 6.BWF9Qx6F.js} +1 -1
- package/build/client/_app/immutable/nodes/6.BWF9Qx6F.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.BWF9Qx6F.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{7.DwKZjoBg.js → 7.DHuDIdpz.js} +1 -1
- package/build/client/_app/immutable/nodes/7.DHuDIdpz.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.DHuDIdpz.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{8.ZUAI6g5E.js → 8.D0Ijt9Vv.js} +1 -1
- package/build/client/_app/immutable/nodes/8.D0Ijt9Vv.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.D0Ijt9Vv.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{9.I_KGXPwB.js → 9.2Piwo35J.js} +1 -1
- package/build/client/_app/immutable/nodes/9.2Piwo35J.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.2Piwo35J.js.gz +0 -0
- package/build/client/_app/version.json +1 -1
- package/build/client/_app/version.json.br +0 -0
- package/build/client/_app/version.json.gz +0 -0
- package/build/pty-holder.cjs +6 -0
- package/build/server/chunks/{0-vrTNAfZB.js → 0-CVGsyVKN.js} +2 -2
- package/build/server/chunks/{0-vrTNAfZB.js.map → 0-CVGsyVKN.js.map} +1 -1
- package/build/server/chunks/{1-nbr-bOoF.js → 1-BAlAsKdp.js} +2 -2
- package/build/server/chunks/{1-nbr-bOoF.js.map → 1-BAlAsKdp.js.map} +1 -1
- package/build/server/chunks/{10-ChyvvJ6w.js → 10-BUCX7Aqz.js} +2 -2
- package/build/server/chunks/{10-ChyvvJ6w.js.map → 10-BUCX7Aqz.js.map} +1 -1
- package/build/server/chunks/{11-6ZAjL3uU.js → 11-DHPvc2yA.js} +2 -2
- package/build/server/chunks/{11-6ZAjL3uU.js.map → 11-DHPvc2yA.js.map} +1 -1
- package/build/server/chunks/{2-DWFRVDWJ.js → 2-DLOMdCHW.js} +4 -4
- package/build/server/chunks/{2-DWFRVDWJ.js.map → 2-DLOMdCHW.js.map} +1 -1
- package/build/server/chunks/{3-CKANM_WM.js → 3-DCf69LYo.js} +2 -2
- package/build/server/chunks/{3-CKANM_WM.js.map → 3-DCf69LYo.js.map} +1 -1
- package/build/server/chunks/{5-BxVjs2qi.js → 5-D-Uv1voC.js} +2 -2
- package/build/server/chunks/{5-BxVjs2qi.js.map → 5-D-Uv1voC.js.map} +1 -1
- package/build/server/chunks/{6-Cbf1AAMQ.js → 6-DUrC2Naz.js} +2 -2
- package/build/server/chunks/{6-Cbf1AAMQ.js.map → 6-DUrC2Naz.js.map} +1 -1
- package/build/server/chunks/{7-CMK2quEf.js → 7-TXwjMHt2.js} +2 -2
- package/build/server/chunks/{7-CMK2quEf.js.map → 7-TXwjMHt2.js.map} +1 -1
- package/build/server/chunks/{8-DhdfkfDM.js → 8-D2X_jBsT.js} +2 -2
- package/build/server/chunks/{8-DhdfkfDM.js.map → 8-D2X_jBsT.js.map} +1 -1
- package/build/server/chunks/{9-CPpxtRM5.js → 9-DK0hH5Xa.js} +2 -2
- package/build/server/chunks/{9-CPpxtRM5.js.map → 9-DK0hH5Xa.js.map} +1 -1
- package/build/server/chunks/_page.svelte-8OFzwdNA.js +758 -0
- package/build/server/chunks/_page.svelte-8OFzwdNA.js.map +1 -0
- package/build/server/chunks/{_server.ts-BWVlO8iV.js → _server.ts-05JJOdcX.js} +15 -12
- package/build/server/chunks/_server.ts-05JJOdcX.js.map +1 -0
- package/build/server/chunks/{_server.ts-CC2K8-L2.js → _server.ts-B54Pvhgc.js} +3 -2
- package/build/server/chunks/_server.ts-B54Pvhgc.js.map +1 -0
- package/build/server/chunks/{_server.ts-BevnuePu.js → _server.ts-BCljU9Sg.js} +7 -3
- package/build/server/chunks/_server.ts-BCljU9Sg.js.map +1 -0
- package/build/server/chunks/{_server.ts-D-vgx5UZ.js → _server.ts-BTmknWpO.js} +2 -2
- package/build/server/chunks/{_server.ts-D-vgx5UZ.js.map → _server.ts-BTmknWpO.js.map} +1 -1
- package/build/server/chunks/{_server.ts-tChyh9FX.js → _server.ts-BXhmLZwN.js} +4 -2
- package/build/server/chunks/{_server.ts-tChyh9FX.js.map → _server.ts-BXhmLZwN.js.map} +1 -1
- package/build/server/chunks/{_server.ts-CvJKTS3Z.js → _server.ts-BbRSpB74.js} +4 -2
- package/build/server/chunks/{_server.ts-CvJKTS3Z.js.map → _server.ts-BbRSpB74.js.map} +1 -1
- package/build/server/chunks/{_server.ts-Dp-hXW_I.js → _server.ts-Bol54_Qo.js} +3 -2
- package/build/server/chunks/_server.ts-Bol54_Qo.js.map +1 -0
- package/build/server/chunks/{_server.ts-X1R7L_QI.js → _server.ts-C0PO_cAu.js} +3 -2
- package/build/server/chunks/{_server.ts-X1R7L_QI.js.map → _server.ts-C0PO_cAu.js.map} +1 -1
- package/build/server/chunks/_server.ts-C6NRpe7e.js +33 -0
- package/build/server/chunks/_server.ts-C6NRpe7e.js.map +1 -0
- package/build/server/chunks/_server.ts-CGqCOCdK.js +53 -0
- package/build/server/chunks/_server.ts-CGqCOCdK.js.map +1 -0
- package/build/server/chunks/{_server.ts-CA5KUENM.js → _server.ts-CZb-BI5H.js} +3 -2
- package/build/server/chunks/_server.ts-CZb-BI5H.js.map +1 -0
- package/build/server/chunks/{_server.ts-VzDcFFgy.js → _server.ts-DPHRUFYS.js} +4 -2
- package/build/server/chunks/_server.ts-DPHRUFYS.js.map +1 -0
- package/build/server/chunks/{_server.ts-D0zRDSx0.js → _server.ts-D_WRex0k.js} +4 -2
- package/build/server/chunks/_server.ts-D_WRex0k.js.map +1 -0
- package/build/server/chunks/{_server.ts-CD7JP3fz.js → _server.ts-DiBMY7Ho.js} +3 -2
- package/build/server/chunks/_server.ts-DiBMY7Ho.js.map +1 -0
- package/build/server/chunks/{library-apns-Dl3iRE2h.js → library-apns-D8RPINlv.js} +62 -7
- package/build/server/chunks/library-apns-D8RPINlv.js.map +1 -0
- package/build/server/chunks/{pending-requests-C9p57WoU.js → pending-requests-8rWjrF6d.js} +3 -2
- package/build/server/chunks/pending-requests-8rWjrF6d.js.map +1 -0
- package/build/server/chunks/presence-store-Bx_g0-Gd.js +23 -0
- package/build/server/chunks/presence-store-Bx_g0-Gd.js.map +1 -0
- package/build/server/chunks/{pty-manager-ZqXqa-6A.js → pty-manager-CoWVT56F.js} +26 -5
- package/build/server/chunks/pty-manager-CoWVT56F.js.map +1 -0
- package/build/server/chunks/shooter-home-4f_HkdGI.js +10 -0
- package/build/server/chunks/shooter-home-4f_HkdGI.js.map +1 -0
- package/build/server/index.js +1 -1
- package/build/server/index.js.map +1 -1
- package/build/server/manifest.js +38 -24
- package/build/server/manifest.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/modules/client/common/index.ts +1 -0
- package/src/lib/modules/client/common/presence.ts +47 -0
- package/src/lib/modules/client/dashboard/AutopilotPanel.svelte +188 -4
- package/src/lib/modules/client/dashboard/autopilot-driver.svelte.ts +681 -0
- package/src/lib/modules/client/dashboard/decide-injection.ts +127 -0
- package/src/lib/modules/client/dashboard/store.svelte.ts +65 -24
- package/src/lib/modules/client/neurolink/fetch-proxy.ts +38 -1
- package/src/lib/modules/server/apn/apns-payload.ts +50 -0
- package/src/lib/modules/server/apn/library-apns.ts +50 -8
- package/src/lib/modules/server/apn/pending-requests.ts +3 -1
- package/src/lib/modules/server/sessions/autopilot-context.ts +57 -0
- package/src/lib/modules/server/sessions/autopilot-engine.ts +148 -43
- package/src/lib/modules/server/sessions/litellm-client.ts +90 -34
- package/src/lib/modules/server/sessions/next-step-consensus.ts +27 -2
- package/src/lib/modules/server/sessions/summary-store.ts +3 -1
- package/src/lib/modules/server/terminal/agent-launch.ts +26 -0
- package/src/lib/modules/server/terminal/pty-holder.cjs +6 -0
- package/src/lib/modules/server/terminal/pty-manager.ts +13 -3
- package/src/lib/modules/server/terminal/session-watcher.ts +12 -2
- package/src/lib/modules/server/terminal/terminal-store.ts +3 -1
- package/src/lib/modules/server/utils/shooter-home.ts +16 -0
- package/src/lib/modules/server/ws/presence-store.ts +50 -0
- package/src/lib/types/autopilot.ts +65 -0
- package/src/routes/api/autopilot/goal/+server.ts +72 -0
- package/src/routes/api/neurolink-proxy/+server.ts +8 -2
- package/src/routes/api/notify/+server.ts +22 -15
- package/src/routes/api/presence/+server.ts +39 -0
- package/src/routes/api/ws-status/+server.ts +8 -5
- package/build/client/_app/immutable/assets/2.BHi6pjT2.css +0 -1
- package/build/client/_app/immutable/assets/2.BHi6pjT2.css.br +0 -0
- package/build/client/_app/immutable/assets/2.BHi6pjT2.css.gz +0 -0
- package/build/client/_app/immutable/chunks/BmfLecb1.js.br +0 -0
- package/build/client/_app/immutable/chunks/BmfLecb1.js.gz +0 -0
- package/build/client/_app/immutable/chunks/CRkG7oE4.js +0 -1
- package/build/client/_app/immutable/chunks/CRkG7oE4.js.br +0 -0
- package/build/client/_app/immutable/chunks/CRkG7oE4.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DOEXXmsh.js +0 -3
- package/build/client/_app/immutable/chunks/DOEXXmsh.js.br +0 -0
- package/build/client/_app/immutable/chunks/DOEXXmsh.js.gz +0 -0
- package/build/client/_app/immutable/chunks/EqMAkEha.js.br +0 -0
- package/build/client/_app/immutable/chunks/EqMAkEha.js.gz +0 -0
- package/build/client/_app/immutable/entry/app.CeSxgGat.js.br +0 -0
- package/build/client/_app/immutable/entry/app.CeSxgGat.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.DrnJFwxA.js +0 -1
- package/build/client/_app/immutable/entry/start.DrnJFwxA.js.br +0 -2
- package/build/client/_app/immutable/entry/start.DrnJFwxA.js.gz +0 -0
- package/build/client/_app/immutable/nodes/0.oaPwxh1O.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.oaPwxh1O.js.gz +0 -0
- package/build/client/_app/immutable/nodes/1.DMPyoM-M.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.DMPyoM-M.js.gz +0 -0
- package/build/client/_app/immutable/nodes/10.Cbm7nQKK.js.br +0 -0
- package/build/client/_app/immutable/nodes/10.Cbm7nQKK.js.gz +0 -0
- package/build/client/_app/immutable/nodes/11.CKmZjP_a.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.zlrdNFtH.js +0 -13
- package/build/client/_app/immutable/nodes/2.zlrdNFtH.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.zlrdNFtH.js.gz +0 -0
- package/build/client/_app/immutable/nodes/3.BgLpGnzb.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.BgLpGnzb.js.gz +0 -0
- package/build/client/_app/immutable/nodes/5.Avc1-gVb.js.br +0 -0
- package/build/client/_app/immutable/nodes/5.Avc1-gVb.js.gz +0 -0
- package/build/client/_app/immutable/nodes/6.Dw2wEssJ.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.Dw2wEssJ.js.gz +0 -0
- package/build/client/_app/immutable/nodes/7.DwKZjoBg.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.DwKZjoBg.js.gz +0 -0
- package/build/client/_app/immutable/nodes/8.ZUAI6g5E.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.ZUAI6g5E.js.gz +0 -0
- package/build/client/_app/immutable/nodes/9.I_KGXPwB.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.I_KGXPwB.js.gz +0 -0
- package/build/server/chunks/_page.svelte-tBuIq8Pg.js +0 -159
- package/build/server/chunks/_page.svelte-tBuIq8Pg.js.map +0 -1
- package/build/server/chunks/_server.ts-BWVlO8iV.js.map +0 -1
- package/build/server/chunks/_server.ts-BevnuePu.js.map +0 -1
- package/build/server/chunks/_server.ts-CA5KUENM.js.map +0 -1
- package/build/server/chunks/_server.ts-CC2K8-L2.js.map +0 -1
- package/build/server/chunks/_server.ts-CD7JP3fz.js.map +0 -1
- package/build/server/chunks/_server.ts-D0zRDSx0.js.map +0 -1
- package/build/server/chunks/_server.ts-Dp-hXW_I.js.map +0 -1
- package/build/server/chunks/_server.ts-VzDcFFgy.js.map +0 -1
- package/build/server/chunks/library-apns-Dl3iRE2h.js.map +0 -1
- package/build/server/chunks/pending-requests-C9p57WoU.js.map +0 -1
- package/build/server/chunks/pty-manager-ZqXqa-6A.js.map +0 -1
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// The phone-resident autonomous loop (runs in the WebView / browser).
|
|
2
|
+
//
|
|
3
|
+
// Watches /ws/events + GET /api/summaries to track per-terminal state, and when a
|
|
4
|
+
// Shooter-managed terminal goes idle with a high-confidence consensus next step, it
|
|
5
|
+
// produces a concrete command, vets it (guardCommand), and injects it into the PTY
|
|
6
|
+
// over /ws/terminal/:id — the same transport humans use. AUTO-INJECT mode.
|
|
7
|
+
//
|
|
8
|
+
// The decision logic lives in the pure, unit-tested decide-injection.ts. This module
|
|
9
|
+
// is the I/O shell: sockets, polling, per-terminal bookkeeping, the kill switch.
|
|
10
|
+
//
|
|
11
|
+
// See docs/superpowers/specs/2026-06-01-phone-autonomous-agent-design.md.
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
ConsensusResult,
|
|
15
|
+
DriverAction,
|
|
16
|
+
DriverActionKind,
|
|
17
|
+
InjectionState,
|
|
18
|
+
NextStep,
|
|
19
|
+
RawEvent,
|
|
20
|
+
SessionSummaryRecord,
|
|
21
|
+
} from '$lib/types';
|
|
22
|
+
|
|
23
|
+
import { decideInjection, guardCommand, normalizeStep } from './decide-injection';
|
|
24
|
+
|
|
25
|
+
const AUTONOMY_KEY = 'shooter_autonomy';
|
|
26
|
+
const POLL_INTERVAL_MS = 6_000;
|
|
27
|
+
const RECONNECT_MS = 3_000;
|
|
28
|
+
const OPEN_TIMEOUT_MS = 5_000;
|
|
29
|
+
const MAX_ACTIONS = 40;
|
|
30
|
+
// When the WS missed the transition to idle (reconnect, or the agent was already idle when the
|
|
31
|
+
// dashboard opened), the poll path may synthesise an agent-idle from a recent idle-triggered
|
|
32
|
+
// summary — but ONLY if that summary is this fresh, so we never auto-act on stale parked state.
|
|
33
|
+
const IDLE_RESUME_WINDOW_MS = 120_000;
|
|
34
|
+
// Gap between the prompt text and the Enter key for agent TUIs (see inject()). A single chunk
|
|
35
|
+
// ending in CR is treated as a paste — the CR becomes a newline, not a submit — so the Enter must
|
|
36
|
+
// arrive as a SEPARATE write a beat later.
|
|
37
|
+
const AGENT_SUBMIT_DELAY_MS = 120;
|
|
38
|
+
// Allowed shape for a terminal id before it is interpolated into a socket URL (hex / UUID-like).
|
|
39
|
+
const SAFE_ID = /^[A-Za-z0-9_-]+$/;
|
|
40
|
+
|
|
41
|
+
/** Command heads we are willing to treat the next-step text as a literal command for. */
|
|
42
|
+
const SAFE_COMMAND_HEADS = new Set([
|
|
43
|
+
'bun',
|
|
44
|
+
'cargo',
|
|
45
|
+
'cat',
|
|
46
|
+
'deno',
|
|
47
|
+
'echo',
|
|
48
|
+
'eslint',
|
|
49
|
+
'git',
|
|
50
|
+
'go',
|
|
51
|
+
'jest',
|
|
52
|
+
'ls',
|
|
53
|
+
'make',
|
|
54
|
+
'node',
|
|
55
|
+
'npm',
|
|
56
|
+
'npx',
|
|
57
|
+
'pnpm',
|
|
58
|
+
'prettier',
|
|
59
|
+
'pwd',
|
|
60
|
+
'python',
|
|
61
|
+
'python3',
|
|
62
|
+
'tsc',
|
|
63
|
+
'vitest',
|
|
64
|
+
'yarn',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/** Terminal commands that are AI agents — inject the next-step as a PROMPT, not a shell command. */
|
|
68
|
+
const AGENT_COMMANDS = new Set(['claude', 'opencode']);
|
|
69
|
+
|
|
70
|
+
// eslint-disable-next-line no-restricted-syntax -- internal DI seam, never exported
|
|
71
|
+
interface AutopilotDriverDeps {
|
|
72
|
+
now: () => number;
|
|
73
|
+
produceCommand: (input: ProduceCommandInput) => Promise<null | string>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// eslint-disable-next-line no-restricted-syntax -- internal
|
|
77
|
+
interface ProduceCommandInput {
|
|
78
|
+
apiKey: string;
|
|
79
|
+
isAgentTerminal: boolean;
|
|
80
|
+
recentOutput: string;
|
|
81
|
+
step: NextStep;
|
|
82
|
+
terminalId: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// eslint-disable-next-line no-restricted-syntax -- internal per-terminal bookkeeping
|
|
86
|
+
interface TerminalRuntime {
|
|
87
|
+
autoActionCount: number;
|
|
88
|
+
busy: boolean;
|
|
89
|
+
command: string;
|
|
90
|
+
consensus: ConsensusResult | null;
|
|
91
|
+
injectSocket: null | WebSocket;
|
|
92
|
+
isManaged: boolean;
|
|
93
|
+
lastActedStep: null | string;
|
|
94
|
+
lastActivityAt: number;
|
|
95
|
+
lastCommand: null | string;
|
|
96
|
+
lastEventAt: number;
|
|
97
|
+
lastEventType: string;
|
|
98
|
+
lastInjectedAt: number;
|
|
99
|
+
recentOutput: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The autonomous driver. One singleton per page; the AutopilotPanel starts/stops it and
|
|
104
|
+
* reads `enabled` + `actions` reactively.
|
|
105
|
+
*/
|
|
106
|
+
export class AutopilotDriver {
|
|
107
|
+
actions = $state<DriverAction[]>([]);
|
|
108
|
+
enabled = $state(false);
|
|
109
|
+
|
|
110
|
+
private apiKey = '';
|
|
111
|
+
private deps: AutopilotDriverDeps;
|
|
112
|
+
private eventsWs: null | WebSocket = null;
|
|
113
|
+
private pollTimer: null | ReturnType<typeof setInterval> = null;
|
|
114
|
+
private reconnectTimer: null | ReturnType<typeof setTimeout> = null;
|
|
115
|
+
private started = false;
|
|
116
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- internal, non-reactive bookkeeping
|
|
117
|
+
private terminals = new Map<string, TerminalRuntime>();
|
|
118
|
+
|
|
119
|
+
constructor(deps?: Partial<AutopilotDriverDeps>) {
|
|
120
|
+
this.deps = {
|
|
121
|
+
now: deps?.now ?? ((): number => Date.now()),
|
|
122
|
+
produceCommand: deps?.produceCommand ?? defaultProduceCommand,
|
|
123
|
+
};
|
|
124
|
+
this.enabled = readPersistedAutonomy();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
setEnabled(value: boolean): void {
|
|
128
|
+
this.enabled = value;
|
|
129
|
+
try {
|
|
130
|
+
localStorage.setItem(AUTONOMY_KEY, JSON.stringify({ enabled: value }));
|
|
131
|
+
} catch {
|
|
132
|
+
// best-effort
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
start(apiKey: string): void {
|
|
137
|
+
this.apiKey = apiKey;
|
|
138
|
+
if (this.started) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.started = true;
|
|
142
|
+
// Native silent-push wake (iOS): the AppDelegate dispatches 'shooter:wake' so the loop
|
|
143
|
+
// runs a burst when woken in the background.
|
|
144
|
+
if (typeof window !== 'undefined') {
|
|
145
|
+
window.addEventListener('shooter:wake', this.onWake);
|
|
146
|
+
}
|
|
147
|
+
void this.connectEvents();
|
|
148
|
+
void this.refresh();
|
|
149
|
+
this.pollTimer = setInterval(() => void this.refresh(), POLL_INTERVAL_MS);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
stop(): void {
|
|
153
|
+
this.started = false;
|
|
154
|
+
if (typeof window !== 'undefined') {
|
|
155
|
+
window.removeEventListener('shooter:wake', this.onWake);
|
|
156
|
+
}
|
|
157
|
+
if (this.pollTimer) {
|
|
158
|
+
clearInterval(this.pollTimer);
|
|
159
|
+
this.pollTimer = null;
|
|
160
|
+
}
|
|
161
|
+
if (this.reconnectTimer) {
|
|
162
|
+
clearTimeout(this.reconnectTimer);
|
|
163
|
+
this.reconnectTimer = null;
|
|
164
|
+
}
|
|
165
|
+
if (this.eventsWs) {
|
|
166
|
+
this.eventsWs.onclose = null;
|
|
167
|
+
this.eventsWs.close();
|
|
168
|
+
this.eventsWs = null;
|
|
169
|
+
}
|
|
170
|
+
for (const rt of this.terminals.values()) {
|
|
171
|
+
rt.injectSocket?.close();
|
|
172
|
+
rt.injectSocket = null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async connectEvents(): Promise<void> {
|
|
177
|
+
const ticket = await this.getTicket();
|
|
178
|
+
if (!ticket || !this.started) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const wsBase = window.location.origin.replace(/^http/, 'ws');
|
|
182
|
+
const ws = new WebSocket(`${wsBase}/ws/events?ticket=${ticket}`);
|
|
183
|
+
this.eventsWs = ws;
|
|
184
|
+
ws.onmessage = (msg: MessageEvent): void => {
|
|
185
|
+
try {
|
|
186
|
+
const raw: unknown = JSON.parse(msg.data as string);
|
|
187
|
+
if (raw && typeof raw === 'object') {
|
|
188
|
+
this.handleEvent(raw as RawEvent);
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
// ignore malformed
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
ws.onclose = (): void => {
|
|
195
|
+
this.eventsWs = null;
|
|
196
|
+
if (this.started) {
|
|
197
|
+
this.scheduleReconnect();
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
ws.onerror = (): void => {
|
|
201
|
+
// close handler does the reconnect
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private createRuntime(terminalId: string): TerminalRuntime {
|
|
206
|
+
const rt: TerminalRuntime = {
|
|
207
|
+
autoActionCount: 0,
|
|
208
|
+
busy: false,
|
|
209
|
+
command: '',
|
|
210
|
+
consensus: null,
|
|
211
|
+
injectSocket: null,
|
|
212
|
+
isManaged: false,
|
|
213
|
+
lastActedStep: null,
|
|
214
|
+
lastActivityAt: 0,
|
|
215
|
+
lastCommand: null,
|
|
216
|
+
lastEventAt: 0,
|
|
217
|
+
lastEventType: '',
|
|
218
|
+
lastInjectedAt: 0,
|
|
219
|
+
recentOutput: '',
|
|
220
|
+
};
|
|
221
|
+
this.terminals.set(terminalId, rt);
|
|
222
|
+
return rt;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async evaluate(terminalId: string): Promise<void> {
|
|
226
|
+
if (!this.enabled) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const rt = this.terminals.get(terminalId);
|
|
230
|
+
if (!rt || rt.busy || !rt.consensus) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const state: InjectionState = {
|
|
234
|
+
autoActionCount: rt.autoActionCount,
|
|
235
|
+
isManaged: rt.isManaged,
|
|
236
|
+
lastActedStep: rt.lastActedStep,
|
|
237
|
+
lastActivityAt: rt.lastActivityAt,
|
|
238
|
+
lastEventType: rt.lastEventType,
|
|
239
|
+
lastInjectedAt: rt.lastInjectedAt,
|
|
240
|
+
terminalId,
|
|
241
|
+
};
|
|
242
|
+
// Agent terminals (claude/opencode) receive the next step as a PROMPT they reason about, so we
|
|
243
|
+
// allow a best-ranked-but-tentative step through (keeps the autonomous loop live past step 1).
|
|
244
|
+
// Shell terminals stay strict — there the injected text is a command run verbatim.
|
|
245
|
+
const decision = decideInjection(state, rt.consensus, this.deps.now(), undefined, {
|
|
246
|
+
allowTentative: isAgentCommand(rt.command),
|
|
247
|
+
});
|
|
248
|
+
if (!decision.act || !decision.step) {
|
|
249
|
+
return; // common case — stay quiet
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
rt.busy = true;
|
|
253
|
+
try {
|
|
254
|
+
const isAgent = isAgentCommand(rt.command);
|
|
255
|
+
let command: null | string;
|
|
256
|
+
try {
|
|
257
|
+
command = await this.deps.produceCommand({
|
|
258
|
+
apiKey: this.apiKey,
|
|
259
|
+
isAgentTerminal: isAgent,
|
|
260
|
+
recentOutput: rt.recentOutput,
|
|
261
|
+
step: decision.step,
|
|
262
|
+
terminalId,
|
|
263
|
+
});
|
|
264
|
+
} catch {
|
|
265
|
+
command = null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!command) {
|
|
269
|
+
// Transient producer failure (e.g. the LLM proxy was busy) — do NOT mark the step acted, so
|
|
270
|
+
// it stays retryable on the next poll instead of being silenced forever.
|
|
271
|
+
this.log(terminalId, 'skipped', `no command for: ${decision.step.text.slice(0, 60)}`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// For agent TUIs the next-step is a natural-language prompt that inject() collapses to a single
|
|
275
|
+
// line; collapse it HERE too, before guardCommand, so a legitimate multi-line prompt isn't
|
|
276
|
+
// rejected as "multi-line" by a guard the injected form would have passed anyway.
|
|
277
|
+
const candidate = isAgent ? command.replace(/\s*[\r\n]+\s*/g, ' ').trim() : command;
|
|
278
|
+
const verdict = guardCommand(candidate, rt.lastCommand);
|
|
279
|
+
if (!verdict.safe) {
|
|
280
|
+
// Deterministic rejection of THIS step text — mark it acted so we don't loop on it.
|
|
281
|
+
rt.lastActedStep = normalizeStep(decision.step.text);
|
|
282
|
+
this.log(terminalId, 'skipped', `guard: ${verdict.reason}`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const ok = await this.inject(terminalId, verdict.command);
|
|
286
|
+
if (ok) {
|
|
287
|
+
rt.lastInjectedAt = this.deps.now();
|
|
288
|
+
rt.lastCommand = verdict.command;
|
|
289
|
+
rt.lastActedStep = normalizeStep(decision.step.text); // mark acted ONLY on a real inject
|
|
290
|
+
rt.autoActionCount += 1;
|
|
291
|
+
this.log(terminalId, 'injected', verdict.command);
|
|
292
|
+
} else {
|
|
293
|
+
// Transient inject failure (socket) — leave the step unmarked so the next poll retries.
|
|
294
|
+
this.log(terminalId, 'error', `inject failed: ${verdict.command}`);
|
|
295
|
+
}
|
|
296
|
+
} finally {
|
|
297
|
+
rt.busy = false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async fetchSummaries(): Promise<void> {
|
|
302
|
+
try {
|
|
303
|
+
const res = await fetch('/api/summaries?limit=30', {
|
|
304
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
305
|
+
});
|
|
306
|
+
if (!res.ok) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const body = (await res.json()) as { summaries?: SessionSummaryRecord[] };
|
|
310
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive dedup
|
|
311
|
+
const latest = new Set<string>();
|
|
312
|
+
for (const rec of body.summaries ?? []) {
|
|
313
|
+
const tid = rec.terminalId;
|
|
314
|
+
if (!tid || latest.has(tid)) {
|
|
315
|
+
continue; // records are newest-first; keep only the latest per terminal
|
|
316
|
+
}
|
|
317
|
+
const rt = this.terminals.get(tid);
|
|
318
|
+
if (!rt) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
latest.add(tid);
|
|
322
|
+
rt.consensus = { agentCount: 5, quorum: 3, steps: parseSteps(rec.nextSteps) };
|
|
323
|
+
// Resume the inject path when the WS missed the transition to idle: if this is a recent
|
|
324
|
+
// agent-idle-triggered summary and nothing newer arrived over the WS, treat the terminal as
|
|
325
|
+
// idle so the poll loop can act (decideInjection hard-requires lastEventType==='agent-idle',
|
|
326
|
+
// which only the WS path otherwise sets — leaving the poll path dead after a reconnect).
|
|
327
|
+
const createdMs = Date.parse(rec.createdAt);
|
|
328
|
+
if (
|
|
329
|
+
rec.trigger === 'agent-idle' &&
|
|
330
|
+
Number.isFinite(createdMs) &&
|
|
331
|
+
createdMs >= rt.lastEventAt &&
|
|
332
|
+
this.deps.now() - createdMs < IDLE_RESUME_WINDOW_MS
|
|
333
|
+
) {
|
|
334
|
+
rt.lastEventType = 'agent-idle';
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} catch {
|
|
338
|
+
// silent — retry next poll
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private async fetchTerminals(): Promise<void> {
|
|
343
|
+
try {
|
|
344
|
+
const res = await fetch('/api/terminals', {
|
|
345
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
346
|
+
});
|
|
347
|
+
if (!res.ok) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const body = (await res.json()) as {
|
|
351
|
+
terminals?: { command: string; exitedAt: null | string; id: string; status: string }[];
|
|
352
|
+
};
|
|
353
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive dedup
|
|
354
|
+
const seen = new Set<string>();
|
|
355
|
+
for (const t of body.terminals ?? []) {
|
|
356
|
+
if (t.exitedAt !== null || t.status === 'exited') {
|
|
357
|
+
continue; // only track live terminals
|
|
358
|
+
}
|
|
359
|
+
seen.add(t.id);
|
|
360
|
+
const rt = this.terminals.get(t.id) ?? this.createRuntime(t.id);
|
|
361
|
+
rt.isManaged = true; // present in GET /api/terminals ⇒ Shooter-managed
|
|
362
|
+
rt.command = t.command ?? '';
|
|
363
|
+
}
|
|
364
|
+
for (const id of [...this.terminals.keys()]) {
|
|
365
|
+
if (!seen.has(id)) {
|
|
366
|
+
this.terminals.get(id)?.injectSocket?.close();
|
|
367
|
+
this.terminals.delete(id);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} catch {
|
|
371
|
+
// silent — retry next poll
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private async getTicket(): Promise<null | string> {
|
|
376
|
+
try {
|
|
377
|
+
const res = await fetch('/api/ws-ticket', {
|
|
378
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
379
|
+
method: 'POST',
|
|
380
|
+
});
|
|
381
|
+
if (!res.ok) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
const { ticket } = (await res.json()) as { ticket: string };
|
|
385
|
+
return ticket;
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private handleEvent(raw: RawEvent): void {
|
|
392
|
+
const type = typeof raw.type === 'string' ? raw.type : '';
|
|
393
|
+
const terminalId = typeof raw.terminalId === 'string' ? raw.terminalId : '';
|
|
394
|
+
if (!type || type === 'welcome' || !terminalId) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const rt = this.terminals.get(terminalId);
|
|
398
|
+
if (!rt) {
|
|
399
|
+
return; // unknown terminal until the next /api/terminals poll
|
|
400
|
+
}
|
|
401
|
+
rt.lastEventType = type;
|
|
402
|
+
rt.lastEventAt = this.deps.now(); // so a later summary can't downgrade a fresher live signal
|
|
403
|
+
if (type !== 'agent-idle' && type !== 'agent-question') {
|
|
404
|
+
rt.lastActivityAt = this.deps.now(); // tool/human activity → resets the grace window
|
|
405
|
+
}
|
|
406
|
+
if (type === 'tool-completed' && raw.success === true) {
|
|
407
|
+
rt.autoActionCount = 0; // real progress resets the circuit breaker
|
|
408
|
+
}
|
|
409
|
+
if (type === 'agent-idle' && this.enabled) {
|
|
410
|
+
void this.evaluate(terminalId);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private async inject(terminalId: string, command: string): Promise<boolean> {
|
|
415
|
+
const rt = this.terminals.get(terminalId);
|
|
416
|
+
if (!rt) {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
// Defense-in-depth: terminalId is a server-issued id, but it reaches this socket-URL sink from
|
|
420
|
+
// the events WebSocket too — constrain it to a safe charset (and URL-encode both it and the
|
|
421
|
+
// ticket) so a malformed/hostile value can't alter the request path or target
|
|
422
|
+
// (CodeQL js/request-forgery). Real ids are short hex / UUID-like.
|
|
423
|
+
if (!SAFE_ID.test(terminalId)) {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
let ws = rt.injectSocket;
|
|
427
|
+
if (!ws || ws.readyState > WebSocket.OPEN) {
|
|
428
|
+
const ticket = await this.getTicket();
|
|
429
|
+
if (!ticket) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
const wsBase = window.location.origin.replace(/^http/, 'ws');
|
|
433
|
+
ws = new WebSocket(
|
|
434
|
+
`${wsBase}/ws/terminal/${encodeURIComponent(terminalId)}?ticket=${encodeURIComponent(ticket)}`
|
|
435
|
+
);
|
|
436
|
+
rt.injectSocket = ws;
|
|
437
|
+
const opened = await waitForOpen(ws);
|
|
438
|
+
if (!opened) {
|
|
439
|
+
rt.injectSocket = null; // drop the failed socket so the next attempt reconnects cleanly
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
if (isAgentCommand(rt.command)) {
|
|
447
|
+
// Agent TUIs (claude/opencode) treat a single chunk ending in CR as a bracketed paste — the
|
|
448
|
+
// CR becomes a literal newline, not a submit. Send the prompt text and the Enter as SEPARATE
|
|
449
|
+
// writes, collapsing any newlines so a multi-line step can't submit halfway through.
|
|
450
|
+
const prompt = command.replace(/\s*[\r\n]+\s*/g, ' ').trim();
|
|
451
|
+
if (!prompt) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
ws.send(JSON.stringify({ data: prompt, type: 'input' }));
|
|
455
|
+
await delay(AGENT_SUBMIT_DELAY_MS);
|
|
456
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
ws.send(JSON.stringify({ data: '\r', type: 'input' }));
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
ws.send(JSON.stringify({ data: `${command}\r`, type: 'input' }));
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private log(terminalId: string, kind: DriverActionKind, detail: string): void {
|
|
467
|
+
this.actions = [{ at: this.deps.now(), detail, kind, terminalId }, ...this.actions].slice(
|
|
468
|
+
0,
|
|
469
|
+
MAX_ACTIONS
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private onWake = (): void => {
|
|
474
|
+
void this.refresh();
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
private async refresh(): Promise<void> {
|
|
478
|
+
if (!this.apiKey) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
await Promise.allSettled([this.fetchTerminals(), this.fetchSummaries()]);
|
|
482
|
+
if (this.enabled) {
|
|
483
|
+
for (const id of this.terminals.keys()) {
|
|
484
|
+
void this.evaluate(id);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private scheduleReconnect(): void {
|
|
490
|
+
if (this.reconnectTimer) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
this.reconnectTimer = setTimeout(() => {
|
|
494
|
+
this.reconnectTimer = null;
|
|
495
|
+
void this.connectEvents();
|
|
496
|
+
}, RECONNECT_MS);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function defaultProduceCommand(input: ProduceCommandInput): Promise<null | string> {
|
|
501
|
+
// Agent terminals (claude/opencode) take a natural-language PROMPT, not a shell command —
|
|
502
|
+
// the consensus next-step is already that prompt, so inject it directly.
|
|
503
|
+
if (input.isAgentTerminal) {
|
|
504
|
+
const prompt = input.step.text.trim();
|
|
505
|
+
return prompt.length > 0 ? prompt : null;
|
|
506
|
+
}
|
|
507
|
+
// Shell terminals: translate the next-step into a concrete command.
|
|
508
|
+
// 1) On-device model via the native bridge (iOS Foundation Models), when present.
|
|
509
|
+
const native = await tryNativeDecide(input);
|
|
510
|
+
if (native) {
|
|
511
|
+
return native;
|
|
512
|
+
}
|
|
513
|
+
// 2) LiteLLM via the server proxy (key stays server-side) — a real decide step in the browser.
|
|
514
|
+
const litellm = await litellmProduceCommand(input);
|
|
515
|
+
if (litellm) {
|
|
516
|
+
return litellm;
|
|
517
|
+
}
|
|
518
|
+
// 3) Heuristic fallback (no LLM): only when the step text is already a literal command.
|
|
519
|
+
const text = input.step.text.trim();
|
|
520
|
+
const backticked = /`([^`]+)`/.exec(text);
|
|
521
|
+
if (backticked) {
|
|
522
|
+
return backticked[1].trim();
|
|
523
|
+
}
|
|
524
|
+
const head = text.split(/\s+/)[0]?.toLowerCase();
|
|
525
|
+
if (head && SAFE_COMMAND_HEADS.has(head) && !/[\r\n]/.test(text) && text.length < 80) {
|
|
526
|
+
return text;
|
|
527
|
+
}
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function delay(ms: number): Promise<void> {
|
|
532
|
+
return new Promise((resolve) => {
|
|
533
|
+
setTimeout(resolve, ms);
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Default command producer: a CONSERVATIVE heuristic with no LLM. It only yields a
|
|
539
|
+
* command when the next-step text is already a literal command (backticked, or a bare
|
|
540
|
+
* command starting with a known head). Prose next-steps return null → push only, never
|
|
541
|
+
* an inject of garbage. The LLM-backed producer (LiteLLM / on-device) replaces this.
|
|
542
|
+
*/
|
|
543
|
+
function isAgentCommand(command: string): boolean {
|
|
544
|
+
const firstToken = command.trim().split(/\s+/)[0] ?? '';
|
|
545
|
+
const base = firstToken.split('/').pop() ?? '';
|
|
546
|
+
return AGENT_COMMANDS.has(base);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function litellmProduceCommand(input: ProduceCommandInput): Promise<null | string> {
|
|
550
|
+
const base = readProcessEnv('LITELLM_BASE_URL');
|
|
551
|
+
if (!base || !input.apiKey) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
const model = readProcessEnv('LITELLM_MODEL') || 'open-large';
|
|
555
|
+
const userPrompt =
|
|
556
|
+
`Recent terminal output:\n${input.recentOutput.slice(-2000)}\n\n` +
|
|
557
|
+
`Suggested next step: ${input.step.text}\n\n` +
|
|
558
|
+
'Reply with ONLY the single shell command to run next — no prose, no backticks, no explanation.';
|
|
559
|
+
try {
|
|
560
|
+
const res = await fetch('/api/neurolink-proxy', {
|
|
561
|
+
body: JSON.stringify({
|
|
562
|
+
body: {
|
|
563
|
+
max_tokens: 60,
|
|
564
|
+
messages: [
|
|
565
|
+
{
|
|
566
|
+
content: 'You are a coding-session copilot. Output ONLY the next shell command.',
|
|
567
|
+
role: 'system',
|
|
568
|
+
},
|
|
569
|
+
{ content: userPrompt, role: 'user' },
|
|
570
|
+
],
|
|
571
|
+
model,
|
|
572
|
+
temperature: 0,
|
|
573
|
+
},
|
|
574
|
+
headers: {},
|
|
575
|
+
provider: 'litellm',
|
|
576
|
+
url: `${base}/chat/completions`,
|
|
577
|
+
}),
|
|
578
|
+
headers: { Authorization: `Bearer ${input.apiKey}`, 'Content-Type': 'application/json' },
|
|
579
|
+
method: 'POST',
|
|
580
|
+
});
|
|
581
|
+
if (!res.ok) {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
|
585
|
+
const content = data.choices?.[0]?.message?.content;
|
|
586
|
+
if (typeof content !== 'string') {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
const cmd = content
|
|
590
|
+
.trim()
|
|
591
|
+
.replace(/^`+|`+$/g, '')
|
|
592
|
+
.trim();
|
|
593
|
+
return cmd.length > 0 ? cmd : null;
|
|
594
|
+
} catch {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function parseSteps(json: string): NextStep[] {
|
|
600
|
+
try {
|
|
601
|
+
const parsed: unknown = JSON.parse(json);
|
|
602
|
+
if (!Array.isArray(parsed)) {
|
|
603
|
+
return [];
|
|
604
|
+
}
|
|
605
|
+
// Validate shape — a malformed/old record must not crash the loop downstream.
|
|
606
|
+
return parsed.filter(
|
|
607
|
+
(el): el is NextStep =>
|
|
608
|
+
typeof el === 'object' &&
|
|
609
|
+
el !== null &&
|
|
610
|
+
typeof (el as NextStep).text === 'string' &&
|
|
611
|
+
typeof (el as NextStep).confidence === 'number'
|
|
612
|
+
);
|
|
613
|
+
} catch {
|
|
614
|
+
return [];
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function readPersistedAutonomy(): boolean {
|
|
619
|
+
if (typeof localStorage === 'undefined') {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
try {
|
|
623
|
+
const raw = localStorage.getItem(AUTONOMY_KEY);
|
|
624
|
+
if (!raw) {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
const parsed: unknown = JSON.parse(raw);
|
|
628
|
+
return Boolean((parsed as { enabled?: unknown })?.enabled);
|
|
629
|
+
} catch {
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function readProcessEnv(key: string): string {
|
|
635
|
+
// eslint-disable-next-line @typescript-eslint/dot-notation -- bracket access avoids the bundler constant-folding process.env to its build-time value
|
|
636
|
+
const proc = (window as unknown as Record<string, unknown>)['process'] as
|
|
637
|
+
| undefined
|
|
638
|
+
| { env?: Record<string, string | undefined> };
|
|
639
|
+
const value = proc?.env?.[key];
|
|
640
|
+
return typeof value === 'string' ? value : '';
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function tryNativeDecide(input: ProduceCommandInput): Promise<null | string> {
|
|
644
|
+
const bridge = (
|
|
645
|
+
window as unknown as { ShooterBridge?: { agentDecide?: (ctx: string) => Promise<string> } }
|
|
646
|
+
).ShooterBridge;
|
|
647
|
+
if (typeof bridge?.agentDecide !== 'function') {
|
|
648
|
+
return Promise.resolve(null);
|
|
649
|
+
}
|
|
650
|
+
const ctx =
|
|
651
|
+
`Recent terminal output:\n${input.recentOutput.slice(-2000)}\n\n` +
|
|
652
|
+
`Suggested next step: ${input.step.text}\n\nReply with the single shell command to run next.`;
|
|
653
|
+
return bridge
|
|
654
|
+
.agentDecide(ctx)
|
|
655
|
+
.then((c) => (typeof c === 'string' && c.trim().length > 0 ? c.trim() : null))
|
|
656
|
+
.catch(() => null);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function waitForOpen(ws: WebSocket): Promise<boolean> {
|
|
660
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
661
|
+
return Promise.resolve(true);
|
|
662
|
+
}
|
|
663
|
+
return new Promise((resolve) => {
|
|
664
|
+
const timer = setTimeout(() => {
|
|
665
|
+
ws.close(); // don't leak a stuck CONNECTING socket (review finding)
|
|
666
|
+
resolve(false);
|
|
667
|
+
}, OPEN_TIMEOUT_MS);
|
|
668
|
+
ws.addEventListener('open', () => {
|
|
669
|
+
clearTimeout(timer);
|
|
670
|
+
resolve(true);
|
|
671
|
+
});
|
|
672
|
+
ws.addEventListener('error', () => {
|
|
673
|
+
clearTimeout(timer);
|
|
674
|
+
ws.close();
|
|
675
|
+
resolve(false);
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Page-level singleton used by the AutopilotPanel. */
|
|
681
|
+
export const autopilotDriver = new AutopilotDriver();
|