aegis-desktop 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/bin/aegis.js +30 -0
- package/build/icon.png +0 -0
- package/lib/local/agents.js +102 -0
- package/lib/local/context.js +81 -0
- package/lib/local/engine.js +460 -0
- package/lib/local/ollama.js +77 -0
- package/lib/local/prompt.js +91 -0
- package/lib/local/providers.js +536 -0
- package/lib/local/shell.js +208 -0
- package/lib/local/tools.js +638 -0
- package/lib/settings.js +225 -0
- package/lib/sync/memory-queue.js +57 -0
- package/lib/sync/sessions.js +199 -0
- package/main.js +715 -0
- package/package.json +46 -0
- package/preload.js +168 -0
- package/renderer/app.js +1990 -0
- package/renderer/index.html +289 -0
- package/renderer/max-tokens.js +18 -0
- package/renderer/style.css +1454 -0
- package/vendor/aegis.js +694 -0
- package/vendor/foreign-memory.js +666 -0
|
@@ -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 };
|