@hellix-ai/cubes 0.1.4 → 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 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:
@@ -28,9 +40,18 @@ Every key goes to the session, exactly as if Claude Code were running locally, e
28
40
  takes it back, the terminal says so and goes read-only; detach and attach again to reclaim it.
29
41
  `cubes attach --view` watches without taking it.
30
42
 
43
+ **Your tab shows the session.** The terminal tab is titled after the session, with its state in front,
44
+ the way Claude Code does it locally (minus the animation): `●` while it works, `✳` when idle, `✋` when it is waiting
45
+ for you. The moment it starts waiting, the terminal also gets a bell (most terminals mark the tab or
46
+ bounce the dock) and a desktop notification with Claude's question (iTerm2, Ghostty, WezTerm, kitty and
47
+ others). In Warp, the same events Warp's own Claude Code plugin sends drive its tab status and
48
+ notifications instead. Muted sessions stay quiet. Your own title comes back when you detach.
49
+ `--no-status` turns all of it off.
50
+
31
51
  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.
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.
34
55
 
35
56
  Scrollback lives in the session's tmux: scroll with the mouse wheel or trackpad (tmux copy-mode), not
36
57
  your terminal's own scrollback.
@@ -38,12 +59,13 @@ your terminal's own scrollback.
38
59
  ## Commands
39
60
 
40
61
  ```
62
+ cubes # the split view
41
63
  cubes login [--host URL] [--org ALIAS] [--no-browser]
42
64
  cubes logout
43
65
  cubes whoami
44
66
  cubes org [ALIAS]
45
67
  cubes ls [--mine] [--live] [--all] [--json]
46
- cubes attach [SESSION] [--view] [--no-resume]
68
+ cubes attach [SESSION] [--view] [--no-resume] [--no-status]
47
69
  cubes upgrade [--check]
48
70
  ```
49
71
 
package/dist/attach.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // on a read-only tile. The writer also decides the pane's size, so the session re-fits to THIS window.
15
15
  import { ApiError } from "./api.js";
16
16
  import { InputFilter, PASTE_IMAGE_BYTE } from "./keys.js";
17
+ import { StreamFilter, TermStatus, TITLE_POP, TITLE_PUSH } from "./termstatus.js";
17
18
  export const TTY_SUBPROTOCOL = "cube.tty.v1";
18
19
  const OPEN = 1;
19
20
  /** Close codes (server/src/engine/tty.ts, web/src/cubes/Terminal.tsx). */
@@ -21,10 +22,16 @@ const WS_CLOSE_BACKPRESSURE = 4001;
21
22
  const WS_CLOSE_FULL = 4002;
22
23
  const WS_CLOSE_PARKED = 4004;
23
24
  const WS_CLOSE_UNAUTHORIZED = 4403;
