@hellix-ai/cubes 0.1.5 → 0.2.1

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:
@@ -23,13 +35,18 @@ Every key goes to the session, exactly as if Claude Code were running locally, e
23
35
  | `Ctrl-]` | detach. The session keeps running. |
24
36
  | `Ctrl-V` | paste the image on your clipboard: it is uploaded to the session and its path typed in (macOS, and Linux with `wl-paste` or `xclip`). With no image on the clipboard, Ctrl-V goes through unchanged. |
25
37
 
38
+ **Copying.** Drag over text in a session and it is copied the moment you let go - the session's tmux
39
+ does the selecting (so there is no selection left for Cmd+C), and the CLI puts the text on your
40
+ clipboard. Claude Code's own copy (the `c` on its login URL) lands there too. On Linux this needs
41
+ `wl-copy`, `xclip` or `xsel`.
42
+
26
43
  **One keyboard per session.** Attaching takes the keyboard from any browser tab open on the same session
27
44
  (that tab becomes read-only, with a Take over button) and fits the session to your window. If someone
28
45
  takes it back, the terminal says so and goes read-only; detach and attach again to reclaim it.
29
46
  `cubes attach --view` watches without taking it.
30
47
 
31
48
  **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: a spinner while it works, `✳` when idle, `✋` when it is waiting
49
+ the way Claude Code does it locally (minus the animation): `●` while it works, `✳` when idle, `✋` when it is waiting
33
50
  for you. The moment it starts waiting, the terminal also gets a bell (most terminals mark the tab or
34
51
  bounce the dock) and a desktop notification with Claude's question (iTerm2, Ghostty, WezTerm, kitty and
35
52
  others). In Warp, the same events Warp's own Claude Code plugin sends drive its tab status and
@@ -37,8 +54,9 @@ notifications instead. Muted sessions stay quiet. Your own title comes back when
37
54
  `--no-status` turns all of it off.
38
55
 
39
56
  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. A dropped connection reconnects on its own and replays
41
- the screen.
57
+ spinning Cubes mark on screen until it is up. If the connection drops, nothing reconnects behind your
58
+ back: you are told why (the connection dropped, the session's machine stopped answering, too many
59
+ viewers) and `r` reconnects when you choose to.
42
60
 
43
61
  Scrollback lives in the session's tmux: scroll with the mouse wheel or trackpad (tmux copy-mode), not
44
62
  your terminal's own scrollback.
@@ -46,6 +64,7 @@ your terminal's own scrollback.
46
64
  ## Commands
47
65
 
