sideby 0.10.0 → 0.12.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 (53) hide show
  1. package/README.md +24 -18
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +11 -1
  4. package/dist/src/cli/commands.js +144 -7
  5. package/dist/src/cli/json-schemas.js +56 -1
  6. package/dist/src/cli/main.js +119 -48
  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 +83 -0
  10. package/dist/src/core/brand-mark.js +88 -0
  11. package/dist/src/core/config.d.ts +16 -0
  12. package/dist/src/core/config.js +17 -2
  13. package/dist/src/core/desktop-app.d.ts +18 -1
  14. package/dist/src/core/desktop-app.js +22 -12
  15. package/dist/src/core/handoff-chain.d.ts +16 -2
  16. package/dist/src/core/handoff-chain.js +19 -6
  17. package/dist/src/core/handoff-run.js +43 -18
  18. package/dist/src/core/handoff-select.d.ts +4 -0
  19. package/dist/src/core/handoff-select.js +3 -2
  20. package/dist/src/core/update.d.ts +209 -0
  21. package/dist/src/core/update.js +631 -0
  22. package/dist/src/desktop/icon.js +293 -62
  23. package/dist/src/families/claude/handoff.js +10 -0
  24. package/dist/src/families/desktop.d.ts +25 -0
  25. package/dist/src/families/desktop.js +8 -2
  26. package/dist/src/families/grok/bot.d.ts +8 -0
  27. package/dist/src/families/grok/bot.js +18 -0
  28. package/dist/src/handoff/hook.js +21 -4
  29. package/dist/src/handoff/messages.d.ts +4 -0
  30. package/dist/src/handoff/messages.js +12 -0
  31. package/dist/src/panel/background.d.ts +2 -0
  32. package/dist/src/panel/background.js +7 -0
  33. package/dist/src/panel/handler.d.ts +35 -0
  34. package/dist/src/panel/handler.js +132 -6
  35. package/dist/src/panel/i18n.d.ts +59 -0
  36. package/dist/src/panel/i18n.js +118 -0
  37. package/dist/src/panel/page-script.js +282 -6
  38. package/dist/src/panel/page-styles.js +21 -2
  39. package/dist/src/panel/page.d.ts +2 -0
  40. package/dist/src/panel/page.js +6 -3
  41. package/dist/src/runtime.d.ts +3 -1
  42. package/dist/src/runtime.js +7 -0
  43. package/dist/src/types.d.ts +5 -0
  44. package/dist/testing/index.js +2 -0
  45. package/package.json +2 -2
  46. package/schemas/config.json +15 -0
  47. package/schemas/desktop-remove.json +33 -0
  48. package/schemas/desktop.json +2 -1
  49. package/schemas/handoff-ready.json +4 -0
  50. package/schemas/list.json +47 -0
  51. package/schemas/update.json +115 -0
  52. package/skills/sideby/SKILL.md +7 -1
  53. package/skills/sideby/references/handoff.md +1 -1
@@ -1,78 +1,312 @@
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 two buddies of core/brand-mark.ts on a light rounded tile,
2
+ // rasterised in pure Node and encoded as PNG, SVG or ICNS. Curves are flattened to polylines; fills use the nonzero
3
+ // rule with exact horizontal coverage on 16 sub-rows per pixel, outlines are round-capped, round-joined thick
4
+ // polylines (distance to a segment), ellipses use an analytic distance. Each shape is computed only inside its own
5
+ // bounding box and composited back to front, fill before outline, as an SVG renderer would.
4
6
  import { deflateSync } from 'node:zlib';
7
+ import { BUDDY, highlights, MARK, MARK_COLORS, MARK_VIEWBOX, markSvgBody, } from '../core/brand-mark.js';
5
8
  /** Text stored in every generated file, so `sideby app uninstall` deletes only what it wrote. */
6
9
  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).
10
+ // The Panel's logo tile (page-styles.ts: .logo 40×40, radius 10); MARK_VIEWBOX maps onto the whole tile square.
9
11
  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
12
  // The tile sits on the macOS icon grid: 824 of 1024 units, centred.
16
13
  const GRID = { canvas: 1024, tile: 824 };