24
- /** Drops that mean "the session is fine, the socket is not": re-mint a ticket and re-attach; the
25
- * server replays its scrollback ring. 1001/1012/1013 are a server going away or restarting. */
26
- const TRANSIENT = new Set([WS_CLOSE_BACKPRESSURE, 1001, 1005, 1006, 1011, 1012, 1013]);
27
- const DEFAULT_BACKOFF = [300, 600, 1200, 2400, 5000, 5000, 5000, 5000];
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
+ }
28
35
  /** Everything an attached session may have switched on in YOUR terminal (mouse reporting, bracketed
29
36
  * paste, focus events, app cursor keys, kitty/modifyOtherKeys keyboard modes, a hidden cursor, a
30
37
  * scroll region) - switched back off on the way out, so detaching never leaves Warp/iTerm in a
@@ -34,13 +41,11 @@ export const RESTORE_TERMINAL = "\x1b[0m\x1b[?25h\x1b[0 q\x1b[r" +
34
41
  "\x1b[?2004l\x1b[?1004l\x1b[?1l\x1b>\x1b[<u\x1b[>4;0m";
35
42
  const ENTER_ALT = "\x1b[?1049h\x1b[H\x1b[2J";
36
43
  const LEAVE_ALT = "\x1b[?1049l";
37
- const CLEAR = "\x1b[H\x1b[2J";
38
44
  export function wsUrlFor(host, id) {
39
45
  return `${host.replace(/^http/i, "ws")}/api/sessions/${encodeURIComponent(id)}/tty`;
40
46
  }
41
47
  export function attach(deps, session, opts = {}) {
42
48
  const { api, stdin, stdout, stderr } = deps;
43
- const backoff = opts.backoffMs ?? DEFAULT_BACKOFF;
44
49
  const filter = new InputFilter({ pasteImage: !opts.view });
45
50
  const enc = new TextEncoder();
46
51
  return new Promise((resolve) => {
@@ -53,12 +58,50 @@ export function attach(deps, session, opts = {}) {
53
58
  let yielded = false;
54
59
  let entered = false;
55
60
  let done = false;
56
- let attempts = 0;
57
- let timer = null;
58
61
  // Keystrokes that must wait their turn: until the server says who holds the keyboard, and while
59
62
  // an image paste is uploading (so what is typed after Ctrl-V lands after the path, not before).
60
63
  let pending = [];
61
64
  let pasting = false;
65
+ // Our own sequences (title, bell, notifications) wait here until the session's stream is between
66
+ // escape sequences - see termstatus.ts. A fresh filter per connection: a replay starts clean.
67
+ let stream = new StreamFilter();
68
+ let injections = [];
69
+ let status = null;
70
+ let pollTimer = null;
71
+ const inject = (seq) => {
72
+ if (stream.safe)
73
+ stdout.write(seq);
74
+ else
75
+ injections.push(seq);
76
+ };
77
+ const flushInjections = () => {
78
+ if (!injections.length || !stream.safe)
79
+ return;
80
+ stdout.write(injections.join(""));
81
+ injections = [];
82
+ };
83
+ const startStatus = () => {
84
+ if (opts.status === false || status)
85
+ return;
86
+ const cfg = opts.status ?? {};
87
+ stdout.write(TITLE_PUSH);
88
+ status = new TermStatus({ status: "running", ...session }, inject, cfg.env);
89
+ status.update({ status: "running", ...session });
90
+ cfg.onRow?.({ status: "running", ...session });
91
+ let inFlight = false;
92
+ pollTimer = setInterval(() => {
93
+ if (inFlight || done)
94
+ return;
95
+ inFlight = true;
96
+ api.get(`/sessions/${encodeURIComponent(session.id)}`)
97
+ .then((row) => { if (!done) {
98
+ status?.update(row);
99
+ cfg.onRow?.(row);
100
+ } })
101
+ .catch(() => { })
102
+ .finally(() => { inFlight = false; });
103
+ }, cfg.pollMs ?? 3000);
104
+ };
62
105
  const note = (msg) => {
63
106
  stderr.write(entered ? `\r\n\x1b[2m[cubes] ${msg}\x1b[0m\r\n` : `[cubes] ${msg}\n`);
64
107
  };
@@ -69,13 +112,14 @@ export function attach(deps, session, opts = {}) {
69
112
  if (stdin.isTTY)
70
113
  stdin.setRawMode?.(true);
71
114
  stdout.write(ENTER_ALT);
115
+ startStatus();
72
116
  };
73
- const finish = (code, message) => {
117
+ const finish = (code, message, outcome) => {
74
118
  if (done)
75
119
  return;
76
120
  done = true;
77
- if (timer)
78
- clearTimeout(timer);
121
+ if (pollTimer)
122
+ clearInterval(pollTimer);
79
123
  stdin.off("data", onData);
80
124
  stdout.off("resize", onResize);
81
125
  const sock = ws;
@@ -89,12 +133,12 @@ export function attach(deps, session, opts = {}) {
89
133
  catch { /* already gone */ }
