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 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 already keeps in `~/.aegiscode/history.jsonl`:
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`, with a transcript
220
- checkpoint alongside it, so `/resume`, `/cost`, `/clear` and `/rewind` all have
221
- something real to read. On exit the session prints how to come back to it.
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.0",
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",
@@ -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/history.jsonl'); c.render(); return true; }
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
- /** 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');
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() {
@@ -3,321 +3,28 @@
3
3
  /**
4
4
  * The AEGIS account credential store for the terminal host.
5
5
  *
6
- * Before this module the CLI had exactly one way to be given a key: an
7
- * `AEGIS_API_KEY` environment variable, checked as `process.env.AEGIS_API_KEY`
8
- * at five separate call sites. A shell export does not survive a new terminal,
9
- * a reboot, an SSH session or a desktop launcher, so every one of those turned
10
- * the product back into "no key export one" with no in-band way to fix it:
11
- * `/byok-set` stores *provider* keys server-side and `/login` is one of the
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
- * Resolution order, first hit wins:
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 `--key` keep working and
18
- * nothing written here can shadow an explicit export.
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, and
22
- * never deleted: it is another product's file and the user's key is in
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
- * The key is never printed in full by anything in this package; callers mask it
28
- * with format.js's maskKey.
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 fs = require('node:fs');
32
- const path = require('node:path');
33
- const { aegisDir, configPath } = require('./config.js');
28
+ const shared = require('./shared.js');
34
29
 
35
- const KEY_ENV = 'AEGIS_API_KEY';
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
- /** Rebuild the transcript (user/assistant pairs) of a session, oldest first. */
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
- return readEntries()
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 merged with real
194
- * Claude Code sessions from ~/.claude/history.jsonl, newest first.
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 };