agenter-app 1.1.0 → 1.2.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/public/style.css CHANGED
@@ -72,6 +72,15 @@
72
72
  :root[data-accent="teal"] { --accent: #73cfcb; --accent-deep: #1d7a76; }
73
73
  :root[data-accent="rose"] { --accent: #e58aa6; --accent-deep: #ad3a61; }
74
74
  :root[data-accent="lavender"] { --accent: #b8acf2; --accent-deep: #6450c4; }
75
+ :root[data-accent="mint"] { --accent: #8fe0b4; --accent-deep: #1b7049; }
76
+ :root[data-accent="sky"] { --accent: #8cc4ef; --accent-deep: #27689f; }
77
+ :root[data-accent="indigo"] { --accent: #a0aef5; --accent-deep: #3f51bf; }
78
+ :root[data-accent="plum"] { --accent: #d49ae0; --accent-deep: #8b3a9b; }
79
+ :root[data-accent="magenta"] { --accent: #f08fd0; --accent-deep: #a82a83; }
80
+ :root[data-accent="coral"] { --accent: #f29a8b; --accent-deep: #b03f2b; }
81
+ :root[data-accent="amber"] { --accent: #eec15f; --accent-deep: #855b05; }
82
+ :root[data-accent="lemon"] { --accent: #e3d96b; --accent-deep: #6d640b; }
83
+ :root[data-accent="slate"] { --accent: #a9b8c6; --accent-deep: #4b5f72; }
75
84
 
76
85
  * { box-sizing: border-box; }
77
86
  html, body { height: 100%; margin: 0; }
@@ -101,7 +110,9 @@ kbd {
101
110
  width: 300px; flex: none; display: flex; flex-direction: column; gap: 6px;
102
111
  background: var(--sidebar); border-right: 1px solid var(--border); padding: 14px 10px 10px; min-height: 0;
103
112
  }
104
- .brand-row { display: flex; align-items: center; justify-content: space-between; padding: 0 6px 8px; }
113
+ .brand-row { display: flex; align-items: center; gap: 8px; padding: 0 6px 8px; }
114
+ .brand-row .brand { margin-right: auto; }
115
+ .nav-min-btn { padding: 4px; color: var(--muted); }
105
116
  .brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.01em; }
106
117
  .logo { width: 22px; height: 22px; border-radius: 7px; background: var(--accent); position: relative; }
107
118
  .logo::after { content: ""; position: absolute; inset: 6px; background: var(--accent-text); transform: rotate(45deg); border-radius: 2px; }
@@ -115,6 +126,7 @@ kbd {
115
126
  }
116
127
  .nav-item:hover { background: var(--hover); }
117
128
  .nav-item.active { background: var(--panel); box-shadow: var(--shadow-sm); }
129
+ .nav-item:disabled { opacity: .6; cursor: progress; }
118
130
  .count { margin-left: auto; min-width: 20px; padding: 0 6px; border-radius: 99px; background: var(--warn); color: #fff; font-size: 11.5px; font-weight: 700; text-align: center; line-height: 19px; }
119
131
 
120
132
  .search { position: relative; display: flex; align-items: center; margin: 4px 0 2px; }
@@ -236,7 +248,8 @@ kbd {
236
248
  .segmented button:focus-visible { outline: 2px solid var(--accent-ink); outline-offset: 1px; }
237
249
  .segmented .ic { width: 14px; height: 14px; }
238
250
  .accent-row { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--text-2); }
239
- .accent-row span { margin-right: auto; }
251
+ .accent-row > span { margin-right: auto; }
252
+ .swatches { display: grid; grid-template-columns: repeat(7, 16px); gap: 7px; }
240
253
  .accent-row button {
241
254
  width: 16px; height: 16px; border-radius: 4px; border: 0; padding: 0; cursor: pointer;
242
255
  background: var(--sw); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .15);
@@ -276,16 +289,17 @@ kbd {
276
289
  .btn.primary kbd { background: transparent; color: inherit; border-color: currentColor; opacity: .7; }
277
290
 
278
291
  /* ---------- main ---------- */
279
- .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
292
+ /* Measured as a container: what matters is the room left beside the sidebar, not the window. */
293
+ .main { flex: 1; display: flex; flex-direction: column; min-width: 0; container: main / inline-size; }
280
294
  .main-head {
281
- display: flex; align-items: center; gap: 12px; padding: 12px 24px; min-height: 64px;
295
+ display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 12px 24px; min-height: 64px;
282
296
  border-bottom: 1px solid var(--border); background: var(--panel);
283
297
  }
284
- .head-text { min-width: 0; flex: 1; }
298
+ .head-text { min-width: 0; flex: 1 1 180px; }
285
299
  .head-title { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; letter-spacing: -.01em; }
286
300
  .head-title span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
287
301
  .head-sub { font-size: 12.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
288
- .head-actions { display: flex; align-items: center; gap: 8px; flex: none; }
302
+ .head-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
289
303
  .menu-btn { display: none; }
290
304
  .path-chip { cursor: pointer; border: 0; background: none; padding: 0; color: var(--muted); font-size: 12px; }
291
305
  .path-chip:hover { color: var(--text); text-decoration: underline; }
@@ -318,6 +332,59 @@ dialog.new-dialog { max-width: 520px; }
318
332
  .new-dialog .dlg-error { margin-top: 12px; font-size: 13px; color: var(--danger); }
319
333
  .new-dialog .dlg-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
320
334
 
335
+ .new-dialog .folder-row { display: flex; gap: 8px; }
336
+ .new-dialog .folder-row .field { flex: 1; min-width: 0; }
337
+ .new-carry { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 7px 10px; border-radius: 8px; background: var(--accent-soft); font-size: 12.5px; }
338
+ .new-carry[hidden] { display: none; }
339
+ .new-carry span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
340
+
341
+ /* ---------- update bar ---------- */
342
+ .update-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 8px 24px; background: var(--accent-soft); border-bottom: 1px solid var(--border); font-size: 13px; }
343
+ .update-bar[hidden] { display: none; }
344
+ .update-bar .update-text { flex: 1 1 240px; min-width: 0; }
345
+ .update-bar .update-text code { font-size: 12px; }
346
+
347
+ /* ---------- more menu ---------- */
348
+ .more-menu { position: fixed; inset: auto; margin: 0; min-width: 200px; padding: 4px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); color: var(--text); box-shadow: var(--shadow); }
349
+ .more-menu button { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 10px; border: 0; border-radius: 7px; background: none; cursor: pointer; font-size: 13px; text-align: left; }
350
+ .more-menu button:hover, .more-menu button:focus-visible { background: var(--hover); outline: none; }
351
+ .more-menu .ic { color: var(--muted); }
352
+
353
+ /* ---------- terminal ---------- */
354
+ .term { flex: none; height: min(36vh, 360px); min-height: 160px; display: flex; flex-direction: column; border-top: 1px solid var(--border); background: var(--code-bg); }
355
+ .term[hidden] { display: none; }
356
+ .term-bar { display: flex; align-items: center; gap: 6px; padding: 4px 8px 4px 14px; border-bottom: 1px solid var(--border); background: var(--panel); }
357
+ .term-title { font-size: 12px; font-weight: 600; flex: none; }
358
+ .term-cwd { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 12px; }
359
+ .term-bar .btn.icon { padding: 4px; }
360
+ .term-out { flex: 1; min-height: 0; overflow: auto; padding: 8px 14px; font-size: 12.5px; line-height: 1.45; outline: none; }
361
+ .term-line { display: flex; align-items: center; gap: 8px; padding: 6px 14px; border-top: 1px solid var(--border); }
362
+ .term-prompt { color: var(--accent-ink); flex: none; }
363
+ .term-input { flex: 1; min-width: 0; border: 0; outline: none; background: none; color: var(--text); padding: 0; }
364
+ .term-input::placeholder { color: var(--muted); }
365
+
366
+ /* ---------- memory ---------- */
367
+ dialog.mem-dialog { max-width: 860px; }
368
+ .mem-dialog[open] { display: flex; flex-direction: column; height: min(640px, calc(100vh - 48px)); }
369
+ .mem-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; min-width: 0; }
370
+ .mem-head h2 { margin: 0; flex: none; }
371
+ .mem-head span { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
372
+ .mem-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 14px; }
373
+ .mem-list { overflow-y: auto; }
374
+ .mem-group { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); padding: 10px 8px 4px; }
375
+ .mem-group:first-child { padding-top: 0; }
376
+ .mem-file { display: flex; align-items: center; gap: 6px; width: 100%; padding: 5px 8px; border: 0; border-radius: 7px; background: none; cursor: pointer; font-size: 13px; text-align: left; }
377
+ .mem-file span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
378
+ .mem-file:hover { background: var(--hover); }
379
+ .mem-file[aria-current="true"] { background: var(--accent-soft); }
380
+ .mem-file .new { margin-left: auto; flex: none; color: var(--muted); font-size: 11px; }
381
+ .mem-file .dirty { margin-left: auto; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--warn); }
382
+ .mem-edit { display: flex; flex-direction: column; gap: 8px; min-height: 0; min-width: 0; }
383
+ .mem-path { font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
384
+ .mem-edit textarea { flex: 1; min-height: 0; resize: none; line-height: 1.5; }
385
+ .mem-dialog .dlg-error { font-size: 13px; color: var(--danger); }
386
+ .mem-dialog .dlg-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
387
+
321
388
  /* ---------- steering ---------- */
322
389
  .kind-steer .reply-row .hint { white-space: normal; }
323
390
  .link { border: 0; background: none; padding: 0; font: inherit; color: var(--accent-ink); text-decoration: underline; cursor: pointer; }
@@ -363,6 +430,9 @@ dialog.new-dialog { max-width: 520px; }
363
430
  .convo { display: flex; flex-direction: column; gap: 10px; }
364
431
  .msg-user { align-self: flex-end; max-width: 78%; background: var(--accent-soft); border: 1px solid transparent; padding: 9px 14px; border-radius: 14px 14px 4px 14px; white-space: pre-wrap; word-break: break-word; }
365
432
  .msg-user.from-dash { border-color: var(--accent-ink); }
433
+ .carried { margin-top: 6px; font-size: 12.5px; color: var(--text-2); white-space: normal; }
434
+ .carried > summary { cursor: pointer; }
435
+ .carried pre { margin-top: 6px; max-height: 280px; overflow: auto; font-size: 12px; }
366
436
  .live-echo { display: flex; justify-content: flex-end; margin-top: 12px; }
367
437
  .msg-claude { display: grid; grid-template-columns: 26px 1fr; gap: 10px; align-items: start; }
368
438
  .claude-mark { width: 26px; height: 26px; border-radius: 8px; background: var(--panel); border: 1px solid var(--border); display: grid; place-items: center; color: var(--accent-ink); }
@@ -495,7 +565,7 @@ dialog.new-dialog { max-width: 520px; }
495
565
  .section-title { display: flex; align-items: baseline; gap: 8px; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 26px 0 10px; }
496
566
  #inboxPending > .section-title { margin-top: 0; }
497
567
  .all-clear { display: flex; align-items: center; gap: 14px; padding: 18px 20px; border-radius: var(--radius); background: var(--panel); border: 1px solid var(--border); }
498
- .all-clear .big { width: 38px; height: 38px; border-radius: 50%; background: var(--ok-soft); color: var(--ok); display: grid; place-items: center; flex: none; }
568
+ .all-clear .big { width: 38px; height: 38px; border-radius: 50%; background: var(--accent-soft); color: var(--accent-ink); display: grid; place-items: center; flex: none; }
499
569
  .all-clear b { display: block; }
500
570
  .all-clear span { color: var(--muted); font-size: 13px; }
501
571
  .tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; }
@@ -538,23 +608,49 @@ dialog form { text-align: right; }
538
608
  *, *::before, *::after { animation: none !important; transition: none !important; }
539
609
  }
540
610
 
611
+ /* ---------- collapsed sidebar: a rail of icons and sessions ---------- */
612
+ @media (min-width: 801px) {
613
+ .app.nav-min .sidebar { width: 60px; padding: 14px 8px 10px; align-items: stretch; }
614
+ .app.nav-min .brand-row { flex-direction: column; padding: 0 0 8px; }
615
+ .app.nav-min .brand { margin: 0; }
616
+ .app.nav-min .brand-name, .app.nav-min .search, .app.nav-min .usage, .app.nav-min .sidebar-foot, .app.nav-min .group-label { display: none; }
617
+ .app.nav-min .nav-item { justify-content: center; padding: 8px 0; position: relative; }
618
+ .app.nav-min .nav-item > span:not(.count) { display: none; }
619
+ .app.nav-min .nav-item .count { position: absolute; top: 0; right: 0; min-width: 16px; padding: 0 4px; line-height: 15px; font-size: 10px; }
620
+ .app.nav-min .session-list { margin: 0; padding: 0; }
621
+ .app.nav-min .s-item { grid-template-columns: 32px; justify-content: center; padding: 6px 0; }
622
+ .app.nav-min .s-main, .app.nav-min .s-when { display: none; }
623
+ }
624
+
541
625
  /* ---------- small screens ---------- */
542
626
  @media (max-width: 800px) {
543
627
  .sidebar { position: fixed; inset: 0 auto 0 0; z-index: 40; transform: translateX(-100%); transition: transform .2s; box-shadow: var(--shadow); width: min(300px, 86vw); }
544
628
  .app.nav-open .sidebar { transform: none; }
545
629
  .app.nav-open .scrim { display: block; position: fixed; inset: 0; background: rgba(0, 0, 0, .35); z-index: 30; }
546
630
  .menu-btn { display: inline-flex; }
631
+ .search kbd { display: none; }
632
+ }
633
+
634
+ /* Going by the space beside the sidebar, so the same room lays out the same way whether
635
+ the sidebar is open, collapsed or a drawer. */
636
+ @container main (max-width: 900px) {
637
+ .head-actions .label { display: none; }
638
+ }
639
+ @container main (max-width: 760px) {
547
640
  .main-head { padding: 10px 16px; }
641
+ .update-bar { padding: 8px 16px; }
548
642
  .content { padding: 16px; }
549
643
  .dock { padding: 0 16px 14px; }
550
- .head-actions .label { display: none; }
551
644
  .msg-user { max-width: 92%; }
552
- .btn kbd, .reply-row .hint, .search kbd { display: none; }
645
+ .btn kbd, .reply-row .hint { display: none; }
553
646
  .tools { margin-left: 8px; }
554
647
  /* No room side by side: the app goes on top, the conversation below. */
555
648
  .split { flex-direction: column; }
556
649
  .splitter { display: none; }
557
- .preview { order: -1; width: auto; max-width: none; min-width: 0; height: 48vh; border-bottom: 1px solid var(--border); }
650
+ .preview { order: -1; width: auto; max-width: none; min-width: 0; height: 48vh; flex: none; border-bottom: 1px solid var(--border); }
651
+ }
652
+ @media (max-width: 640px) {
653
+ .mem-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 2fr) minmax(0, 5fr); }
558
654
  }
559
655
 
560
656
  .auth-lost { position: fixed; top: 12px; left: 50%; transform: translateX(-50%); z-index: 1000; max-width: calc(100vw - 32px); padding: 10px 14px; border-radius: 10px; background: #5c2a26; color: #fff; font-size: 13px; }
@@ -0,0 +1,85 @@
1
+ # The Windows folder window behind New session. Prints the folder you chose, or nothing if
2
+ # you cancel.
3
+ Add-Type -AssemblyName System.Windows.Forms
4
+
5
+ # Windows won't let a background process take focus, so without this the window could open
6
+ # behind the dashboard and clicking New session would seem to do nothing. Joining the input
7
+ # of the window that has focus is what makes SetForegroundWindow allowed.
8
+ Add-Type @"
9
+ using System;
10
+ using System.Text;
11
+ using System.Runtime.InteropServices;
12
+ public static class PickFolderWindow {
13
+ [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();
14
+ [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, IntPtr p);
15
+ [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();
16
+ [DllImport("user32.dll")] static extern bool AttachThreadInput(uint a, uint b, bool attach);
17
+ [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr h);
18
+ [DllImport("user32.dll")] static extern bool BringWindowToTop(IntPtr h);
19
+ [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h);
20
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassName(IntPtr h, StringBuilder name, int size);
21
+ delegate bool WindowVisitor(IntPtr h, IntPtr data);
22
+ [DllImport("user32.dll")] static extern bool EnumThreadWindows(uint thread, WindowVisitor visit, IntPtr data);
23
+ [DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);
24
+ // Dialogs are the #32770 class. Runs on the dialog's thread, so its windows are ours.
25
+ static IntPtr FindDialog(uint thread) {
26
+ IntPtr found = IntPtr.Zero;
27
+ EnumThreadWindows(thread, (h, data) => {
28
+ StringBuilder name = new StringBuilder(16);
29
+ GetClassName(h, name, 16);
30
+ if (name.ToString() != "#32770" || !IsWindowVisible(h)) return true;
31
+ found = h;
32
+ return false;
33
+ }, IntPtr.Zero);
34
+ return found;
35
+ }
36
+ // True once the dialog really has focus.
37
+ public static bool BringForward() {
38
+ uint self = GetCurrentThreadId();
39
+ IntPtr dialog = FindDialog(self);
40
+ if (dialog == IntPtr.Zero) return false; // not up yet
41
+ if (GetForegroundWindow() == dialog) return true;
42
+ SetWindowPos(dialog, new IntPtr(-1), 0, 0, 0, 0, 0x0043); // topmost, no move or size, show
43
+ uint focused = GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero);
44
+ bool joined = focused != 0 && focused != self && AttachThreadInput(self, focused, true);
45
+ BringWindowToTop(dialog);
46
+ SetForegroundWindow(dialog);
47
+ if (joined) AttachThreadInput(self, focused, false);
48
+ return GetForegroundWindow() == dialog;
49
+ }
50
+ }
51
+ "@
52
+
53
+ [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
54
+
55
+ $owner = New-Object System.Windows.Forms.Form
56
+ $owner.TopMost = $true
57
+ # Windows can still refuse focus, and this keeps the window findable when it does.
58
+ $owner.ShowInTaskbar = $true
59
+ $owner.Text = 'Agenter: choose a folder'
60
+ $owner.FormBorderStyle = 'None'
61
+ $owner.Opacity = 0
62
+ $owner.StartPosition = 'Manual'
63
+ $owner.Location = New-Object System.Drawing.Point(-32000, -32000)
64
+ $owner.Show()
65
+
66
+ $dialog = New-Object System.Windows.Forms.FolderBrowserDialog
67
+ $dialog.Description = 'Choose the project folder for this session'
68
+ $dialog.ShowNewFolderButton = $true
69
+ if ($env:AGENTER_PICK_START -and (Test-Path -LiteralPath $env:AGENTER_PICK_START)) { $dialog.SelectedPath = $env:AGENTER_PICK_START }
70
+
71
+ # Runs inside the dialog's own message loop. Keeps trying for a few seconds, then lets you be.
72
+ $raise = New-Object System.Windows.Forms.Timer
73
+ $raise.Interval = 50
74
+ $script:tries = 0
75
+ $raise.add_Tick({
76
+ $script:tries++
77
+ if ([PickFolderWindow]::BringForward() -or $script:tries -ge 60) { $raise.Stop() }
78
+ })
79
+ $raise.Start()
80
+
81
+ if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) {
82
+ [Console]::Out.Write($dialog.SelectedPath)
83
+ }
84
+ $raise.Dispose()
85
+ $owner.Dispose()
package/server.js CHANGED
@@ -13,6 +13,10 @@ const { truncate, describeTool, readTail, readHead, parseCodexItem, readTranscri
13
13
  const { normalizeLimits, codexWindowLabel } = require('./lib/limits');
14
14
  const { buildDiffs } = require('./lib/diff');
15
15
  const { getToken, sameToken } = require('./lib/token');
16
+ const { version: VERSION } = require('./package.json');
17
+ const updates = require('./lib/update');
18
+ const { createTerminals } = require('./lib/terminal');
19
+ const { readMemory, saveMemory, carriedContext } = require('./lib/memory');
16
20
 
17
21
  const PORT = Number(process.env.AGENTER_PORT) || 4317;
18
22
  const HOST = '127.0.0.1';
@@ -36,6 +40,9 @@ const DISCOVER_FULL_SCAN_MS = 5 * 60 * 1000; // when a file watcher catches chan
36
40
  const CREDENTIALS_FILE = path.join(os.homedir(), '.claude', '.credentials.json');
37
41
  const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
38
42
  const USAGE_INTERVAL_MS = 2 * 60 * 1000;
43
+ // A window opening also checks, so a long-running server still tells you about updates.
44
+ const UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000;
45
+ const UPDATE_THROTTLE_MS = 60 * 1000;
39
46
 
40
47
  const sessions = new Map(); // session_id -> session
41
48
  const pending = new Map(); // pending id -> { item, res, payload }
@@ -463,6 +470,56 @@ function noteCodexLimits(rl, at) {
463
470
  codexUsage = { updatedAt: at, plan: rl.plan_type || '', limits, credits: rl.credits || null };
464
471
  }
465
472
 
473
+ // ---------- updates ----------
474
+
475
+ let latestVersion = updates.cachedLatest();
476
+ let updateCheckedAt = 0;
477
+
478
+ async function checkForUpdate() {
479
+ if (!updates.enabled()) return;
480
+ updateCheckedAt = Date.now();
481
+ latestVersion = (await updates.checkLatest({ timeoutMs: 10000 })) || latestVersion;
482
+ broadcast();
483
+ }
484
+
485
+ function updateState() {
486
+ const installed = updates.installedVersion();
487
+ return {
488
+ current: VERSION,
489
+ latest: latestVersion,
490
+ installed,
491
+ available: updates.isNewer(latestVersion, VERSION) && !updates.isNewer(installed, VERSION),
492
+ restart: updates.isNewer(installed, VERSION), // installed, but this server is still the old code
493
+ canSelfUpdate: updates.canSelfUpdate(),
494
+ };
495
+ }
496
+
497
+ // ---------- terminal ----------
498
+
499
+ const TERMINAL_ENABLED = process.platform === 'win32' && process.env.AGENTER_TERMINAL !== '0';
500
+ const terminals = createTerminals((e) => sendAll('terminal', e));
501
+ const terminalId = (cwd) => path.resolve(cwd).toLowerCase();
502
+
503
+ // ---------- folder window ----------
504
+ // The dashboard can't browse this machine's folders itself, so the server opens Windows' own.
505
+
506
+ let pickingFolder = null;
507
+
508
+ function pickFolder(start) {
509
+ return new Promise((resolve) => {
510
+ if (pickingFolder) return resolve({ busy: true });
511
+ if (process.platform !== 'win32') return resolve({ unsupported: true });
512
+ const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-File', path.join(__dirname, 'scripts', 'pick-folder.ps1')];
513
+ pickingFolder = execFile('powershell.exe', args, { windowsHide: true, env: { ...process.env, AGENTER_PICK_START: String(start || '') } }, (err, out) => {
514
+ pickingFolder = null;
515
+ onAttentionChanged();
516
+ const folder = String(out || '').trim();
517
+ resolve(err ? { unsupported: true } : folder ? { path: folder } : { cancelled: true });
518
+ });
519
+ onAttentionChanged();
520
+ });
521
+ }
522
+
466
523
  function openInVSCode(cwd) {
467
524
  if (!cwd || /["&|<>^%]/.test(cwd) || !fs.existsSync(cwd)) return false;
468
525
  const child =
@@ -660,7 +717,8 @@ function respond(id, action, body = {}) {
660
717
 
661
718
  // Sessions are only held while a dashboard window has focus. If you're in VS Code,
662
719
  // Claude finishes (or asks) there as usual instead of spinning on the dashboard.
663
- const inUse = () => [...clients.values()].some((c) => c.focused);
720
+ // The folder window takes focus from the dashboard, but you're still here.
721
+ const inUse = () => !!pickingFolder || [...clients.values()].some((c) => c.focused);
664
722
 
665
723
  let releaseTimer = null;
666
724
  function onAttentionChanged() {
@@ -673,15 +731,24 @@ function onAttentionChanged() {
673
731
  }
674
732
 
675
733
  // Quit, from the tray. Open windows close themselves on the 'quit' event.
676
- function stopServer() {
677
- for (const c of clients.keys()) c.write('event: quit\ndata: {}\n\n');
734
+ // A restart (to finish an update) leaves them open; they reconnect to the new server.
735
+ function stopServer({ restart = false } = {}) {
736
+ if (!restart) sendAll('quit', {});
678
737
  for (const id of [...pending.keys()]) respond(id, 'pass');
679
738
  for (const child of runners.values()) {
680
739
  child.interrupted = true;
681
740
  killRun(child);
682
741
  }
683
- fs.mkdirSync(SETTINGS_DIR, { recursive: true });
684
- fs.writeFileSync(STOPPED_FILE, '');
742
+ terminals.killAll();
743
+ pickingFolder?.kill();
744
+ if (restart) {
745
+ // It waits for this process to let go of the port.
746
+ const env = { ...process.env, AGENTER_RESTART: '1' };
747
+ spawn(process.execPath, [path.join(__dirname, 'server.js'), '--no-open'], { detached: true, stdio: 'ignore', windowsHide: true, env }).unref();
748
+ } else {
749
+ fs.mkdirSync(SETTINGS_DIR, { recursive: true });
750
+ fs.writeFileSync(STOPPED_FILE, '');
751
+ }
685
752
  // Long enough for the hooks to receive those answers.
686
753
  setTimeout(() => process.exit(0), 300);
687
754
  }
@@ -929,6 +996,15 @@ function startCodexRun(s, text, { fresh = false } = {}) {
929
996
  });
930
997
  }
931
998
 
999
+ // ---------- memory ----------
1000
+
1001
+ const memoryContext = (s) => ({ agent: s.agent, cwd: s.cwd, transcriptPath: s.transcriptPath, home: os.homedir(), codexHome: CODEX_HOME });
1002
+
1003
+ function carryFrom(s) {
1004
+ const t = s?.transcriptPath && readTranscript(s.transcriptPath);
1005
+ return t ? carriedContext({ agent: s.agent, cwd: s.cwd, title: s.title, messages: t.messages }) : '';
1006
+ }
1007
+
932
1008
  // ---------- hook events ----------
933
1009
 
934
1010
  function handleHook(p, res) {
@@ -1011,6 +1087,9 @@ function handleHook(p, res) {
1011
1087
 
1012
1088
  function snapshot() {
1013
1089
  return {
1090
+ version: VERSION,
1091
+ update: updateState(),
1092
+ terminal: TERMINAL_ENABLED,
1014
1093
  settings,
1015
1094
  usage,
1016
1095
  codexUsage,
@@ -1020,14 +1099,18 @@ function snapshot() {
1020
1099
  };
1021
1100
  }
1022
1101
 
1102
+ const sendAll = (event, data) => {
1103
+ const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
1104
+ for (const c of clients.keys()) c.write(msg);
1105
+ };
1106
+
1023
1107
  let broadcastQueued = false;
1024
1108
  function broadcast() {
1025
1109
  if (broadcastQueued) return;
1026
1110
  broadcastQueued = true;
1027
1111
  setTimeout(() => {
1028
1112
  broadcastQueued = false;
1029
- const msg = `event: state\ndata: ${JSON.stringify(snapshot())}\n\n`;
1030
- for (const c of clients.keys()) c.write(msg);
1113
+ sendAll('state', snapshot());
1031
1114
  }, 30);
1032
1115
  }
1033
1116
 
@@ -1115,6 +1198,7 @@ const server = http.createServer(async (req, res) => {
1115
1198
  clients.set(res, { id: url.searchParams.get('client') || '', focused: url.searchParams.get('focused') === '1' });
1116
1199
  onAttentionChanged();
1117
1200
  if (!usage || Date.now() - usage.fetchedAt > 60000) fetchUsage();
1201
+ if (Date.now() - updateCheckedAt > UPDATE_THROTTLE_MS) checkForUpdate();
1118
1202
  const ping = setInterval(() => res.write(': ping\n\n'), 20000);
1119
1203
  req.on('close', () => {
1120
1204
  clearInterval(ping);
@@ -1141,6 +1225,12 @@ const server = http.createServer(async (req, res) => {
1141
1225
  return json(res, 200, { apps: await findApps(s.cwd), detects: process.platform === 'win32' });
1142
1226
  }
1143
1227
 
1228
+ if (req.method === 'GET' && url.pathname === '/api/memory') {
1229
+ const s = sessions.get(url.searchParams.get('session'));
1230
+ if (!s) return json(res, 404, { error: 'unknown session' });
1231
+ return json(res, 200, { files: readMemory(memoryContext(s)) });
1232
+ }
1233
+
1144
1234
  if (req.method === 'POST' && url.pathname.startsWith('/api/')) {
1145
1235
  const body = await readBody(req);
1146
1236
 
@@ -1148,6 +1238,39 @@ const server = http.createServer(async (req, res) => {
1148
1238
  stopServer();
1149
1239
  return json(res, 200, {});
1150
1240
  }
1241
+ if (url.pathname === '/api/restart') {
1242
+ stopServer({ restart: true });
1243
+ return json(res, 200, {});
1244
+ }
1245
+ if (url.pathname === '/api/update') {
1246
+ if (!TERMINAL_ENABLED) return json(res, 400, { error: 'Run agenter update in a terminal.' });
1247
+ if (!updates.canSelfUpdate()) return json(res, 400, { error: 'This copy is a git clone. Update it with git pull.' });
1248
+ const id = terminalId(os.homedir());
1249
+ const term = terminals.open(id, os.homedir());
1250
+ if (term.running) return json(res, 409, { error: 'That terminal is busy. Wait for it to finish, or press Stop.' });
1251
+ const quote = (p) => `'${p.replace(/'/g, "''")}'`;
1252
+ terminals.run(id, `& ${quote(process.execPath)} ${quote(path.join(__dirname, 'cli.js'))} update`, checkForUpdate);
1253
+ return json(res, 200, { id });
1254
+ }
1255
+ if (url.pathname.startsWith('/api/terminal/')) {
1256
+ if (!TERMINAL_ENABLED) return json(res, 400, { error: 'The terminal is turned off or not available here.' });
1257
+ if (url.pathname === '/api/terminal/open') {
1258
+ const cwd = sessions.get(body.sessionId)?.cwd;
1259
+ const folder = cwd && fs.existsSync(cwd) ? cwd : os.homedir();
1260
+ return json(res, 200, terminals.open(terminalId(folder), folder));
1261
+ }
1262
+ const ok = url.pathname === '/api/terminal/run' ? terminals.run(body.id, String(body.input ?? '')) : url.pathname === '/api/terminal/stop' && terminals.stop(body.id);
1263
+ return json(res, ok ? 200 : 404, {});
1264
+ }
1265
+ if (url.pathname === '/api/pick-folder') {
1266
+ return json(res, 200, await pickFolder(body.start));
1267
+ }
1268
+ if (url.pathname === '/api/memory/save') {
1269
+ const s = sessions.get(body.sessionId);
1270
+ if (!s) return json(res, 404, { error: 'unknown session' });
1271
+ const { status, ...out } = saveMemory(memoryContext(s), body.path, body.text, body.mtime);
1272
+ return json(res, status, out);
1273
+ }
1151
1274
  if (url.pathname === '/api/respond') {
1152
1275
  const s = sessions.get(pending.get(body.id)?.item.sessionId);
1153
1276
  const ok = respond(body.id, body.action, body);
@@ -1178,6 +1301,8 @@ const server = http.createServer(async (req, res) => {
1178
1301
  const cwd = String(body.cwd || '').trim().replace(/^"(.*)"$/, '$1');
1179
1302
  const text = String(body.message || '').trim();
1180
1303
  const codex = body.agent === 'codex';
1304
+ const carried = carryFrom(sessions.get(body.fromSessionId));
1305
+ const prompt = carried ? `${text}\n\n${carried}` : text;
1181
1306
  if (!text) return json(res, 400, { error: `Type a first message for ${codex ? 'Codex' : 'Claude'}.` });
1182
1307
  let isDir = false;
1183
1308
  try {
@@ -1189,7 +1314,7 @@ const server = http.createServer(async (req, res) => {
1189
1314
  // Codex names the session itself, so it's registered once Codex has started it.
1190
1315
  const draft = Object.assign(newSession(''), { agent: 'codex', title: text.split('\n')[0].slice(0, 140), lastEventAt: Date.now() });
1191
1316
  setCwd(draft, path.resolve(cwd));
1192
- const started = await Promise.race([startCodexRun(draft, text, { fresh: true }), new Promise((r) => setTimeout(() => r({ error: 'Codex took too long to start.' }), 60000))]);
1317
+ const started = await Promise.race([startCodexRun(draft, prompt, { fresh: true }), new Promise((r) => setTimeout(() => r({ error: 'Codex took too long to start.' }), 60000))]);
1193
1318
  if (!started?.id) return json(res, 500, { error: `Codex couldn't start: ${started?.error || 'unknown error'}` });
1194
1319
  settings.drive = { ...settings.drive, [started.id]: 'agenter' };
1195
1320
  saveSettings();
@@ -1204,7 +1329,7 @@ const server = http.createServer(async (req, res) => {
1204
1329
  s.lastEventAt = Date.now();
1205
1330
  settings.drive = { ...settings.drive, [s.id]: 'agenter' };
1206
1331
  saveSettings();
1207
- if (!startRun(s, text, { fresh: true })) {
1332
+ if (!startRun(s, prompt, { fresh: true })) {
1208
1333
  sessions.delete(s.id);
1209
1334
  return json(res, 500, { error: "Couldn't start Claude." });
1210
1335
  }
@@ -1278,7 +1403,9 @@ const server = http.createServer(async (req, res) => {
1278
1403
  server.requestTimeout = 0; // hooks can legitimately wait a long time for you
1279
1404
  // Only listen when run directly; the tests load this file to drive the hook and steering logic.
1280
1405
  if (require.main === module) {
1406
+ let portRetries = process.env.AGENTER_RESTART ? 50 : 0;
1281
1407
  server.on('error', (err) => {
1408
+ if (err.code === 'EADDRINUSE' && portRetries-- > 0) return void setTimeout(() => server.listen(PORT, HOST), 200);
1282
1409
  if (err.code === 'EADDRINUSE') console.error(`Port ${PORT} is busy. Is Agenter already running? (Set AGENTER_PORT to use another port.)`);
1283
1410
  else console.error(err);
1284
1411
  process.exit(1);
@@ -1286,12 +1413,18 @@ if (require.main === module) {
1286
1413
  server.listen(PORT, HOST, () => {
1287
1414
  const url = `http://localhost:${PORT}`;
1288
1415
  console.log(`Agenter dashboard: ${url}`);
1416
+ delete process.env.AGENTER_RESTART;
1289
1417
  fs.rmSync(STOPPED_FILE, { force: true });
1418
+ // Shortcuts start us inside the install folder, and on Windows a folder that's in use
1419
+ // can't be replaced by `npm i -g`. Everything here uses full paths.
1420
+ process.chdir(os.homedir());
1290
1421
  startTray();
1291
1422
  startDiscovery();
1292
1423
  startCodexDiscovery();
1293
1424
  fetchUsage();
1294
1425
  setInterval(fetchUsage, USAGE_INTERVAL_MS);
1426
+ checkForUpdate();
1427
+ setInterval(checkForUpdate, UPDATE_INTERVAL_MS);
1295
1428
  if (!process.argv.includes('--no-open')) openDashboard(url);
1296
1429
  });
1297
1430
  }