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/README.md +121 -78
- package/bin/aegiscode.js +9 -1
- package/package.json +3 -3
- package/scripts/predist.mjs +11 -1
- package/src/agents.js +136 -0
- package/src/app.js +520 -162
- package/src/chatflow.js +1475 -0
- package/src/checkpoint.js +85 -0
- package/src/clipboard.js +62 -0
- package/src/commands.js +1234 -150
- package/src/config.js +163 -0
- package/src/deps.js +14 -1
- package/src/devrun.js +110 -0
- package/src/engine.js +62 -0
- package/src/events.js +278 -0
- package/src/export.js +64 -0
- package/src/history.js +201 -0
- package/src/init.js +162 -0
- package/src/input.js +136 -0
- package/src/keys.js +141 -0
- package/src/panels.js +1171 -0
- package/src/permissions.js +102 -0
- package/src/render.js +33 -1
- package/src/summarize.js +90 -0
- package/src/system.js +37 -0
- package/src/tokens.js +166 -0
- package/vendor/desktop/lib/local/agents.js +102 -0
- package/vendor/desktop/lib/local/engine.js +972 -0
- package/vendor/desktop/lib/local/prompt.js +91 -0
- package/vendor/desktop/lib/local/shell.js +208 -0
- package/vendor/desktop/lib/local/tools.js +882 -0
package/src/app.js
CHANGED
|
@@ -9,15 +9,29 @@
|
|
|
9
9
|
* real streaming and real command dispatch without a TTY and without a child
|
|
10
10
|
* process. Bin entry = argument parsing and process lifecycle; everything else
|
|
11
11
|
* lives here.
|
|
12
|
+
*
|
|
13
|
+
* Commands come from `./commands.js`. Each entry is either handler-backed
|
|
14
|
+
* (`cmd.handler(c, args)`, the reference aegiscodex-dev contract), tool-backed
|
|
15
|
+
* (`cmd.tool` + `cmd.build`), a generic escape hatch (`cmd.generic`, `/tool`),
|
|
16
|
+
* or `unavailable` (a Claude Code auth-loop command this client cannot honour).
|
|
17
|
+
* Handlers run against the FROZEN command context built below; the tool path
|
|
18
|
+
* and the `/tool` escape hatch are unchanged from the previous revision.
|
|
12
19
|
*/
|
|
13
20
|
|
|
14
21
|
const readline = require('node:readline');
|
|
22
|
+
const os = require('node:os');
|
|
23
|
+
const { randomUUID } = require('node:crypto');
|
|
15
24
|
const { createTools, createClient, usageTokens } = require('./deps.js');
|
|
16
|
-
const {
|
|
17
|
-
const {
|
|
18
|
-
const {
|
|
25
|
+
const { createEngine } = require('./engine.js');
|
|
26
|
+
const { GLYPH, VERBS, themeOf, RESET } = require('./theme.js');
|
|
27
|
+
const { LiveRegion, termWidth, w } = require('./screen.js');
|
|
28
|
+
const { parseLine, COMMANDS, visibleCommands } = require('./commands.js');
|
|
29
|
+
const { updateConfig, loadPermissions } = require('./config.js');
|
|
30
|
+
const { readSessionTranscript } = require('./history.js');
|
|
31
|
+
const chatflow = require('./chatflow.js');
|
|
32
|
+
const overlays = require('./overlays.js');
|
|
19
33
|
const render = require('./render.js');
|
|
20
|
-
const { fmtTokens, fmtEur, maskKey
|
|
34
|
+
const { fmtTokens, fmtEur, maskKey } = require('./format.js');
|
|
21
35
|
|
|
22
36
|
const VERSION = require('../package.json').version;
|
|
23
37
|
|
|
@@ -36,9 +50,57 @@ function createApp(options = {}) {
|
|
|
36
50
|
const client = options.client || createClient();
|
|
37
51
|
const { TOOLS, toolList } = options.tools || createTools(client);
|
|
38
52
|
|
|
39
|
-
|
|
53
|
+
// Tool-approval gate (exec/writeFile/editFile confirm before running).
|
|
54
|
+
// Read per call, so /permissions and /yolo take effect on the very next tool
|
|
55
|
+
// round rather than needing a restart. The mode is read LIVE from the
|
|
56
|
+
// permission store rather than from a boolean captured at construction:
|
|
57
|
+
// `/yolo` and `/confirm` write the store, so a captured flag made both
|
|
58
|
+
// commands cosmetic — the panel changed while the engine kept asking.
|
|
59
|
+
let confirmMode = options.confirmMode !== false;
|
|
60
|
+
const engine =
|
|
61
|
+
options.engine ||
|
|
62
|
+
createEngine({
|
|
63
|
+
client,
|
|
64
|
+
getConfirmMode: () => {
|
|
65
|
+
if (options.confirmMode !== undefined) return confirmMode;
|
|
66
|
+
try {
|
|
67
|
+
return loadPermissions().defaultMode !== 'allow';
|
|
68
|
+
} catch {
|
|
69
|
+
return confirmMode;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
// Set by runInteractive: a question/answer channel for the engine's
|
|
74
|
+
// tool-approval requests. Left null in one-shot (-p) runs, where there is
|
|
75
|
+
// no one to ask — see the approval branch in ask() below.
|
|
76
|
+
let approvalPrompter = null;
|
|
77
|
+
|
|
40
78
|
const width = () => (options.width ? options.width() : termWidth());
|
|
41
79
|
|
|
80
|
+
// ── live session/command state ─────────────────────────────────────────────
|
|
81
|
+
// `commandCtx` is the FROZEN `c.ctx` a handler receives: the mutable slice of
|
|
82
|
+
// session state a command may set (model, effort, thinking, theme, vim, stream,
|
|
83
|
+
// cwd, lastRecap). The renderers read their colours from it via ctx().
|
|
84
|
+
const commandCtx = {
|
|
85
|
+
light: opts.light,
|
|
86
|
+
model: opts.model,
|
|
87
|
+
effort: 'high',
|
|
88
|
+
thinking: false,
|
|
89
|
+
themeIndex: opts.light ? 0 : 1,
|
|
90
|
+
vim: false,
|
|
91
|
+
stream: opts.stream,
|
|
92
|
+
cwd: process.cwd(),
|
|
93
|
+
lastRecap: null,
|
|
94
|
+
sessionId: randomUUID(),
|
|
95
|
+
};
|
|
96
|
+
// The transcript rows a handler reads/pushes (user/assistant/note/panel/…).
|
|
97
|
+
// It is also the source of the engine's conversation history, so /clear and
|
|
98
|
+
// /new genuinely reset context.
|
|
99
|
+
const transcript = [];
|
|
100
|
+
let wantExit = false;
|
|
101
|
+
|
|
102
|
+
const ctx = () => ({ light: commandCtx.light });
|
|
103
|
+
|
|
42
104
|
const session = {
|
|
43
105
|
turns: 0,
|
|
44
106
|
calls: 0,
|
|
@@ -51,7 +113,7 @@ function createApp(options = {}) {
|
|
|
51
113
|
startedAt: Date.now(),
|
|
52
114
|
};
|
|
53
115
|
|
|
54
|
-
let
|
|
116
|
+
let activeSessionId = null;
|
|
55
117
|
let closed = false;
|
|
56
118
|
|
|
57
119
|
// --- helpers --------------------------------------------------------------
|
|
@@ -86,10 +148,10 @@ function createApp(options = {}) {
|
|
|
86
148
|
return render.renderBanner(ctx(), {
|
|
87
149
|
width: width(),
|
|
88
150
|
version: VERSION,
|
|
89
|
-
model:
|
|
151
|
+
model: commandCtx.model || 'server default',
|
|
90
152
|
base: client.apiBase,
|
|
91
153
|
key: maskKey(client.apiKey),
|
|
92
|
-
stream:
|
|
154
|
+
stream: commandCtx.stream,
|
|
93
155
|
});
|
|
94
156
|
}
|
|
95
157
|
|
|
@@ -106,18 +168,27 @@ function createApp(options = {}) {
|
|
|
106
168
|
// --- the ask path ---------------------------------------------------------
|
|
107
169
|
|
|
108
170
|
/**
|
|
109
|
-
* One
|
|
171
|
+
* One turn through the agent-loop engine (persistent-shell exec,
|
|
172
|
+
* readFile/writeFile/editFile/listDir/glob/grep, Task subagents), streamed
|
|
173
|
+
* into the live region. `history` is every prior turn's user/assistant pair
|
|
174
|
+
* — never that turn's own tool-call scratchpad, which the engine keeps
|
|
175
|
+
* internally and never returns (see engine.js's `chat()`).
|
|
110
176
|
* @returns {Promise<{text:string, usage:object|null, model:string|null, ms:number, interrupted:boolean}>}
|
|
111
177
|
*/
|
|
112
|
-
async function ask(prompt) {
|
|
178
|
+
async function ask(prompt, { history = [], presenter = null, signal = null } = {}) {
|
|
113
179
|
const started = Date.now();
|
|
114
|
-
|
|
180
|
+
// A presenter takes over ALL presentation (the chatflow's frame paints the
|
|
181
|
+
// streaming answer into a transcript row), so the linear/inline live region
|
|
182
|
+
// is only built when there is none.
|
|
183
|
+
const live =
|
|
184
|
+
!presenter && opts.interactive && opts.stream && out.isTTY ? new LiveRegion(out) : null;
|
|
115
185
|
let tick = 0;
|
|
116
186
|
let chars = 0;
|
|
117
187
|
let partial = '';
|
|
118
188
|
let reasoning = 0;
|
|
119
189
|
let verb = VERBS[Math.floor(Math.random() * VERBS.length)];
|
|
120
190
|
let sawReasoning = false;
|
|
191
|
+
let usedTools = false;
|
|
121
192
|
|
|
122
193
|
const paint = () => {
|
|
123
194
|
if (!live) return;
|
|
@@ -135,56 +206,121 @@ function createApp(options = {}) {
|
|
|
135
206
|
if (timer && timer.unref) timer.unref();
|
|
136
207
|
paint();
|
|
137
208
|
|
|
138
|
-
|
|
209
|
+
// Presentation hooks. With no presenter these reproduce the linear
|
|
210
|
+
// behaviour exactly (live-region spinner, tool rows written to scrollback).
|
|
211
|
+
const p = presenter || {};
|
|
212
|
+
const present = {
|
|
213
|
+
text: (d) => {
|
|
214
|
+
if (p.text) return p.text(d);
|
|
215
|
+
if (live && tick % 2 === 0) paint();
|
|
216
|
+
return undefined;
|
|
217
|
+
},
|
|
218
|
+
reasoning: (r) => {
|
|
219
|
+
if (p.reasoning) return p.reasoning(r);
|
|
220
|
+
return undefined;
|
|
221
|
+
},
|
|
222
|
+
tool: (tool) => {
|
|
223
|
+
if (p.tool) return p.tool(tool);
|
|
224
|
+
if (live) live.clear();
|
|
225
|
+
emit(
|
|
226
|
+
render.renderTurn(
|
|
227
|
+
ctx(),
|
|
228
|
+
{ role: 'tool', label: tool.name, args: tool.args, ok: tool.ok },
|
|
229
|
+
width()
|
|
230
|
+
)
|
|
231
|
+
);
|
|
232
|
+
paint();
|
|
233
|
+
return undefined;
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const sessionId = randomUUID();
|
|
238
|
+
activeSessionId = sessionId;
|
|
139
239
|
let interrupted = false;
|
|
140
|
-
|
|
240
|
+
// One cancel path for both callers: a SIGINT from the process, and the
|
|
241
|
+
// chatflow's Esc, which aborts the controller it handed in. Without this
|
|
242
|
+
// second source the full-screen loop's Esc had nothing listening to it —
|
|
243
|
+
// the turn kept running and billing while the UI said "stopped".
|
|
244
|
+
const cancel = () => {
|
|
141
245
|
interrupted = true;
|
|
142
|
-
|
|
246
|
+
engine.cancel(sessionId);
|
|
143
247
|
};
|
|
248
|
+
const onSigint = () => cancel();
|
|
144
249
|
process.once('SIGINT', onSigint);
|
|
250
|
+
if (signal) {
|
|
251
|
+
if (signal.aborted) cancel();
|
|
252
|
+
else signal.addEventListener('abort', cancel, { once: true });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// A denial with no one to ask it of: -p and any other run with no
|
|
256
|
+
// approvalPrompter set. Fails safe (deny) rather than hanging the tool
|
|
257
|
+
// round forever waiting for an answer nobody can give.
|
|
258
|
+
const denyNoPrompter = (info) => {
|
|
259
|
+
err.write(
|
|
260
|
+
`aegiscode: ${info.tool} needs confirmation but this run has no prompt for it — denied ` +
|
|
261
|
+
'(pass --yolo to auto-approve mutating tools).\n'
|
|
262
|
+
);
|
|
263
|
+
return Promise.resolve('deny');
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const onDelta = (chunk) => {
|
|
267
|
+
if (!chunk) return;
|
|
268
|
+
if (chunk.reasoning) {
|
|
269
|
+
reasoning += w(chunk.reasoning);
|
|
270
|
+
// The pool's worker findings arrive on the reasoning channel before
|
|
271
|
+
// the answer; say so rather than looking stalled.
|
|
272
|
+
if (!sawReasoning) {
|
|
273
|
+
sawReasoning = true;
|
|
274
|
+
verb = 'Reasoning';
|
|
275
|
+
paint();
|
|
276
|
+
}
|
|
277
|
+
present.reasoning(chunk.reasoning);
|
|
278
|
+
}
|
|
279
|
+
if (chunk.delta) {
|
|
280
|
+
chars += w(chunk.delta);
|
|
281
|
+
partial += chunk.delta;
|
|
282
|
+
present.text(chunk.delta);
|
|
283
|
+
}
|
|
284
|
+
if (chunk.tool) {
|
|
285
|
+
usedTools = true;
|
|
286
|
+
present.tool(chunk.tool);
|
|
287
|
+
}
|
|
288
|
+
if (chunk.approval) {
|
|
289
|
+
const info = chunk.approval;
|
|
290
|
+
if (live) live.clear();
|
|
291
|
+
const decide = p.approval || approvalPrompter || denyNoPrompter;
|
|
292
|
+
Promise.resolve(decide(info))
|
|
293
|
+
.catch(() => 'deny')
|
|
294
|
+
.then((decision) => engine.respondApproval(info.id, decision));
|
|
295
|
+
}
|
|
296
|
+
};
|
|
145
297
|
|
|
146
298
|
try {
|
|
147
|
-
const res = await
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
onReasoning: (t) => {
|
|
159
|
-
reasoning += w(t);
|
|
160
|
-
// The pool's worker findings arrive on the reasoning channel before
|
|
161
|
-
// the answer; say so rather than looking stalled.
|
|
162
|
-
if (!sawReasoning) {
|
|
163
|
-
sawReasoning = true;
|
|
164
|
-
verb = 'Reasoning';
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
onStream: ({ delta, reasoning: r }) => {
|
|
168
|
-
if (r) reasoning += w(r);
|
|
169
|
-
if (delta) {
|
|
170
|
-
chars += w(delta);
|
|
171
|
-
partial += delta;
|
|
172
|
-
// Re-paint on every delta but coalesce to ~30fps through the frame
|
|
173
|
-
// counter — a fast provider otherwise spends the CPU on escapes.
|
|
174
|
-
if (live && tick % 2 === 0) paint();
|
|
175
|
-
}
|
|
299
|
+
const res = await engine.chat(
|
|
300
|
+
{
|
|
301
|
+
prompt,
|
|
302
|
+
messages: history,
|
|
303
|
+
model: commandCtx.model || undefined,
|
|
304
|
+
system: opts.system,
|
|
305
|
+
maxTokens: opts.maxTokens,
|
|
306
|
+
// false asks the engine for the buffered (non-stream) wire form, so
|
|
307
|
+
// `--no-stream` and piped runs get a single body rather than SSE.
|
|
308
|
+
stream: commandCtx.stream !== false,
|
|
309
|
+
sessionId,
|
|
176
310
|
},
|
|
177
|
-
|
|
311
|
+
onDelta
|
|
312
|
+
);
|
|
178
313
|
|
|
179
314
|
const choice = (res.choices && res.choices[0]) || {};
|
|
180
315
|
const text = (choice.message && choice.message.content) || partial;
|
|
181
316
|
return {
|
|
182
317
|
text,
|
|
183
318
|
usage: res.usage || null,
|
|
184
|
-
model: res.model ||
|
|
319
|
+
model: res.model || commandCtx.model || null,
|
|
185
320
|
ms: Date.now() - started,
|
|
186
321
|
interrupted,
|
|
187
322
|
reasoningChars: reasoning,
|
|
323
|
+
usedTools,
|
|
188
324
|
};
|
|
189
325
|
} catch (e) {
|
|
190
326
|
// A user interrupt is not a failure: keep what already streamed.
|
|
@@ -192,18 +328,20 @@ function createApp(options = {}) {
|
|
|
192
328
|
return {
|
|
193
329
|
text: partial,
|
|
194
330
|
usage: null,
|
|
195
|
-
model:
|
|
331
|
+
model: commandCtx.model || null,
|
|
196
332
|
ms: Date.now() - started,
|
|
197
333
|
interrupted: true,
|
|
198
334
|
reasoningChars: reasoning,
|
|
335
|
+
usedTools,
|
|
199
336
|
};
|
|
200
337
|
}
|
|
201
338
|
throw e;
|
|
202
339
|
} finally {
|
|
203
340
|
if (timer) clearInterval(timer);
|
|
204
341
|
process.removeListener('SIGINT', onSigint);
|
|
342
|
+
if (signal) signal.removeEventListener('abort', cancel);
|
|
205
343
|
if (live) live.clear();
|
|
206
|
-
|
|
344
|
+
activeSessionId = null;
|
|
207
345
|
}
|
|
208
346
|
}
|
|
209
347
|
|
|
@@ -212,6 +350,13 @@ function createApp(options = {}) {
|
|
|
212
350
|
emit(render.renderTurn(ctx(), turn, width()));
|
|
213
351
|
}
|
|
214
352
|
|
|
353
|
+
/** Prior user/assistant pairs from the live transcript, for the engine. */
|
|
354
|
+
function historyPairs() {
|
|
355
|
+
return transcript
|
|
356
|
+
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
357
|
+
.map((m) => ({ role: m.role, content: m.text }));
|
|
358
|
+
}
|
|
359
|
+
|
|
215
360
|
/** Ask, then account for it. Shared by plain prompts and `/ask`. */
|
|
216
361
|
async function runPrompt(prompt, { label = 'you' } = {}) {
|
|
217
362
|
if (!prompt) {
|
|
@@ -229,25 +374,21 @@ function createApp(options = {}) {
|
|
|
229
374
|
return;
|
|
230
375
|
}
|
|
231
376
|
|
|
377
|
+
const history = historyPairs();
|
|
232
378
|
printTurn({ role: 'user', text: prompt, label });
|
|
379
|
+
transcript.push({ role: 'user', text: prompt });
|
|
233
380
|
|
|
234
381
|
let res;
|
|
235
382
|
try {
|
|
236
|
-
res = await ask(prompt);
|
|
383
|
+
res = await ask(prompt, { history });
|
|
237
384
|
} catch (e) {
|
|
238
385
|
emit(render.renderTurn(ctx(), { role: 'error', text: e.message }, width()));
|
|
239
386
|
return;
|
|
240
387
|
}
|
|
241
388
|
|
|
242
|
-
|
|
243
|
-
session.calls++;
|
|
389
|
+
if (res.text) transcript.push({ role: 'assistant', text: res.text });
|
|
244
390
|
|
|
245
|
-
const tokens =
|
|
246
|
-
if (tokens != null) {
|
|
247
|
-
session.tokens += tokens;
|
|
248
|
-
session.inputTokens += Number(res.usage.input_tokens ?? res.usage.prompt_tokens ?? 0) || 0;
|
|
249
|
-
session.outputTokens += Number(res.usage.output_tokens ?? res.usage.completion_tokens ?? 0) || 0;
|
|
250
|
-
}
|
|
391
|
+
const tokens = recordTurn(res);
|
|
251
392
|
|
|
252
393
|
const spend = await refreshSpend();
|
|
253
394
|
|
|
@@ -289,18 +430,6 @@ function createApp(options = {}) {
|
|
|
289
430
|
emit(render.renderToolResult(ctx(), name, text, width()));
|
|
290
431
|
}
|
|
291
432
|
|
|
292
|
-
// Category order and labels, matching aegiscodex-dev's palette.
|
|
293
|
-
const CATEGORY_ORDER = ['aegis', 'model', 'session', 'data', 'auth', 'support', 'workspace'];
|
|
294
|
-
const CATEGORY_LABEL = {
|
|
295
|
-
aegis: 'Aegis plugin',
|
|
296
|
-
model: 'Model & behavior',
|
|
297
|
-
session: 'Session & context',
|
|
298
|
-
data: 'Data',
|
|
299
|
-
auth: 'Auth',
|
|
300
|
-
support: 'Support',
|
|
301
|
-
workspace: 'Workspace',
|
|
302
|
-
};
|
|
303
|
-
|
|
304
433
|
/**
|
|
305
434
|
* The nearest routable name to a mistyped one: a prefix of it, or a name it
|
|
306
435
|
* is a prefix of. Deliberately simple — `cli/src/fuzzy.js` is a separate
|
|
@@ -323,37 +452,199 @@ function createApp(options = {}) {
|
|
|
323
452
|
return best;
|
|
324
453
|
}
|
|
325
454
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
455
|
+
/** Tokenize one argument string, honouring single/double quotes. */
|
|
456
|
+
function tokenize(s) {
|
|
457
|
+
const out = [];
|
|
458
|
+
let cur = '';
|
|
459
|
+
let quote = null;
|
|
460
|
+
let has = false;
|
|
461
|
+
for (const ch of String(s || '')) {
|
|
462
|
+
if (quote) {
|
|
463
|
+
if (ch === quote) quote = null;
|
|
464
|
+
else cur += ch;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (ch === '"' || ch === "'") { quote = ch; has = true; continue; }
|
|
468
|
+
if (/\s/.test(ch)) {
|
|
469
|
+
if (cur || has) { out.push(cur); cur = ''; has = false; }
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
cur += ch;
|
|
473
|
+
has = true;
|
|
474
|
+
}
|
|
475
|
+
if (cur || has) out.push(cur);
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Positional args keyed by the entry's `args` names, plus `_rest`. */
|
|
480
|
+
function parseArgs(cmd, arg) {
|
|
481
|
+
const raw = String(arg == null ? '' : arg);
|
|
482
|
+
const names = Array.isArray(cmd.args) ? cmd.args : [];
|
|
483
|
+
const tokens = tokenize(raw);
|
|
484
|
+
const out = { _rest: raw.trim() };
|
|
485
|
+
names.forEach((n, i) => { if (tokens[i] !== undefined) out[n] = tokens[i]; });
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** A fresh snapshot for panels.js (the frozen `c.state()` shape). */
|
|
490
|
+
function buildState() {
|
|
491
|
+
const rules = loadPermissions();
|
|
492
|
+
return {
|
|
493
|
+
version: VERSION,
|
|
494
|
+
model: commandCtx.model || 'server default',
|
|
495
|
+
effort: commandCtx.effort,
|
|
496
|
+
thinking: commandCtx.thinking,
|
|
497
|
+
theme: commandCtx.light ? 'light' : 'dark',
|
|
498
|
+
themeIndex: commandCtx.themeIndex,
|
|
499
|
+
vim: commandCtx.vim,
|
|
500
|
+
stream: commandCtx.stream,
|
|
501
|
+
cwd: commandCtx.cwd,
|
|
502
|
+
home: os.homedir(),
|
|
503
|
+
sessionId: commandCtx.sessionId,
|
|
504
|
+
base: client.apiBase,
|
|
505
|
+
keyMask: maskKey(client.apiKey),
|
|
506
|
+
online: !!client.apiKey,
|
|
507
|
+
turns: session.turns,
|
|
508
|
+
calls: session.calls,
|
|
509
|
+
startedAt: session.startedAt,
|
|
510
|
+
tokens: { input: session.inputTokens, output: session.outputTokens, total: session.tokens },
|
|
511
|
+
costEur: session.cost,
|
|
512
|
+
balance: session.balance,
|
|
513
|
+
plan: session.plan || null,
|
|
514
|
+
account: session.account || null,
|
|
515
|
+
permissions: { mode: rules.defaultMode, rules },
|
|
516
|
+
models: [],
|
|
517
|
+
commands: visibleCommands(),
|
|
518
|
+
transcript: transcript.slice(),
|
|
519
|
+
sessions: [],
|
|
520
|
+
memory: {},
|
|
521
|
+
lastRecap: commandCtx.lastRecap,
|
|
522
|
+
backend: 'aegis',
|
|
523
|
+
url: client.apiBase,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Flatten a span line (panels.js/overlays.js output) back to an ANSI row. */
|
|
528
|
+
function flattenSpans(line) {
|
|
529
|
+
if (!Array.isArray(line)) return String(line == null ? '' : line);
|
|
530
|
+
return line.map((sp) => (sp && (sp.s || '') + (sp.t == null ? '' : sp.t)) || '').join('');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Push one transcript row. `c.push(row)`/`c.note`/`c.panel` land here; the
|
|
535
|
+
* linear CLI prints each row once to scrollback (there is no alt-screen).
|
|
536
|
+
*/
|
|
537
|
+
function pushRow(row) {
|
|
538
|
+
if (!row || typeof row !== 'object') return;
|
|
539
|
+
const W = width();
|
|
540
|
+
switch (row.role) {
|
|
541
|
+
case 'panel': {
|
|
542
|
+
const lines = Array.isArray(row.lines) ? row.lines : [];
|
|
543
|
+
for (const line of lines) emit(flattenSpans(line));
|
|
544
|
+
return;
|
|
346
545
|
}
|
|
546
|
+
case 'note': emit(render.renderNotice(ctx(), 'info', row.text)); return;
|
|
547
|
+
case 'tip': emit(render.renderNotice(ctx(), 'info', row.text)); return;
|
|
548
|
+
case 'done': emit(render.renderNotice(ctx(), 'ok', row.text)); return;
|
|
549
|
+
case 'error': emit(render.renderNotice(ctx(), 'error', row.text)); return;
|
|
550
|
+
case 'user': emit(render.renderTurn(ctx(), { role: 'user', text: row.text, label: row.label }, W)); return;
|
|
551
|
+
case 'assistant': emit(render.renderTurn(ctx(), { role: 'assistant', text: row.text }, W)); return;
|
|
552
|
+
case 'tool':
|
|
553
|
+
emit(render.renderTurn(ctx(), { role: 'tool', label: row.label || row.name, args: row.args, ok: row.ok }, W));
|
|
554
|
+
return;
|
|
555
|
+
default: emit(render.renderNotice(ctx(), 'info', row.text == null ? '' : String(row.text))); return;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** A static (non-interactive) print of an overlay's contents. */
|
|
560
|
+
function openOverlay(o) {
|
|
561
|
+
if (!o || typeof o !== 'object') return;
|
|
562
|
+
const W = width();
|
|
563
|
+
const rows = Math.max(5, (process.stdout && process.stdout.rows) || 24);
|
|
564
|
+
let lines = null;
|
|
565
|
+
if (o.type === 'panel') lines = o.lines;
|
|
566
|
+
else if (o.type === 'palette') lines = overlays.renderPalette(visibleCommands(), { query: o.query || '', sel: o.sel || 0 }, W, rows);
|
|
567
|
+
else if (o.type === 'model') lines = overlays.renderModelPicker(o.items || [], o.sel || 0, W, rows, o.current != null ? o.current : commandCtx.model);
|
|
568
|
+
else if (o.type === 'effort') lines = overlays.renderEffortPicker(o.sel || 0, W, commandCtx.effort);
|
|
569
|
+
else if (o.type === 'resume') lines = overlays.renderResumeList(o.items || [], o.sel || 0, W, rows);
|
|
570
|
+
else if (o.type === 'confirm') lines = render.renderApproval(ctx(), o.info || {}, W).map((s) => [spanRow(s)]);
|
|
571
|
+
if (!lines) return;
|
|
572
|
+
for (const line of lines) emit(flattenSpans(line));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** A single-span row for a pre-styled string (renderApproval output). */
|
|
576
|
+
function spanRow(s) {
|
|
577
|
+
return { t: s, s: '', w: w(s) };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Run `fn(signal)` with the spinner up (interactive TTY only). */
|
|
581
|
+
async function withWorking(fn) {
|
|
582
|
+
const controller = new AbortController();
|
|
583
|
+
const live = opts.interactive && out.isTTY ? new LiveRegion(out) : null;
|
|
584
|
+
let timer = null;
|
|
585
|
+
if (live) {
|
|
586
|
+
let tick = 0;
|
|
587
|
+
const started = Date.now();
|
|
588
|
+
const paint = () => live.update([render.renderWorking(ctx(), { tick: tick++, verb: VERBS[0], elapsedMs: Date.now() - started })]);
|
|
589
|
+
paint();
|
|
590
|
+
timer = setInterval(paint, 90);
|
|
591
|
+
if (timer.unref) timer.unref();
|
|
347
592
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
593
|
+
try {
|
|
594
|
+
return await fn(controller.signal);
|
|
595
|
+
} finally {
|
|
596
|
+
if (timer) clearInterval(timer);
|
|
597
|
+
if (live) live.clear();
|
|
351
598
|
}
|
|
352
|
-
emit(['', render.renderNotice(ctx(), 'info', `plain text is a prompt ${GLYPH.bullet} /exit exits`)]);
|
|
353
599
|
}
|
|
354
600
|
|
|
355
|
-
|
|
356
|
-
|
|
601
|
+
async function showThemePicker() {
|
|
602
|
+
commandCtx.light = !commandCtx.light;
|
|
603
|
+
commandCtx.themeIndex = commandCtx.light ? 0 : 1;
|
|
604
|
+
emit(render.renderNotice(ctx(), 'ok', `theme: ${commandCtx.light ? 'light' : 'dark'}`));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Build the FROZEN command context `c` a handler runs against. */
|
|
608
|
+
function makeCommandContext() {
|
|
609
|
+
const c = {
|
|
610
|
+
ctx: commandCtx,
|
|
611
|
+
transcript,
|
|
612
|
+
push: (row) => pushRow(row),
|
|
613
|
+
note: (text) => pushRow({ role: 'note', text }),
|
|
614
|
+
panel: (lines) => pushRow({ role: 'panel', lines }),
|
|
615
|
+
render: () => {},
|
|
616
|
+
openOverlay: (o) => openOverlay(o),
|
|
617
|
+
closeOverlay: () => {},
|
|
618
|
+
askInput: () => Promise.resolve(null),
|
|
619
|
+
withWorking: (fn) => withWorking(fn),
|
|
620
|
+
runPrompt: (text) => runPrompt(text),
|
|
621
|
+
ask: (text) => ask(text),
|
|
622
|
+
runTool: (name, args) => runTool(name, args),
|
|
623
|
+
refreshSpend: () => refreshSpend(),
|
|
624
|
+
state: () => buildState(),
|
|
625
|
+
setInput: () => {},
|
|
626
|
+
exit: () => { wantExit = true; },
|
|
627
|
+
client,
|
|
628
|
+
TOOLS,
|
|
629
|
+
saveConfig: (patch) => updateConfig(patch),
|
|
630
|
+
showThemePicker: () => showThemePicker(),
|
|
631
|
+
};
|
|
632
|
+
Object.defineProperty(c, 'sessionId', {
|
|
633
|
+
enumerable: true,
|
|
634
|
+
get: () => commandCtx.sessionId,
|
|
635
|
+
set: (v) => { commandCtx.sessionId = v; },
|
|
636
|
+
});
|
|
637
|
+
return c;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Handle one line of input. Returns false when the session should end.
|
|
642
|
+
*
|
|
643
|
+
* `cOverride` lets the chatflow supply the context it owns (its own `push`,
|
|
644
|
+
* `render`, `openOverlay`, `askInput`, `runPrompt`) instead of the linear
|
|
645
|
+
* defaults — the handlers are identical either way, which is the point.
|
|
646
|
+
*/
|
|
647
|
+
async function handleLine(line, cOverride = null) {
|
|
357
648
|
const parsed = parseLine(line);
|
|
358
649
|
|
|
359
650
|
if (parsed.kind === 'empty') return true;
|
|
@@ -374,7 +665,7 @@ function createApp(options = {}) {
|
|
|
374
665
|
}
|
|
375
666
|
if (parsed.kind === 'unavailable') {
|
|
376
667
|
const c = parsed.command;
|
|
377
|
-
emit(render.renderNotice(ctx(), 'warn', `/${c.name} is not available in aegiscode — ${c.
|
|
668
|
+
emit(render.renderNotice(ctx(), 'warn', `/${c.name} is not available in aegiscode — ${c.unavailable}`));
|
|
378
669
|
if (c.alt) emit(render.renderNotice(ctx(), 'info', `try ${c.alt} instead`));
|
|
379
670
|
else {
|
|
380
671
|
const alt = nearest(c.name);
|
|
@@ -386,70 +677,16 @@ function createApp(options = {}) {
|
|
|
386
677
|
const cmd = parsed.command;
|
|
387
678
|
const arg = parsed.arg;
|
|
388
679
|
|
|
389
|
-
|
|
390
|
-
switch (cmd.local) {
|
|
391
|
-
case 'exit':
|
|
392
|
-
return false;
|
|
393
|
-
case 'clear':
|
|
394
|
-
out.write(EC.clearScreen);
|
|
395
|
-
// aegiscodex-dev's /clear starts a new session with empty context, so
|
|
396
|
-
// the session tallies reset too (the transcript is not persisted here).
|
|
397
|
-
session.turns = 0;
|
|
398
|
-
session.calls = 0;
|
|
399
|
-
session.tokens = 0;
|
|
400
|
-
session.inputTokens = 0;
|
|
401
|
-
session.outputTokens = 0;
|
|
402
|
-
session.cost = 0;
|
|
403
|
-
session.startedAt = Date.now();
|
|
404
|
-
emit(bannerLines());
|
|
405
|
-
return true;
|
|
406
|
-
case 'help':
|
|
407
|
-
printHelp();
|
|
408
|
-
return true;
|
|
409
|
-
case 'version':
|
|
410
|
-
emit(render.renderNotice(ctx(), 'info', `aegiscode v${VERSION}`));
|
|
411
|
-
return true;
|
|
412
|
-
case 'model':
|
|
413
|
-
if (!arg) {
|
|
414
|
-
emit(render.renderNotice(ctx(), 'info', `model: ${opts.model || 'server default'}`));
|
|
415
|
-
} else if (arg === '-') {
|
|
416
|
-
opts.model = null;
|
|
417
|
-
emit(render.renderNotice(ctx(), 'ok', 'model pin cleared — the server will choose'));
|
|
418
|
-
} else {
|
|
419
|
-
opts.model = arg;
|
|
420
|
-
emit(render.renderNotice(ctx(), 'ok', `pinned model: ${arg}`));
|
|
421
|
-
}
|
|
422
|
-
return true;
|
|
423
|
-
case 'stream':
|
|
424
|
-
opts.stream = arg ? !/^off|false|0$/i.test(arg) : !opts.stream;
|
|
425
|
-
emit(render.renderNotice(ctx(), 'ok', `streaming ${opts.stream ? 'on' : 'off'}`));
|
|
426
|
-
return true;
|
|
427
|
-
case 'theme':
|
|
428
|
-
opts.light = arg ? /^light/i.test(arg) : !opts.light;
|
|
429
|
-
emit(render.renderNotice(ctx(), 'ok', `theme: ${opts.light ? 'light' : 'dark'}`));
|
|
430
|
-
return true;
|
|
431
|
-
case 'cost':
|
|
432
|
-
case 'tokens': {
|
|
433
|
-
const t = themeOf(ctx());
|
|
434
|
-
emit([render.renderHeading(ctx(), 'session', width())]);
|
|
435
|
-
emit([
|
|
436
|
-
` tokens ${t.white}${fmtTokens(session.tokens)}${RESET} ` +
|
|
437
|
-
t.gray + `(${fmtTokens(session.inputTokens)} in / ${fmtTokens(session.outputTokens)} out)` + RESET,
|
|
438
|
-
` spend ${t.green}${fmtEur(session.cost)}${RESET}`,
|
|
439
|
-
` calls ${session.calls}`,
|
|
440
|
-
` balance ${session.balance == null ? 'unknown' : fmtEur(session.balance)}`,
|
|
441
|
-
` elapsed ${fmtElapsed(Date.now() - session.startedAt)}`,
|
|
442
|
-
]);
|
|
443
|
-
return true;
|
|
444
|
-
}
|
|
445
|
-
default:
|
|
446
|
-
emit(render.renderNotice(ctx(), 'error', `/${cmd.name} is not implemented`));
|
|
447
|
-
return true;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
680
|
+
// The generic escape hatch (/tool <name> [json]).
|
|
451
681
|
if (cmd.generic) {
|
|
452
|
-
|
|
682
|
+
let built;
|
|
683
|
+
try {
|
|
684
|
+
built = cmd.build(arg);
|
|
685
|
+
} catch (e) {
|
|
686
|
+
emit(render.renderNotice(ctx(), 'error', e.message));
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
const { tool, args } = built;
|
|
453
690
|
if (!tool) {
|
|
454
691
|
emit(render.renderNotice(ctx(), 'error', '/tool needs a tool name — see /help'));
|
|
455
692
|
return true;
|
|
@@ -462,6 +699,19 @@ function createApp(options = {}) {
|
|
|
462
699
|
return true;
|
|
463
700
|
}
|
|
464
701
|
|
|
702
|
+
// Handler-backed (the reference aegiscode-dev contract).
|
|
703
|
+
if (typeof cmd.handler === 'function') {
|
|
704
|
+
const args = parseArgs(cmd, arg);
|
|
705
|
+
const c = cOverride || makeCommandContext();
|
|
706
|
+
try {
|
|
707
|
+
const keep = await cmd.handler(c, args);
|
|
708
|
+
return keep === false ? false : true;
|
|
709
|
+
} catch (e) {
|
|
710
|
+
emit(render.renderNotice(ctx(), 'error', e && e.message ? e.message : String(e)));
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
465
715
|
// Tool-backed command.
|
|
466
716
|
try {
|
|
467
717
|
let args = cmd.build(arg);
|
|
@@ -562,8 +812,87 @@ function createApp(options = {}) {
|
|
|
562
812
|
return res.interrupted ? 130 : 0;
|
|
563
813
|
}
|
|
564
814
|
|
|
565
|
-
/**
|
|
566
|
-
|
|
815
|
+
/** Fold one completed turn's usage into the session tallies.
|
|
816
|
+
* @returns {number|null} the total token count for the turn, when known. */
|
|
817
|
+
function recordTurn(res) {
|
|
818
|
+
session.turns++;
|
|
819
|
+
session.calls += Number((res && res.calls) || 1) || 1;
|
|
820
|
+
const usage = res && res.usage;
|
|
821
|
+
const tokens = usageTokens(usage);
|
|
822
|
+
if (tokens != null) {
|
|
823
|
+
session.tokens += tokens;
|
|
824
|
+
session.inputTokens += Number(usage.input_tokens ?? usage.prompt_tokens ?? 0) || 0;
|
|
825
|
+
session.outputTokens += Number(usage.output_tokens ?? usage.completion_tokens ?? 0) || 0;
|
|
826
|
+
}
|
|
827
|
+
return tokens;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** One line of session accounting, for ctrl+t and the meta row. */
|
|
831
|
+
function tokenSummary() {
|
|
832
|
+
return (
|
|
833
|
+
`${fmtTokens(session.tokens)} tok ` +
|
|
834
|
+
`(${fmtTokens(session.inputTokens)} in / ${fmtTokens(session.outputTokens)} out) · ` +
|
|
835
|
+
`${session.calls} call${session.calls === 1 ? '' : 's'} · ${fmtEur(session.cost)}` +
|
|
836
|
+
(session.balance == null ? '' : ` · balance ${fmtEur(session.balance)}`)
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Load a stored session back into the live transcript. */
|
|
841
|
+
async function resumeSession(item) {
|
|
842
|
+
if (!item || !item.id) return;
|
|
843
|
+
const rows = readSessionTranscript(item.id);
|
|
844
|
+
if (!rows.length) {
|
|
845
|
+
emit(render.renderNotice(ctx(), 'warn', `no stored turns for ${item.id}`));
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
// Replace, rather than append: "resume" means continue THIS conversation,
|
|
849
|
+
// and appending would put two sessions' turns in one context window.
|
|
850
|
+
transcript.length = 0;
|
|
851
|
+
for (const r of rows) transcript.push(r);
|
|
852
|
+
commandCtx.sessionId = item.id;
|
|
853
|
+
emit(render.renderNotice(ctx(), 'ok', `resumed ${item.id} — ${rows.length} turn(s)`));
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* The host the chatflow drives. Everything the loop needs from the app, with
|
|
858
|
+
* no layering of its own — the loop owns the frame and the keys, this owns
|
|
859
|
+
* the transport, the tools, the command table and the tallies.
|
|
860
|
+
*/
|
|
861
|
+
function makeHost() {
|
|
862
|
+
return {
|
|
863
|
+
ctx: commandCtx,
|
|
864
|
+
version: VERSION,
|
|
865
|
+
transcript,
|
|
866
|
+
session,
|
|
867
|
+
client,
|
|
868
|
+
TOOLS,
|
|
869
|
+
ask: (prompt, o) => ask(prompt, o),
|
|
870
|
+
makeCommandContext: () => makeCommandContext(),
|
|
871
|
+
buildState: () => buildState(),
|
|
872
|
+
dispatchLine: (line, c) => handleLine(line, c),
|
|
873
|
+
refreshSpend: () => refreshSpend(),
|
|
874
|
+
updateConfig: (patch) => updateConfig(patch),
|
|
875
|
+
visibleCommands: () => visibleCommands(),
|
|
876
|
+
tokensFor: (usage) => usageTokens(usage),
|
|
877
|
+
recordTurn: (res) => recordTurn(res),
|
|
878
|
+
tokenSummary: () => tokenSummary(),
|
|
879
|
+
resumeSession: (item) => resumeSession(item),
|
|
880
|
+
requestExit: () => {
|
|
881
|
+
wantExit = true;
|
|
882
|
+
},
|
|
883
|
+
wantsExit: () => wantExit,
|
|
884
|
+
isYolo: () => {
|
|
885
|
+
try {
|
|
886
|
+
return loadPermissions().defaultMode === 'allow';
|
|
887
|
+
} catch {
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
},
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** The plain (non-alt-screen) REPL: readline, one turn written to scrollback. */
|
|
895
|
+
async function runLinearRepl() {
|
|
567
896
|
emit(bannerLines());
|
|
568
897
|
await refreshSpend();
|
|
569
898
|
out.write('\n');
|
|
@@ -582,7 +911,7 @@ function createApp(options = {}) {
|
|
|
582
911
|
} catch (e) {
|
|
583
912
|
emit(render.renderNotice(ctx(), 'error', e.message));
|
|
584
913
|
}
|
|
585
|
-
if (!keep) {
|
|
914
|
+
if (!keep || wantExit) {
|
|
586
915
|
closed = true;
|
|
587
916
|
rl.close();
|
|
588
917
|
return;
|
|
@@ -597,21 +926,50 @@ function createApp(options = {}) {
|
|
|
597
926
|
});
|
|
598
927
|
}
|
|
599
928
|
|
|
929
|
+
/**
|
|
930
|
+
* Interactive entry point. On a real terminal this is the full chatflow
|
|
931
|
+
* (alternate screen, header, transcript viewport, spinner, effort line,
|
|
932
|
+
* input line, status line, overlays). Anywhere else — a pipe, a test with an
|
|
933
|
+
* injected readline, `--print` — it stays the linear loop, so output remains
|
|
934
|
+
* pipeable and scriptable.
|
|
935
|
+
*/
|
|
936
|
+
async function runInteractive() {
|
|
937
|
+
const tty =
|
|
938
|
+
!options.readline &&
|
|
939
|
+
options.chatflow !== false &&
|
|
940
|
+
process.stdin.isTTY &&
|
|
941
|
+
process.stdout.isTTY;
|
|
942
|
+
if (tty) {
|
|
943
|
+
await chatflow.runSession(makeHost());
|
|
944
|
+
return 0;
|
|
945
|
+
}
|
|
946
|
+
return runLinearRepl();
|
|
947
|
+
}
|
|
948
|
+
|
|
600
949
|
return {
|
|
601
950
|
opts,
|
|
602
951
|
session,
|
|
603
952
|
client,
|
|
604
953
|
TOOLS,
|
|
605
954
|
toolList,
|
|
955
|
+
ctx: commandCtx,
|
|
956
|
+
transcript,
|
|
606
957
|
ask,
|
|
607
958
|
runPrompt,
|
|
608
959
|
handleLine,
|
|
609
960
|
runOnce,
|
|
610
961
|
runInteractive,
|
|
962
|
+
runLinearRepl,
|
|
611
963
|
refreshSpend,
|
|
612
964
|
bannerLines,
|
|
965
|
+
makeHost,
|
|
966
|
+
recordTurn,
|
|
967
|
+
tokenSummary,
|
|
968
|
+
resumeSession,
|
|
969
|
+
makeCommandContext,
|
|
970
|
+
buildState,
|
|
613
971
|
get aborted() {
|
|
614
|
-
return
|
|
972
|
+
return false;
|
|
615
973
|
},
|
|
616
974
|
};
|
|
617
975
|
}
|