mouaif 0.3.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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1,654 @@
1
+ 'use strict';
2
+
3
+ // Native tool REST handlers (shell, subagent, tool list). Extracted
4
+ // from the original single-file http-server.js. Shared helpers live in
5
+ // src/server-shared.js.
6
+
7
+ const {
8
+ sendJSON,
9
+ qs,
10
+ readJsonOr400,
11
+ resolveModel,
12
+ settings,
13
+ chats,
14
+ messages,
15
+ usage,
16
+ mcp,
17
+ shellTool,
18
+ ai,
19
+ broadcast
20
+ } = require('./server-shared.js');
21
+
22
+ // ---- Interactive CLI session ------------------------------------------------
23
+ //
24
+ // A single persistent command-line child per project (the platform shell:
25
+ // cmd.exe on Windows, the user's $SHELL or /bin/sh on POSIX) with
26
+ // stdin/stdout/stderr piped over HTTP. All commands run in the project
27
+ // directory ("default path to the project"). The session is created with
28
+ // `windowsHide` and no window is ever shown; output streaming rides the
29
+ // SSE endpoint (see startCliSession below).
30
+ //
31
+ // The child is intentionally spawned WITHOUT a TTY (pipe stdio). Commands
32
+ // that require an interactive TTY (REPLs, `cmd.exe` interactive prompts
33
+ // like `del` confirmation) will fail or exit immediately — a documented
34
+ // limitation, same as the native `shell` tool. Everything non-interactive
35
+ // works exactly like a real Command Prompt.
36
+
37
+ const { spawn } = require('node:child_process');
38
+
39
+ const cliSessions = new Map(); // projectDir -> { child, projectDir, id, startedAt }
40
+
41
+ // Reap every live CLI child on parent exit so a server shutdown never
42
+ // leaves orphaned cmd.exe processes behind (same pattern as the native
43
+ // shell tool's liveChildren set).
44
+ let cliExitHooked = false;
45
+ function hookCliExit() {
46
+ if (cliExitHooked) return;
47
+ cliExitHooked = true;
48
+ const reap = () => {
49
+ for (const s of cliSessions.values()) {
50
+ try { if (s.child && !s.child.killed) s.child.kill(); } catch { /* already gone */ }
51
+ }
52
+ cliSessions.clear();
53
+ };
54
+ process.on('exit', reap);
55
+ process.on('SIGINT', () => { reap(); process.exit(130); });
56
+ process.on('SIGTERM', () => { reap(); process.exit(143); });
57
+ }
58
+
59
+ function cliShellMeta() {
60
+ // Default grid for a PTY-backed session. The modal renders plain text and
61
+ // does not yet report its own size, so this is a sensible fixed terminal
62
+ // size rather than the browser viewport.
63
+ const cols = 100;
64
+ const rows = 30;
65
+ if (process.platform === 'win32') {
66
+ // Windows Terminal Detection: if the session's env already has a WSL
67
+ // or PowerShell default, use it; otherwise cmd.exe (the classic prompt).
68
+ return {
69
+ exe: process.env.ComSpec || 'cmd.exe',
70
+ args: [],
71
+ label: 'Command Prompt (cmd.exe)',
72
+ windows: true,
73
+ cols,
74
+ rows
75
+ };
76
+ }
77
+ const shellPath = process.env.SHELL || '/bin/sh';
78
+ return {
79
+ exe: shellPath,
80
+ args: [],
81
+ label: 'Shell (' + shellPath + ')',
82
+ windows: false,
83
+ cols,
84
+ rows
85
+ };
86
+ }
87
+
88
+ // Load node-pty once. It is an optional native dependency: a platform with
89
+ // no prebuilt binary (and no C++ toolchain) still installs mouaif, and the
90
+ // session silently falls back to the piped spawn below. `require` is
91
+ // wrapped so a missing/broken addon is a degraded mode, not a crash.
92
+ let ptyModule = null;
93
+ let ptyLoadAttempted = false;
94
+ function loadPty() {
95
+ if (ptyLoadAttempted) return ptyModule;
96
+ ptyLoadAttempted = true;
97
+ try {
98
+ ptyModule = require('node-pty');
99
+ } catch {
100
+ ptyModule = null;
101
+ }
102
+ return ptyModule;
103
+ }
104
+
105
+ // Start the persistent session (idempotent) and return the session handle.
106
+ //
107
+ // The session runs on a pseudo-terminal when node-pty is available. That
108
+ // matters for prompting programs: over pipes (`stdio: ['pipe', ...]`) the
109
+ // child's stdin is not a TTY, so a program that asks a question either gets
110
+ // an immediate EOF or refuses to prompt at all — `npm publish` under 2FA
111
+ // answers `EOTP` with a masked auth URL instead of asking for a code, and
112
+ // `read` returns an empty answer. With a PTY the prompt is written to the
113
+ // screen, the user types the answer into the modal's prompt line, and it is
114
+ // delivered to the still-running child.
115
+ //
116
+ // A PTY merges stdout and stderr into one stream, so `attachCliStream`
117
+ // labels every chunk `stdout`; there is no separate stderr channel to
118
+ // preserve. When the PTY is unavailable the old piped triple is used and
119
+ // stderr keeps its own channel.
120
+ function ensureCliSession(projectDir) {
121
+ const key = String(projectDir || '');
122
+ const existing = cliSessions.get(key);
123
+ if (existing && existing.child && !existing.child.killed) return existing;
124
+ const meta = cliShellMeta();
125
+ const pty = loadPty();
126
+ let child;
127
+ let isPty = false;
128
+ if (pty && typeof pty.spawn === 'function') {
129
+ try {
130
+ // The shell is only interactive once it has a TTY, so ask for `-i`
131
+ // here rather than in cliShellMeta (the piped fallback must not: bash
132
+ // warns about job control with no TTY).
133
+ const ptyArgs = (!meta.windows && meta.args.indexOf('-i') === -1)
134
+ ? meta.args.concat('-i')
135
+ : meta.args;
136
+ child = pty.spawn(meta.exe, ptyArgs, {
137
+ name: 'xterm-256color',
138
+ cols: meta.cols,
139
+ rows: meta.rows,
140
+ cwd: projectDir,
141
+ // The child inherits the server's env; force a colour-capable TERM
142
+ // so utilities that gate formatting on terminfo behave.
143
+ env: Object.assign({}, process.env, { TERM: process.env.TERM || 'xterm-256color' })
144
+ });
145
+ isPty = true;
146
+ } catch {
147
+ child = null;
148
+ }
149
+ }
150
+ if (!child) {
151
+ child = spawn(meta.exe, meta.args, {
152
+ cwd: projectDir,
153
+ windowsHide: true,
154
+ stdio: ['pipe', 'pipe', 'pipe']
155
+ });
156
+ }
157
+ const session = {
158
+ id: 'cli_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7),
159
+ projectDir,
160
+ child,
161
+ startedAt: Date.now(),
162
+ // True when the child runs on a pseudo-terminal (see loadPty above).
163
+ pty: isPty,
164
+ // Record the platform so command writes use the correct line
165
+ // terminator: CRLF for cmd.exe on Windows, LF for sh/bash on POSIX.
166
+ windows: !!meta.windows
167
+ };
168
+ hookCliExit();
169
+ cliSessions.set(key, session);
170
+ // Reap on exit so a closed session doesn't leak. node-pty reports exit
171
+ // through `onExit`; a piped child uses the standard `exit` event.
172
+ if (isPty) child.onExit(() => { if (cliSessions.get(key) === session) cliSessions.delete(key); });
173
+ else child.on('exit', () => { if (cliSessions.get(key) === session) cliSessions.delete(key); });
174
+ return session;
175
+ }
176
+
177
+ function closeCliSession(projectDir) {
178
+ const key = String(projectDir || '');
179
+ const session = cliSessions.get(key);
180
+ if (!session) return false;
181
+ try {
182
+ if (session.child && !session.child.killed) session.child.kill();
183
+ } catch { /* already gone */ }
184
+ cliSessions.delete(key);
185
+ return true;
186
+ }
187
+
188
+ // Write one command line to the session's stdin. Shared by the HTTP handler
189
+ // and the tests so the terminator rule lives in exactly one place: CRLF for
190
+ // cmd.exe on Windows, LF for sh/bash on POSIX. A PTY in canonical mode also
191
+ // accepts CR to submit, but LF is what the piped path needs and what the
192
+ // shell consumes cleanly under both.
193
+ //
194
+ // `raw` writes the text with no terminator: an interactive program waiting on
195
+ // a single key (a `y/n` confirmation, a pager, a TUI) needs the byte alone,
196
+ // where appending a newline would answer a *second* prompt.
197
+ function writeCliCommand(session, cmd, raw) {
198
+ if (!session || !session.child) return false;
199
+ const newline = session.windows ? '\r\n' : '\n';
200
+ const line = String(cmd) + (raw ? '' : newline);
201
+ if (session.pty) {
202
+ if (typeof session.child.write !== 'function') return false;
203
+ session.child.write(line);
204
+ return true;
205
+ }
206
+ if (!session.child.stdin || !session.child.stdin.writable) return false;
207
+ session.child.stdin.write(line);
208
+ return true;
209
+ }
210
+
211
+ // Forward a session's stdout/stderr to the SSE broadcast channel. The
212
+ // browser opens GET /events, receives the session id, and listens for
213
+ // `cli_output` frames tagged with that id.
214
+ function attachCliStream(session, broadcast) {
215
+ if (!session || !session.child || !broadcast) return;
216
+ const emit = (stream, d) => {
217
+ broadcast('cli_output', { id: session.id, stream, data: Buffer.isBuffer(d) ? d.toString('utf8') : String(d) });
218
+ };
219
+ if (session.pty) {
220
+ // A PTY merges stdout and stderr into one readable stream and reports
221
+ // the exit code through onExit (the child has no `exit` event). Every
222
+ // chunk is labelled stdout because the two channels are no longer
223
+ // distinguishable.
224
+ session.child.onData((d) => emit('stdout', d));
225
+ session.child.onExit(({ exitCode }) => emit('exit', String(exitCode)));
226
+ return;
227
+ }
228
+ session.child.stdout.on('data', (d) => emit('stdout', d));
229
+ session.child.stderr.on('data', (d) => emit('stderr', d));
230
+ session.child.on('exit', (code) => emit('exit', String(code)));
231
+ }
232
+
233
+ // ---- Tools API ---------------------------------------------------------------
234
+ //
235
+ // REST surface for the native tool set (shell, file tools).
236
+ // Routes:
237
+ // GET /api/tools/list?projectDir=<abs>
238
+ // Catalog of every tool the model can be advertised to use on
239
+ // this project: native shell + file tools, plus MCP-discovered
240
+ // tools whose server is currently running. The chat UI reads
241
+ // this to render the per-chat tool toggles shown below the
242
+ // system prompt on a brand-new chat.
243
+ // POST /api/tools/shell body: { projectDir, cmd, timeoutMs? }
244
+ // -> NDJSON stream (application/x-ndjson): `output` lines while the
245
+ // command runs, then `result` line with
246
+ // { ok, stdout, stderr, exitCode, durationMs } | { ok:false, error, code }
247
+ // The tool is off unless the project's resolved settings enable it
248
+ // (settings.tools.shell.enabled). A disabled project returns
249
+ // ETOOL_DISABLED with HTTP 403.
250
+
251
+ // pushNativeTool(tools, opts) — append one built-in tool entry to the
252
+ // catalog. All the single native tools advertise the same shape
253
+ // ({ name, kind: 'native', source, description }); only the module to
254
+ // load, its spec accessor and the fallback description differ, so the
255
+ // six repetitive try/catch + push blocks collapse into one loader.
256
+ // `opts.spec(mod)` returns the tool's `function` block (or undefined).
257
+ // If the module cannot be loaded the tool is silently omitted.
258
+ function pushNativeTool(tools, opts) {
259
+ try {
260
+ const mod = require(opts.load);
261
+ const fn = opts.spec(mod) || {};
262
+ tools.push({
263
+ name: opts.name,
264
+ kind: 'native',
265
+ source: opts.source,
266
+ description: fn.description || opts.fallback
267
+ });
268
+ } catch { /* module unavailable; omit */ }
269
+ }
270
+
271
+ async function handleTools(req, res, parsed) {
272
+ const urlPath = parsed.pathname;
273
+ const method = req.method;
274
+ const q = parsed.query || {};
275
+
276
+ // GET /api/tools/list?projectDir=<abs>
277
+ // The catalog is read-only; it does not require a chat id. Native
278
+ // tools are always present; MCP tools are filtered to servers
279
+ // whose session is currently 'ready' (decisions §18: tools belong
280
+ // to a running session). A server that is not running is not
281
+ // listed — the user can start it from Settings → MCP, and a
282
+ // subsequent call will pick up the newly discovered tools.
283
+ if (urlPath === '/api/tools/list' && method === 'GET') {
284
+ const projectDir = qs(q, 'projectDir');
285
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
286
+ // A chat opening (or the mobile UI re-loading a project) should
287
+ // re-attach the MCP servers it needs. The Settings UI documents
288
+ // this as "open a chat that references a stopped server" — without
289
+ // it, the tool list comes back empty after a server restart.
290
+ // Wait for discovery so this response is authoritative. Returning
291
+ // before the servers finish starting makes a fresh .mcp.json
292
+ // configuration disappear from the first tool catalog (there may be
293
+ // no cache yet), and callers have no completion signal to know when
294
+ // to retry. ensureServersRunning isolates failures per server, so one
295
+ // broken MCP process does not fail the native-tool catalog.
296
+ await mcp.ensureServersRunning(projectDir).catch(() => []);
297
+ const tools = [];
298
+ pushNativeTool(tools, { load: './tools/shell.js', name: 'shell', source: 'shell', fallback: 'Run a shell command in the project directory.', spec: (m) => m.SPEC && m.SPEC.function });
299
+ pushNativeTool(tools, { load: './tools/progress.js', name: 'report_progress', source: 'progress', fallback: 'Report real-time progress on a long-running operation.', spec: (m) => m.SPEC && m.SPEC.function });
300
+ pushNativeTool(tools, { load: './tools/subagent.js', name: 'subagent', source: 'subagent', fallback: 'Delegate a focused task to a nested AI call.', spec: (m) => m.SPEC && m.SPEC.function });
301
+ pushNativeTool(tools, { load: './agentFeatures.js', name: 'list_features', source: 'features', fallback: 'Describe mouaif feature state.', spec: (m) => m.LIST_FEATURES_SPEC && m.LIST_FEATURES_SPEC.function });
302
+ pushNativeTool(tools, { load: './tools/ask.js', name: 'ask_user', source: 'ask_user', fallback: 'Ask the user a structured question with options.', spec: (m) => m.SPEC && m.SPEC.function });
303
+ pushNativeTool(tools, { load: './tools/task.js', name: 'task', source: 'task', fallback: 'Create, update, track progress on, and list structured tasks with subtasks.', spec: (m) => m.SPEC && m.SPEC.function });
304
+ pushNativeTool(tools, { load: './tools/webpreview.js', name: 'webpreview', source: 'webpreview', fallback: 'Open a web URL in the debug Chrome and return a small screenshot of the page.', spec: (m) => m.SPEC && m.SPEC.function });
305
+ pushNativeTool(tools, { load: './tools/restart.js', name: 'restart_app', source: 'restart', fallback: 'Gracefully restart mouaif from the current chat.', spec: (m) => m.SPEC && m.SPEC.function });
306
+ try {
307
+
308
+ const ft = require('./tools/files.js');
309
+ for (const name of ft.FILE_TOOL_NAMES) {
310
+ const spec = ft.SPECS && ft.SPECS[name];
311
+ tools.push({
312
+ name,
313
+ kind: 'native',
314
+ source: 'files',
315
+ description: (spec && spec.function && spec.function.description) || '',
316
+ parameters: (spec && spec.function && spec.function.parameters) || null
317
+ });
318
+ }
319
+ } catch { /* files module unavailable; omit */ }
320
+ try {
321
+ const mcpMod = require('./mcp.js');
322
+ const specs = mcpMod.listComposedToolSpecs(projectDir);
323
+ for (const s of (specs || [])) {
324
+ tools.push({
325
+ name: s.name,
326
+ kind: 'mcp',
327
+ source: s.serverSlug || '',
328
+ serverId: s.serverId || '',
329
+ description: s.description || '',
330
+ parameters: s.parameters || null
331
+ });
332
+ }
333
+ } catch { /* mcp module not loaded; no MCP tools */ }
334
+ return sendJSON(res, 200, { tools });
335
+ }
336
+
337
+ if (urlPath === '/api/tools/shell' && method === 'POST') {
338
+ const body = await readJsonOr400(req, res);
339
+ if (!body) return;
340
+ const projectDir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
341
+ const cmd = body && typeof body.cmd === 'string' ? body.cmd : '';
342
+ const shellOverride = body && typeof body.shell === 'string' ? body.shell : '';
343
+ const timeoutMs = body && typeof body.timeoutMs === 'number' ? body.timeoutMs : undefined;
344
+ const chatId = body && typeof body.chatId === 'string' ? body.chatId : '';
345
+ const callId = body && typeof body.callId === 'string' ? body.callId : '';
346
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
347
+ if (!cmd) return sendJSON(res, 400, { error: 'cmd is required' });
348
+ if (!chatId || !callId) return sendJSON(res, 400, { error: 'chatId and callId are required' });
349
+ if (!chats.getChat(projectDir, chatId)) return sendJSON(res, 404, { error: 'Chat not found', chatId });
350
+
351
+ // The authorization gate owns enablement. A legacy `tools.shell.enabled`
352
+ // check used to short-circuit here, but the authorization refactor
353
+ // stores enablement as `mode` (off = disabled, ask/allow/allowlist =
354
+ // enabled) with no `enabled` field — so that stale check always read
355
+ // `false` and every direct `/shell` call failed with ETOOL_DISABLED
356
+ // before ever reaching the prompt. authorize() below rejects `off` mode
357
+ // itself (defense-in-depth), so the pre-check is dropped entirely.
358
+
359
+ let authorization;
360
+ try {
361
+ authorization = await require('./tools/authorization.js').authorize({
362
+ projectDir, chatId, callId, tool: 'shell', cmd, summary: cmd, timeoutMs, flow: 'retry'
363
+ });
364
+ } catch (e) {
365
+ const status = e.code === 'ETOOL_DISABLED' || e.code === 'EDENIED' ? 403 : 400;
366
+ return sendJSON(res, status, { ok: false, error: e.message, code: e.code || 'EAUTH' });
367
+ }
368
+ if (authorization.decision === 'prompt') {
369
+ return sendJSON(res, 409, {
370
+ ok: false,
371
+ code: 'EAUTH_REQUIRED',
372
+ chatId,
373
+ callId,
374
+ tool: 'shell',
375
+ cmd,
376
+ timeoutMs: authorization.timeoutMs,
377
+ projectDir
378
+ });
379
+ }
380
+
381
+ // Streamed NDJSON response (Content-Type: application/x-ndjson):
382
+ // one line per live output chunk while the command is still running,
383
+ // then a final `result` line with the complete tool result. The chat
384
+ // composer's /shell card renders the output lines into its live
385
+ // preview, so a direct run behaves exactly like a model-driven run
386
+ // (which streams `shell_output` SSE frames). Error frames never
387
+ // appear here — the pre-run failures above (authorization, bad
388
+ // input) are plain JSON responses; only the run itself streams.
389
+ // The client treats a non-NDJSON body as a fallback and reads it as
390
+ // JSON, so an older server still works.
391
+ res.writeHead(200, {
392
+ 'Content-Type': 'application/x-ndjson; charset=utf-8',
393
+ 'Cache-Control': 'no-cache',
394
+ 'X-Content-Type-Options': 'nosniff'
395
+ });
396
+ const out = await shellTool.runShell({
397
+ projectDir, cmd,
398
+ shell: shellOverride || undefined,
399
+ timeoutMs: authorization.timeoutMs,
400
+ // Live output deltas for the composer card, mirroring the
401
+ // `shell_output` SSE event of the model-driven path.
402
+ onOutput: (stream, delta) => {
403
+ try {
404
+ res.write(JSON.stringify({ type: 'output', stream, delta }) + '\n');
405
+ } catch { /* client disconnected; the run keeps going server-side */ }
406
+ }
407
+ });
408
+ // Same identity header the model-facing tool message carries, so a
409
+ // composer /shell run reads exactly like a model-driven run.
410
+ if (out && typeof out === 'object' && !out.identity) out.identity = 'mouaif shell';
411
+ try {
412
+ res.end(JSON.stringify({ type: 'result', result: out }) + '\n');
413
+ } catch { /* client gone */ }
414
+ return;
415
+ }
416
+
417
+ // GET /api/tools/cli/session?projectDir=<abs>
418
+ // Start (or reuse) the persistent interactive terminal session for the
419
+ // project and return its id. The browser then opens GET /events and
420
+ // listens for `cli_output` frames tagged with that id.
421
+ if (urlPath === '/api/tools/cli/session' && method === 'GET') {
422
+ const projectDir = qs(q, 'projectDir');
423
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
424
+ const fs = require('node:fs');
425
+ let real;
426
+ try { real = fs.realpathSync(projectDir); } catch { return sendJSON(res, 400, { error: 'project directory not found', code: 'ENOENT' }); }
427
+ let st;
428
+ try { st = fs.statSync(real); } catch { return sendJSON(res, 400, { error: 'project directory not found', code: 'ENOENT' }); }
429
+ if (!st.isDirectory()) return sendJSON(res, 400, { error: 'projectDir is not a directory', code: 'ENOTDIR' });
430
+ const session = ensureCliSession(real);
431
+ // Attach the SSE output stream to the newly created (or reused)
432
+ // session. Re-used sessions already have their stream attached —
433
+ // attaching twice would double every output frame in the browser.
434
+ if (!session._streamAttached) {
435
+ session._streamAttached = true;
436
+ attachCliStream(session, broadcast);
437
+ }
438
+ const meta = cliShellMeta();
439
+ return sendJSON(res, 200, {
440
+ id: session.id,
441
+ projectDir: real,
442
+ shell: meta.label,
443
+ // True when the session runs on a pseudo-terminal, so prompting
444
+ // programs (npm under 2FA, git, sudo) can ask a question and read
445
+ // the answer. The modal surfaces this so a user knows interactive
446
+ // input is supported.
447
+ interactive: !!session.pty,
448
+ startedAt: session.startedAt,
449
+ defaultDir: real
450
+ });
451
+ }
452
+
453
+ // POST /api/tools/cli/command body: { projectDir, cmd }
454
+ // Write one command to the persistent session's stdin. The session
455
+ // stays open; the next command appends after it.
456
+ if (urlPath === '/api/tools/cli/command' && method === 'POST') {
457
+ const body = await readJsonOr400(req, res);
458
+ if (!body) return;
459
+ const projectDir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
460
+ const cmd = body && typeof body.cmd === 'string' ? body.cmd : '';
461
+ // `raw` sends the text with no line terminator (a single-key answer).
462
+ const raw = !!(body && body.raw);
463
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
464
+ const session = cliSessions.get(String(projectDir));
465
+ if (!session) return sendJSON(res, 404, { error: 'cli session not found — reopen the command prompt', code: 'ENOSESSION' });
466
+ try {
467
+ if (!writeCliCommand(session, cmd, raw)) {
468
+ return sendJSON(res, 410, { ok: false, error: 'cli session is not accepting input — reopen the command prompt', code: 'ENOWRITE' });
469
+ }
470
+ return sendJSON(res, 200, { ok: true });
471
+ } catch (e) {
472
+ return sendJSON(res, 500, { ok: false, error: e.message });
473
+ }
474
+ }
475
+
476
+ // POST /api/tools/cli/close body: { projectDir }
477
+ // Kill the persistent session (idempotent).
478
+ if (urlPath === '/api/tools/cli/close' && method === 'POST') {
479
+ const body = await readJsonOr400(req, res);
480
+ if (!body) return;
481
+ const projectDir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
482
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
483
+ closeCliSession(projectDir);
484
+ return sendJSON(res, 200, { ok: true });
485
+ }
486
+
487
+ // POST /api/tools/webpreview body: { projectDir, chatId, url, viewport? }
488
+ // Direct user-facing refresh of the web preview from the full-screen
489
+ // viewer (and the dock's Dismiss/Refresh cycle). The user may change the
490
+ // capture resolution with `viewport` (preset id or "WIDTHxHEIGHT"). The
491
+ // run goes through the same webpreview authorization gate as the
492
+ // model-driven path, so a disabled tool (mode 'off') is refused and an
493
+ // 'ask'-gated project still prompts. The result is returned as plain JSON
494
+ // (no SSE stream) — the chat UI publishes the fresh screenshot into its
495
+ // preview dock exactly as a model-driven capture does.
496
+ if (urlPath === '/api/tools/webpreview' && method === 'POST') {
497
+ const body = await readJsonOr400(req, res);
498
+ if (!body) return;
499
+ const projectDir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
500
+ const chatId = body && typeof body.chatId === 'string' ? body.chatId : '';
501
+ const url = body && typeof body.url === 'string' ? body.url.trim() : '';
502
+ const viewport = body && typeof body.viewport === 'string' ? body.viewport : '';
503
+ const callId = (body && typeof body.callId === 'string' && body.callId) ||
504
+ ('ui_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7));
505
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
506
+ if (!url) return sendJSON(res, 400, { error: 'url is required' });
507
+ if (!chatId) return sendJSON(res, 400, { error: 'chatId is required' });
508
+ if (!chats.getChat(projectDir, chatId)) return sendJSON(res, 404, { error: 'Chat not found', chatId });
509
+ // Gate through the same authorization module the model path uses. The URL
510
+ // is the summary (matching the allowlist by hostname / full URL) and the
511
+ // flow is 'retry' so a prompt resolves as a normal user decision.
512
+ let authorization;
513
+ try {
514
+ authorization = await require('./tools/authorization.js').authorize({
515
+ projectDir, chatId, callId, tool: 'webpreview', url, summary: url, flow: 'retry'
516
+ });
517
+ } catch (e) {
518
+ const status = e.code === 'ETOOL_DISABLED' || e.code === 'EDENIED' ? 403 : 400;
519
+ return sendJSON(res, status, { ok: false, error: e.message, code: e.code || 'EAUTH' });
520
+ }
521
+ if (authorization.decision === 'prompt') {
522
+ return sendJSON(res, 409, {
523
+ ok: false,
524
+ code: 'EAUTH_REQUIRED',
525
+ chatId,
526
+ callId,
527
+ tool: 'webpreview',
528
+ url,
529
+ projectDir
530
+ });
531
+ }
532
+ // Run the native capture. Only the user-facing result is returned; the
533
+ // screenshot is published to the dock by the same tool-publish hook the
534
+ // model path uses.
535
+ let wp;
536
+ try { wp = require('./tools/webpreview.js'); }
537
+ catch (e) {
538
+ return sendJSON(res, 500, { ok: false, error: 'webpreview tool module unavailable: ' + (e.message || e), code: 'EMODULE' });
539
+ }
540
+ try {
541
+ const out = await wp.runWebpreview({ url, viewport: viewport || undefined });
542
+ return sendJSON(res, 200, out);
543
+ } catch (e) {
544
+ return sendJSON(res, 500, { ok: false, error: e.message || String(e), code: e.code || 'EWEBPREVIEW' });
545
+ }
546
+ }
547
+ // POST /api/tools/subagent body: { projectDir, chatId, task, agent?, context?, modelId?, providerId? }
548
+ // Direct subagent dispatch from the composer (@agent <task>). Runs the
549
+ // native subagent tool through the same authorization gate and the
550
+ // same dispatcher the model-driven loop uses — one tool_call +
551
+ // tool_result pair, returned in the JSON body (no SSE stream).
552
+ if (urlPath === '/api/tools/subagent' && method === 'POST') {
553
+ const body = await readJsonOr400(req, res);
554
+ if (!body) return;
555
+ const projectDir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
556
+ const chatId = body && typeof body.chatId === 'string' ? body.chatId : '';
557
+ const task = body && typeof body.task === 'string' ? body.task.trim() : '';
558
+ const agentName = body && typeof body.agent === 'string' ? body.agent.trim() : '';
559
+ const context = body && typeof body.context === 'string' ? body.context : '';
560
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
561
+ if (!chatId) return sendJSON(res, 400, { error: 'chatId is required' });
562
+ if (!task) return sendJSON(res, 400, { error: 'task is required' });
563
+ const chat = chats.getChat(projectDir, chatId);
564
+ if (!chat) return sendJSON(res, 404, { error: 'Chat not found', chatId });
565
+
566
+ // Model: explicit body.modelId wins, else the chat's current model.
567
+ const modelId = typeof body.modelId === 'string' && body.modelId ? body.modelId : (chat.modelId || '');
568
+ const providerId = typeof body.providerId === 'string' && body.providerId ? body.providerId : (chat.providerId || '');
569
+ if (!modelId) return sendJSON(res, 400, { error: 'No model selected for this chat' });
570
+ let model;
571
+ try { model = resolveModel(modelId, projectDir, providerId); }
572
+ catch (e) { return sendJSON(res, e.code === 'EMODEL_NOT_FOUND' || e.code === 'EPROVIDER_NOT_FOUND' ? 404 : 400, { error: e.message, code: e.code || 'EBADMODEL' }); }
573
+ // Inherit the chat's thinking level so a delegated run (agent pin or
574
+ // generic) matches what a normal chat turn would send. An agent's own
575
+ // thinkingLevel still wins inside the dispatcher.
576
+ if (typeof chat.thinkingLevel === 'string' && chat.thinkingLevel) model.thinkingLevel = chat.thinkingLevel;
577
+
578
+ let appSettings;
579
+ try { appSettings = settings.getApp(); } catch { /* defaults apply */ }
580
+ const args = { task };
581
+ if (agentName) args.agent = agentName;
582
+ if (context) args.context = context;
583
+
584
+ const callId = 'direct_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
585
+ const events = [];
586
+ try {
587
+ const out = await ai.runSingleToolCall(
588
+ { id: callId, name: 'subagent', arguments: JSON.stringify(args) },
589
+ {
590
+ opts: {
591
+ projectDir,
592
+ chatId,
593
+ appSettings,
594
+ promptSize: chat.promptSize,
595
+ enabledTools: Array.isArray(chat.tools) ? chat.tools : null,
596
+ model
597
+ },
598
+ onEvent: (name, data) => events.push({ name, data }),
599
+ convo: null,
600
+ toolSpecs: [],
601
+ promptProfilesMod: null,
602
+ discoveredToolNames: null,
603
+ modelContentForTool: (name, exec) => (exec && exec.content) || '',
604
+ getLastToolCallKey: () => null,
605
+ setLastToolCallKey: () => {},
606
+ getRepeatedToolCallCount: () => 0,
607
+ setRepeatedToolCallCount: () => {},
608
+ REPEATED_TOOL_CALL_LIMIT: 3
609
+ }
610
+ );
611
+ const exec = out && out.exec;
612
+ const result = exec && exec.result;
613
+ const toolCall = events.find((e) => e.name === 'tool_call');
614
+ if (exec && exec.ok && result && typeof result.text === 'string' && result.text) {
615
+ let total = Number(result.totalCost);
616
+ if (!(isFinite(total) && total >= 0) && result.model && result.usage) {
617
+ try {
618
+ const estimated = usage.computeCost({ model: result.model, usage: result.usage, app: appSettings });
619
+ total = estimated.known ? estimated.total : NaN;
620
+ } catch { total = NaN; }
621
+ }
622
+ const cost = isFinite(total) && total >= 0
623
+ ? { known: true, input: 0, output: 0, total, currency: 'USD' }
624
+ : { known: false, input: 0, output: 0, total: 0, currency: 'USD' };
625
+ try {
626
+ messages.appendMessage(projectDir, chatId, {
627
+ role: 'assistant',
628
+ content: result.text,
629
+ usage: result.usage || undefined,
630
+ cost,
631
+ modelId: result.model && result.model.id ? result.model.id : model.id
632
+ });
633
+ } catch { /* direct dispatch still returns its result if persistence fails */ }
634
+ result.cost = cost;
635
+ }
636
+ const payload = {
637
+ ok: !!(exec && exec.ok),
638
+ id: callId,
639
+ name: 'subagent',
640
+ args,
641
+ result,
642
+ toolCall: toolCall ? { id: toolCall.data && toolCall.data.id, name: toolCall.data && toolCall.data.name, args: toolCall.data && toolCall.data.args } : { id: callId, name: 'subagent', args }
643
+ };
644
+ return sendJSON(res, 200, payload);
645
+ } catch (e) {
646
+ const status = e && (e.code === 'ETOOL_DISABLED' || e.code === 'EDENIED') ? 403 : 500;
647
+ return sendJSON(res, status, { ok: false, error: (e && e.message) || String(e), code: (e && e.code) || 'ESUBAGENT' });
648
+ }
649
+ }
650
+
651
+ return sendJSON(res, 404, { error: 'Not found' });
652
+ }
653
+
654
+ module.exports = { handleTools, ensureCliSession, closeCliSession, writeCliCommand, cliShellMeta, attachCliStream };