90
134
  }
91
135
  if (entered) {
92
- stdout.write(RESTORE_TERMINAL + LEAVE_ALT);
136
+ stdout.write(RESTORE_TERMINAL + (status ? TITLE_POP : "") + LEAVE_ALT);
93
137
  if (stdin.isTTY)
94
138
  stdin.setRawMode?.(false);
95
139
  }
96
140
  stdin.pause();
97
- resolve({ code, message });
141
+ resolve({ code, message, outcome });
98
142
  };
99
143
  const sendRaw = (b) => {
100
144
  if (ws && ws.readyState === OPEN)
@@ -161,7 +205,11 @@ export function attach(deps, session, opts = {}) {
161
205
  function onData(chunk) {
162
206
  for (const a of filter.feed(chunk)) {
163
207
  if (a.kind === "detach") {
164
- finish(0, "detached");
208
+ if (opts.onDetachKey) {
209
+ opts.onDetachKey();
210
+ continue;
211
+ }
212
+ finish(0, "detached", "detached");
165
213
  return;
166
214
  }
167
215
  if (a.kind === "paste-image") {
@@ -174,44 +222,20 @@ export function attach(deps, session, opts = {}) {
174
222
  }
175
223
  }
176
224
  function onResize() { sendResize(); }
177
- const retry = (ms) => {
178
- if (done)
179
- return;
180
- timer = setTimeout(() => { timer = null; void connect(); }, ms);
181
- };
182
225
  const onClose = (code, reason) => {
183
226
  ws = null;
184
227
  role = null;
185
228
  if (done)
186
229
  return;
187
230
  if (code === 1000) {
188
- finish(0, reason ? `session ended (${reason})` : "session ended");
231
+ finish(0, reason ? `session ended (${reason})` : "session ended", "ended");
189
232
  return;
190
233
  }
191
234
  if (code === WS_CLOSE_UNAUTHORIZED) {
192
- finish(1, "the server refused the attach - your sign-in or org membership may have changed");
193
- return;
194
- }
195
- if (code === WS_CLOSE_PARKED) {
196
- note("this session's terminal is parked (its machine stopped answering). Waiting for it to come back - Ctrl-] to give up.");
197
- retry(opts.parkedRetryMs ?? 15_000);
235
+ finish(1, "the server refused the attach - your sign-in or org membership may have changed", "refused");
198
236
  return;
199
237
  }
200
- if (code === WS_CLOSE_FULL) {
201
- note("this session already has the most viewers it allows - waiting for one to leave. Ctrl-] to give up.");
202
- retry(opts.fullRetryMs ?? 5_000);
203
- return;
204
- }
205
- if (TRANSIENT.has(code) && attempts < backoff.length) {
206
- const ms = backoff[attempts];
207
- attempts += 1;
208
- note(`connection dropped (${code}) - reconnecting (${attempts}/${backoff.length})…`);
209
- retry(ms);
210
- return;
211
- }
212
- finish(1, TRANSIENT.has(code)
213
- ? "lost the connection to the session and could not get it back"
214
- : `the session closed the connection (${code}${reason ? ` ${reason}` : ""})`);
238
+ finish(1, dropReason(code, reason), "disconnected");
215
239
  };
216
240
  const connect = async () => {
217
241
  if (done)
@@ -224,22 +248,15 @@ export function attach(deps, session, opts = {}) {
224
248
  if (done)
225
249
  return;
226
250
  if (e instanceof ApiError && e.status === 409) {
227
- finish(1, "the session is not running any more");
251
+ finish(1, "the session is not running any more", "ended");
228
252
  return;
229
253
  }
230
254
  if (e instanceof ApiError && e.status < 500) {
231
- finish(1, e.message);
232
- return;
233
- }
234
- // The server itself is restarting or unreachable: same ladder as a dropped socket.
235
- if (attempts < backoff.length) {
236
- const ms = backoff[attempts];
237
- attempts += 1;
238
- note(`${e.message} - retrying (${attempts}/${backoff.length})…`);
239
- retry(ms);
255
+ finish(1, e.message, "refused");
240
256
  return;
241
257
  }
242
- 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");
243
260
  return;
244
261
  }
245
262
  if (done)
@@ -247,15 +264,13 @@ export function attach(deps, session, opts = {}) {
247
264
  const sock = deps.openWs(wsUrlFor(deps.host, session.id), [TTY_SUBPROTOCOL, ticket]);
248
265
  sock.binaryType = "arraybuffer";
249
266
  ws = sock;
267
+ stream = new StreamFilter();
250
268
  askedTakeover = false;
251
269
  takeoverPending = false;
252
270
  sock.onopen = () => {
253
271
  if (ws !== sock)
254
272
  return;
255
- if (!entered)
256
- enter();
257
- else
258
- stdout.write(CLEAR); // the server replays its ring; paint it on a clean screen
273
+ enter();
259
274
  sendResize();
260
275
  };
261
276
  sock.onmessage = (ev) => {
@@ -271,9 +286,6 @@ export function attach(deps, session, opts = {}) {
271
286
  return;
272
287
  }
273
288
  if (msg.type === "role") {
274
- // A role is what proves the attach stuck (the viewer-cap refusal arrives AFTER open),
275
- // so this - not onopen - is where the reconnect ladder resets.
276
- attempts = 0;
277
289
  const prev = role;
278
290
  role = msg.writer ? "writer" : "mirror";
279
291
  if (role === "mirror" && prev === "writer") {
@@ -296,10 +308,12 @@ export function attach(deps, session, opts = {}) {
296
308
  }
297
309
  return;
298
310
  }
299
- if (data instanceof ArrayBuffer)
300
- stdout.write(new Uint8Array(data));
301
- else if (ArrayBuffer.isView(data))
302
- stdout.write(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
311
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data)
312
+ : ArrayBuffer.isView(data) ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : null;
313
+ if (!bytes)
314
+ return;
315
+ stdout.write(stream.feed(bytes));
316
+ flushInjections();
303
317
  };
304
318
  sock.onclose = (ev) => { if (ws === sock)
305
319
  onClose(ev.code, ev.reason); };
@@ -309,10 +323,10 @@ export function attach(deps, session, opts = {}) {
309
323
  stdout.on("resize", onResize);
310
324
  stdin.resume();
311
325
  if (opts.signal?.aborted) {
312
- finish(0, "detached");
326
+ finish(0, "detached", "detached");
313
327
  return;
314
328
  }
315
- opts.signal?.addEventListener("abort", () => finish(0, "detached"), { once: true });
329
+ opts.signal?.addEventListener("abort", () => finish(0, "detached", "detached"), { once: true });
316
330
  void connect();
317
331
  });
318
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, ApiError } from "./api.js";
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,13 +25,16 @@ 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
27
33
  cubes org [ALIAS] list your orgs, or switch the default
28
34
  cubes ls [--mine] [--live] [--all] [--json] list sessions by group (--mine: you started them,
29
35
  --live: running now, --all: include spawned ones)
30
- cubes attach [SESSION] [--view] [--no-resume] attach to a session
36
+ cubes attach [SESSION] [--view] [--no-resume] [--no-status]
37
+ attach to a session
31
38
  cubes upgrade [--check] install the newest cubes (--check only reports)
32
39
 
33
40
  SESSION is an id, the start of one (4+ characters), or part of its title. Left out, you pick from
@@ -37,9 +44,18 @@ While attached, every key goes to the session except:
37
44
  Ctrl-] detach (the session keeps running)
38
45
  Ctrl-V paste the image on your clipboard (uploaded, then its path is typed in)
39
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
+
40
52
  Attaching takes the keyboard from any browser tab on the same session (it becomes read-only) and
41
53
  fits the session to this window. --view watches without taking it.
42
54
 
55
+ The tab is titled after the session, with its state: ● while it works, ✳ when idle, ✋ when it
56
+ is waiting for you - and a bell plus a desktop notification the moment it starts waiting (in Warp, its
57
+ own agent status instead). --no-status turns all of that off.
58
+
43
59
  Global: --org ALIAS overrides the default org for one command.
44
60
  Env: CUBES_HOST + CUBES_TOKEN (+ CUBES_ORG) use a token instead of the stored login.
45
61
  Config: ${configPath()}
@@ -177,46 +193,6 @@ async function cmdLs(a) {
177
193
  const color = !!process.stdout.isTTY && !process.env.NO_COLOR;
178
194
  process.stdout.write(formatSections(sectionize(rows), process.stdout.columns ?? 120, { color }) + "\n");
179
195
  }
180
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
181
- /** Bring a stopped/hibernated session back and wait until its terminal can be attached. */
182
- async function ensureRunning(api, row, resume) {
183
- const id = encodeURIComponent(row.id);
184
- let s = row;
185
- if (s.status === "running")
186
- return s;
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
- }
201
- }
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);
213
- }
214
- throw new CliError("the session is taking too long to start - check it in the browser");
215
- }
216
- finally {
217
- spin.stop();
218
- }
219
- }
220
196
  async function cmdAttach(a) {
221
197
  const s = currentSession({ org: str(a.flags, "org") });
222
198
  const api = makeApi(s);
@@ -238,9 +214,7 @@ async function cmdAttach(a) {
238
214
  }
239
215
  if (!isTTY())
240
216
  throw new CliError("attach needs an interactive terminal");
241
- row = await ensureRunning(api, row, !a.flags.has("no-resume"));
242
217
  const view = a.flags.has("view");
243
- process.stderr.write(`Attaching to "${row.title ?? row.id}" (${row.id.slice(0, 8)})${view ? ", read-only" : ""}. Ctrl-] detaches.\n`);
244
218
  const ac = new AbortController();
245
219
  for (const sig of ["SIGTERM", "SIGHUP"])
246
220
  process.on(sig, () => ac.abort());
@@ -254,17 +228,45 @@ async function cmdAttach(a) {
254
228
  process.stderr.write(`cubes: ${e.message}\n`);
255
229
  process.exit(1);
256
230
  });