17
- function shapes(size) {
18
- const tilePx = (size * GRID.tile) / GRID.canvas;
19
- const off = (size - tilePx) / 2;
20
- const u = tilePx / TILE.size;
14
+ // Sub-rows per pixel row for fills.
15
+ const SUB_ROWS = 16;
16
+ const rgb = (hex) => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
17
+ function tileRect(size) {
18
+ const w = (size * GRID.tile) / GRID.canvas;
19
+ const off = (size - w) / 2;
20
+ return { x: off, y: off, w, h: w, r: (TILE.radius * w) / TILE.size };
21
+ }
22
+ /** Pixels per mark unit and the map from a buddy's 64-unit grid to pixels. */
23
+ function placement(size, b) {
24
+ const tile = tileRect(size);
25
+ const u = tile.w / MARK_VIEWBOX.size;
26
+ const k = b.scale * u;
21
27
  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 })),
28
+ k,
29
+ map: (gx, gy) => [
30
+ tile.x + (b.x + b.scale * gx - MARK_VIEWBOX.x) * u,
31
+ tile.y + (b.y + b.scale * gy - MARK_VIEWBOX.y) * u,
32
+ ],
33
+ };
34
+ }
35
+ /** Parses absolute M, L, H, Q, C, Z path data and flattens it to pixel polylines. */
36
+ function flatten(d, map) {
37
+ const tokens = d.match(/[MLHQCZ]|-?\d*\.?\d+(?:e-?\d+)?/gi) ?? [];
38
+ const polys = [];
39
+ let cur = null;
40
+ let gx = 0;
41
+ let gy = 0;
42
+ let start = [0, 0];
43
+ let i = 0;
44
+ const num = () => Number(tokens[i++]);
45
+ const isNum = () => i < tokens.length && !/^[A-Za-z]$/.test(tokens[i]);
46
+ const lineTo = (x, y) => {
47
+ gx = x;
48
+ gy = y;
49
+ cur.pts.push(map(x, y));
50
+ };
51
+ const curve = (ctrl, end) => {
52
+ const all = [map(gx, gy), ...ctrl.map(([x, y]) => map(x, y)), map(end[0], end[1])];
53
+ let len = 0;
54
+ for (let j = 1; j < all.length; j++)
55
+ len += Math.hypot(all[j][0] - all[j - 1][0], all[j][1] - all[j - 1][1]);
56
+ const n = Math.max(4, Math.ceil(len / 2));
57
+ for (let s = 1; s <= n; s++) {
58
+ const t = s / n;
59
+ // De Casteljau on the pixel control points.
60
+ let p = all.map((q) => [q[0], q[1]]);
61
+ while (p.length > 1)
62
+ p = p.slice(1).map((q, j) => [p[j][0] + (q[0] - p[j][0]) * t, p[j][1] + (q[1] - p[j][1]) * t]);
63
+ cur.pts.push([p[0][0], p[0][1]]);
64
+ }
65
+ gx = end[0];
66
+ gy = end[1];
24
67
  };
68
+ let cmd = '';
69
+ while (i < tokens.length) {
70
+ if (!isNum())
71
+ cmd = tokens[i++].toUpperCase();
72
+ switch (cmd) {
73
+ case 'M':
74
+ gx = num();
75
+ gy = num();
76
+ start = [gx, gy];
77
+ cur = { pts: [map(gx, gy)], closed: false };
78
+ polys.push(cur);
79
+ cmd = 'L'; // further pairs after M are lines
80
+ break;
81
+ case 'L':
82
+ lineTo(num(), num());
83
+ break;
84
+ case 'H':
85
+ lineTo(num(), gy);
86
+ break;
87
+ case 'Q': {
88
+ const c = [num(), num()];
89
+ curve([c], [num(), num()]);
90
+ break;
91
+ }
92
+ case 'C': {
93
+ const c1 = [num(), num()];
94
+ const c2 = [num(), num()];
95
+ curve([c1, c2], [num(), num()]);
96
+ break;
97
+ }
98
+ case 'Z':
99
+ cur.closed = true;
100
+ gx = start[0];
101
+ gy = start[1];
102
+ break;
103
+ default:
104
+ throw new Error(`icon: unsupported path command ${cmd}`);
105
+ }
106
+ }
107
+ return polys;
108
+ }
109
+ function box(size, x0, y0, x1, y1) {
110
+ const bx0 = Math.max(0, Math.floor(x0));
111
+ const by0 = Math.max(0, Math.floor(y0));
112
+ const bx1 = Math.min(size, Math.ceil(x1));
113
+ const by1 = Math.min(size, Math.ceil(y1));
114
+ if (bx1 <= bx0 || by1 <= by0)
115
+ return null;
116
+ const w = bx1 - bx0;
117
+ const h = by1 - by0;
118
+ return { x0: bx0, y0: by0, w, h, a: new Float32Array(w * h) };
119
+ }
120
+ /** Nonzero fill of the polylines (each closed implicitly), exact in x and sampled on SUB_ROWS rows per pixel. */
121
+ function fillMask(size, polys) {
122
+ const edges = [];
123
+ let x0 = Infinity;
124
+ let y0 = Infinity;
125
+ let x1 = -Infinity;
126
+ let y1 = -Infinity;
127
+ for (const { pts } of polys)
128
+ for (let j = 0; j < pts.length; j++) {
129
+ const p = pts[j];
130
+ const q = pts[(j + 1) % pts.length];
131
+ if (p[1] !== q[1])
132
+ edges.push([p[0], p[1], q[0], q[1]]);
133
+ x0 = Math.min(x0, p[0]);
134
+ y0 = Math.min(y0, p[1]);
135
+ x1 = Math.max(x1, p[0]);
136
+ y1 = Math.max(y1, p[1]);
137
+ }
138
+ const m = box(size, x0, y0, x1, y1);
139
+ if (!m)
140
+ return null;
141
+ const hits = [];
142
+ for (let row = 0; row < m.h; row++)
143
+ for (let s = 0; s < SUB_ROWS; s++) {
144
+ const sy = m.y0 + row + (s + 0.5) / SUB_ROWS;
145
+ hits.length = 0;
146
+ for (const [ax, ay, bx, by] of edges) {
147
+ if (sy < Math.min(ay, by) || sy >= Math.max(ay, by))
148
+ continue;
149
+ hits.push([ax + ((sy - ay) * (bx - ax)) / (by - ay), by > ay ? 1 : -1]);
150
+ }
151
+ if (hits.length < 2)
152
+ continue;
153
+ hits.sort((p, q) => p[0] - q[0]);
154
+ let wind = 0;
155
+ for (let h = 0; h < hits.length - 1; h++) {
156
+ wind += hits[h][1];
157
+ if (wind === 0)
158
+ continue;
159
+ const xa = Math.max(hits[h][0], m.x0);
160
+ const xb = Math.min(hits[h + 1][0], m.x0 + m.w);
161
+ for (let px = Math.floor(xa); px < xb; px++) {
162
+ const cover = Math.min(xb, px + 1) - Math.max(xa, px);
163
+ if (cover > 0)
164
+ m.a[row * m.w + px - m.x0] += cover / SUB_ROWS;
165
+ }
166
+ }
167
+ }
168
+ return m;
169
+ }
170
+ /** Round-capped, round-joined line of width `w` along the polylines: distance to the nearest segment ≤ w/2. */
171
+ function strokeMask(size, polys, w) {
172
+ const r = w / 2;
173
+ const segs = [];
174
+ let x0 = Infinity;
175
+ let y0 = Infinity;
176
+ let x1 = -Infinity;
177
+ let y1 = -Infinity;
178
+ for (const { pts, closed } of polys) {
179
+ const n = closed ? pts.length : pts.length - 1;
180
+ for (let j = 0; j < Math.max(n, 1); j++) {
181
+ const p = pts[j];
182
+ const q = pts[(j + 1) % pts.length] ?? p;
183
+ segs.push([p[0], p[1], q[0], q[1]]);
184
+ x0 = Math.min(x0, p[0], q[0]);
185
+ y0 = Math.min(y0, p[1], q[1]);
186
+ x1 = Math.max(x1, p[0], q[0]);
187
+ y1 = Math.max(y1, p[1], q[1]);
188
+ }
189
+ }
190
+ const m = box(size, x0 - r - 1, y0 - r - 1, x1 + r + 1, y1 + r + 1);
191
+ if (!m)
192
+ return null;
193
+ const dist = new Float32Array(m.w * m.h).fill(Infinity);
194
+ for (const [ax, ay, bx, by] of segs) {
195
+ const dx = bx - ax;
196
+ const dy = by - ay;
197
+ const len2 = dx * dx + dy * dy;
198
+ const sx0 = Math.max(m.x0, Math.floor(Math.min(ax, bx) - r - 1));
199
+ const sx1 = Math.min(m.x0 + m.w, Math.ceil(Math.max(ax, bx) + r + 1));
200
+ const sy0 = Math.max(m.y0, Math.floor(Math.min(ay, by) - r - 1));
201
+ const sy1 = Math.min(m.y0 + m.h, Math.ceil(Math.max(ay, by) + r + 1));
202
+ for (let py = sy0; py < sy1; py++)
203
+ for (let px = sx0; px < sx1; px++) {
204
+ const cx = px + 0.5 - ax;
205
+ const cy = py + 0.5 - ay;
206
+ const t = len2 === 0 ? 0 : Math.min(1, Math.max(0, (cx * dx + cy * dy) / len2));
207
+ const d = Math.hypot(cx - t * dx, cy - t * dy);
208
+ const i = (py - m.y0) * m.w + px - m.x0;
209
+ if (d < dist[i])
210
+ dist[i] = d;
211
+ }
212
+ }
213
+ for (let i = 0; i < dist.length; i++)
214
+ m.a[i] = Math.min(1, Math.max(0, r + 0.5 - dist[i]));
215
+ return m;
216
+ }
217
+ /** Filled ellipse with an approximate signed distance (exact on the axes, good near the edge). */
218
+ function ellipseMask(size, cx, cy, rx, ry) {
219
+ const m = box(size, cx - rx - 1, cy - ry - 1, cx + rx + 1, cy + ry + 1);
220
+ if (!m)
221
+ return null;
222
+ for (let py = 0; py < m.h; py++)
223
+ for (let px = 0; px < m.w; px++) {
224
+ const x = m.x0 + px + 0.5 - cx;
225
+ const y = m.y0 + py + 0.5 - cy;
226
+ const k0 = Math.hypot(x / rx, y / ry);
227
+ const k1 = Math.hypot(x / (rx * rx), y / (ry * ry));
228
+ const d = k1 === 0 ? -Math.min(rx, ry) : (k0 * (k0 - 1)) / k1;
229
+ m.a[py * m.w + px] = Math.min(1, Math.max(0, 0.5 - d));
230
+ }
231
+ return m;
25
232
  }
