aegiscode 6.1.0 → 6.2.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.
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * prompt.js — the desktop client's system prompt (client half of
5
+ * aegiscodex-dev's tool calling).
6
+ *
7
+ * aegiscodex-dev sends a real persona on every provider turn
8
+ * (src/backend.js MAIN_CHAT_PROMPT plus a docs/operating-context.md block).
9
+ * The desktop renderer used to send nothing at all, so every model answered a
10
+ * bare user string with no identity, no work rules and no idea which machine
11
+ * it was on — hence the "which OS are you using?" round trips.
12
+ *
13
+ * MAIN_CHAT_PROMPT below is the same identity + truthfulness rule set, ported
14
+ * verbatim where it still applies, including the CLI's "delegate with the
15
+ * task tool" rule now that the desktop has a subagent runner (engine.js
16
+ * runSubagent). One naming deviation from the CLI text: the tool names are
17
+ * readFile/writeFile/editFile/listDir/glob/grep/exec/task (lowerCamelCase,
18
+ * matching this repo's existing tool vocabulary — see
19
+ * desktop/lib/local/tools.js) rather than the CLI's Read/Write/Edit/Grep/
20
+ * Bash/Task.
21
+ *
22
+ * The environment preamble names the platform, the home directory and the
23
+ * repo roots the host already knows (main.js), so the model stops asking.
24
+ */
25
+
26
+ /** Identity + work rules. Ported from aegiscodex-dev src/backend.js. */
27
+ const MAIN_CHAT_PROMPT =
28
+ `You are Aegiscodex, a terminal coding assistant that works in the user's repository. ` +
29
+ `Help with software engineering tasks: read and reason about code, write and edit files, ` +
30
+ `run shell commands, and investigate bugs. Work rules:\n` +
31
+ `- Use tools silently. A one-line reason is enough; do not narrate your plan as a story. ` +
32
+ `- Never claim what a tool found or what a command returned before the tool actually runs. ` +
33
+ ` Report only the results you really received. ` +
34
+ `- Act, don't just inspect. After at most 2 rounds of reading or exploration, start making ` +
35
+ ` changes with writeFile or editFile. Reconnaissance is not progress — implement, then verify.\n` +
36
+ `- When you have what you need, stop using tools and give a concise, direct answer to the ` +
37
+ ` user's question. Never end your turn with an intention like "Let me check…" or "I'll now…" ` +
38
+ ` — that is not an answer. ` +
39
+ `- When a focused multi-step sub-task can be delegated, use the task tool to spawn a ` +
40
+ ` specialist subagent rather than doing everything inline. ` +
41
+ `- If the user references a workflow you don't recognize, inspect the repo/scripts for that ` +
42
+ ` mechanism before acting — don't assume it means inline work.`;
43
+
44
+ /** The tools the desktop actually advertises (kept in step with tools.js). */
45
+ const TOOL_LINE =
46
+ 'Tools: readFile, writeFile, editFile, listDir, glob, grep, exec, task. Paths are absolute; ' +
47
+ 'exec runs in a persistent shell session on this machine — cd and exported env vars carry ' +
48
+ 'across calls within the turn, like a real terminal. task spawns a specialist subagent ' +
49
+ '(its own tool loop, same model) for a focused, self-contained piece of work.';
50
+
51
+ /**
52
+ * Render the environment preamble. Everything is optional: a missing field is
53
+ * simply omitted, so a headless caller can build a persona with nothing but
54
+ * the identity block.
55
+ */
56
+ function environmentPreamble(env = {}) {
57
+ const { platform, arch, homedir, cwd, roots, appVersion, model } = env || {};
58
+ const bits = [];
59
+ if (platform) bits.push(`platform: ${platform}${arch ? ` (${arch})` : ''}`);
60
+ if (homedir) bits.push(`home directory: ${homedir}`);
61
+ if (cwd) bits.push(`working directory: ${cwd}`);
62
+ if (model) bits.push(`model: ${model}`);
63
+ if (appVersion) bits.push(`AEGIS Desktop ${appVersion}`);
64
+
65
+ const rootList = Array.isArray(roots) ? roots.filter(Boolean) : [];
66
+ const lines = [];
67
+ if (bits.length) lines.push(bits.join('\n'));
68
+ if (rootList.length) {
69
+ lines.push(`repo roots:\n${rootList.map((r) => ` - ${r}`).join('\n')}`);
70
+ }
71
+ if (!lines.length) return '';
72
+ return `# Environment\n${lines.join('\n')}`;
73
+ }
74
+
75
+ /**
76
+ * The full system prompt for a desktop chat turn: identity + work rules +
77
+ * the tool line + the environment block. Never empty.
78
+ */
79
+ function buildSystemPrompt(env = {}) {
80
+ const parts = [MAIN_CHAT_PROMPT, TOOL_LINE];
81
+ const preamble = environmentPreamble(env);
82
+ if (preamble) parts.push(preamble);
83
+ return parts.join('\n\n');
84
+ }
85
+
86
+ module.exports = {
87
+ MAIN_CHAT_PROMPT,
88
+ TOOL_LINE,
89
+ environmentPreamble,
90
+ buildSystemPrompt,
91
+ };
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * shell.js — a persistent shell session for the `exec` tool, ported from
5
+ * aegiscodex-dev's src/shell.js (client half of the same design).
6
+ *
7
+ * The desktop `exec` tool used to spawn ONE process per call: `cd /foo` in
8
+ * one turn had no effect on the next call, so a model that wanted to work
9
+ * inside a subdirectory had to prefix every single command with `cd X &&`.
10
+ * This keeps ONE long-lived shell per chat turn — bash on macOS/Linux,
11
+ * PowerShell on Windows (no bash there by default) — feeding it commands over
12
+ * stdin and framing each command's output with a per-session random sentinel
13
+ * printed alongside the exit code. On bash, stderr is merged into stdout
14
+ * (`exec 2>&1`) so ordering is preserved; PowerShell's stderr pipe is merged
15
+ * the same way by listening on both streams.
16
+ *
17
+ * Self-contained (node:child_process + node:crypto only) and never throws:
18
+ * run() always resolves { content, isError }. If the session can't start or
19
+ * dies, run() falls back to a one-shot spawn so `exec` keeps working.
20
+ */
21
+
22
+ const { spawn } = require('node:child_process');
23
+ const crypto = require('node:crypto');
24
+
25
+ const IS_WIN32 = process.platform === 'win32';
26
+ const OUTPUT_CAP = 30_000;
27
+ const MAX_TIMEOUT = 600_000;
28
+ const DEFAULT_TIMEOUT = 120_000;
29
+
30
+ function cap(s) {
31
+ return s.length > OUTPUT_CAP ? `${s.slice(0, OUTPUT_CAP)}\n… (truncated)` : s;
32
+ }
33
+
34
+ /** One-shot fallback — the pre-session behavior, used when no live session. */
35
+ function oneShotShell({ command, timeout = DEFAULT_TIMEOUT, working_directory } = {}) {
36
+ return new Promise((resolve) => {
37
+ const cmd = IS_WIN32 ? (process.env.ComSpec || 'cmd.exe') : (process.env.SHELL || '/bin/sh');
38
+ const arg = IS_WIN32 ? '/d /s /c' : '-c';
39
+ const child = spawn(cmd, [arg, String(command || '')], {
40
+ cwd: working_directory || process.cwd(),
41
+ timeout: Math.min(Number(timeout) || DEFAULT_TIMEOUT, MAX_TIMEOUT),
42
+ });
43
+ let out = '';
44
+ let err = '';
45
+ child.stdout.on('data', (d) => { out += d; });
46
+ child.stderr.on('data', (d) => { err += d; });
47
+ child.on('error', (e) => resolve({ content: `error: ${e.message}`, isError: true }));
48
+ child.on('close', (code) => {
49
+ const body = `${out}${err ? `\n${err}` : ''}`.trim() || `(exit ${code})`;
50
+ resolve({ content: cap(body), isError: code !== 0 });
51
+ });
52
+ });
53
+ }
54
+
55
+ class ShellSession {
56
+ constructor({ cwd } = {}) {
57
+ this.sentinel = `__AEGIS_SH_${crypto.randomBytes(8).toString('hex')}__`;
58
+ this.marker = `${this.sentinel}EXIT:`;
59
+ this.buf = '';
60
+ this.alive = false;
61
+ this._pending = null; // resolver-scan for the in-flight command
62
+ this._start(cwd);
63
+ }
64
+
65
+ _start(cwd) {
66
+ try {
67
+ this.child = IS_WIN32
68
+ // -Command - reads the script from stdin as a non-interactive session,
69
+ // so there are no prompts to pollute output, but state still persists.
70
+ ? spawn('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '-'], {
71
+ cwd: cwd || process.cwd(),
72
+ env: process.env,
73
+ stdio: ['pipe', 'pipe', 'pipe'],
74
+ })
75
+ // No args → the shell reads commands from stdin as a non-interactive
76
+ // script, so there are no prompts to pollute output, but state persists.
77
+ : spawn(process.env.SHELL || '/bin/bash', [], {
78
+ cwd: cwd || process.cwd(),
79
+ env: process.env,
80
+ stdio: ['pipe', 'pipe', 'pipe'],
81
+ });
82
+ } catch {
83
+ this.alive = false;
84
+ return;
85
+ }
86
+ this.alive = true;
87
+ const onData = (d) => { this.buf += d.toString(); if (this._pending) this._pending(); };
88
+ this.child.stdout.on('data', onData);
89
+ this.child.stderr.on('data', onData);
90
+ this.child.on('exit', () => { this.alive = false; if (this._pending) this._pending(true); });
91
+ this.child.on('error', () => { this.alive = false; if (this._pending) this._pending(true); });
92
+ // Merge stderr into stdout for the whole session so output ordering is
93
+ // faithful and the sentinel (printed to fd1) always lands after it.
94
+ // PowerShell errors already arrive on the stderr pipe onData folds in
95
+ // above, so only bash needs the explicit redirect.
96
+ if (!IS_WIN32) {
97
+ try { this.child.stdin.write('exec 2>&1\n'); } catch { this.alive = false; }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Run one command in the session. Resolves { content, isError }. A dead or
103
+ * unstartable session (or a working_directory that must not persist)
104
+ * degrades gracefully. `working_directory` is scoped to this one command (a
105
+ * subshell on bash, Push-Location/Pop-Location on PowerShell) so it doesn't
106
+ * move the session's cwd.
107
+ */
108
+ run(command, { timeout = DEFAULT_TIMEOUT, working_directory } = {}) {
109
+ if (!this.alive || !this.child) {
110
+ return oneShotShell({ command, timeout, working_directory });
111
+ }
112
+ let cmd = String(command || '');
113
+
114
+ return new Promise((resolve) => {
115
+ let settled = false;
116
+ const finish = (result) => {
117
+ if (settled) return;
118
+ settled = true;
119
+ clearTimeout(timer);
120
+ this._pending = null;
121
+ resolve(result);
122
+ };
123
+
124
+ const timer = setTimeout(() => {
125
+ // A hung command (e.g. one that reads stdin) can never reach the
126
+ // sentinel — kill the poisoned session and report what we have.
127
+ const partial = cap(this.buf.trim());
128
+ this.dispose();
129
+ finish({ content: `${partial}${partial ? '\n' : ''}(timed out after ${timeout}ms)`, isError: true });
130
+ }, Math.min(Number(timeout) || DEFAULT_TIMEOUT, MAX_TIMEOUT));
131
+
132
+ this._pending = (died) => {
133
+ if (died && !this.buf.includes(this.marker)) {
134
+ finish({ content: cap(this.buf.trim()) || '(shell session ended)', isError: true });
135
+ return;
136
+ }
137
+ const idx = this.buf.indexOf(this.marker);
138
+ if (idx === -1) return; // sentinel not here yet — wait for more data
139
+ const after = this.buf.slice(idx + this.marker.length);
140
+ const nl = after.indexOf('\n');
141
+ if (nl === -1) return; // exit-code line still arriving
142
+ const code = parseInt(after.slice(0, nl), 10);
143
+ const output = this.buf.slice(0, idx).replace(/\n+$/, '');
144
+ this.buf = after.slice(nl + 1); // leftover for the next command (usually '')
145
+ finish({ content: cap(output || `(exit ${code})`), isError: code !== 0 });
146
+ };
147
+
148
+ // Send the command, then print the sentinel + exit code on its own line.
149
+ let script;
150
+ if (IS_WIN32) {
151
+ // Push-Location/Pop-Location scope working_directory to this one
152
+ // command without moving the session's persistent cwd. $__c captures
153
+ // the real exit status before Pop-Location's own success would
154
+ // otherwise overwrite $?/$LASTEXITCODE.
155
+ const body = cmd.trim() ? cmd : 'Write-Output $null';
156
+ const exitCapture =
157
+ '$__c = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 }';
158
+ script = working_directory
159
+ ? `Push-Location -LiteralPath ${pshq(working_directory)}\ntry {\n${body}\n${exitCapture}\n} finally {\nPop-Location\n}\n`
160
+ : `${body}\n${exitCapture}\n`;
161
+ script += `Write-Output ("${this.marker}" + $__c)\n`;
162
+ } else {
163
+ // The command runs in a brace group with its stdin redirected from
164
+ // /dev/null: a group (not a subshell) keeps cd/export state
165
+ // persisting, and `</dev/null` stops the command from consuming the
166
+ // control channel. Without this, any stdin-reading command (cat,
167
+ // read, a REPL, ssh, a y/n prompt) swallows the sentinel line that
168
+ // follows it — hanging the whole session until the timeout, and
169
+ // echoing commands (cat) even splice the sentinel into their output
170
+ // and mis-resolve with garbage. working_directory runs in a subshell
171
+ // (parens, not the brace group) so it doesn't move the session's cwd.
172
+ if (working_directory) cmd = `( cd ${shq(working_directory)} && ${cmd} )`;
173
+ const body = cmd.trim() ? cmd : ':';
174
+ script = `{ ${body}\n} </dev/null\nprintf '\\n%s%d\\n' '${this.sentinel}EXIT:' "$?"\n`;
175
+ }
176
+ try {
177
+ this.child.stdin.write(script);
178
+ } catch {
179
+ this.alive = false;
180
+ oneShotShell({ command, timeout, working_directory }).then(finish);
181
+ return;
182
+ }
183
+ // Data may already be buffered (fast commands) — scan once now.
184
+ if (this._pending) this._pending();
185
+ });
186
+ }
187
+
188
+ dispose() {
189
+ this.alive = false;
190
+ if (this.child) {
191
+ try { this.child.stdin.end(); } catch { /* already gone */ }
192
+ try { this.child.kill('SIGKILL'); } catch { /* already gone */ }
193
+ this.child = null;
194
+ }
195
+ }
196
+ }
197
+
198
+ // Minimal single-quote shell-escape for a path.
199
+ function shq(s) {
200
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
201
+ }
202
+
203
+ // Minimal single-quote escape for a PowerShell -LiteralPath (double up ').
204
+ function pshq(s) {
205
+ return `'${String(s).replace(/'/g, "''")}'`;
206
+ }
207
+
208
+ module.exports = { ShellSession, oneShotShell, IS_WIN32 };