aegiscode 6.1.1 → 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.
package/src/config.js ADDED
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * User config + permissions persistence (~/.aegiscode/config.json and
5
+ * permissions.json). Honors AEGISCODE_HOME so tests and side-by-side installs
6
+ * can redirect the data dir. Reads and writes are best-effort: a corrupt
7
+ * config falls back to defaults, and write failures never crash the session.
8
+ *
9
+ * Ported from aegiscodex-dev/src/config.js (ESM → CommonJS). The data dir was
10
+ * renamed ~/.aegiscodex → ~/.aegiscode and the override env var
11
+ * AEGISCODEX_HOME → AEGISCODE_HOME; `aegisDir` is the one shared helper every
12
+ * other module imports rather than recomputing the path.
13
+ */
14
+
15
+ const fs = require('node:fs');
16
+ const os = require('node:os');
17
+ const path = require('node:path');
18
+
19
+ /** Data directory: $AEGISCODE_HOME or ~/.aegiscode. */
20
+ function aegisDir() {
21
+ const override = process.env.AEGISCODE_HOME;
22
+ if (override && String(override).trim()) return path.resolve(String(override).trim());
23
+ return path.join(os.homedir(), '.aegiscode');
24
+ }
25
+
26
+ function configPath() {
27
+ return path.join(aegisDir(), 'config.json');
28
+ }
29
+
30
+ /**
31
+ * True once the user has run before — the config file is written during
32
+ * onboarding (and on the first preference change). Used to gate the trust
33
+ * check + theme picker to a genuine first run: re-showing them every launch
34
+ * wiped the screen with the theme picker and discarded the prior session.
35
+ */
36
+ function configExists() {
37
+ try { return fs.existsSync(configPath()); } catch { return false; }
38
+ }
39
+
40
+ function permissionsPath() {
41
+ return path.join(aegisDir(), 'permissions.json');
42
+ }
43
+
44
+ const DEFAULT_CONFIG = {
45
+ themeIndex: 1, // Dark mode
46
+ model: 'sonnet',
47
+ // Phase 6: the full model table (seeded from src/models.js MODELS on first
48
+ // read by pickerModels()). 'currentModelId' mirrors `model` under the
49
+ // aegiscode- name so /model add/remove/switch stay compatible both ways.
50
+ models: null,
51
+ currentModelId: null,
52
+ effort: 'high',
53
+ vim: false,
54
+ lastCwd: '',
55
+ };
56
+
57
+ /** Read the config, falling back to defaults (never throws). */
58
+ function loadConfig() {
59
+ try {
60
+ const raw = fs.readFileSync(configPath(), 'utf8');
61
+ const parsed = JSON.parse(raw);
62
+ if (parsed && typeof parsed === 'object') return { ...DEFAULT_CONFIG, ...parsed };
63
+ } catch {}
64
+ return { ...DEFAULT_CONFIG };
65
+ }
66
+
67
+ /**
68
+ * Merge a patch into the config on disk and return the merged config.
69
+ * A no-op that still returns the merged view when the data dir is unwritable.
70
+ */
71
+ function updateConfig(patch) {
72
+ const next = { ...loadConfig(), ...patch };
73
+ try {
74
+ fs.mkdirSync(aegisDir(), { recursive: true });
75
+ // Write atomically-ish: tmp file + rename so a crash can't corrupt it.
76
+ const tmp = configPath() + '.tmp';
77
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
78
+ fs.renameSync(tmp, configPath());
79
+ } catch (e) {
80
+ if (process.env.AEGIS_HIST_DEBUG) console.error('[config] write failed:', e);
81
+ }
82
+ return next;
83
+ }
84
+
85
+ // ── Permissions (~/.aegiscode/permissions.json) ─────────────────────────────
86
+
87
+ const DEFAULT_PERMISSIONS = {
88
+ defaultMode: 'ask', // 'ask' | 'allow' | 'deny'
89
+ allow: [], // ["Bash(npm run *)", "Edit(src/**)"]
90
+ deny: [],
91
+ ask: [],
92
+ };
93
+
94
+ /**
95
+ * Read permission rules, falling back to the empty default (never throws).
96
+ * `explicitAsk` is true only when the permissions file *explicitly* writes
97
+ * `"defaultMode": "ask"`. The file-free default is also 'ask', but it must
98
+ * not trigger a prompt on every call out of the box — evalPermission only
99
+ * consults explicitAsk, so the distinction matters.
100
+ */
101
+ function loadPermissions() {
102
+ try {
103
+ const raw = fs.readFileSync(permissionsPath(), 'utf8');
104
+ const parsed = JSON.parse(raw);
105
+ if (parsed && typeof parsed === 'object') {
106
+ return {
107
+ ...DEFAULT_PERMISSIONS,
108
+ allow: Array.isArray(parsed.allow) ? parsed.allow : [],
109
+ deny: Array.isArray(parsed.deny) ? parsed.deny : [],
110
+ ask: Array.isArray(parsed.ask) ? parsed.ask : [],
111
+ defaultMode: parsed.defaultMode || DEFAULT_PERMISSIONS.defaultMode,
112
+ explicitAsk: parsed.defaultMode === 'ask',
113
+ };
114
+ }
115
+ } catch {}
116
+ return { ...DEFAULT_PERMISSIONS, allow: [], deny: [], ask: [], explicitAsk: false };
117
+ }
118
+
119
+ /** Persist permission rules. Returns the saved rules (or the input on failure). */
120
+ function savePermissions(rules) {
121
+ try {
122
+ fs.mkdirSync(aegisDir(), { recursive: true });
123
+ const tmp = permissionsPath() + '.tmp';
124
+ fs.writeFileSync(tmp, JSON.stringify(rules, null, 2) + '\n');
125
+ fs.renameSync(tmp, permissionsPath());
126
+ } catch (e) {
127
+ if (process.env.AEGIS_HIST_DEBUG) console.error('[permissions] write failed:', e);
128
+ }
129
+ return rules;
130
+ }
131
+
132
+ /**
133
+ * Add a rule to a permission list if not already present.
134
+ * Returns { added, rules } — added is false when the rule already exists.
135
+ */
136
+ function addPermissionRule(list, pattern, rules) {
137
+ const key = list === 'allow' || list === 'deny' || list === 'ask' ? list : 'allow';
138
+ const patterns = rules[key];
139
+ if (patterns.includes(pattern)) return { added: false, rules };
140
+ return { added: true, rules: { ...rules, [key]: [...patterns, pattern] } };
141
+ }
142
+
143
+ /** Remove a rule from a permission list. Returns { removed, rules }. */
144
+ function removePermissionRule(list, pattern, rules) {
145
+ const key = list === 'allow' || list === 'deny' || list === 'ask' ? list : 'allow';
146
+ const patterns = rules[key].filter((p) => p !== pattern);
147
+ return { removed: patterns.length !== rules[key].length, rules: { ...rules, [key]: patterns } };
148
+ }
149
+
150
+ module.exports = {
151
+ aegisDir,
152
+ configPath,
153
+ configExists,
154
+ permissionsPath,
155
+ DEFAULT_CONFIG,
156
+ loadConfig,
157
+ updateConfig,
158
+ DEFAULT_PERMISSIONS,
159
+ loadPermissions,
160
+ savePermissions,
161
+ addPermissionRule,
162
+ removePermissionRule,
163
+ };
package/src/deps.js CHANGED
@@ -51,15 +51,28 @@ const toolsPath = resolveShared(path.join('mcp', 'tools.js'));
51
51
  // disagree about what a call consumed (test/cli-tools.test.mjs asserts the
