@hellix-ai/cubes 0.1.1 → 0.1.3

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.
package/README.md CHANGED
@@ -7,12 +7,12 @@ Terminal.app, anything) with your own font, colors and keyboard shortcuts.
7
7
  npm install -g @hellix-ai/cubes # needs Node.js 22.4+
8
8
 
9
9
  cubes login --host cubes.hellix.ai # prints a code; approve it in the browser
10
- cubes ls # your sessions
10
+ cubes ls # sessions, under a heading per group (--mine, --live)
11
11
  cubes attach # pick a running one
12
12
  cubes attach 7ff4 # or name one: an id prefix, or part of its title
13
13
  ```
14
14
 
15
- No install: `npx @hellix-ai/cubes attach <session>`.
15
+ No install: `npx @hellix-ai/cubes attach <session>`. Later, `cubes upgrade` updates it in place.
16
16
 
17
17
  ## While attached
18
18
 
@@ -28,8 +28,9 @@ Every key goes to the session, exactly as if Claude Code were running locally, e
28
28
  takes it back, the terminal says so and goes read-only; detach and attach again to reclaim it.
29
29
  `cubes attach --view` watches without taking it.
30
30
 
31
- A stopped or hibernated session is resumed before attaching (`--no-resume` to refuse). A dropped
32
- connection reconnects on its own and replays the screen.
31
+ A stopped or hibernated session is resumed before attaching (`--no-resume` to refuse), with the
32
+ spinning Cubes mark on screen until it is up. A dropped connection reconnects on its own and replays
33
+ the screen.
33
34
 
34
35
  Scrollback lives in the session's tmux: scroll with the mouse wheel or trackpad (tmux copy-mode), not
35
36
  your terminal's own scrollback.
@@ -41,10 +42,15 @@ cubes login [--host URL] [--org ALIAS] [--no-browser]
41
42
  cubes logout
42
43
  cubes whoami
43
44
  cubes org [ALIAS]
44
- cubes ls [--all] [--json]
45
+ cubes ls [--mine] [--live] [--all] [--json]
45
46
  cubes attach [SESSION] [--view] [--no-resume]
47
+ cubes upgrade [--check]
46
48
  ```
47
49
 
50
+ `cubes ls` lists sessions the way the web's Sessions page does: a section per package, then per group
51
+ (A-Z), then Ungrouped, live ones first in each. `--mine` keeps the ones you started, `--live` the ones
52
+ running now; they combine. `--all` adds sessions other sessions spawned; `--json` prints the rows.
53
+
48
54
  `--org ALIAS` works on any command. If you belong to several organizations, `cubes org` lists them and
49
55
  `cubes org <alias>` changes the default.
50
56
 
package/dist/cli.js CHANGED
@@ -11,9 +11,10 @@ import { makeApi, ApiError } from "./api.js";
11
11
  import { attach, RESTORE_TERMINAL } from "./attach.js";
12
12
  import { CliError, currentSession, discover, loginFromTokens, normalizeHost, orgsOf, pollDevice, revoke, startDevice, whoOf, } from "./auth.js";
13
13
  import { readClipboardImage } from "./clipboard.js";
14
+ import { CubeSpinner } from "./cube.js";
14
15
  import { clearLogin, configPath, loadLogin, saveLogin } from "./config.js";
15
16
  import { parseArgs } from "./args.js";
16
- import { formatTable, isUuid, listSessions, LIVE, resolveSession, stateOf } from "./sessions.js";
17
+ import { formatSections, isUuid, listSessions, LIVE, resolveSession, sectionize, stateOf } from "./sessions.js";
17
18
  import { upgrade } from "./upgrade.js";
18
19
  const PKG = createRequire(import.meta.url)("../package.json");
19
20
  const VERSION = PKG.version;
@@ -24,7 +25,8 @@ Usage:
24
25
  cubes logout sign out and revoke this computer's login
25
26
  cubes whoami who, where, and which org
26
27
  cubes org [ALIAS] list your orgs, or switch the default
27
- cubes ls [--all] [--json] list sessions (--all includes spawned ones)
28
+ cubes ls [--mine] [--live] [--all] [--json] list sessions by group (--mine: you started them,
29
+ --live: running now, --all: include spawned ones)
28
30
  cubes attach [SESSION] [--view] [--no-resume] attach to a session
