aegis-desktop 0.3.0 → 0.4.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/README.md +61 -0
- package/lib/deep-link.js +83 -0
- package/lib/local/engine.js +398 -29
- package/lib/local/tools.js +257 -13
- package/lib/quick-launcher.js +81 -0
- package/lib/settings.js +76 -0
- package/lib/sync/sessions.js +27 -0
- package/lib/window-state.js +63 -0
- package/main.js +971 -32
- package/package.json +8 -4
- package/preload.js +121 -0
- package/renderer/app.js +533 -16
- package/renderer/index.html +49 -0
- package/renderer/markdown.js +165 -0
- package/renderer/quick.css +126 -0
- package/renderer/quick.html +39 -0
- package/renderer/quick.js +188 -0
- package/renderer/style.css +341 -0
- package/renderer/vendor/NOTICE.md +20 -0
- package/renderer/vendor/aegis-highlight.js +135 -0
- package/renderer/vendor/marked.umd.js +80 -0
- package/renderer/vendor/purify.min.js +3 -0
- package/vendor/aegis.js +34 -4
package/lib/local/tools.js
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
const { spawn } = require('node:child_process');
|
|
42
|
+
const crypto = require('node:crypto');
|
|
42
43
|
const fs = require('node:fs');
|
|
43
44
|
const path = require('node:path');
|
|
44
45
|
const { agentRoles } = require('./agents.js');
|
|
@@ -53,6 +54,11 @@ const GREP_SIZE_CAP = 10 * 1024 * 1024; // grep silently skips files above this
|
|
|
53
54
|
const EXEC_TIMEOUT_DEFAULT = 120_000;
|
|
54
55
|
const EXEC_TIMEOUT_CAP = 600_000; // 10 minutes, matches the CLI's cap
|
|
55
56
|
const EXEC_MAX_BUFFER = 1_048_576; // 1 MB of combined stdout+stderr
|
|
57
|
+
// The LCS diff below is O(lines_before * lines_after); past this many cells
|
|
58
|
+
// (or this many lines on either side) the diff is skipped in favor of a
|
|
59
|
+
// one-line summary rather than freezing the approval flow on a huge file.
|
|
60
|
+
const DIFF_MAX_CELLS = 4_000_000;
|
|
61
|
+
const DIFF_MAX_LINES = 20_000;
|
|
56
62
|
|
|
57
63
|
/** Truncate oversized tool output (the model never needs the whole log). */
|
|
58
64
|
function cap(s) {
|
|
@@ -332,28 +338,260 @@ function writeFile({ file_path, content } = {}) {
|
|
|
332
338
|
}
|
|
333
339
|
}
|
|
334
340
|
|
|
335
|
-
function editFile(
|
|
341
|
+
function editFile(args = {}) {
|
|
342
|
+
try {
|
|
343
|
+
const preview = previewEditFile(args);
|
|
344
|
+
if (!preview.ok) return fail(preview.error);
|
|
345
|
+
fs.writeFileSync(args.file_path, preview.after, 'utf8');
|
|
346
|
+
return ok(`Edited ${args.file_path} (${preview.count} occurrence${preview.count > 1 ? 's' : ''} replaced)`);
|
|
347
|
+
} catch (e) {
|
|
348
|
+
return fail(e && e.message ? e.message : String(e));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── Approval-gate helpers ───────────────────────────────────────────────────
|
|
353
|
+
//
|
|
354
|
+
// The renderer approval gate (desktop/lib/local/engine.js gatedExecuteTool)
|
|
355
|
+
// needs to show the user a diff BEFORE a mutating call runs, then re-verify
|
|
356
|
+
// the file hasn't moved out from under it before actually writing. Everything
|
|
357
|
+
// below is pure preparation: it never writes to disk on its own except
|
|
358
|
+
// applyWriteChecked/applyEditChecked, which are the only functions engine.js
|
|
359
|
+
// calls once the user has approved.
|
|
360
|
+
|
|
361
|
+
/** exec/writeFile/editFile change machine state; listDir/glob/grep/readFile
|
|
362
|
+
* never do — this is the set the approval gate checks against. */
|
|
363
|
+
const MUTATING_TOOLS = new Set(['exec', 'writeFile', 'editFile']);
|
|
364
|
+
|
|
365
|
+
function sha256(text) {
|
|
366
|
+
return crypto.createHash('sha256').update(text == null ? '' : text, 'utf8').digest('hex');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Read a file for a preview/hash snapshot. Distinguishes "doesn't exist yet"
|
|
370
|
+
* (ok, exists:false) from a real read failure (permissions, is a directory —
|
|
371
|
+
* ok:false), so callers can tell a brand-new file from a broken path. */
|
|
372
|
+
function readForPreview(file_path) {
|
|
373
|
+
try {
|
|
374
|
+
if (!fs.existsSync(file_path)) return { ok: true, exists: false, content: null };
|
|
375
|
+
if (fs.statSync(file_path).isDirectory()) {
|
|
376
|
+
return { ok: false, error: `${file_path} is a directory` };
|
|
377
|
+
}
|
|
378
|
+
return { ok: true, exists: true, content: fs.readFileSync(file_path, 'utf8') };
|
|
379
|
+
} catch (e) {
|
|
380
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Classic LCS line diff: returns the {type:'equal'|'delete'|'insert', a, b}
|
|
385
|
+
* op list turning array `a` into array `b`, indices into each array. */
|
|
386
|
+
function diffLines(a, b) {
|
|
387
|
+
const n = a.length;
|
|
388
|
+
const m = b.length;
|
|
389
|
+
const dp = new Array(n + 1);
|
|
390
|
+
for (let i = 0; i <= n; i++) dp[i] = new Int32Array(m + 1);
|
|
391
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
392
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
393
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const ops = [];
|
|
397
|
+
let i = 0;
|
|
398
|
+
let j = 0;
|
|
399
|
+
while (i < n && j < m) {
|
|
400
|
+
if (a[i] === b[j]) {
|
|
401
|
+
ops.push({ type: 'equal', a: i, b: j });
|
|
402
|
+
i++;
|
|
403
|
+
j++;
|
|
404
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
405
|
+
ops.push({ type: 'delete', a: i });
|
|
406
|
+
i++;
|
|
407
|
+
} else {
|
|
408
|
+
ops.push({ type: 'insert', b: j });
|
|
409
|
+
j++;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
while (i < n) ops.push({ type: 'delete', a: i++ });
|
|
413
|
+
while (j < m) ops.push({ type: 'insert', b: j++ });
|
|
414
|
+
return ops;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Group an op list into unified-diff hunks (git diff -Ucontext style) and
|
|
418
|
+
* render them as text with a/b line numbers in the @@ headers. */
|
|
419
|
+
function formatUnifiedDiff(ops, a, b, { label, context }) {
|
|
420
|
+
const n = ops.length;
|
|
421
|
+
const keep = new Array(n).fill(false);
|
|
422
|
+
for (let i = 0; i < n; i++) {
|
|
423
|
+
if (ops[i].type === 'equal') continue;
|
|
424
|
+
keep[i] = true;
|
|
425
|
+
for (let k = 1; k <= context; k++) {
|
|
426
|
+
if (i - k >= 0) keep[i - k] = true;
|
|
427
|
+
if (i + k < n) keep[i + k] = true;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const hunkRanges = [];
|
|
431
|
+
let start = -1;
|
|
432
|
+
for (let i = 0; i <= n; i++) {
|
|
433
|
+
if (i < n && keep[i]) {
|
|
434
|
+
if (start === -1) start = i;
|
|
435
|
+
} else if (start !== -1) {
|
|
436
|
+
hunkRanges.push([start, i - 1]);
|
|
437
|
+
start = -1;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (!hunkRanges.length) return `--- ${label}\n+++ ${label}\n(no changes)`;
|
|
441
|
+
|
|
442
|
+
const out = [`--- ${label}`, `+++ ${label}`];
|
|
443
|
+
for (const [s, e] of hunkRanges) {
|
|
444
|
+
let aLine = 1;
|
|
445
|
+
let bLine = 1;
|
|
446
|
+
for (let i = 0; i < s; i++) {
|
|
447
|
+
if (ops[i].type !== 'insert') aLine++;
|
|
448
|
+
if (ops[i].type !== 'delete') bLine++;
|
|
449
|
+
}
|
|
450
|
+
let aCount = 0;
|
|
451
|
+
let bCount = 0;
|
|
452
|
+
const body = [];
|
|
453
|
+
for (let i = s; i <= e; i++) {
|
|
454
|
+
const op = ops[i];
|
|
455
|
+
if (op.type === 'equal') {
|
|
456
|
+
body.push(` ${a[op.a]}`);
|
|
457
|
+
aCount++;
|
|
458
|
+
bCount++;
|
|
459
|
+
} else if (op.type === 'delete') {
|
|
460
|
+
body.push(`-${a[op.a]}`);
|
|
461
|
+
aCount++;
|
|
462
|
+
} else {
|
|
463
|
+
body.push(`+${b[op.b]}`);
|
|
464
|
+
bCount++;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
out.push(`@@ -${aLine},${aCount} +${bLine},${bCount} @@`);
|
|
468
|
+
out.push(...body);
|
|
469
|
+
}
|
|
470
|
+
return out.join('\n');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Unified diff between two whole-file strings (`before` may be null — a new
|
|
474
|
+
* file). Falls back to a one-line summary for files too large to diff cheaply. */
|
|
475
|
+
function unifiedDiff(before, after, { label = 'file', context = 3 } = {}) {
|
|
476
|
+
const a = before == null ? [] : String(before).split('\n');
|
|
477
|
+
const b = after == null ? [] : String(after).split('\n');
|
|
478
|
+
if (a.length > DIFF_MAX_LINES || b.length > DIFF_MAX_LINES || a.length * b.length > DIFF_MAX_CELLS) {
|
|
479
|
+
const added = Math.max(0, b.length - a.length);
|
|
480
|
+
const removed = Math.max(0, a.length - b.length);
|
|
481
|
+
return `--- ${label}\n+++ ${label}\n@@ file too large to preview — approx +${added}/-${removed} lines @@`;
|
|
482
|
+
}
|
|
483
|
+
return formatUnifiedDiff(diffLines(a, b), a, b, { label, context });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Build the {before, after, diff, hash} preview for a writeFile call without
|
|
487
|
+
* touching disk. `hash` is the sha256 of the CURRENT on-disk content (null
|
|
488
|
+
* for a not-yet-existing file) — the snapshot applyWriteChecked re-verifies
|
|
489
|
+
* against before actually writing. */
|
|
490
|
+
function previewWriteFile({ file_path, content } = {}) {
|
|
491
|
+
if (!file_path) return { ok: false, error: 'file_path is required' };
|
|
492
|
+
const read = readForPreview(file_path);
|
|
493
|
+
if (!read.ok) return { ok: false, error: read.error };
|
|
494
|
+
const before = read.exists ? read.content : null;
|
|
495
|
+
const after = String(content == null ? '' : content);
|
|
496
|
+
return {
|
|
497
|
+
ok: true,
|
|
498
|
+
before,
|
|
499
|
+
after,
|
|
500
|
+
diff: unifiedDiff(before, after, { label: file_path }),
|
|
501
|
+
hash: before == null ? null : sha256(before),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Same shape as previewWriteFile, for editFile — shares its validation with
|
|
506
|
+
* the executor above so there is exactly one place that knows how to apply
|
|
507
|
+
* an edit. */
|
|
508
|
+
function previewEditFile({ file_path, old_string, new_string, replace_all } = {}) {
|
|
509
|
+
if (!file_path) return { ok: false, error: 'file_path is required' };
|
|
510
|
+
if (old_string === undefined || old_string === '') {
|
|
511
|
+
return { ok: false, error: 'old_string is required and must be non-empty' };
|
|
512
|
+
}
|
|
513
|
+
const read = readForPreview(file_path);
|
|
514
|
+
if (!read.ok) return { ok: false, error: read.error };
|
|
515
|
+
if (!read.exists) return { ok: false, error: `${file_path} does not exist` };
|
|
516
|
+
const before = read.content;
|
|
517
|
+
const count = before.split(old_string).length - 1;
|
|
518
|
+
if (count === 0) return { ok: false, error: `old_string not found in ${file_path}` };
|
|
519
|
+
if (count > 1 && !replace_all) {
|
|
520
|
+
return { ok: false, error: `old_string is not unique (${count} matches) — use replace_all or more context` };
|
|
521
|
+
}
|
|
522
|
+
const after = replace_all
|
|
523
|
+
? before.split(old_string).join(new_string == null ? '' : new_string)
|
|
524
|
+
: before.replace(old_string, new_string == null ? '' : new_string);
|
|
525
|
+
return {
|
|
526
|
+
ok: true,
|
|
527
|
+
before,
|
|
528
|
+
after,
|
|
529
|
+
diff: unifiedDiff(before, after, { label: file_path }),
|
|
530
|
+
hash: sha256(before),
|
|
531
|
+
count,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Dispatch a preview by tool name. Only writeFile/editFile have one — exec
|
|
536
|
+
* has nothing to diff, and the approval gate skips this call for it. */
|
|
537
|
+
function previewMutation(name, args) {
|
|
538
|
+
if (name === 'writeFile') return previewWriteFile(args);
|
|
539
|
+
if (name === 'editFile') return previewEditFile(args);
|
|
540
|
+
return { ok: false, error: `no diff preview for ${name}` };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Apply a writeFile the user has approved, but only if the file on disk
|
|
544
|
+
* still matches the hash captured at preview time — otherwise something
|
|
545
|
+
* else changed it while the approval card was open, and applying blind
|
|
546
|
+
* would silently clobber that change. */
|
|
547
|
+
function applyWriteChecked({ file_path, content } = {}, expectedHash) {
|
|
336
548
|
try {
|
|
337
549
|
if (!file_path) return fail('file_path is required');
|
|
338
|
-
|
|
339
|
-
|
|
550
|
+
const read = readForPreview(file_path);
|
|
551
|
+
if (!read.ok) return fail(read.error);
|
|
552
|
+
const currentHash = read.exists ? sha256(read.content) : null;
|
|
553
|
+
if (currentHash !== expectedHash) {
|
|
554
|
+
return fail(
|
|
555
|
+
`${file_path} changed on disk since the diff was shown — refusing to apply a stale write. Re-run writeFile to get an updated diff.`
|
|
556
|
+
);
|
|
340
557
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
558
|
+
fs.mkdirSync(path.dirname(file_path), { recursive: true });
|
|
559
|
+
fs.writeFileSync(file_path, String(content == null ? '' : content), 'utf8');
|
|
560
|
+
return ok(`Wrote ${String(content == null ? '' : content).length} bytes to ${file_path}`);
|
|
561
|
+
} catch (e) {
|
|
562
|
+
return fail(e && e.message ? e.message : String(e));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Same guard as applyWriteChecked, for an already-computed editFile result
|
|
567
|
+
* (`after` — the preview's replacement, not recomputed here since a hash
|
|
568
|
+
* match means the source it was computed from is still exactly on disk). */
|
|
569
|
+
function applyEditChecked({ file_path, after } = {}, expectedHash) {
|
|
570
|
+
try {
|
|
571
|
+
if (!file_path) return fail('file_path is required');
|
|
572
|
+
const read = readForPreview(file_path);
|
|
573
|
+
if (!read.ok) return fail(read.error);
|
|
574
|
+
const currentHash = read.exists ? sha256(read.content) : null;
|
|
575
|
+
if (currentHash !== expectedHash) {
|
|
576
|
+
return fail(
|
|
577
|
+
`${file_path} changed on disk since the diff was shown — refusing to apply a stale edit. Re-run editFile to get an updated diff.`
|
|
578
|
+
);
|
|
346
579
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
: src.replace(old_string, new_string == null ? '' : new_string);
|
|
350
|
-
fs.writeFileSync(file_path, next, 'utf8');
|
|
351
|
-
return ok(`Edited ${file_path} (${count} occurrence${count > 1 ? 's' : ''} replaced)`);
|
|
580
|
+
fs.writeFileSync(file_path, after, 'utf8');
|
|
581
|
+
return ok(`Edited ${file_path}`);
|
|
352
582
|
} catch (e) {
|
|
353
583
|
return fail(e && e.message ? e.message : String(e));
|
|
354
584
|
}
|
|
355
585
|
}
|
|
356
586
|
|
|
587
|
+
/** Apply an approved writeFile/editFile call using the hash captured in its
|
|
588
|
+
* `preview` (see previewMutation) — the single entry point engine.js calls
|
|
589
|
+
* once the user has said yes. */
|
|
590
|
+
function applyChecked(name, args, preview) {
|
|
591
|
+
if (name === 'writeFile') return applyWriteChecked(args, preview.hash);
|
|
592
|
+
return applyEditChecked({ file_path: args.file_path, after: preview.after }, preview.hash);
|
|
593
|
+
}
|
|
594
|
+
|
|
357
595
|
const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', '.aegiscode']);
|
|
358
596
|
|
|
359
597
|
function listDir({ path: dir } = {}) {
|
|
@@ -626,6 +864,10 @@ module.exports = {
|
|
|
626
864
|
executeTool,
|
|
627
865
|
isTool,
|
|
628
866
|
toolResultText,
|
|
867
|
+
// approval gate (desktop/lib/local/engine.js gatedExecuteTool)
|
|
868
|
+
MUTATING_TOOLS,
|
|
869
|
+
previewMutation,
|
|
870
|
+
applyChecked,
|
|
629
871
|
// limits (unit tests assert against them instead of hard-coding numbers)
|
|
630
872
|
OUTPUT_CAP,
|
|
631
873
|
READ_LINE_CAP,
|
|
@@ -635,4 +877,6 @@ module.exports = {
|
|
|
635
877
|
EXEC_TIMEOUT_DEFAULT,
|
|
636
878
|
EXEC_TIMEOUT_CAP,
|
|
637
879
|
EXEC_MAX_BUFFER,
|
|
880
|
+
DIFF_MAX_CELLS,
|
|
881
|
+
DIFF_MAX_LINES,
|
|
638
882
|
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* quick-launcher.js — pure geometry + gating logic for the global quick-
|
|
5
|
+
* launcher popup (native desktop plumbing). Mirrors desktop/lib/window-state.js:
|
|
6
|
+
* no Electron import, so both functions unit-test without the Electron binary.
|
|
7
|
+
* main.js wires the result into a BrowserWindow + globalShortcut; this module
|
|
8
|
+
* only decides where the window should sit and whether the shortcut should be
|
|
9
|
+
* registered at all.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A global shortcut is a machine-wide hook — every packaged build ships it,
|
|
14
|
+
* but a `npm start` dev run must NOT silently start grabbing a hotkey that
|
|
15
|
+
* might collide with whatever else the developer has running, unless they've
|
|
16
|
+
* explicitly opted in via the settings flag.
|
|
17
|
+
*/
|
|
18
|
+
function shouldEnableGlobalShortcut({ isPackaged, enabled } = {}) {
|
|
19
|
+
return Boolean(isPackaged) || Boolean(enabled);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Where the launcher window should land: centred on the cursor's current
|
|
24
|
+
* position, clamped so the whole window stays within that cursor's display
|
|
25
|
+
* work area (never straddling into an unplugged second monitor, never
|
|
26
|
+
* hanging off a screen edge). Falls back to centring on the first available
|
|
27
|
+
* display — or the given fallback rectangle — when there is no usable cursor
|
|
28
|
+
* point or display list (e.g. a headless test).
|
|
29
|
+
*
|
|
30
|
+
* `cursor` / `displays` take the plain shapes electron.screen already
|
|
31
|
+
* returns (`{x,y}` / `{workArea:{x,y,width,height}}`), so this stays pure and
|
|
32
|
+
* Electron-free for testing, exactly like window-state.js's clampToDisplay.
|
|
33
|
+
*/
|
|
34
|
+
function computeQuickLauncherBounds({ cursor, displays, size, fallback } = {}) {
|
|
35
|
+
const width = Math.max(1, (size && size.width) || 560);
|
|
36
|
+
const height = Math.max(1, (size && size.height) || 320);
|
|
37
|
+
const list = Array.isArray(displays) ? displays : [];
|
|
38
|
+
const point =
|
|
39
|
+
cursor && Number.isFinite(cursor.x) && Number.isFinite(cursor.y) ? cursor : null;
|
|
40
|
+
|
|
41
|
+
const containing = point
|
|
42
|
+
? list.find((d) => {
|
|
43
|
+
const wa = d && d.workArea;
|
|
44
|
+
return (
|
|
45
|
+
wa &&
|
|
46
|
+
point.x >= wa.x &&
|
|
47
|
+
point.x < wa.x + wa.width &&
|
|
48
|
+
point.y >= wa.y &&
|
|
49
|
+
point.y < wa.y + wa.height
|
|
50
|
+
);
|
|
51
|
+
})
|
|
52
|
+
: null;
|
|
53
|
+
|
|
54
|
+
const target = containing || list[0] || null;
|
|
55
|
+
if (!target || !target.workArea) {
|
|
56
|
+
return {
|
|
57
|
+
x: (fallback && fallback.x) || 0,
|
|
58
|
+
y: (fallback && fallback.y) || 0,
|
|
59
|
+
width,
|
|
60
|
+
height,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const wa = target.workArea;
|
|
65
|
+
let x;
|
|
66
|
+
let y;
|
|
67
|
+
if (point) {
|
|
68
|
+
x = Math.round(point.x - width / 2);
|
|
69
|
+
y = Math.round(point.y - height / 2);
|
|
70
|
+
} else {
|
|
71
|
+
x = Math.round(wa.x + (wa.width - width) / 2);
|
|
72
|
+
y = Math.round(wa.y + (wa.height - height) / 2);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
x = Math.min(Math.max(x, wa.x), Math.max(wa.x, wa.x + wa.width - width));
|
|
76
|
+
y = Math.min(Math.max(y, wa.y), Math.max(wa.y, wa.y + wa.height - height));
|
|
77
|
+
|
|
78
|
+
return { x, y, width, height };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { shouldEnableGlobalShortcut, computeQuickLauncherBounds };
|
package/lib/settings.js
CHANGED
|
@@ -35,10 +35,31 @@ const AEGIS_KEY_NAMESPACE = '__aegis';
|
|
|
35
35
|
* migrateLegacyAegisKey() relocates it (and list() hides it meanwhile). */
|
|
36
36
|
const LEGACY_AEGIS_NAMESPACE = 'aegis';
|
|
37
37
|
|
|
38
|
+
/** Reserved namespace for the global quick-launcher shortcut config
|
|
39
|
+
* ({ enabled, shortcut }) — app-level settings, not a provider, so it must
|
|
40
|
+
* stay out of the provider CRUD surface for the same reason the AEGIS key
|
|
41
|
+
* does (see AEGIS_KEY_NAMESPACE above). */
|
|
42
|
+
const QUICK_LAUNCHER_NAMESPACE = '__quickLauncher';
|
|
43
|
+
|
|
44
|
+
/** Default global shortcut: rare enough on both major platforms to avoid
|
|
45
|
+
* fighting existing app/OS bindings, memorable enough to type once. */
|
|
46
|
+
const DEFAULT_QUICK_LAUNCHER_SHORTCUT = 'CmdOrCtrl+Shift+Space';
|
|
47
|
+
|
|
48
|
+
/** Reserved namespace for the tool-call approval ("confirm mode") preference:
|
|
49
|
+
* `{ enabled }` — app-level, not a provider, so it stays out of the provider
|
|
50
|
+
* CRUD surface for the same reason the AEGIS key does. Default is ON
|
|
51
|
+
* (settings.getConfirmMode() === true when unset), which is the historical
|
|
52
|
+
* behaviour: exec/writeFile/editFile always asked for approval.
|
|
53
|
+
* NOTE: like the AEGIS key and the quick launcher, this lives in its own
|
|
54
|
+
* top-level namespace — never inside a provider's `cfg[provider]` object. */
|
|
55
|
+
const CONFIRM_MODE_NAMESPACE = '__confirmMode';
|
|
56
|
+
|
|
38
57
|
/** Namespaces the provider-config surface must never see or mutate. */
|
|
39
58
|
const RESERVED_NAMESPACES = Object.freeze([
|
|
40
59
|
AEGIS_KEY_NAMESPACE,
|
|
41
60
|
LEGACY_AEGIS_NAMESPACE,
|
|
61
|
+
QUICK_LAUNCHER_NAMESPACE,
|
|
62
|
+
CONFIRM_MODE_NAMESPACE,
|
|
42
63
|
]);
|
|
43
64
|
|
|
44
65
|
/** True for the AEGIS-key namespace(s) — provider CRUD must refuse these. */
|
|
@@ -199,6 +220,54 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
199
220
|
}
|
|
200
221
|
}
|
|
201
222
|
|
|
223
|
+
// --- Quick launcher: reserved namespace, plain (unencrypted) config ------
|
|
224
|
+
// No secret lives here — just a bool and a shortcut string — so unlike the
|
|
225
|
+
// AEGIS key above there is nothing to encrypt/decrypt.
|
|
226
|
+
|
|
227
|
+
function quickLauncherConfig() {
|
|
228
|
+
const cfg = load()[QUICK_LAUNCHER_NAMESPACE] || {};
|
|
229
|
+
const shortcut =
|
|
230
|
+
typeof cfg.shortcut === 'string' && cfg.shortcut.trim()
|
|
231
|
+
? cfg.shortcut.trim()
|
|
232
|
+
: DEFAULT_QUICK_LAUNCHER_SHORTCUT;
|
|
233
|
+
return { enabled: Boolean(cfg.enabled), shortcut };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function setQuickLauncherConfig({ enabled, shortcut } = {}) {
|
|
237
|
+
const data = load();
|
|
238
|
+
data[QUICK_LAUNCHER_NAMESPACE] = {
|
|
239
|
+
enabled: Boolean(enabled),
|
|
240
|
+
shortcut:
|
|
241
|
+
typeof shortcut === 'string' && shortcut.trim()
|
|
242
|
+
? shortcut.trim()
|
|
243
|
+
: DEFAULT_QUICK_LAUNCHER_SHORTCUT,
|
|
244
|
+
};
|
|
245
|
+
save(data);
|
|
246
|
+
return quickLauncherConfig();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- Confirm mode: reserved namespace, plain (unencrypted) preference ----
|
|
250
|
+
// "Confirm before running tools" — the ON/OFF switch for the renderer's
|
|
251
|
+
// tool-call approval gate (desktop/lib/local/engine.js gatedExecuteTool).
|
|
252
|
+
// ON (default, and the behaviour shipped so far) means every mutating tool
|
|
253
|
+
// call — exec / writeFile / editFile — is previewed and approved by the user
|
|
254
|
+
// first. OFF means the engine runs them straight through, like a tool the
|
|
255
|
+
// session already allowed. Nothing secret is stored here, so no encryption.
|
|
256
|
+
|
|
257
|
+
function getConfirmMode() {
|
|
258
|
+
const cfg = load()[CONFIRM_MODE_NAMESPACE] || {};
|
|
259
|
+
// Unset === true: an existing install that never touched the toggle keeps
|
|
260
|
+
// the gate exactly as it was.
|
|
261
|
+
return cfg.enabled === undefined ? true : Boolean(cfg.enabled);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function setConfirmMode(enabled) {
|
|
265
|
+
const data = load();
|
|
266
|
+
data[CONFIRM_MODE_NAMESPACE] = { enabled: Boolean(enabled) };
|
|
267
|
+
save(data);
|
|
268
|
+
return getConfirmMode();
|
|
269
|
+
}
|
|
270
|
+
|
|
202
271
|
return {
|
|
203
272
|
file,
|
|
204
273
|
get,
|
|
@@ -211,6 +280,10 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
211
280
|
aegisRawKey,
|
|
212
281
|
setAegisKey,
|
|
213
282
|
migrateLegacyAegisKey,
|
|
283
|
+
quickLauncherConfig,
|
|
284
|
+
setQuickLauncherConfig,
|
|
285
|
+
getConfirmMode,
|
|
286
|
+
setConfirmMode,
|
|
214
287
|
};
|
|
215
288
|
}
|
|
216
289
|
|
|
@@ -218,6 +291,9 @@ module.exports = {
|
|
|
218
291
|
SETTINGS_FILE,
|
|
219
292
|
AEGIS_KEY_NAMESPACE,
|
|
220
293
|
LEGACY_AEGIS_NAMESPACE,
|
|
294
|
+
QUICK_LAUNCHER_NAMESPACE,
|
|
295
|
+
CONFIRM_MODE_NAMESPACE,
|
|
296
|
+
DEFAULT_QUICK_LAUNCHER_SHORTCUT,
|
|
221
297
|
RESERVED_NAMESPACES,
|
|
222
298
|
isReservedNamespace,
|
|
223
299
|
maskKey,
|
package/lib/sync/sessions.js
CHANGED
|
@@ -183,6 +183,31 @@ function mergeRemoteSessions(dir, remoteSessions) {
|
|
|
183
183
|
return merged;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Markdown export (session export, plan: Save as.../Export session). Each
|
|
188
|
+
* message becomes a `## Role` heading followed by its content verbatim —
|
|
189
|
+
* content is never re-escaped or re-wrapped, so any code fences a message
|
|
190
|
+
* already contains (assistant replies routinely have them) survive untouched
|
|
191
|
+
* instead of being nested inside an outer fence.
|
|
192
|
+
*/
|
|
193
|
+
function toMarkdown(session) {
|
|
194
|
+
const title = (session && (session.title || session.id)) || 'session';
|
|
195
|
+
const messages = (session && Array.isArray(session.messages)) ? session.messages : [];
|
|
196
|
+
const lines = [`# ${title}`, ''];
|
|
197
|
+
for (const message of messages) {
|
|
198
|
+
const role = (message && message.role) || 'unknown';
|
|
199
|
+
const heading = role.charAt(0).toUpperCase() + role.slice(1);
|
|
200
|
+
const content = (message && (message.content || message.text)) || '';
|
|
201
|
+
lines.push(`## ${heading}`, '', content, '');
|
|
202
|
+
}
|
|
203
|
+
return lines.join('\n');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** JSON export: the session record as stored, pretty-printed. */
|
|
207
|
+
function toJson(session) {
|
|
208
|
+
return JSON.stringify(session, null, 2);
|
|
209
|
+
}
|
|
210
|
+
|
|
186
211
|
module.exports = {
|
|
187
212
|
sessionsFile,
|
|
188
213
|
load,
|
|
@@ -196,4 +221,6 @@ module.exports = {
|
|
|
196
221
|
markPending,
|
|
197
222
|
listPending,
|
|
198
223
|
mergeRemoteSessions,
|
|
224
|
+
toMarkdown,
|
|
225
|
+
toJson,
|
|
199
226
|
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* window-state.js — persisted BrowserWindow bounds (native desktop plumbing).
|
|
5
|
+
* Pure Node + injectable dir, mirrors desktop/lib/sync/memory-queue.js: reads
|
|
6
|
+
* and writes <dir>/window-state.json, no Electron import, so it unit-tests
|
|
7
|
+
* without the Electron binary.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
|
|
13
|
+
function stateFile(dir) {
|
|
14
|
+
return path.join(dir, 'window-state.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function load(dir) {
|
|
18
|
+
try {
|
|
19
|
+
const data = JSON.parse(fs.readFileSync(stateFile(dir), 'utf8'));
|
|
20
|
+
return data && typeof data === 'object' ? data : null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function save(dir, state) {
|
|
27
|
+
const file = stateFile(dir);
|
|
28
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
29
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
30
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
31
|
+
fs.renameSync(tmp, file);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Clamp a saved rectangle so it always intersects at least one connected
|
|
36
|
+
* display's work area. A saved position from a monitor that has since been
|
|
37
|
+
* unplugged, or from a resolution that shrank, would otherwise place the
|
|
38
|
+
* window off every visible screen — it "opens" but is unreachable. `displays`
|
|
39
|
+
* takes plain `{ workArea: {x,y,width,height} }` objects (electron.screen's
|
|
40
|
+
* shape) so this stays pure and Electron-free for testing.
|
|
41
|
+
*/
|
|
42
|
+
function clampToDisplay(bounds, displays, fallback) {
|
|
43
|
+
if (!bounds || typeof bounds !== 'object') return { ...fallback };
|
|
44
|
+
const { x, y, width, height } = bounds;
|
|
45
|
+
if (![x, y, width, height].every((n) => Number.isFinite(n))) return { ...fallback };
|
|
46
|
+
|
|
47
|
+
const w = Math.max(1, width);
|
|
48
|
+
const h = Math.max(1, height);
|
|
49
|
+
const list = Array.isArray(displays) ? displays : [];
|
|
50
|
+
|
|
51
|
+
const visible = list.some((d) => {
|
|
52
|
+
const wa = d && d.workArea;
|
|
53
|
+
if (!wa) return false;
|
|
54
|
+
return x < wa.x + wa.width && x + w > wa.x && y < wa.y + wa.height && y + h > wa.y;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (visible) return { x, y, width: w, height: h };
|
|
58
|
+
// Position is off every screen: keep the saved size but drop x/y so the
|
|
59
|
+
// caller's default centering (no x/y passed to BrowserWindow) takes over.
|
|
60
|
+
return { ...fallback, width: w, height: h };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { stateFile, load, save, clampToDisplay };
|