52
52
  // function identity).
53
53
  const usagePath = resolveShared(path.join('desktop', 'renderer', 'usage.js'));
54
+ // The desktop's agent-loop engine — persistent-shell exec, editFile/grep,
55
+ // Task subagents — reused verbatim (src/engine.js scopes it to the 'aegis'
56
+ // class) so the CLI's chat loop is the same tool loop as the GUI's, not a
57
+ // second implementation that can drift out of step with it.
58
+ const enginePath = resolveShared(path.join('desktop', 'lib', 'local', 'engine.js'));
59
+ // Subagent role presets (/agents lists these; the model's task tool delegates
60
+ // to them by name) — lives beside engine.js, staged into the same vendor dir.
61
+ const agentsPath = resolveShared(path.join('desktop', 'lib', 'local', 'agents.js'));
54
62
 
55
63
  const { createClient } = require(clientPath);
56
64
  const { createTools } = require(toolsPath);
57
65
  const { usageTokens } = require(usagePath);
66
+ const { createLocalEngine } = require(enginePath);
67
+ const { agentRoles, agentRoleLabel } = require(agentsPath);
58
68
 
59
69
  module.exports = {
60
70
  createClient,
61
71
  createTools,
62
72
  usageTokens,
63
- paths: { client: clientPath, tools: toolsPath, usage: usagePath },
73
+ createLocalEngine,
74
+ agentRoles,
75
+ agentRoleLabel,
76
+ paths: { client: clientPath, tools: toolsPath, usage: usagePath, engine: enginePath, agents: agentsPath },
64
77
  roots,
65
78
  };
