ccdeck 3.25.0 → 3.26.1

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.
@@ -44,8 +44,8 @@
44
44
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
45
45
  })();
46
46
  </script>
47
- <script type="module" crossorigin src="/assets/index-BoR2dMqR.js"></script>
48
- <link rel="stylesheet" crossorigin href="/assets/index-7a7ISatq.css">
47
+ <script type="module" crossorigin src="/assets/index-CO8B_qBe.js"></script>
48
+ <link rel="stylesheet" crossorigin href="/assets/index-DlaHgzye.css">
49
49
  </head>
50
50
  <body>
51
51
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.25.0",
3
+ "version": "3.26.1",
4
4
  "description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,7 +43,9 @@
43
43
  "3.23.1",
44
44
  "3.23.2",
45
45
  "3.23.3",
46
- "3.23.4"
46
+ "3.23.4",
47
+ "3.26.0",
48
+ "3.26.1"
47
49
  ],
48
50
  "3.25.0": [
49
51
  {
@@ -0,0 +1,37 @@
1
+ // When the deck runs inside the ccdeck desktop app (#1160).
2
+ //
3
+ // The app starts this deck with its own binary acting as Node
4
+ // (ELECTRON_RUN_AS_NODE=1) and tells it so, because three things the deck does
5
+ // for itself are the app's to do there, and doing them anyway goes wrong:
6
+ //
7
+ // UPDATES. The deck would ask npm for a newer ccdeck and `npm i -g` it — a
8
+ // second, global copy the app never runs, reinstalled every thirty minutes by
9
+ // the away-update because the app's own version never changes. The app
10
+ // updates itself from GitHub Releases instead.
11
+ //
12
+ // START AT LOGIN. The deck offers a login item that runs `process.execPath
13
+ // bin/agent-dag.js` — here the app's binary, started at login with no window
14
+ // and no tray, beside the app's own login item. The app owns that switch.
15
+ //
16
+ // THE CLAUDE CODE HOOK. Its command is `<node> hook.js`, and `process.execPath`
17
+ // is the app's binary, which without ELECTRON_RUN_AS_NODE opens the app. The
18
+ // app hands the deck a launcher that finds a Node to run the hook with.
19
+ //
20
+ // Environment, not flags: the supervisor re-spawns the worker with its own
21
+ // argv, and the environment is what both of them inherit unchanged.
22
+
23
+ /** "1" when the desktop app started this deck. */
24
+ export const APP_ENV = "CCDECK_APP";
25
+
26
+ /** The program the Claude Code hook command runs hook.js with, when it must
27
+ * not be `process.execPath`. */
28
+ export const HOOK_RUNTIME_ENV = "CCDECK_HOOK_RUNTIME";
29
+
30
+ export function inApp(env = process.env) {
31
+ return env[APP_ENV] === "1";
32
+ }
33
+
34
+ export function hookRuntime(env = process.env, execPath = process.execPath) {
35
+ const given = env[HOOK_RUNTIME_ENV];
36
+ return typeof given === "string" && given.trim() ? given : execPath;
37
+ }
@@ -6,6 +6,12 @@
6
6
  // because raising a notification from a page requires a page, and the case this
7
7
  // whole feature exists for is the one where you walked away.
8
8
  //
9
+ // WHAT IT SAYS IS WHAT AN OPEN DECK WOULD HAVE PLAYED. With a tab open you hear
10
+ // a turn finish and hear Claude ask; with none open nothing plays, and those are
11
+ // exactly the moments somebody who walked away wanted. So a closed deck stands
12
+ // in for its own sounds rather than keeping a narrower rule of its own. See
13
+ // `isChimeEvent`.
14
+ //
9
15
  // The server can, and it already knows the one fact that makes it safe to:
10
16
  // `sseClients.size`. Nobody is listening means nobody is being told by any
11
17
  // other surface — the chip, the title, the favicon and the live region are all
@@ -55,30 +61,85 @@ export const OFF_ENV = "AGENTS_DECK_NO_NOTIFY";
55
61
  export const QUIET_MS = 2 * 60 * 1000;
56
62
 
57
63
  /**
58
- * The events that mean a session has stopped and cannot start again without a
59
- * human. Two of the three kinds CC emits.
64
+ * Would an open deck have played a tone for this?
60
65
  *
61
- * `permission_prompt` — it wants to run something and is waiting to be allowed.
66
+ * The page's rule, not a new one: `Stop` is the "turn finished" tone and every
67
+ * `Notification` is "Claude is asking" (src/web/sound.ts `chimeFor`).
62
68
  *
63
- * `agent_needs_input` — it asked a question and is waiting for the answer, with
64
- * the question itself in `message`. This was missing, and its absence was not a
65
- * small gap: on a machine running `bypassPermissions` Claude Code never asks to
66
- * run anything, so `permission_prompt` essentially never fires and the desktop
67
- * notification could not happen at all. Measured on one real log — 1683 events,
68
- * every one of them bypassPermissions — a single permission prompt in the whole
69
- * history against five of these.
69
+ * WIDER THAN IT USED TO BE, ON PURPOSE. This was permission prompts and
70
+ * questions only — `idle_prompt` and every finished turn left out, because
71
+ * #348 measured idle at three quarters of what CC emits. What that cost is
72
+ * the setup the deck's owner runs: under `bypassPermissions` Claude Code almost
73
+ * never raises a permission prompt (one log: 14 idle, 1 permission), so the
74
+ * notification this switch promised could not happen. The noise is held back
75
+ * by the memo instead — see `memoKeys` — and the channel is still off until
76
+ * somebody turns it on (deck-prefs.mjs).
70
77
  *
71
- * `idle_prompt` is deliberately still out: #348 measured 16 idle to 5
72
- * permission, and an idle prompt is a turn that ended, not a session that is
73
- * stuck. Three quarters noise is how a notification channel gets muted, and a
74
- * muted channel is worse than none because the deck goes on believing it told
75
- * somebody.
78
+ * WRITTEN TWICE BECAUSE IT HAS TO BE. sound.ts is TypeScript bundled for the
79
+ * browser and this runs in bare node, so neither can import the other.
80
+ * notify-mirror.test.ts runs both over the same events and fails the day they
81
+ * disagree, which is the only thing that keeps a mirrored rule a mirror.
76
82
  */
77
- export function isBlockingPrompt(raw) {
78
- return !!raw
79
- && raw.hook_event_name === "Notification"
80
- && (raw.notification_type === "permission_prompt"
81
- || raw.notification_type === "agent_needs_input");
83
+ export function isChimeEvent(raw) {
84
+ return !!raw && (raw.hook_event_name === "Stop" || raw.hook_event_name === "Notification");
85
+ }
86
+
87
+ /** Which of the two tones it would have been — sound.ts's names, so the
88
+ * desktop app can play the same one with the notification. */
89
+ export function chimeOf(raw) {
90
+ return raw?.hook_event_name === "Stop" ? "done" : "needs-input";
91
+ }
92
+
93
+ /**
94
+ * How long one session stays quiet about a FINISHED TURN after saying so.
95
+ *
96
+ * Far shorter than `QUIET_MS`, because every `Stop` is a different turn and
97
+ * somebody answering from the terminal can finish two inside a minute — each is
98
+ * the tone an open deck would have played. What this still stops is one `Stop`
99
+ * delivered twice: a retried hook, or a second deck feeding the same log.
100
+ */
101
+ export const TURN_QUIET_MS = 10 * 1000;
102
+
103
+ /**
104
+ * Which memo entries speak for this event: its own, and any whose having been
105
+ * said makes this one old news.
106
+ *
107
+ * The case with an `also` is the idle prompt. CC sends one a minute after a
108
+ * turn ends with the input box still empty — so after a `Stop` that was already
109
+ * on the desktop it says nothing new, and a second notification for the same
110
+ * turn a minute later is exactly the drumming that gets a channel muted. The
111
+ * tab plays both tones because a tone is gone in a second; a notification sits
112
+ * in the tray. An idle prompt whose `Stop` was NOT said — the tab was open when
113
+ * the turn ended and closed since — still is.
114
+ */
115
+ export function memoKeys(raw) {
116
+ const sid = raw?.session_id ?? "";
117
+ if (raw?.hook_event_name === "Stop") return { own: `${sid}#turn`, also: [] };
118
+ if (raw?.notification_type === "idle_prompt") return { own: `${sid}#idle`, also: [`${sid}#turn`] };
119
+ return { own: sid, also: [] };
120
+ }
121
+
122
+ /** The quiet window this event is measured against. */
123
+ export function quietFor(raw) {
124
+ return raw?.hook_event_name === "Stop" ? TURN_QUIET_MS : QUIET_MS;
125
+ }
126
+
127
+ /** Longest quoted body. macOS shows about two lines of this; the rest is
128
+ * still worth having in Notification Center, but not a transcript. */
129
+ export const TURN_BODY_MAX = 160;
130
+
131
+ /**
132
+ * The body for a finished turn: what the agent last said, cut to what a tray
133
+ * shows, or a plain sentence when there is nothing to quote — Codex's `Stop`
134
+ * carries no message, and neither does an older Claude Code's.
135
+ */
136
+ export function turnBody(raw) {
137
+ const said = typeof raw?.last_assistant_message === "string"
138
+ ? raw.last_assistant_message.replace(/\s+/g, " ").trim()
139
+ : "";
140
+ if (!said) return "Finished its turn";
141
+ const chars = [...said];
142
+ return chars.length > TURN_BODY_MAX ? `${chars.slice(0, TURN_BODY_MAX - 1).join("").trimEnd()}…` : said;
82
143
  }
83
144
 
84
145
  /**
@@ -97,10 +158,18 @@ export function isBlockingPrompt(raw) {
97
158
  * dimmer copy of a rule that already exists — the failure ambient-counts.ts and
98
159
  * block-announce.ts were both written to end.
99
160
  */
161
+ /** Which session a notification is about, in the words the user has for it:
162
+ * the working directory's last segment, else a short session id. */
163
+ export function whoOf(raw) {
164
+ const cwd = typeof raw?.cwd === "string" && raw.cwd ? basename(raw.cwd) : "";
165
+ return cwd || (typeof raw?.session_id === "string" ? raw.session_id.slice(0, 8) : "a session");
166
+ }
167
+
100
168
  export function blockNotice(raw, product) {
101
- const cwd = typeof raw.cwd === "string" && raw.cwd ? basename(raw.cwd) : "";
102
- const who = cwd || (typeof raw.session_id === "string" ? raw.session_id.slice(0, 8) : "a session");
103
- const said = typeof raw.message === "string" && raw.message ? raw.message : "Needs your permission";
169
+ const who = whoOf(raw);
170
+ if (raw.hook_event_name === "Stop") return { title: `${who} — ${product}`, body: turnBody(raw) };
171
+ const fallback = raw.notification_type === "idle_prompt" ? "Waiting for your input" : "Needs your permission";
172
+ const said = typeof raw.message === "string" && raw.message ? raw.message : fallback;
104
173
  return { title: `${who} — ${product}`, body: said };
105
174
  }
106
175
 
@@ -111,20 +180,20 @@ export function blockNotice(raw, product) {
111
180
  * notification — a channel that fires twelve times in a second is one the user
112
181
  * turns off within the minute:
113
182
  *
114
- * - a blocking prompt, per `isBlockingPrompt`
183
+ * - something an open deck would have played a tone for, per `isChimeEvent`
115
184
  * - NOTHING LISTENING. A page is a better surface than this in every way, so
116
185
  * wherever there is one, this stays out of the way.
117
186
  * - NOT A REPLAY. The server replays events.jsonl into itself at boot to
118
187
  * rebuild the ring, and that log holds every permission prompt of the last
119
188
  * 50MB. Without this gate, starting the deck would announce the entire
120
189
  * history of the machine at once.
121
- * - the session has not just been announced, per `QUIET_MS`.
190
+ * - the session has not just been announced, per `quietFor`.
122
191
  */
123
192
  export function shouldNotify(raw, { clients, replay, lastAt, now }) {
124
- if (!isBlockingPrompt(raw)) return false;
193
+ if (!isChimeEvent(raw)) return false;
125
194
  if (clients > 0) return false;
126
195
  if (replay) return false;
127
- if (lastAt != null && now - lastAt < QUIET_MS) return false;
196
+ if (lastAt != null && now - lastAt < quietFor(raw)) return false;
128
197
  return true;
129
198
  }
130
199
 
@@ -146,7 +215,7 @@ export function shouldNotify(raw, { clients, replay, lastAt, now }) {
146
215
  */
147
216
  export function createBlockNotifier({ notify, product, now = Date.now, enabled = true, onError }) {
148
217
  const isEnabled = typeof enabled === "function" ? enabled : () => enabled;
149
- /** session_id → when it was last announced. Bounded by pruning on read: a
218
+ /** memo key (see `memoKeys`) → when it was last announced. Bounded by pruning on read: a
150
219
  * long-lived server sees many sessions and this must not become a second
151
220
  * ring nobody empties. */
152
221
  const seen = new Map();
@@ -156,9 +225,11 @@ export function createBlockNotifier({ notify, product, now = Date.now, enabled =
156
225
  consider(raw, { clients, replay = false }) {
157
226
  if (!isEnabled()) return "off";
158
227
  const at = now();
159
- const id = raw?.session_id ?? "";
160
- if (!shouldNotify(raw, { clients, replay, lastAt: seen.get(id), now: at })) return "skipped";
161
- seen.set(id, at);
228
+ const { own, also } = memoKeys(raw);
229
+ const said = [own, ...also].map(k => seen.get(k)).filter(t => t != null);
230
+ const lastAt = said.length ? Math.max(...said) : undefined;
231
+ if (!shouldNotify(raw, { clients, replay, lastAt, now: at })) return "skipped";
232
+ seen.set(own, at);
162
233
  for (const [key, when] of seen) if (at - when > QUIET_MS) seen.delete(key);
163
234
  const { title, body } = blockNotice(raw, product);
164
235
  // Fire-and-forget, and the catch is not decoration. `notify` shells out —
@@ -166,7 +237,10 @@ export function createBlockNotifier({ notify, product, now = Date.now, enabled =
166
237
  // notification daemon the last of those simply is not there. A rejected
167
238
  // promise from a notification must never take down the ingest path that
168
239
  // every hook event in the process goes through.
169
- Promise.resolve(notify(title, body)).catch(err => onError?.(err));
240
+ // `who` alone as well: the desktop app shows its own name above every
241
+ // notification, so the "— ccdeck" the title carries for osascript's sake
242
+ // would be said twice there.
243
+ Promise.resolve(notify(title, body, { chime: chimeOf(raw), who: whoOf(raw) })).catch(err => onError?.(err));
170
244
  return "notified";
171
245
  },
172
246
  };
@@ -184,10 +184,25 @@ export async function notify(title, body, platform = process.platform, deps = {}
184
184
  // is a broken notification rather than an execution — fixed anyway, because
185
185
  // the rule is "never let a user string be read as an option" and a rule with
186
186
  // an exception is a rule somebody will apply to the wrong call next time.
187
- const r = await exec("notify-send", ["--", title, body]).catch(() => null);
187
+ //
188
+ // AND THE BODY IS ESCAPED, BECAUSE NOTIFY-SEND'S BODY IS MARKUP. The
189
+ // freedesktop spec lets a server read `<b>`, `<i>`, `<a>` and entities in the
190
+ // body, and GNOME, KDE, dunst and mako all do — so a bare `&` or `<` is a
191
+ // parse error there, and the usual result is an empty or missing body. That
192
+ // was theoretical while the body was CC's own sentence or a host name. It is
193
+ // not now that a finished turn quotes the agent's last reply, which is code
194
+ // and arrows as often as not. The summary is plain text in the spec, so the
195
+ // title is left as it is: escaping it would print `&amp;` on every daemon.
196
+ const r = await exec("notify-send", ["--", title, markupEscape(body)]).catch(() => null);
188
197
  return r?.ok === true;
189
198
  }
190
199
 
200
+ /** Text as notify-send's body markup reads it: the three characters that can
201
+ * start a tag or an entity, and nothing else. */
202
+ export function markupEscape(text) {
203
+ return String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
204
+ }
205
+
191
206
  /** Close one tab by its exact URL. macOS only; see `available`. */
192
207
  export async function closeTab(browserKey, url, platform = process.platform, deps = {}) {
193
208
  if (platform !== "darwin") return { ok: false, reason: "unsupported" };
@@ -11,6 +11,7 @@
11
11
  // the current call always serves from the already-installed copy. If the
12
12
  // managed install is missing/broken we fall back to the old npx path so the
13
13
  // feature still works on a fresh machine.
14
+ import { createRequire } from "node:module";
14
15
  import { spawn } from "node:child_process";
15
16
  import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
16
17
  import path from "node:path";
@@ -738,12 +739,43 @@ function discardDamagedInstall(runner, err) {
738
739
  export const userSpec = (file, args = [], platform = process.platform) =>
739
740
  spawnSpec(file, args, platform);
740
741
 
742
+ /**
743
+ * The platform binary ccusage's own cli.js would run, resolved the way cli.js
744
+ * resolves it (`@ccusage/ccusage-<platform>-<arch>/bin/ccusage[.exe]`, from
745
+ * the package's own directory), or null when there is none.
746
+ *
747
+ * WINDOWS ONLY, and for one reason: cli.js starts that binary WITHOUT hiding
748
+ * its console. The deck runs with no console of its own (detached, or inside
749
+ * the desktop app), so the binary is given a new one — and with Windows
750
+ * Terminal as the default terminal that is a window flashing up on every
751
+ * usage read, measured on a Windows 10 box as a CASCADIA_HOSTING_WINDOW_CLASS
752
+ * hosting ccusage. Running the binary directly, with windowsHide, is what
753
+ * cli.js does minus the window. Elsewhere nothing flashes, and the wrapper
754
+ * stays in the path it has always been in.
755
+ *
756
+ * Exported for tests; `resolve` is injectable.
757
+ */
758
+ export function nativeCcusage(entry, platform = process.platform, arch = process.arch, resolve = null) {
759
+ if (platform !== "win32" || typeof entry !== "string" || !entry) return null;
760
+ const pkg = arch === "arm64" ? "@ccusage/ccusage-win32-arm64" : arch === "x64" ? "@ccusage/ccusage-win32-x64" : null;
761
+ if (!pkg) return null;
762
+ try {
763
+ const req = resolve ?? createRequire(entry).resolve;
764
+ return req(`${pkg}/bin/ccusage.exe`);
765
+ } catch {
766
+ return null;
767
+ }
768
+ }
769
+
741
770
  // One attempt with one runner. No branch gets a shell. The managed install is
742
771
  // `node <entry> …`, which never needed one; the user's own copy and the npx
743
772
  // fallback are routed through spawnSpec instead — see npxSpec and userSpec, and
744
773
  // note that `args` here ends in whatever /api/ccusage was asked for.
745
774
  function runOnce(runner, args) {
746
- const { file, args: full, opts } = runner.kind === "node"
775
+ const native = runner.kind === "node" ? nativeCcusage(runner.entry) : null;
776
+ const { file, args: full, opts } = native
777
+ ? { file: native, args, opts: {} }
778
+ : runner.kind === "node"
747
779
  ? { file: process.execPath, args: [runner.entry, ...args], opts: {} }
748
780
  : runner.kind === "path"
749
781
  ? userSpec(runner.file, args)
@@ -67,7 +67,8 @@ export const prefsPath = (home = deckDataDir()) => join(prefsDir(home), "prefs.j
67
67
  * `notifications` defaults OFF since 3.22.7. The deck's own sounds are how it
68
68
  * gets attention by default; a desktop notification is something a person
69
69
  * turns on. It defaulted on from 3.7.0 until then, and a deck that saved that
70
- * `true` keeps it.
70
+ * `true` keeps it. On, a CLOSED deck notifies wherever an open one would have
71
+ * played a sound (block-notify.mjs `isChimeEvent`).
71
72
  */
72
73
  export const DEFAULTS = Object.freeze({
73
74
  notifications: false,
@@ -109,6 +110,15 @@ export const DEFAULTS = Object.freeze({
109
110
  // of one person's machines show each other where they are working; off, and
110
111
  // paired decks read "current account hidden" instead.
111
112
  shareActive: true,
113
+ // FINDING THIS PERSON'S OTHER MACHINES OVER TAILSCALE, off until they turn
114
+ // it on: it is a new path off this machine, and the owner chooses it. Its
115
+ // two permissions ship on, like the local pair above, and they are narrower
116
+ // than those by construction — they only ever answer for a machine signed
117
+ // in to the same Tailscale account as this one (see tailscale.mjs), never
118
+ // for a colleague's node or one shared in from another tailnet.
119
+ tailscale: false,
120
+ tailscaleAsk: true,
121
+ tailscaleAccept: true,
112
122
  // What somebody HERE calls another deck, keyed by its fingerprint. The name
113
123
  // a deck gives itself is its owner's to choose; this is the other half.
114
124
  aliases: Object.freeze({}),
@@ -217,6 +227,11 @@ function normaliseLan(raw) {
217
227
  // Whether paired decks are told which shared account this one is on. Absent
218
228
  // is on — the default above — and only a real boolean turns it off.
219
229
  shareActive: typeof src.shareActive === "boolean" ? src.shareActive : DEFAULTS.lan.shareActive,
230
+ // The Tailscale switch and its two permissions. Only a real boolean
231
+ // overrides a default, as above.
232
+ tailscale: typeof src.tailscale === "boolean" ? src.tailscale : DEFAULTS.lan.tailscale,
233
+ tailscaleAsk: typeof src.tailscaleAsk === "boolean" ? src.tailscaleAsk : DEFAULTS.lan.tailscaleAsk,
234
+ tailscaleAccept: typeof src.tailscaleAccept === "boolean" ? src.tailscaleAccept : DEFAULTS.lan.tailscaleAccept,
220
235
  };
221
236
  }
222
237
 
@@ -36,6 +36,10 @@ import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdate
36
36
  import { createPresence } from "./presence.mjs";
37
37
  import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, updatePrefs, writePrefs } from "./deck-prefs.mjs";
38
38
  import { createEngine, defaultName } from "./lan-engine.mjs";
39
+ import { createTailnet, IDLE_MS as TAILNET_IDLE_MS } from "./tailscale.mjs";
40
+ import { portHolder } from "./port-holder.mjs";
41
+ import { createRouteCheck } from "./route-via.mjs";
42
+ import { DISCOVERY_PORT } from "./lan-socket.mjs";
39
43
  import { aboutThisDeck } from "./lan-about.mjs";
40
44
  import { MAC_FW, PROBE_PS, UFW_CONF, UFW_DEFAULTS, isActive, localAliases, reachability, readMacProbe, readProbe, readUfw, silentInbound } from "./lan-reach.mjs";
41
45
  import { run } from "./exec.mjs";
@@ -366,6 +370,28 @@ let nextSeq = 1;
366
370
  // duplicate" instead of silently dropping the live stream.
367
371
  const SEQ_EPOCH = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
368
372
  const sseClients = new Set(); // res handles
373
+ // The desktop app's own connections (#1160): subscribed like any client, so
374
+ // they receive every event, and ALSO kept here so they are never counted as a
375
+ // page. The app reads the stream to draw its tray icon; if that counted as a
376
+ // tab, the deck would believe a page was open whenever the app ran, and the
377
+ // notifications a closed deck raises (block-notify.mjs) would never fire —
378
+ // the one thing the app exists to deliver. See `pageCount`.
379
+ const trayClients = new Set();
380
+
381
+ /** Pages — deck tabs and the app's window — as opposed to the app's tray
382
+ * connection, which is a reader and not somebody looking. */
383
+ function pageCount() {
384
+ return sseClients.size - trayClients.size;
385
+ }
386
+
387
+ /** A notification for the desktop app to raise as itself, instead of the OS
388
+ * helper raising it as Script Editor (osascript) or PowerShell. */
389
+ function notifyTrays(title, body, { chime = null, who = null } = {}) {
390
+ // The app names itself above the notification, so the title is the session.
391
+ const line = `event: notify\ndata: ${JSON.stringify({ title: who ?? title, body, chime })}\n\n`;
392
+ for (const res of trayClients) writeSse(res, line);
393
+ return true;
394
+ }
369
395
 
370
396
  let persistPath = null; // absolute path to events.jsonl, or null
371
397
 
@@ -3438,6 +3464,7 @@ export function queuedBytes(res) {
3438
3464
  * before they are subscribed — is a harmless no-op. */
3439
3465
  function dropSse(res) {
3440
3466
  sseClients.delete(res);
3467
+ trayClients.delete(res);
3441
3468
  // Destroying the socket is what makes the request emit 'close', which is
3442
3469
  // where the ping interval is cleared.
3443
3470
  try { res.destroy(); } catch {}
@@ -3677,7 +3704,10 @@ let _prefs = { ...PREF_DEFAULTS };
3677
3704
  const _prefsRead = readPrefs().then(p => { _prefs = p; }).catch(() => {});
3678
3705
 
3679
3706
  const blockNotifier = createBlockNotifier({
3680
- notify: osNotify,
3707
+ // The desktop app, while it is connected, raises the notification itself —
3708
+ // under its own name, icon and permission, with a click that opens its
3709
+ // window. Only when no app is listening does the OS helper speak.
3710
+ notify: (title, body, meta) => (trayClients.size > 0 ? notifyTrays(title, body, meta) : osNotify(title, body)),
3681
3711
  product: PRODUCT,
3682
3712
  // A function, not a boolean: this is a switch a person flips from the sound
3683
3713
  // menu while the deck is running, and a mute that waited for a restart would
@@ -3815,7 +3845,17 @@ async function handlePresence(req, res) {
3815
3845
  // The engine is built once and told the settings; it opens and closes its own
3816
3846
  // sockets as those change. Nothing here touches a credential — see
3817
3847
  // lan-engine.mjs, which passes an opaque blob between two claude-swap commands.
3848
+ /** This machine's view of its tailnet, read through the Tailscale CLI — see
3849
+ * tailscale.mjs. Built whatever the switch says: the dialog asks it whether
3850
+ * Tailscale is here at all before anybody can turn discovery on. */
3851
+ const tailnet = createTailnet();
3852
+
3818
3853
  const lanEngine = createEngine({
3854
+ tailnet,
3855
+ // Who holds the discovery port when it is taken, so the panel can say.
3856
+ portHolder: () => portHolder(DISCOVERY_PORT),
3857
+ // Where each broadcast would leave by, so none goes into a VPN tunnel.
3858
+ routes: createRouteCheck(),
3819
3859
  // This deck's version and machine, for the decks it is paired with and
3820
3860
  // nobody else. RUNNING_VERSION rather than a fresh read, for the reason it
3821
3861
  // is read at import: it is what this process actually runs.
@@ -4023,6 +4063,11 @@ export function lanApplyFields(prefs, { load = false, env = process.env } = {})
4023
4063
  autoAccept: lan.autoAccept !== false,
4024
4064
  // Whether paired decks are told which shared account this one is on.
4025
4065
  shareActive: lan.shareActive !== false,
4066
+ // Discovery over Tailscale, off unless somebody turned it on, and the two
4067
+ // permissions that answer for the owner's own machines there.
4068
+ tailscale: lan.tailscale === true,
4069
+ tailscaleAsk: lan.tailscaleAsk !== false,
4070
+ tailscaleAccept: lan.tailscaleAccept !== false,
4026
4071
  // Names somebody here gave other decks. The engine only hands them to
4027
4072
  // the page, so a change never restarts anything.
4028
4073
  aliases: lan.aliases && typeof lan.aliases === "object" ? lan.aliases : {},
@@ -4247,6 +4292,9 @@ function refreshReach() {
4247
4292
  * else is in the group. No passphrase, for the reason prefsPayload gives. */
4248
4293
  function handleLanStatus(req, res) {
4249
4294
  refreshReach();
4295
+ // Behind the answer, like the reach probe: the dialog's poll is what finds a
4296
+ // Tailscale somebody installed while the deck was running.
4297
+ if (lanEnabled(_prefs)) void tailnet.freshen(TAILNET_IDLE_MS);
4250
4298
  return send(res, 200, { ok: true, ...lanEngine.status(), reach: reachSaid });
4251
4299
  }
4252
4300
 
@@ -4547,7 +4595,7 @@ function pushEvent(raw, source, opts = {}) {
4547
4595
  // other means". The web notifier owns the case where a page exists, and this
4548
4596
  // owns the case where none does; the two never both fire, and neither has to
4549
4597
  // know the other exists. block-notify.mjs holds the gates and the cooldown.
4550
- blockNotifier.consider(raw, { clients: sseClients.size, replay: !!opts.replay });
4598
+ blockNotifier.consider(raw, { clients: pageCount(), replay: !!opts.replay });
4551
4599
 
4552
4600
  // Whether a turn is running, for the away-update. Not from a replay: the log
4553
4601
  // is history, and a turn it shows open is one that ended in another process.
@@ -5300,7 +5348,7 @@ function handleSse(req, res) {
5300
5348
  // is already committed to a 200 and its own failure path is to hang up — so
5301
5349
  // start it, keep the router's contract of returning nothing, and make sure a
5302
5350
  // rejection ends the stream rather than the process.
5303
- resumeSse(req, res, lastId).catch(() => dropSse(res));
5351
+ resumeSse(req, res, lastId, { tray: isTrayRequest(req) }).catch(() => dropSse(res));
5304
5352
  }
5305
5353
 
5306
5354
  /**
@@ -5314,7 +5362,17 @@ function handleSse(req, res) {
5314
5362
  * neither stream — a hole the client cannot even ask for again, its last id
5315
5363
  * having moved past it.
5316
5364
  */
5317
- async function resumeSse(req, res, lastId) {
5365
+ /**
5366
+ * Is this the desktop app's tray connection? Only with the deck's own token:
5367
+ * a page cannot opt itself out of being counted, because that would let any
5368
+ * tab switch on the closed-deck notifications over itself.
5369
+ */
5370
+ function isTrayRequest(req) {
5371
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
5372
+ return url.searchParams.get("role") === "tray" && presentsDeckToken(req.headers ?? {});
5373
+ }
5374
+
5375
+ async function resumeSse(req, res, lastId, { tray = false } = {}) {
5318
5376
  let sentThrough = lastId;
5319
5377
  let ping = null;
5320
5378
  let closed = false;
@@ -5322,6 +5380,7 @@ async function resumeSse(req, res, lastId) {
5322
5380
  closed = true;
5323
5381
  if (ping) clearInterval(ping);
5324
5382
  sseClients.delete(res);
5383
+ trayClients.delete(res);
5325
5384
  });
5326
5385
 
5327
5386
  for (;;) {
@@ -5393,6 +5452,7 @@ async function resumeSse(req, res, lastId) {
5393
5452
  // takes it back out of the set — the same exit writeSse uses.
5394
5453
  const flushed = writeResume(res, `event: replay-end\ndata: {}\n\n`);
5395
5454
  sseClients.add(res);
5455
+ if (tray) trayClients.add(res);
5396
5456
  // Through writeSse like every other frame: on a client that has stopped
5397
5457
  // reading, the ping is the one thing still being written between events, and
5398
5458
  // it is what eventually reveals the socket as unrecoverable.
@@ -6576,7 +6636,9 @@ function handleHealth(_req, res) {
6576
6636
  ok: true,
6577
6637
  name: "agent-dag",
6578
6638
  seq: nextSeq - 1,
6579
- clients: sseClients.size,
6639
+ clients: pageCount(),
6640
+ // The desktop app's tray connections, which are not pages (#1160).
6641
+ trays: trayClients.size,
6580
6642
  uptimeMs: Math.round(process.uptime() * 1000),
6581
6643
  workspace: _workspace,
6582
6644
  providers: _providers,
@@ -5,6 +5,7 @@
5
5
  // sessions reach the same server through the rollout watcher instead, so one
6
6
  // running server still sees both CLIs. Re-runs are safe; entries are tagged
7
7
  // with __agent-dag and de-duped.
8
+ import { hookRuntime } from "./app-host.mjs";
8
9
  import { readFile, mkdir, unlink, rename, open, stat, chmod, realpath, readlink, utimes } from "node:fs/promises";
9
10
  import { existsSync } from "node:fs";
10
11
  import { join, resolve, dirname } from "node:path";
@@ -464,7 +465,9 @@ export async function installHooks({ provider = "claude", beforeWrite = null } =
464
465
  const { settings: current, raw: before } = await readSettingsForWrite(cfg.settingsPath);
465
466
 
466
467
  const hookPath = await installHookScript(cfg.hookInstallDir);
467
- const command = hookCommand(hookPath, provider);
468
+ // Through the desktop app's launcher when it started this deck — its own
469
+ // binary opens the app rather than running a script (app-host.mjs).
470
+ const command = hookCommand(hookPath, provider, hookRuntime());
468
471
  await ensureDir(cfg.ensureDir);
469
472
  // Discovery dir is shared across providers — always make sure it exists.
470
473
  await ensureDir(AGENT_DAG_DIR);