26
233
  /** Signed distance from (px, py) to a rounded rectangle; negative inside. */
27
- function roundedRectDistance(px, py, s) {
234
+ function roundedRectMask(size, s) {
235
+ const m = box(size, s.x - 1, s.y - 1, s.x + s.w + 1, s.y + s.h + 1);
236
+ if (!m)
237
+ return null;
28
238
  const hx = s.w / 2;
29
239
  const hy = s.h / 2;
30
- const qx = Math.abs(px - (s.x + hx)) - (hx - s.r);
31
- const qy = Math.abs(py - (s.y + hy)) - (hy - s.r);
32
- const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));
33
- return outside + Math.min(Math.max(qx, qy), 0) - s.r;
240
+ for (let py = 0; py < m.h; py++)
241
+ for (let px = 0; px < m.w; px++) {
242
+ const qx = Math.abs(m.x0 + px + 0.5 - (s.x + hx)) - (hx - s.r);
243
+ const qy = Math.abs(m.y0 + py + 0.5 - (s.y + hy)) - (hy - s.r);
244
+ const d = Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - s.r;
245
+ m.a[py * m.w + px] = Math.min(1, Math.max(0, 0.5 - d));
246
+ }
247
+ return m;
34
248
  }
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);
249
+ /** The shapes of one placed buddy, back to front, each with its colour. */
250
+ function buddyLayers(size, b) {
251
+ const { k, map } = placement(size, b);
252
+ const ink = rgb(MARK_COLORS.ink);
253
+ const fills = {
254
+ body: b.colors.body,
255
+ shade: b.colors.shade,
256
+ };
257
+ const layers = [];
258
+ for (const p of BUDDY.paths) {
259
+ const polys = flatten(p.d, map);
260
+ if (p.fill !== 'none')
261
+ layers.push({ mask: fillMask(size, polys), color: rgb(fills[p.fill]) });
262
+ const stroke = p.stroke === 'chevron' ? rgb(b.colors.chevron) : ink;
263
+ layers.push({ mask: strokeMask(size, polys, (p.width ?? MARK.placedLine) * k), color: stroke });
264
+ }
265
+ const dots = [...BUDDY.eyes, ...highlights(b.look)];
266
+ for (const e of dots) {
267
+ const [cx, cy] = map(e.cx, e.cy);
268
+ layers.push({
269
+ mask: ellipseMask(size, cx, cy, e.rx * k, e.ry * k),
270
+ color: rgb(e.fill === 'ink' ? MARK_COLORS.ink : MARK_COLORS.light),
271
+ });
272
+ }
273
+ return layers;
38
274
  }
