@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 +5 -3
- package/dist/cli.js +37 -28
- package/dist/cube.js +168 -0
- package/package.json +1 -1
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)
|
|
32
|
-
connection reconnects on its own and replays
|
|
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
|
|
97
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
+
}
|