ccdeck 3.44.0 → 3.45.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.
Files changed (44) hide show
  1. package/README.md +14 -5
  2. package/THIRD_PARTY_NOTICES.md +27 -0
  3. package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
  4. package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
  5. package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
  6. package/dist/web/assets/{TrafficRadar-Ch0LVdSl.js → TrafficRadar-D1gZCIwX.js} +1 -1
  7. package/dist/web/assets/{UsageHistoryModal-Du0ZkIJ1.js → UsageHistoryModal-DAhplVju.js} +1 -1
  8. package/dist/web/assets/app-BW3NYUYB.js +318 -0
  9. package/dist/web/assets/app-C5RY_wZ4.css +1 -0
  10. package/dist/web/assets/{index-BQbOfxbL.js → index-r8Ymr47Q.js} +1 -1
  11. package/dist/web/index.html +3 -3
  12. package/package.json +1 -1
  13. package/release-notes.json +18 -0
  14. package/src/server/account-notify.mjs +68 -6
  15. package/src/server/account-watch.mjs +61 -2
  16. package/src/server/claude-accounts.mjs +12 -2
  17. package/src/server/deck-prefs.mjs +15 -5
  18. package/src/server/feature-use.mjs +11 -1
  19. package/src/server/forge-http.mjs +514 -0
  20. package/src/server/forge-public-fetch.mjs +228 -0
  21. package/src/server/forge-routes.mjs +154 -0
  22. package/src/server/github-api.mjs +1369 -0
  23. package/src/server/gitlab-api.mjs +1271 -0
  24. package/src/server/index.mjs +6 -2
  25. package/src/server/integrations/cache.mjs +42 -0
  26. package/src/server/integrations/errors.mjs +167 -0
  27. package/src/server/integrations/forge.mjs +76 -0
  28. package/src/server/integrations/jira.mjs +1026 -0
  29. package/src/server/integrations/providers.mjs +11 -0
  30. package/src/server/integrations/proxy.mjs +314 -0
  31. package/src/server/integrations/registry.mjs +142 -0
  32. package/src/server/integrations/routes.mjs +498 -0
  33. package/src/server/integrations/store.mjs +226 -0
  34. package/src/server/lan-deck.mjs +7 -0
  35. package/src/server/lan-elsewhere.d.mts +30 -0
  36. package/src/server/lan-elsewhere.mjs +137 -0
  37. package/src/server/org-telemetry.mjs +173 -0
  38. package/src/server/pinned-build.mjs +3 -0
  39. package/src/server/request-gates.mjs +19 -0
  40. package/src/server/usage-day.mjs +2 -0
  41. package/dist/web/assets/BrowserWatchModal-DMIUygWz.js +0 -1
  42. package/dist/web/assets/RadioPanel-DNB_AHSs.js +0 -1
  43. package/dist/web/assets/app-CQIstkMF.js +0 -318
  44. package/dist/web/assets/app-Ch-0CYlK.css +0 -1
@@ -1 +1 @@
1
- import"./app-CQIstkMF.js";(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();
1
+ import"./app-BW3NYUYB.js";(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();
@@ -77,9 +77,9 @@
77
77
  document.documentElement.setAttribute("data-density", density === "tightest" || density === "tighter" || density === "compact" || density === "comfortable" ? density : "default");
78
78
  })();
79
79
  </script>
80
- <script type="module" crossorigin src="/assets/index-BQbOfxbL.js"></script>
81
- <link rel="modulepreload" crossorigin href="/assets/app-CQIstkMF.js">
82
- <link rel="stylesheet" crossorigin href="/assets/app-Ch-0CYlK.css">
80
+ <script type="module" crossorigin src="/assets/index-r8Ymr47Q.js"></script>
81
+ <link rel="modulepreload" crossorigin href="/assets/app-BW3NYUYB.js">
82
+ <link rel="stylesheet" crossorigin href="/assets/app-C5RY_wZ4.css">
83
83
  </head>