39
275
  /** RGBA pixels (straight alpha) of the icon at `size`×`size`. */
40
276
  export function renderIcon(size) {
41
- const { tile, halves } = shapes(size);
42
277
  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
- })),
278
+ { mask: roundedRectMask(size, tileRect(size)), color: TILE.color },
279
+ ...buddyLayers(size, MARK.back),
280
+ ...buddyLayers(size, { ...MARK.front, colors: { ...MARK.front.colors, body: MARK_COLORS.blue.body } }),
49
281
  ];
50
- const px = new Uint8Array(size * size * 4);
51
- for (let y = 0; y < size; y++)
52
- for (let x = 0; x < size; x++) {
53
- // Premultiplied "over" compositing; coverage from the distance at the pixel centre.
54
- let r = 0;
55
- let g = 0;
56
- let b = 0;
57
- let a = 0;
58
- 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;
61
- if (la === 0)
282
+ // Premultiplied "over" compositing, layer by layer inside each mask's box.
283
+ const acc = new Float32Array(size * size * 4);
284
+ for (const { mask: m, color } of layers) {
285
+ if (!m)
286
+ continue;
287
+ const [cr, cg, cb] = color.map((c) => c / 255);
288
+ for (let y = 0; y < m.h; y++)
289
+ for (let x = 0; x < m.w; x++) {
290
+ const la = Math.min(1, m.a[y * m.w + x]);
291
+ if (la <= 0)
62
292
  continue;
63
- r = (l.color[0] / 255) * la + r * (1 - la);
64
- g = (l.color[1] / 255) * la + g * (1 - la);
65
- b = (l.color[2] / 255) * la + b * (1 - la);
66
- a = la + a * (1 - la);
293
+ const i = ((m.y0 + y) * size + m.x0 + x) * 4;
294
+ acc[i] = cr * la + acc[i] * (1 - la);
295
+ acc[i + 1] = cg * la + acc[i + 1] * (1 - la);
296
+ acc[i + 2] = cb * la + acc[i + 2] * (1 - la);
297
+ acc[i + 3] = la + acc[i + 3] * (1 - la);
67
298
  }
68
- const i = (y * size + x) * 4;
69
- if (a > 0) {
70
- px[i] = Math.round((r / a) * 255);
71
- px[i + 1] = Math.round((g / a) * 255);
72
- px[i + 2] = Math.round((b / a) * 255);
73
- px[i + 3] = Math.round(a * 255);
74
- }
75
- }
299
+ }
300
+ const px = new Uint8Array(size * size * 4);
301
+ for (let i = 0; i < px.length; i += 4) {
302
+ const a = acc[i + 3];
303
+ if (a <= 0)
304
+ continue;
305
+ px[i] = Math.round((acc[i] / a) * 255);
306
+ px[i + 1] = Math.round((acc[i + 1] / a) * 255);
307
+ px[i + 2] = Math.round((acc[i + 2] / a) * 255);
308
+ px[i + 3] = Math.round(a * 255);
309
+ }
76
310
  return px;
