aegiscode 6.4.0 → 6.5.1
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 +35 -4
- package/package.json +1 -1
- package/scripts/predist.mjs +5 -0
- package/src/commands.js +1 -1
- package/src/config.js +8 -6
- package/src/credentials.js +17 -310
- package/src/history.js +86 -6
- package/src/shared.js +45 -0
- package/vendor/client/credentials.js +386 -0
- package/vendor/client/session-store.js +514 -0
package/README.md
CHANGED
|
@@ -54,6 +54,10 @@ older AEGIS CLI left in `~/.aegiscode/config.json` is picked up and copied into
|
|
|
54
54
|
the 0600 store automatically — `/cloud status` says so if the plaintext copy is
|
|
55
55
|
still there.
|
|
56
56
|
|
|
57
|
+
That one file is shared: the MCP plugin and AEGIS Desktop read the same store, so
|
|
58
|
+
signing in here signs you in everywhere (see [One memory, shared with AEGIS
|
|
59
|
+
Desktop](#one-memory-shared-with-aegis-desktop)).
|
|
60
|
+
|
|
57
61
|
## Use
|
|
58
62
|
|
|
59
63
|
```bash
|
|
@@ -139,7 +143,7 @@ a *provider* key, so that advice sent the AEGIS key into the wrong slot. It is
|
|
|
139
143
|
now the in-band way to set the account key, and `/logout` removes it.
|
|
140
144
|
|
|
141
145
|
Conversation sync is the same `conversationSyncPush/Pull` surface the desktop
|
|
142
|
-
uses, over the sessions this host
|
|
146
|
+
uses, over the sessions this host keeps in `~/.aegiscode/history.jsonl`:
|
|
143
147
|
|
|
144
148
|
```bash
|
|
145
149
|
/sync # push what is pending, then pull — the one you want
|
|
@@ -156,6 +160,31 @@ out, not as "sync failed". Imported remote sessions land in the same store
|
|
|
156
160
|
`/resume` reads, and are marked `imported` with estimated token counts — never
|
|
157
161
|
passed off as measured here.
|
|
158
162
|
|
|
163
|
+
## One memory, shared with AEGIS Desktop
|
|
164
|
+
|
|
165
|
+
`~/.aegiscode/` is the data dir for **every** AEGIS host — this CLI, AEGIS
|
|
166
|
+
Desktop and the MCP plugin built on the same account:
|
|
167
|
+
|
|
168
|
+
| File | What it holds | Written by |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `credentials.json` | the account key, mode 0600 | `aegiscode login`, `/key`, the desktop's Settings pane |
|
|
171
|
+
| `sessions.json` | every conversation, one record per session | both hosts, live |
|
|
172
|
+
| `history.jsonl` | this host's per-exchange ledger (feeds `/cost`) | the CLI |
|
|
173
|
+
| `config.json` | preferences, permissions | the CLI |
|
|
174
|
+
|
|
175
|
+
Sign in once and all three hosts are signed in: they resolve the key with the
|
|
176
|
+
same precedence (`$AEGIS_API_KEY` → `credentials.json` → an older `config.json`),
|
|
177
|
+
and when two hosts hold a key the **most recently saved one wins** — so rotating
|
|
178
|
+
it from the terminal does not leave the app 401-ing on a stale string.
|
|
179
|
+
|
|
180
|
+
Sessions are shared too, and not only through the cloud. `/resume` lists a
|
|
181
|
+
thread typed in the desktop, and the desktop's session list shows a thread typed
|
|
182
|
+
here — same file, no sync and no network. An upgrade adopts a desktop install's
|
|
183
|
+
private `sessions.json` into the shared store once, so no existing conversation
|
|
184
|
+
is lost. Records carry `origin`, and terminal sessions are **not** enrolled in
|
|
185
|
+
the desktop's push queue (that would spend the account's synced-token quota as a
|
|
186
|
+
side effect of typing in a shell); this host's own `/sync` covers them.
|
|
187
|
+
|
|
159
188
|
There are currently **no** `unavailable` commands: every entry in the registry
|
|
160
189
|
either runs or is a real handler that says honestly what it cannot do.
|
|
161
190
|
|
|
@@ -216,9 +245,11 @@ What the loop does, in the order a turn happens:
|
|
|
216
245
|
`/resume` the session list, `?` the shortcut grid, `ctrl+o` permissions, and a
|
|
217
246
|
centred Yes/No dialog when a mutating tool needs approval. `Esc` closes an
|
|
218
247
|
overlay, and clears the input line when nothing is open.
|
|
219
|
-
- **Every turn is persisted** to `~/.aegiscode/history.jsonl
|
|
220
|
-
|
|
221
|
-
|
|
248
|
+
- **Every turn is persisted** to `~/.aegiscode/history.jsonl` and mirrored into
|
|
249
|
+
the shared `~/.aegiscode/sessions.json`, with a transcript checkpoint alongside
|
|
250
|
+
them, so `/resume`, `/cost`, `/clear` and `/rewind` all have something real to
|
|
251
|
+
read — and the desktop app sees the same conversations. On exit the session
|
|
252
|
+
prints how to come back to it.
|
|
222
253
|
|
|
223
254
|
Anything that is not a real terminal — a pipe, `-p`, a CI run — stays a linear
|
|
224
255
|
transcript written once to scrollback, so output remains pipeable and
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegiscode",
|
|
3
3
|
"productName": "AEGIS Code",
|
|
4
|
-
"version": "6.
|
|
4
|
+
"version": "6.5.1",
|
|
5
5
|
"description": "aegiscode \u2014 the command-line version of AEGIS Desktop. The shared tool surface in your shell, over the same thin transport and tool registry as the MCP plugin and the desktop app. Ships transport + UI only; no brain.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "AEGIS Code",
|
package/scripts/predist.mjs
CHANGED
|
@@ -38,6 +38,11 @@ const VENDOR = path.join(CLI_DIR, 'vendor');
|
|
|
38
38
|
const FILES = [
|
|
39
39
|
'client/aegis.js',
|
|
40
40
|
'client/foreign-memory.js',
|
|
41
|
+
// The account credential store and the unified session store. Shared with the
|
|
42
|
+
// desktop app and the MCP plugin so all three see one key and one session
|
|
43
|
+
// list; `src/shared.js` resolves them from here in an installed package.
|
|
44
|
+
'client/credentials.js',
|
|
45
|
+
'client/session-store.js',
|
|
41
46
|
'mcp/tools.js',
|
|
42
47
|
'desktop/renderer/usage.js',
|
|
43
48
|
// The agent-loop engine (persistent shell, editFile/grep/exec, Task
|
package/src/commands.js
CHANGED
|
@@ -726,7 +726,7 @@ const COMMANDS = [
|
|
|
726
726
|
desc: 'Switch to a previous session',
|
|
727
727
|
handler: async (c) => {
|
|
728
728
|
const items = readResumeList();
|
|
729
|
-
if (!items.length) { note(c, 'No previous sessions found in ~/.aegiscode/
|
|
729
|
+
if (!items.length) { note(c, 'No previous sessions found — conversations from this host and from AEGIS Desktop both land in ~/.aegiscode/sessions.json'); c.render(); return true; }
|
|
730
730
|
c.openOverlay({ type: 'resume', items, sel: 0 });
|
|
731
731
|
return true;
|
|
732
732
|
},
|
package/src/config.js
CHANGED
|
@@ -13,14 +13,16 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const fs = require('node:fs');
|
|
16
|
-
const os = require('node:os');
|
|
17
16
|
const path = require('node:path');
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
// The data dir is defined once, in the shared credential module, because the
|
|
19
|
+
// desktop app and the MCP plugin now read and write the same store: a second
|
|
20
|
+
// definition here is how the hosts would drift back apart.
|
|
21
|
+
const { credentials } = require('./shared.js');
|
|
22
|
+
|
|
23
|
+
/** Data directory: $AEGISCODE_HOME or ~/.aegiscode (see client/credentials.js). */
|
|
24
|
+
function aegisDir(env) {
|
|
25
|
+
return credentials.aegisHome(env);
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
function configPath() {
|
package/src/credentials.js
CHANGED
|
@@ -3,321 +3,28 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The AEGIS account credential store for the terminal host.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* reference commands this host deliberately marks unavailable. The only
|
|
13
|
-
* credential the CLI could not accept was its own.
|
|
6
|
+
* The implementation moved to `client/credentials.js` — the tree every host in
|
|
7
|
+
* this repo already bundles — because the desktop app and the MCP plugin read
|
|
8
|
+
* the same file now. Before that move the MCP host took its key from the
|
|
9
|
+
* environment only (it ships as `mcp/` + `client/`, so it could not require this
|
|
10
|
+
* directory), which meant `aegiscode login` made the CLI work and left the
|
|
11
|
+
* plugin saying "No AEGIS_API_KEY is set".
|
|
14
12
|
*
|
|
15
|
-
*
|
|
13
|
+
* This module stays as the CLI's name for that store, so the resolution order,
|
|
14
|
+
* the 0600 write and the legacy-adoption behaviour are unchanged for every
|
|
15
|
+
* existing caller and test:
|
|
16
16
|
*
|
|
17
|
-
* 1. `AEGIS_API_KEY` — the environment, so CI and
|
|
18
|
-
* nothing written here can shadow
|
|
17
|
+
* 1. `AEGIS_API_KEY` — the environment, so CI and an explicit export keep
|
|
18
|
+
* working and nothing written here can shadow them.
|
|
19
19
|
* 2. `credentials.json` in the data dir, mode 0600. The writable store.
|
|
20
20
|
* 3. `config.json`'s `aegiscloud.api_key` / `memory.token` — the shape an
|
|
21
|
-
* earlier AEGIS CLI left in the same data dir. Read, never written
|
|
22
|
-
*
|
|
23
|
-
* it. When one is found it is *also* copied into credentials.json so the
|
|
24
|
-
* next run reads the 0600 copy, and `/cloud status` says the plaintext
|
|
25
|
-
* copy is still there rather than silently leaving it.
|
|
21
|
+
* earlier AEGIS CLI left in the same data dir. Read, never written or
|
|
22
|
+
* deleted; adopted into the 0600 store once so the next run reads that.
|
|
26
23
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* A re-export, not a copy: two implementations is how the CLI and the plugin
|
|
25
|
+
* would come to disagree about which key is configured.
|
|
29
26
|
*/
|
|
30
27
|
|
|
31
|
-
const
|
|
32
|
-
const path = require('node:path');
|
|
33
|
-
const { aegisDir, configPath } = require('./config.js');
|
|
28
|
+
const shared = require('./shared.js');
|
|
34
29
|
|
|
35
|
-
|
|
36
|
-
/** Optional override for the memory token (cloud sync's own credential). */
|
|
37
|
-
const MEMORY_ENV = 'AEGIS_MEMORY_TOKEN';
|
|
38
|
-
const CREDENTIALS_FILE = 'credentials.json';
|
|
39
|
-
const FILE_MODE = 0o600;
|
|
40
|
-
const DIR_MODE = 0o700;
|
|
41
|
-
|
|
42
|
-
function credentialsPath() {
|
|
43
|
-
return path.join(aegisDir(), CREDENTIALS_FILE);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Accept the key in the shapes a user actually pastes it.
|
|
48
|
-
*
|
|
49
|
-
* Copy-paste from a dashboard, a `.env` line, a shell profile or a chat message
|
|
50
|
-
* are all realistic — and pasting `AEGIS_API_KEY=aegis_…` or `"aegis_…"` into a
|
|
51
|
-
* prompt that stores the literal string produces an auth failure the user
|
|
52
|
-
* cannot see the cause of, because the key *looks* right in the status line.
|
|
53
|
-
*/
|
|
54
|
-
function normalizeApiKey(raw) {
|
|
55
|
-
let s = String(raw == null ? '' : raw).trim();
|
|
56
|
-
if (!s) return '';
|
|
57
|
-
s = s.replace(/^export\s+/i, '').trim();
|
|
58
|
-
const assignment = /^[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/s.exec(s);
|
|
59
|
-
if (assignment) s = assignment[1].trim();
|
|
60
|
-
s = s.replace(/^["']|["']$/g, '').trim();
|
|
61
|
-
s = s.replace(/^Bearer\s+/i, '').trim();
|
|
62
|
-
return s;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Shape check only — no network. Catches the two mistakes that are structural
|
|
67
|
-
* (an empty paste, and a wrapped/truncated multi-line paste) so the caller can
|
|
68
|
-
* refuse before spending a verification round trip.
|
|
69
|
-
*/
|
|
70
|
-
function validateApiKey(raw) {
|
|
71
|
-
const key = normalizeApiKey(raw);
|
|
72
|
-
if (!key) return { ok: false, key, reason: 'empty', message: 'no key given' };
|
|
73
|
-
if (/\s/.test(key)) {
|
|
74
|
-
return {
|
|
75
|
-
ok: false,
|
|
76
|
-
key,
|
|
77
|
-
reason: 'whitespace',
|
|
78
|
-
message: 'that looks like more than one word — paste just the key',
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
if (key.length < 16) {
|
|
82
|
-
return {
|
|
83
|
-
ok: false,
|
|
84
|
-
key,
|
|
85
|
-
reason: 'too short',
|
|
86
|
-
message: `that key is ${key.length} characters — AEGIS keys are longer than that`,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
return { ok: true, key, reason: null, message: null };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** The stored credential object, or {} — never throws on a missing/corrupt file. */
|
|
93
|
-
function readCredentials() {
|
|
94
|
-
try {
|
|
95
|
-
const parsed = JSON.parse(fs.readFileSync(credentialsPath(), 'utf8'));
|
|
96
|
-
if (parsed && typeof parsed === 'object') return parsed;
|
|
97
|
-
} catch {}
|
|
98
|
-
return {};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Merge a patch into the store, creating it 0600 (and tightening an existing
|
|
103
|
-
* file that is wider — a key file that a previous run left 0644 is exactly the
|
|
104
|
-
* leak this file exists to avoid).
|
|
105
|
-
*/
|
|
106
|
-
function writeCredentials(patch) {
|
|
107
|
-
const next = { ...readCredentials(), ...patch, version: 1 };
|
|
108
|
-
try {
|
|
109
|
-
fs.mkdirSync(aegisDir(), { recursive: true, mode: DIR_MODE });
|
|
110
|
-
const target = credentialsPath();
|
|
111
|
-
const tmp = target + '.tmp';
|
|
112
|
-
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: FILE_MODE });
|
|
113
|
-
fs.renameSync(tmp, target);
|
|
114
|
-
try {
|
|
115
|
-
fs.chmodSync(target, FILE_MODE);
|
|
116
|
-
} catch {}
|
|
117
|
-
} catch (e) {
|
|
118
|
-
if (process.env.AEGIS_HIST_DEBUG) console.error('[credentials] write failed:', e);
|
|
119
|
-
return { ok: false, error: e, credentials: next };
|
|
120
|
-
}
|
|
121
|
-
return { ok: true, credentials: next };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* The key an earlier AEGIS CLI wrote into config.json (the `aegiscloud` /
|
|
126
|
-
* `memory` blocks are not ours — config.js cannot produce them). Read-only.
|
|
127
|
-
*/
|
|
128
|
-
function readLegacyConfig() {
|
|
129
|
-
try {
|
|
130
|
-
const parsed = JSON.parse(fs.readFileSync(configPath(), 'utf8'));
|
|
131
|
-
if (!parsed || typeof parsed !== 'object') return {};
|
|
132
|
-
const cloud = parsed.aegiscloud && typeof parsed.aegiscloud === 'object' ? parsed.aegiscloud : {};
|
|
133
|
-
const memory = parsed.memory && typeof parsed.memory === 'object' ? parsed.memory : {};
|
|
134
|
-
return {
|
|
135
|
-
apiKey: normalizeApiKey(cloud.api_key),
|
|
136
|
-
memoryToken: String(memory.token || '').trim(),
|
|
137
|
-
syncConversations: cloud.syncConversations === true ? true : undefined,
|
|
138
|
-
lastVerified: cloud.lastVerified || null,
|
|
139
|
-
memorySubscribed: memory.subscribed === true ? true : undefined,
|
|
140
|
-
};
|
|
141
|
-
} catch {
|
|
142
|
-
return {};
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** True when config.json still carries a plaintext copy of the account key. */
|
|
147
|
-
function legacyKeyOnDisk() {
|
|
148
|
-
return !!readLegacyConfig().apiKey;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Copy a legacy key/token into the 0600 store, once, without touching the file
|
|
153
|
-
* it came from. Returns which fields were adopted so the caller can say so.
|
|
154
|
-
*/
|
|
155
|
-
function adoptLegacy() {
|
|
156
|
-
const legacy = readLegacyConfig();
|
|
157
|
-
const creds = readCredentials();
|
|
158
|
-
const patch = {};
|
|
159
|
-
if (legacy.apiKey && !creds.aegisApiKey) patch.aegisApiKey = legacy.apiKey;
|
|
160
|
-
if (legacy.memoryToken && !creds.memoryToken) patch.memoryToken = legacy.memoryToken;
|
|
161
|
-
if (!Object.keys(patch).length) return { adopted: [] };
|
|
162
|
-
const written = writeCredentials({ ...patch, adoptedFrom: configPath() });
|
|
163
|
-
if (!written.ok) return { adopted: [] };
|
|
164
|
-
return { adopted: Object.keys(patch) };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* The key to use, and where it came from.
|
|
169
|
-
*
|
|
170
|
-
* @returns {{key:string, source:'env'|'credentials'|'config'|'none', from:string}}
|
|
171
|
-
*/
|
|
172
|
-
function resolveApiKey(o = {}) {
|
|
173
|
-
const env = o.env || process.env;
|
|
174
|
-
const fromEnv = normalizeApiKey(env && env[KEY_ENV]);
|
|
175
|
-
if (fromEnv) return { key: fromEnv, source: 'env', from: KEY_ENV };
|
|
176
|
-
|
|
177
|
-
const creds = readCredentials();
|
|
178
|
-
const stored = normalizeApiKey(creds.aegisApiKey);
|
|
179
|
-
if (stored) return { key: stored, source: 'credentials', from: credentialsPath() };
|
|
180
|
-
|
|
181
|
-
const legacy = readLegacyConfig().apiKey;
|
|
182
|
-
if (legacy) return { key: legacy, source: 'config', from: configPath() };
|
|
183
|
-
|
|
184
|
-
return { key: '', source: 'none', from: '' };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Whether any credential is available (env, store or legacy config). */
|
|
188
|
-
function hasApiKey(o = {}) {
|
|
189
|
-
return !!resolveApiKey(o).key;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Persist a key.
|
|
194
|
-
*
|
|
195
|
-
* @returns {{ok:boolean, key:string, path:string, error?:Error, message?:string}}
|
|
196
|
-
*/
|
|
197
|
-
function saveApiKey(raw) {
|
|
198
|
-
const v = validateApiKey(raw);
|
|
199
|
-
if (!v.ok) return { ok: false, key: v.key, path: credentialsPath(), message: v.message };
|
|
200
|
-
const written = writeCredentials({ aegisApiKey: v.key, savedAt: new Date().toISOString() });
|
|
201
|
-
if (!written.ok) {
|
|
202
|
-
return {
|
|
203
|
-
ok: false,
|
|
204
|
-
key: v.key,
|
|
205
|
-
path: credentialsPath(),
|
|
206
|
-
error: written.error,
|
|
207
|
-
message: `could not write ${credentialsPath()}`,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
return { ok: true, key: v.key, path: credentialsPath() };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Remove the stored key (and nothing else — the memory token stays, since a
|
|
215
|
-
* key rotation should not silently unsubscribe cloud memory). config.json is
|
|
216
|
-
* never touched: another product writes it.
|
|
217
|
-
*/
|
|
218
|
-
function clearApiKey() {
|
|
219
|
-
const had = !!normalizeApiKey(readCredentials().aegisApiKey);
|
|
220
|
-
const written = writeCredentials({ aegisApiKey: '', adoptedFrom: '' });
|
|
221
|
-
return { cleared: had, ok: written.ok, path: credentialsPath() };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** The memory token cloud sync authenticates with, from store or legacy. */
|
|
225
|
-
function resolveMemoryToken(o = {}) {
|
|
226
|
-
const env = o.env || process.env;
|
|
227
|
-
const fromEnv = String((env && env[MEMORY_ENV]) || '').trim();
|
|
228
|
-
if (fromEnv) return { token: fromEnv, source: 'env' };
|
|
229
|
-
const creds = readCredentials();
|
|
230
|
-
const stored = String(creds.memoryToken || '').trim();
|
|
231
|
-
if (stored) return { token: stored, source: 'credentials' };
|
|
232
|
-
const legacy = readLegacyConfig().memoryToken;
|
|
233
|
-
if (legacy) return { token: legacy, source: 'config' };
|
|
234
|
-
return { token: '', source: 'none' };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function saveMemoryToken(token, extra = {}) {
|
|
238
|
-
return writeCredentials({ memoryToken: String(token || '').trim(), ...extra });
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function clearMemoryToken() {
|
|
242
|
-
return writeCredentials({ memoryToken: '', memorySubscribed: false });
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Everything a status screen or a `doctor` line needs about the credential,
|
|
247
|
-
* without ever exposing the key itself.
|
|
248
|
-
*/
|
|
249
|
-
function keyStatus(o = {}) {
|
|
250
|
-
const { key, source, from } = resolveApiKey(o);
|
|
251
|
-
const creds = readCredentials();
|
|
252
|
-
let mode = null;
|
|
253
|
-
try {
|
|
254
|
-
mode = fs.statSync(credentialsPath()).mode & 0o777;
|
|
255
|
-
} catch {}
|
|
256
|
-
return {
|
|
257
|
-
configured: !!key,
|
|
258
|
-
key,
|
|
259
|
-
source,
|
|
260
|
-
from,
|
|
261
|
-
path: credentialsPath(),
|
|
262
|
-
fileMode: mode == null ? null : '0' + mode.toString(8),
|
|
263
|
-
stored: !!normalizeApiKey(creds.aegisApiKey),
|
|
264
|
-
legacyPlaintext: legacyKeyOnDisk(),
|
|
265
|
-
verifiedAt: creds.verifiedAt || null,
|
|
266
|
-
account: creds.account || null,
|
|
267
|
-
memoryToken: !!resolveMemoryToken(o).token,
|
|
268
|
-
memorySource: resolveMemoryToken(o).source,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Client options for `createClient`: the resolved key plus the stored memory
|
|
274
|
-
* token, so a restart does not re-exchange for a token it already has.
|
|
275
|
-
*/
|
|
276
|
-
function clientOptions(o = {}) {
|
|
277
|
-
const { key } = resolveApiKey(o);
|
|
278
|
-
const { token } = resolveMemoryToken(o);
|
|
279
|
-
const opts = {};
|
|
280
|
-
if (key) opts.apiKey = key;
|
|
281
|
-
if (token) opts.memoryToken = token;
|
|
282
|
-
return opts;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/** Human label for a resolution source, for status lines. */
|
|
286
|
-
const SOURCE_LABEL = {
|
|
287
|
-
env: `$${KEY_ENV}`,
|
|
288
|
-
credentials: 'saved key file',
|
|
289
|
-
config: 'config.json (aegis CLI)',
|
|
290
|
-
none: 'not set',
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
function sourceLabel(source) {
|
|
294
|
-
return SOURCE_LABEL[source] || SOURCE_LABEL.none;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/** One line telling the user how to supply a key, used by every error path. */
|
|
298
|
-
const HOW_TO_SET = 'run `aegiscode login` (or /key inside a session) to save one';
|
|
299
|
-
|
|
300
|
-
module.exports = {
|
|
301
|
-
KEY_ENV,
|
|
302
|
-
MEMORY_ENV,
|
|
303
|
-
CREDENTIALS_FILE,
|
|
304
|
-
credentialsPath,
|
|
305
|
-
normalizeApiKey,
|
|
306
|
-
validateApiKey,
|
|
307
|
-
readCredentials,
|
|
308
|
-
writeCredentials,
|
|
309
|
-
readLegacyConfig,
|
|
310
|
-
legacyKeyOnDisk,
|
|
311
|
-
adoptLegacy,
|
|
312
|
-
resolveApiKey,
|
|
313
|
-
hasApiKey,
|
|
314
|
-
saveApiKey,
|
|
315
|
-
clearApiKey,
|
|
316
|
-
resolveMemoryToken,
|
|
317
|
-
saveMemoryToken,
|
|
318
|
-
clearMemoryToken,
|
|
319
|
-
keyStatus,
|
|
320
|
-
clientOptions,
|
|
321
|
-
sourceLabel,
|
|
322
|
-
HOW_TO_SET,
|
|
323
|
-
};
|
|
30
|
+
module.exports = shared.credentials;
|
package/src/history.js
CHANGED
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
* (or $AEGISCODE_HOME). Keeps the file bounded (oldest entries dropped past
|
|
6
6
|
* HISTORY_LIMIT lines).
|
|
7
7
|
*
|
|
8
|
+
* history.jsonl is this host's *ledger*: one record per exchange, carrying the
|
|
9
|
+
* token/cost numbers `/cost` aggregates. It stays the CLI's own format.
|
|
10
|
+
*
|
|
11
|
+
* What changed with the shared store (`client/session-store.js`) is that every
|
|
12
|
+
* recorded exchange is ALSO mirrored into `<dir>/sessions.json`, the store the
|
|
13
|
+
* desktop app and the MCP plugin read. That file is what makes a terminal turn
|
|
14
|
+
* show up in the GUI's session list — and, in the other direction, what lets
|
|
15
|
+
* `/resume` open a session that was typed in the desktop, without cloud sync
|
|
16
|
+
* and without a key. `readResumeList` merges both sources by id, so no session
|
|
17
|
+
* is listed twice.
|
|
18
|
+
*
|
|
8
19
|
* Ported from aegiscodex-dev/src/history.js (ESM → CommonJS). The data dir now
|
|
9
20
|
* comes from config.js's shared `aegisDir()` helper.
|
|
10
21
|
*/
|
|
@@ -13,9 +24,12 @@ const fs = require('node:fs');
|
|
|
13
24
|
const os = require('node:os');
|
|
14
25
|
const path = require('node:path');
|
|
15
26
|
const { aegisDir } = require('./config.js');
|
|
27
|
+
const { sessionStore } = require('./shared.js');
|
|
16
28
|
const { estimateTokens } = require('./tokens.js');
|
|
17
29
|
|
|
18
30
|
const HISTORY_LIMIT = 500;
|
|
31
|
+
/** Which host wrote a mirrored record, so either side can tell them apart. */
|
|
32
|
+
const SOURCE = 'aegiscode-cli';
|
|
19
33
|
|
|
20
34
|
function historyPath() {
|
|
21
35
|
return path.join(aegisDir(), 'history.jsonl');
|
|
@@ -82,6 +96,7 @@ function appendHistoryEntries(entries) {
|
|
|
82
96
|
const lines = [...prev, ...list.map((e) => JSON.stringify(e))];
|
|
83
97
|
const trimmed = lines.slice(Math.max(0, lines.length - HISTORY_LIMIT));
|
|
84
98
|
fs.writeFileSync(p, trimmed.join('\n') + '\n');
|
|
99
|
+
mirrorToSharedStore(list);
|
|
85
100
|
return list.length;
|
|
86
101
|
} catch (e) {
|
|
87
102
|
// Persistence is best-effort; never crash the session over it.
|
|
@@ -90,6 +105,35 @@ function appendHistoryEntries(entries) {
|
|
|
90
105
|
}
|
|
91
106
|
}
|
|
92
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Mirror freshly-recorded exchanges into the shared session store, so the
|
|
110
|
+
* desktop and the MCP plugin can see this host's sessions.
|
|
111
|
+
*
|
|
112
|
+
* Only *writes* mirror — a read of history.jsonl never re-imports itself, which
|
|
113
|
+
* is what would otherwise duplicate every exchange on every launch. A mirror
|
|
114
|
+
* failure must never cost the user the history line it accompanies, so this is
|
|
115
|
+
* called after the ledger write and swallows its own errors.
|
|
116
|
+
*/
|
|
117
|
+
function mirrorToSharedStore(entries) {
|
|
118
|
+
for (const e of entries) {
|
|
119
|
+
try {
|
|
120
|
+
sessionStore.recordExchange(aegisDir(), {
|
|
121
|
+
sessionId: e.sessionId,
|
|
122
|
+
prompt: e.prompt,
|
|
123
|
+
reply: e.reply,
|
|
124
|
+
status: e.status,
|
|
125
|
+
cwd: e.cwd,
|
|
126
|
+
ts: e.ts,
|
|
127
|
+
tokens: e.tokens,
|
|
128
|
+
costUsd: e.costUsd,
|
|
129
|
+
origin: SOURCE,
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (process.env.AEGIS_HIST_DEBUG) console.error('[history] mirror failed:', err);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
93
137
|
function readEntries() {
|
|
94
138
|
try {
|
|
95
139
|
const raw = fs.readFileSync(historyPath(), 'utf8');
|
|
@@ -129,14 +173,36 @@ function readOwnSessions(limit = 8) {
|
|
|
129
173
|
return items;
|
|
130
174
|
}
|
|
131
175
|
|
|
132
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Rebuild the transcript (user/assistant pairs) of a session, oldest first.
|
|
178
|
+
*
|
|
179
|
+
* history.jsonl wins when it has records for the id (this host's own ledger,
|
|
180
|
+
* complete with the exchanges the shared store may have trimmed). Otherwise the
|
|
181
|
+
* session came from another host — a desktop thread — and the shared store is
|
|
182
|
+
* the only place its messages exist, so /resume can open it too.
|
|
183
|
+
*/
|
|
133
184
|
function readSessionTranscript(sessionId) {
|
|
134
|
-
|
|
185
|
+
const own = readEntries()
|
|
135
186
|
.filter((e) => e.sessionId === sessionId)
|
|
136
187
|
.flatMap((e) => [
|
|
137
188
|
{ role: 'user', text: e.prompt },
|
|
138
189
|
{ role: 'assistant', text: e.reply || '(no response)' },
|
|
139
190
|
]);
|
|
191
|
+
if (own.length) return own;
|
|
192
|
+
try {
|
|
193
|
+
return sessionStore.readTranscript(aegisDir(), sessionId);
|
|
194
|
+
} catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Sessions in the shared store written by another host (the desktop app). */
|
|
200
|
+
function readSharedStoreSessions(limit = 8) {
|
|
201
|
+
try {
|
|
202
|
+
return sessionStore.listSummaries(aegisDir(), limit);
|
|
203
|
+
} catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
140
206
|
}
|
|
141
207
|
|
|
142
208
|
/** All history records for one session, oldest first (power /cost). */
|
|
@@ -190,11 +256,22 @@ function aggregateSessionUsage(sessionId) {
|
|
|
190
256
|
}
|
|
191
257
|
|
|
192
258
|
/**
|
|
193
|
-
* Sessions for the /resume overlay: own Aegiscode sessions
|
|
194
|
-
*
|
|
259
|
+
* Sessions for the /resume overlay: own Aegiscode sessions, sessions written
|
|
260
|
+
* into the shared store by another host (the desktop app), and real Claude Code
|
|
261
|
+
* sessions from ~/.claude/history.jsonl — newest first, deduplicated by id.
|
|
262
|
+
*
|
|
263
|
+
* The dedup matters: an own session is mirrored into the shared store, so
|
|
264
|
+
* without it every terminal session would be listed twice, once from each
|
|
265
|
+
* source.
|
|
195
266
|
*/
|
|
196
267
|
function readResumeList(limit = 8, ownLimit = 5, claudeLimit = 8) {
|
|
197
268
|
const items = readOwnSessions(ownLimit);
|
|
269
|
+
const seen = new Set(items.map((i) => i.id));
|
|
270
|
+
const sharedItems = readSharedStoreSessions(ownLimit).filter((i) => {
|
|
271
|
+
if (seen.has(i.id)) return false;
|
|
272
|
+
seen.add(i.id);
|
|
273
|
+
return true;
|
|
274
|
+
});
|
|
198
275
|
const claudeItems = [];
|
|
199
276
|
const histPath = `${os.homedir()}/.claude/history.jsonl`;
|
|
200
277
|
try {
|
|
@@ -204,7 +281,8 @@ function readResumeList(limit = 8, ownLimit = 5, claudeLimit = 8) {
|
|
|
204
281
|
try {
|
|
205
282
|
const j = JSON.parse(l);
|
|
206
283
|
const meta = j.extra && JSON.parse(j.extra);
|
|
207
|
-
if (meta && meta.sessionId) {
|
|
284
|
+
if (meta && meta.sessionId && !seen.has(meta.sessionId)) {
|
|
285
|
+
seen.add(meta.sessionId);
|
|
208
286
|
const c = (j.cwd || '').split('/').filter(Boolean).pop() || '~';
|
|
209
287
|
const t = (j.summary || '').slice(0, 60);
|
|
210
288
|
claudeItems.push({ id: meta.sessionId, cwd: c, summary: t, time: j.timestamp, own: false });
|
|
@@ -212,13 +290,14 @@ function readResumeList(limit = 8, ownLimit = 5, claudeLimit = 8) {
|
|
|
212
290
|
} catch {}
|
|
213
291
|
}
|
|
214
292
|
} catch {}
|
|
215
|
-
return [...items, ...claudeItems]
|
|
293
|
+
return [...items, ...sharedItems, ...claudeItems]
|
|
216
294
|
.sort((a, b) => (String(a.time || '') < String(b.time || '') ? 1 : -1))
|
|
217
295
|
.slice(0, limit);
|
|
218
296
|
}
|
|
219
297
|
|
|
220
298
|
module.exports = {
|
|
221
299
|
HISTORY_LIMIT,
|
|
300
|
+
SOURCE,
|
|
222
301
|
historyPath,
|
|
223
302
|
ensureHistoryDir,
|
|
224
303
|
appendHistory,
|
|
@@ -226,6 +305,7 @@ module.exports = {
|
|
|
226
305
|
readHistoryEntries,
|
|
227
306
|
readOwnSessions,
|
|
228
307
|
readSessionTranscript,
|
|
308
|
+
readSharedStoreSessions,
|
|
229
309
|
sessionHistoryEntries,
|
|
230
310
|
pruneSessionHistory,
|
|
231
311
|
aggregateSessionUsage,
|
package/src/shared.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the CLI finds the modules it shares with the other hosts.
|
|
5
|
+
*
|
|
6
|
+
* `src/deps.js` does this for the transport/engine/registry trio, but it pulls
|
|
7
|
+
* the whole agent loop in at require time — far too heavy for `config.js` and
|
|
8
|
+
* `history.js`, which every command and every launch touches. This module
|
|
9
|
+
* resolves the two *pure* shared modules those files need, with the same
|
|
10
|
+
* two-layout rule and the same by-existence lookup:
|
|
11
|
+
*
|
|
12
|
+
* in-repo <repo>/cli/src/shared.js -> <repo>/client/*.js
|
|
13
|
+
* npm <pkg>/src/shared.js -> <pkg>/vendor/client/*.js
|
|
14
|
+
*
|
|
15
|
+
* A missing module is a loud error rather than a silent fallback to a second
|
|
16
|
+
* copy: a duplicate credential store or session store is exactly the drift this
|
|
17
|
+
* whole arrangement exists to prevent.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
function candidates(name) {
|
|
24
|
+
return [
|
|
25
|
+
path.join(__dirname, '..', '..', 'client', name), // repo checkout
|
|
26
|
+
path.join(__dirname, '..', 'vendor', 'client', name), // staged package
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveClientModule(name) {
|
|
31
|
+
const tried = candidates(name);
|
|
32
|
+
for (const candidate of tried) {
|
|
33
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
34
|
+
}
|
|
35
|
+
throw new Error(
|
|
36
|
+
`aegiscode: cannot find client/${name}. Expected it beside cli/ (in the repo) ` +
|
|
37
|
+
'or under cli/vendor/client/ (installed package). Reinstall the package, or ' +
|
|
38
|
+
'run `node scripts/predist.mjs` from cli/ if this is a source checkout.'
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const credentials = require(resolveClientModule('credentials.js'));
|
|
43
|
+
const sessionStore = require(resolveClientModule('session-store.js'));
|
|
44
|
+
|
|
45
|
+
module.exports = { credentials, sessionStore, resolveClientModule };
|