@xingjisoft/neutrino 0.6.0 → 0.7.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.
Files changed (169) hide show
  1. package/README.md +6 -1
  2. package/assets/fast-planner.txt +57 -0
  3. package/assets/fast-replanner.txt +28 -0
  4. package/dist/agents.d.ts +25 -0
  5. package/dist/app.d.ts +206 -0
  6. package/dist/app.js +15 -0
  7. package/dist/background.d.ts +96 -0
  8. package/dist/bin.d.ts +2 -0
  9. package/dist/bin.js +40 -3
  10. package/dist/browser.d.ts +18 -0
  11. package/dist/clipboard.d.ts +16 -0
  12. package/dist/colors.d.ts +29 -0
  13. package/dist/compact.d.ts +52 -0
  14. package/dist/config.d.ts +221 -0
  15. package/dist/config.js +22 -7
  16. package/dist/cwd.d.ts +19 -0
  17. package/dist/fast/artifacts.d.ts +26 -0
  18. package/dist/fast/artifacts.js +109 -0
  19. package/dist/fast/engine.d.ts +78 -0
  20. package/dist/fast/engine.js +657 -0
  21. package/dist/fast/evidence.d.ts +21 -0
  22. package/dist/fast/evidence.js +68 -0
  23. package/dist/fast/planner.d.ts +10 -0
  24. package/dist/fast/planner.js +51 -0
  25. package/dist/fast/pool.d.ts +17 -0
  26. package/dist/fast/pool.js +78 -0
  27. package/dist/fast/scheduler.d.ts +54 -0
  28. package/dist/fast/scheduler.js +383 -0
  29. package/dist/fast/types.d.ts +100 -0
  30. package/dist/fast/types.js +21 -0
  31. package/dist/fast/worker.d.ts +23 -0
  32. package/dist/fast/worker.js +185 -0
  33. package/dist/header.d.ts +3 -0
  34. package/dist/index.d.ts +5 -0
  35. package/dist/llm/approval.d.ts +204 -0
  36. package/dist/llm/approval.js +14 -3
  37. package/dist/llm/client.d.ts +118 -0
  38. package/dist/llm/client.js +57 -8
  39. package/dist/llm/codeExecutor.d.ts +14 -0
  40. package/dist/llm/codemode.d.ts +15 -0
  41. package/dist/llm/context.d.ts +29 -0
  42. package/dist/llm/context.js +17 -4
  43. package/dist/llm/diagnostics.d.ts +91 -0
  44. package/dist/llm/diagnostics.js +242 -0
  45. package/dist/llm/plan.d.ts +148 -0
  46. package/dist/llm/retry.d.ts +80 -0
  47. package/dist/llm/risk.d.ts +21 -0
  48. package/dist/llm/risk.js +7 -0
  49. package/dist/llm/runtime.d.ts +189 -0
  50. package/dist/llm/runtime.js +178 -24
  51. package/dist/llm/search.d.ts +2 -0
  52. package/dist/llm/searchNetwork.d.ts +8 -0
  53. package/dist/llm/subagent.d.ts +112 -0
  54. package/dist/llm/subagent.js +25 -12
  55. package/dist/llm/tools.d.ts +20 -0
  56. package/dist/llm/tools.js +4 -1
  57. package/dist/llm/workspace.d.ts +6 -0
  58. package/dist/llm/workspace.js +271 -0
  59. package/dist/markdown.d.ts +58 -0
  60. package/dist/migration.d.ts +8 -0
  61. package/dist/persistence.d.ts +14 -0
  62. package/dist/plugins/catalog.d.ts +20 -0
  63. package/dist/plugins/catalog.js +45 -0
  64. package/dist/plugins/cli.d.ts +1 -0
  65. package/dist/plugins/cli.js +106 -0
  66. package/dist/plugins/host.d.ts +121 -0
  67. package/dist/plugins/host.js +450 -0
  68. package/dist/plugins/manager.d.ts +24 -0
  69. package/dist/plugins/manager.js +288 -0
  70. package/dist/plugins/package.d.ts +5 -0
  71. package/dist/plugins/package.js +53 -0
  72. package/dist/plugins/runner.d.ts +1 -0
  73. package/dist/plugins/runner.js +227 -0
  74. package/dist/plugins/session.d.ts +12 -0
  75. package/dist/plugins/session.js +97 -0
  76. package/dist/plugins/terminal.d.ts +22 -0
  77. package/dist/plugins/terminal.js +33 -0
  78. package/dist/plugins/types.d.ts +178 -0
  79. package/dist/plugins/types.js +6 -0
  80. package/dist/plugins/validation.d.ts +14 -0
  81. package/dist/plugins/validation.js +174 -0
  82. package/dist/plugins/web-sdk.d.ts +58 -0
  83. package/dist/plugins/web-sdk.js +1 -0
  84. package/dist/pro/agents.d.ts +34 -0
  85. package/dist/pro/agents.js +1 -1
  86. package/dist/pro/config.d.ts +75 -0
  87. package/dist/pro/config.js +3 -2
  88. package/dist/pro/preflight.d.ts +21 -0
  89. package/dist/sanitize.d.ts +34 -0
  90. package/dist/screens/agentConversation.d.ts +28 -0
  91. package/dist/screens/agentHistory.d.ts +25 -0
  92. package/dist/screens/agentSelect.d.ts +16 -0
  93. package/dist/screens/chat.d.ts +292 -0
  94. package/dist/screens/chat.js +179 -65
  95. package/dist/screens/history.d.ts +23 -0
  96. package/dist/screens/modelSelect.d.ts +29 -0
  97. package/dist/screens/plugins.d.ts +29 -0
  98. package/dist/screens/plugins.js +196 -0
  99. package/dist/screens/settings.d.ts +42 -0
  100. package/dist/screens/start.d.ts +43 -0
  101. package/dist/screens/start.js +25 -17
  102. package/dist/screens/updatePrompt.d.ts +41 -0
  103. package/dist/selection.d.ts +75 -0
  104. package/dist/state.d.ts +26 -0
  105. package/dist/terminal/buffer.d.ts +47 -0
  106. package/dist/terminal/input.d.ts +70 -0
  107. package/dist/terminal/renderer.d.ts +43 -0
  108. package/dist/terminal/restore.d.ts +41 -0
  109. package/dist/terminal/screen.d.ts +57 -0
  110. package/dist/tray.d.ts +14 -0
  111. package/dist/ui.d.ts +123 -0
  112. package/dist/update.d.ts +71 -0
  113. package/dist/updateWatch.d.ts +43 -0
  114. package/dist/web/approvalQueue.d.ts +8 -0
  115. package/dist/web/approvalQueue.js +59 -0
  116. package/dist/web/auth.d.ts +14 -0
  117. package/dist/web/clientState.d.ts +16 -0
  118. package/dist/web/folderPicker.d.ts +34 -0
  119. package/dist/web/folderPicker.js +544 -0
  120. package/dist/web/hub.d.ts +213 -0
  121. package/dist/web/hub.js +14 -2
  122. package/dist/web/localApi.d.ts +11 -0
  123. package/dist/web/localApi.js +260 -0
  124. package/dist/web/plugins.d.ts +8 -0
  125. package/dist/web/plugins.js +64 -0
  126. package/dist/web/server.d.ts +81 -0
  127. package/dist/web/server.js +127 -19
  128. package/dist/web/service.d.ts +7 -0
  129. package/dist/web/service.js +1 -0
  130. package/dist/web/session.d.ts +176 -0
  131. package/dist/web/session.js +170 -21
  132. package/dist/web/settings.d.ts +65 -0
  133. package/dist/web/terminalBridge.d.ts +15 -0
  134. package/dist/web/terminalBridge.js +8 -2
  135. package/dist/width.d.ts +6 -0
  136. package/docs/plugins/README.md +126 -0
  137. package/docs/plugins/agent-sdk.md +92 -0
  138. package/docs/plugins/architecture.md +64 -0
  139. package/docs/plugins/compatibility.md +34 -0
  140. package/docs/plugins/development.md +55 -0
  141. package/docs/plugins/manifest.md +59 -0
  142. package/docs/plugins/manifest.schema.json +286 -0
  143. package/docs/plugins/security.md +53 -0
  144. package/docs/plugins/skills-prompts.md +69 -0
  145. package/docs/plugins/ui-sdk.md +109 -0
  146. package/examples/local-api.py +105 -0
  147. package/examples/plugins/cards/card.html +6 -0
  148. package/examples/plugins/cards/neutrino.plugin.json +42 -0
  149. package/examples/plugins/composer/editor.html +10 -0
  150. package/examples/plugins/composer/neutrino.plugin.json +10 -0
  151. package/examples/plugins/function-tools/agent.js +32 -0
  152. package/examples/plugins/function-tools/neutrino.plugin.json +15 -0
  153. package/examples/plugins/menus-tabs/neutrino.plugin.json +15 -0
  154. package/examples/plugins/menus-tabs/panel.html +6 -0
  155. package/examples/plugins/skills-prompts/neutrino.plugin.json +13 -0
  156. package/examples/plugins/skills-prompts/prompts/evidence.md +1 -0
  157. package/examples/plugins/skills-prompts/prompts/reviewer.md +1 -0
  158. package/examples/plugins/skills-prompts/skills/review/SKILL.md +8 -0
  159. package/examples/plugins/theme/neutrino.plugin.json +12 -0
  160. package/package.json +66 -42
  161. package/web/LOCAL-API.md +65 -0
  162. package/web/README.md +30 -8
  163. package/web/app.js +256 -67
  164. package/web/i18n.js +18 -3
  165. package/web/index.html +3 -1
  166. package/web/plugins.css +10 -0
  167. package/web/plugins.js +230 -0
  168. package/web/style.css +12 -3
  169. package/web/turns.js +7 -8
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The live-session hub.
3
+ *
4
+ * The terminal UI is the source of truth for a conversation: while a
5
+ * {@link ../screens/chat.js ChatScreen} is open it owns the message list, the
6
+ * streaming buffer and the busy/paused flags. The web server, however, must be
7
+ * able to (a) show *every* conversation live and (b) drive the running one
8
+ * (pause, resume, send). This hub is the bridge between the two.
9
+ *
10
+ * A screen registers itself once it is activated and unregisters when it is
11
+ * popped. Every time the screen changes something meaningful it calls
12
+ * {@link LiveHub.notify}, which coalesces rapid-fire updates (one per streamed
13
+ * token) and pushes a snapshot to any connected browser.
14
+ */
15
+ import type { ConversationContext } from "../compact.js";
16
+ import { type ApprovalMode, type Mode, type AgentMode, type ResolvedModelSelection } from "../config.js";
17
+ import type { RiskAssessment } from "../llm/risk.js";
18
+ import type { PlanState } from "../llm/plan.js";
19
+ import type { ProFallback } from "../pro/config.js";
20
+ import type { FastSnapshot } from "../fast/types.js";
21
+ export type ModelChangeResult = {
22
+ ok: true;
23
+ } | {
24
+ ok: false;
25
+ error: string;
26
+ };
27
+ /** Complete proposal shown before a human or automated approval decision. */
28
+ export interface LiveApprovalRequest {
29
+ id?: string;
30
+ tool: string;
31
+ level: "high" | "extreme";
32
+ arguments: Record<string, unknown>;
33
+ cwd: string;
34
+ summary: string;
35
+ reasons: string[];
36
+ }
37
+ export interface LiveReviewProgress {
38
+ total: number;
39
+ round: number;
40
+ rounds: number;
41
+ request?: LiveApprovalRequest;
42
+ }
43
+ export declare function liveApprovalDetails(tool: string, args: Record<string, unknown>, risk: RiskAssessment, cwd: string): LiveApprovalRequest;
44
+ /** A message as understood by the web UI. */
45
+ export type LiveWireMessage = {
46
+ role: "user" | "assistant";
47
+ content: string;
48
+ } | {
49
+ role: "tool";
50
+ name: string;
51
+ arguments: Record<string, unknown>;
52
+ result: string;
53
+ } | {
54
+ role: "summary";
55
+ content: string;
56
+ count: number;
57
+ };
58
+ /** A model request that failed and is being retried. */
59
+ export interface LiveRetryNotice {
60
+ /** The attempt about to be made (2 = the first retry). */
61
+ attempt: number;
62
+ /** Total attempts allowed. */
63
+ attempts: number;
64
+ /** How long this attempt waits before going out. */
65
+ delayMs: number;
66
+ }
67
+ /** The small, frequently-changing part of a live session. */
68
+ export interface LiveMeta {
69
+ task?: import("../llm/runtime.js").TaskSnapshot;
70
+ /** On-disk session id, or null until the conversation is first saved. */
71
+ sessionId: string | null;
72
+ title: string;
73
+ /**
74
+ * Nothing has been said in this conversation yet. The sidebar leaves such a
75
+ * session out of the list until it has content, so an abandoned new chat
76
+ * never turns into a history entry.
77
+ */
78
+ empty?: boolean;
79
+ provider: string;
80
+ model: string;
81
+ intensity: string;
82
+ busy: boolean;
83
+ paused: boolean;
84
+ queued: string | null;
85
+ compressing: boolean;
86
+ /** A failed history write, cleared after the next successful checkpoint. */
87
+ saveError?: string | null;
88
+ /** A retried model request, so the browser can explain the silence. */
89
+ retrying?: LiveRetryNotice | null;
90
+ tokens: {
91
+ prompt: number;
92
+ completion: number;
93
+ };
94
+ /** Tool call waiting for a human decision, or null. */
95
+ approval?: LiveApprovalRequest | null;
96
+ /** Reviewer count while an automated approval review is running. */
97
+ reviewing?: LiveReviewProgress | null;
98
+ /** What a running sub-agent is doing right now, or null when none is running. */
99
+ subagent?: string | null;
100
+ approvalMode?: ApprovalMode;
101
+ agentMode?: AgentMode;
102
+ /** Strategy frozen for the running turn; agentMode selects the next turn. */
103
+ activeAgentMode?: AgentMode;
104
+ fast?: FastSnapshot;
105
+ pro?: {
106
+ preset: string;
107
+ checking: boolean;
108
+ checkId: string;
109
+ models: Array<{
110
+ role: string;
111
+ provider: string;
112
+ model: string;
113
+ intensity: string;
114
+ tier: string;
115
+ }>;
116
+ fallbacks: ProFallback[];
117
+ error?: string;
118
+ };
119
+ }
120
+ /** Everything a browser needs to render a live conversation. */
121
+ export interface LiveSnapshot extends LiveMeta {
122
+ key: string;
123
+ cwd: string;
124
+ messages: LiveWireMessage[];
125
+ context?: ConversationContext | null;
126
+ streamingContent: string | null;
127
+ /**
128
+ * The plan of the current turn. The terminal draws it in the info card; a
129
+ * browser client can render the same steps from here without parsing text.
130
+ */
131
+ plan?: PlanState | null;
132
+ }
133
+ /** A screen that can be observed and driven through the web UI. */
134
+ export interface LiveAgent {
135
+ /**
136
+ * Workspace this conversation belongs to. Optional so existing embedders and
137
+ * terminal screens keep working; a missing mode means the standard workspace.
138
+ */
139
+ readonly mode?: Mode;
140
+ /** Working directory the conversation belongs to (used to group it by path). */
141
+ liveCwd(): string;
142
+ liveMeta(): LiveMeta;
143
+ liveSnapshot(key: string): LiveSnapshot;
144
+ /** Pause the in-flight turn (no-op when idle). */
145
+ livePause(): void;
146
+ /** Resume a paused conversation. */
147
+ liveResume(): void;
148
+ /** Append a user message and start (or queue) a turn. */
149
+ liveSend(text: string): void;
150
+ /** Answer a pending approval. Returns false when nothing was pending. */
151
+ liveApprove?(approved: boolean, approvalId?: string): boolean;
152
+ liveSetApprovalMode?(mode: ApprovalMode): void;
153
+ liveSetAgentMode?(mode: AgentMode): ModelChangeResult;
154
+ /** Empty the conversation in place (a new chat in the same folder). */
155
+ liveClear?(): void;
156
+ /** Apply a server-resolved choice to future turns, while idle or paused. */
157
+ liveConfigure?(selection: ResolvedModelSelection): ModelChangeResult;
158
+ liveSetProPreset?(preset: string): ModelChangeResult;
159
+ /** A terminal in another process handles these actions through its bridge. */
160
+ remoteAction?(action: string, body: unknown): Promise<{
161
+ status: number;
162
+ data: unknown;
163
+ }>;
164
+ }
165
+ /** Summary of a live session, as shown in the sidebar. */
166
+ export interface LiveEntrySummary extends LiveMeta {
167
+ key: string;
168
+ cwd: string;
169
+ }
170
+ type Listener = (snapshot: LiveSnapshot) => void;
171
+ declare class LiveHub {
172
+ private entries;
173
+ private seq;
174
+ /** Register a screen. Returns the stable key used to address it over HTTP. */
175
+ register(agent: LiveAgent): string;
176
+ unregister(key: string | null): void;
177
+ /**
178
+ * Whether a key is live. With a mode, keys from the other workspace are
179
+ * treated as unknown: the Pro page must never reach a terminal session.
180
+ */
181
+ has(key: string, mode?: Mode): boolean;
182
+ remoteAction(key: string): LiveAgent["remoteAction"] | null;
183
+ /** All live sessions, newest registration last. Optionally one workspace only. */
184
+ list(mode?: Mode): LiveEntrySummary[];
185
+ /** One-off snapshot, or null when the key is unknown. */
186
+ snapshot(key: string): LiveSnapshot | null;
187
+ /**
188
+ * Subscribe to a live session. The current snapshot is delivered immediately
189
+ * so a freshly-connected browser never has to wait for the next change.
190
+ */
191
+ subscribe(key: string, fn: Listener): (() => void) | null;
192
+ /**
193
+ * Mark a session as changed. Snapshots are only produced when somebody is
194
+ * listening, and duplicate snapshots (e.g. a cursor blink) are dropped.
195
+ */
196
+ notify(key: string | null): void;
197
+ private flush;
198
+ pause(key: string): boolean;
199
+ resume(key: string): boolean;
200
+ send(key: string, text: string): boolean;
201
+ /** Mode and message are committed synchronously; a running turn keeps its frozen strategy. */
202
+ sendConfigured(key: string, text: string, mode?: AgentMode): ModelChangeResult;
203
+ approve(key: string, approved: boolean, approvalId?: string): boolean;
204
+ configure(key: string, selection: ResolvedModelSelection): ModelChangeResult;
205
+ setProPreset(key: string, preset: string): ModelChangeResult;
206
+ setAgentMode(key: string, mode: AgentMode): ModelChangeResult;
207
+ setApprovalMode(key: string, mode: ApprovalMode): boolean;
208
+ /** Empty a live conversation in place, keeping its key and working folder. */
209
+ clear(key: string): boolean;
210
+ }
211
+ /** Process-wide singleton shared by the TUI and the web server. */
212
+ export declare const hub: LiveHub;
213
+ export {};
package/dist/web/hub.js CHANGED
@@ -163,11 +163,23 @@ class LiveHub {
163
163
  this.notify(key);
164
164
  return true;
165
165
  }