77
311
  }
78
312
  const CRC_TABLE = (() => {
@@ -157,17 +391,14 @@ export function encodeIcns(pngs) {
157
391
  }
158
392
  /** The same mark as an SVG (1024-unit canvas), for Linux icon themes. */
159
393
  export function iconSvg() {
160
- const { tile, halves } = shapes(GRID.canvas);
394
+ const tile = tileRect(GRID.canvas);
161
395
  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)}/>`;
396
+ const V = MARK_VIEWBOX;
166
397
  return [
167
398
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${GRID.canvas} ${GRID.canvas}" width="${GRID.canvas}" height="${GRID.canvas}">`,
168
399
  ` <!-- ${APP_MARKER} -->`,
169
- rect(tile, hex(TILE.color)),
170
- ...halves.map((h, i) => half(h, hex(MARK.color), MARK.opacities[i])),
400
+ ` <rect x="${tile.x}" y="${tile.y}" width="${tile.w}" height="${tile.h}" rx="${tile.r}" fill="${hex(TILE.color)}"/>`,
401
+ ` <svg x="${tile.x}" y="${tile.y}" width="${tile.w}" height="${tile.h}" viewBox="${V.x} ${V.y} ${V.size} ${V.size}">${markSvgBody(MARK_COLORS.blue.body)}</svg>`,
171
402
  '</svg>',
172
403
  '',
173
404
  ].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);