257
- const result = await attach({
258
- api,
259
- host: s.host,
260
- openWs: (url, protocols) => new WebSocket(url, protocols),
261
- stdin: process.stdin,
262
- stdout: process.stdout,
263
- stderr: process.stderr,
264
- readClipboardImage: () => readClipboardImage(),
265
- }, row, { view, signal: ac.signal });
266
- process.stderr.write(`[cubes] ${result.message}\n`);
267
- return result.code;
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
+ });
268
270
  }
269
271
  async function main(argv) {
270
272
  if (typeof WebSocket === "undefined") {
@@ -275,6 +277,9 @@ async function main(argv) {
275
277
  process.stdout.write(`${VERSION}\n`);
276
278
  return 0;
277
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") });
278
283
  if (!a.cmd || a.cmd === "help" || a.flags.has("help")) {
279
284
  process.stdout.write(HELP);
280
285
  return a.cmd || a.flags.has("help") ? 0 : 1;
@@ -299,6 +304,9 @@ async function main(argv) {
299
304
  return 0;
300
305
  case "attach":
301
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]);
302
310
  case "upgrade":
303
311
  case "update":
304
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
- constructor(out, caption, env = process.env) {
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) + 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 ${renderRow(r, this.color)}\x1b[K\n`;
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 q = new URLSearchParams({ limit: "200" });
10
- if (opts.all)
11
- q.set("scope", "all");
12
- const page = await api.get(`/sessions?${q}`);
13
- return (page.sessions ?? []).filter(attachable);
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) {