sideby 0.0.0-stage → 0.2.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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/README.zh-CN.md +112 -0
  4. package/dist/build.json +1 -0
  5. package/dist/src/cli/args.d.ts +13 -0
  6. package/dist/src/cli/args.js +38 -0
  7. package/dist/src/cli/commands.d.ts +50 -0
  8. package/dist/src/cli/commands.js +566 -0
  9. package/dist/src/cli/format.d.ts +16 -0
  10. package/dist/src/cli/format.js +80 -0
  11. package/dist/src/cli/json-schemas.d.ts +2 -0
  12. package/dist/src/cli/json-schemas.js +259 -0
  13. package/dist/src/cli/main.d.ts +2 -0
  14. package/dist/src/cli/main.js +276 -0
  15. package/dist/src/cli/schemas.d.ts +1 -0
  16. package/dist/src/cli/schemas.js +16 -0
  17. package/dist/src/core/account-name.d.ts +2 -0
  18. package/dist/src/core/account-name.js +2 -0
  19. package/dist/src/core/accounts.d.ts +20 -0
  20. package/dist/src/core/accounts.js +131 -0
  21. package/dist/src/core/config.d.ts +61 -0
  22. package/dist/src/core/config.js +184 -0
  23. package/dist/src/core/create.d.ts +30 -0
  24. package/dist/src/core/create.js +126 -0
  25. package/dist/src/core/doctor.d.ts +41 -0
  26. package/dist/src/core/doctor.js +171 -0
  27. package/dist/src/core/errors.d.ts +12 -0
  28. package/dist/src/core/errors.js +14 -0
  29. package/dist/src/core/file-memo.d.ts +24 -0
  30. package/dist/src/core/file-memo.js +41 -0
  31. package/dist/src/core/fs-safe.d.ts +38 -0
  32. package/dist/src/core/fs-safe.js +205 -0
  33. package/dist/src/core/handoff.d.ts +41 -0
  34. package/dist/src/core/handoff.js +54 -0
  35. package/dist/src/core/identity.d.ts +23 -0
  36. package/dist/src/core/identity.js +79 -0
  37. package/dist/src/core/last-doctor.d.ts +36 -0
  38. package/dist/src/core/last-doctor.js +69 -0
  39. package/dist/src/core/launch.d.ts +37 -0
  40. package/dist/src/core/launch.js +108 -0
  41. package/dist/src/core/login-path.d.ts +23 -0
  42. package/dist/src/core/login-path.js +96 -0
  43. package/dist/src/core/paths.d.ts +13 -0
  44. package/dist/src/core/paths.js +32 -0
  45. package/dist/src/core/quota-levels.d.ts +19 -0
  46. package/dist/src/core/quota-levels.js +34 -0
  47. package/dist/src/core/resume.d.ts +9 -0
  48. package/dist/src/core/resume.js +15 -0
  49. package/dist/src/core/secret-file.d.ts +30 -0
  50. package/dist/src/core/secret-file.js +116 -0
  51. package/dist/src/core/share-meaning.d.ts +6 -0
  52. package/dist/src/core/share-meaning.js +32 -0
  53. package/dist/src/core/share-mode-names.d.ts +1 -0
  54. package/dist/src/core/share-mode-names.js +10 -0
  55. package/dist/src/core/share-modes.d.ts +24 -0
  56. package/dist/src/core/share-modes.js +405 -0
  57. package/dist/src/core/shell-init.d.ts +31 -0
  58. package/dist/src/core/shell-init.js +84 -0
  59. package/dist/src/core/skill-check.d.ts +11 -0
  60. package/dist/src/core/skill-check.js +57 -0
  61. package/dist/src/core/usage-days.d.ts +15 -0
  62. package/dist/src/core/usage-days.js +33 -0
  63. package/dist/src/core/version.d.ts +9 -0
  64. package/dist/src/core/version.js +46 -0
  65. package/dist/src/desktop/app.d.ts +60 -0
  66. package/dist/src/desktop/app.js +316 -0
  67. package/dist/src/desktop/icon.d.ts +16 -0
  68. package/dist/src/desktop/icon.js +174 -0
  69. package/dist/src/families/claude/index.d.ts +10 -0
  70. package/dist/src/families/claude/index.js +127 -0
  71. package/dist/src/families/claude/layout.d.ts +6 -0
  72. package/dist/src/families/claude/layout.js +5 -0
  73. package/dist/src/families/claude/quota-cache.d.ts +31 -0
  74. package/dist/src/families/claude/quota-cache.js +111 -0
  75. package/dist/src/families/claude/quota.d.ts +3 -0
  76. package/dist/src/families/claude/quota.js +53 -0
  77. package/dist/src/families/claude/usage.d.ts +22 -0
  78. package/dist/src/families/claude/usage.js +146 -0
  79. package/dist/src/families/codex/index.d.ts +5 -0
  80. package/dist/src/families/codex/index.js +70 -0
  81. package/dist/src/families/codex/rollout.d.ts +67 -0
  82. package/dist/src/families/codex/rollout.js +298 -0
  83. package/dist/src/families/grok/index.d.ts +8 -0
  84. package/dist/src/families/grok/index.js +82 -0
  85. package/dist/src/families/index.d.ts +3 -0
  86. package/dist/src/families/index.js +40 -0
  87. package/dist/src/families/logos.d.ts +21 -0
  88. package/dist/src/families/logos.js +26 -0
  89. package/dist/src/families/pi/index.d.ts +3 -0
  90. package/dist/src/families/pi/index.js +54 -0
  91. package/dist/src/families/shared/sessions.d.ts +9 -0
  92. package/dist/src/families/shared/sessions.js +70 -0
  93. package/dist/src/families/shared/toml.d.ts +5 -0
  94. package/dist/src/families/shared/toml.js +16 -0
  95. package/dist/src/index.d.ts +5 -0
  96. package/dist/src/index.js +4 -0
  97. package/dist/src/panel/background.d.ts +62 -0
  98. package/dist/src/panel/background.js +274 -0
  99. package/dist/src/panel/handler.d.ts +91 -0
  100. package/dist/src/panel/handler.js +396 -0
  101. package/dist/src/panel/i18n.d.ts +287 -0
  102. package/dist/src/panel/i18n.js +570 -0
  103. package/dist/src/panel/page-logic.d.ts +154 -0
  104. package/dist/src/panel/page-logic.js +326 -0
  105. package/dist/src/panel/page-script.d.ts +2 -0
  106. package/dist/src/panel/page-script.js +1633 -0
  107. package/dist/src/panel/page-styles.d.ts +7 -0
  108. package/dist/src/panel/page-styles.js +514 -0
  109. package/dist/src/panel/page.d.ts +20 -0
  110. package/dist/src/panel/page.js +96 -0
  111. package/dist/src/panel/server.d.ts +32 -0
  112. package/dist/src/panel/server.js +133 -0
  113. package/dist/src/panel/theme.d.ts +116 -0
  114. package/dist/src/panel/theme.js +132 -0
  115. package/dist/src/plugins/account-script.d.ts +4 -0
  116. package/dist/src/plugins/account-script.js +66 -0
  117. package/dist/src/plugins/bus.d.ts +33 -0
  118. package/dist/src/plugins/bus.js +84 -0
  119. package/dist/src/plugins/loader.d.ts +58 -0
  120. package/dist/src/plugins/loader.js +250 -0
  121. package/dist/src/quota/claude-statusline.d.ts +28 -0
  122. package/dist/src/quota/claude-statusline.js +301 -0
  123. package/dist/src/quota/statusline-tap.d.ts +11 -0
  124. package/dist/src/quota/statusline-tap.js +104 -0
  125. package/dist/src/runtime.d.ts +195 -0
  126. package/dist/src/runtime.js +424 -0
  127. package/dist/src/types.d.ts +285 -0
  128. package/dist/src/types.js +3 -0
  129. package/dist/testing/demo.d.ts +8 -0
  130. package/dist/testing/demo.js +50 -0
  131. package/dist/testing/index.d.ts +36 -0
  132. package/dist/testing/index.js +118 -0
  133. package/package.json +66 -4
  134. package/schemas/alias.json +102 -0
  135. package/schemas/app-install.json +74 -0
  136. package/schemas/app-uninstall.json +54 -0
  137. package/schemas/config.json +170 -0
  138. package/schemas/doctor.json +229 -0
  139. package/schemas/error.json +25 -0
  140. package/schemas/families.json +205 -0
  141. package/schemas/list.json +177 -0
  142. package/schemas/new.json +199 -0
  143. package/schemas/next.json +127 -0
  144. package/schemas/plugins.json +74 -0
  145. package/schemas/quota-setup.json +60 -0
  146. package/schemas/quota-teardown.json +30 -0
  147. package/schemas/quota.json +203 -0
  148. package/skills/sideby/SKILL.md +61 -0
  149. package/skills/sideby/references/accounts.md +31 -0
  150. package/skills/sideby/references/diagnose.md +47 -0
  151. package/skills/sideby/references/handoff.md +41 -0
  152. package/skills/sideby/references/quota.md +14 -0
  153. package/skills/sideby/references/setup.md +31 -0
  154. package/skills/sideby/references/sharing.md +17 -0