@@ -12,4 +12,8 @@ declare const WHY: {
12
12
  };
13
13
  /** The next session's first prompt: the Brief's path only, never its text (it would show in a process list). */
14
14
  export declare function firstPrompt(fromRef: string, brief: string, trigger: keyof typeof WHY): string;
15
+ /** For the user, not the model (ADR-0018): this session will wait for the reset, and how to move on now instead. */
16
+ export declare function waitNotice(ref: string, until: string, pressure?: number): string;
17
+ /** A desktop notification through the Host (OSC 9 and OSC 777), the only output `StopFailure` keeps. */
18
+ export declare function notifySequence(text: string): string;
15
19
  export {};
@@ -34,3 +34,15 @@ const WHY = {
34
34
  export function firstPrompt(fromRef, brief, trigger) {
35
35
  return `This task was handed over from ${fromRef} by sideby because ${WHY[trigger]}. Read ${brief} first, then continue the task it describes. If the user asks to move the task to another account (back to ${fromRef}, say), run \`sideby handoff ready --to <account>\` and end your turn.`;
36
36
  }
37
+ const clock = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
38
+ /** For the user, not the model (ADR-0018): this session will wait for the reset, and how to move on now instead. */
39
+ export function waitNotice(ref, until, pressure) {
40
+ const now = pressure === undefined ? 'hit its limit' : `is at ${Math.round(pressure)}%`;
41
+ const then = pressure === undefined ? 'this session waits' : 'when it runs out this session waits';
42
+ return `sideby: ${ref} ${now}; its quota is back at ${clock(until)}, so ${then} and goes on by itself instead of moving to another account. To move on now, type: ! sideby handoff ready`;
43
+ }
44
+ /** A desktop notification through the Host (OSC 9 and OSC 777), the only output `StopFailure` keeps. */
45
+ export function notifySequence(text) {
46
+ const safe = [...text].map((ch) => (ch < ' ' || ch === '\u007f' ? ' ' : ch)).join('');
47
+ return `\u001b]9;${safe}\u0007\u001b]777;notify;sideby;${safe}\u0007`;
48
+ }
@@ -16,6 +16,8 @@ export declare const logFilePath: (p: Paths) => string;
16
16
  export declare function readPidFile(p: Paths): Promise<PanelPidFile | undefined>;
17
17
  /** When `pid` started, as `ps` prints it; with the pid it names one process, which a reused pid does not match. */
18
18
  export declare const processStartTime: (pid: number) => Promise<string | undefined>;
19
+ /** The port of this state directory's background Panel when it runs and answers as itself, else null. */
20
+ export declare function backgroundPanelPort(p: Paths): Promise<number | null>;
19
21
  /** URL of a running background Panel, or of any sideby Panel on `port`; removes a stale pid file. */
20
22
  export declare function findRunningPanel(p: Paths, port: number): Promise<string | undefined>;
21
23
  export interface StartBackgroundOptions {
@@ -68,6 +68,13 @@ async function checkPanel(rec) {
68
68
  return stale(`the panel on port ${rec.port} is not the one it names`);
69
69
  return { status: 'ours', ...(id.build ? { build: id.build } : {}) };
70
70
  }
71
+ /** The port of this state directory's background Panel when it runs and answers as itself, else null. */
72
+ export async function backgroundPanelPort(p) {
73
+ const rec = await readPidFile(p);
74
+ if (!rec)
75
+ return null;
76
+ return (await checkPanel(rec)).status === 'ours' ? rec.port : null;
77
+ }
71
78
  /** URL of a running background Panel, or of any sideby Panel on `port`; removes a stale pid file. */
72
79
  export async function findRunningPanel(p, port) {
73
80
  const rec = await readPidFile(p);