@hellix-ai/cubes 0.1.5 → 0.2.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.
- package/README.md +17 -3
- package/dist/attach.js +32 -66
- package/dist/cli.js +58 -55
- package/dist/cube.js +7 -3
- package/dist/resume.js +47 -0
- package/dist/sessions.js +16 -5
- package/dist/termstatus.js +16 -15
- package/dist/tui/launch.js +98 -0
- package/dist/tui/listApp.js +347 -0
- package/dist/tui/model.js +117 -0
- package/dist/tui/paneApp.js +156 -0
- package/dist/tui/term.js +133 -0
- package/dist/tui/tmux.js +49 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,18 @@ cubes attach 7ff4 # or name one: an id prefix, or part of its
|
|
|
14
14
|
|
|
15
15
|
No install: `npx @hellix-ai/cubes attach <session>`. Later, `cubes upgrade` updates it in place.
|
|
16
16
|
|
|
17
|
+
## The split view
|
|
18
|
+
|
|
19
|
+
Run `cubes` on its own for everything at once: your sessions on the left, one of them open on the right.
|
|
20
|
+
It runs inside its own tmux (install it with `brew install tmux` or `apt install tmux`; a tmux you already
|
|
21
|
+
use is never touched), so close the terminal and run `cubes` again to land right back where you were.
|
|
22
|
+
|
|
23
|
+
- **The list** is grouped like the web's Sessions page, with a session's spawned sessions nested under it
|
|
24
|
+
and a coloured dot for its state: working, idle, **needs you**, starting, ended.
|
|
25
|
+
- **Above the session**, a header says which one it is: name · group · runner · state.
|
|
26
|
+
- **Keys:** `⏎` (or a click) opens a session · `Ctrl-]` in a session jumps back to the list · `l` live only
|
|
27
|
+
· `m` mine only · `/` find · `←` `→` fold a session's children · `q` quits (sessions keep running).
|
|
28
|
+
|
|
17
29
|
## While attached
|
|
18
30
|
|
|
19
31
|
Every key goes to the session, exactly as if Claude Code were running locally, except:
|
|
@@ -29,7 +41,7 @@ takes it back, the terminal says so and goes read-only; detach and attach again
|
|
|
29
41
|
`cubes attach --view` watches without taking it.
|
|
30
42
|
|
|
31
43
|
**Your tab shows the session.** The terminal tab is titled after the session, with its state in front,
|
|
32
|
-
the way Claude Code does it locally:
|
|
44
|
+
the way Claude Code does it locally (minus the animation): `●` while it works, `✳` when idle, `✋` when it is waiting
|
|
33
45
|
for you. The moment it starts waiting, the terminal also gets a bell (most terminals mark the tab or
|
|
34
46
|
bounce the dock) and a desktop notification with Claude's question (iTerm2, Ghostty, WezTerm, kitty and
|
|
35
47
|
others). In Warp, the same events Warp's own Claude Code plugin sends drive its tab status and
|
|
@@ -37,8 +49,9 @@ notifications instead. Muted sessions stay quiet. Your own title comes back when
|
|
|
37
49
|
`--no-status` turns all of it off.
|
|
38
50
|
|
|
39
51
|
A stopped or hibernated session is resumed before attaching (`--no-resume` to refuse), with the
|
|
40
|
-
spinning Cubes mark on screen until it is up.
|
|
41
|
-
the
|
|
52
|
+
spinning Cubes mark on screen until it is up. If the connection drops, nothing reconnects behind your
|
|
53
|
+
back: you are told why (the connection dropped, the session's machine stopped answering, too many
|
|
54
|
+
viewers) and `r` reconnects when you choose to.
|
|
42
55
|
|
|
43
56
|
Scrollback lives in the session's tmux: scroll with the mouse wheel or trackpad (tmux copy-mode), not
|
|
44
57
|
your terminal's own scrollback.
|
|
@@ -46,6 +59,7 @@ your terminal's own scrollback.
|
|
|
46
59
|
## Commands
|
|
47
60
|
|
|
48
61
|
```
|
|
62
|
+
cubes # the split view
|
|
49
63
|
cubes login [--host URL] [--org ALIAS] [--no-browser]
|
|
50
64
|
cubes logout
|
|
51
65
|
cubes whoami
|
package/dist/attach.js
CHANGED
|
@@ -22,10 +22,16 @@ const WS_CLOSE_BACKPRESSURE = 4001;
|
|
|
22
22
|
const WS_CLOSE_FULL = 4002;
|
|
23
23
|
const WS_CLOSE_PARKED = 4004;
|
|
24
24
|
const WS_CLOSE_UNAUTHORIZED = 4403;
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
/** Why a connection that was not a session ending went away, in words a person can act on. */
|
|
26
|
+
export function dropReason(code, reason = "") {
|
|
27
|
+
if (code === WS_CLOSE_PARKED)
|
|
28
|
+
return "this session's machine stopped answering";
|
|
29
|
+
if (code === WS_CLOSE_FULL)
|
|
30
|
+
return "this session already has the most viewers it allows - close another view, then reconnect";
|
|
31
|
+
if (code === WS_CLOSE_BACKPRESSURE)
|
|
32
|
+
return "the connection fell too far behind the session and was dropped";
|
|
33
|
+
return `the connection dropped (${code}${reason ? ` ${reason}` : ""})`;
|
|
34
|
+
}
|
|
29
35
|
/** Everything an attached session may have switched on in YOUR terminal (mouse reporting, bracketed
|
|
30
36
|
* paste, focus events, app cursor keys, kitty/modifyOtherKeys keyboard modes, a hidden cursor, a
|
|
31
37
|
* scroll region) - switched back off on the way out, so detaching never leaves Warp/iTerm in a
|
|
@@ -35,13 +41,11 @@ export const RESTORE_TERMINAL = "\x1b[0m\x1b[?25h\x1b[0 q\x1b[r" +
|
|
|
35
41
|
"\x1b[?2004l\x1b[?1004l\x1b[?1l\x1b>\x1b[<u\x1b[>4;0m";
|
|
36
42
|
const ENTER_ALT = "\x1b[?1049h\x1b[H\x1b[2J";
|
|
37
43
|
const LEAVE_ALT = "\x1b[?1049l";
|
|
38
|
-
const CLEAR = "\x1b[H\x1b[2J";
|
|
39
44
|
export function wsUrlFor(host, id) {
|
|
40
45
|
return `${host.replace(/^http/i, "ws")}/api/sessions/${encodeURIComponent(id)}/tty`;
|
|
41
46
|
}
|
|
42
47
|
export function attach(deps, session, opts = {}) {
|
|
43
48
|
const { api, stdin, stdout, stderr } = deps;
|
|
44
|
-
const backoff = opts.backoffMs ?? DEFAULT_BACKOFF;
|
|
45
49
|
const filter = new InputFilter({ pasteImage: !opts.view });
|
|
46
50
|
const enc = new TextEncoder();
|
|
47
51
|
return new Promise((resolve) => {
|
|
@@ -54,8 +58,6 @@ export function attach(deps, session, opts = {}) {
|
|
|
54
58
|
let yielded = false;
|
|
55
59
|
let entered = false;
|
|
56
60
|
let done = false;
|
|
57
|
-
let attempts = 0;
|
|
58
|
-
let timer = null;
|
|
59
61
|
// Keystrokes that must wait their turn: until the server says who holds the keyboard, and while
|
|
60
62
|
// an image paste is uploading (so what is typed after Ctrl-V lands after the path, not before).
|
|
61
63
|
let pending = [];
|
|
@@ -66,7 +68,6 @@ export function attach(deps, session, opts = {}) {
|
|
|
66
68
|
let injections = [];
|
|
67
69
|
let status = null;
|
|
68
70
|
let pollTimer = null;
|
|
69
|
-
let spinTimer = null;
|
|
70
71
|
const inject = (seq) => {
|
|
71
72
|
if (stream.safe)
|
|
72
73
|
stdout.write(seq);
|
|
@@ -86,18 +87,20 @@ export function attach(deps, session, opts = {}) {
|
|
|
86
87
|
stdout.write(TITLE_PUSH);
|
|
87
88
|
status = new TermStatus({ status: "running", ...session }, inject, cfg.env);
|
|
88
89
|
status.update({ status: "running", ...session });
|
|
90
|
+
cfg.onRow?.({ status: "running", ...session });
|
|
89
91
|
let inFlight = false;
|
|
90
92
|
pollTimer = setInterval(() => {
|
|
91
93
|
if (inFlight || done)
|
|
92
94
|
return;
|
|
93
95
|
inFlight = true;
|
|
94
96
|
api.get(`/sessions/${encodeURIComponent(session.id)}`)
|
|
95
|
-
.then((row) => { if (!done)
|
|
96
|
-
status?.update(row);
|
|
97
|
+
.then((row) => { if (!done) {
|
|
98
|
+
status?.update(row);
|
|
99
|
+
cfg.onRow?.(row);
|
|
100
|
+
} })
|
|
97
101
|
.catch(() => { })
|
|
98
102
|
.finally(() => { inFlight = false; });
|
|
99
103
|
}, cfg.pollMs ?? 3000);
|
|
100
|
-
spinTimer = setInterval(() => { status?.tick(); flushInjections(); }, cfg.spinMs ?? 250);
|
|
101
104
|
};
|
|
102
105
|
const note = (msg) => {
|
|
103
106
|
stderr.write(entered ? `\r\n\x1b[2m[cubes] ${msg}\x1b[0m\r\n` : `[cubes] ${msg}\n`);
|
|
@@ -111,16 +114,12 @@ export function attach(deps, session, opts = {}) {
|
|
|
111
114
|
stdout.write(ENTER_ALT);
|
|
112
115
|
startStatus();
|
|
113
116
|
};
|
|
114
|
-
const finish = (code, message) => {
|
|
117
|
+
const finish = (code, message, outcome) => {
|
|
115
118
|
if (done)
|
|
116
119
|
return;
|
|
117
120
|
done = true;
|
|
118
|
-
if (timer)
|
|
119
|
-
clearTimeout(timer);
|
|
120
121
|
if (pollTimer)
|
|
121
122
|
clearInterval(pollTimer);
|
|
122
|
-
if (spinTimer)
|
|
123
|
-
clearInterval(spinTimer);
|
|
124
123
|
stdin.off("data", onData);
|
|
125
124
|
stdout.off("resize", onResize);
|
|
126
125
|
const sock = ws;
|
|
@@ -139,7 +138,7 @@ export function attach(deps, session, opts = {}) {
|
|
|
139
138
|
stdin.setRawMode?.(false);
|
|
140
139
|
}
|
|
141
140
|
stdin.pause();
|
|
142
|
-
resolve({ code, message });
|
|
141
|
+
resolve({ code, message, outcome });
|
|
143
142
|
};
|
|
144
143
|
const sendRaw = (b) => {
|
|
145
144
|
if (ws && ws.readyState === OPEN)
|
|
@@ -206,7 +205,11 @@ export function attach(deps, session, opts = {}) {
|
|
|
206
205
|
function onData(chunk) {
|
|
207
206
|
for (const a of filter.feed(chunk)) {
|
|
208
207
|
if (a.kind === "detach") {
|
|
209
|
-
|
|
208
|
+
if (opts.onDetachKey) {
|
|
209
|
+
opts.onDetachKey();
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
finish(0, "detached", "detached");
|
|
210
213
|
return;
|
|
211
214
|
}
|
|
212
215
|
if (a.kind === "paste-image") {
|
|
@@ -219,44 +222,20 @@ export function attach(deps, session, opts = {}) {
|
|
|
219
222
|
}
|
|
220
223
|
}
|
|
221
224
|
function onResize() { sendResize(); }
|
|
222
|
-
const retry = (ms) => {
|
|
223
|
-
if (done)
|
|
224
|
-
return;
|
|
225
|
-
timer = setTimeout(() => { timer = null; void connect(); }, ms);
|
|
226
|
-
};
|
|
227
225
|
const onClose = (code, reason) => {
|
|
228
226
|
ws = null;
|
|
229
227
|
role = null;
|
|
230
228
|
if (done)
|
|
231
229
|
return;
|
|
232
230
|
if (code === 1000) {
|
|
233
|
-
finish(0, reason ? `session ended (${reason})` : "session ended");
|
|
231
|
+
finish(0, reason ? `session ended (${reason})` : "session ended", "ended");
|
|
234
232
|
return;
|
|
235
233
|
}
|
|
236
234
|
if (code === WS_CLOSE_UNAUTHORIZED) {
|
|
237
|
-
finish(1, "the server refused the attach - your sign-in or org membership may have changed");
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
if (code === WS_CLOSE_PARKED) {
|
|
241
|
-
note("this session's terminal is parked (its machine stopped answering). Waiting for it to come back - Ctrl-] to give up.");
|
|
242
|
-
retry(opts.parkedRetryMs ?? 15_000);
|
|
235
|
+
finish(1, "the server refused the attach - your sign-in or org membership may have changed", "refused");
|
|
243
236
|
return;
|
|
244
237
|
}
|
|
245
|
-
|
|
246
|
-
note("this session already has the most viewers it allows - waiting for one to leave. Ctrl-] to give up.");
|
|
247
|
-
retry(opts.fullRetryMs ?? 5_000);
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
if (TRANSIENT.has(code) && attempts < backoff.length) {
|
|
251
|
-
const ms = backoff[attempts];
|
|
252
|
-
attempts += 1;
|
|
253
|
-
note(`connection dropped (${code}) - reconnecting (${attempts}/${backoff.length})…`);
|
|
254
|
-
retry(ms);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
finish(1, TRANSIENT.has(code)
|
|
258
|
-
? "lost the connection to the session and could not get it back"
|
|
259
|
-
: `the session closed the connection (${code}${reason ? ` ${reason}` : ""})`);
|
|
238
|
+
finish(1, dropReason(code, reason), "disconnected");
|
|
260
239
|
};
|
|
261
240
|
const connect = async () => {
|
|
262
241
|
if (done)
|
|
@@ -269,22 +248,15 @@ export function attach(deps, session, opts = {}) {
|
|
|
269
248
|
if (done)
|
|
270
249
|
return;
|
|
271
250
|
if (e instanceof ApiError && e.status === 409) {
|
|
272
|
-
finish(1, "the session is not running any more");
|
|
251
|
+
finish(1, "the session is not running any more", "ended");
|
|
273
252
|
return;
|
|
274
253
|
}
|
|
275
254
|
if (e instanceof ApiError && e.status < 500) {
|
|
276
|
-
finish(1, e.message);
|
|
255
|
+
finish(1, e.message, "refused");
|
|
277
256
|
return;
|
|
278
257
|
}
|
|
279
|
-
// The server
|
|
280
|
-
|
|
281
|
-
const ms = backoff[attempts];
|
|
282
|
-
attempts += 1;
|
|
283
|
-
note(`${e.message} - retrying (${attempts}/${backoff.length})…`);
|
|
284
|
-
retry(ms);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
finish(1, e.message);
|
|
258
|
+
// The server is restarting or unreachable: a disconnect like any other, the caller offers it.
|
|
259
|
+
finish(1, e.message, "disconnected");
|
|
288
260
|
return;
|
|
289
261
|
}
|
|
290
262
|
if (done)
|
|
@@ -298,10 +270,7 @@ export function attach(deps, session, opts = {}) {
|
|
|
298
270
|
sock.onopen = () => {
|
|
299
271
|
if (ws !== sock)
|
|
300
272
|
return;
|
|
301
|
-
|
|
302
|
-
enter();
|
|
303
|
-
else
|
|
304
|
-
stdout.write(CLEAR); // the server replays its ring; paint it on a clean screen
|
|
273
|
+
enter();
|
|
305
274
|
sendResize();
|
|
306
275
|
};
|
|
307
276
|
sock.onmessage = (ev) => {
|
|
@@ -317,9 +286,6 @@ export function attach(deps, session, opts = {}) {
|
|
|
317
286
|
return;
|
|
318
287
|
}
|
|
319
288
|
if (msg.type === "role") {
|
|
320
|
-
// A role is what proves the attach stuck (the viewer-cap refusal arrives AFTER open),
|
|
321
|
-
// so this - not onopen - is where the reconnect ladder resets.
|
|
322
|
-
attempts = 0;
|
|
323
289
|
const prev = role;
|
|
324
290
|
role = msg.writer ? "writer" : "mirror";
|
|
325
291
|
if (role === "mirror" && prev === "writer") {
|
|
@@ -357,10 +323,10 @@ export function attach(deps, session, opts = {}) {
|
|
|
357
323
|
stdout.on("resize", onResize);
|
|
358
324
|
stdin.resume();
|
|
359
325
|
if (opts.signal?.aborted) {
|
|
360
|
-
finish(0, "detached");
|
|
326
|
+
finish(0, "detached", "detached");
|
|
361
327
|
return;
|
|
362
328
|
}
|
|
363
|
-
opts.signal?.addEventListener("abort", () => finish(0, "detached"), { once: true });
|
|
329
|
+
opts.signal?.addEventListener("abort", () => finish(0, "detached", "detached"), { once: true });
|
|
364
330
|
void connect();
|
|
365
331
|
});
|
|
366
332
|
}
|
package/dist/cli.js
CHANGED
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import { createInterface } from "node:readline/promises";
|
|
10
|
-
import { makeApi
|
|
10
|
+
import { makeApi } 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
14
|
import { CubeSpinner } from "./cube.js";
|
|
15
|
+
import { ensureRunning } from "./resume.js";
|
|
16
|
+
import { launchTui } from "./tui/launch.js";
|
|
17
|
+
import { runList } from "./tui/listApp.js";
|
|
18
|
+
import { runPane } from "./tui/paneApp.js";
|
|
15
19
|
import { clearLogin, configPath, loadLogin, saveLogin } from "./config.js";
|
|
16
20
|
import { parseArgs } from "./args.js";
|
|
17
21
|
import { formatSections, isUuid, listSessions, LIVE, resolveSession, sectionize, stateOf } from "./sessions.js";
|
|
@@ -21,6 +25,8 @@ const VERSION = PKG.version;
|
|
|
21
25
|
const HELP = `cubes ${VERSION} - attach to your Cubes sessions from your own terminal
|
|
22
26
|
|
|
23
27
|
Usage:
|
|
28
|
+
cubes the split view: your sessions on the left, one open
|
|
29
|
+
on the right (needs tmux)
|
|
24
30
|
cubes login [--host URL] [--org ALIAS] [--no-browser] sign in with a code you approve in the browser
|
|
25
31
|
cubes logout sign out and revoke this computer's login
|
|
26
32
|
cubes whoami who, where, and which org
|
|
@@ -38,10 +44,15 @@ While attached, every key goes to the session except:
|
|
|
38
44
|
Ctrl-] detach (the session keeps running)
|
|
39
45
|
Ctrl-V paste the image on your clipboard (uploaded, then its path is typed in)
|
|
40
46
|
|
|
47
|
+
If the connection drops, nothing reconnects on its own: you are told why, and r reconnects.
|
|
48
|
+
|
|
49
|
+
In the split view: ⏎ or a click opens a session, Ctrl-] in a session jumps back to the list, l / m
|
|
50
|
+
show only live / your sessions, / finds, ←→ fold a session's children, q quits (sessions keep running).
|
|
51
|
+
|
|
41
52
|
Attaching takes the keyboard from any browser tab on the same session (it becomes read-only) and
|
|
42
53
|
fits the session to this window. --view watches without taking it.
|
|
43
54
|
|
|
44
|
-
The tab is titled after the session, with its state:
|
|
55
|
+
The tab is titled after the session, with its state: ● while it works, ✳ when idle, ✋ when it
|
|
45
56
|
is waiting for you - and a bell plus a desktop notification the moment it starts waiting (in Warp, its
|
|
46
57
|
own agent status instead). --no-status turns all of that off.
|
|
47
58
|
|
|
@@ -182,46 +193,6 @@ async function cmdLs(a) {
|
|
|
182
193
|
const color = !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
183
194
|
process.stdout.write(formatSections(sectionize(rows), process.stdout.columns ?? 120, { color }) + "\n");
|
|
184
195
|
}
|
|
185
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
186
|
-
/** Bring a stopped/hibernated session back and wait until its terminal can be attached. */
|
|
187
|
-
async function ensureRunning(api, row, resume) {
|
|
188
|
-
const id = encodeURIComponent(row.id);
|
|
189
|
-
let s = row;
|
|
190
|
-
if (s.status === "running")
|
|
191
|
-
return s;
|
|
192
|
-
if (!LIVE.has(s.status) && !resume)
|
|
193
|
-
throw new CliError(`that session is ${s.status}; leave out --no-resume to bring it back`);
|
|
194
|
-
const name = `"${s.title ?? s.id.slice(0, 8)}"`;
|
|
195
|
-
// The same cube the session's own screen spins while Claude boots, so the wait reads as one thing.
|
|
196
|
-
const spin = new CubeSpinner(process.stderr, `${LIVE.has(s.status) ? "Starting" : "Resuming"} ${name}`);
|
|
197
|
-
try {
|
|
198
|
-
if (!LIVE.has(s.status)) {
|
|
199
|
-
try {
|
|
200
|
-
await api.post(`/sessions/${id}/resume`);
|
|
201
|
-
}
|
|
202
|
-
catch (e) {
|
|
203
|
-
if (!(e instanceof ApiError && e.status === 409 && /already running/.test(e.message)))
|
|
204
|
-
throw e;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
const deadline = Date.now() + 5 * 60_000;
|
|
208
|
-
while (Date.now() < deadline) {
|
|
209
|
-
s = await api.get(`/sessions/${id}`);
|
|
210
|
-
if (s.status === "running")
|
|
211
|
-
return s;
|
|
212
|
-
if (!LIVE.has(s.status))
|
|
213
|
-
throw new CliError(`the session stopped while starting (${s.status})`);
|
|
214
|
-
if (s.provision_note) {
|
|
215
|
-
spin.update(`${s.provision_note}${s.provision_total ? ` (${s.provision_done ?? 0}/${s.provision_total})` : ""}`);
|
|
216
|
-
}
|
|
217
|
-
await sleep(1000);
|
|
218
|
-
}
|
|
219
|
-
throw new CliError("the session is taking too long to start - check it in the browser");
|
|
220
|
-
}
|
|
221
|
-
finally {
|
|
222
|
-
spin.stop();
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
196
|
async function cmdAttach(a) {
|
|
226
197
|
const s = currentSession({ org: str(a.flags, "org") });
|
|
227
198
|
const api = makeApi(s);
|
|
@@ -243,9 +214,7 @@ async function cmdAttach(a) {
|
|
|
243
214
|
}
|
|
244
215
|
if (!isTTY())
|
|
245
216
|
throw new CliError("attach needs an interactive terminal");
|
|
246
|
-
row = await ensureRunning(api, row, !a.flags.has("no-resume"));
|
|
247
217
|
const view = a.flags.has("view");
|
|
248
|
-
process.stderr.write(`Attaching to "${row.title ?? row.id}" (${row.id.slice(0, 8)})${view ? ", read-only" : ""}. Ctrl-] detaches.\n`);
|
|
249
218
|
const ac = new AbortController();
|
|
250
219
|
for (const sig of ["SIGTERM", "SIGHUP"])
|
|
251
220
|
process.on(sig, () => ac.abort());
|
|
@@ -259,17 +228,45 @@ async function cmdAttach(a) {
|
|
|
259
228
|
process.stderr.write(`cubes: ${e.message}\n`);
|
|
260
229
|
process.exit(1);
|
|
261
230
|
});
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
231
|
+
// ⚠ ONE ATTACH PER DECISION. A drop is reported, not retried ("never keep retrying to reattach a
|
|
232
|
+
// session"): the person reads why and presses r to go again, or anything else to leave.
|
|
233
|
+
for (;;) {
|
|
234
|
+
row = await ensureRunning(api, row, !a.flags.has("no-resume"));
|
|
235
|
+
process.stderr.write(`Attaching to "${row.title ?? row.id}" (${row.id.slice(0, 8)})${view ? ", read-only" : ""}. Ctrl-] detaches.\n`);
|
|
236
|
+
const result = await attach({
|
|
237
|
+
api,
|
|
238
|
+
host: s.host,
|
|
239
|
+
openWs: (url, protocols) => new WebSocket(url, protocols),
|
|
240
|
+
stdin: process.stdin,
|
|
241
|
+
stdout: process.stdout,
|
|
242
|
+
stderr: process.stderr,
|
|
243
|
+
readClipboardImage: () => readClipboardImage(),
|
|
244
|
+
}, row, { view, signal: ac.signal, status: a.flags.has("no-status") ? false : undefined });
|
|
245
|
+
if (result.outcome !== "disconnected" || ac.signal.aborted) {
|
|
246
|
+
process.stderr.write(`[cubes] ${result.message}\n`);
|
|
247
|
+
return result.code;
|
|
248
|
+
}
|
|
249
|
+
process.stderr.write(`[cubes] disconnected: ${result.message}\n`);
|
|
250
|
+
if (!(await keyIs("Press r to reconnect, any other key to quit. ", ["r", "R", "\r"])))
|
|
251
|
+
return result.code;
|
|
252
|
+
row = await api.get(`/sessions/${encodeURIComponent(row.id)}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** One keystroke, raw: true when it is one of `yes`. */
|
|
256
|
+
function keyIs(prompt, yes) {
|
|
257
|
+
if (!process.stdin.isTTY)
|
|
258
|
+
return Promise.resolve(false);
|
|
259
|
+
process.stderr.write(prompt);
|
|
260
|
+
return new Promise((resolve) => {
|
|
261
|
+
process.stdin.setRawMode(true);
|
|
262
|
+
process.stdin.resume();
|
|
263
|
+
process.stdin.once("data", (b) => {
|
|
264
|
+
process.stdin.setRawMode(false);
|
|
265
|
+
process.stdin.pause();
|
|
266
|
+
process.stderr.write("\n");
|
|
267
|
+
resolve(yes.includes(b.toString("utf8")));
|
|
268
|
+
});
|
|
269
|
+
});
|
|
273
270
|
}
|
|
274
271
|
async function main(argv) {
|
|
275
272
|
if (typeof WebSocket === "undefined") {
|
|
@@ -280,6 +277,9 @@ async function main(argv) {
|
|
|
280
277
|
process.stdout.write(`${VERSION}\n`);
|
|
281
278
|
return 0;
|
|
282
279
|
}
|
|
280
|
+
// No command at all, in a terminal: the split view. Piped or scripted, still the help.
|
|
281
|
+
if (!a.cmd && !a.flags.has("help") && isTTY())
|
|
282
|
+
return launchTui({ org: str(a.flags, "org") });
|
|
283
283
|
if (!a.cmd || a.cmd === "help" || a.flags.has("help")) {
|
|
284
284
|
process.stdout.write(HELP);
|
|
285
285
|
return a.cmd || a.flags.has("help") ? 0 : 1;
|
|
@@ -304,6 +304,9 @@ async function main(argv) {
|
|
|
304
304
|
return 0;
|
|
305
305
|
case "attach":
|
|
306
306
|
case "a": return cmdAttach(a);
|
|
307
|
+
// The split view's two panes (src/tui), started by tmux - not for typing by hand.
|
|
308
|
+
case "_list": return runList();
|
|
309
|
+
case "_pane": return runPane(a.pos[0]);
|
|
307
310
|
case "upgrade":
|
|
308
311
|
case "update":
|
|
309
312
|
return upgrade({
|
package/dist/cube.js
CHANGED
|
@@ -100,6 +100,7 @@ export function renderRow(levels, color) {
|
|
|
100
100
|
*/
|
|
101
101
|
export class CubeSpinner {
|
|
102
102
|
out;
|
|
103
|
+
indent;
|
|
103
104
|
timer = null;
|
|
104
105
|
frame = 0;
|
|
105
106
|
drawn = 0;
|
|
@@ -110,8 +111,10 @@ export class CubeSpinner {
|
|
|
110
111
|
// A Ctrl-C mid-wait: Node's default SIGINT exit runs no "exit" listeners, so it would leave the
|
|
111
112
|
// cube on screen and the cursor hidden. Clean up, then exit the way the default would have.
|
|
112
113
|
onSigint = () => { this.stop(); process.exit(130); };
|
|
113
|
-
|
|
114
|
+
/** `indent`: columns to the left of the cube (the split view centres it in its pane). */
|
|
115
|
+
constructor(out, caption, env = process.env, indent = 2) {
|
|
114
116
|
this.out = out;
|
|
117
|
+
this.indent = indent;
|
|
115
118
|
this.caption = caption;
|
|
116
119
|
this.size = out.isTTY && env.TERM !== "dumb" ? cubeSize(out.columns ?? 80, out.rows ?? 24) : null;
|
|
117
120
|
this.color = !env.NO_COLOR;
|
|
@@ -155,10 +158,11 @@ export class CubeSpinner {
|
|
|
155
158
|
// A caption that wraps would add a row this redraw does not know to move back over.
|
|
156
159
|
const room = Math.max(10, (this.out.columns ?? 80) - 6);
|
|
157
160
|
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) +
|
|
161
|
+
const pad = " ".repeat(Math.max(0, Math.floor((w - text.length - 3) / 2) + this.indent));
|
|
159
162
|
let s = this.drawn ? `\x1b[${this.drawn}A` : "";
|
|
163
|
+
const lead = " ".repeat(this.indent);
|
|
160
164
|
for (const r of rows)
|
|
161
|
-
s += `\r
|
|
165
|
+
s += `\r${lead}${renderRow(r, this.color)}\x1b[K\n`;
|
|
162
166
|
const cap = `${text}${dots}`;
|
|
163
167
|
s += `\r\x1b[K\n\r${pad}${this.color ? CAPTION + cap + "\x1b[0m" : cap}\x1b[K\n`;
|
|
164
168
|
this.out.write(s);
|
package/dist/resume.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Bringing a stopped or hibernated session back, and waiting until its terminal can be attached -
|
|
2
|
+
// shared by `cubes attach` and the split view's session pane.
|
|
3
|
+
import { ApiError } from "./api.js";
|
|
4
|
+
import { CliError } from "./auth.js";
|
|
5
|
+
import { CubeSpinner } from "./cube.js";
|
|
6
|
+
import { LIVE } from "./sessions.js";
|
|
7
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
8
|
+
/** Bring a stopped/hibernated session back and wait until its terminal can be attached. `out` is
|
|
9
|
+
* where the cube spins meanwhile; `indent` centres it (the split view's pane). */
|
|
10
|
+
export async function ensureRunning(api, row, resume, out = process.stderr, indent = 2) {
|
|
11
|
+
const id = encodeURIComponent(row.id);
|
|
12
|
+
let s = row;
|
|
13
|
+
if (s.status === "running")
|
|
14
|
+
return s;
|
|
15
|
+
if (!LIVE.has(s.status) && !resume)
|
|
16
|
+
throw new CliError(`that session is ${s.status}; leave out --no-resume to bring it back`);
|
|
17
|
+
const name = `"${s.title ?? s.id.slice(0, 8)}"`;
|
|
18
|
+
// The same cube the session's own screen spins while Claude boots, so the wait reads as one thing.
|
|
19
|
+
const spin = new CubeSpinner(out, `${LIVE.has(s.status) ? "Starting" : "Resuming"} ${name}`, process.env, indent);
|
|
20
|
+
try {
|
|
21
|
+
if (!LIVE.has(s.status)) {
|
|
22
|
+
try {
|
|
23
|
+
await api.post(`/sessions/${id}/resume`);
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (!(e instanceof ApiError && e.status === 409 && /already running/.test(e.message)))
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const deadline = Date.now() + 5 * 60_000;
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
s = await api.get(`/sessions/${id}`);
|
|
33
|
+
if (s.status === "running")
|
|
34
|
+
return s;
|
|
35
|
+
if (!LIVE.has(s.status))
|
|
36
|
+
throw new CliError(`the session stopped while starting (${s.status})`);
|
|
37
|
+
if (s.provision_note) {
|
|
38
|
+
spin.update(`${s.provision_note}${s.provision_total ? ` (${s.provision_done ?? 0}/${s.provision_total})` : ""}`);
|
|
39
|
+
}
|
|
40
|
+
await sleep(1000);
|
|
41
|
+
}
|
|
42
|
+
throw new CliError("the session is taking too long to start - check it in the browser");
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
spin.stop();
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/sessions.js
CHANGED
|
@@ -5,12 +5,23 @@ export const LIVE = new Set(["starting", "running"]);
|
|
|
5
5
|
export function attachable(s) {
|
|
6
6
|
return s.origin !== "ide" && s.exec_mode !== "headless";
|
|
7
7
|
}
|
|
8
|
+
/** The server pages at 200 (live sessions first), so a big org needs several pages. Capped, because
|
|
9
|
+
* this runs every few seconds in the split view and the oldest ended sessions are the least useful. */
|
|
10
|
+
const PAGE = 200;
|
|
11
|
+
const MAX_PAGES = 5;
|
|
8
12
|
export async function listSessions(api, opts = {}) {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
q
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
const out = [];
|
|
14
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
15
|
+
const q = new URLSearchParams({ limit: String(PAGE), offset: String(page * PAGE) });
|
|
16
|
+
if (opts.all)
|
|
17
|
+
q.set("scope", "all");
|
|
18
|
+
const res = await api.get(`/sessions?${q}`);
|
|
19
|
+
const got = res.sessions ?? [];
|
|
20
|
+
out.push(...got);
|
|
21
|
+
if (got.length < PAGE || (typeof res.total === "number" && out.length >= res.total))
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
return out.filter(attachable);
|
|
14
25
|
}
|
|
15
26
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
27
|
export function isUuid(s) {
|
package/dist/termstatus.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// working / ready / waiting-with-a-reason), in the three dialects terminals understand:
|
|
8
8
|
//
|
|
9
9
|
// • OSC 0 TITLE - every terminal. `<glyph> <session title>`, the convention Claude Code itself uses:
|
|
10
|
-
//
|
|
10
|
+
// ● while working, ✳ when idle, ✋ when it is waiting for you. Static: an animated glyph churns the tab.
|
|
11
11
|
// • BEL + OSC 9 - the generic "this tab wants you": a bell marks the tab / bounces the dock almost
|
|
12
12
|
// everywhere, and OSC 9 raises a desktop notification in iTerm2, Ghostty, WezTerm, kitty, Warp.
|
|
13
13
|
// • OSC 777 `warp://cli-agent` - Warp's structured agent protocol, the same events and payload as
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// would corrupt both. `StreamFilter` tracks where the stream is, and the caller only writes ours when
|
|
20
20
|
// the stream is between sequences.
|
|
21
21
|
export const WARP_PLUGIN_COMPAT = "2.3.0"; // the claude-code-warp release whose event set this mirrors
|
|
22
|
-
const
|
|
22
|
+
const WORKING = "●";
|
|
23
23
|
const IDLE = "✳";
|
|
24
24
|
const WAITING = "✋";
|
|
25
25
|
/** Title push / pop (xterm window ops): our title is set on top of yours and yours comes back after. */
|
|
@@ -192,18 +192,18 @@ export function warpProtocol(env) {
|
|
|
192
192
|
export class TermStatus {
|
|
193
193
|
emit;
|
|
194
194
|
state = null;
|
|
195
|
-
frame = 0;
|
|
196
195
|
shown = "";
|
|
197
196
|
row;
|
|
198
197
|
warp;
|
|
198
|
+
inTmux;
|
|
199
199
|
constructor(row, emit, env = process.env) {
|
|
200
200
|
this.emit = emit;
|
|
201
201
|
this.row = row;
|
|
202
202
|
this.warp = warpProtocol(env);
|
|
203
|
+
this.inTmux = !!env.TMUX;
|
|
203
204
|
}
|
|
204
205
|
title() {
|
|
205
|
-
const glyph = this.state === "working" ?
|
|
206
|
-
: this.state === "waiting" ? WAITING : IDLE;
|
|
206
|
+
const glyph = this.state === "working" ? WORKING : this.state === "waiting" ? WAITING : IDLE;
|
|
207
207
|
return `${glyph} ${clean(this.row.title || "Cubes session", 80)}`;
|
|
208
208
|
}
|
|
209
209
|
/** Only when it changed: the activity is re-read every few seconds, and most reads change nothing. */
|
|
@@ -214,6 +214,15 @@ export class TermStatus {
|
|
|
214
214
|
this.shown = t;
|
|
215
215
|
this.emit(`\x1b]0;${t}\x07`);
|
|
216
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Notifications (OSC 9, OSC 777) are for the terminal the PERSON sees. Inside tmux - the split view
|
|
219
|
+
* runs everything in one - tmux would swallow them, so they travel as tmux's DCS passthrough (every
|
|
220
|
+
* ESC doubled), which reaches the outer terminal when `allow-passthrough` is on (the split view turns
|
|
221
|
+
* it on). The bell needs none of this: tmux forwards a bell by itself.
|
|
222
|
+
*/
|
|
223
|
+
outward(seq) {
|
|
224
|
+
return this.inTmux ? `\x1bPtmux;${seq.replace(/\x1b/g, "\x1b\x1b")}\x1b\\` : seq;
|
|
225
|
+
}
|
|
217
226
|
warpEvent(event, extra = {}) {
|
|
218
227
|
if (this.warp === null)
|
|
219
228
|
return;
|
|
@@ -221,7 +230,7 @@ export class TermStatus {
|
|
|
221
230
|
v: this.warp, agent: "claude", event, session_id: this.row.id, cwd: "",
|
|
222
231
|
project: clean(this.row.title || "cubes", 60), ...extra,
|
|
223
232
|
});
|
|
224
|
-
this.emit(`\x1b]777;notify;warp://cli-agent;${body}\x07`);
|
|
233
|
+
this.emit(this.outward(`\x1b]777;notify;warp://cli-agent;${body}\x07`));
|
|
225
234
|
}
|
|
226
235
|
/** Feed the latest row. The first call only establishes the state - nobody needs to be told that a
|
|
227
236
|
* session they are attaching to right now is waiting for them. */
|
|
@@ -260,16 +269,8 @@ export class TermStatus {
|
|
|
260
269
|
else if (!row.muted_at) {
|
|
261
270
|
// Bell first (marks the tab nearly everywhere), then the desktop notification where supported.
|
|
262
271
|
// "Cubes" leads the text so it can never be read as ConEmu's numeric OSC 9 sub-commands.
|
|
263
|
-
this.emit(`\x07
|
|
272
|
+
this.emit(`\x07${this.outward(`\x1b]9;Cubes · ${clean(row.title || "session", 60)}: ${summary}\x07`)}`);
|
|
264
273
|
}
|
|
265
274
|
}
|
|
266
275
|
}
|
|
267
|
-
/** Advance the working spinner. Returns false when there is nothing to animate. */
|
|
268
|
-
tick() {
|
|
269
|
-
if (this.state !== "working")
|
|
270
|
-
return false;
|
|
271
|
-
this.frame++;
|
|
272
|
-
this.setTitle();
|
|
273
|
-
return true;
|
|
274
|
-
}
|
|
275
276
|
}
|