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.
- package/bin/agent-dag.js +23 -1
- package/dist/web/assets/{BrowserWatchModal-Chcet-7r.js → BrowserWatchModal-CnAbmyRH.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-BIdS0lCL.js → UsageHistoryModal-BCpieMjP.js} +1 -1
- package/dist/web/assets/index-CO8B_qBe.js +297 -0
- package/dist/web/assets/{index-7a7ISatq.css → index-DlaHgzye.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +3 -1
- package/src/server/app-host.mjs +37 -0
- package/src/server/block-notify.mjs +106 -32
- package/src/server/browser-react.mjs +16 -1
- package/src/server/ccusage.mjs +33 -1
- package/src/server/deck-prefs.mjs +16 -1
- package/src/server/index.mjs +67 -5
- package/src/server/installer.mjs +4 -1
- package/src/server/lan-engine.mjs +193 -16
- package/src/server/lan-reach.mjs +26 -8
- package/src/server/lan-socket.mjs +250 -67
- package/src/server/lan-sync.mjs +15 -2
- package/src/server/login-service.mjs +4 -0
- package/src/server/port-holder.mjs +66 -0
- package/src/server/route-via.mjs +109 -0
- package/src/server/self-update.mjs +6 -2
- package/src/server/tailscale.mjs +269 -0
- package/dist/web/assets/index-BoR2dMqR.js +0 -297
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
48
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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": {
|
package/release-notes.json
CHANGED
|
@@ -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
|
-
*
|
|
59
|
-
* human. Two of the three kinds CC emits.
|
|
64
|
+
* Would an open deck have played a tone for this?
|
|
60
65
|
*
|
|
61
|
-
*
|
|
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
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
78
|
-
return !!raw
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
const
|
|
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
|
|
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 `
|
|
190
|
+
* - the session has not just been announced, per `quietFor`.
|
|
122
191
|
*/
|
|
123
192
|
export function shouldNotify(raw, { clients, replay, lastAt, now }) {
|
|
124
|
-
if (!
|
|
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 <
|
|
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
|
-
/**
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `&` 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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" };
|
package/src/server/ccusage.mjs
CHANGED
|
@@ -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
|
|
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
|
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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,
|
package/src/server/installer.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|