29
31
  cubes upgrade [--check] install the newest cubes (--check only reports)
30
32
 
@@ -93,8 +95,14 @@ async function cmdLogin(a) {
93
95
  process.stderr.write(`\nTo sign in to ${host}, open:\n\n ${url}\n\nand confirm the code ${start.user_code}\n\n`);
94
96
  if (!a.flags.has("no-browser"))
95
97
  openBrowser(url);
96
- process.stderr.write("Waiting for approval…\n");
97
- const tokens = await pollDevice(d, start);
98
+ const spin = new CubeSpinner(process.stderr, "Waiting for you to approve it in the browser");
99
+ let tokens;
100
+ try {
101
+ tokens = await pollDevice(d, start);
102
+ }
103
+ finally {
104
+ spin.stop();
105
+ }
98
106
  const orgs = orgsOf(tokens.access_token);
99
107
  let org = str(a.flags, "org");
100
108
  if (org && orgs.length && !orgs.includes(org))
@@ -153,16 +161,21 @@ async function cmdOrg(a) {
153
161
  }
154
162
  async function cmdLs(a) {
155
163
  const api = makeApi(currentSession({ org: str(a.flags, "org") }));
156
- const rows = await listSessions(api, { all: a.flags.has("all") });
164
+ const mine = a.flags.has("mine");
165
+ const live = a.flags.has("live");
166
+ // Both narrow, and they compose: `--mine --live` is "my sessions that are running now".
167
+ const rows = (await listSessions(api, { all: a.flags.has("all") }))
168
+ .filter((r) => (!mine || r.created_by_me === true) && (!live || LIVE.has(r.status)));
157
169
  if (a.flags.has("json")) {
158
170
  process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
159
171
  return;
160
172
  }
161
173
  if (!rows.length) {
162
- process.stderr.write("No sessions.\n");
174
+ process.stderr.write(`No ${live ? "live " : ""}sessions${mine ? " of yours" : ""}.\n`);
163
175
  return;
164
176
  }
165
- process.stdout.write(formatTable(rows, process.stdout.columns ?? 120) + "\n");
177
+ const color = !!process.stdout.isTTY && !process.env.NO_COLOR;
178
+ process.stdout.write(formatSections(sectionize(rows), process.stdout.columns ?? 120, { color }) + "\n");
166
179
  }
167
180
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
168
181
  /** Bring a stopped/hibernated session back and wait until its terminal can be attached. */
@@ -171,36 +184,38 @@ async function ensureRunning(api, row, resume) {
171
184
  let s = row;
172
185
  if (s.status === "running")
173
186
  return s;
174
- if (!LIVE.has(s.status)) {
175
- if (!resume)
176
- throw new CliError(`that session is ${s.status}; leave out --no-resume to bring it back`);
177
- process.stderr.write(`Resuming "${s.title ?? s.id}"…\n`);
178
- try {
179
- await api.post(`/sessions/${id}/resume`);
187
+ if (!LIVE.has(s.status) && !resume)
188
+ throw new CliError(`that session is ${s.status}; leave out --no-resume to bring it back`);
189
+ const name = `"${s.title ?? s.id.slice(0, 8)}"`;
190
+ // The same cube the session's own screen spins while Claude boots, so the wait reads as one thing.
191
+ const spin = new CubeSpinner(process.stderr, `${LIVE.has(s.status) ? "Starting" : "Resuming"} ${name}`);
192
+ try {
193
+ if (!LIVE.has(s.status)) {
194
+ try {
195
+ await api.post(`/sessions/${id}/resume`);
196
+ }
197
+ catch (e) {
198
+ if (!(e instanceof ApiError && e.status === 409 && /already running/.test(e.message)))
199
+ throw e;
200
+ }
180
201
  }
181
- catch (e) {
182
- if (!(e instanceof ApiError && e.status === 409 && /already running/.test(e.message)))
183
- throw e;
202
+ const deadline = Date.now() + 5 * 60_000;
203
+ while (Date.now() < deadline) {
204
+ s = await api.get(`/sessions/${id}`);
205
+ if (s.status === "running")
206
+ return s;
207
+ if (!LIVE.has(s.status))
208
+ throw new CliError(`the session stopped while starting (${s.status})`);
209
+ if (s.provision_note) {
210
+ spin.update(`${s.provision_note}${s.provision_total ? ` (${s.provision_done ?? 0}/${s.provision_total})` : ""}`);
211
+ }
212
+ await sleep(1000);
184
213
  }
214
+ throw new CliError("the session is taking too long to start - check it in the browser");
185
215
  }
186
- let lastNote = "";
187
- const deadline = Date.now() + 5 * 60_000;
188
- while (Date.now() < deadline) {
189
- s = await api.get(`/sessions/${id}`);
190
- if (s.status === "running")
191
- return s;
192
- if (!LIVE.has(s.status))
193
- throw new CliError(`the session stopped while starting (${s.status})`);
194
- const note = s.provision_note
195
- ? `${s.provision_note}${s.provision_total ? ` (${s.provision_done ?? 0}/${s.provision_total})` : ""}`
196
- : "starting";
197
- if (note !== lastNote) {
198
- process.stderr.write(` ${note}…\n`);
199
- lastNote = note;
200
- }
201
- await sleep(1000);
216
+ finally {
217
+ spin.stop();
202
218
  }
203
- throw new CliError("the session is taking too long to start - check it in the browser");
204
219
  }
205
220
  async function cmdAttach(a) {
206
221
  const s = currentSession({ org: str(a.flags, "org") });
package/dist/cube.js ADDED
@@ -0,0 +1,168 @@
1
+ // The Cubes mark as a spinning ASCII cube, drawn in YOUR terminal while the CLI waits on something.
2
+ //
3
+ // It is the session boot animation (server/src/engine/interactiveAuth.ts, CUBE_SPIN_AWK) ported
4
+ // line for line: the same six faces sampled parametrically, rotated yaw-then-pitch with the pitch on
5
+ // the true isometric angle (so frame 0 is the app icon's pose), the same back-face culling, the same
6
+ // Lambert shading quantised to five steps of one indigo ramp and four ASCII densities, and the same
7
+ // 2π/40 turn so it loops without a stutter. So a resume looks like one continuous thing: this cube
8
+ // while the session comes back, then the session's own cube while Claude boots, then Claude.
9
+ //
10
+ // It is drawn INLINE, not on the alternate screen: what is above it (a sign-in code you have to type)
11
+ // stays where it is, and when the wait ends the region is erased and the cursor is back where it was.
12
+ const NFR = 40;
13
+ const CH = ["", "+", "*", "*", "%", "#"];
14
+ const CO = ["", "\x1b[38;5;61m", "\x1b[38;5;62m", "\x1b[38;5;63m", "\x1b[38;5;105m", "\x1b[38;5;147m"];
15
+ const CAPTION = "\x1b[38;5;147m";
16
+ const FACES = [
17
+ // outward normal, then the two tangents that sweep it
18
+ { n: [0, 1, 0], a: [1, 0, 0], b: [0, 0, 1] },
19
+ { n: [0, -1, 0], a: [1, 0, 0], b: [0, 0, 1] },
20
+ { n: [1, 0, 0], a: [0, 1, 0], b: [0, 0, 1] },
21
+ { n: [-1, 0, 0], a: [0, 1, 0], b: [0, 0, 1] },
22
+ { n: [0, 0, 1], a: [1, 0, 0], b: [0, 1, 0] },
23
+ { n: [0, 0, -1], a: [1, 0, 0], b: [0, 1, 0] },
24
+ ];
25
+ const SP = Math.sin(0.61548);
26
+ const CP = Math.cos(0.61548);
27
+ const L = [-0.5, 0.72, 0.48];
28
+ /** The cube's size for a terminal of `cols` x `rows`: the boot window's caps, a little shorter so
29
+ * an inline cube does not take over the screen. Null when there is no room to draw one. */
30
+ export function cubeSize(cols, rows) {
31
+ let w = Math.min(cols - 4, 46);
32
+ let h = Math.min(rows - 6, 15);
33
+ if (h % 2 === 0)
34
+ h--;
35
+ if (w < 30 || h < 9)
36
+ return null;
37
+ return { w, h };
38
+ }
39
+ /** One frame: `h` rows of shade levels 0-5 (0 = empty). Frame numbers past 39 wrap seamlessly. */
40
+ export function cubeFrame(frame, w, h) {
41
+ const cx = (w - 1) / 2;
42
+ const cy = (h - 1) / 2;
43
+ const ys = cy / 1.634;
44
+ const xs = Math.min(2 * ys, cx / 1.415);
45
+ const th = 0.785398 + frame * (6.283185 / NFR);
46
+ const sq = Math.sin(th);
47
+ const cq = Math.cos(th);
48
+ const sc = Array.from({ length: h }, () => new Array(w).fill(0));
49
+ for (const f of FACES) {
50
+ const [nx, ny, nz] = f.n;
51
+ const mx = nx * cq - nz * sq;
52
+ const mz = nx * sq + nz * cq;
53
+ const qy = ny * CP - mz * SP;
54
+ const qz = ny * SP + mz * CP;
55
+ if (qz <= 0.001)
56
+ continue; // facing away
57
+ const bb = mx * L[0] + qy * L[1] + qz * L[2];
58
+ const lv = Math.min(5, Math.max(1, 1 + Math.floor(((bb + 0.55) / 1.5) * 5)));
59
+ for (let u = -1; u <= 1.0001; u += 0.035) {
60
+ for (let v = -1; v <= 1.0001; v += 0.035) {
61
+ const px = nx + u * f.a[0] + v * f.b[0];
62
+ const py = ny + u * f.a[1] + v * f.b[1];
63
+ const pz = nz + u * f.a[2] + v * f.b[2];
64
+ const rx = px * cq - pz * sq;
65
+ const rz = px * sq + pz * cq;
66
+ const ty = py * CP - rz * SP;
67
+ const sx = Math.floor(cx + xs * rx + 0.5);
68
+ const sy = Math.floor(cy - ys * ty + 0.5);
69
+ if (sx < 0 || sx >= w || sy < 0 || sy >= h)
70
+ continue;
71
+ sc[sy][sx] = lv;
72
+ }
73
+ }
74
+ }
75
+ return sc;
76
+ }
77
+ export function renderRow(levels, color) {
78
+ let out = "";
79
+ let cur = 0;
80
+ for (const s of levels) {
81
+ if (s === 0) {
82
+ if (cur && color)
83
+ out += "\x1b[0m";
84
+ cur = 0;
85
+ out += " ";
86
+ }
87
+ else {
88
+ if (cur !== s && color)
89
+ out += CO[s];
90
+ cur = s;
91
+ out += CH[s];
92
+ }
93
+ }
94
+ return (color ? out + "\x1b[0m" : out).replace(/\s+$/, "");
95
+ }
96
+ /**
97
+ * A wait with the cube on it. Where it cannot draw (not a terminal, TERM=dumb, or too small), each
98
+ * caption is printed as an ordinary line instead, so a pipe or a CI log still says what is happening.
99
+ * NO_COLOR keeps the cube and drops the colour, which the four ASCII densities survive.
100
+ */
101
+ export class CubeSpinner {
102
+ out;
103
+ timer = null;
104
+ frame = 0;
105
+ drawn = 0;
106
+ caption;
107
+ size;
108
+ color;
109
+ restore = () => this.out.write("\x1b[?25h");
110
+ // A Ctrl-C mid-wait: Node's default SIGINT exit runs no "exit" listeners, so it would leave the
111
+ // cube on screen and the cursor hidden. Clean up, then exit the way the default would have.
112
+ onSigint = () => { this.stop(); process.exit(130); };
113
+ constructor(out, caption, env = process.env) {
114
+ this.out = out;
115
+ this.caption = caption;
116
+ this.size = out.isTTY && env.TERM !== "dumb" ? cubeSize(out.columns ?? 80, out.rows ?? 24) : null;
117
+ this.color = !env.NO_COLOR;
118
+ if (!this.size) {
119
+ out.write(`${caption}…\n`);
120
+ return;
121
+ }
122
+ out.write("\x1b[?25l");
123
+ process.once("exit", this.restore);
124
+ process.once("SIGINT", this.onSigint);
125
+ this.draw();
126
+ this.timer = setInterval(() => this.draw(), 60);
127
+ }
128
+ update(caption) {
129
+ if (caption === this.caption)
130
+ return;
131
+ this.caption = caption;
132
+ if (!this.size)
133
+ this.out.write(`${caption}…\n`);
134
+ }
135
+ /** Erase the cube, put the cursor back, and say how it ended (if anything). */
136
+ stop(final) {
137
+ if (this.timer)
138
+ clearInterval(this.timer);
139
+ this.timer = null;
140
+ if (this.size) {
141
+ if (this.drawn)
142
+ this.out.write(`\x1b[${this.drawn}A\r\x1b[J`);
143
+ this.restore();
144
+ process.removeListener("exit", this.restore);
145
+ process.removeListener("SIGINT", this.onSigint);
146
+ this.size = null;
147
+ }
148
+ if (final)
149
+ this.out.write(`${final}\n`);
150
+ }
151
+ draw() {
152
+ const { w, h } = this.size;
153
+ const rows = cubeFrame(this.frame, w, h);
154
+ const dots = ".".repeat((Math.floor(this.frame / 4) % 3) + 1);
155
+ // A caption that wraps would add a row this redraw does not know to move back over.
156
+ const room = Math.max(10, (this.out.columns ?? 80) - 6);
157
+ const text = this.caption.length > room ? this.caption.slice(0, room - 1) + "…" : this.caption;
158
+ const pad = " ".repeat(Math.max(0, Math.floor((w - text.length - 3) / 2) + 2));
159
+ let s = this.drawn ? `\x1b[${this.drawn}A` : "";
160
+ for (const r of rows)
161
+ s += `\r ${renderRow(r, this.color)}\x1b[K\n`;
162
+ const cap = `${text}${dots}`;
163
+ s += `\r\x1b[K\n\r${pad}${this.color ? CAPTION + cap + "\x1b[0m" : cap}\x1b[K\n`;
164
+ this.out.write(s);
165
+ this.drawn = rows.length + 2;
166
+ this.frame = (this.frame + 1) % NFR;
167
+ }
168
+ }
package/dist/sessions.js CHANGED
@@ -64,20 +64,85 @@ function cut(s, w) {
64
64
  return "";
65
65
  return s.length <= w ? s : s.slice(0, w - 1) + "…";
66
66
  }
67
- export function formatTable(rows, width, now = Date.now()) {
68
- const head = ["ID", "STATE", "AGE", "TITLE", "GROUP"];
69
- const body = rows.map((s) => [
70
- s.id.slice(0, 8),
71
- stateOf(s),
72
- age(s.last_attached_at ?? s.created_at, now),
73
- s.title || "(untitled)",
74
- s.group_name ?? "",
75
- ]);
67
+ /** Column layout shared by every table: widths come from ALL the rows, so the sections of one listing
68
+ * line up with each other. */
69
+ function layout(rows, width, now) {
70
+ const head = ["ID", "STATE", "AGE", "TITLE"];
71
+ const cells = (s) => [s.id.slice(0, 8), stateOf(s), age(s.last_attached_at ?? s.created_at, now), s.title || "(untitled)"];
72
+ const body = rows.map(cells);
76
73
  const w = (i) => Math.max(head[i].length, ...body.map((r) => r[i].length));
77
74
  const fixed = [w(0), w(1), w(2)];
78
- const groupW = Math.min(w(4), 32);
79
- const titleW = Math.max(10, Math.min(w(3), width - fixed.reduce((a, b) => a + b, 0) - groupW - 8));
80
- const line = (r) => [r[0].padEnd(fixed[0]), r[1].padEnd(fixed[1]), r[2].padStart(fixed[2]),
81
- cut(r[3], titleW).padEnd(titleW), cut(r[4], groupW)].join(" ").trimEnd();
82
- return [line(head), ...body.map(line)].join("\n");
75
+ const titleW = Math.max(10, Math.min(w(3), width - fixed.reduce((a, b) => a + b, 0) - 6));
76
+ const line = (r) => [r[0].padEnd(fixed[0]), r[1].padEnd(fixed[1]), r[2].padStart(fixed[2]), cut(r[3], titleW)].join(" ").trimEnd();
77
+ return { header: line(head), row: (s) => line(cells(s)), width: fixed.reduce((a, b) => a + b, 0) + titleW + 6 };
78
+ }
79
+ export function formatTable(rows, width, now = Date.now()) {
80
+ const l = layout(rows, width, now);
81
+ return [l.header, ...rows.map(l.row)].join("\n");
82
+ }
83
+ /**
84
+ * The web Sessions page's sections (web SessionsPage.tsx groupSections), so a listing reads the same in
85
+ * both places: one per PACKAGE (most recently started first), then one per GROUP A-Z, then Ungrouped.
86
+ * Inside each, live sessions first, then the most recently started. Unlike the page, an empty group is
87
+ * not shown - there is nothing to do with an empty heading in a terminal.
88
+ */
89
+ export function sectionize(rows) {
90
+ const startOf = (r) => (r ? r.started_at ?? r.ended_at ?? r.created_at ?? "" : "");
91
+ const order = (a, b) => {
92
+ const la = LIVE.has(a.status);
93
+ const lb = LIVE.has(b.status);
94
+ if (la !== lb)
95
+ return la ? -1 : 1;
96
+ return startOf(b).localeCompare(startOf(a));
97
+ };
98
+ const pkgs = new Map();
99
+ const groups = new Map();
100
+ const ungrouped = [];
101
+ for (const r of rows) {
102
+ if (r.package_id) {
103
+ const sec = pkgs.get(r.package_id) ?? { title: r.package_name || "Package", rows: [] };
104
+ sec.rows.push(r);
105
+ pkgs.set(r.package_id, sec);
106
+ }
107
+ else if (r.group_id && r.group_name) {
108
+ const sec = groups.get(r.group_id) ?? { title: r.group_name, rows: [] };
109
+ sec.rows.push(r);
110
+ groups.set(r.group_id, sec);
111
+ }
112
+ else {
113
+ ungrouped.push(r);
114
+ }
115
+ }
116
+ for (const sec of [...pkgs.values(), ...groups.values()])
117
+ sec.rows.sort(order);
118
+ const out = [
119
+ ...[...pkgs.values()].sort((a, b) => startOf(b.rows[0]).localeCompare(startOf(a.rows[0]))),
120
+ ...[...groups.values()].sort((a, b) => a.title.localeCompare(b.title)),
121
+ ];
122
+ if (ungrouped.length)
123
+ out.push({ title: "Ungrouped", rows: ungrouped.sort(order) });
124
+ return out;
125
+ }
126
+ /** The listing: one column header, then each section under a rule naming it and its count. A listing
127
+ * that is ALL ungrouped (an org that does not use groups) gets no rule at all - it would only say so. */
128
+ export function formatSections(sections, width, opts = {}) {
129
+ const all = sections.flatMap((s) => s.rows);
130
+ const l = layout(all, width, opts.now ?? Date.now());
131
+ const dim = (t) => (opts.color ? `\x1b[2m${t}\x1b[0m` : t);
132
+ const bold = (t) => (opts.color ? `\x1b[1m${t}\x1b[0m` : t);
133
+ const ruleW = Math.max(24, Math.min(width, l.width));
134
+ const out = [dim(l.header)];
135
+ const bare = sections.length === 1 && sections[0].title === "Ungrouped";
136
+ sections.forEach((sec, i) => {
137
+ if (!bare) {
138
+ const label = `${sec.title} (${sec.rows.length})`;
139
+ const fill = Math.max(2, ruleW - label.length - 4);
140
+ if (i > 0)
141
+ out.push("");
142
+ out.push(`${dim("──")} ${bold(sec.title)} ${dim(`(${sec.rows.length}) ${"─".repeat(fill)}`)}`);
143
+ }
144
+ for (const r of sec.rows)
145
+ out.push(l.row(r));
146
+ });
147
+ return out.join("\n");
83
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellix-ai/cubes",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Attach to your Cubes sessions from your own terminal (Warp, iTerm, Ghostty, anything).",
5
5
  "type": "module",
6
6
  "bin": {