sideby 0.16.0 → 0.17.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 (77) hide show
  1. package/README.md +5 -3
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +16 -0
  4. package/dist/src/cli/commands.js +460 -5
  5. package/dist/src/cli/json-schemas.js +115 -0
  6. package/dist/src/cli/main.js +93 -3
  7. package/dist/src/core/ask.d.ts +150 -0
  8. package/dist/src/core/ask.js +365 -0
  9. package/dist/src/core/attachments.d.ts +50 -0
  10. package/dist/src/core/attachments.js +442 -0
  11. package/dist/src/core/autostart.d.ts +88 -0
  12. package/dist/src/core/autostart.js +256 -0
  13. package/dist/src/core/config.d.ts +97 -0
  14. package/dist/src/core/config.js +278 -10
  15. package/dist/src/core/launch.d.ts +15 -4
  16. package/dist/src/core/launch.js +27 -23
  17. package/dist/src/core/local-build.d.ts +17 -0
  18. package/dist/src/core/local-build.js +39 -3
  19. package/dist/src/core/probe.d.ts +78 -0
  20. package/dist/src/core/probe.js +127 -0
  21. package/dist/src/core/same-login.d.ts +9 -4
  22. package/dist/src/core/same-login.js +9 -4
  23. package/dist/src/core/secret-file.d.ts +26 -0
  24. package/dist/src/core/secret-file.js +126 -7
  25. package/dist/src/core/terminal.d.ts +93 -0
  26. package/dist/src/core/terminal.js +371 -0
  27. package/dist/src/core/update.d.ts +17 -0
  28. package/dist/src/core/update.js +63 -1
  29. package/dist/src/families/claude/index.js +3 -0
  30. package/dist/src/families/claude/quota-cache.d.ts +24 -7
  31. package/dist/src/families/claude/quota-cache.js +72 -21
  32. package/dist/src/families/codex/index.js +7 -0
  33. package/dist/src/families/codex/login-key.d.ts +8 -0
  34. package/dist/src/families/codex/login-key.js +33 -0
  35. package/dist/src/families/grok/index.js +3 -0
  36. package/dist/src/families/login-keys.d.ts +6 -0
  37. package/dist/src/families/login-keys.js +6 -0
  38. package/dist/src/families/pi/index.js +4 -0
  39. package/dist/src/panel/background.js +3 -2
  40. package/dist/src/panel/handler.d.ts +29 -2
  41. package/dist/src/panel/handler.js +396 -9
  42. package/dist/src/panel/i18n.d.ts +175 -2
  43. package/dist/src/panel/i18n.js +381 -35
  44. package/dist/src/panel/page-logic.d.ts +214 -0
  45. package/dist/src/panel/page-logic.js +394 -0
  46. package/dist/src/panel/page-script.js +984 -134
  47. package/dist/src/panel/page-styles.js +69 -19
  48. package/dist/src/panel/page.d.ts +5 -0
  49. package/dist/src/panel/page.js +2 -0
  50. package/dist/src/panel/server.d.ts +16 -2
  51. package/dist/src/panel/server.js +41 -13
  52. package/dist/src/plugins/check.d.ts +47 -0
  53. package/dist/src/plugins/check.js +544 -0
  54. package/dist/src/plugins/scaffold.d.ts +21 -0
  55. package/dist/src/plugins/scaffold.js +123 -0
  56. package/dist/src/quota/statusline-tap.js +1 -1
  57. package/dist/src/runtime.d.ts +10 -0
  58. package/dist/src/runtime.js +47 -16
  59. package/dist/src/types.d.ts +19 -0
  60. package/package.json +2 -2
  61. package/schemas/ask.json +180 -0
  62. package/schemas/autostart.json +165 -0
  63. package/schemas/config-get.json +132 -0
  64. package/schemas/config-set.json +107 -0
  65. package/schemas/config.json +73 -6
  66. package/schemas/key.json +93 -0
  67. package/schemas/local.json +4 -0
  68. package/schemas/plugins-check.json +113 -0
  69. package/schemas/plugins-new.json +87 -0
  70. package/schemas/terminal.json +152 -0
  71. package/schemas/try.json +109 -0
  72. package/schemas/update.json +4 -0
  73. package/skills/sideby/SKILL.md +12 -6
  74. package/skills/sideby/references/accounts.md +1 -1
  75. package/skills/sideby/references/ai-tasks.md +116 -0
  76. package/skills/sideby/references/panel.md +14 -0
  77. package/skills/sideby/references/update.md +1 -1
