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.
@@ -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 };