aegis-desktop 0.5.3 → 0.5.4

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.
@@ -0,0 +1,297 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Turn-start foreign-write detection.
5
+ *
6
+ * Ported from aegiscodex-dev/src/turn-guard.js (ESM → CommonJS).
7
+ *
8
+ * Bug this exists for: the commit-time scope check already knew how to tell
9
+ * "my changes" from "somebody else's in-flight work" — but only *after* a
10
+ * task had finished. The dangerous window is the opposite end: a session
11
+ * reads a file, time passes, a second agent edits that file, and the first
12
+ * session then acts on a stale mental model. That is how a live session
13
+ * destroyed a peer's work — the content it reverted was real and correct,
14
+ * just not the content it had read.
15
+ *
16
+ * `git checkout HEAD -- <file>`, `git reset --hard`, `git stash` and
17
+ * `git clean` are the destructive operations, and their default scope is the
18
+ * whole tree. Running any of them while a peer has uncommitted work is the
19
+ * entire incident.
20
+ *
21
+ * Fix: snapshot the dirty set at turn start, record the paths this session
22
+ * actually writes, then answer two questions on demand —
23
+ * `foreignSince()` / `concurrentSince()` whose work is in the tree
24
+ * `blocksDestructive()` would a given command clobber it
25
+ * The second is load-bearing: it turns "revert someone else's file" into a
26
+ * refusal, which is the cheapest place to stop it.
27
+ *
28
+ * This is detection, not prevention — it cannot stop a writer outside the
29
+ * host. Paired with worktree-lock.js it covers our own sessions.
30
+ */
31
+
32
+ const { gitRoot, gitStatusSnapshot } = require('./git-scope.js');
33
+
34
+ /** Git's own flags that consume the *next* argv entry, so that token is not
35
+ * the subcommand. Without this, `git -C /repo reset --hard` parses `reset`
36
+ * as a flag argument and the whole command reads as harmless. */
37
+ const GIT_FLAGS_WITH_ARG = new Set([
38
+ '-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path',
39
+ ]);
40
+
41
+ /** Split a command line into shell segments and then argv, stripping quotes.
42
+ * Segmenting on `&&`/`;`/`|` means `ls && git checkout -- a` is still caught,
43
+ * while `git stash list` is judged on its own segment. */
44
+ function tokenize(command) {
45
+ const out = [];
46
+ for (const segment of String(command || '').split(/[|;&\n]+/)) {
47
+ const argv = [];
48
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
49
+ let m;
50
+ while ((m = re.exec(segment))) argv.push(m[1] ?? m[2] ?? m[3]);
51
+ if (argv.length) out.push(argv);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** The git subcommand and its arguments in one argv, or null if this argv
57
+ * isn't a git invocation that has a subcommand. */
58
+ function gitInvocation(argv) {
59
+ const i = argv.findIndex((t) => t === 'git' || t.endsWith('/git'));
60
+ if (i === -1) return null;
61
+ let j = i + 1;
62
+ while (j < argv.length && argv[j].startsWith('-')) {
63
+ j += GIT_FLAGS_WITH_ARG.has(argv[j]) ? 2 : 1;
64
+ }
65
+ if (j >= argv.length) return null;
66
+ return { sub: argv[j], args: argv.slice(j + 1) };
67
+ }
68
+
69
+ /** Positional arguments (the paths a command names), dropping flags. Values
70
+ * after `--` are always paths; before it, anything not starting with `-` is
71
+ * taken as one, which is right for checkout/restore and harmlessly unused
72
+ * elsewhere. */
73
+ function namedPaths(args) {
74
+ const sep = args.indexOf('--');
75
+ if (sep !== -1) return args.slice(sep + 1).filter((a) => !a.startsWith('-'));
76
+ return args.filter((a) => !a.startsWith('-'));
77
+ }
78
+
79
+ const isTreeMark = (p) => p === '.' || p === './' || p === '*' || p === './*';
80
+ const hasForce = (args) => args.some((a) => a === '-f' || a === '--force' || /^-[a-zA-Z]*f/.test(a));
81
+
82
+ /** subcommand -> { what, scope } where scope is 'tree' (discards broadly) or
83
+ * 'paths' (discards only the paths it names). Returns null when harmless. */
84
+ function judge(sub, args) {
85
+ const paths = namedPaths(args);
86
+ const treeWide = paths.length === 0 || paths.some(isTreeMark);
87
+ switch (sub) {
88
+ case 'checkout':
89
+ if (args.includes('--') || hasForce(args)) {
90
+ return { what: 'checkout of uncommitted changes', scope: treeWide ? 'tree' : 'paths', paths };
91
+ }
92
+ // `git checkout .` restores the whole tree; a bare branch switch is safe.
93
+ if (treeWide && paths.length) return { what: 'checkout . (tree-wide restore)', scope: 'tree', paths };
94
+ return null;
95
+ case 'restore':
96
+ return { what: 'restore (discards uncommitted changes)', scope: treeWide ? 'tree' : 'paths', paths };
97
+ case 'reset':
98
+ return args.includes('--hard') ? { what: 'reset --hard', scope: 'tree', paths: [] } : null;
99
+ case 'clean':
100
+ return hasForce(args) ? { what: 'clean -f (deletes untracked files)', scope: 'tree', paths: [] } : null;
101
+ case 'stash': {
102
+ const first = args.find((a) => !a.startsWith('-'));
103
+ if (first === 'list' || first === 'show') return null;
104
+ // pop/apply can conflict with, and push parks, every path in the tree.
105
+ return { what: `stash ${first || 'push'} (uncommitted changes across the tree)`, scope: 'tree', paths: [] };
106
+ }
107
+ case 'worktree': {
108
+ const first = args.find((a) => !a.startsWith('-'));
109
+ return first === 'remove' || first === 'prune'
110
+ ? { what: `worktree ${first}`, scope: 'tree', paths: [] }
111
+ : null;
112
+ }
113
+ default:
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Classify a shell command line. Returns
120
+ * `{destructive:false}` or `{destructive:true, what, scope:'tree'|'paths', paths}`.
121
+ *
122
+ * Parsed as argv rather than matched as text, because a message *about* a
123
+ * destructive command is not one: `git commit -m "fix stash handling"` must
124
+ * not read as a stash. Quoting is handled, so the two are actually different.
125
+ */
126
+ function classifyDestructive(command) {
127
+ for (const argv of tokenize(command)) {
128
+ const inv = gitInvocation(argv);
129
+ if (!inv) continue;
130
+ const verdict = judge(inv.sub, inv.args);
131
+ if (verdict) return { destructive: true, ...verdict };
132
+ }
133
+ return { destructive: false };
134
+ }
135
+
136
+ /**
137
+ * Begin guarding a turn in `cwd`. Costs one `git status`; a non-repo (or a
138
+ * git failure) yields a guard that reports nothing, so callers never have to
139
+ * branch on whether the guard is armed.
140
+ */
141
+ function beginTurnGuard(cwd) {
142
+ const root = gitRoot(cwd);
143
+ const before = root ? gitStatusSnapshot(cwd) : null;
144
+ return {
145
+ root,
146
+ cwd,
147
+ before,
148
+ startedAt: Date.now(),
149
+ written: new Set(),
150
+ active: Boolean(root && before),
151
+ };
152
+ }
153
+
154
+ /** Normalise a written path to root-relative, so it can be compared against
155
+ * porcelain output. Accepts absolute or relative paths. */
156
+ function toRel(guard, p) {
157
+ let s = String(p || '');
158
+ if (!s) return null;
159
+ if (guard.root && s.startsWith(guard.root)) {
160
+ s = s.slice(guard.root.length).replace(/^[/\\]/, '');
161
+ }
162
+ return s.split('\\').join('/');
163
+ }
164
+
165
+ /**
166
+ * Record a path this session itself wrote. A recorded path is never reported
167
+ * as foreign — this is what keeps the guard from crying wolf about the
168
+ * caller's own edits, and it is why the host's file tools must call it.
169
+ */
170
+ function recordWrite(guard, p) {
171
+ if (!guard || !guard.active) return;
172
+ const rel = toRel(guard, p);
173
+ if (rel) guard.written.add(rel);
174
+ }
175
+
176
+ /**
177
+ * Paths that are *unambiguously* a peer's in-flight work: dirty when the turn
178
+ * started, byte-identical now, and never written by this session.
179
+ *
180
+ * "Dirty before and still identical" is what makes this confident — a path
181
+ * nobody touched is dirty *because* it belongs to someone else. It is
182
+ * git-scope's own definition, which is why the two modules agree.
183
+ */
184
+ function foreignSince(guard) {
185
+ if (!guard || !guard.active) return [];
186
+ const now = gitStatusSnapshot(guard.cwd);
187
+ if (!now) return [];
188
+ const out = [];
189
+ for (const [p, hash] of now) {
190
+ if (guard.written.has(p)) continue;
191
+ if (guard.before.has(p) && guard.before.get(p) === hash) out.push(p);
192
+ }
193
+ return out.sort();
194
+ }
195
+
196
+ /**
197
+ * Paths clean at turn start and dirty now, excluding any this session
198
+ * recorded writing.
199
+ *
200
+ * Weaker than `foreignSince` because attribution is incomplete: the host's
201
+ * file tools call `recordWrite`, but a path a shell command creates
202
+ * (`> out.txt`, `npm install`) is invisible here and can be mistaken for a
203
+ * peer's. Still reported, and still consulted by `blocksDestructive` — see
204
+ * the asymmetry note there.
205
+ */
206
+ function concurrentSince(guard) {
207
+ if (!guard || !guard.active) return [];
208
+ const now = gitStatusSnapshot(guard.cwd);
209
+ if (!now) return [];
210
+ const out = [];
211
+ for (const [p] of now) {
212
+ if (guard.written.has(p)) continue;
213
+ if (!guard.before.has(p)) out.push(p);
214
+ }
215
+ return out.sort();
216
+ }
217
+
218
+ /**
219
+ * Would running `command` be unsafe right now? Returns `{ok:true}` or
220
+ * `{ok:false, what, scope, reason, foreign, unattributed}`.
221
+ *
222
+ * Scope decides what has to be at risk:
223
+ * - a *tree-wide* op (`git reset --hard`, `git stash`, `git checkout .`) is
224
+ * refused whenever any peer work is present, because it cannot help but
225
+ * touch it;
226
+ * - a *path-scoped* op is refused only when one of the paths it names is
227
+ * actually foreign, so reverting your own file still works while a peer
228
+ * happens to be editing a different one.
229
+ *
230
+ * Both signals count, because the risk is asymmetric: a refusal costs the
231
+ * caller one step (name the paths, or take the lock), while a miss destroys
232
+ * work that may exist nowhere else. In the incident this module exists for,
233
+ * the peer's files appeared *during* the turn, so the confident signal was
234
+ * empty — blocking on it alone would have missed the exact case that
235
+ * motivated the guard.
236
+ *
237
+ * A tree with nothing foreign is never blocked, so a solo session is never
238
+ * wedged: the module is inert unless somebody else's work is present.
239
+ */
240
+ function blocksDestructive(guard, command) {
241
+ const verdict = classifyDestructive(command);
242
+ if (!verdict.destructive) return { ok: true };
243
+ if (!guard || !guard.active) return { ok: true };
244
+
245
+ let foreign = foreignSince(guard);
246
+ let unattributed = concurrentSince(guard).filter((p) => !foreign.includes(p));
247
+
248
+ let atRisk = [];
249
+ if (verdict.scope === 'tree') {
250
+ atRisk = [...foreign, ...unattributed];
251
+ } else {
252
+ const named = new Set(verdict.paths.map((p) => p.replace(/^\.\//, '')));
253
+ const hits = (list) => list.filter((p) => named.has(p) || named.has('./' + p));
254
+ foreign = hits(foreign);
255
+ unattributed = hits(unattributed);
256
+ atRisk = [...foreign, ...unattributed];
257
+ }
258
+ if (!atRisk.length) return { ok: true, what: verdict.what };
259
+
260
+ const parts = [];
261
+ if (foreign.length) {
262
+ parts.push(
263
+ `${foreign.length} hold another agent's uncommitted work: `
264
+ + foreign.slice(0, 8).join(', ') + (foreign.length > 8 ? ` (+${foreign.length - 8} more)` : ''),
265
+ );
266
+ }
267
+ if (unattributed.length) {
268
+ parts.push(
269
+ `${unattributed.length} changed since this turn began and were not written by `
270
+ + `this session: ${unattributed.slice(0, 8).join(', ')}`
271
+ + (unattributed.length > 8 ? ` (+${unattributed.length - 8} more)` : ''),
272
+ );
273
+ }
274
+ return {
275
+ ok: false,
276
+ what: verdict.what,
277
+ scope: verdict.scope,
278
+ foreign,
279
+ unattributed,
280
+ reason:
281
+ `refusing \`${verdict.what}\` in ${guard.root}: `
282
+ + (verdict.scope === 'tree'
283
+ ? 'it is tree-scoped and would discard work it did not create — '
284
+ : 'it targets a path that is not yours to discard — ')
285
+ + parts.join('; ')
286
+ + '. Name only the paths you mean, or take the worktree lock first.',
287
+ };
288
+ }
289
+
290
+ module.exports = {
291
+ classifyDestructive,
292
+ beginTurnGuard,
293
+ recordWrite,
294
+ foreignSince,
295
+ concurrentSince,
296
+ blocksDestructive,
297
+ };
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cross-process lock on a git working tree.
5
+ *
6
+ * Ported from aegiscodex-dev/src/worktree-lock.js (ESM → CommonJS).
7
+ *
8
+ * Bug this exists for: nothing keyed on *the working tree*. A session-scoped
9
+ * lock only stops two processes resuming the SAME transcript, so three
10
+ * different sessions in one checkout are three different ids and therefore
11
+ * all legal. A queue-worker lock only serialises queue drains. An interactive
12
+ * session took no lock at all.
13
+ *
14
+ * So N agents editing one checkout was, by every existing lock, correct
15
+ * behaviour — and it is destructive. It happened for real: one session ran
16
+ * `git checkout HEAD --` against a file a second session was mid-edit on
17
+ * (destroying it), a third ran `git stash` and parked everybody's tree, and a
18
+ * test run on that shared tree reported failures belonging to a half-built
19
+ * feature that was not the caller's. Git has no advisory locking, so none of
20
+ * it produced an error — just unexplained file changes, which is exactly the
21
+ * kind of evidence that gets misread as "my tool lied to me".
22
+ *
23
+ * Fix: a lock keyed on the repository root — the resource actually contended
24
+ * — taken by every host that runs a turn (the desktop app and the CLI both
25
+ * reach this file through desktop/lib/local/engine.js). Same atomic-marker +
26
+ * PID-liveness pattern as the session lock, so a crashed holder is reclaimed
27
+ * with no staleness window to tune and no manual cleanup.
28
+ *
29
+ * This lock is advisory and cooperative: it cannot stop a writer that does
30
+ * not take it (a plain `git` in a terminal, another harness, an editor). Its
31
+ * value is that our own hosts stop racing each other, and that a caller
32
+ * receiving `null` knows to back off instead of writing blind.
33
+ *
34
+ * The stronger fix is one worktree per agent (`git worktree add`), which
35
+ * removes the shared mutable tree entirely. This lock is what makes the
36
+ * single-tree case safe when that is not available.
37
+ */
38
+
39
+ const fs = require('node:fs');
40
+ const path = require('node:path');
41
+ const os = require('node:os');
42
+ const crypto = require('node:crypto');
43
+
44
+ /**
45
+ * Where lock files live.
46
+ *
47
+ * The plugin's data dir is ~/.aegiscode ($AEGISCODE_HOME), per config.js's
48
+ * `aegisDir()` convention — but the dev engine's ~/.aegiscodex is honoured
49
+ * too, and either `*_WORKTREE_LOCK_DIR` override points every host at ONE
50
+ * directory. That override is the interlock: two separate tools disagreeing
51
+ * about where the lock file goes is the same as having no lock, so operators
52
+ * running the GUI, the CLI and the dev engine against one checkout should set
53
+ * AEGISCODE_WORKTREE_LOCK_DIR (or AEGISCODEX_WORKTREE_LOCK_DIR) to a shared
54
+ * path. Within this repo the desktop and CLI hosts always agree, because both
55
+ * resolve this same helper.
56
+ */
57
+ function worktreeLockDir() {
58
+ if (process.env.AEGISCODE_WORKTREE_LOCK_DIR) return process.env.AEGISCODE_WORKTREE_LOCK_DIR;
59
+ if (process.env.AEGISCODEX_WORKTREE_LOCK_DIR) return process.env.AEGISCODEX_WORKTREE_LOCK_DIR;
60
+ const home = process.env.AEGISCODE_HOME
61
+ || process.env.AEGISCODEX_HOME
62
+ || path.join(os.homedir(), '.aegiscode');
63
+ return path.join(home, 'worktree-locks');
64
+ }
65
+
66
+ /** Default wait: a live holder is bounded by its own turn watchdog, so
67
+ * waiting slightly past that lets a legitimate queued turn run once the
68
+ * holder's deadline fires, rather than failing right as the tree is about to
69
+ * free up. */
70
+ const DEFAULT_WORKTREE_WAIT_MS = 11 * 60 * 1000;
71
+
72
+ /** Sentinel token for the "not in a repo" no-op path — always truthy, never
73
+ * matches a real acquisition, so a stray release() can't release anything. */
74
+ const NOOP_TOKEN = '(no-worktree)';
75
+
76
+ /**
77
+ * Canonicalise the root so two paths to one repo — a symlink, a trailing
78
+ * slash, a relative path, `/tmp/../repo` — agree on a single lock file.
79
+ * Falls back to the resolved path when realpath fails.
80
+ */
81
+ function canonical(root) {
82
+ if (!root) return null;
83
+ const abs = path.resolve(String(root));
84
+ try { return fs.realpathSync(abs); } catch { return abs; }
85
+ }
86
+
87
+ /** The lock file for `root`. The path is hashed so an arbitrarily long or
88
+ * awkward root maps to one flat, safe filename; the root itself is written
89
+ * inside the file for humans reading the lock dir. */
90
+ function worktreeLockPath(root) {
91
+ if (!root) return null;
92
+ const key = crypto.createHash('sha1').update(canonical(root)).digest('hex').slice(0, 16);
93
+ return path.join(worktreeLockDir(), key + '.lock');
94
+ }
95
+
96
+ function pidAlive(pid) {
97
+ try { process.kill(pid, 0); return true; } catch { return false; }
98
+ }
99
+
100
+ function readHolder(root) {
101
+ try { return JSON.parse(fs.readFileSync(worktreeLockPath(root), 'utf8')); } catch { return null; }
102
+ }
103
+
104
+ /**
105
+ * The current holder of `root`'s lock, or null. Never throws and never
106
+ * clears a stale lock — use `isWorktreeLocked` for the reclaiming check.
107
+ */
108
+ function worktreeLockHolder(root) {
109
+ if (!root) return null;
110
+ const h = readHolder(root);
111
+ if (!h) return null;
112
+ return {
113
+ pid: h.pid, token: h.token, since: h.since,
114
+ root: h.root || canonical(root), session: h.session || null,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * True if `root` is held by a live process (any process, including this one
120
+ * — two concurrent callers inside one process must still queue behind each
121
+ * other). A lock file whose PID is dead or unparsable is stale and cleared as
122
+ * a side effect, so callers never special-case it.
123
+ */
124
+ function isWorktreeLocked(root) {
125
+ if (!root) return false;
126
+ const holder = readHolder(root);
127
+ // `Number(null)` is 0 and `process.kill(0, 0)` targets the caller's process
128
+ // GROUP, so it succeeds — a missing lock file would otherwise read as a live
129
+ // holder forever. Require a real pid. (The session lock has the same latent
130
+ // trap; it never surfaced there only because it is called after EEXIST.)
131
+ const pid = Number(holder && holder.pid);
132
+ if (!Number.isInteger(pid) || pid <= 0 || !pidAlive(pid)) {
133
+ try { fs.unlinkSync(worktreeLockPath(root)); } catch {}
134
+ return false;
135
+ }
136
+ return true;
137
+ }
138
+
139
+ /** Exclusive-create the lock file for `token`. Returns true on success.
140
+ * Best-effort: a data-dir failure other than "already exists" fails OPEN so a
141
+ * broken filesystem can never block a turn that would otherwise run fine. */
142
+ function tryAcquire(root, token, meta) {
143
+ try {
144
+ fs.mkdirSync(worktreeLockDir(), { recursive: true });
145
+ const fd = fs.openSync(worktreeLockPath(root), 'wx');
146
+ fs.writeFileSync(fd, JSON.stringify({
147
+ pid: process.pid,
148
+ token,
149
+ since: Date.now(),
150
+ root: canonical(root),
151
+ session: (meta && meta.session) || null,
152
+ }));
153
+ fs.closeSync(fd);
154
+ return true;
155
+ } catch (e) {
156
+ if (e && e.code === 'EEXIST') return false;
157
+ return true;
158
+ }
159
+ }
160
+
161
+ /** Release `root`'s lock — only if `token` matches the current holder, so
162
+ * releasing a lock you don't (or no longer) own can never clobber whoever
163
+ * holds it now. No-op on a falsy root/token or any filesystem error
164
+ * (best-effort, like the rest of this repo's persistence helpers). */
165
+ function releaseWorktreeLock(root, token) {
166
+ if (!root || !token || token === NOOP_TOKEN) return;
167
+ const holder = readHolder(root);
168
+ if (holder && holder.token === token) {
169
+ try { fs.unlinkSync(worktreeLockPath(root)); } catch {}
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Acquire `root`'s lock, waiting out a live holder instead of racing it.
175
+ * Resolves a truthy opaque token once acquired — pass it back to
176
+ * `releaseWorktreeLock` — or `null` if `waitMs` elapses or `signal` aborts
177
+ * first; either way the caller must not write. A falsy `root` (not inside a
178
+ * git work tree — nothing shared to collide over) resolves the sentinel
179
+ * immediately with no file touched.
180
+ */
181
+ async function acquireWorktreeLock(root, {
182
+ waitMs = DEFAULT_WORKTREE_WAIT_MS, pollMs = 200, signal, session = null,
183
+ } = {}) {
184
+ if (!root) return NOOP_TOKEN;
185
+ const token = crypto.randomUUID();
186
+ const deadline = Date.now() + waitMs;
187
+ for (;;) {
188
+ if (signal && signal.aborted) return null;
189
+ if (tryAcquire(root, token, { session })) return token;
190
+ // Was stale, just reclaimed by clearing — retry now.
191
+ if (!isWorktreeLocked(root)) continue;
192
+ if (Date.now() >= deadline) return null;
193
+ await new Promise((r) => setTimeout(r, pollMs));
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Run `fn` holding `root`'s lock, releasing it on every exit path. Throws
199
+ * `WorktreeBusyError` (rather than running unprotected) if the lock can't be
200
+ * taken, because proceeding without it is the bug this module exists to
201
+ * prevent.
202
+ */
203
+ class WorktreeBusyError extends Error {
204
+ constructor(root, holder) {
205
+ super(`working tree ${root} is locked by pid ${holder ? holder.pid : '?'}`);
206
+ this.name = 'WorktreeBusyError';
207
+ this.root = root;
208
+ this.holder = holder || null;
209
+ }
210
+ }
211
+
212
+ async function withWorktreeLock(root, opts, fn) {
213
+ const token = await acquireWorktreeLock(root, opts);
214
+ if (!token) throw new WorktreeBusyError(root, worktreeLockHolder(root));
215
+ try {
216
+ return await fn();
217
+ } finally {
218
+ releaseWorktreeLock(root, token);
219
+ }
220
+ }
221
+
222
+ module.exports = {
223
+ DEFAULT_WORKTREE_WAIT_MS,
224
+ worktreeLockDir,
225
+ worktreeLockPath,
226
+ worktreeLockHolder,
227
+ isWorktreeLocked,
228
+ releaseWorktreeLock,
229
+ acquireWorktreeLock,
230
+ WorktreeBusyError,
231
+ withWorktreeLock,
232
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.5.3",
4
+ "version": "0.5.4",
5
5
  "description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
@@ -29,10 +29,10 @@
29
29
  "icon": "node scripts/generate-icon.mjs",
30
30
  "dist": "npm run predist && electron-builder",
31
31
  "dist:dir": "npm run predist && electron-builder --dir",
32
- "check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/max-tokens.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
32
+ "check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/budget.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
33
33
  "test:shell": "node ../test/desktop-shell.mjs",
34
34
  "test:model": "node ../test/model-dispatch.mjs",
35
- "test:max-tokens": "node ../test/max-tokens.test.mjs",
35
+ "test:budget": "node ../test/budget.test.mjs",
36
36
  "test:foreign-memory": "node ../test/foreign-memory.test.mjs",
37
37
  "test:tools": "node ../test/local-tools.test.mjs",
38
38
  "test:engine": "node ../test/local-engine.test.mjs",