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.
- package/lib/local/engine.js +169 -55
- package/lib/local/git-scope.js +71 -0
- package/lib/local/ollama.js +1 -1
- package/lib/local/providers.js +29 -4
- package/lib/local/turn-guard.js +297 -0
- package/lib/local/worktree-lock.js +232 -0
- package/package.json +3 -3
- package/renderer/app.js +144 -111
- package/renderer/budget.js +138 -0
- package/renderer/index.html +34 -29
- package/renderer/stream-policy.js +1 -1
- package/renderer/style.css +11 -9
- package/renderer/transcript-view.js +1 -1
- package/renderer/usage.js +1 -1
- package/renderer/max-tokens.js +0 -18
|
@@ -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.
|
|
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/
|
|
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:
|
|
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",
|