@@ -1,6 +1,7 @@
1
1
  // The Claude Quota cache: one module owns where it lives, how the Account is named, and how it is written,
2
2
  // read and validated. The status line tap writes it on every refresh, so this module imports only leaf
3
3
  // modules and Node built-ins (never the Family index, the Runtime or core/accounts.ts).
4
+ import { createHash } from 'node:crypto';
4
5
  import { mkdir, readFile } from 'node:fs/promises';
5
6
  import { homedir } from 'node:os';
6
7
  import { basename, dirname, join, resolve } from 'node:path';
@@ -8,6 +9,9 @@ import { ACCOUNT_NAME } from '../../core/account-name.js';
8
9
  import { writeFileAtomic } from '../../core/fs-safe.js';
9
10
  import { resolvePaths } from '../../core/paths.js';
10
11
  import { CLAUDE_ACCOUNT_PREFIX, CLAUDE_MAIN_DIR } from './layout.js';
12
+ const MAX_SESSIONS = 32;
13
+ const SESSION_DAYS = 8;
14
+ const SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/;
11
15
  const WINDOWS = [
12
16
  { key: 'five_hour', label: '5h', minutes: 300 },
13
17
  { key: 'seven_day', label: '7d', minutes: 10_080 },
@@ -32,7 +36,7 @@ export function accountNameFromEnv(env) {
32
36
  const name = base.slice(CLAUDE_ACCOUNT_PREFIX.length);
33
37
  return name !== 'main' && ACCOUNT_NAME.test(name) ? name : null;
34
38
  }
35
- /** Extracts the 5h and 7d windows from status line JSON; null when there is nothing usable. */
39
+ /** Extracts the 5h and 7d windows and the session id from status line JSON; null when there is nothing usable. */
36
40
  export function parseRateLimits(input, observedAt) {
37
41
  let data;
38
42
  try {
@@ -62,7 +66,15 @@ export function parseRateLimits(input, observedAt) {
62
66
  resetsAt: new Date(resets * 1000).toISOString(),
63
67
  });
64
68
  }
65
- return windows.length ? { version: 1, observedAt: observedAt.toISOString(), windows } : null;
69
+ if (!windows.length)
70
+ return null;
71
+ const id = data.session_id;
72
+ return {
73
+ version: 1,
74
+ observedAt: observedAt.toISOString(),
75
+ windows,
76
+ ...(typeof id === 'string' && SESSION_ID.test(id) ? { sessionId: id } : {}),
77
+ };
66
78
  }
67
79
  /** Two readings belong to one window when their resets are less than a quarter of the window apart. */
