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,341 @@
1
+ 'use strict';
2
+
3
+ // Native `shell` tool — lets the model run a command in the project's
4
+ // working directory. Implements docs/features/shell-tool.md.
5
+ //
6
+ // Public surface:
7
+ // runShell({ projectDir, cmd, timeoutMs, maxBytes }) ->
8
+ // Promise<{ ok, stdout, stderr, exitCode, durationMs }>
9
+ // | { ok: false, error, code, durationMs }
10
+ // SPEC — the OpenAI-compatible tool spec advertised to the model.
11
+ //
12
+ // The runner is built on node:child_process.spawn only; no third-party
13
+ // shell wrappers, no new runtime dependencies. Every child is tracked
14
+ // in a module-level Set and killed on parent exit so a server shutdown
15
+ // does not leak zombies.
16
+
17
+ const { spawn } = require('node:child_process');
18
+ const { StringDecoder } = require('node:string_decoder');
19
+ const fs = require('node:fs');
20
+
21
+ const DEFAULT_TIMEOUT_MS = 30_000;
22
+ const MAX_TIMEOUT_MS = 10 * 60 * 1000; // 10 min ceiling
23
+ const DEFAULT_MAX_CHARS = 256 * 1024; // 256K chars per stream
24
+ const DEFAULT_MAX_BYTES = DEFAULT_MAX_CHARS; // legacy export alias
25
+ const KILL_GRACE_MS = 5_000; // SIGTERM -> SIGKILL grace
26
+
27
+ // Environment variables stripped from the child to prevent trivial
28
+ // tool escape via preloading / injected node options.
29
+ const ENV_DENYLIST = [
30
+ 'LD_PRELOAD',
31
+ 'LD_LIBRARY_PATH',
32
+ 'DYLD_INSERT_LIBRARIES',
33
+ 'NODE_OPTIONS'
34
+ ];
35
+
36
+ // Live children, reaped on parent exit.
37
+ const liveChildren = new Set();
38
+ let exitHooked = false;
39
+ function hookExit() {
40
+ if (exitHooked) return;
41
+ exitHooked = true;
42
+ const reap = () => {
43
+ for (const child of liveChildren) {
44
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
45
+ }
46
+ liveChildren.clear();
47
+ };
48
+ process.on('exit', reap);
49
+ process.on('SIGINT', () => { reap(); process.exit(130); });
50
+ process.on('SIGTERM', () => { reap(); process.exit(143); });
51
+ }
52
+
53
+ // Human-readable label for the platform shell ("PowerShell 7 (pwsh)",
54
+ // "bash (bash)", ...). Computed once at module load from the exact
55
+ // interpreter the runner will spawn, so the model-facing description
56
+ // and the result metadata always agree with what actually ran.
57
+ //
58
+ // Windows note: cmd.exe is a COMMAND.COM-style shell whose quoting,
59
+ // escaping and environment differ from POSIX sh. `SHELL` is
60
+ // conventionally unset on Windows, so we never consult it there; the
61
+ // child always runs under `ComSpec` (cmd.exe by default).
62
+ function shellLabelFor(shellPath) {
63
+ if (process.platform === 'win32') {
64
+ const base = shellPath.split(/[\\/]/).pop().toLowerCase();
65
+ if (base === 'pwsh.exe' || base === 'pwsh') return 'PowerShell 7 (pwsh)';
66
+ if (base === 'powershell.exe' || base === 'powershell') return 'Windows PowerShell (powershell)';
67
+ if (base === 'cmd.exe' || base === 'cmd') return 'Command Prompt (cmd.exe)';
68
+ return 'Windows shell (' + shellPath + ')';
69
+ }
70
+ const base = shellPath.split('/').pop() || shellPath;
71
+ return base + ' (' + shellPath + ')';
72
+ }
73
+
74
+ function shellLabel() {
75
+ return shellLabelFor(process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : (process.env.SHELL || '/bin/sh'));
76
+ }
77
+
78
+ function osLabel() {
79
+ if (process.platform === 'win32') return 'Windows';
80
+ if (process.platform === 'darwin') return 'macOS';
81
+ return process.platform;
82
+ }
83
+
84
+ // Shell-family summary for the model, e.g. "Command Prompt (cmd.exe) —
85
+ // Windows command-line syntax, not POSIX sh". This makes the
86
+ // interpreter's dialect explicit so the model does not assume bash.
87
+ function shellDialectHint() {
88
+ const label = shellLabel();
89
+ if (process.platform === 'win32') {
90
+ return label + ' — Windows command-line syntax (cmd.exe batch-style quoting and escaping; not POSIX sh/bash)';
91
+ }
92
+ return label + ' — POSIX sh syntax';
93
+ }
94
+
95
+ // "The exact interpreter that runs commands, e.g. 'C:\Windows\system32\cmd.exe'."
96
+ function shellExeDescription() {
97
+ if (process.platform === 'win32') {
98
+ const comspec = process.env.ComSpec || 'cmd.exe';
99
+ return 'The exact interpreter that runs commands, e.g. ' + JSON.stringify(comspec) + '.';
100
+ }
101
+ return "The exact interpreter that runs commands, e.g. '/bin/sh' or the user's \$SHELL.";
102
+ }
103
+
104
+ // The model-facing tool spec (OpenAI-compatible function shape).
105
+ const SPEC = {
106
+ type: 'function',
107
+ function: {
108
+ name: 'shell',
109
+ description: 'Run a shell command in the project directory through mouaif, the local AI coding assistant that provides this chat. Commands execute on ' + osLabel() + ' via ' + shellDialectHint() + '. Returns stdout, stderr, and exit code. Non-interactive only: the child has no stdin, so REPLs, prompts, and commands that read from stdin fail or exit immediately — run the one-shot/flagged form instead (e.g. "node -e ...", "npm test", not bare "node" or "cmd").',
110
+ parameters: {
111
+ type: 'object',
112
+ properties: {
113
+ cmd: { type: 'string', description: 'The command to run, as a single string. Must be non-interactive (no stdin input, no REPL, no prompts).' },
114
+ shell: { type: 'string', description: shellExeDescription() },
115
+ timeoutMs: { type: 'integer', description: 'Optional per-call timeout, 1 ms - 10 min. Default 30000.' }
116
+ },
117
+ required: ['cmd'],
118
+ additionalProperties: false
119
+ }
120
+ }
121
+ };
122
+
123
+ // Resolve and validate the working directory. Returns the real,
124
+ // absolute project dir or throws a typed error.
125
+ function resolveSandbox(projectDir) {
126
+ if (!projectDir || typeof projectDir !== 'string') {
127
+ const e = new Error('projectDir is required'); e.code = 'EBADINPUT'; throw e;
128
+ }
129
+ let real;
130
+ try { real = fs.realpathSync(projectDir); }
131
+ catch { const e = new Error('project directory not found'); e.code = 'ENOENT'; throw e; }
132
+ let st;
133
+ try { st = fs.statSync(real); }
134
+ catch { const e = new Error('project directory not found'); e.code = 'ENOENT'; throw e; }
135
+ if (!st.isDirectory()) {
136
+ const e = new Error('projectDir is not a directory'); e.code = 'ENOTDIR'; throw e;
137
+ }
138
+ return real;
139
+ }
140
+
141
+ // Build the child environment: parent env minus the denylist.
142
+ function childEnv() {
143
+ const env = Object.assign({}, process.env);
144
+ for (const key of ENV_DENYLIST) delete env[key];
145
+ return env;
146
+ }
147
+
148
+ // Truncate a Buffer/string to maxChars characters, appending a
149
+ // truncation marker. Counts UTF-16 code units (String.length) rather
150
+ // than bytes so the cap and the marker match how the model and the UI
151
+ // read the text; multi-byte output can no longer be split mid-codepoint.
152
+ function truncate(buf, maxChars) {
153
+ const s = Buffer.isBuffer(buf) ? buf.toString('utf8') : String(buf);
154
+ if (s.length <= maxChars) return s;
155
+ return s.slice(0, maxChars) + '\n...[truncated at ' + maxChars + ' chars]';
156
+ }
157
+
158
+ // The platform shell: cmd.exe on Windows, $SHELL (or /bin/sh) on POSIX.
159
+ //
160
+ // Windows needs two non-obvious tweaks:
161
+ // 1. The flags must be separate argv elements; passing "/d /s /c" as one
162
+ // element makes Node quote it ("/d /s /c") and cmd.exe mis-parse it.
163
+ // 2. With /s, cmd.exe strips the first and last quote of the command line,
164
+ // mangling inner quotes ("node -e \"console.log(1+1)\"" silently
165
+ // produces no output). Wrapping the whole command in an extra pair of
166
+ // quotes plus windowsVerbatimArguments preserves it exactly.
167
+ function platformShell() {
168
+ if (process.platform === 'win32') {
169
+ return { file: process.env.ComSpec || 'cmd.exe', flags: ['/d', '/s', '/c'], wrapQuotes: true, verbatim: true };
170
+ }
171
+ return { file: process.env.SHELL || '/bin/sh', flags: ['-c'], wrapQuotes: false, verbatim: false };
172
+ }
173
+
174
+ // runShell — execute cmd in projectDir, capturing stdout/stderr.
175
+ // Optional opts.onOutput(stream, delta) fires per decoded output chunk
176
+ // while the command is still running so callers can stream a live
177
+ // preview to the chat UI.
178
+ async function runShell(opts) {
179
+ const projectDir = opts && opts.projectDir;
180
+ const cmd = opts && opts.cmd;
181
+ const onOutput = opts && typeof opts.onOutput === 'function' ? opts.onOutput : null;
182
+ let timeoutMs = opts && typeof opts.timeoutMs === 'number' ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
183
+ const maxChars = opts && typeof opts.maxChars === 'number' ? opts.maxChars
184
+ : (opts && typeof opts.maxBytes === 'number' ? opts.maxBytes : DEFAULT_MAX_CHARS);
185
+
186
+ if (!cmd || typeof cmd !== 'string' || !cmd.trim()) {
187
+ return { ok: false, error: 'cmd is required', code: 'EBADINPUT', durationMs: 0 };
188
+ }
189
+ // Optional explicit interpreter override (Windows: cmd.exe / pwsh /
190
+ // powershell.exe; POSIX: a path to a sh-compatible binary). The
191
+ // override is validated below and reuses the platform flags, so it
192
+ // cannot turn into an arbitrary-args injection.
193
+ let shellOverride = opts && typeof opts.shell === 'string' ? opts.shell.trim() : '';
194
+ if (shellOverride && shellOverride !== 'default') {
195
+ const lower = shellOverride.toLowerCase();
196
+ const knownWin = ['cmd.exe', 'cmd', 'pwsh.exe', 'pwsh', 'powershell.exe', 'powershell'];
197
+ if (process.platform === 'win32' && !knownWin.includes(lower)) {
198
+ return { ok: false, error: 'unsupported shell override on Windows: ' + shellOverride + ' (expected cmd.exe, pwsh, or powershell)', code: 'EBADINPUT', durationMs: 0 };
199
+ }
200
+ if (process.platform !== 'win32' && (lower.endsWith('.exe') || lower.includes('\\') || !lower.includes('/'))) {
201
+ return { ok: false, error: 'unsupported shell override: ' + shellOverride + ' (expected a path to a sh-compatible binary)', code: 'EBADINPUT', durationMs: 0 };
202
+ }
203
+ } else {
204
+ shellOverride = '';
205
+ }
206
+ // Clamp timeout to [1, MAX_TIMEOUT_MS].
207
+ if (!(timeoutMs >= 1)) timeoutMs = DEFAULT_TIMEOUT_MS;
208
+ if (timeoutMs > MAX_TIMEOUT_MS) timeoutMs = MAX_TIMEOUT_MS;
209
+
210
+ // Identity of this runner, surfaced in the tool result and prepended
211
+ // to the first model-facing tool message, so the model always knows
212
+ // which software and which shell executed the command.
213
+ let identity = 'mouaif shell · ' + osLabel() + ' · ' + shellLabel();
214
+
215
+ let cwd;
216
+ try { cwd = resolveSandbox(projectDir); }
217
+ catch (e) { return { ok: false, error: e.message, code: e.code || 'EOUTSIDE_PROJECT', durationMs: 0 }; }
218
+
219
+ hookExit();
220
+
221
+ const base = platformShell();
222
+ const override = shellOverride || '';
223
+ const file = override || base.file;
224
+ // cmd.exe (and cmd aliases) must keep the wrapper-quote handling;
225
+ // any POSIX-style interpreter gets the plain `-c` flag path.
226
+ const isCmd = process.platform === 'win32' && (!override || ['cmd.exe', 'cmd'].includes(override.toLowerCase()));
227
+ const flags = isCmd ? base.flags : ['-c'];
228
+ const wrapQuotes = isCmd;
229
+ const verbatim = isCmd;
230
+ const args = flags.concat(wrapQuotes ? '"' + cmd + '"' : cmd);
231
+ // Identity reflects the interpreter that actually runs the command,
232
+ // which is the override when one was requested.
233
+ if (override) identity = 'mouaif shell · ' + osLabel() + ' · ' + shellLabelFor(file);
234
+ const startedAt = Date.now();
235
+
236
+ return await new Promise((resolve) => {
237
+ let child;
238
+ try {
239
+ child = spawn(file, args, {
240
+ cwd,
241
+ env: childEnv(),
242
+ windowsHide: true,
243
+ windowsVerbatimArguments: verbatim,
244
+ stdio: ['ignore', 'pipe', 'pipe']
245
+ });
246
+ } catch (e) {
247
+ return resolve({ ok: false, error: e.message, code: 'ESPAWN', durationMs: Date.now() - startedAt });
248
+ }
249
+
250
+ liveChildren.add(child);
251
+
252
+ // Output is accumulated as decoded text (StringDecoder keeps
253
+ // multi-byte UTF-8 sequences intact across chunk boundaries) so
254
+ // both the char cap and the live preview deltas are codepoint-safe.
255
+ const outDec = new StringDecoder('utf8');
256
+ const errDec = new StringDecoder('utf8');
257
+ let outText = '';
258
+ let errText = '';
259
+ let settled = false;
260
+ let killGrace = null;
261
+
262
+ const appendCapped = (existing, decoder, chunk, stream) => {
263
+ const text = decoder.write(chunk);
264
+ if (text && onOutput) {
265
+ try { onOutput(stream, text); } catch { /* preview is best-effort */ }
266
+ }
267
+ // Stop growing the buffer once we exceed the cap (leave room for
268
+ // the marker in truncate()); we still drain the stream so the
269
+ // child does not block on a full pipe.
270
+ if (existing.length >= maxChars) return existing;
271
+ return existing + text;
272
+ };
273
+
274
+ child.stdout.on('data', (c) => { outText = appendCapped(outText, outDec, c, 'stdout'); });
275
+ child.stderr.on('data', (c) => { errText = appendCapped(errText, errDec, c, 'stderr'); });
276
+
277
+ const timer = setTimeout(() => {
278
+ // Timed out: SIGTERM, then SIGKILL after the grace window.
279
+ try { child.kill('SIGTERM'); } catch { /* gone */ }
280
+ killGrace = setTimeout(() => {
281
+ try { child.kill('SIGKILL'); } catch { /* gone */ }
282
+ }, KILL_GRACE_MS);
283
+ killGrace.unref && killGrace.unref();
284
+ if (!settled) {
285
+ settled = true;
286
+ // Note: the child may still be alive (waiting out the SIGKILL
287
+ // grace window). Keep it in liveChildren so the exit hook can
288
+ // reap it; its late 'close' is a no-op because `settled` is true.
289
+ resolve({
290
+ ok: false,
291
+ error: 'timed out',
292
+ code: 'ETIMEDOUT',
293
+ stdout: truncate(outText, maxChars),
294
+ stderr: truncate(errText, maxChars),
295
+ identity,
296
+ durationMs: Date.now() - startedAt
297
+ });
298
+ }
299
+ }, timeoutMs);
300
+ timer.unref && timer.unref();
301
+
302
+ child.on('error', (e) => {
303
+ if (settled) return;
304
+ settled = true;
305
+ clearTimeout(timer);
306
+ if (killGrace) clearTimeout(killGrace);
307
+ liveChildren.delete(child);
308
+ resolve({ ok: false, error: e.message, code: 'ESPAWN', durationMs: Date.now() - startedAt });
309
+ });
310
+
311
+ child.on('close', (exitCode) => {
312
+ if (settled) return;
313
+ settled = true;
314
+ clearTimeout(timer);
315
+ if (killGrace) clearTimeout(killGrace);
316
+ liveChildren.delete(child);
317
+ resolve({
318
+ ok: exitCode === 0,
319
+ stdout: truncate(outText, maxChars),
320
+ stderr: truncate(errText, maxChars),
321
+ exitCode: exitCode == null ? -1 : exitCode,
322
+ identity,
323
+ durationMs: Date.now() - startedAt
324
+ });
325
+ });
326
+ });
327
+ }
328
+
329
+ module.exports = {
330
+ runShell,
331
+ resolveSandbox,
332
+ truncate,
333
+ SPEC,
334
+ DEFAULT_TIMEOUT_MS,
335
+ MAX_TIMEOUT_MS,
336
+ DEFAULT_MAX_CHARS,
337
+ DEFAULT_MAX_BYTES,
338
+ shellLabel,
339
+ shellDialectHint,
340
+ platformShell
341
+ };
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ // Native `subagent` tool — delegates a focused prompt to a nested model call.
4
+ // The nested call uses the same model and the same project tool surface (except
5
+ // subagent itself, to avoid recursive delegation loops). Tool authorization is
6
+ // still enforced by the normal gate. The result is returned as an ordinary tool
7
+ // result for the parent assistant to summarize or use. When the model emits
8
+ // several subagent calls in one turn, the tool loop runs them concurrently
9
+ // (see the parallel branch in src/ai.js).
10
+
11
+ const BASE_DESCRIPTION = 'Delegate a focused analysis or planning task to a nested AI call. The subagent can use the project tools and MCP tools, with normal authorization prompts. You may call subagent multiple times in a single turn; same-turn subagent calls run in parallel.';
12
+
13
+ const AGENT_DESCRIPTION = 'Optional project agent name (from Settings → Project → Agents) to use for this delegation.';
14
+
15
+ const SPEC = {
16
+ type: 'function',
17
+ function: {
18
+ name: 'subagent',
19
+ description: BASE_DESCRIPTION,
20
+ parameters: {
21
+ type: 'object',
22
+ properties: {
23
+ task: { type: 'string', description: 'The focused task or question for the subagent.' },
24
+ context: { type: 'string', description: 'Optional relevant context to include with the task.' },
25
+ agent: { type: 'string', description: AGENT_DESCRIPTION }
26
+ },
27
+ required: ['task'],
28
+ additionalProperties: false
29
+ }
30
+ }
31
+ };
32
+
33
+ // buildSpec(agentNames)
34
+ //
35
+ // Return the tool spec with the `agent` parameter description listing
36
+ // the project's currently-defined agent names, so the model sees
37
+ // exactly what it can pass. With no agents the base spec is returned.
38
+ function buildSpec(agentNames) {
39
+ const names = Array.isArray(agentNames) ? agentNames.filter(Boolean) : [];
40
+ if (!names.length) return SPEC;
41
+ const spec = JSON.parse(JSON.stringify(SPEC));
42
+ spec.function.parameters.properties.agent.description =
43
+ AGENT_DESCRIPTION + ' Available agents: ' + names.join(', ') + '.';
44
+ return spec;
45
+ }
46
+
47
+ module.exports = { SPEC, buildSpec };
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+
3
+ // Native `task` tool — lets the model define structured tasks with subtasks,
4
+ // track progress, and mark items done. Tasks are stored in-memory per chat
5
+ // (they are ephemeral and do not survive a server restart). Each task has an
6
+ // auto-generated id so the model can reference it across turns.
7
+ //
8
+ // Public surface:
9
+ // SPEC — the OpenAI-compatible tool spec advertised to the model.
10
+ // validateArgs — validate and normalize the incoming arguments.
11
+ // buildResult — shape the runner result.
12
+ // dispatchTask — execute a task action and return the result.
13
+ // clearChat — remove all tasks for a chat (called on chat delete).
14
+ // listChatTasks — return the task list for a chat (for UI queries).
15
+
16
+ const crypto = require('node:crypto');
17
+
18
+ // In-memory store: Map<chatId, Map<taskId, task>>
19
+ const taskStore = new Map();
20
+
21
+ function tasksFor(chatId) {
22
+ if (!chatId || typeof chatId !== 'string') return null;
23
+ if (!taskStore.has(chatId)) taskStore.set(chatId, new Map());
24
+ return taskStore.get(chatId);
25
+ }
26
+
27
+ function nextId() {
28
+ return crypto.randomBytes(4).toString('hex');
29
+ }
30
+
31
+ const MAX_TITLE_CHARS = 60;
32
+ const MAX_DESC_CHARS = 2000;
33
+ const MAX_SUBTASK_TITLE_CHARS = 200;
34
+ const MAX_TASKS_PER_CHAT = 50;
35
+
36
+ const SPEC = {
37
+ type: 'function',
38
+ function: {
39
+ name: 'task',
40
+ description: 'Create, update, track progress on, and list structured tasks with subtasks. Use this to break down complex work into manageable pieces and show progress to the user. Each task gets an auto-generated ID you reference in later calls.',
41
+ parameters: {
42
+ type: 'object',
43
+ properties: {
44
+ action: {
45
+ type: 'string',
46
+ enum: ['create', 'update_progress', 'list', 'complete'],
47
+ description: 'Action to perform: create a new task, update progress on an existing task, list all tasks in the current chat, or mark a task complete.'
48
+ },
49
+ title: {
50
+ type: 'string',
51
+ description: 'Task title (required when action is "create", max 60 chars). Keep it short — a few words.'
52
+ },
53
+ description: {
54
+ type: 'string',
55
+ description: 'Optional task description (max 2000 chars).'
56
+ },
57
+ taskId: {
58
+ type: 'string',
59
+ description: 'The task ID returned when the task was created. Required for "update_progress", "complete".'
60
+ },
61
+ current: {
62
+ type: 'number',
63
+ description: 'Current progress count (0-based). Required for "update_progress". Ignored for other actions.'
64
+ },
65
+ total: {
66
+ type: 'number',
67
+ description: 'Total progress count. Required for "update_progress". Ignored for other actions.'
68
+ }
69
+ },
70
+ required: ['action'],
71
+ additionalProperties: false
72
+ }
73
+ }
74
+ };
75
+
76
+ function trimString(value, max) {
77
+ if (typeof value !== 'string') return '';
78
+ const s = value.trim();
79
+ if (s.length <= max) return s;
80
+ return s.slice(0, max);
81
+ }
82
+
83
+ function validateArgs(args) {
84
+ if (!args || typeof args !== 'object') {
85
+ const e = new Error('args is required'); e.code = 'EBADINPUT'; throw e;
86
+ }
87
+ const action = args.action;
88
+ if (action !== 'create' && action !== 'update_progress' && action !== 'list' && action !== 'complete') {
89
+ const e = new Error('action must be "create", "update_progress", "list", or "complete"'); e.code = 'EBADINPUT'; throw e;
90
+ }
91
+ if (action === 'create') {
92
+ const title = trimString(args.title, MAX_TITLE_CHARS);
93
+ if (!title) {
94
+ const e = new Error('title is required when action is "create"'); e.code = 'EBADINPUT'; throw e;
95
+ }
96
+ return {
97
+ action: 'create',
98
+ title,
99
+ description: trimString(args.description, MAX_DESC_CHARS)
100
+ };
101
+ }
102
+ if (action === 'update_progress') {
103
+ const taskId = typeof args.taskId === 'string' && args.taskId.trim() ? args.taskId.trim() : null;
104
+ if (!taskId) {
105
+ const e = new Error('taskId is required when action is "update_progress"'); e.code = 'EBADINPUT'; throw e;
106
+ }
107
+ const current = Number.isFinite(args.current) ? Math.max(0, Math.round(args.current)) : null;
108
+ const total = Number.isFinite(args.total) ? Math.max(1, Math.round(args.total)) : null;
109
+ if (current == null || total == null) {
110
+ const e = new Error('current and total are required when action is "update_progress"'); e.code = 'EBADINPUT'; throw e;
111
+ }
112
+ return { action: 'update_progress', taskId, current, total };
113
+ }
114
+ if (action === 'complete') {
115
+ const taskId = typeof args.taskId === 'string' && args.taskId.trim() ? args.taskId.trim() : null;
116
+ if (!taskId) {
117
+ const e = new Error('taskId is required when action is "complete"'); e.code = 'EBADINPUT'; throw e;
118
+ }
119
+ return { action: 'complete', taskId };
120
+ }
121
+ // list — no extra args
122
+ return { action: 'list' };
123
+ }
124
+
125
+ // dispatchTask(chatId, args) -> { ok, content, result }
126
+ //
127
+ // args must already be validated by validateArgs.
128
+ function dispatchTask(chatId, validated) {
129
+ if (!chatId) {
130
+ return { ok: false, content: JSON.stringify({ error: 'chatId is required' }), result: { error: 'chatId is required' } };
131
+ }
132
+
133
+ if (validated.action === 'list') {
134
+ const tasks = tasksFor(chatId);
135
+ if (!tasks || !tasks.size) {
136
+ const result = { tasks: [] };
137
+ return { ok: true, content: JSON.stringify(result), result };
138
+ }
139
+ const taskList = [];
140
+ for (const t of tasks.values()) {
141
+ taskList.push(t);
142
+ }
143
+ // Sort by creation order
144
+ taskList.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
145
+ const result = { tasks: taskList };
146
+ return { ok: true, content: JSON.stringify(result), result };
147
+ }
148
+
149
+ if (validated.action === 'create') {
150
+ const tasks = tasksFor(chatId);
151
+ if (!tasks) {
152
+ return { ok: false, content: JSON.stringify({ error: 'No chat context' }), result: { error: 'No chat context' } };
153
+ }
154
+ if (tasks.size >= MAX_TASKS_PER_CHAT) {
155
+ return { ok: false, content: JSON.stringify({ error: 'Maximum tasks per chat reached (' + MAX_TASKS_PER_CHAT + ')' }), result: { error: 'Maximum tasks per chat reached' } };
156
+ }
157
+ const id = nextId();
158
+ const task = {
159
+ id,
160
+ title: validated.title,
161
+ description: validated.description || '',
162
+ status: 'in_progress',
163
+ current: 0,
164
+ total: 100,
165
+ createdAt: Date.now()
166
+ };
167
+ tasks.set(id, task);
168
+ const result = { task, action: 'created' };
169
+ return { ok: true, content: JSON.stringify(result), result };
170
+ }
171
+
172
+ if (validated.action === 'update_progress') {
173
+ const tasks = tasksFor(chatId);
174
+ if (!tasks || !tasks.has(validated.taskId)) {
175
+ return { ok: false, content: JSON.stringify({ error: 'task not found', taskId: validated.taskId }), result: { error: 'task not found', taskId: validated.taskId } };
176
+ }
177
+ const task = tasks.get(validated.taskId);
178
+ task.current = validated.current;
179
+ task.total = validated.total;
180
+ const result = {
181
+ task: {
182
+ id: task.id,
183
+ title: task.title,
184
+ status: task.status,
185
+ current: task.current,
186
+ total: task.total
187
+ },
188
+ action: 'progress_updated'
189
+ };
190
+ return { ok: true, content: JSON.stringify(result), result };
191
+ }
192
+
193
+ if (validated.action === 'complete') {
194
+ const tasks = tasksFor(chatId);
195
+ if (!tasks || !tasks.has(validated.taskId)) {
196
+ return { ok: false, content: JSON.stringify({ error: 'task not found', taskId: validated.taskId }), result: { error: 'task not found', taskId: validated.taskId } };
197
+ }
198
+ const task = tasks.get(validated.taskId);
199
+ task.status = 'completed';
200
+ task.current = task.total;
201
+ const result = {
202
+ task: {
203
+ id: task.id,
204
+ title: task.title,
205
+ status: task.status,
206
+ current: task.current,
207
+ total: task.total
208
+ },
209
+ action: 'completed'
210
+ };
211
+ return { ok: true, content: JSON.stringify(result), result };
212
+ }
213
+
214
+ return { ok: false, content: JSON.stringify({ error: 'unknown action' }), result: { error: 'unknown action' } };
215
+ }
216
+
217
+ function clearChat(chatId) {
218
+ taskStore.delete(chatId);
219
+ }
220
+
221
+ function listChatTasks(chatId) {
222
+ const tasks = taskStore.get(chatId);
223
+ if (!tasks) return [];
224
+ return Array.from(tasks.values()).sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
225
+ }
226
+
227
+ module.exports = {
228
+ SPEC,
229
+ validateArgs,
230
+ dispatchTask,
231
+ clearChat,
232
+ listChatTasks,
233
+ MAX_TASKS_PER_CHAT
234
+ };