sideby 0.10.0 → 0.11.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 (43) hide show
  1. package/README.md +22 -16
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +4 -1
  4. package/dist/src/cli/commands.js +50 -5
  5. package/dist/src/cli/json-schemas.js +31 -1
  6. package/dist/src/cli/main.js +15 -5
  7. package/dist/src/core/app-profiles.d.ts +50 -0
  8. package/dist/src/core/app-profiles.js +206 -0
  9. package/dist/src/core/brand-mark.d.ts +29 -0
  10. package/dist/src/core/brand-mark.js +42 -0
  11. package/dist/src/core/desktop-app.d.ts +18 -1
  12. package/dist/src/core/desktop-app.js +22 -12
  13. package/dist/src/core/handoff-chain.d.ts +16 -2
  14. package/dist/src/core/handoff-chain.js +19 -6
  15. package/dist/src/core/handoff-run.js +43 -18
  16. package/dist/src/core/handoff-select.d.ts +4 -0
  17. package/dist/src/core/handoff-select.js +3 -2
  18. package/dist/src/desktop/icon.js +33 -33
  19. package/dist/src/families/claude/handoff.js +10 -0
  20. package/dist/src/families/desktop.d.ts +25 -0
  21. package/dist/src/families/desktop.js +8 -2
  22. package/dist/src/families/grok/bot.d.ts +8 -0
  23. package/dist/src/families/grok/bot.js +18 -0
  24. package/dist/src/handoff/hook.js +21 -4
  25. package/dist/src/handoff/messages.d.ts +4 -0
  26. package/dist/src/handoff/messages.js +12 -0
  27. package/dist/src/panel/handler.d.ts +3 -0
  28. package/dist/src/panel/handler.js +37 -1
  29. package/dist/src/panel/i18n.d.ts +30 -0
  30. package/dist/src/panel/i18n.js +60 -0
  31. package/dist/src/panel/page-script.js +149 -5
  32. package/dist/src/panel/page-styles.js +15 -0
  33. package/dist/src/panel/page.js +2 -1
  34. package/dist/src/runtime.d.ts +3 -1
  35. package/dist/src/runtime.js +7 -0
  36. package/dist/src/types.d.ts +5 -0
  37. package/package.json +2 -2
  38. package/schemas/desktop-remove.json +33 -0
  39. package/schemas/desktop.json +2 -1
  40. package/schemas/handoff-ready.json +4 -0
  41. package/schemas/list.json +47 -0
  42. package/skills/sideby/SKILL.md +3 -1
  43. package/skills/sideby/references/handoff.md +1 -1
@@ -2,6 +2,7 @@ import { type DesktopAppDef } from '../families/desktop.ts';
2
2
  import type { LoadedPlugin } from '../plugins/loader.ts';
3
3
  import type { Runtime } from '../runtime.ts';
4
4
  import type { Account, CheckResult, Env } from '../types.ts';
