aegiscode 6.3.2 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/commands.js CHANGED
@@ -58,6 +58,9 @@ const { summarizeTranscript, recapLine } = require('./summarize.js');
58
58
  const { sessionAccounting, accountingFromUsage, estimateTokens } = require('./tokens.js');
59
59
  const { transcriptToMarkdown, transcriptToJSON, writeExportFile, lastAssistantText } = require('./export.js');
60
60
  const { detectDevCommand, runDevServer } = require('./devrun.js');
61
+ const credentials = require('./credentials.js');
62
+ const cloudsync = require('./cloudsync.js');
63
+ const { maskKey } = require('./format.js');
61
64
  const { openUrl, URLS } = require('./system.js');
62
65
  const { sniffProject, buildAegisMd } = require('./init.js');
63
66
  const {
@@ -127,8 +130,117 @@ async function loadModels(c) {
127
130
  * The ÆGIS LLM routes are gated on the pooled brain being reachable — exactly
128
131
  * as the reference hides its ÆGIS routes when the backend is absent. `hidden`
129
132
  * is a function so visibility follows the environment at call time.
133
+ *
134
+ * It reads the *resolved* credential, not the environment: a key saved by
135
+ * `aegiscode login` lives in credentials.json, and checking only
136
+ * `process.env.AEGIS_API_KEY` here hid every AEGIS route from a user who had
137
+ * just successfully set one.
138
+ */
139
+ const cloudReady = () => credentials.hasApiKey();
140
+
141
+ // ── account key + cloud sync handlers ────────────────────────────────────────
142
+
143
+ /** The account-key panel body, shared by /key, /login and /cloud. */
144
+ function keyPanelLines(c, title = 'AEGIS account key') {
145
+ const st = c.keyStatus();
146
+ const lines = [
147
+ [span(C.gold + BOLD, title), span(BOLD_OFF, '')],
148
+ [span(C.gray, '─'.repeat(34))],
149
+ [
150
+ span(st.configured ? C.green : C.coral, ` ${st.configured ? '✔' : '⚠'}`),
151
+ span(C.white, ' key: '),
152
+ span(st.configured ? C.white : C.gray, st.configured ? maskKey(st.key) : 'not set'),
153
+ span(C.gray, ` (${credentials.sourceLabel(st.source)})`),
154
+ ],
155
+ [span(C.gray, ` stored in: ${st.path}${st.fileMode ? ` ${st.fileMode}` : ''}`)],
156
+ ];
157
+ if (st.verifiedAt) lines.push([span(C.gray, ` verified: ${st.verifiedAt}`)]);
158
+ if (st.account && (st.account.plan || st.account.email)) {
159
+ lines.push([
160
+ span(C.gray, ' account: '),
161
+ span(C.white, [st.account.plan, st.account.email].filter(Boolean).join(' · ')),
162
+ ]);
163
+ }
164
+ lines.push([
165
+ span(C.gray, ' memory: '),
166
+ span(st.memoryToken ? C.green : C.gray, st.memoryToken ? 'token held (cloud sync ready)' : 'no token yet'),
167
+ ]);
168
+ if (!st.configured) {
169
+ lines.push([span(C.gray, '')]);
170
+ lines.push([span(C.white, ' Set one with /key <api_key>, or press Enter on /key to paste it.')]);
171
+ lines.push([span(C.gray, ' Free keys: https://aegiscloud.org')]);
172
+ }
173
+ if (st.legacyPlaintext) {
174
+ lines.push([span(C.gray, '')]);
175
+ lines.push([
176
+ span(C.coral, ' ⚠ a plaintext copy of the key is still in config.json (another AEGIS CLI wrote it).'),
177
+ ]);
178
+ lines.push([span(C.gray, ' Re-save with /key <api_key> and delete that block when convenient.')]);
179
+ }
180
+ return lines;
181
+ }
182
+
183
+ /**
184
+ * Take a key from the argument or an echo-off prompt, save it, and report what
185
+ * the account said. Shared by `/key`, `/login` and `/cloud key` so all three
186
+ * behave identically — the whole point is that there is one way to set a key.
130
187
  */
131
- const cloudReady = () => !!process.env.AEGIS_API_KEY;
188
+ async function applyAccountKey(c, raw, { label = 'AEGIS API key' } = {}) {
189
+ let key = String(raw || '').trim();
190
+ if (!key) {
191
+ key = await c.readSecret(`${label} (kept off screen, Enter to cancel): `);
192
+ }
193
+ if (!key) {
194
+ note(c, 'no key given — /key <api_key> takes it inline, or press Enter on a bare /key to paste it');
195
+ return { ok: false };
196
+ }
197
+ const res = await c.setApiKey(key);
198
+ if (!res.ok) {
199
+ note(c, res.message || 'that key could not be saved');
200
+ return res;
201
+ }
202
+ if (res.error) {
203
+ note(c, `saved, but the account check failed: ${res.error.message}`);
204
+ note(c, 'the key is stored — /key status shows it; re-run /key <api_key> if it was mistyped');
205
+ return res;
206
+ }
207
+ const acct = (res.account && (res.account.email || res.account.plan)) || null;
208
+ done(c, `AEGIS key saved to ${res.path}${acct ? ` — signed in as ${acct}` : ' and verified'}`);
209
+ // The catalog is key-gated, so a freshly authenticated session should offer
210
+ // /model's picker without the user having to know to re-run it.
211
+ try {
212
+ await c.loadModels({ force: true });
213
+ const n = (c.state().models || []).length;
214
+ if (n) note(c, `${n} pinnable model${n === 1 ? '' : 's'} available — /model to pin one`);
215
+ } catch {}
216
+ return res;
217
+ }
218
+
219
+ /** Push + pull once, and report the counts (or the quota refusal). */
220
+ async function runCloudSync(c, o = {}) {
221
+ if (!c.client.apiKey) {
222
+ note(c, `no key — ${credentials.HOW_TO_SET}`);
223
+ return null;
224
+ }
225
+ const res = await c.withWorking(() => cloudsync.syncNow(c.client, o));
226
+ const pushed = (res.push && res.push.pushed) || [];
227
+ const failed = [...((res.push && res.push.failed) || []), ...((res.pull && res.pull.failed) || [])];
228
+ const pulled = (res.pull && res.pull.imported) || 0;
229
+ if (pushed.length) {
230
+ done(c, `pushed ${pushed.length} session${pushed.length === 1 ? '' : 's'} (${pushed[0].messages} messages in the first)`);
231
+ } else if (res.push && res.push.skipped && res.push.skipped.length) {
232
+ note(c, `${res.push.skipped.length} session(s) had nothing to send`);
233
+ } else {
234
+ note(c, 'nothing to push — every local session is already in the cloud');
235
+ }
236
+ if (pulled) done(c, `pulled ${pulled} record${pulled === 1 ? '' : 's'} into the local store — /resume lists them`);
237
+ else if (res.pull && res.pull.ok) note(c, `cloud has ${res.pull.remote || 0} session(s); nothing newer locally`);
238
+ for (const f of failed) {
239
+ note(c, `${f.kind === 'quota' ? 'quota' : 'failed'}: ${f.message}`);
240
+ if (f.hint) note(c, f.hint);
241
+ }
242
+ return res;
243
+ }
132
244
 
133
245
  /** Read + merge hook config from the standard settings files. */
134
246
  function readHooks() {
@@ -467,15 +579,32 @@ const COMMANDS = [
467
579
  },
468
580
  },
469
581
  {
470
- name: 'login', hint: '', category: 'auth',
471
- desc: 'Sign in to Claude Code',
472
- unavailable: 'sign-in uses Claude Code auth, which this client does not have — it authenticates with your AEGIS key',
473
- alt: '/byok-set',
582
+ name: 'login', aliases: ['signin'], args: ['value'], hint: '[<api_key>]', category: 'auth',
583
+ desc: 'Save your AEGIS API key',
584
+ handler: async (c, args) => {
585
+ // This used to be `unavailable` with `/byok-set` as its alternative —
586
+ // which was worse than useless: /byok-set stores a *provider* key
587
+ // server-side, so a user following that advice pasted their AEGIS key
588
+ // into a BYOK provider slot. Signing in to AEGIS Cloud *is* handing over
589
+ // an API key, and this is now where you give it one.
590
+ const arg = String(args.value || args._rest || '').trim();
591
+ await applyAccountKey(c, arg, { label: 'AEGIS API key' });
592
+ c.render();
593
+ return true;
594
+ },
474
595
  },
475
596
  {
476
- name: 'logout', hint: '', category: 'auth',
477
- desc: 'Sign out',
478
- unavailable: 'there is no Claude Code sign-in here to end — aegiscode holds no session token of its own',
597
+ name: 'logout', args: [], hint: '', category: 'auth',
598
+ desc: 'Remove the saved AEGIS API key',
599
+ handler: async (c) => {
600
+ const res = c.forgetApiKey();
601
+ done(c, res.cleared ? `key removed from ${res.path}` : 'there was no saved key to remove');
602
+ if (credentials.legacyKeyOnDisk()) {
603
+ note(c, `a copy is still in ${configPath()} (another AEGIS CLI wrote it) — delete its "aegiscloud" block too`);
604
+ }
605
+ c.render();
606
+ return true;
607
+ },
479
608
  },
480
609
  {
481
610
  name: 'model', aliases: ['m'], args: ['sub'], hint: '[id|-]', category: 'model',
@@ -597,7 +726,7 @@ const COMMANDS = [
597
726
  desc: 'Switch to a previous session',
598
727
  handler: async (c) => {
599
728
  const items = readResumeList();
600
- 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; }
601
730
  c.openOverlay({ type: 'resume', items, sel: 0 });
602
731
  return true;
603
732
  },
@@ -709,8 +838,19 @@ const COMMANDS = [
709
838
  let git = false;
710
839
  try { execSync('git rev-parse --git-dir', { cwd: process.cwd(), stdio: ['ignore', 'ignore', 'ignore'], timeout: 4000 }); git = true; } catch {}
711
840
  ok('git repo', git, git ? 'inside a work tree' : 'not a git repository');
712
- const key = process.env.AEGIS_API_KEY || '';
713
- ok('AEGIS_API_KEY', !!key, key ? 'set' : 'not set — export one (https://aegiscloud.org)');
841
+ const ks = credentials.keyStatus();
842
+ ok(
843
+ 'AEGIS key',
844
+ ks.configured,
845
+ ks.configured
846
+ ? `${maskKey(ks.key)} via ${credentials.sourceLabel(ks.source)}`
847
+ : `not set — ${credentials.HOW_TO_SET} (free at https://aegiscloud.org)`
848
+ );
849
+ ok('credentials', true, `${ks.path}${ks.fileMode ? ` (${ks.fileMode})` : ''}`);
850
+ ok('cloud memory', ks.memoryToken, ks.memoryToken ? 'token held' : 'no memory token — /cloud activate');
851
+ if (ks.legacyPlaintext) {
852
+ ok('plaintext key', false, `a copy also sits in ${configPath()} (another AEGIS CLI) — /key re-saves it to ${ks.path}`);
853
+ }
714
854
  ok('config path', true, configPath());
715
855
  const lines = [[span(C.gold + BOLD, 'Doctor'), span(BOLD_OFF, '')], [span(C.gray, '─'.repeat(30))]];
716
856
  for (const [good, label, detail] of checks) {
@@ -1205,17 +1345,145 @@ const COMMANDS = [
1205
1345
  },
1206
1346
  },
1207
1347
  {
1208
- name: 'cloud', args: ['sub', 'value'], hint: '[status|key <api_key>|activate|deactivate]', category: 'support',
1209
- desc: 'Show ÆGIS cloud sync status',
1348
+ name: 'cloud', args: ['sub', 'value'], hint: '[status|key <api_key>|activate|deactivate|sync [on|off|now]]', category: 'support',
1349
+ desc: 'Show and control ÆGIS cloud sync',
1210
1350
  handler: async (c, args) => {
1211
1351
  const sub = (args.sub || '').toLowerCase();
1212
- if (['key', 'activate', 'deactivate'].includes(sub)) {
1213
- note(c, 'ÆGIS cloud key/sync is managed by the aegis CLI: run `aegis login` (free) — aegiscode does not store cloud keys.');
1352
+ const rest = (args.value || args._rest || '').trim();
1353
+
1354
+ if (sub === 'key') {
1355
+ await applyAccountKey(c, rest);
1214
1356
  c.render();
1215
1357
  return true;
1216
1358
  }
1217
- const st = c.state();
1218
- panel(c, panels.buildAegisStatus({ ...st, cloud: { key: st.online, sync: st.online } }, c.ctx));
1359
+
1360
+ if (sub === 'activate') {
1361
+ if (!c.client.apiKey) {
1362
+ note(c, `no key — ${credentials.HOW_TO_SET}`);
1363
+ c.render();
1364
+ return true;
1365
+ }
1366
+ try {
1367
+ const res = await c.withWorking(() => c.client.memoryActivate());
1368
+ const token = await c.client.getMemoryToken();
1369
+ if (token) credentials.saveMemoryToken(token, { memorySubscribed: true });
1370
+ done(c, res && res.ok === false ? 'the server refused activation' : 'cloud memory activated for this account');
1371
+ if (res && res.token_limit) note(c, `quota: ${res.tokens_used || 0} / ${res.token_limit} synced tokens`);
1372
+ } catch (e) {
1373
+ note(c, `activation failed: ${e.message}`);
1374
+ }
1375
+ c.render();
1376
+ return true;
1377
+ }
1378
+
1379
+ if (sub === 'deactivate') {
1380
+ // There is no /api/memory/deactivate on the server, so this clears the
1381
+ // credential this host holds rather than pretending to unenrol the
1382
+ // account. Saying which one it did is the difference between a control
1383
+ // and a lie.
1384
+ credentials.clearMemoryToken();
1385
+ c.client.setApiKey(c.client.apiKey);
1386
+ note(c, 'cloud memory token cleared locally — sync stops until /cloud activate re-issues one');
1387
+ note(c, 'the account itself is still subscribed; manage the subscription at https://aegiscloud.org/subscribe');
1388
+ c.render();
1389
+ return true;
1390
+ }
1391
+
1392
+ if (sub === 'sync' || sub === 'push' || sub === 'pull') {
1393
+ const mode = sub === 'sync' ? (rest || 'status').toLowerCase() : sub;
1394
+ if (mode === 'on' || mode === 'off') {
1395
+ const on = mode === 'on';
1396
+ c.setCloudSync(on);
1397
+ note(c, on ? 'cloud sync on — /sync now pushes after each turn' : 'cloud sync off — nothing is sent until /sync now');
1398
+ c.render();
1399
+ return true;
1400
+ }
1401
+ if (mode === 'now') {
1402
+ await runCloudSync(c);
1403
+ c.render();
1404
+ return true;
1405
+ }
1406
+ const st = cloudsync.status();
1407
+ const rows = [
1408
+ [span(C.gold + BOLD, 'Cloud sync'), span(BOLD_OFF, '')],
1409
+ [span(C.gray, '─'.repeat(34))],
1410
+ [span(C.white, ` ${c.cloudSyncEnabled() ? 'on' : 'off'}`), span(C.gray, c.cloudSyncEnabled() ? ' (auto-push each turn)' : ' (manual: /sync now)')],
1411
+ [span(C.gray, ` local sessions: ${st.local} · pending push: ${st.pending} · in sync: ${st.synced}`)],
1412
+ [span(C.gray, ` last push: ${st.lastPushAt ? new Date(st.lastPushAt).toLocaleString() : 'never'} · last pull: ${st.lastPullAt ? new Date(st.lastPullAt).toLocaleString() : 'never'}`)],
1413
+ [span(C.gray, ` state: ${st.path}`)],
1414
+ ];
1415
+ for (const e of st.errors.slice(0, 3)) {
1416
+ rows.push([span(C.coral, ` ⚠ ${e.error}`)]);
1417
+ }
1418
+ panel(c, rows);
1419
+ c.render();
1420
+ return true;
1421
+ }
1422
+
1423
+ // Default: the whole picture — key plus sync state.
1424
+ const st = cloudsync.status();
1425
+ panel(c, [
1426
+ ...keyPanelLines(c, 'AEGIS cloud'),
1427
+ [span(C.gray, '')],
1428
+ [span(C.white + BOLD, ' Sync'), span(BOLD_OFF, '')],
1429
+ [span(C.gray, ` ${c.cloudSyncEnabled() ? 'on' : 'off'} · ${st.pending} pending · ${st.synced} in sync · ${st.importedRemote} pulled from cloud`)],
1430
+ [span(C.gray, ' /cloud sync on | now | off')],
1431
+ ]);
1432
+ c.render();
1433
+ return true;
1434
+ },
1435
+ },
1436
+ {
1437
+ name: 'key', aliases: ['apikey', 'api-key'], args: ['value'], hint: '[<api_key>|status|clear]', category: 'auth',
1438
+ desc: 'Save or show the AEGIS account key',
1439
+ handler: async (c, args) => {
1440
+ const arg = String(args.value || args._rest || '').trim();
1441
+ const lower = arg.toLowerCase();
1442
+ if (lower === 'status') {
1443
+ panel(c, keyPanelLines(c));
1444
+ c.render();
1445
+ return true;
1446
+ }
1447
+ if (lower === 'clear' || lower === 'remove' || lower === 'rm') {
1448
+ const res = c.forgetApiKey();
1449
+ done(c, res.cleared ? `key removed from ${res.path}` : 'there was no saved key to remove');
1450
+ if (credentials.legacyKeyOnDisk()) {
1451
+ note(c, `a copy is still in ${configPath()} (written by another AEGIS CLI) — delete the "aegiscloud" block to finish the job`);
1452
+ }
1453
+ c.render();
1454
+ return true;
1455
+ }
1456
+ await applyAccountKey(c, arg);
1457
+ c.render();
1458
+ return true;
1459
+ },
1460
+ },
1461
+ {
1462
+ name: 'sync', aliases: ['cloudsync', 'cloud-sync'], args: ['mode'], hint: '[now|on|off|status]', category: 'data',
1463
+ desc: 'Sync conversations with AEGIS Cloud',
1464
+ hidden: (c) => !cloudReady(),
1465
+ handler: async (c, args) => {
1466
+ const mode = (args.mode || '').toLowerCase();
1467
+ if (mode === 'on' || mode === 'off') {
1468
+ c.setCloudSync(mode === 'on');
1469
+ note(c, mode === 'on' ? 'cloud sync on' : 'cloud sync off — /sync now still runs a manual pass');
1470
+ c.render();
1471
+ return true;
1472
+ }
1473
+ if (mode === 'status') {
1474
+ const st = cloudsync.status();
1475
+ panel(c, [
1476
+ [span(C.gold + BOLD, 'Cloud sync'), span(BOLD_OFF, '')],
1477
+ [span(C.gray, '─'.repeat(34))],
1478
+ [span(C.white, ` ${c.cloudSyncEnabled() ? 'on' : 'off'}`)],
1479
+ [span(C.gray, ` local ${st.local} · pending ${st.pending} · in sync ${st.synced}`)],
1480
+ ]);
1481
+ c.render();
1482
+ return true;
1483
+ }
1484
+ // Bare /sync and /sync now both do the thing: this is the command a user
1485
+ // reaches for when they want their sessions in the cloud right now.
1486
+ await runCloudSync(c);
1219
1487
  c.render();
1220
1488
  return true;
1221
1489
  },
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() {
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The AEGIS account credential store for the terminal host.
5
+ *
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".
12
+ *
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
+ *
17
+ * 1. `AEGIS_API_KEY` — the environment, so CI and an explicit export keep
18
+ * working and nothing written here can shadow them.
19
+ * 2. `credentials.json` in the data dir, mode 0600. The writable store.
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 or
22
+ * deleted; adopted into the 0600 store once so the next run reads that.
23
+ *
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.
26
+ */
27
+
28
+ const shared = require('./shared.js');
29
+
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');
@@ -35,7 +49,6 @@ function ensureHistoryDir() {
35
49
  */
36
50
  function appendHistory({ sessionId, prompt, reply, status, usage }) {
37
51
  try {
38
- ensureHistoryDir();
39
52
  const entry = {
40
53
  ts: new Date().toISOString(),
41
54
  sessionId,
@@ -54,14 +67,70 @@ function appendHistory({ sessionId, prompt, reply, status, usage }) {
54
67
  : { input: estimateTokens(prompt), output: estimateTokens(reply || ''), real: false },
55
68
  };
56
69
  if (usage && typeof usage.costUsd === 'number') entry.costUsd = usage.costUsd;
70
+ return appendHistoryEntries([entry]);
71
+ } catch (e) {
72
+ // Persistence is best-effort; never crash the session over it.
73
+ if (process.env.AEGIS_HIST_DEBUG) console.error('[history] write failed:', e);
74
+ return 0;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Append a batch of already-shaped entries in ONE read/trim/write pass.
80
+ *
81
+ * `appendHistory` re-reads and rewrites the whole file per exchange, which is
82
+ * fine for one turn at a time but quadratic for a caller with a list of them —
83
+ * importing 50 pulled sessions would rewrite the file 50 times, each pass
84
+ * re-parsing everything the previous pass just wrote. The single writer (and
85
+ * therefore the single place the file format is defined) stays here.
86
+ *
87
+ * @returns {number} entries written
88
+ */
89
+ function appendHistoryEntries(entries) {
90
+ const list = (Array.isArray(entries) ? entries : []).filter(Boolean);
91
+ if (!list.length) return 0;
92
+ try {
93
+ ensureHistoryDir();
57
94
  const p = historyPath();
58
95
  const prev = fs.existsSync(p) ? fs.readFileSync(p, 'utf8').split('\n').filter(Boolean) : [];
59
- const lines = [...prev, JSON.stringify(entry)];
96
+ const lines = [...prev, ...list.map((e) => JSON.stringify(e))];
60
97
  const trimmed = lines.slice(Math.max(0, lines.length - HISTORY_LIMIT));
61
98
  fs.writeFileSync(p, trimmed.join('\n') + '\n');
99
+ mirrorToSharedStore(list);
100
+ return list.length;
62
101
  } catch (e) {
63
102
  // Persistence is best-effort; never crash the session over it.
64
103
  if (process.env.AEGIS_HIST_DEBUG) console.error('[history] write failed:', e);
104
+ return 0;
105
+ }
106
+ }
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
+ }
65
134
  }
66
135
  }
67
136
 
@@ -79,6 +148,11 @@ function readEntries() {
79
148
  }
80
149
  }
81
150
 
151
+ /** Every history record, oldest first. Public name for other modules. */
152
+ function readHistoryEntries() {
153
+ return readEntries();
154
+ }
155
+
82
156
  /** Newest-first list of own sessions, one per distinct sessionId. */
83
157
  function readOwnSessions(limit = 8) {
84
158
  const entries = readEntries();
@@ -99,14 +173,36 @@ function readOwnSessions(limit = 8) {
99
173
  return items;
100
174
  }
101
175
 
102
- /** 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
+ */
103
184
  function readSessionTranscript(sessionId) {
104
- return readEntries()
185
+ const own = readEntries()
105
186
  .filter((e) => e.sessionId === sessionId)
106
187
  .flatMap((e) => [
107
188
  { role: 'user', text: e.prompt },
108
189
  { role: 'assistant', text: e.reply || '(no response)' },
109
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
+ }
110
206
  }
111
207
 
112
208
  /** All history records for one session, oldest first (power /cost). */
@@ -160,11 +256,22 @@ function aggregateSessionUsage(sessionId) {
160
256
  }
161
257
 
162
258
  /**
163
- * Sessions for the /resume overlay: own Aegiscode sessions merged with real
164
- * 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.
165
266
  */
166
267
  function readResumeList(limit = 8, ownLimit = 5, claudeLimit = 8) {
167
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
+ });
168
275
  const claudeItems = [];
169
276
  const histPath = `${os.homedir()}/.claude/history.jsonl`;
170
277
  try {
@@ -174,7 +281,8 @@ function readResumeList(limit = 8, ownLimit = 5, claudeLimit = 8) {
174
281
  try {
175
282
  const j = JSON.parse(l);
176
283
  const meta = j.extra && JSON.parse(j.extra);
177
- if (meta && meta.sessionId) {
284
+ if (meta && meta.sessionId && !seen.has(meta.sessionId)) {
285
+ seen.add(meta.sessionId);
178
286
  const c = (j.cwd || '').split('/').filter(Boolean).pop() || '~';
179
287
  const t = (j.summary || '').slice(0, 60);
180
288
  claudeItems.push({ id: meta.sessionId, cwd: c, summary: t, time: j.timestamp, own: false });
@@ -182,18 +290,22 @@ function readResumeList(limit = 8, ownLimit = 5, claudeLimit = 8) {
182
290
  } catch {}
183
291
  }
184
292
  } catch {}
185
- return [...items, ...claudeItems]
293
+ return [...items, ...sharedItems, ...claudeItems]
186
294
  .sort((a, b) => (String(a.time || '') < String(b.time || '') ? 1 : -1))
187
295
  .slice(0, limit);
188
296
  }
189
297
 
190
298
  module.exports = {
191
299
  HISTORY_LIMIT,
300
+ SOURCE,
192
301
  historyPath,
193
302
  ensureHistoryDir,
194
303
  appendHistory,
304
+ appendHistoryEntries,
305
+ readHistoryEntries,
195
306
  readOwnSessions,
196
307
  readSessionTranscript,
308
+ readSharedStoreSessions,
197
309
  sessionHistoryEntries,
198
310
  pruneSessionHistory,
199
311
  aggregateSessionUsage,