@@ -0,0 +1,154 @@
1
+ /** The parts of a PanelAccount the helpers read. */
2
+ export interface LogicAccount {
3
+ family: string;
4
+ name: string;
5
+ ref: string;
6
+ dir?: string;
7
+ isMain?: boolean;
8
+ kind?: string;
9
+ login?: string;
10
+ model?: string;
11
+ aliases?: string[];
12
+ aliasArgs?: Record<string, string[]>;
13
+ identity?: {
14
+ email?: string;
15
+ org?: string;
16
+ };
17
+ hostInstalled?: boolean;
18
+ error?: string;
19
+ quota?: {
20
+ status: string;
21
+ windows?: {
22
+ label: string;
23
+ windowMinutes: number;
24
+ usedPercent: number;
25
+ resetsAt: string;
26
+ }[];
27
+ observedAt?: string;
28
+ plan?: string;
29
+ };
30
+ usage?: {
31
+ status: string;
32
+ inputTokens?: number;
33
+ cacheReadTokens?: number;
34
+ cacheWriteTokens?: number;
35
+ totalTokens?: number;
36
+ daily?: {
37
+ date: string;
38
+ totalTokens: number;
39
+ }[];
40
+ lastActivityAt?: string;
41
+ };
42
+ }
43
+ /** A short countdown such as `2h 05m`, `3d 4h` or `12m` (`2小时05分` in Chinese); fixed shape so it does not jump. */
44
+ export declare function formatDuration(ms: number, lang: string): string;
45
+ /** One Quota window as the page draws it: percent used (0 after a reset passed) and its warning level. */
46
+ export declare function windowState(w: {
47
+ usedPercent: number;
48
+ resetsAt: string;
49
+ }, now: number, warn: number, fail: number): {
50
+ pct: number;
51
+ passed: boolean;
52
+ level: string;
53
+ };
54
+ /**
55
+ * A window whose reset time has passed: the numbers on record are from before the reset and nothing newer came
56
+ * in. `since` is when they were last seen (the Quota's observation time, or the reset itself when that is
57
+ * unknown). Null while the window is live, so the page shows a countdown instead.
58
+ */
59
+ export declare function passedWindow(w: {
60
+ resetsAt: string;
61
+ }, observedAt: string | undefined, now: number): {
62
+ since: string;
63
+ } | null;
64
+ /** The fullest live Quota window in percent, or -1 when the Account has no Quota data. */
65
+ export declare function quotaPressure(a: LogicAccount, now: number): number;
66
+ /**
67
+ * Why an Account needs a look, most serious first: `error`, `health-fail`, `signed-out`, `not-installed`,
68
+ * `quota-high` (a live window at or over the warning level), `health-warn`. Empty when all is well.
69
+ */
70
+ export declare function attentionReasons(a: LogicAccount, health: {
71
+ fail: number;
72
+ warn: number;
73
+ } | null, now: number, warn: number): string[];
74
+ /** Sorted copy: `pressure` (fullest Quota first), `name`, or `recent` (last activity first). The Main Account leads ties. */
75
+ export declare function sortAccounts<T extends LogicAccount>(list: T[], mode: string, now: number): T[];
76
+ /**
77
+ * Whether every word of the query appears in the Account's name, ref, aliases (with their arguments), Family title,
78
+ * model, plan, email or organization.
79
+ */
80
+ export declare function matchesQuery(a: LogicAccount, query: string, familyTitle: string): boolean;
81
+ /**
82
+ * The Host arguments the alias `name` of this Account adds, or null when it adds none. Reads only own keys, so an
83
+ * alias named like an Object method (`constructor`, `toString`) is not mistaken for one with arguments.
84
+ */
85
+ export declare function aliasArgsOf(a: LogicAccount, name: string): string[] | null;
86
+ /**
87
+ * Where a new Account would live, derived from an existing one of the same Family (`…/.claude-work` gives
88
+ * `…/.claude-<name>`, `…/.pi-work/agent` gives `…/.pi-<name>/agent`), or from a Main Account directory that is a
89
+ * single dot folder (`…/.codex` gives `…/.codex-<name>`). Null when neither tells.
90
+ */
91
+ export declare function newAccountDir(accounts: LogicAccount[], family: string, name: string): string | null;
92
+ /** Share of input served from the prompt cache (0 to 1), or null without usage. */
93
+ export declare function cacheHitRate(u: LogicAccount['usage']): number | null;
94
+ /** Tokens per day summed over Accounts, oldest first; days missing from an Account count as 0. */
95
+ export declare function mergeDaily(lists: ({
96
+ date: string;
97
+ totalTokens: number;
98
+ }[] | undefined)[]): {
99
+ date: string;
100
+ totalTokens: number;
101
+ }[];
102
+ /** The soonest Quota reset still ahead of `now`, with the window label and Account. */
103
+ export declare function nextReset(accounts: LogicAccount[], now: number): {
104
+ at: number;
105
+ label: string;
106
+ ref: string;
107
+ name: string;
108
+ family: string;
109
+ } | null;
110
+ /**
111
+ * A name to offer for a new Account of a Family, given its existing Account names (`main` included or not).
112
+ * Numbered names (`001` … `007`) continue after the highest number with the same width (`008`); otherwise
113
+ * `work`, then `work2`, `work3`, … whichever is free.
114
+ */
115
+ export declare function suggestName(names: string[]): string;
116
+ /**
117
+ * A Finding's hint as the Panel shows it: for a fixable Finding, "run `sideby doctor --fix`" becomes the
118
+ * Panel's own action (`action`, such as "use Fix all"), capitalized when it starts the hint. Hints of Findings
119
+ * that are not fixable, and `--fix --force`, keep the command: Fix all would not do them.
120
+ */
121
+ export declare function panelHint(hint: string, fixable: boolean, action: string): string;
122
+ /**
123
+ * A short command to offer for a new Account `name` of a Family, from the config aliases of its existing Accounts:
124
+ * an alias that is a prefix plus its Account's name (`cc001` for `claude:001`) gives the prefix, the most common
125
+ * one wins (then the longer, then the first alphabetically), and the offer is that prefix plus `name` (`cc008`).
126
+ * Aliases that do not end in their Account's name (`codex001` for `codex:main`) are ignored. Empty when no
127
+ * pattern shows, or without a name. The caller still checks the result like any alias.
128
+ */
129
+ export declare function suggestAlias(accounts: LogicAccount[], family: string, name: string): string;
130
+ /**
131
+ * Why `alias` cannot become the short command of `ref`, by the config rules: `pattern` (not a shell-safe name,
132
+ * matched against the config's own pattern source), `reserved` (a shell keyword, builtin or `sideby`), `taken`
133
+ * (the config already maps it to another Account). Empty when it is fine or empty.
134
+ */
135
+ export declare function aliasIssue(alias: string, pattern: string, reserved: string[], configAliases: Record<string, string> | undefined, ref: string): string;
136
+ /**
137
+ * An email with the local part hidden for screen sharing: its first character, three dots, then the domain
138
+ * (`a•••@example.com`). Text without an `@` keeps only its first character.
139
+ */
140
+ export declare function maskEmail(email: string): string;
141
+ /**
142
+ * The header's "Updated …" age as a message key and count. The state reloads every 20 s, so the whole first minute
143
+ * is "just now": a count of seconds would tick all the time and make an idle page look busy. After that, whole
144
+ * minutes, hours and days, which only show when refreshes stopped (connection lost, page hidden, load failing).
145
+ */
146
+ export declare function updatedAge(ms: number): {
147
+ key: string;
148
+ n?: number;
149
+ };
150
+ /**
151
+ * The Panel's language: a host `lang` of `en` or `zh` wins; `auto` uses the viewer's saved choice, then the browser
152
+ * language (`zh*` gives Chinese, anything else English).
153
+ */
154
+ export declare function panelLang(host: string, saved: string, browser: string): string;
@@ -0,0 +1,326 @@
1
+ // Pure helpers for the Panel page. page-script.ts embeds them with Function.prototype.toString(), so the
2
+ // browser runs exactly the code these tests cover. Each function must be self-contained: no imports, no
3
+ // references to module scope or to each other except through parameters, and only type annotations as
4
+ // TypeScript (they are stripped by Node and by the build, leaving plain JavaScript).
5
+ /** A short countdown such as `2h 05m`, `3d 4h` or `12m` (`2小时05分` in Chinese); fixed shape so it does not jump. */
6
+ export function formatDuration(ms, lang) {
7
+ const zh = lang === 'zh';
8
+ if (!Number.isFinite(ms) || ms < 60000)
9
+ return zh ? '不到1分钟' : '<1m';
10
+ const total = Math.floor(ms / 60000);
11
+ const d = Math.floor(total / 1440);
12
+ const h = Math.floor((total % 1440) / 60);
13
+ const m = total % 60;
14
+ const mm = String(m).padStart(2, '0');
15
+ if (d > 0)
16
+ return zh ? `${d}天${h}小时` : `${d}d ${h}h`;
17
+ if (h > 0)
18
+ return zh ? `${h}小时${mm}分` : `${h}h ${mm}m`;
19
+ return zh ? `${m}分钟` : `${m}m`;
20
+ }
21
+ /** One Quota window as the page draws it: percent used (0 after a reset passed) and its warning level. */
22
+ export function windowState(w, now, warn, fail) {
23
+ const reset = Date.parse(w.resetsAt);
24
+ const passed = Number.isFinite(reset) && reset <= now;
25
+ const raw = Math.max(0, Math.min(100, Math.round(Number(w.usedPercent) || 0)));
26
+ const pct = passed ? 0 : raw;
27
+ const level = passed ? 'reset' : pct >= fail ? 'fail' : pct >= warn ? 'warn' : 'ok';
28
+ return { pct, passed, level };
29
+ }
30
+ /**
31
+ * A window whose reset time has passed: the numbers on record are from before the reset and nothing newer came
32
+ * in. `since` is when they were last seen (the Quota's observation time, or the reset itself when that is
33
+ * unknown). Null while the window is live, so the page shows a countdown instead.
34
+ */
35
+ export function passedWindow(w, observedAt, now) {
36
+ const reset = Date.parse(w.resetsAt);
37
+ if (!(Number.isFinite(reset) && reset <= now))
38
+ return null;
39
+ const seen = Date.parse(observedAt || '');
40
+ return { since: Number.isFinite(seen) ? new Date(seen).toISOString() : w.resetsAt };
41
+ }
42
+ /** The fullest live Quota window in percent, or -1 when the Account has no Quota data. */
43
+ export function quotaPressure(a, now) {
44
+ const q = a.quota;
45
+ if (q?.status !== 'ok' || !q.windows)
46
+ return -1;
47
+ let max = -1;
48
+ for (const w of q.windows) {
49
+ const reset = Date.parse(w.resetsAt);
50
+ if (Number.isFinite(reset) && reset <= now)
51
+ continue;
52
+ max = Math.max(max, Math.max(0, Math.min(100, Number(w.usedPercent) || 0)));
53
+ }
54
+ return q.windows.length && max < 0 ? 0 : max;
55
+ }
56
+ /**
57
+ * Why an Account needs a look, most serious first: `error`, `health-fail`, `signed-out`, `not-installed`,
58
+ * `quota-high` (a live window at or over the warning level), `health-warn`. Empty when all is well.
59
+ */
60
+ export function attentionReasons(a, health, now, warn) {
61
+ const out = [];
62
+ if (a.error)
63
+ out.push('error');
64
+ if (health && health.fail > 0)
65
+ out.push('health-fail');
66
+ if (a.login === 'logged-out' && a.kind !== 'api')
67
+ out.push('signed-out');
68
+ if (a.hostInstalled === false)
69
+ out.push('not-installed');
70
+ const q = a.quota;
71
+ if (q && q.status === 'ok' && q.windows)
72
+ for (const w of q.windows) {
73
+ const reset = Date.parse(w.resetsAt);
74
+ if (Number.isFinite(reset) && reset <= now)
75
+ continue;
76
+ if ((Number(w.usedPercent) || 0) >= warn) {
77
+ out.push('quota-high');
78
+ break;
79
+ }
80
+ }
81
+ if (health && !health.fail && health.warn > 0)
82
+ out.push('health-warn');
83
+ return out;
84
+ }
85
+ /** Sorted copy: `pressure` (fullest Quota first), `name`, or `recent` (last activity first). The Main Account leads ties. */
86
+ export function sortAccounts(list, mode, now) {
87
+ const byName = (x, y) => Number(Boolean(y.isMain)) - Number(Boolean(x.isMain)) ||
88
+ x.name.localeCompare(y.name, undefined, { numeric: true });
89
+ const pressure = (a) => {
90
+ const q = a.quota;
91
+ if (q?.status !== 'ok' || !q.windows)
92
+ return -1;
93
+ let max = q.windows.length ? 0 : -1;
94
+ for (const w of q.windows) {
95
+ const reset = Date.parse(w.resetsAt);
96
+ if (!(Number.isFinite(reset) && reset <= now))
97
+ max = Math.max(max, Number(w.usedPercent) || 0);
98
+ }
99
+ return max;
100
+ };
101
+ const last = (a) => {
102
+ const t = Date.parse(a.usage?.lastActivityAt || '');
103
+ return Number.isFinite(t) ? t : -1;
104
+ };
105
+ const out = list.slice();
106
+ if (mode === 'name')
107
+ out.sort(byName);
108
+ else if (mode === 'recent')
109
+ out.sort((x, y) => last(y) - last(x) || byName(x, y));
110
+ else
111
+ out.sort((x, y) => pressure(y) - pressure(x) || byName(x, y));
112
+ return out;
113
+ }
114
+ /**
115
+ * Whether every word of the query appears in the Account's name, ref, aliases (with their arguments), Family title,
116
+ * model, plan, email or organization.
117
+ */
118
+ export function matchesQuery(a, query, familyTitle) {
119
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
120
+ if (!words.length)
121
+ return true;
122
+ const hay = [
123
+ a.name,
124
+ a.ref,
125
+ familyTitle,
126
+ a.model || '',
127
+ a.quota?.plan || '',
128
+ a.identity?.email || '',
129
+ a.identity?.org || '',
130
+ ]
131
+ .concat(a.aliases || [])
132
+ .concat(Object.values(a.aliasArgs || {}).flat())
133
+ .join(' ')
134
+ .toLowerCase();
135
+ return words.every((w) => hay.includes(w));
136
+ }
137
+ /**
138
+ * The Host arguments the alias `name` of this Account adds, or null when it adds none. Reads only own keys, so an
139
+ * alias named like an Object method (`constructor`, `toString`) is not mistaken for one with arguments.
140
+ */
141
+ export function aliasArgsOf(a, name) {
142
+ const args = a.aliasArgs && Object.hasOwn(a.aliasArgs, name) ? a.aliasArgs[name] : undefined;
143
+ return Array.isArray(args) && args.length ? args : null;
144
+ }
145
+ /**
146
+ * Where a new Account would live, derived from an existing one of the same Family (`…/.claude-work` gives
147
+ * `…/.claude-<name>`, `…/.pi-work/agent` gives `…/.pi-<name>/agent`), or from a Main Account directory that is a
148
+ * single dot folder (`…/.codex` gives `…/.codex-<name>`). Null when neither tells.
149
+ */
150
+ export function newAccountDir(accounts, family, name) {
151
+ for (const a of accounts) {
152
+ if (a.family !== family || a.isMain || !a.dir)
153
+ continue;
154
+ const needle = `-${a.name}`;
155
+ let i = a.dir.lastIndexOf(needle);
156
+ while (i >= 0) {
157
+ const after = a.dir.charAt(i + needle.length);
158
+ if (after === '' || after === '/' || after === '\\')
159
+ return `${a.dir.slice(0, i)}-${name}${a.dir.slice(i + needle.length)}`;
160
+ i = a.dir.lastIndexOf(needle, i - 1);
161
+ }
162
+ }
163
+ const main = accounts.find((a) => a.family === family && a.isMain && a.dir);
164
+ if (main?.dir) {
165
+ const seg = main.dir.split(/[\\/]/).pop() || '';
166
+ if (/^\.[A-Za-z0-9_-]+$/.test(seg))
167
+ return `${main.dir}-${name}`;
168
+ }
169
+ return null;
170
+ }
171
+ /** Share of input served from the prompt cache (0 to 1), or null without usage. */
172
+ export function cacheHitRate(u) {
173
+ if (u?.status !== 'ok')
174
+ return null;
175
+ const read = Number(u.cacheReadTokens) || 0;
176
+ const total = (Number(u.inputTokens) || 0) + read + (Number(u.cacheWriteTokens) || 0);
177
+ return total > 0 ? read / total : null;
178
+ }
179
+ /** Tokens per day summed over Accounts, oldest first; days missing from an Account count as 0. */
180
+ export function mergeDaily(lists) {
181
+ const sums = {};
182
+ for (const list of lists)
183
+ for (const d of list || []) {
184
+ if (!d || typeof d.date !== 'string')
185
+ continue;
186
+ sums[d.date] = (sums[d.date] || 0) + (Number(d.totalTokens) || 0);
187
+ }
188
+ return Object.keys(sums)
189
+ .sort()
190
+ .map((date) => ({ date, totalTokens: sums[date] || 0 }));
191
+ }
192
+ /** The soonest Quota reset still ahead of `now`, with the window label and Account. */
193
+ export function nextReset(accounts, now) {
194
+ let best = null;
195
+ for (const a of accounts) {
196
+ const q = a.quota;
197
+ if (q?.status !== 'ok' || !q.windows)
198
+ continue;
199
+ for (const w of q.windows) {
200
+ const at = Date.parse(w.resetsAt);
201
+ if (!Number.isFinite(at) || at <= now)
202
+ continue;
203
+ if (!best || at < best.at)
204
+ best = { at, label: w.label, ref: a.ref, name: a.name, family: a.family };
205
+ }
206
+ }
207
+ return best;
208
+ }
209
+ /**
210
+ * A name to offer for a new Account of a Family, given its existing Account names (`main` included or not).
211
+ * Numbered names (`001` … `007`) continue after the highest number with the same width (`008`); otherwise
212
+ * `work`, then `work2`, `work3`, … whichever is free.
213
+ */
214
+ export function suggestName(names) {
215
+ const taken = {};
216
+ for (const n of names)
217
+ taken[n] = true;
218
+ let max = -1;
219
+ let width = 0;
220
+ for (const n of names) {
221
+ if (!/^[0-9]{1,32}$/.test(n))
222
+ continue;
223
+ max = Math.max(max, Number(n));
224
+ width = Math.max(width, n.length);
225
+ }
226
+ if (max >= 0) {
227
+ for (let i = max + 1;; i++) {
228
+ const s = String(i).padStart(width, '0');
229
+ if (s.length > 32)
230
+ break;
231
+ if (!taken[s])
232
+ return s;
233
+ }
234
+ }
235
+ if (!taken.work)
236
+ return 'work';
237
+ for (let i = 2;; i++)
238
+ if (!taken['work' + i])
239
+ return 'work' + i;
240
+ }
241
+ /**
242
+ * A Finding's hint as the Panel shows it: for a fixable Finding, "run `sideby doctor --fix`" becomes the
243
+ * Panel's own action (`action`, such as "use Fix all"), capitalized when it starts the hint. Hints of Findings
244
+ * that are not fixable, and `--fix --force`, keep the command: Fix all would not do them.
245
+ */
246
+ export function panelHint(hint, fixable, action) {
247
+ if (!fixable || !hint)
248
+ return hint;
249
+ return hint.replace(/run `sideby doctor --fix`/g, (_m, at) => at === 0 ? action.charAt(0).toUpperCase() + action.slice(1) : action);
250
+ }
251
+ /**
252
+ * A short command to offer for a new Account `name` of a Family, from the config aliases of its existing Accounts:
253
+ * an alias that is a prefix plus its Account's name (`cc001` for `claude:001`) gives the prefix, the most common
254
+ * one wins (then the longer, then the first alphabetically), and the offer is that prefix plus `name` (`cc008`).
255
+ * Aliases that do not end in their Account's name (`codex001` for `codex:main`) are ignored. Empty when no
256
+ * pattern shows, or without a name. The caller still checks the result like any alias.
257
+ */
258
+ export function suggestAlias(accounts, family, name) {
259
+ if (!name)
260
+ return '';
261
+ const counts = {};
262
+ for (const a of accounts) {
263
+ if (a.family !== family)
264
+ continue;
265
+ for (const alias of a.aliases || [])
266
+ if (alias.length > a.name.length && alias.endsWith(a.name)) {
267
+ const prefix = alias.slice(0, alias.length - a.name.length);
268
+ counts[prefix] = (counts[prefix] || 0) + 1;
269
+ }
270
+ }
271
+ const best = Object.keys(counts).sort((x, y) => (counts[y] || 0) - (counts[x] || 0) || y.length - x.length || (x < y ? -1 : x > y ? 1 : 0))[0];
272
+ return best ? best + name : '';
273
+ }
274
+ /**
275
+ * Why `alias` cannot become the short command of `ref`, by the config rules: `pattern` (not a shell-safe name,
276
+ * matched against the config's own pattern source), `reserved` (a shell keyword, builtin or `sideby`), `taken`
277
+ * (the config already maps it to another Account). Empty when it is fine or empty.
278
+ */
279
+ export function aliasIssue(alias, pattern, reserved, configAliases, ref) {
280
+ if (!alias)
281
+ return '';
282
+ if (!new RegExp(pattern).test(alias))
283
+ return 'pattern';
284
+ if (reserved.indexOf(alias) >= 0)
285
+ return 'reserved';
286
+ const target = configAliases && Object.hasOwn(configAliases, alias) ? configAliases[alias] : undefined;
287
+ if (target !== undefined && target !== ref)
288
+ return 'taken';
289
+ return '';
290
+ }
291
+ /**
292
+ * An email with the local part hidden for screen sharing: its first character, three dots, then the domain
293
+ * (`a•••@example.com`). Text without an `@` keeps only its first character.
294
+ */
295
+ export function maskEmail(email) {
296
+ const at = email.lastIndexOf('@');
297
+ const first = Array.from(email)[0] || '';
298
+ return at > 0 ? `${first}•••${email.slice(at)}` : first ? `${first}•••` : '';
299
+ }
300
+ /**
301
+ * The header's "Updated …" age as a message key and count. The state reloads every 20 s, so the whole first minute
302
+ * is "just now": a count of seconds would tick all the time and make an idle page look busy. After that, whole
303
+ * minutes, hours and days, which only show when refreshes stopped (connection lost, page hidden, load failing).
304
+ */
305
+ export function updatedAge(ms) {
306
+ const m = Number.isFinite(ms) ? Math.floor(ms / 60000) : 0;
307
+ if (m < 1)
308
+ return { key: 'time.justNow' };
309
+ if (m < 60)
310
+ return { key: 'time.minAgo', n: m };
311
+ const h = Math.floor(m / 60);
312
+ if (h < 24)
313
+ return { key: 'time.hAgo', n: h };
314
+ return { key: 'time.dAgo', n: Math.floor(h / 24) };
315
+ }
316
+ /**
317
+ * The Panel's language: a host `lang` of `en` or `zh` wins; `auto` uses the viewer's saved choice, then the browser
318
+ * language (`zh*` gives Chinese, anything else English).
319
+ */
320
+ export function panelLang(host, saved, browser) {
321
+ if (host === 'en' || host === 'zh')
322
+ return host;
323
+ if (saved === 'en' || saved === 'zh')
324
+ return saved;
325
+ return /^zh/i.test(browser || '') ? 'zh' : 'en';
326
+ }
@@ -0,0 +1,2 @@
1
+ /** The page script: the tested helpers, then the page logic, in one strict-mode function. */
2
+ export declare const PAGE_SCRIPT: string;