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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- 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
|
+
};
|