48
66
  ```
67
+ cubes # the split view
49
68
  cubes login [--host URL] [--org ALIAS] [--no-browser]
50
69
  cubes logout
51
70
  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
- /** Drops that mean "the session is fine, the socket is not": re-mint a ticket and re-attach; the
26
- * server replays its scrollback ring. 1001/1012/1013 are a server going away or restarting. */
27
- const TRANSIENT = new Set([WS_CLOSE_BACKPRESSURE, 1001, 1005, 1006, 1011, 1012, 1013]);
28
- 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
+ }
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,19 +58,29 @@ 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 = [];
62
64
  let pasting = false;
63
65
  // Our own sequences (title, bell, notifications) wait here until the session's stream is between
64
66
  // escape sequences - see termstatus.ts. A fresh filter per connection: a replay starts clean.
65
- let stream = new StreamFilter();
67
+ // ⚠ ONLY THE KEYBOARD COPIES. The session's output reaches every viewer, so a `c` pressed in a
68
+ // browser tab on the same session would otherwise overwrite THIS person's clipboard too. The web
69
+ // terminal draws the same line (Terminal.tsx, its OSC 52 handler).
70
+ const onClipboard = deps.clipboard ? (text) => {
71
+ if (role !== "writer")
72
+ return;
73
+ void deps.clipboard.write(text).then((ok) => {
74
+ if (!ok || done)
75
+ return;
76
+ status?.flash(`✓ Copied ${[...text].length} characters`);
77
+ opts.onCopied?.(text);
78
+ });
79
+ } : undefined;
80
+ let stream = new StreamFilter(onClipboard);
66
81
  let injections = [];
67
82
  let status = null;
68
83
  let pollTimer = null;
69
- let spinTimer = null;
70
84
  const inject = (seq) => {
71
85
  if (stream.safe)
72
86
  stdout.write(seq);
@@ -86,18 +100,20 @@ export function attach(deps, session, opts = {}) {
86
100
  stdout.write(TITLE_PUSH);
87
101
  status = new TermStatus({ status: "running", ...session }, inject, cfg.env);
88
102
  status.update({ status: "running", ...session });
103
+ cfg.onRow?.({ status: "running", ...session });
89
104
  let inFlight = false;
90
105
  pollTimer = setInterval(() => {
91
106
  if (inFlight || done)
92
107
  return;
93
108
  inFlight = true;
94
109
  api.get(`/sessions/${encodeURIComponent(session.id)}`)
95
- .then((row) => { if (!done)
96
- status?.update(row); })
110
+ .then((row) => { if (!done) {
111
+ status?.update(row);
112
+ cfg.onRow?.(row);
113
+ } })
97
114
  .catch(() => { })
98
115
  .finally(() => { inFlight = false; });
99
116
  }, cfg.pollMs ?? 3000);
100
- spinTimer = setInterval(() => { status?.tick(); flushInjections(); }, cfg.spinMs ?? 250);
101
117
  };
102
118
  const note = (msg) => {
103
119
  stderr.write(entered ? `\r\n\x1b[2m[cubes] ${msg}\x1b[0m\r\n` : `[cubes] ${msg}\n`);
@@ -111,16 +127,12 @@ export function attach(deps, session, opts = {}) {
111
127
  stdout.write(ENTER_ALT);
112
128
  startStatus();
113
129
  };
114
- const finish = (code, message) => {
130
+ const finish = (code, message, outcome) => {
115
131
  if (done)
116
132
  return;
117
133
  done = true;
118
- if (timer)
119
- clearTimeout(timer);
120
134
  if (pollTimer)
121
135
  clearInterval(pollTimer);
122
- if (spinTimer)
123
- clearInterval(spinTimer);
124
136
  stdin.off("data", onData);
125
137
  stdout.off("resize", onResize);
126
138
  const sock = ws;
@@ -139,7 +151,7 @@ export function attach(deps, session, opts = {}) {
139
151
  stdin.setRawMode?.(false);
140
152
  }
141
153
  stdin.pause();
142
- resolve({ code, message });
154
+ resolve({ code, message, outcome });
143
155
  };
144
156
  const sendRaw = (b) => {
145
157
  if (ws && ws.readyState === OPEN)
@@ -206,7 +218,11 @@ export function attach(deps, session, opts = {}) {
206
218
  function onData(chunk) {
207
219
  for (const a of filter.feed(chunk)) {
208
220
  if (a.kind === "detach") {
209
- finish(0, "detached");
221
+ if (opts.onDetachKey) {
222
+ opts.onDetachKey();
223
+ continue;
224
+ }
225
+ finish(0, "detached", "detached");
210
226
  return;
211
227
  }
212
228
  if (a.kind === "paste-image") {
@@ -219,44 +235,20 @@ export function attach(deps, session, opts = {}) {
219
235
  }
220
236
  }
221
237
  function onResize() { sendResize(); }
222
- const retry = (ms) => {
223
- if (done)
224
- return;
225
- timer = setTimeout(() => { timer = null; void connect(); }, ms);
226
- };
227
238
  const onClose = (code, reason) => {
228
239
  ws = null;
229
240
  role = null;
230
241
  if (done)
231
242
  return;
232
243
  if (code === 1000) {
233
- finish(0, reason ? `session ended (${reason})` : "session ended");
244
+ finish(0, reason ? `session ended (${reason})` : "session ended", "ended");
234
245
  return;
235
246
  }
236
247
  if (code === WS_CLOSE_UNAUTHORIZED) {
237
- finish(1, "the server refused the attach - your sign-in or org membership may have changed");
248
+ finish(1, "the server refused the attach - your sign-in or org membership may have changed", "refused");
238
249
  return;
239
250
  }
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);
243
- return;
244
- }
245
- if (code === WS_CLOSE_FULL) {
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}` : ""})`);
251
+ finish(1, dropReason(code, reason), "disconnected");
260
252
  };
261
253
  const connect = async () => {
262
254
  if (done)
@@ -269,22 +261,15 @@ export function attach(deps, session, opts = {}) {
269
261
  if (done)
270
262
  return;
271
263
  if (e instanceof ApiError && e.status === 409) {
272
- finish(1, "the session is not running any more");
264
+ finish(1, "the session is not running any more", "ended");
273
265
  return;
274
266
  }
275
267
  if (e instanceof ApiError && e.status < 500) {
276
- finish(1, e.message);
277
- return;
278
- }
279
- // The server itself is restarting or unreachable: same ladder as a dropped socket.
280
- if (attempts < backoff.length) {
281
- const ms = backoff[attempts];
282
- attempts += 1;
283
- note(`${e.message} - retrying (${attempts}/${backoff.length})…`);
284
- retry(ms);
268
+ finish(1, e.message, "refused");
285
269
  return;
286
270
  }
287
- finish(1, e.message);
271
+ // The server is restarting or unreachable: a disconnect like any other, the caller offers it.
272
+ finish(1, e.message, "disconnected");
288
273
  return;
289
274
  }
290
275
  if (done)
@@ -292,16 +277,13 @@ export function attach(deps, session, opts = {}) {
292
277
  const sock = deps.openWs(wsUrlFor(deps.host, session.id), [TTY_SUBPROTOCOL, ticket]);
293
278
  sock.binaryType = "arraybuffer";
294
279
  ws = sock;
295
- stream = new StreamFilter();
280
+ stream = new StreamFilter(onClipboard);
296
281
  askedTakeover = false;
297
282
  takeoverPending = false;
298
283
  sock.onopen = () => {
299
284
  if (ws !== sock)
300
285
  return;
301
- if (!entered)
302
- enter();
303
- else
304
- stdout.write(CLEAR); // the server replays its ring; paint it on a clean screen
286
+ enter();
305
287
  sendResize();
306
288
  };
307
289
  sock.onmessage = (ev) => {
@@ -317,9 +299,6 @@ export function attach(deps, session, opts = {}) {
317
299
  return;
318
300
  }
319
301
  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
302
  const prev = role;
324
303
  role = msg.writer ? "writer" : "mirror";
325
304
  if (role === "mirror" && prev === "writer") {
@@ -357,10 +336,10 @@ export function attach(deps, session, opts = {}) {
357
336
  stdout.on("resize", onResize);
358
337
  stdin.resume();
359
338
  if (opts.signal?.aborted) {
360
- finish(0, "detached");
339
+ finish(0, "detached", "detached");
361
340
  return;
362
341
  }
363
- opts.signal?.addEventListener("abort", () => finish(0, "detached"), { once: true });
342
+ opts.signal?.addEventListener("abort", () => finish(0, "detached", "detached"), { once: true });
364
343
  void connect();
365
344
  });
366
345
  }
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
- import { readClipboardImage } from "./clipboard.js";
13
+ import { clipboardWriter, readClipboardImage, writeClipboard } 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,18 @@ 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
+ To copy, drag over text: it is on your clipboard the moment you let go (the session's tmux does the
48
+ selecting). Claude Code's own copy, like the c on its login URL, lands there too.
49
+
50
+ If the connection drops, nothing reconnects on its own: you are told why, and r reconnects.
51
+
52
+ In the split view: ⏎ or a click opens a session, Ctrl-] in a session jumps back to the list, l / m
53
+ show only live / your sessions, / finds, ←→ fold a session's children, q quits (sessions keep running).
54
+
41
55
  Attaching takes the keyboard from any browser tab on the same session (it becomes read-only) and
42
56
  fits the session to this window. --view watches without taking it.
43
57
 
44
- The tab is titled after the session, with its state: a spinner while it works, ✳ when idle, ✋ when it
58
+ The tab is titled after the session, with its state: ● while it works, ✳ when idle, ✋ when it
45
59
  is waiting for you - and a bell plus a desktop notification the moment it starts waiting (in Warp, its
46
60
  own agent status instead). --no-status turns all of that off.
47
61
 
@@ -182,46 +196,6 @@ async function cmdLs(a) {
182
196
  const color = !!process.stdout.isTTY && !process.env.NO_COLOR;
183
197
  process.stdout.write(formatSections(sectionize(rows), process.stdout.columns ?? 120, { color }) + "\n");
184
198
  }
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
199
  async function cmdAttach(a) {
226
200
  const s = currentSession({ org: str(a.flags, "org") });
227
201
  const api = makeApi(s);
@@ -243,9 +217,7 @@ async function cmdAttach(a) {
243
217
  }
244
218
  if (!isTTY())
245
219
  throw new CliError("attach needs an interactive terminal");
246
- row = await ensureRunning(api, row, !a.flags.has("no-resume"));
247
220
  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
221
  const ac = new AbortController();
250
222
  for (const sig of ["SIGTERM", "SIGHUP"])
251
223
  process.on(sig, () => ac.abort());
@@ -259,17 +231,51 @@ async function cmdAttach(a) {
259
231
  process.stderr.write(`cubes: ${e.message}\n`);
260
232
  process.exit(1);
261
233
  });
262
- const result = await attach({
263
- api,
264
- host: s.host,
265
- openWs: (url, protocols) => new WebSocket(url, protocols),
266
- stdin: process.stdin,
267
- stdout: process.stdout,
268
- stderr: process.stderr,
269
- readClipboardImage: () => readClipboardImage(),
270
- }, row, { view, signal: ac.signal, status: a.flags.has("no-status") ? false : undefined });
271
- process.stderr.write(`[cubes] ${result.message}\n`);
272
- return result.code;
234
+ // ⚠ ONE ATTACH PER DECISION. A drop is reported, not retried ("never keep retrying to reattach a
235
+ // session"): the person reads why and presses r to go again, or anything else to leave.
236
+ for (;;) {
237
+ row = await ensureRunning(api, row, !a.flags.has("no-resume"));
238
+ process.stderr.write(`Attaching to "${row.title ?? row.id}" (${row.id.slice(0, 8)})${view ? ", read-only" : ""}. Ctrl-] detaches.\n`);
239
+ const result = await attach({
240
+ api,
241
+ host: s.host,
242
+ openWs: (url, protocols) => new WebSocket(url, protocols),
243
+ stdin: process.stdin,
244
+ stdout: process.stdout,
245
+ stderr: process.stderr,
246
+ readClipboardImage: () => readClipboardImage(),
247
+ clipboard: copier(),
248
+ }, row, { view, signal: ac.signal, status: a.flags.has("no-status") ? false : undefined });
249
+ if (result.outcome !== "disconnected" || ac.signal.aborted) {
250
+ process.stderr.write(`[cubes] ${result.message}\n`);
251
+ return result.code;
252
+ }
253
+ process.stderr.write(`[cubes] disconnected: ${result.message}\n`);
254
+ if (!(await keyIs("Press r to reconnect, any other key to quit. ", ["r", "R", "\r"])))
255
+ return result.code;
256
+ row = await api.get(`/sessions/${encodeURIComponent(row.id)}`);
257
+ }
258
+ }
259
+ /** What the session copies goes to this machine's clipboard, when it has a way to write one. */
260
+ export function copier() {
261
+ const cmd = clipboardWriter();
262
+ return cmd ? { write: (text) => writeClipboard(cmd, text) } : null;
263
+ }
264
+ /** One keystroke, raw: true when it is one of `yes`. */
265
+ function keyIs(prompt, yes) {
266
+ if (!process.stdin.isTTY)
267
+ return Promise.resolve(false);
268
+ process.stderr.write(prompt);
269
+ return new Promise((resolve) => {
270
+ process.stdin.setRawMode(true);
271
+ process.stdin.resume();
272
+ process.stdin.once("data", (b) => {
273
+ process.stdin.setRawMode(false);
274
+ process.stdin.pause();
275
+ process.stderr.write("\n");
276
+ resolve(yes.includes(b.toString("utf8")));
277
+ });
278
+ });
273
279
  }
274
280
  async function main(argv) {
275
281
  if (typeof WebSocket === "undefined") {
@@ -280,6 +286,9 @@ async function main(argv) {
280
286
  process.stdout.write(`${VERSION}\n`);
281
287
  return 0;
282
288
  }
289
+ // No command at all, in a terminal: the split view. Piped or scripted, still the help.
290
+ if (!a.cmd && !a.flags.has("help") && isTTY())
291
+ return launchTui({ org: str(a.flags, "org") });
283
292
  if (!a.cmd || a.cmd === "help" || a.flags.has("help")) {
284
293
  process.stdout.write(HELP);
285
294
  return a.cmd || a.flags.has("help") ? 0 : 1;
@@ -304,6 +313,9 @@ async function main(argv) {
304
313
  return 0;
305
314
  case "attach":
306
315
  case "a": return cmdAttach(a);
316
+ // The split view's two panes (src/tui), started by tmux - not for typing by hand.
317
+ case "_list": return runList();
318
+ case "_pane": return runPane(a.pos[0]);
307
319
  case "upgrade":
308
320
  case "update":
309
321
  return upgrade({
package/dist/clipboard.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // macOS: AppleScript coerces whatever the pasteboard holds (a screenshot, an image copied from a
4
4
  // browser) to PNG and writes it to a temp file. Linux: wl-paste on Wayland, xclip on X11. Anything
5
5
  // else, or no image there, is null - and the caller then sends Ctrl-V through untouched.
6
- import { execFile } from "node:child_process";
6
+ import { execFile, spawn, spawnSync } from "node:child_process";
7
7
  import { mkdtempSync, readFileSync, rmSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
@@ -65,3 +65,34 @@ export async function readClipboardImage(platform = process.platform, env = proc
65
65
  return linux(env);
66
66
  return null;
67
67
  }
68
+ // ── WRITING: what the session copies (OSC 52) lands on THIS machine's clipboard ─────────────────────
69
+ /** The command that writes the clipboard here, or null when there is none to use. */
70
+ export function clipboardWriter(platform = process.platform, env = process.env) {
71
+ const has = (cmd) => spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "ignore" }).status === 0;
72
+ if (platform === "darwin")
73
+ return ["pbcopy"];
74
+ if (platform === "win32")
75
+ return ["clip"];
76
+ if (platform === "linux") {
77
+ if (env.WAYLAND_DISPLAY && has("wl-copy"))
78
+ return ["wl-copy"];
79
+ if (env.DISPLAY && has("xclip"))
80
+ return ["xclip", "-selection", "clipboard", "-in"];
81
+ if (env.DISPLAY && has("xsel"))
82
+ return ["xsel", "--clipboard", "--input"];
83
+ }
84
+ return null;
85
+ }
86
+ export function writeClipboard(cmd, text) {
87
+ return new Promise((resolve) => {
88
+ try {
89
+ const child = spawn(cmd[0], cmd.slice(1), { stdio: ["pipe", "ignore", "ignore"] });
90
+ child.on("error", () => resolve(false));
91
+ child.on("exit", (code) => resolve(code === 0));
92
+ child.stdin.end(text);
93
+ }
94
+ catch {
95
+ resolve(false);
96
+ }
97
+ });
98
+ }
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) {