package/src/devrun.js ADDED
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * /run support: sniff the project's dev command and run it in a captured
5
+ * sub-shell. Output streams to the transcript as it happens; the session loop
6
+ * can stop the process (Esc) via the returned job handle.
7
+ *
8
+ * Ported from aegiscodex-dev/src/devrun.js (ESM → CommonJS).
9
+ */
10
+
11
+ const { spawn } = require('node:child_process');
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ const MAX_STREAM_LINES = 40;
16
+
17
+ /** Detect the most likely dev command for a project, or null. */
18
+ function detectDevCommand(cwd = process.cwd()) {
19
+ const pkg = path.join(cwd, 'package.json');
20
+ if (fs.existsSync(pkg)) {
21
+ try {
22
+ const scripts = JSON.parse(fs.readFileSync(pkg, 'utf8')).scripts || {};
23
+ for (const name of ['dev', 'start', 'watch', 'serve']) {
24
+ if (typeof scripts[name] === 'string' && scripts[name].trim()) return `npm run ${name}`;
25
+ }
26
+ } catch {}
27
+ return 'npm start';
28
+ }
29
+ if (fs.existsSync(path.join(cwd, 'go.mod'))) return 'go run .';
30
+ if (fs.existsSync(path.join(cwd, 'Cargo.toml'))) return 'cargo run';
31
+ if (fs.existsSync(path.join(cwd, 'Makefile'))) return 'make';
32
+ if (fs.existsSync(path.join(cwd, 'pyproject.toml'))) return 'python -m <module>'; // honest: user picks
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Run a shell command, streaming stdout/stderr lines to onLine. Returns a job:
38
+ * { stop(), done: Promise<{ code, stopped }> }
39
+ * The child is killed (SIGTERM) when stop() is called.
40
+ */
41
+ function runDevServer(command, { onLine, signal, cwd } = {}) {
42
+ // detached + kill(-pid) so the whole process group dies (shell + child),
43
+ // otherwise a stopped dev server leaks its grandchildren.
44
+ const child = spawn(command, { shell: true, cwd: cwd || process.cwd(), env: process.env, detached: true });
45
+ let buffer = '';
46
+ let stopped = false;
47
+ let code = null;
48
+ let nLines = 0;
49
+ // Settle-once guard shared by every completion path. The job's `done` MUST
50
+ // resolve: the session loop's streamJob guard — which swallows every key
51
+ // while a /run job is live — is only cleared by job.done settling. If
52
+ // 'close' never fires (a detached grandchild escaped the group kill and
53
+ // still holds the stdout pipe), 'exit' + the stop fallback cover it.
54
+ let doneResolved = false;
55
+ let resolveDone = null;
56
+
57
+ const deliver = (chunk) => {
58
+ buffer += chunk;
59
+ let idx;
60
+ while ((idx = buffer.indexOf('\n')) >= 0) {
61
+ const line = buffer.slice(0, idx).replace(/\r$/, '').trimEnd();
62
+ buffer = buffer.slice(idx + 1);
63
+ if (line && nLines++ < MAX_STREAM_LINES && onLine) onLine(line);
64
+ }
65
+ };
66
+ child.stdout && child.stdout.on('data', deliver);
67
+ child.stderr && child.stderr.on('data', deliver);
68
+ if (buffer.trim() && onLine) onLine(buffer.trim()); // trailing partial line
69
+
70
+ const killGroup = () => {
71
+ try { process.kill(-child.pid, 'SIGTERM'); } catch {}
72
+ };
73
+ const onAbort = () => { stopped = true; killGroup(); };
74
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
75
+
76
+ const settle = (patch) => {
77
+ if (doneResolved) return;
78
+ doneResolved = true;
79
+ if (signal) signal.removeEventListener('abort', onAbort);
80
+ resolveDone(patch);
81
+ };
82
+
83
+ const done = new Promise((resolve) => { resolveDone = resolve; });
84
+ child.on('error', () => settle({ code: code ?? 1, stopped }));
85
+ child.on('close', (c) => {
86
+ code = c;
87
+ settle({ code, stopped });
88
+ });
89
+ // 'close' waits for the stdio pipes; a grandchild that escaped the group
90
+ // keeps the write end open and 'close' never fires. Settle shortly after
91
+ // the shell itself exits, whatever the pipes do.
92
+ child.on('exit', () => setTimeout(() => settle({ code: code ?? 0, stopped }), 1000));
93
+
94
+ return {
95
+ stop: () => {
96
+ stopped = true;
97
+ killGroup();
98
+ // Pipe-holding survivors can still delay 'close' — force the job to
99
+ // settle so the session loop's streamJob guard can't swallow keys.
100
+ setTimeout(() => settle({ code: code ?? null, stopped: true }), 5000);
101
+ },
102
+ done,
103
+ };
104
+ }
105
+
106
+ module.exports = {
107
+ MAX_STREAM_LINES,
108
+ detectDevCommand,
109
+ runDevServer,
110
+ };
package/src/engine.js ADDED
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wires the CLI into the same agent-loop engine the desktop app already
5
+ * ships (desktop/lib/local/engine.js, commit e4b0a9a): persistent-shell exec,
6
+ * readFile/writeFile/editFile/listDir/glob/grep, and Task subagents. This
7
+ * host only ever selects the 'aegis' class (it authenticates with an AEGIS
8
+ * key, not a local Ollama/BYOK endpoint), so the other three classes'
9
+ * dependencies (settings store, Ollama probe, custom-endpoint providers) are
10
+ * unreachable and stay stubs that throw if the engine ever calls them —
11
+ * proof that a code path meant only for those classes never silently runs
12
+ * here instead.
13
+ */
14
+
15
+ const os = require('node:os');
16
+ const { createLocalEngine } = require('./deps.js');
17
+ const VERSION = require('../package.json').version;
18
+
19
+ function unsupported(label) {
20
+ return async () => {
21
+ throw new Error(`aegiscode: ${label} is not available — this client only runs the aegis class`);
22
+ };
23
+ }
24
+
25
+ /**
26
+ * @param {object} client A client from client/aegis.js (createClient()).
27
+ * @param {() => boolean} getConfirmMode Whether mutating tools (exec,
28
+ * writeFile, editFile) require approval before running. Read per call, so
29
+ * toggling it (/permissions, /yolo) takes effect on the next tool round.
30
+ */
31
+ function createEngine({ client, getConfirmMode }) {
32
+ const local = createLocalEngine({
33
+ aegis: client,
34
+ settings: { get: () => ({}), rawKey: unsupported('BYOK/custom endpoints') },
35
+ ollama: {
36
+ probe: async () => ({ running: false }),
37
+ listTags: async () => [],
38
+ chat: unsupported('Ollama'),
39
+ },
40
+ providers: {
41
+ anthropicMessages: unsupported('a custom Anthropic-compatible endpoint'),
42
+ openaiCompatible: unsupported('a custom OpenAI-compatible endpoint'),
43
+ },
44
+ env: {
45
+ platform: process.platform,
46
+ arch: process.arch,
47
+ homedir: os.homedir(),
48
+ cwd: process.cwd(),
49
+ appVersion: `aegiscode v${VERSION}`,
50
+ },
51
+ getConfirmMode,
52
+ });
53
+
54
+ return {
55
+ chat: (payload, onDelta) => local.chat({ ...payload, class: 'aegis' }, onDelta),
56
+ cancel: local.cancel,
57
+ respondApproval: local.respondApproval,
58
+ clearSessionApprovals: local.clearSessionApprovals,
59
+ };
60
+ }
61
+
62
+ module.exports = { createEngine };
package/src/events.js ADDED
@@ -0,0 +1,278 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The global key-event pump.
5
+ *
6
+ * A single stdin 'data' event may carry many keystrokes (pipes batch writes),
7
+ * so each chunk is split into characters and fed through a small state machine
8
+ * that also handles:
9
+ *
10
+ * · a lone Escape, resolved by a short timeout so it is distinguishable from
11
+ * the start of an escape sequence;
12
+ * · bracketed paste (DECSET 2004) and, for terminals that ignore it, a
13
+ * length/newline heuristic so a multi-line paste does not submit the prompt
14
+ * line-by-line as it arrives;
15
+ * · SGR mouse wheel events (DECSET 1000+1006), decoded to `{name:'wheel'}`
16
+ * instead of being mistaken for Escape (which would abort a working turn).
17
+ *
18
+ * Ported from `aegiscodex-dev/src/events.js`. It is a module-level singleton —
19
+ * one stdin, one queue — which is what lets the session loop, a command
20
+ * handler's `askInput()`, and the mid-turn Esc drain all read keys through the
21
+ * same nextKey() without fighting over the `data` listener.
22
+ */
23
+
24
+ const { decodeEscSequence, decodePlain, KEY } = require('./keys.js');
25
+
26
+ const queue = [];
27
+ let waiting = [];
28
+ let buffer = '';
29
+ let seq = '';
30
+ let escTimer = null;
31
+
32
+ // A paste-like chunk accumulates instead of being fed char-by-char; a short
33
+ // idle window merges chunks that a pty fragmented across 'data' events.
34
+ const PASTE_LARGE = 100;
35
+ const PASTE_DEBOUNCE_MS = 30;
36
+ let pasteChunks = [];
37
+ let pasteTimer = null;
38
+
39
+ const looksLikePaste = (s) => {
40
+ if (s.length > PASTE_LARGE) return true;
41
+ // A trailing \n or \r\n is the ordinary end of a piped batch, not evidence
42
+ // of a paste; strip one terminator before looking for an embedded newline.
43
+ return /[\n\r]/.test(s.replace(/\r\n$|[\r\n]$/, ''));
44
+ };
45
+
46
+ let attachedStdin = null;
47
+ let onData = null;
48
+ let suspended = false;
49
+
50
+ function isKeyStreamSuspended() {
51
+ return suspended;
52
+ }
53
+
54
+ function attachKeyStream(stdin) {
55
+ attachedStdin = stdin;
56
+ onData = (chunk) => pushChars(String(chunk));
57
+ // A non-TTY stdin has no setRawMode at all; guard it so callers degrade
58
+ // instead of throwing "stdin.setRawMode is not a function".
59
+ if (typeof stdin.setRawMode === 'function') stdin.setRawMode(true);
60
+ stdin.resume();
61
+ stdin.setEncoding('utf8');
62
+ stdin.on('data', onData);
63
+ }
64
+
65
+ /** Hand the terminal to a child process: detach the pump and drop half-parsed
66
+ * state so nothing leaks across the suspension. Pair with resumeKeyStream(). */
67
+ function suspendKeyStream() {
68
+ if (!attachedStdin || suspended) return;
69
+ suspended = true;
70
+ attachedStdin.removeListener('data', onData);
71
+ clearTimeout(escTimer);
72
+ seq = '';
73
+ buffer = '';
74
+ clearTimeout(pasteTimer);
75
+ pasteTimer = null;
76
+ pasteChunks = [];
77
+ drainQueue();
78
+ try {
79
+ attachedStdin.setRawMode(false);
80
+ } catch {
81
+ /* not a TTY */
82
+ }
83
+ attachedStdin.pause();
84
+ }
85
+
86
+ function resumeKeyStream() {
87
+ if (!attachedStdin || !suspended) return;
88
+ suspended = false;
89
+ try {
90
+ attachedStdin.setRawMode(true);
91
+ } catch {
92
+ /* not a TTY */
93
+ }
94
+ attachedStdin.resume();
95
+ attachedStdin.setEncoding('utf8');
96
+ attachedStdin.on('data', onData);
97
+ }
98
+
99
+ function flushPaste() {
100
+ const raw = pasteChunks.join('');
101
+ pasteChunks = [];
102
+ pasteTimer = null;
103
+ // Strip any bracketed-paste framing that reached the buffer via a chunk
104
+ // split mid-frame, so literal [200~…[201~ can never leak into the editor.
105
+ const text = raw
106
+ .replace(/\x1b\[200~/g, '')
107
+ .replace(/\x1b\[201~/g, '')
108
+ .replace(/\r\n/g, '\n')
109
+ .replace(/\r/g, '\n')
110
+ .trimEnd();
111
+ if (text) dispatch({ name: 'paste', text });
112
+ }
113
+
114
+ function pushChars(s) {
115
+ const framed = s.match(/^\x1b\[200~(.*)\x1b\[201~$/s);
116
+ if (framed) {
117
+ pasteChunks.push(framed[1]);
118
+ clearTimeout(pasteTimer);
119
+ pasteTimer = setTimeout(flushPaste, PASTE_DEBOUNCE_MS);
120
+ return;
121
+ }
122
+ if (s.startsWith('\x1b[200~')) {
123
+ pasteChunks.push(s.slice('\x1b[200~'.length));
124
+ clearTimeout(pasteTimer);
125
+ pasteTimer = setTimeout(flushPaste, PASTE_DEBOUNCE_MS);
126
+ return;
127
+ }
128
+ if (pasteChunks.length || looksLikePaste(s)) {
129
+ pasteChunks.push(s);
130
+ clearTimeout(pasteTimer);
131
+ pasteTimer = setTimeout(flushPaste, PASTE_DEBOUNCE_MS);
132
+ return;
133
+ }
134
+ buffer += s;
135
+ while (buffer.length) {
136
+ const cp = buffer.codePointAt(0);
137
+ const c = String.fromCodePoint(cp);
138
+ const units = c.length;
139
+ if (units > buffer.length) break; // incomplete multi-byte char
140
+ buffer = buffer.slice(units);
141
+ feed(c);
142
+ }
143
+ }
144
+
145
+ function feed(c) {
146
+ if (c === '\x1b') {
147
+ clearTimeout(escTimer);
148
+ escTimer = setTimeout(() => {
149
+ // Nothing followed the ESC within the window → it was a bare Escape.
150
+ if (seq === '\x1b') dispatch({ name: KEY.ESC });
151
+ seq = '';
152
+ }, 60);
153
+ seq = '\x1b';
154
+ return;
155
+ }
156
+ if (seq === '\x1b') {
157
+ // First char after ESC: a CSI (ESC[), an SS3 (ESCO), or an Alt chord.
158
+ if (c === '[' || c === 'O') {
159
+ seq += c;
160
+ return;
161
+ }
162
+ clearTimeout(escTimer);
163
+ seq = '';
164
+ dispatch({ name: 'alt', ch: c });
165
+ return;
166
+ }
167
+ if (seq.startsWith('\x1b[') || seq.startsWith('\x1bO')) {
168
+ seq += c;
169
+ if (/[A-Za-z~]/.test(c)) {
170
+ clearTimeout(escTimer);
171
+ const mouseDir = parseSgrMouse(seq);
172
+ if (mouseDir) {
173
+ seq = '';
174
+ dispatch({ name: 'wheel', dir: mouseDir });
175
+ return;
176
+ }
177
+ const k = decodeEscSequence(seq);
178
+ seq = '';
179
+ if (k) dispatch(k);
180
+ }
181
+ return;
182
+ }
183
+ const key = decodePlain(c);
184
+ if (key) dispatch(key);
185
+ }
186
+
187
+ /**
188
+ * SGR mouse button code → wheel direction. 64 = up, 65 = down; modifier bits
189
+ * are masked off so Shift+wheel still scrolls. Anything else (clicks, drags)
190
+ * returns null, and the caller's CSI decoder ignores it — so stray mouse bytes
191
+ * can neither type into the editor nor abort a turn.
192
+ */
193
+ function parseSgrMouse(seq) {
194
+ if (!seq.startsWith('\x1b[<')) return null;
195
+ const body = seq.slice(3).replace(/[Mm]$/, '');
196
+ const code = parseInt(body.split(';')[0], 10);
197
+ if (!Number.isFinite(code)) return null;
198
+ const base = code & ~0b111100;
199
+ if (base === 64) return 'up';
200
+ if (base === 65) return 'down';
201
+ return null;
202
+ }
203
+
204
+ function dispatch(key) {
205
+ if (waiting.length) {
206
+ const w = waiting.shift();
207
+ w(key);
208
+ } else {
209
+ queue.push(key);
210
+ }
211
+ }
212
+
213
+ /** The next key, waiting as long as it takes. */
214
+ function nextKey() {
215
+ if (queue.length) return Promise.resolve(queue.shift());
216
+ return new Promise((resolve) => waiting.push(resolve));
217
+ }
218
+
219
+ /**
220
+ * nextKey with an upper bound: resolves null when no key arrives within `ms`.
221
+ * The session loop polls with this while a turn runs — it must never wedge on a
222
+ * key that never comes once the turn settles. The timeout path removes the
223
+ * resolver from the waiting stack, so a late keystroke routes to the queue and
224
+ * the loop's next nextKey() sees it.
225
+ */
226
+ function nextKeyTimeout(ms) {
227
+ if (queue.length) return Promise.resolve(queue.shift());
228
+ return new Promise((resolve) => {
229
+ const timer = setTimeout(() => {
230
+ const i = waiting.indexOf(wrapper);
231
+ if (i !== -1) waiting.splice(i, 1);
232
+ resolve(null);
233
+ }, ms);
234
+ const wrapper = (key) => {
235
+ clearTimeout(timer);
236
+ resolve(key);
237
+ };
238
+ waiting.push(wrapper);
239
+ });
240
+ }
241
+
242
+ /** Put keys back at the FRONT of the queue (typed-ahead replay after a turn). */
243
+ function requeueKeys(keys) {
244
+ if (keys && keys.length) queue.unshift(...keys);
245
+ }
246
+
247
+ function drainQueue() {
248
+ queue.length = 0;
249
+ }
250
+
251
+ /** Test seam: forget attached stdin, timers and queued keys. */
252
+ function resetKeyStream() {
253
+ clearTimeout(escTimer);
254
+ clearTimeout(pasteTimer);
255
+ escTimer = null;
256
+ pasteTimer = null;
257
+ seq = '';
258
+ buffer = '';
259
+ pasteChunks = [];
260
+ drainQueue();
261
+ waiting = [];
262
+ attachedStdin = null;
263
+ onData = null;
264
+ suspended = false;
265
+ }
266
+
267
+ module.exports = {
268
+ KEY,
269
+ attachKeyStream,
270
+ suspendKeyStream,
271
+ resumeKeyStream,
272
+ isKeyStreamSuspended,
273
+ nextKey,
274
+ nextKeyTimeout,
275
+ requeueKeys,
276
+ drainQueue,
277
+ resetKeyStream,
278
+ };