5
+ import { UserError } from './errors.ts';
5
6
  export interface ExecResult {
6
7
  code: number;
7
8
  stdout: string;
@@ -20,6 +21,7 @@ export interface DesktopDeps {
20
21
  * `createRuntime` calls it, from its `desktop` option, which is the one way to replace the machine (tests).
21
22
  */
22
23
  export declare function bindDesktopDeps(rt: Runtime, deps: Partial<DesktopDeps>): void;
24
+ export declare function depsFor(rt: Runtime): DesktopDeps;
23
25
  /** The Desktop App of a Family, only when a built-in Plugin provides that Family (a plugin may not claim one). */
24
26
  export declare function desktopAppOf(plugins: readonly LoadedPlugin[], familyId: string): DesktopAppDef | undefined;
25
27
  /** Where a non-main Account's App Data lives: `~/Library/Application Support/sideby/<family>/<name>`. */
@@ -33,15 +35,28 @@ export interface FoundApp {
33
35
  * Finds the app by bundle id: the usual names in the app folders first, then (with `spotlight`) Spotlight. Listing
34
36
  * Accounts leaves Spotlight out to stay fast; opening one asks it too.
35
37
  */
36
- export declare function findApp(def: DesktopAppDef, deps: DesktopDeps, opts?: {
38
+ export declare function findApp(def: Pick<DesktopAppDef, 'bundleId' | 'bundleNames'>, deps: DesktopDeps, opts?: {
37
39
  spotlight?: boolean;
38
40
  }): Promise<FoundApp | null>;
39
41
  export interface Proc {
40
42
  pid: number;
41
43
  command: string;
42
44
  }
45
+ /** Every process with its command line, or null when `ps` failed (then nothing is known to run). */
46
+ export declare function processes(deps: DesktopDeps): Promise<Proc[] | null>;
47
+ /** The arguments after the app's own executable, when `p` is the app's main process (not a helper). */
48
+ export declare function mainArgs(app: FoundApp, p: Proc): string | null;
43
49
  /** The running instance of this Account: the one with its App Data, or for the Main Account the one without any. */
44
50
  export declare function instanceOf(app: FoundApp, appData: string | null, procs: readonly Proc[]): Proc | undefined;
51
+ /**
52
+ * Brings a running app to the front as a Dock click does: a reopen event (`aevt/rapp`) to that one process, so an
53
+ * app whose windows were all closed opens one again, then NSRunningApplication activation. Activation alone makes
54
+ * such an app frontmost with nothing on screen (seen 2026-10-14 with the Codex app). Neither needs the Automation
55
+ * permission.
56
+ */
57
+ export declare function activate(deps: DesktopDeps, pid: number): Promise<boolean>;
58
+ /** The caller's environment without `clear`, `SIDEBY_*` and the variables of the agent session it may run in. */
59
+ export declare function cleanEnv(base: Env, clear: readonly string[]): Env;
45
60
  export interface DesktopOpenResult {
46
61
  ref: string;
47
62
  family: string;
@@ -61,6 +76,8 @@ export interface DesktopOpenResult {
61
76
  /** What the user should know: sign in first, close other windows while signing in. */
62
77
  notices: string[];
63
78
  }
79
+ export declare function notMac(platform: string): UserError;
80
+ export declare function notInstalled(def: Pick<DesktopAppDef, 'title' | 'installUrl'>, deps: DesktopDeps): UserError;
64
81
  /**
65
82
  * Opens the Account's Desktop App, or brings its running instance to the front. Never starts a second instance of the
66
83
  * same Account, never ends a process, never touches the app bundle or the Host account directory.
@@ -5,7 +5,7 @@
5
5
  import { execFile } from 'node:child_process';
6
6
  import { chmod, mkdir, readdir, readFile, symlink } from 'node:fs/promises';
7
7
  import { basename, join } from 'node:path';
8
- import { desktopDef } from '../families/desktop.js';
8
+ import { desktopDef, profileAppFor } from '../families/desktop.js';
9
9
  import { UserError } from './errors.js';
10
10
  import { lstatOrNull, statOrNull } from './fs-safe.js';
11
11
  import { checkItem } from './share-modes.js';
@@ -28,7 +28,7 @@ const overrides = new WeakMap();
28
28
  export function bindDesktopDeps(rt, deps) {
29
29
  overrides.set(rt, deps);
30
30
  }
31
- function depsFor(rt) {
31
+ export function depsFor(rt) {
32
32
  const o = overrides.get(rt) ?? {};
33
33
  return {
34
34
  platform: o.platform ?? process.platform,
@@ -102,7 +102,7 @@ export async function findApp(def, deps, opts = {}) {
102
102
  return null;
103
103
  }
104
104
  /** Every process with its command line, or null when `ps` failed (then nothing is known to run). */
105
- async function processes(deps) {
105
+ export async function processes(deps) {
106
106
  const r = await deps.exec(PS, ['-axo', 'pid=,command=']);
107
107
  if (r.code !== 0)
108
108
  return null;
@@ -115,7 +115,7 @@ async function processes(deps) {
115
115
  return out;
116
116
  }
117
117
  /** The arguments after the app's own executable, when `p` is the app's main process (not a helper). */
118
- function mainArgs(app, p) {
118
+ export function mainArgs(app, p) {
119
119
  const exe = `/${basename(app.path)}/Contents/MacOS/${app.executable}`;
120
120
  const at = p.command.indexOf(exe);
121
121
  if (at === -1)
@@ -145,7 +145,7 @@ export function instanceOf(app, appData, procs) {
145
145
  * such an app frontmost with nothing on screen (seen 2026-10-14 with the Codex app). Neither needs the Automation
146
146
  * permission.
147
147
  */
148
- async function activate(deps, pid) {
148
+ export async function activate(deps, pid) {
149
149
  const p = Math.trunc(pid);
150
150
  const script = [
151
151
  "ObjC.import('AppKit');",
@@ -162,11 +162,12 @@ async function activate(deps, pid) {
162
162
  }
163
163
  /** The environment `open` runs with: the caller's, without anything that would pick another Account (spec §3.5). */
164
164
  function openEnv(base, family, def) {
165
+ return cleanEnv(base, [...family.hijackVars, family.selectVar, ...def.clearVars]);
166
+ }
167
+ /** The caller's environment without `clear`, `SIDEBY_*` and the variables of the agent session it may run in. */
168
+ export function cleanEnv(base, clear) {
165
169
  const env = { ...base };
166
- for (const v of family.hijackVars)
167
- delete env[v];
168
- delete env[family.selectVar];
169
- for (const v of def.clearVars)
170
+ for (const v of clear)
170
171
  delete env[v];
171
172
  for (const k of Object.keys(env))
172
173
  if (k.startsWith('SIDEBY_') || isSessionVar(k))
@@ -191,10 +192,19 @@ const SESSION_VARS = new Set([
191
192
  function isSessionVar(k) {
192
193
  return SESSION_VARS.has(k) || SESSION_PREFIXES.some((p) => k.startsWith(p));
193
194
  }
194
- function notMac(platform) {
195
+ /** `a`, `a and b`, `a, b and c`. */
196
+ function joinNames(ids) {
197
+ return ids.length < 2 ? ids.join('') : `${ids.slice(0, -1).join(', ')} and ${ids.at(-1)}`;
198
+ }
199
+ /** For a Family whose people use a separate app with App Profiles (grok: Grok Bot), how to open that instead. */
200
+ function profileHint(familyId) {
201
+ const p = profileAppFor(familyId);
202
+ return p ? `; for ${p.title}, which signs in on its own, use \`sideby desktop ${p.id}:<name>\`` : '';
203
+ }
204
+ export function notMac(platform) {
195
205
  return new UserError(`opening a desktop app per account works on macOS only for now (this is ${platform}); use \`sideby run <account>\` for the terminal`, { code: 'not-macos' });
196
206
  }
197
- function notInstalled(def, deps) {
207
+ export function notInstalled(def, deps) {
198
208
  return new UserError(`${def.title} is not installed (looked in ${deps.appDirs.join(' and ')}); install it from ${def.installUrl}, then try again`, { code: 'app-not-installed' });
199
209
  }
200
210
  /**
@@ -208,7 +218,7 @@ export async function openDesktopApp(rt, ref) {
208
218
  const def = family ? desktopAppOf(rt.plugins, account.family) : undefined;
209
219
  if (!family || !def) {
210
220
  const have = [...rt.families.keys()].filter((id) => desktopAppOf(rt.plugins, id));
211
- throw new UserError(`${account.ref}: ${family?.title ?? account.family} has no desktop app sideby can open${have.length ? ` (only ${have.join(' and ')} accounts do)` : ''}; use \`sideby run ${account.ref}\``, { code: 'no-desktop-app' });
221
+ throw new UserError(`${account.ref}: ${family?.title ?? account.family} has no desktop app sideby can open${have.length ? ` (only ${joinNames(have)} accounts do)` : ''}; use \`sideby run ${account.ref}\`${profileHint(account.family)}`, { code: 'no-desktop-app' });
212
222
  }
213
223
  if (account.kind === 'api')
214
224
  throw new UserError(`${account.ref} is an API account; ${def.title} signs in with a subscription only, so open it for a subscription account, or use \`sideby run ${account.ref}\``, { code: 'api-account' });
@@ -14,6 +14,8 @@ export interface PlanFile {
14
14
  at: string;
15
15
  decision: 'pick' | 'wait' | 'none';
16
16
  pick?: string;
17
+ /** For `wait`: when the full window resets and the session goes on by itself. */
18
+ until?: string;
17
19
  prepareAt: number;
18
20
  threshold: number;
19
21
  /** False when the session cannot write its Brief: no preparing step, and the threshold only ends the turn. */
@@ -26,6 +28,8 @@ export interface RunState {
26
28
  prepareSentAt?: string;
27
29
  thresholdSentAt?: string;
28
30
  stopBlocked?: boolean;
31
+ /** The user was told this run waits for a reset instead of moving on (ADR-0018). */
32
+ waitNoticeSentAt?: string;
29
33
  sessionId?: string;
30
34
  transcriptPath?: string;
31
35
  }
@@ -42,6 +46,12 @@ export interface ReadyFile {
42
46
  sessionId?: string;
43
47
  transcriptPath?: string;
44
48
  }
49
+ /** The parent kept a session that hit its limit, to wait for the reset at `until` (ADR-0018). */
50
+ export interface WaitingFile {
51
+ runId: string;
52
+ until: string;
53
+ at: string;
54
+ }
45
55
  export interface ChainHop {
46
56
  ref: string;
47
57
  startedAt: string;
@@ -57,6 +67,7 @@ export declare const FILES: {
57
67
  readonly plan: 'plan.json';
58
68
  readonly ready: 'ready.json';
59
69
  readonly request: 'request.json';
70
+ readonly waiting: 'waiting.json';
60
71
  readonly chain: 'chain.json';
61
72
  readonly run: (runId: string) => string;
62
73
  };
@@ -73,7 +84,10 @@ export declare function writeJsonFile(path: string, value: unknown): Promise<voi
73
84
  /**
74
85
  * `sideby handoff ready` (spec §3.17): asks the parent to hand over after this turn. Works only inside a run sideby
75
86
  * started with Auto Handoff; `briefFile`, when given, is copied into the chain directory (mode 600); `to` is the
76
- * Account ref to hand over to.
87
+ * Account ref to hand over to. While the session waits for a reset (`waiting.json`, typed as `! sideby handoff ready`),
88
+ * no turn will end to pick up a request, so it writes `ready.json` at once (`now`).
77
89
  * Returns the request, or null outside such a run.
78
90
  */
79
- export declare function requestHandoff(env: Readonly<Record<string, string | undefined>>, briefFile?: string, to?: string): Promise<ReadyFile | null>;
91
+ export declare function requestHandoff(env: Readonly<Record<string, string | undefined>>, briefFile?: string, to?: string, now?: Date): Promise<(ReadyFile & {
92
+ now: boolean;
93
+ }) | null>;
@@ -1,7 +1,8 @@
1
1
  // The files one Handoff chain keeps under `<state>/handoffs/<chain>/` (spec §3.17). The handoff hook runs on every
2
2
  // tool call, so this module imports only Node built-ins and leaf modules (never the Runtime or a Family index).
3
- // The parent `sideby run` writes `plan.json` and `chain.json`; the hook writes `run-<id>.json` and `ready.json`;
4
- // `sideby handoff ready` writes `request.json`. Every file is mode 600 in a mode 700 directory.
3
+ // The parent `sideby run` writes `plan.json`, `chain.json` and, while a session waits for a reset, `waiting.json`; the
4
+ // hook writes `run-<id>.json` and `ready.json`; `sideby handoff ready` writes `request.json` (`ready.json` while the
5
+ // session waits). Every file is mode 600 in a mode 700 directory.
5
6
  import { randomBytes } from 'node:crypto';
6
7
  import { chmod, mkdir, readFile } from 'node:fs/promises';
7
8
  import { join } from 'node:path';
@@ -16,6 +17,7 @@ export const FILES = {
16
17
  plan: 'plan.json',
17
18
  ready: 'ready.json',
18
19
  request: 'request.json',
20
+ waiting: 'waiting.json',
19
21
  chain: 'chain.json',
20
22
  run: (runId) => `run-${runId}.json`,
21
23
  };
@@ -51,10 +53,11 @@ export async function writeJsonFile(path, value) {
51
53
  /**
52
54
  * `sideby handoff ready` (spec §3.17): asks the parent to hand over after this turn. Works only inside a run sideby
53
55
  * started with Auto Handoff; `briefFile`, when given, is copied into the chain directory (mode 600); `to` is the
54
- * Account ref to hand over to.
56
+ * Account ref to hand over to. While the session waits for a reset (`waiting.json`, typed as `! sideby handoff ready`),
57
+ * no turn will end to pick up a request, so it writes `ready.json` at once (`now`).
55
58
  * Returns the request, or null outside such a run.
56
59
  */
57
- export async function requestHandoff(env, briefFile, to) {
60
+ export async function requestHandoff(env, briefFile, to, now = new Date()) {
58
61
  const runId = env[HANDOFF_ENV.run];
59
62
  const dir = env[HANDOFF_ENV.dir];
60
63
  if (!runId || !dir)
@@ -66,11 +69,21 @@ export async function requestHandoff(env, briefFile, to) {
66
69
  }
67
70
  const request = {
68
71
  runId,
69
- at: new Date().toISOString(),
72
+ at: now.toISOString(),
70
73
  trigger: 'manual',
71
74
  ...(brief ? { brief, briefSource: 'user' } : {}),
72
75
  ...(to ? { to } : {}),
73
76
  };
77
+ const waiting = await readJsonFile(join(dir, FILES.waiting));
78
+ if (waiting && waiting.runId === runId && Date.parse(waiting.until) > now.getTime()) {
79
+ const state = (await readJsonFile(join(dir, FILES.run(runId)))) ?? {};
80
+ await writeJsonFile(join(dir, FILES.ready), {
81
+ ...request,
82
+ ...(state.sessionId ? { sessionId: state.sessionId } : {}),
83
+ ...(state.transcriptPath ? { transcriptPath: state.transcriptPath } : {}),
84
+ });
85
+ return { ...request, now: true };
86
+ }
74
87
  await writeJsonFile(join(dir, FILES.request), request);
75
- return request;
88
+ return { ...request, now: false };
76
89
  }
@@ -132,9 +132,12 @@ export function hookEntryWorks(env) {
132
132
  });
133
133
  }
134
134
  /** The decision, or `none` with the reason when sideby cannot tell (an account removed mid-chain, say). */
135
- async function decide(rt, account, used, to) {
135
+ async function decide(rt, account, used, to, manual = false) {
136
136
  try {
137
- return await rt.handoffDecision(account, used, to !== undefined ? { to } : {});
137
+ return await rt.handoffDecision(account, used, {
138
+ ...(to !== undefined ? { to } : {}),
139
+ ...(manual ? { manual: true } : {}),
140
+ });
138
141
  }
139
142
  catch (err) {
140
143
  return { kind: 'none', reason: err.message };
@@ -230,6 +233,7 @@ opts = {}) {
230
233
  at: new Date().toISOString(),
231
234
  decision: d.kind,
232
235
  ...(d.kind === 'pick' ? { pick: d.ref } : {}),
236
+ ...(d.kind === 'wait' ? { until: d.until } : {}),
233
237
  prepareAt: settings.prepareAt,
234
238
  threshold: settings.threshold,
235
239
  ...(elig.agentBrief === false ? { agentBrief: false } : {}),
@@ -246,27 +250,48 @@ opts = {}) {
246
250
  await writePlan();
247
251
  const refresh = setInterval(() => void writePlan(), PLAN_REFRESH_MS);
248
252
  const host = spawnHost({ ...launch, args, env });
249
- const watcher = watchReady(dir, runId);
250
- const outcome = await Promise.race([
251
- host.exited.then((code) => ({ kind: 'exit', code })),
252
- watcher.ready.then((ready) => ({ kind: 'ready', ready })),
253
- ]);
253
+ const exited = host.exited.then((code) => ({ kind: 'exit', code }));
254
+ // Nothing to move on to (or better to wait): leave the session alone, keep listening (the user may still ask to
255
+ // move on while it waits, ADR-0018), and say why once it ends.
256
+ let kept;
257
+ let outcome;
258
+ for (;;) {
259
+ const watcher = watchReady(dir, runId);
260
+ const got = await Promise.race([
261
+ exited,
262
+ watcher.ready.then((ready) => ({ kind: 'ready', ready })),
263
+ ]);
264
+ watcher.stop();
265
+ if (got.kind === 'exit') {
266
+ outcome = got;
267
+ break;
268
+ }
269
+ const d = await decide(rt, account, used, got.ready.to, got.ready.trigger === 'manual');
270
+ if (d.kind === 'pick') {
271
+ outcome = { ...got, decision: d };
272
+ break;
273
+ }
274
+ await rm(join(dir, FILES.ready), { force: true });
275
+ if (d.kind === 'wait')
276
+ await writeJsonFile(join(dir, FILES.waiting), {
277
+ runId,
278
+ until: d.until,
279
+ at: new Date().toISOString(),
280
+ });
281
+ kept = d;
282
+ }
254
283
  clearInterval(refresh);
255
- watcher.stop();
256
284
  stopped = true;
257
285
  await pending;
286
+ await rm(join(dir, FILES.waiting), { force: true });
258
287
  if (outcome.kind === 'exit') {
259
- await noteUnhandedHighQuota(rt, account, used, settings.threshold, io);
288
+ if (kept)
289
+ io.err(`sideby: ${account.ref} did not hand over: ${explain(kept)}`);
290
+ else
291
+ await noteUnhandedHighQuota(rt, account, used, settings.threshold, io);
260
292
  return outcome.code;
261
293
  }
262
- // Nothing to move on to (or better to wait): leave the session alone and say why once it ends.
263
- const decision = await decide(rt, account, used, outcome.ready.to);
264
- if (decision.kind !== 'pick') {
265
- await rm(join(dir, FILES.ready), { force: true });
266
- const code = await host.exited;
267
- io.err(`sideby: ${account.ref} did not hand over: ${explain(decision)}`);
268
- return code;
269
- }
294
+ const decision = outcome.decision;
270
295
  // The session is ready: end the Host, give it time to save, never kill it.
271
296
  host.child.kill('SIGTERM');
272
297
  const ended = await Promise.race([
@@ -356,5 +381,5 @@ export async function readyDecision(rt, env, to) {
356
381
  }
357
382
  const account = await rt.resolve(ref);
358
383
  const chain = await readJsonFile(join(dir, FILES.chain));
359
- return decide(rt, account, chain?.hops.map((h) => h.ref) ?? [account.ref], to);
384
+ return decide(rt, account, chain?.hops.map((h) => h.ref) ?? [account.ref], to, true);
360
385
  }
@@ -15,6 +15,8 @@ export interface SelectCandidate {
15
15
  installed: boolean;
16
16
  /** Whether its Family can start as the next Account (has `handoff`, and the run would be eligible). */
17
17
  receive: boolean;
18
+ /** A session stopped at the limit continues by itself at the reset (`FamilyHandoff.resumesAtReset`); set for the origin. */
19
+ resumesAtReset?: boolean;
18
20
  }
19
21
  /** An Account the Handoff Order lists that was left out, and why (spec §3.21). */
20
22
  export interface SkippedAccount {
@@ -55,4 +57,6 @@ export declare function selectNext(input: {
55
57
  * an Account whose Quota came back); every other rule still applies.
56
58
  */
57
59
  to?: string;
60
+ /** The user asked to hand over (`sideby handoff ready`): never wait for a reset (ADR-0018). */
61
+ manual?: boolean;
58
62
  }): HandoffDecision;
@@ -15,7 +15,8 @@ export function sameOrganization(a, b) {
15
15
  * reaches the threshold (spec §3.17 "选号" 1: plan.json and the Panel, decided before the moment comes).
16
16
  */
17
17
  function waitUntil(origin, s, now, ahead) {
18
- if (origin.quota.status !== 'ok' || s.waitIfResetWithinMinutes <= 0)
18
+ // Waiting only makes sense when the session goes on by itself at the reset (ADR-0018).
19
+ if (origin.quota.status !== 'ok' || s.waitIfResetWithinMinutes <= 0 || origin.resumesAtReset !== true)
19
20
  return undefined;
20
21
  const open = origin.quota.windows.filter((w) => !windowReset(w.resetsAt, now));
21
22
  const top = Math.max(0, ...open.map((w) => w.usedPercent));
@@ -60,7 +61,7 @@ export function selectNext(input) {
60
61
  return { kind: 'none', reason: `${input.to} is not an account sideby can find` };
61
62
  return toTarget(origin, target, s, new Set(s.crossOrganization.map(refOf)), now);
62
63
  }
63
- const until = waitUntil(origin, s, now, input.ahead === true);
64
+ const until = input.manual ? undefined : waitUntil(origin, s, now, input.ahead === true);
64
65
  if (until)
65
66
  return { kind: 'wait', until };
66
67
  const allowed = new Set(s.crossOrganization.map(refOf));
@@ -1,26 +1,32 @@
1
- // The sideby mark drawn without dependencies: the Panel header's logo (a dark rounded tile with a light disc
2
- // split into two halves, offset along the diagonal, the second at 55% opacity) rasterised with analytic
3
- // antialiasing and encoded as PNG, SVG or ICNS.
1
+ // The sideby mark drawn without dependencies: the Panel header's logo (two faces side by side on a light rounded
2
+ // tile, core/brand-mark.ts) rasterised with analytic antialiasing and encoded as PNG, SVG or ICNS.
4
3
  import { deflateSync } from 'node:zlib';
4
+ import { MARK, MARK_COLORS, MARK_TILE_VIEWBOX, markSvgBody } from '../core/brand-mark.js';
5
5
  /** Text stored in every generated file, so `sideby app uninstall` deletes only what it wrote. */
6
6
  export const APP_MARKER = 'sideby-app-install';
7
- // Geometry in the Panel's 40-unit logo box (page.ts: .logo 40×40, radius 10; halves of radius 10.5 centred at
8
- // 18.5,18.5 facing left and 21.5,21.5 facing right, so they sit 3 apart and mirror through the centre).
7
+ // The Panel's logo tile (page-styles.ts: .logo 40×40, radius 10), with the mark in MARK_TILE_VIEWBOX units on it.
9
8
  const TILE = { size: 40, radius: 10, color: [0xe5, 0xf2, 0xfb] };
10
- const MARK = { color: [0x3f, 0x8f, 0xc4], opacities: [1, 0.55] };
11
- const HALVES = [
12
- { cx: 18.5, cy: 18.5, r: 10.5, side: -1 },
13
- { cx: 21.5, cy: 21.5, r: 10.5, side: 1 },
14
- ];
15
9
  // The tile sits on the macOS icon grid: 824 of 1024 units, centred.
16
10
  const GRID = { canvas: 1024, tile: 824 };
11
+ const rgb = (hex) => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
17
12
  function shapes(size) {
18
13
  const tilePx = (size * GRID.tile) / GRID.canvas;
19
14
  const off = (size - tilePx) / 2;
20
- const u = tilePx / TILE.size;
15
+ const u = tilePx / MARK_TILE_VIEWBOX.size;
16
+ const place = ([x, y, w, h, r]) => ({
17
+ x: off + (x - MARK_TILE_VIEWBOX.x) * u,
18
+ y: off + (y - MARK_TILE_VIEWBOX.y) * u,
19
+ w: w * u,
20
+ h: h * u,
21
+ r: r * u,
22
+ });
21
23
  return {
22
- tile: { x: off, y: off, w: tilePx, h: tilePx, r: TILE.radius * u },
23
- halves: HALVES.map((h) => ({ cx: off + h.cx * u, cy: off + h.cy * u, r: h.r * u, side: h.side })),
24
+ tile: { x: off, y: off, w: tilePx, h: tilePx, r: (TILE.radius * tilePx) / TILE.size },
25
+ back: place(MARK.back),
26
+ front: place(MARK.front),
27
+ gap: place(MARK.gap),
28
+ backEyes: MARK.backEyes.map(place),
29
+ frontEyes: MARK.frontEyes.map(place),
24
30
  };
25
31
  }
26
32
  /** Signed distance from (px, py) to a rounded rectangle; negative inside. */
@@ -32,20 +38,18 @@ function roundedRectDistance(px, py, s) {
32
38
  const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));
33
39
  return outside + Math.min(Math.max(qx, qy), 0) - s.r;
34
40
  }
35
- /** Signed distance from (px, py) to a half disc: the disc intersected with the half-plane on its side. */
36
- function halfDistance(px, py, h) {
37
- return Math.max(Math.hypot(px - h.cx, py - h.cy) - h.r, (px - h.cx) * -h.side);
38
- }
39
41
  /** RGBA pixels (straight alpha) of the icon at `size`×`size`. */
40
42
  export function renderIcon(size) {
41
- const { tile, halves } = shapes(size);
43
+ const sh = shapes(size);
44
+ const d = (r) => (x, y) => roundedRectDistance(x, y, r);
45
+ // Inside `r` and outside the gap around the front face.
46
+ const cut = (r) => (x, y) => Math.max(roundedRectDistance(x, y, r), -roundedRectDistance(x, y, sh.gap));
42
47
  const layers = [
43
- { dist: (x, y) => roundedRectDistance(x, y, tile), color: TILE.color, opacity: 1 },
44
- ...halves.map((h, i) => ({
45
- dist: (x, y) => halfDistance(x, y, h),
46
- color: MARK.color,
47
- opacity: MARK.opacities[i],
48
- })),
48
+ { dist: d(sh.tile), color: TILE.color },
49
+ { dist: cut(sh.back), color: rgb(MARK_COLORS.back) },
50
+ ...sh.backEyes.map((e) => ({ dist: cut(e), color: rgb(MARK_COLORS.eye) })),
51
+ { dist: d(sh.front), color: rgb(MARK_COLORS.front) },
52
+ ...sh.frontEyes.map((e) => ({ dist: d(e), color: rgb(MARK_COLORS.eye) })),
49
53
  ];
50
54
  const px = new Uint8Array(size * size * 4);
51
55
  for (let y = 0; y < size; y++)
@@ -56,8 +60,7 @@ export function renderIcon(size) {
56
60
  let b = 0;
57
61
  let a = 0;
58
62
  for (const l of layers) {
59
- const cover = Math.min(1, Math.max(0, 0.5 - l.dist(x + 0.5, y + 0.5)));
60
- const la = cover * l.opacity;
63
+ const la = Math.min(1, Math.max(0, 0.5 - l.dist(x + 0.5, y + 0.5)));
61
64
  if (la === 0)
62
65
  continue;
63
66
  r = (l.color[0] / 255) * la + r * (1 - la);
@@ -157,17 +160,14 @@ export function encodeIcns(pngs) {
157
160
  }
158
161
  /** The same mark as an SVG (1024-unit canvas), for Linux icon themes. */
159
162
  export function iconSvg() {
160
- const { tile, halves } = shapes(GRID.canvas);
163
+ const { tile } = shapes(GRID.canvas);
161
164
  const hex = (c) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
162
- const opacityAttr = (opacity) => (opacity === 1 ? '' : ` fill-opacity="${opacity}"`);
163
- const rect = (s, fill) => ` <rect x="${s.x}" y="${s.y}" width="${s.w}" height="${s.h}" rx="${s.r}" fill="${fill}"/>`;
164
- // From the top of the cut to its bottom, bulging to the half's side.
165
- const half = (h, fill, opacity) => ` <path d="M${h.cx} ${h.cy - h.r}A${h.r} ${h.r} 0 0 ${h.side > 0 ? 1 : 0} ${h.cx} ${h.cy + h.r}Z" fill="${fill}"${opacityAttr(opacity)}/>`;
165
+ const V = MARK_TILE_VIEWBOX;
166
166
  return [
167
167
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${GRID.canvas} ${GRID.canvas}" width="${GRID.canvas}" height="${GRID.canvas}">`,
168
168
  ` <!-- ${APP_MARKER} -->`,
169
- rect(tile, hex(TILE.color)),
170
- ...halves.map((h, i) => half(h, hex(MARK.color), MARK.opacities[i])),
169
+ ` <rect x="${tile.x}" y="${tile.y}" width="${tile.w}" height="${tile.h}" rx="${tile.r}" fill="${hex(TILE.color)}"/>`,
170
+ ` <svg x="${tile.x}" y="${tile.y}" width="${tile.w}" height="${tile.h}" viewBox="${V.x} ${V.y} ${V.size} ${V.size}">${markSvgBody('gap', MARK_COLORS.front)}</svg>`,
171
171
  '</svg>',
172
172
  '',
173
173
  ].join('\n');
@@ -2,6 +2,7 @@
2
2
  // Account's own settings hooks (verified 2026-10-09), and the Brief's directory through `--add-dir`.
3
3
  import { mkdir } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
+ import { locate, readObject } from '../../core/claude-settings-file.js';
5
6
  import { writeFileAtomic } from '../../core/fs-safe.js';
6
7
  import { carryArgs, hasOption, promptAfterDashes } from '../shared/handoff-args.js';
7
8
  import { claudeBrief } from './brief.js';
@@ -113,4 +114,13 @@ export const claudeHandoff = {
113
114
  return { start: true, receive: true };
114
115
  },
115
116
  briefFromSession: claudeBrief,
117
+ // Only `autoContinueAtUsageLimit` is read (ADR-0018); Claude Code's default is true. Managed and project settings
118
+ // and `--settings` can set it too; sideby does not read them.
119
+ async resumesAtReset(account) {
120
+ const where = await locate(account);
121
+ if (!where.exists)
122
+ return true;
123
+ const read = await readObject(where.file);
124
+ return 'error' in read || read.obj.autoContinueAtUsageLimit !== false;
125
+ },
116
126
  };
@@ -25,3 +25,28 @@ export interface DesktopAppDef {
25
25
  }
26
26
  /** The Desktop App of a built-in Family id, if it has one. */
27
27
  export declare function desktopDef(familyId: string): DesktopAppDef | undefined;
28
+ /**
29
+ * A desktop app that signs in apart from every Account (Grok Bot), so each of its windows is an App Profile with its
30
+ * own App Data, named by the user (ADR-0019).
31
+ */
32
+ export interface ProfileAppDef {
33
+ /** The ref prefix: `<id>:<name>`, and `<id>` alone for the Main Profile. */
34
+ id: string;
35
+ title: string;
36
+ bundleId: string;
37
+ bundleNames: readonly string[];
38
+ installUrl: string;
39
+ /** `open` arguments for a non-main Profile; `app` is the bundle path, `appData` the Profile's folder. */
40
+ openArgs(app: string, appData: string): string[];
41
+ /** Inherited variables cleared before `open`, which would point the app at other data. */
42
+ clearVars: readonly string[];
43
+ /** The Main Profile's app data folder, relative to HOME. */
44
+ mainUserData: string;
45
+ /** The app's own status file in an app data folder, whose boolean `signedIn` sideby reads. */
46
+ statusFile: string;
47
+ /** The Family whose users may look for this app under an Account (grok), for a pointer in its error. */
48
+ relatedFamily: string;
49
+ }
50
+ export declare const PROFILE_APPS: readonly ProfileAppDef[];
51
+ /** The app with App Profiles related to a Family id, if any. */
52
+ export declare function profileAppFor(familyId: string): ProfileAppDef | undefined;
@@ -1,9 +1,15 @@
1
- // Desktop Apps of the built-in Families (spec §3.20, ADR-0015). Internal on purpose: not part of the public
2
- // FamilyDef, so plugin Families cannot declare one in v1.
1
+ // Desktop Apps of the built-in Families (spec §3.20, ADR-0015) and apps with App Profiles (ADR-0019). Internal on
2
+ // purpose: not part of the public FamilyDef, so plugin Families cannot declare one in v1.
3
3
  import { claudeDesktop } from './claude/desktop.js';
4
4
  import { codexDesktop } from './codex/desktop.js';
5
+ import { grokBot } from './grok/bot.js';
5
6
  const DESKTOP_APPS = new Map([claudeDesktop, codexDesktop].map((d) => [d.family, d]));
6
7
  /** The Desktop App of a built-in Family id, if it has one. */
7
8
  export function desktopDef(familyId) {
8
9
  return DESKTOP_APPS.get(familyId);
9
10
  }
11
+ export const PROFILE_APPS = [grokBot];
12
+ /** The app with App Profiles related to a Family id, if any. */
13
+ export function profileAppFor(familyId) {
14
+ return PROFILE_APPS.find((p) => p.relatedFamily === familyId);
15
+ }
@@ -0,0 +1,8 @@
1
+ import type { ProfileAppDef } from '../desktop.ts';
2
+ /**
3
+ * Grok Bot (spec §3.20 "Grok Bot 窗口", ADR-0019). It signs in with a Cursor account, apart from every grok Account,
4
+ * so its windows are App Profiles. `--user-data-dir` is the app's isolated mode: userData and its data root
5
+ * (`<dir>/sand-data`) move there. SAND_DATA_ROOT and SAND_USER_DATA_DIR would override it; all of this is internal to
6
+ * the app and may change.
7
+ */
8
+ export declare const grokBot: ProfileAppDef;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Grok Bot (spec §3.20 "Grok Bot 窗口", ADR-0019). It signs in with a Cursor account, apart from every grok Account,
3
+ * so its windows are App Profiles. `--user-data-dir` is the app's isolated mode: userData and its data root
4
+ * (`<dir>/sand-data`) move there. SAND_DATA_ROOT and SAND_USER_DATA_DIR would override it; all of this is internal to
5
+ * the app and may change.
6
+ */
7
+ export const grokBot = {
8
+ id: 'grok-bot',
9
+ title: 'Grok Bot',
10
+ bundleId: 'com.anysphere.sand',
11
+ bundleNames: ['Grok Bot.app'],
12
+ installUrl: 'https://x.ai/bot/',
13
+ openArgs: (app, appData) => ['-n', '-a', app, '--args', `--user-data-dir=${appData}`],
14
+ clearVars: ['SAND_DATA_ROOT', 'SAND_USER_DATA_DIR'],
15
+ mainUserData: 'Library/Application Support/Grok Bot',
16
+ statusFile: 'desktop-status.json',
17
+ relatedFamily: 'grok',
18
+ };
@@ -7,7 +7,7 @@ import { FILES, HANDOFF_ENV, readJsonFile, writeJsonFile, } from '../core/handof
7
7
  import { claudeHookReader } from '../families/claude/handoff-hook.js';
8
8
  import { codexHookReader } from '../families/codex/handoff-hook.js';
9
9
  import { grokHookReader } from '../families/grok/handoff-hook.js';
10
- import { endTurnMessage, prepareMessage, sessionStartMessage, stopMessage, thresholdMessage, } from './messages.js';
10
+ import { endTurnMessage, notifySequence, prepareMessage, sessionStartMessage, stopMessage, thresholdMessage, waitNotice, } from './messages.js';
11
11
  import { field } from './reader.js';
12
12
  export const HOOK_COMMAND = 'sideby handoff-hook';
13
13
  const READERS = {
@@ -77,10 +77,21 @@ export async function decide(opts) {
77
77
  }
78
78
  const threshold = usable?.threshold ?? 95;
79
79
  const pressure = reader.pressure ? await reader.pressure(input, env, now) : null;
80
+ const ref = env.SIDEBY_ACCOUNT ?? family;
81
+ const waiting = usable?.decision === 'wait' && usable.until ? usable.until : undefined;
82
+ // Once per run, shown to the user (not the model): sideby will wait for the reset, and how to move on now.
83
+ const waitOutput = async () => {
84
+ if (!waiting || pressure === null || pressure < threshold || state.waitNoticeSentAt)
85
+ return undefined;
86
+ await save({ ...state, waitNoticeSentAt: now.toISOString() });
87
+ return { systemMessage: waitNotice(ref, waiting, pressure) };
88
+ };
80
89
  if (event === 'StopFailure') {
81
- if (reader.limitHit?.(input, pressure, threshold))
82
- await ready({ trigger: 'limit' });
83
- return {};
90
+ if (!reader.limitHit?.(input, pressure, threshold))
91
+ return {};
92
+ await ready({ trigger: 'limit' });
93
+ // The Host keeps only `terminalSequence` of a StopFailure hook's output.
94
+ return waiting ? { output: { terminalSequence: notifySequence(waitNotice(ref, waiting)) } } : {};
84
95
  }
85
96
  if (event === 'Stop') {
86
97
  // Grok also fires Stop when the session ends; only a finished turn counts.
@@ -95,6 +106,9 @@ export async function decide(opts) {
95
106
  }
96
107
  if (!usable)
97
108
  return {};
109
+ const notice = await waitOutput();
110
+ if (notice)
111
+ return { output: notice };
98
112
  if (state.thresholdSentAt && usable.agentBrief === false) {
99
113
  await ready({ trigger: 'threshold' });
100
114
  return {};
@@ -125,6 +139,9 @@ export async function decide(opts) {
125
139
  // PostToolUse
126
140
  if (!usable || pressure === null)
127
141
  return {};
142
+ const notice = await waitOutput();
143
+ if (notice)
144
+ return { output: notice };
128
145
  if (pressure >= usable.threshold && usable.decision === 'pick' && !state.thresholdSentAt) {
129
146
  await save({ ...state, thresholdSentAt: now.toISOString() });
130
147
  const text = usable.agentBrief === false ? endTurnMessage(pressure) : thresholdMessage(usable.brief, pressure);