84
84
  <body>
85
85
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.44.0",
3
+ "version": "3.45.0",
4
4
  "description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -80,6 +80,24 @@
80
80
  "3.43.1",
81
81
  "3.43.3"
82
82
  ],
83
+ "3.45.0": [
84
+ {
85
+ "title": "🔗 Read-only Jira, GitHub and GitLab panels beside your agents",
86
+ "body": "Settings › Integrations connects Jira (Cloud, Server or Data Center), GitHub, and GitLab.com or your own GitLab, each with a token, and a control on the left stripe opens its panel: a Jira board's backlog in Jira's own order, or a project's branches, pull or merge requests, pipelines and issues, with the item beside the list. The panels only read: they never comment, merge, re-run a job or change an issue. The token stays on this machine, in a file only you can read, and goes only to that connection's own API. Nothing is read until you connect a tracker, and then only while its panel is open."
87
+ },
88
+ {
89
+ "title": "📤 Each account says what its organization's telemetry sends",
90
+ "body": "A Team or Enterprise organization can turn on Claude Code's telemetry for every member, and Claude Code applies it without asking. Each account of such an organization now carries a line under its name: “Telemetry · usage data”, or “Telemetry · prompts, tool details” in the warning colour when your prompts and tool details are sent too. Its tooltip names the organization and where the data goes. After a switch, Auto-switch's notification and the row's “Now active” note say it too. The deck changes nothing; it learns what the organization turns on from a Claude Code session run on one of its accounts, and until then that account shows no line."
91
+ },
92
+ {
93
+ "title": "⚠️ A warning when a paired deck is on the same Claude login",
94
+ "body": "A Claude login renews once, so when two machines use one copied login, the one that renews second is signed out. When a paired deck on Local network says it is on the login this deck is using, the live account's row now says “Also active on” and names that deck, with what to do: use a different account on one of the machines, or sign in on each one separately. A switch onto such a login says so too, and so does one desktop notification per incident, “Login active on another deck” in Settings › Notifications, which is on by default."
95
+ },
96
+ {
97
+ "title": "📱 A phone opens one panel at most when the deck loads",
98
+ "body": "On a window 640 px wide or less, a reload used to open Accounts, Usage and the machine panel on top of each other, with none of the board in sight. It now opens at most one of them, preferring Usage over the machine panel, and Accounts and the session list open from the dock. A wider window loads exactly as before, and nothing you chose is forgotten."
99
+ }
100
+ ],
83
101
  "3.44.0": [
84
102
  {
85
103
  "title": "⛔ A turn that stops on an error now shows as stopped",
@@ -1,4 +1,4 @@
1
- // Four notifications about the accounts the deck is on, and the rules for when
1
+ // Five notifications about the accounts the deck is on, and the rules for when
2
2
  // each is said.
3
3
  //
4
4
  // SWAP the deck's own auto-switch tick moved the live Claude account.
@@ -13,6 +13,11 @@
13
13
  // every session on the machine shares the login, so they stop
14
14
  // together — with how many stopped and that each needs a
15
15
  // "continue" once a working login is active.
16
+ // ELSEWHERE the Claude account this deck is on is the one a paired deck says
17
+ // it is on too (#2192). Said once per incident — from the moment
18
+ // both are on it until one of them is not — because the next
19
+ // renewal signs one of the two machines out, and nothing on the
20
+ // desktop says so before it does. See lan-elsewhere.mjs.
16
21
  //
17
22
  // SPARSE BY CONSTRUCTION. Each window is a record keyed by provider, account
18
23
  // and window, holding the reset instant it is about and the levels already said
@@ -42,6 +47,7 @@
42
47
  // injected — the shape block-notify.mjs uses, for the reason it gives: what the
43
48
  // suite cannot run is what drifts.
44
49
  import { switchCanCure } from "./stop-failure.mjs";
50
+ import { deckList } from "./lan-elsewhere.mjs";
45
51
 
46
52
  /** The levels a window is announced at, lowest first. */
47
53
  export const LEVELS = Object.freeze([90, 100]);
@@ -403,6 +409,23 @@ export function stuckNotice({ fromName }) {
403
409
  return { who: SWAP_WHO, body: `${from}'s login expired · no other account is ready to switch to` };
404
410
  }
405
411
 
412
+ /**
413
+ * "Claude · work@x.com", "Also active on iMac. When one machine renews this
414
+ * login, the other is signed out — use a different account on one of them."
415
+ *
416
+ * The decks by the names Local network's list gives them. The reason is the
417
+ * sentence, because it is the whole of why this is worth a notification: the
418
+ * same account on two machines is ordinary, one copied login live on two is
419
+ * what breaks, and the remedy is one press on either machine.
420
+ */
421
+ export function elsewhereNotice({ accountName, decks }) {
422
+ return {
423
+ who: whoFor("claude", accountName),
424
+ body: `Also active on ${cut(deckList(decks), NAME_MAX)}. When one machine renews this login, the other is `
425
+ + "signed out — use a different account on one of them.",
426
+ };
427
+ }
428
+
406
429
  /** The records, as read back from disk: anything that is not one is dropped,
407
430
  * and so is one nobody has read for RECORD_KEEP_MS. */
408
431
  export function normaliseRecords(raw, now) {
@@ -435,9 +458,9 @@ function laterUnref(fn, ms) {
435
458
  *
436
459
  * notify(title, body, meta) the OS call — injected, never awaited by a poll
437
460
  * or a tick, and its failure goes to `onError`
438
- * settings() `{ swap, quota, reset, stopped }`, asked per
439
- * decision, so a switch pressed mid-run takes
440
- * effect at once
461
+ * settings() `{ swap, quota, reset, stopped, elsewhere }`,
462
+ * asked per decision, so a switch pressed mid-run
463
+ * takes effect at once
441
464
  * load() / save(records) the disk, both best effort
442
465
  * now, later the clock and the hold timer, for the suite
443
466
  *
@@ -459,6 +482,11 @@ export function createAccountNotifier({
459
482
  let policy = null;
460
483
  /** Threshold notices held for a tick, by record key. */
461
484
  const held = new Map();
485
+ /** The account whose ELSEWHERE incident is open — this deck and another on
486
+ * it — and therefore already said, or null. One at a time, because this
487
+ * deck is on one account at a time. Kept in memory: a restart in the middle
488
+ * of an incident says it once more, which is a restart's news as well. */
489
+ let elsewhereOpen = null;
462
490
  let holdTimer = null;
463
491
  let saving = Promise.resolve();
464
492
  /** The stop incident being gathered or held, or the one already said while
@@ -617,8 +645,13 @@ export function createAccountNotifier({
617
645
  * `{ email, organizationUuid, alias }` — read after the tick, for the names
618
646
  * and the keys of the two accounts a switch moved between. `startedAt` is
619
647
  * when the tick began, which decides whether it can answer for a stop.
648
+ *
649
+ * `telemetryOf`, when given, answers what an organization's telemetry
650
+ * settings turn on, as one sentence or null (org-telemetry.mjs's
651
+ * telemetryClause): the swap notice carries it for the account it moved to,
652
+ * because an unattended switch is exactly when nobody saw it coming.
620
653
  */
621
- async function tick(result, accounts, { startedAt = null } = {}) {
654
+ async function tick(result, accounts, { startedAt = null, telemetryOf = null } = {}) {
622
655
  try {
623
656
  await ready;
624
657
  if (!result || typeof result !== "object") return;
@@ -686,7 +719,12 @@ export function createAccountNotifier({
686
719
  cured.said = true;
687
720
  if (cured.timer) { clearTimeout(cured.timer); cured.timer = null; }
688
721
  }
689
- say(cured && s.stopped ? withStopped(notice, cured.ids.size) : notice);
722
+ const told = cured && s.stopped ? withStopped(notice, cured.ids.size) : notice;
723
+ // Only for an account the store and the tick agree on: an organization
724
+ // read off the wrong slot would put another company's policy on it.
725
+ const toOrg = to?.key ? accounts?.[String(result.to?.number)]?.organizationUuid : null;
726
+ const telemetry = toOrg && telemetryOf ? telemetryOf(toOrg) : null;
727
+ say(telemetry ? { ...told, body: `${told.body}\n${telemetry}` } : told);
690
728
  }
691
729
  await persist();
692
730
  } catch (err) {
@@ -721,11 +759,35 @@ export function createAccountNotifier({
721
759
  }
722
760
  }
723
761
 
762
+ /**
763
+ * What Local network says about the account this deck is on: `account` is
764
+ * `{ key, name }`, or null when this deck is on none, and `decks` the paired
765
+ * decks that say they are on it too, by name.
766
+ *
767
+ * ONCE PER INCIDENT. The incident opens when both are on it, is said then —
768
+ * if the switch is on at that moment — and closes when either moves off it
769
+ * or the other deck stops saying so (lan-elsewhere.mjs holds a deck's word
770
+ * for five minutes, which is what keeps a dropped round from closing it). A
771
+ * new one is news again; the same one, every minute, is not.
772
+ */
773
+ async function elsewhere(account, decks) {
774
+ try {
775
+ await ready;
776
+ const key = account?.key && Array.isArray(decks) && decks.length > 0 ? account.key : null;
777
+ if (key === elsewhereOpen) return;
778
+ elsewhereOpen = key;
779
+ if (key && settings().elsewhere) say(elsewhereNotice({ accountName: account.name, decks }));
780
+ } catch (err) {
781
+ onError?.(err);
782
+ }
783
+ }
784
+
724
785
  return {
725
786
  ready,
726
787
  observe,
727
788
  tick,
728
789
  stopped,
790
+ elsewhere,
729
791
  /** The loop was switched on or off. Off, nothing will carry what is held. */
730
792
  autoSwitch(on) {
731
793
  autoOn = on === true;
@@ -24,6 +24,10 @@ import { writeFileAtomic } from "./atomic-write.mjs";
24
24
  // What a StopFailure says, and the words a notification names a session by.
25
25
  import { stopFailureOf, switchCanCure } from "./stop-failure.mjs";
26
26
  import { whoOf } from "./block-notify.mjs";
27
+ // Which deck is on what, as Local network last heard it, and the identity rule
28
+ // its keys are made by (#2192).
29
+ import { elsewhereNow } from "./lan-elsewhere.mjs";
30
+ import { accountKey } from "./lan-copies.mjs";
27
31
 
28
32
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
29
33
 
@@ -42,10 +46,19 @@ export function recordsPath(home = deckDataDir()) {
42
46
  */
43
47
  export const WATCH_MS = 2 * 60_000;
44
48
 
49
+ /**
50
+ * How often the notifier asks Local network whether the account this deck is
51
+ * on is live on another deck too (#2192). A round is a minute, so asking more
52
+ * often finds nothing new; and with no deck on any account of this one's, the
53
+ * look is the engine's status and nothing else — the store is not read.
54
+ */
55
+ export const ELSEWHERE_WATCH_MS = 60_000;
56
+
45
57
  let _deps = null;
46
58
  let _notifier = null;
47
59
  let _autoOn = false;
48
60
  let _timer = null;
61
+ let _elsewhereTimer = null;
49
62
  let _watching = null;
50
63
  /** The auto-switch loop's "tick now", handed in by the loop as it loads
51
64
  * (cswap-auto-loop.mjs `tickSoon`). Null until then, and on a deck that never
@@ -110,11 +123,23 @@ export function noteAutoTick(result, meta = {}) {
110
123
  if (!n || !result) return Promise.resolve();
111
124
  // A dead login with nowhere to go is named too, so its notice can say whose.
112
125
  const named = result.switched || result.reason === "login-expired";
113
- return (named ? storeAccounts() : Promise.resolve(null))
114
- .then(accounts => n.tick(result, accounts, meta))
126
+ return (named ? Promise.all([storeAccounts(), result.switched ? telemetryReader() : null]) : Promise.resolve([null, null]))
127
+ .then(([accounts, telemetryOf]) => n.tick(result, accounts, { ...meta, telemetryOf }))
115
128
  .catch(() => {});
116
129
  }
117
130
 
131
+ /** What each organization's settings were last seen to turn on, as the
132
+ * sentence a swap notice carries (org-telemetry.mjs), or null. */
133
+ async function telemetryReader() {
134
+ try {
135
+ const { knownOrgTelemetry, telemetryClause } = await import(pathToFileURL(join(PKG_ROOT, "src/server/org-telemetry.mjs")).href);
136
+ const records = await knownOrgTelemetry();
137
+ return org => telemetryClause(records?.[org]);
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
118
143
  /** The loop's way to be asked for a tick now. Called once, as it loads. */
119
144
  export function connectEarlyTick(fn) {
120
145
  _tickSoon = typeof fn === "function" ? fn : null;
@@ -199,6 +224,35 @@ export function watchOnce(providers) {
199
224
  return _watching;
200
225
  }
201
226
 
227
+ /**
228
+ * One look at whether the account this deck is on is one a paired deck says it
229
+ * is on too, handed to the notifier, which says it once per incident.
230
+ *
231
+ * The store is read only when some deck is on some account, and through the
232
+ * roster's own cache, which the panel and every LAN round already keep warm. A
233
+ * roster that could not be read says nothing and closes nothing: not knowing
234
+ * which account this deck is on is not the same as being on none.
235
+ */
236
+ export async function watchElsewhereOnce(now = Date.now()) {
237
+ const n = notifier();
238
+ if (!n) return;
239
+ try {
240
+ const on = elsewhereNow(now);
241
+ if (on.size === 0) return n.elsewhere(null, []);
242
+ const { fetchClaudeAccounts } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
243
+ const got = await fetchClaudeAccounts();
244
+ if (!got?.ok || !Array.isArray(got.accounts)) return;
245
+ const live = got.accounts.find(a => a?.active === true);
246
+ if (!live?.email) return n.elsewhere(null, []);
247
+ const key = accountKey(live.email, live.orgUuid);
248
+ const name = typeof live.alias === "string" && live.alias.trim() ? live.alias.trim() : live.email;
249
+ return n.elsewhere({ key, name }, on.get(key) ?? []);
250
+ } catch {
251
+ // The roster's own failures are its panel's to say; here there is nothing
252
+ // to compare this time.
253
+ }
254
+ }
255
+
202
256
  /**
203
257
  * Start reading quota on a timer, from startServer once the port is bound —
204
258
  * never at import, because a launcher imports the server module only to ask
@@ -210,4 +264,9 @@ export function startAccountWatch({ providers } = {}) {
210
264
  _timer.unref?.();
211
265
  const first = setTimeout(() => { watchOnce(providers).catch(() => {}); }, 15_000);
212
266
  first.unref?.();
267
+ // And Local network's word on the account this deck is on, on its own clock:
268
+ // it costs nothing while no deck is on anything, and it is said whichever of
269
+ // the quota switches is on.
270
+ _elsewhereTimer = setInterval(() => { watchElsewhereOnce().catch(() => {}); }, ELSEWHERE_WATCH_MS);
271
+ _elsewhereTimer.unref?.();
213
272
  }
@@ -52,6 +52,10 @@ import { nextReadAt, nudgeCollector } from "./claude-collector.mjs";
52
52
  // (#1893). Pure, and keyed the way LAN sync keys accounts.
53
53
  import { hasRecovered, reauthFor } from "./account-origins.mjs";
54
54
  import { accountKey } from "./lan-copies.mjs";
55
+ // What each organization's Claude settings were last seen to turn on, so a row
56
+ // can say it before anyone switches to the account. See its header for when the
57
+ // shared remote-settings.json can be pinned on an organization at all.
58
+ import { observeOrgTelemetry } from "./org-telemetry.mjs";
55
59
  // Whether a switch target's stored login is known dead, and whether a
56
60
  // collection would read it first (#2184). Pure, and imports nothing.
57
61
  import { collectionWouldRead, deadLoginOf } from "./login-guard.mjs";
@@ -467,6 +471,7 @@ async function readRoster(now, gen) {
467
471
  }
468
472
 
469
473
  const rows = await readUsageRows(root);
474
+ const orgTelemetry = await observeOrgTelemetry({ seq }).catch(() => ({}));
470
475
 
471
476
  // Kick a collection for the NEXT poll if anything is due — either because
472
477
  // claude-swap's schedule says so, or because an account has never been
@@ -500,7 +505,7 @@ async function readRoster(now, gen) {
500
505
  const row = rows[num];
501
506
  const key = accountKey(acct.email, acct.organizationUuid);
502
507
  const origin = Object.hasOwn(origins, key) ? origins[key] : null;
503
- const shown = rosterRow({ seq, num, acct, row, identity, now, key, origin });
508
+ const shown = rosterRow({ seq, num, acct, row, identity, now, key, origin, telemetry: orgTelemetry[acct.organizationUuid] ?? null });
504
509
  accounts.push(shown);
505
510
  // A put-off incident is over once a read succeeds after it, and the
506
511
  // put-off goes with it (#1893) — said here, where the read is, rather than
@@ -527,7 +532,7 @@ function observedMs(seconds) {
527
532
  return Number.isFinite(ms) && ms > 0 && Number.isFinite(new Date(ms).getTime()) ? ms : null;
528
533
  }
529
534
 
530
- function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
535
+ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null, telemetry = null }) {
531
536
  const matches = rowIsFor(row, acct);
532
537
  const good = matches ? row.lastGood : null;
533
538
 
@@ -617,6 +622,11 @@ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
617
622
  // claude-swap has refused its login since: `{ key, since, dismissed }`.
618
623
  // `key` and `since` together name it, for "Not now" to send back.
619
624
  reauth: reauth ? { key, ...reauth } : null,
625
+ // What this account's organization last turned on for Claude Code's
626
+ // telemetry export, when the deck has seen its settings (org-telemetry.mjs):
627
+ // `{ orgName, level, content, destinations, seenAt }`. `level` "off" was
628
+ // seen off; null was never seen, which is unknown and not "off".
629
+ telemetry: telemetry ?? null,
620
630
  };
621
631
  }
622
632
 
@@ -88,6 +88,10 @@ export const DEFAULTS = Object.freeze({
88
88
  // incident (account-notify.mjs). ON for the swap's reason: it is rare, every
89
89
  // running session is stopped when it happens, and nothing else says so.
90
90
  notifyStopped: true,
91
+ // A login this deck is on, live on another deck too (#2192). ON, like the
92
+ // swap: it is rare, it is said once per incident, and nothing else on the
93
+ // desktop says it before one of the two machines is signed out.
94
+ notifyElsewhere: true,
91
95
  // Continue a BACKGROUND session whose turn stopped on an expired login or a
92
96
  // usage limit, once a working login with room is active again — once per
93
97
  // stop (stop-continue.mjs, #2193). OFF: it stops and restarts a session's
@@ -344,6 +348,7 @@ export function normalise(raw) {
344
348
  notifyQuota: flagOr(src.notifyQuota, DEFAULTS.notifyQuota),
345
349
  notifyReset: flagOr(src.notifyReset, DEFAULTS.notifyReset),
346
350
  notifyStopped: flagOr(src.notifyStopped, DEFAULTS.notifyStopped),
351
+ notifyElsewhere: flagOr(src.notifyElsewhere, DEFAULTS.notifyElsewhere),
347
352
  continueStopped: flagOr(src.continueStopped, DEFAULTS.continueStopped),
348
353
  tourSeen: flagOr(src.tourSeen, DEFAULTS.tourSeen),
349
354
  autoUpdate: flagOr(src.autoUpdate, DEFAULTS.autoUpdate),
@@ -729,7 +734,10 @@ export function publicPrefs(prefs) {
729
734
  * here — prefs-route-fields.test.ts lists them.
730
735
  */
731
736
  export const PAGE_FIELDS = Object.freeze({
732
- top: Object.freeze(["notifications", "notifySwap", "notifyQuota", "notifyReset", "notifyStopped", "continueStopped", "tourSeen", "autoUpdate", "recoverCodexSessions", "recoverClaudeSessions"]),
737
+ top: Object.freeze([
738
+ "notifications", "notifySwap", "notifyQuota", "notifyReset", "notifyStopped", "notifyElsewhere", "continueStopped",
739
+ "tourSeen", "autoUpdate", "recoverCodexSessions", "recoverClaudeSessions",
740
+ ]),
733
741
  lan: Object.freeze([
734
742
  "enabled", "name", "shared", "manual", "shareActive", "sendInUse", "pairingMode",
735
743
  "autoAsk", "autoAccept", "tailscale", "tailscaleAsk", "tailscaleAccept",
@@ -763,15 +771,17 @@ export function notificationsOn(prefs, env = process.env) {
763
771
  }
764
772
 
765
773
  /**
766
- * Which account notifications may go out right now: the four switches, each
767
- * on its own, and all four off when the machine vetoed notifications at
774
+ * Which account notifications may go out right now: the five switches, each
775
+ * on its own, and all five off when the machine vetoed notifications at
768
776
  * launch — AGENTS_DECK_NO_NOTIFY=1 keeps the deck off the desktop whatever
769
777
  * else it was told, and these are desktop notifications too.
770
778
  */
771
779
  export function accountNotifySettings(prefs, env = process.env) {
772
- if (notificationsVetoed(env)) return { swap: false, quota: false, reset: false, stopped: false };
780
+ if (notificationsVetoed(env)) return { swap: false, quota: false, reset: false, stopped: false, elsewhere: false };
773
781
  const p = normalise(prefs);
774
- return { swap: p.notifySwap, quota: p.notifyQuota, reset: p.notifyReset, stopped: p.notifyStopped };
782
+ return {
783
+ swap: p.notifySwap, quota: p.notifyQuota, reset: p.notifyReset, stopped: p.notifyStopped, elsewhere: p.notifyElsewhere,
784
+ };
775
785
  }
776
786
 
777
787
  /**
@@ -23,10 +23,20 @@ const ROUTE_FEATURES = new Map([
23
23
  ["POST /api/clear", "clear-board"],
24
24
  ]);
25
25
 
26
+ /** Features a whole namespace of routes is: a connected tracker's reads carry
27
+ * the connection's id in the path (#2178), so they are matched by prefix. The
28
+ * name is the provider's, never the connection, board or issue read. */
29
+ const ROUTE_PREFIX_FEATURES = [
30
+ ["GET /api/integrations/jira/", "jira"],
31
+ ["GET /api/integrations/github/", "github"],
32
+ ["GET /api/integrations/gitlab/", "gitlab"],
33
+ ];
34
+
26
35
  /** Note the feature a request is, if it is one. Called for requests that got
27
36
  * past the deck's own gates. */
28
37
  export function noteRouteFeature(method, pathname) {
29
- const name = ROUTE_FEATURES.get(`${method} ${pathname}`);
38
+ const route = `${method} ${pathname}`;
39
+ const name = ROUTE_FEATURES.get(route) ?? ROUTE_PREFIX_FEATURES.find(([prefix]) => route.startsWith(prefix))?.[1];
30
40
  if (name) usageDay.noteFeature(name);
31
41
  }
32
42