68
80
  function sameWindow(a, b) {
@@ -76,33 +88,56 @@ const order = (label) => {
76
88
  const i = WINDOWS.findIndex((w) => w.label === label);
77
89
  return i < 0 ? WINDOWS.length : i;
78
90
  };
91
+ /** What a session reported, as a short hash: equal hashes mean the session has made no request since. */
92
+ function windowsHash(windows) {
93
+ const key = windows.map((w) => [w.label, w.usedPercent, w.resetsAt]);
94
+ return createHash('sha256').update(JSON.stringify(key)).digest('hex').slice(0, 16);
95
+ }
96
+ /** The newest {@link MAX_SESSIONS} sessions seen in the last {@link SESSION_DAYS} days before `now`. */
97
+ function pruneSessions(sessions, now) {
98
+ const oldest = Date.parse(now) - SESSION_DAYS * 86_400_000;
99
+ const kept = Object.entries(sessions)
100
+ .filter(([, s]) => Date.parse(s.seenAt) >= oldest)
101
+ .sort((a, b) => Date.parse(b[1].seenAt) - Date.parse(a[1].seenAt))
102
+ .slice(0, MAX_SESSIONS);
103
+ return Object.fromEntries(kept);
104
+ }
79
105
  /**
80
- * Merges a status line reading into the cached windows. Each session reports the Quota of its own last request, so an
81
- * idle session of the same Account reports older numbers, sometimes of a window that has already reset: within one
82
- * window the higher use wins, otherwise the later reset; a window the reading lacks is kept. Null when the reading
83
- * only repeats older numbers, so nothing is written and `observedAt` keeps the time of the newer reading.
106
+ * Merges a status line reading into the cache (spec §3.7). Each session reports the Quota of its own last request, so
107
+ * an idle session of the same Account keeps repeating older numbers. A reading whose session reported exactly these
108
+ * windows before carries nothing new: null, so nothing is written. A reading that changed, or the first of a session,
109
+ * is a fresh request result and wins for every window it has, the vendor's resets included; a window it lacks is
110
+ * kept. A reading without a session id falls back to: within one window the higher use wins, otherwise the reading
111
+ * wins; null when that only repeats the cache.
84
112
  */
85
113
  export function mergeQuotaCache(cached, next) {
114
+ const { sessionId, ...reading } = next;
115
+ const before = cached?.windows ?? [];
116
+ const keepLacking = (windows) => {
117
+ for (const c of before)
118
+ if (!windows.some((w) => w.label === c.label))
119
+ windows.push(c);
120
+ return windows.sort((a, b) => order(a.label) - order(b.label));
121
+ };
122
+ if (sessionId) {
123
+ const hash = windowsHash(reading.windows);
124
+ if (cached?.sessions?.[sessionId]?.hash === hash)
125
+ return null;
126
+ const sessions = pruneSessions({ ...cached?.sessions, [sessionId]: { hash, seenAt: reading.observedAt } }, reading.observedAt);
127
+ return { ...reading, windows: keepLacking([...reading.windows]), sessions };
128
+ }
86
129
  let older = false;
87
- const windows = next.windows.map((w) => {
88
- const c = cached.find((x) => x.label === w.label);
89
- if (!c)
90
- return w;
91
- const keep = sameWindow(c, w)
92
- ? c.usedPercent > w.usedPercent
93
- : Date.parse(c.resetsAt) > Date.parse(w.resetsAt);
130
+ const windows = keepLacking(reading.windows.map((w) => {
131
+ const c = before.find((x) => x.label === w.label);
132
+ const keep = !!c && sameWindow(c, w) && c.usedPercent > w.usedPercent;
94
133
  if (keep)
95
134
  older = true;
96
135
  return keep ? c : w;
97
- });
98
- for (const c of cached)
99
- if (!windows.some((w) => w.label === c.label))
100
- windows.push(c);
101
- windows.sort((a, b) => order(a.label) - order(b.label));
102
- const unchanged = windows.length === cached.length && windows.every((w) => cached.some((c) => sameReading(c, w)));
136
+ }));
137
+ const unchanged = windows.length === before.length && windows.every((w) => before.some((c) => sameReading(c, w)));
103
138
  if (older && unchanged)
104
139
  return null;
105
- return { ...next, windows };
140
+ return { ...reading, windows, ...(cached?.sessions ? { sessions: cached.sessions } : {}) };
106
141
  }
107
142
  /** Writes the cache atomically; creates the private parent directory first (writeFileAtomic does not). */
108
143
  export async function writeQuotaCache(stateDir, name, cache) {
@@ -147,5 +182,21 @@ export async function readQuotaCache(stateDir, name) {
147
182
  !Number.isFinite(Date.parse(cache.observedAt)) ||
148
183
  !windows.length)
149
184
  return { status: 'unrecognized', detail: `${file} has no usable quota windows` };
150
- return { status: 'ok', observedAt: cache.observedAt, windows };
185
+ const sessions = validSessions(cache.sessions);
186
+ return { status: 'ok', observedAt: cache.observedAt, windows, ...(sessions ? { sessions } : {}) };
187
+ }
188
+ /** The well-formed session entries of a cache file; undefined when it has none (older files). */
189
+ function validSessions(raw) {
190
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
191
+ return undefined;
192
+ const out = {};
193
+ for (const [id, v] of Object.entries(raw)) {
194
+ const s = v;
195
+ if (SESSION_ID.test(id) &&
196
+ typeof s?.hash === 'string' &&
197
+ typeof s.seenAt === 'string' &&
198
+ Number.isFinite(Date.parse(s.seenAt)))
199
+ out[id] = { hash: s.hash, seenAt: s.seenAt };
200
+ }
201
+ return Object.keys(out).length ? out : undefined;
151
202
  }
@@ -31,6 +31,13 @@ export const codexFamily = {
31
31
  layout: { main: '.codex', account: '.codex-<name>' },
32
32
  hijackVars: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'CODEX_API_BASE_URL'],
33
33
  apiVars: ['OPENAI_BASE_URL', 'OPENAI_API_KEY'],
34
+ secretVar: 'OPENAI_API_KEY',
35
+ // `exec` refuses to run outside a git repo unless this flag is set (Codex 0.163.0 --help:
36
+ // allow running outside a Git repository). The probe's cwd is a temp directory.
37
+ probe: { args: ['exec', '--skip-git-repo-check', 'Reply with OK'] },
38
+ // `-i, --image <FILE>...` takes several values (Codex 0.163.0 --help); AI Tasks pass `--image=<path>`, one value
39
+ // per flag, so the prompt after it stays a positional argument.
40
+ imageArg: '--image',
34
41
  sharedItems: [
35
42
  { path: 'AGENTS.md', mode: 'link' },
36
43
  { path: 'hooks.json', mode: 'link' },
@@ -0,0 +1,8 @@
1
+ import type { Account } from '../../types.ts';
2
+ /**
3
+ * A hash of `chatgpt_account_id` (the workspace) and `chatgpt_user_id` under the auth claim of `tokens.id_token`,
4
+ * cut to 16 hex characters, only for comparing two Accounts while the Runtime groups a Same Login. The payload is
5
+ * decoded as for the email (core/identity.ts); the token, both ids and every other claim are dropped at once.
6
+ * Undefined on any problem: the Account then simply does not group.
7
+ */
8
+ export declare function codexLoginKey(account: Account): Promise<string | undefined>;
@@ -0,0 +1,33 @@
1
+ // Codex's Login Key (ADR-0028): which ChatGPT workspace and user an Account is signed in as, kept only as a hash.
2
+ import { createHash } from 'node:crypto';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ const AUTH_CLAIM = 'https://api.openai.com/auth';
6
+ // The shapes Codex writes; anything else gives no key rather than a key built from an unexpected value.
7
+ const ACCOUNT_ID = /^[A-Za-z0-9-]{8,64}$/;
8
+ const USER_ID = /^user-[A-Za-z0-9]{8,64}$/;
9
+ /**
10
+ * A hash of `chatgpt_account_id` (the workspace) and `chatgpt_user_id` under the auth claim of `tokens.id_token`,
11
+ * cut to 16 hex characters, only for comparing two Accounts while the Runtime groups a Same Login. The payload is
12
+ * decoded as for the email (core/identity.ts); the token, both ids and every other claim are dropped at once.
13
+ * Undefined on any problem: the Account then simply does not group.
14
+ */
15
+ export async function codexLoginKey(account) {
16
+ try {
17
+ const token = JSON.parse(await readFile(join(account.dir, 'auth.json'), 'utf8'))?.tokens?.id_token;
18
+ if (typeof token !== 'string')
19
+ return undefined;
20
+ const parts = token.split('.');
21
+ if (parts.length !== 3 || !parts[1])
22
+ return undefined;
23
+ const auth = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))?.[AUTH_CLAIM];
24
+ const ws = auth?.chatgpt_account_id;
25
+ const user = auth?.chatgpt_user_id;
26
+ if (typeof ws !== 'string' || !ACCOUNT_ID.test(ws) || typeof user !== 'string' || !USER_ID.test(user))
27
+ return undefined;
28
+ return createHash('sha256').update(`codex\0${ws}\0${user}`).digest('hex').slice(0, 16);
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
@@ -49,6 +49,9 @@ export const grokFamily = {
49
49
  layout: { main: '.grok', account: '.grok-<name>' },
50
50
  hijackVars: ['XAI_API_KEY', 'GROK_MODELS_BASE_URL'],
51
51
  apiVars: ['GROK_MODELS_BASE_URL', 'XAI_API_KEY'],
52
+ secretVar: 'XAI_API_KEY',
53
+ // `-p` is one non-interactive prompt (Grok Build 1.0.60 --help, 2026-10-17).
54
+ probe: { args: ['-p', 'Reply with OK'] },
52
55
  sharedItems: [
53
56
  { path: 'skills', mode: 'link' },
54
57
  { path: 'installed-plugins', mode: 'link' },
@@ -0,0 +1,6 @@
1
+ import type { Account } from '../types.ts';
2
+ /**
3
+ * Family id → its Login Key reader. Must list exactly the Families of `LOGIN_KEY_FAMILIES` (core/same-login.ts);
4
+ * a test keeps the two in step.
5
+ */
6
+ export declare const LOGIN_KEY_READERS: ReadonlyMap<string, (account: Account) => Promise<string | undefined>>;
@@ -0,0 +1,6 @@
1
+ import { codexLoginKey } from './codex/login-key.js';
2
+ /**
3
+ * Family id → its Login Key reader. Must list exactly the Families of `LOGIN_KEY_FAMILIES` (core/same-login.ts);
4
+ * a test keeps the two in step.
5
+ */
6
+ export const LOGIN_KEY_READERS = new Map([['codex', codexLoginKey]]);
@@ -41,6 +41,10 @@ export const piFamily = {
41
41
  'OPENAI_BASE_URL',
42
42
  ],
43
43
  apiVars: ['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_BASE_URL'],
44
+ // First key in the proxy.env template `new --api` writes. The OpenAI key is still set with `--var`.
45
+ secretVar: 'ANTHROPIC_API_KEY',
46
+ // `-p` is one non-interactive prompt (pi 1.3.0 --help, 2026-10-17).
47
+ probe: { args: ['-p', 'Reply with OK'] },
44
48
  sharedItems: [
45
49
  { path: 'AGENTS.md', mode: 'link' },
46
50
  { path: 'auth.json', mode: 'info', credential: true },
@@ -7,7 +7,7 @@ import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/prom
7
7
  import { join } from 'node:path';
8
8
  import { loginShellPath } from '../core/login-path.js';
9
9
  import { buildId } from '../core/version.js';
10
- import { DEFAULT_PANEL_PORT, isSidebyPanel, panelIdentity, startPanelServer, } from './server.js';
10
+ import { DEFAULT_PANEL_PORT, isSidebyPanel, panelIdentity, panelOwner, startPanelServer, } from './server.js';
11
11
  export const DETACHED_FLAG = 'serve-detached';
12
12
  const START_TIMEOUT_MS = 20_000;
13
13
  const LOCK_STALE_MS = 30_000;
@@ -85,7 +85,7 @@ export async function findRunningPanel(p, port) {
85
85
  if (check.status === 'stale')
86
86
  await rm(pidFilePath(p), { force: true });
87
87
  }
88
- return (await isSidebyPanel(port)) ? `http://127.0.0.1:${port}/` : undefined;
88
+ return (await isSidebyPanel(port, panelOwner(p.home))) ? `http://127.0.0.1:${port}/` : undefined;
89
89
  }
90
90
  /**
91
91
  * Stops this state directory's background Panel when it runs another build than `build` (or is older than build
@@ -209,6 +209,7 @@ export async function serveDetached(opts) {
209
209
  const srv = await startPanelServer({
210
210
  runtimeFactory: async () => opts.runtimeFactory(await envReady),
211
211
  instance,
212
+ owner: panelOwner(opts.paths.home),
212
213
  ...(opts.port ? { port: opts.port } : {}),
213
214
  open: false,
214
215
  log,
@@ -1,7 +1,8 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { type AppProfileStatus } from '../core/app-profiles.ts';
3
+ import { type AutostartContext, type AutostartStatus } from '../core/autostart.ts';
3
4
  import { type BrowserReport } from '../core/browser-profiles.ts';
4
- import { type DisplayConfig, type HandoffSettings } from '../core/config.ts';
5
+ import { type DisplayConfig, type HandoffSettings, type PanelConfig } from '../core/config.ts';
5
6
  import { type AccountModel } from '../core/model-choice.ts';
6
7
  import type { RemoteControlGroup } from '../core/remote-control.ts';
7
8
  import * as update from '../core/update.ts';
@@ -68,9 +69,15 @@ export declare function healthByAccount(history: DoctorHistory | undefined): Map
68
69
  fail: number;
69
70
  warn: number;
70
71
  }>;
71
- /** A Family as the page sees it: FamilyInfo plus the mark from its FamilyDef. */
72
+ /** A Family as the page sees it: FamilyInfo plus the mark from its FamilyDef, and what the key box needs. */
72
73
  export type PanelFamily = FamilyInfo & {
73
74
  logo?: FamilyLogo;
75
+ /** The variable the key box writes by default (spec §3.28); null when the Family names none. */
76
+ keyVar?: string | null;
77
+ /** The Secret File holds keys even for a subscription (pi), so its accounts offer the key box too. */
78
+ keyAlways?: boolean;
79
+ /** What "Try it" runs (`sideby try`): the Host and the probe arguments; absent when it cannot be tried. */
80
+ tryCommand?: string[];
74
81
  };
75
82
  /** What the Panel shows about updates (spec §3.23); absent when the handler was created with `updates: false`. */
76
83
  export interface PanelUpdate {
@@ -155,7 +162,27 @@ export interface PanelState {
155
162
  display: DisplayConfig;
156
163
  /** Families that ask for Quota in a way that costs a request (spec §3.26), with their switch; absent when none. */
157
164
  quotaChecks?: QuotaCheckInfo[];
165
+ /** Config `panel` (spec §3.29): the theme and language the Panel shows; absent when neither is set. */
166
+ panel?: PanelConfig;
158
167
  }
168
+ /**
169
+ * Internal, for tests: replaces parts of the machine the Panel's start-at-login switch uses (platform, launcher,
170
+ * `exec`, login home). Not part of the plugin or Portal contract.
171
+ */
172
+ export declare function bindPanelAutostart(ctx: Partial<AutostartContext>): () => void;
173
+ /**
174
+ * The start-at-login state as the page shows it (spec §3.29): the agent's path and what it starts, with the home
175
+ * folder as `~`, and nothing else of the machine.
176
+ */
177
+ export declare function autostartView(s: AutostartStatus, home: string): {
178
+ supported: boolean;
179
+ reason?: string | undefined;
180
+ state: import("../core/autostart.ts").AutostartState;
181
+ path: string;
182
+ current: boolean;
183
+ programArguments?: string[] | undefined;
184
+ missing?: string[] | undefined;
185
+ };
159
186
  /** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
160
187
  export declare function handoffByFamily(rt: Runtime, accounts: readonly PanelAccount[], now: Date): Record<string, string>;
161
188
  export type RemoteControlView = Omit<RemoteControlGroup, 'file'> & {