166
- approve(key, approved) {
166
+ /** Mode and message are committed synchronously; a running turn keeps its frozen strategy. */
167
+ sendConfigured(key, text, mode) {
168
+ if (!this.entries.has(key))
169
+ return { ok: false, error: "unknown live session" };
170
+ if (mode !== undefined) {
171
+ const changed = this.setAgentMode(key, mode);
172
+ if (!changed.ok)
173
+ return changed;
174
+ }
175
+ this.send(key, text);
176
+ return { ok: true };
177
+ }
178
+ approve(key, approved, approvalId) {
167
179
  const entry = this.entries.get(key);
168
180
  if (!entry || !entry.agent.liveApprove)
169
181
  return false;
170
- const handled = entry.agent.liveApprove(approved);
182
+ const handled = entry.agent.liveApprove(approved, approvalId);
171
183
  this.notify(key);
172
184
  return handled;
173
185
  }
@@ -0,0 +1,11 @@
1
+ import http from "node:http";
2
+ import { type Mode } from "../config.js";
3
+ export interface LocalApiHandle {
4
+ url: string;
5
+ token: string;
6
+ close(): Promise<void>;
7
+ }
8
+ type Route = (req: http.IncomingMessage, res: http.ServerResponse, url: URL, mode: Mode, pathname: string) => Promise<void>;
9
+ /** A separate, authenticated listener: Web can bind to LAN without exposing this API. */
10
+ export declare function startLocalApi(port: number, route: Route, publish: boolean): Promise<LocalApiHandle>;
11
+ export {};
@@ -0,0 +1,260 @@
1
+ import fs from "node:fs";
2
+ import http from "node:http";
3
+ import path from "node:path";
4
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
+ import { AGENT_MODES, NEUTRINO_DIR } from "../config.js";
6
+ import { getWebSettings } from "./settings.js";
7
+ import { hub } from "./hub.js";
8
+ function json(res, status, body) {
9
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
10
+ res.end(JSON.stringify(body));
11
+ }
12
+ async function body(req) {
13
+ const chunks = [];
14
+ let size = 0;
15
+ for await (const chunk of req) {
16
+ const bytes = Buffer.from(chunk);
17
+ size += bytes.length;
18
+ if (size > 1024 * 1024)
19
+ throw new Error("request too large");
20
+ chunks.push(bytes);
21
+ }
22
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
23
+ if (!value || typeof value !== "object" || Array.isArray(value))
24
+ throw new Error("invalid JSON body");
25
+ return value;
26
+ }
27
+ /** A separate, authenticated listener: Web can bind to LAN without exposing this API. */
28
+ export async function startLocalApi(port, route, publish) {
29
+ const token = randomBytes(32).toString("hex");
30
+ const epoch = randomUUID();
31
+ let seq = 0;
32
+ const watches = new Map();
33
+ const sockets = new Set();
34
+ const auth = (req) => {
35
+ const supplied = Buffer.from(String(req.headers.authorization ?? ""));
36
+ const expected = Buffer.from(`Bearer ${token}`);
37
+ return supplied.length === expected.length && timingSafeEqual(supplied, expected);
38
+ };
39
+ const frame = (event) => `id: ${event.id}\nevent: ${event.name}\ndata: ${JSON.stringify(event.data)}\n\n`;
40
+ const send = (res, event) => {
41
+ if (res.destroyed || res.writableEnded)
42
+ return;
43
+ // A stalled client must not accumulate an unbounded queue in memory.
44
+ if (res.writableLength > 4 * 1024 * 1024) {
45
+ res.destroy();
46
+ return;
47
+ }
48
+ res.write(frame(event));
49
+ };
50
+ const watch = (key) => {
51
+ const existing = watches.get(key);
52
+ if (existing)
53
+ return existing;
54
+ if (!hub.snapshot(key))
55
+ return null;
56
+ if (watches.size >= 64) {
57
+ const idle = [...watches.entries()].filter(([, value]) => value.clients.size === 0).sort((a, b) => a[1].touched - b[1].touched)[0];
58
+ if (!idle)
59
+ return null;
60
+ idle[1].unsubscribe();
61
+ watches.delete(idle[0]);
62
+ }
63
+ const item = { events: [], bytes: 0, clients: new Set(), unsubscribe: () => { }, last: null, touched: Date.now() };
64
+ const emit = (name, data) => {
65
+ const event = { id: `${epoch}:${++seq}`, name, data, bytes: Buffer.byteLength(JSON.stringify(data)) };
66
+ item.events.push(event);
67
+ item.bytes += event.bytes;
68
+ while (item.events.length > 1 && (item.events.length > 256 || item.bytes > 4 * 1024 * 1024))
69
+ item.bytes -= item.events.shift().bytes;
70
+ for (const client of item.clients)
71
+ send(client, event);
72
+ };
73
+ watches.set(key, item);
74
+ const unsub = hub.subscribe(key, snapshot => {
75
+ const previous = item.last;
76
+ item.last = snapshot;
77
+ emit("snapshot", snapshot);
78
+ if (snapshot.approval?.id && snapshot.approval.id !== previous?.approval?.id)
79
+ emit("approval.requested", snapshot.approval);
80
+ if (previous?.approval?.id && previous.approval.id !== snapshot.approval?.id)
81
+ emit("approval.resolved", { id: previous.approval.id });
82
+ });
83
+ if (!unsub) {
84
+ watches.delete(key);
85
+ return null;
86
+ }
87
+ item.unsubscribe = unsub;
88
+ return item;
89
+ };
90
+ const sweeper = setInterval(() => {
91
+ for (const [key, item] of watches) {
92
+ if (!item.clients.size && (!hub.has(key) || Date.now() - item.touched > 10 * 60_000)) {
93
+ item.unsubscribe();
94
+ watches.delete(key);
95
+ }
96
+ }
97
+ }, 60_000);
98
+ sweeper.unref();
99
+ const statusFile = path.join(NEUTRINO_DIR, "api.json");
100
+ let actualPort = port;
101
+ const server = http.createServer((req, res) => {
102
+ void (async () => {
103
+ // A browser-originated request must never be able to drive the agent, even
104
+ // if it can reach loopback. Do not trust forwarded addresses or Host.
105
+ if (req.headers.origin || req.headers.host !== `127.0.0.1:${actualPort}`) {
106
+ json(res, 403, { error: "local API requires a loopback Host and no Origin" });
107
+ return;
108
+ }
109
+ if (!auth(req)) {
110
+ json(res, 401, { error: "Bearer token required" });
111
+ return;
112
+ }
113
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${actualPort}`);
114
+ const match = /^\/api\/v1\/(.*)$/.exec(url.pathname);
115
+ if (!match) {
116
+ json(res, 404, { error: "not found" });
117
+ return;
118
+ }
119
+ let name = match[1];
120
+ let mode = "standard";
121
+ if (name.startsWith("pro/")) {
122
+ mode = "pro";
123
+ name = name.slice(4);
124
+ }
125
+ if (req.method === "GET" && name === "health") {
126
+ json(res, 200, { ok: true, version: 1 });
127
+ return;
128
+ }
129
+ if (req.method === "GET" && name === "capabilities") {
130
+ json(res, 200, { version: 1, workspaces: ["standard", "pro"], agent_modes: AGENT_MODES, streaming: "sse-snapshots", approval_ids: true });
131
+ return;
132
+ }
133
+ if (req.method === "GET" && name === "models") {
134
+ const settings = getWebSettings(mode);
135
+ json(res, 200, { providers: settings.providers, intensities: settings.intensities, ...(mode === "pro" ? { pro: settings.pro } : {}) });
136
+ return;
137
+ }
138
+ const approval = /^live\/([^/]+)\/approvals\/([^/]+)$/.exec(name);
139
+ if (approval && req.method === "POST") {
140
+ const key = decodeURIComponent(approval[1]), id = decodeURIComponent(approval[2]);
141
+ if (!hub.has(key, mode)) {
142
+ json(res, 404, { error: "unknown live session" });
143
+ return;
144
+ }
145
+ const input = await body(req);
146
+ if (typeof input.approved !== "boolean") {
147
+ json(res, 400, { error: "approved must be a boolean" });
148
+ return;
149
+ }
150
+ const remote = hub.remoteAction(key);
151
+ if (remote) {
152
+ const result = await remote("approve", { approved: input.approved, approval_id: id });
153
+ json(res, result.status, result.data);
154
+ return;
155
+ }
156
+ if (!hub.approve(key, input.approved, id)) {
157
+ json(res, 409, { error: "approval is no longer pending" });
158
+ return;
159
+ }
160
+ json(res, 200, { ok: true, approved: input.approved });
161
+ return;
162
+ }
163
+ const stream = /^live\/([^/]+)\/events$/.exec(name);
164
+ if (stream && req.method === "GET") {
165
+ const key = decodeURIComponent(stream[1]);
166
+ if (!hub.has(key, mode)) {
167
+ json(res, 404, { error: "unknown live session" });
168
+ return;
169
+ }
170
+ const item = watch(key);
171
+ if (!item) {
172
+ json(res, 503, { error: "too many watched sessions" });
173
+ return;
174
+ }
175
+ res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no" });
176
+ res.write("retry: 2000\n\n");
177
+ const cursor = String(req.headers["last-event-id"] ?? "");
178
+ const index = cursor ? item.events.findIndex(event => event.id === cursor) : -1;
179
+ if (cursor && index < 0)
180
+ res.write(`event: reset\ndata: ${JSON.stringify({ reason: "event history unavailable" })}\n\n`);
181
+ if (cursor && index >= 0)
182
+ for (const event of item.events.slice(index + 1))
183
+ send(res, event);
184
+ // Always provide a current snapshot, including when no event changed while disconnected.
185
+ const current = hub.snapshot(key);
186
+ if (current)
187
+ res.write(`event: snapshot\ndata: ${JSON.stringify(current)}\n\n`);
188
+ item.clients.add(res);
189
+ item.touched = Date.now();
190
+ sockets.add(res);
191
+ const timer = setInterval(() => {
192
+ if (!hub.has(key, mode)) {
193
+ res.write("event: gone\ndata: {}\n\n");
194
+ res.end();
195
+ return;
196
+ }
197
+ res.write(": ping\n\n");
198
+ }, 15000);
199
+ timer.unref();
200
+ const cleanup = () => { clearInterval(timer); item.clients.delete(res); item.touched = Date.now(); sockets.delete(res); };
201
+ req.on("close", cleanup);
202
+ res.on("error", cleanup);
203
+ return;
204
+ }
205
+ // The shared Web handlers are the source of truth. Explicitly exclude
206
+ // application settings, login and service lifecycle controls.
207
+ if (/^(settings|auth|background)(\/|$)/.test(name) || /^live\/[^/]+\/approve$/.test(name)) {
208
+ json(res, 404, { error: "not found" });
209
+ return;
210
+ }
211
+ await route(req, res, url, mode, `/api/${name}`);
212
+ })().catch(error => { if (!res.headersSent)
213
+ json(res, 400, { error: error instanceof Error ? error.message : "invalid request" });
214
+ else
215
+ res.end(); });
216
+ });
217
+ await new Promise((resolve, reject) => {
218
+ server.once("error", reject);
219
+ server.listen(port, "127.0.0.1", () => { server.removeListener("error", reject); resolve(); });
220
+ });
221
+ const address = server.address();
222
+ if (!address || typeof address === "string") {
223
+ server.close();
224
+ throw new Error("local API failed to bind");
225
+ }
226
+ actualPort = address.port;
227
+ const url = `http://127.0.0.1:${actualPort}/api/v1`;
228
+ if (publish) {
229
+ try {
230
+ fs.mkdirSync(NEUTRINO_DIR, { recursive: true, mode: 0o700 });
231
+ const temporary = `${statusFile}.${process.pid}.${randomBytes(6).toString("hex")}`;
232
+ fs.writeFileSync(temporary, JSON.stringify({ pid: process.pid, url, token }), { mode: 0o600, flag: "wx" });
233
+ fs.renameSync(temporary, statusFile);
234
+ }
235
+ catch (error) {
236
+ clearInterval(sweeper);
237
+ server.close();
238
+ throw error;
239
+ }
240
+ }
241
+ server.on("error", () => { });
242
+ return { url, token, close: async () => {
243
+ clearInterval(sweeper);
244
+ for (const item of watches.values())
245
+ item.unsubscribe();
246
+ watches.clear();
247
+ for (const response of sockets)
248
+ response.destroy();
249
+ sockets.clear();
250
+ if (publish) {
251
+ try {
252
+ const saved = JSON.parse(fs.readFileSync(statusFile, "utf8"));
253
+ if (saved.token === token)
254
+ fs.unlinkSync(statusFile);
255
+ }
256
+ catch { /* already gone */ }
257
+ }
258
+ await new Promise(resolve => { server.close(() => resolve()); server.closeAllConnections(); });
259
+ } };
260
+ }
@@ -0,0 +1,8 @@
1
+ import type http from "node:http";
2
+ import type { Profile } from "../plugins/types.js";
3
+ /** Called only after the server's existing authentication check. */
4
+ export declare function handlePlugins(req: http.IncomingMessage, res: http.ServerResponse, url: URL, pathname: string, profile: Profile, helpers: {
5
+ sameOrigin: boolean;
6
+ read(): Promise<unknown>;
7
+ json(status: number, value: unknown): void;
8
+ }): Promise<boolean>;
@@ -0,0 +1,64 @@
1
+ import { configurePlugin, installPlugin, listPlugins, pluginAsset, removePlugin, rollbackPlugin, selectProvider } from "../plugins/manager.js";
2
+ import { pluginCatalog } from "../plugins/catalog.js";
3
+ import { assert, object } from "../plugins/validation.js";
4
+ /** Called only after the server's existing authentication check. */
5
+ export async function handlePlugins(req, res, url, pathname, profile, helpers) {
6
+ if (pathname !== "/api/plugins" && !pathname.startsWith("/api/plugins/"))
7
+ return false;
8
+ try {
9
+ const cwd = url.searchParams.get("cwd") || undefined;
10
+ if (req.method === "GET") {
11
+ if (pathname === "/api/plugins")
12
+ helpers.json(200, listPlugins(profile, cwd));
13
+ else if (pathname === "/api/plugins/catalog")
14
+ helpers.json(200, pluginCatalog(profile, cwd));
15
+ else if (pathname === "/api/plugins/asset")
16
+ helpers.json(200, { body: pluginAsset(url.searchParams.get("id") ?? "", url.searchParams.get("file") ?? "", profile, cwd) });
17
+ else
18
+ helpers.json(404, { error: "Unknown plugin endpoint" });
19
+ return true;
20
+ }
21
+ if (req.method !== "POST" || pathname !== "/api/plugins") {
22
+ helpers.json(405, { error: "Method not allowed" });
23
+ return true;
24
+ }
25
+ if (!helpers.sameOrigin) {
26
+ helpers.json(403, { error: "cross-origin request rejected" });
27
+ return true;
28
+ }
29
+ const body = await helpers.read();
30
+ assert(object(body), "Invalid plugin request");
31
+ assert(body.revision === undefined || Number.isSafeInteger(body.revision), "Invalid plugin revision");
32
+ const revision = body.revision;
33
+ const id = String(body.id ?? "");
34
+ if (body.action === "install") {
35
+ assert(typeof body.source === "string", "Source directory is required");
36
+ installPlugin(body.source, revision);
37
+ }
38
+ else if (body.action === "configure") {
39
+ assert(object(body.patch), "Plugin patch is required");
40
+ assert(Object.keys(body.patch).every(k => ["enabled", "config", "trustNative"].includes(k)), "Unknown plugin patch field");
41
+ configurePlugin(id, body.patch, profile, cwd, revision);
42
+ }
43
+ else if (body.action === "remove")
44
+ removePlugin(id, revision);
45
+ else if (body.action === "update") {
46
+ const plugin = listPlugins(profile, cwd).plugins.find(p => p.manifest.id === id);
47
+ assert(plugin, "Unknown plugin");
48
+ installPlugin(plugin.source, revision);
49
+ }
50
+ else if (body.action === "rollback")
51
+ rollbackPlugin(id, revision);
52
+ else if (body.action === "provider") {
53
+ assert(typeof body.slot === "string" && typeof body.provider === "string", "Slot and provider are required");
54
+ selectProvider(body.slot, body.provider, profile, revision);
55
+ }
56
+ else
57
+ throw new Error("Unknown plugin action");
58
+ helpers.json(200, listPlugins(profile, cwd));
59
+ }
60
+ catch (e) {
61
+ helpers.json(e.message.includes("state changed") ? 409 : 400, { error: e.message });
62
+ }
63
+ return true;
64
+ }
@@ -0,0 +1,81 @@
1
+ import { type FolderPick } from "./folderPicker.js";
2
+ import { type BackgroundStatus, type BackgroundUpdate } from "../background.js";
3
+ export interface WebServerOptions {
4
+ /** Port to listen on. `0` lets the OS pick a free one. */
5
+ port: number;
6
+ /** Interface to bind. Defaults to loopback. */
7
+ host?: string;
8
+ /** Require password login by default. `false` explicitly disables authentication. */
9
+ auth?: boolean;
10
+ /**
11
+ * Offer the background-residency prompt to the first browser that opens this
12
+ * console. Only a console started by the terminal UI sets this.
13
+ */
14
+ backgroundPrompt?: boolean;
15
+ /**
16
+ * Present only in the detached background service: the token that authorizes
17
+ * `/api/background/{ping,stop}` and the callback that shuts the process down.
18
+ */
19
+ backgroundService?: {
20
+ token: string;
21
+ onStop: () => void;
22
+ status?: () => BackgroundStatus;
23
+ configure?: (update: BackgroundUpdate) => BackgroundStatus;
24
+ };
25
+ /** Keep service discovery in sync after a live LAN switch. */
26
+ onAddressChange?: () => void;
27
+ /**
28
+ * Opens the operating system's folder window and reports the chosen folder.
29
+ * Only replaced by tests, which run where no window can be drawn.
30
+ */
31
+ folderPicker?: FolderPicker;
32
+ /** Local API port. Zero selects an available port. */
33
+ apiPort?: number;
34
+ /** Publish connection information in the user data directory. */
35
+ publishApi?: boolean;
36
+ /** Run only the local API, with no Web listener. */
37
+ apiOnly?: boolean;
38
+ }
39
+ /**
40
+ * Draws the operating system's folder window on the desktop of this machine.
41
+ * The console opens one window at a time (see `startWebServer`).
42
+ */
43
+ export type FolderPicker = (request: {
44
+ start?: string;
45
+ title?: string;
46
+ }) => Promise<FolderPick>;
47
+ export interface WebServerHandle {
48
+ /** Loopback URL. Always usable from this machine. */
49
+ url: string;
50
+ /** Addresses to open. Credentials are never included in URLs. */
51
+ urls: string[];
52
+ port: number;
53
+ host: string;
54
+ /** Whether a browser must log in before using the API. */
55
+ authRequired: boolean;
56
+ /** Local API connection information; token must be kept private. */
57
+ api?: {
58
+ url: string;
59
+ token: string;
60
+ };
61
+ /**
62
+ * Re-bind the same port on another interface. Live connections (a streaming
63
+ * turn, the tab asking for the change) stay open; resolves once the new
64
+ * socket is listening and rejects if it could not be bound.
65
+ */
66
+ setHost(host: string): Promise<void>;
67
+ close(): Promise<void>;
68
+ }
69
+ /** True when `host` only accepts connections from this machine. */
70
+ export declare function isLoopbackHost(host: string): boolean;
71
+ /** True when `host` listens on every interface, i.e. what --lan selects. */
72
+ export declare function isWildcardHost(host: string): boolean;
73
+ /** The IPv4 addresses other devices can reach this machine on. */
74
+ export declare function lanAddresses(): string[];
75
+ /** Build the list of addresses worth showing to the operator. */
76
+ export declare function webUrls(host: string, port: number): {
77
+ url: string;
78
+ urls: string[];
79
+ };
80
+ /** Start the web UI. Resolves once the socket is actually listening. */
81
+ export declare function startWebServer(options: WebServerOptions): Promise<WebServerHandle>;