@hellix-ai/cubes 0.1.1 → 0.1.2

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
@@ -12,7 +12,7 @@ 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.
@@ -43,6 +44,7 @@ cubes whoami
43
44
  cubes org [ALIAS]
44
45
  cubes ls [--all] [--json]
45
46
  cubes attach [SESSION] [--view] [--no-resume]
47
+ cubes upgrade [--check]
46
48
  ```
47
49
 
48
50
  `--org ALIAS` works on any command. If you belong to several organizations, `cubes org` lists them and
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ 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
17
  import { formatTable, isUuid, listSessions, LIVE, resolveSession, stateOf } from "./sessions.js";
@@ -93,8 +94,14 @@ async function cmdLogin(a) {
93
94
  process.stderr.write(`\nTo sign in to ${host}, open:\n\n ${url}\n\nand confirm the code ${start.user_code}\n\n`);
94
95
  if (!a.flags.has("no-browser"))
95
96
  openBrowser(url);
96
- process.stderr.write("Waiting for approval…\n");
97
- const tokens = await pollDevice(d, start);
97
+ const spin = new CubeSpinner(process.stderr, "Waiting for you to approve it in the browser");
98
+ let tokens;
99
+ try {
100
+ tokens = await pollDevice(d, start);
101
+ }
102
+ finally {
103
+ spin.stop();
104
+ }
98
105
  const orgs = orgsOf(tokens.access_token);
99
106
  let org = str(a.flags, "org");
100
107
  if (org && orgs.length && !orgs.includes(org))
@@ -171,36 +178,38 @@ async function ensureRunning(api, row, resume) {
171
178
  let s = row;
172
179
  if (s.status === "running")
173
180
  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`);
181
+ if (!LIVE.has(s.status) && !resume)
182
+ throw new CliError(`that session is ${s.status}; leave out --no-resume to bring it back`);
183
+ const name = `"${s.title ?? s.id.slice(0, 8)}"`;
184
+ // The same cube the session's own screen spins while Claude boots, so the wait reads as one thing.
185
+ const spin = new CubeSpinner(process.stderr, `${LIVE.has(s.status) ? "Starting" : "Resuming"} ${name}`);
186
+ try {
187
+ if (!LIVE.has(s.status)) {
188
+ try {
189
+ await api.post(`/sessions/${id}/resume`);
190
+ }
191
+ catch (e) {
192
+ if (!(e instanceof ApiError && e.status === 409 && /already running/.test(e.message)))
193
+ throw e;
194
+ }
180
195
  }
181
- catch (e) {
182
- if (!(e instanceof ApiError && e.status === 409 && /already running/.test(e.message)))
183
- throw e;
196
+ const deadline = Date.now() + 5 * 60_000;
197
+ while (Date.now() < deadline) {
198
+ s = await api.get(`/sessions/${id}`);
199
+ if (s.status === "running")
200
+ return s;
201
+ if (!LIVE.has(s.status))
202
+ throw new CliError(`the session stopped while starting (${s.status})`);
203
+ if (s.provision_note) {
204
+ spin.update(`${s.provision_note}${s.provision_total ? ` (${s.provision_done ?? 0}/${s.provision_total})` : ""}`);
205
+ }
206
+ await sleep(1000);
184
207
  }
208
+ throw new CliError("the session is taking too long to start - check it in the browser");
185
209
  }
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);
210
+ finally {
211
+ spin.stop();
202
212
  }
203
- throw new CliError("the session is taking too long to start - check it in the browser");
204
213
  }
205
214
  async function cmdAttach(a) {
206
215
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellix-ai/cubes",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Attach to your Cubes sessions from your own terminal (Warp, iTerm, Ghostty, anything).",
5
5
  "type": "module",
6
6
  "bin": {