@hellix-ai/cubes 0.1.3 → 0.1.5

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
@@ -28,6 +28,14 @@ Every key goes to the session, exactly as if Claude Code were running locally, e
28
28
  takes it back, the terminal says so and goes read-only; detach and attach again to reclaim it.
29
29
  `cubes attach --view` watches without taking it.
30
30
 
31
+ **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
33
+ for you. The moment it starts waiting, the terminal also gets a bell (most terminals mark the tab or
34
+ bounce the dock) and a desktop notification with Claude's question (iTerm2, Ghostty, WezTerm, kitty and
35
+ others). In Warp, the same events Warp's own Claude Code plugin sends drive its tab status and
36
+ notifications instead. Muted sessions stay quiet. Your own title comes back when you detach.
37
+ `--no-status` turns all of it off.
38
+
31
39
  A stopped or hibernated session is resumed before attaching (`--no-resume` to refuse), with the
32
40
  spinning Cubes mark on screen until it is up. A dropped connection reconnects on its own and replays
33
41
  the screen.
@@ -43,7 +51,7 @@ cubes logout
43
51
  cubes whoami
44
52
  cubes org [ALIAS]
45
53
  cubes ls [--mine] [--live] [--all] [--json]
46
- cubes attach [SESSION] [--view] [--no-resume]
54
+ cubes attach [SESSION] [--view] [--no-resume] [--no-status]
47
55
  cubes upgrade [--check]
48
56
  ```
49
57
 
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). */
@@ -59,6 +60,45 @@ export function attach(deps, session, opts = {}) {
59
60
  // an image paste is uploading (so what is typed after Ctrl-V lands after the path, not before).
60
61
  let pending = [];
61
62
  let pasting = false;
63
+ // Our own sequences (title, bell, notifications) wait here until the session's stream is between
64
+ // escape sequences - see termstatus.ts. A fresh filter per connection: a replay starts clean.
65
+ let stream = new StreamFilter();
66
+ let injections = [];
67
+ let status = null;
68
+ let pollTimer = null;
69
+ let spinTimer = null;
70
+ const inject = (seq) => {
71
+ if (stream.safe)
72
+ stdout.write(seq);
73
+ else
74
+ injections.push(seq);
75
+ };
76
+ const flushInjections = () => {
77
+ if (!injections.length || !stream.safe)
78
+ return;
79
+ stdout.write(injections.join(""));
80
+ injections = [];
81
+ };
82
+ const startStatus = () => {
83
+ if (opts.status === false || status)
84
+ return;
85
+ const cfg = opts.status ?? {};
86
+ stdout.write(TITLE_PUSH);
87
+ status = new TermStatus({ status: "running", ...session }, inject, cfg.env);
88
+ status.update({ status: "running", ...session });
89
+ let inFlight = false;
90
+ pollTimer = setInterval(() => {
91
+ if (inFlight || done)
92
+ return;
93
+ inFlight = true;
94
+ api.get(`/sessions/${encodeURIComponent(session.id)}`)
95
+ .then((row) => { if (!done)
96
+ status?.update(row); })
97
+ .catch(() => { })
98
+ .finally(() => { inFlight = false; });
99
+ }, cfg.pollMs ?? 3000);
100
+ spinTimer = setInterval(() => { status?.tick(); flushInjections(); }, cfg.spinMs ?? 250);
101
+ };
62
102
  const note = (msg) => {
63
103
  stderr.write(entered ? `\r\n\x1b[2m[cubes] ${msg}\x1b[0m\r\n` : `[cubes] ${msg}\n`);
64
104
  };
@@ -69,6 +109,7 @@ export function attach(deps, session, opts = {}) {
69
109
  if (stdin.isTTY)
70
110
  stdin.setRawMode?.(true);
71
111
  stdout.write(ENTER_ALT);
112
+ startStatus();
72
113
  };
73
114
  const finish = (code, message) => {
74
115
  if (done)
@@ -76,6 +117,10 @@ export function attach(deps, session, opts = {}) {
76
117
  done = true;
77
118
  if (timer)
78
119
  clearTimeout(timer);
120
+ if (pollTimer)
121
+ clearInterval(pollTimer);
122
+ if (spinTimer)
123
+ clearInterval(spinTimer);
79
124
  stdin.off("data", onData);
80
125
  stdout.off("resize", onResize);
81
126
  const sock = ws;
@@ -89,7 +134,7 @@ export function attach(deps, session, opts = {}) {
89
134
  catch { /* already gone */ }
90
135
  }
91
136
  if (entered) {
92
- stdout.write(RESTORE_TERMINAL + LEAVE_ALT);
137
+ stdout.write(RESTORE_TERMINAL + (status ? TITLE_POP : "") + LEAVE_ALT);
93
138
  if (stdin.isTTY)
94
139
  stdin.setRawMode?.(false);
95
140
  }
@@ -247,6 +292,7 @@ export function attach(deps, session, opts = {}) {
247
292
  const sock = deps.openWs(wsUrlFor(deps.host, session.id), [TTY_SUBPROTOCOL, ticket]);
248
293
  sock.binaryType = "arraybuffer";
249
294
  ws = sock;
295
+ stream = new StreamFilter();
250
296
  askedTakeover = false;
251
297
  takeoverPending = false;
252
298
  sock.onopen = () => {
@@ -296,10 +342,12 @@ export function attach(deps, session, opts = {}) {
296
342
  }
297
343
  return;
298
344
  }
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));
345
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data)
346
+ : ArrayBuffer.isView(data) ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : null;
347
+ if (!bytes)
348
+ return;
349
+ stdout.write(stream.feed(bytes));
350
+ flushInjections();
303
351
  };
304
352
  sock.onclose = (ev) => { if (ws === sock)
305
353
  onClose(ev.code, ev.reason); };
package/dist/cli.js CHANGED
@@ -27,7 +27,8 @@ Usage:
27
27
  cubes org [ALIAS] list your orgs, or switch the default
28
28
  cubes ls [--mine] [--live] [--all] [--json] list sessions by group (--mine: you started them,
29
29
  --live: running now, --all: include spawned ones)
30
- cubes attach [SESSION] [--view] [--no-resume] attach to a session
30
+ cubes attach [SESSION] [--view] [--no-resume] [--no-status]
31
+ attach to a session
31
32
  cubes upgrade [--check] install the newest cubes (--check only reports)
32
33
 
33
34
  SESSION is an id, the start of one (4+ characters), or part of its title. Left out, you pick from
@@ -40,6 +41,10 @@ While attached, every key goes to the session except:
40
41
  Attaching takes the keyboard from any browser tab on the same session (it becomes read-only) and
41
42
  fits the session to this window. --view watches without taking it.
42
43
 
44
+ The tab is titled after the session, with its state: a spinner while it works, ✳ when idle, ✋ when it
45
+ is waiting for you - and a bell plus a desktop notification the moment it starts waiting (in Warp, its
46
+ own agent status instead). --no-status turns all of that off.
47
+
43
48
  Global: --org ALIAS overrides the default org for one command.
44
49
  Env: CUBES_HOST + CUBES_TOKEN (+ CUBES_ORG) use a token instead of the stored login.
45
50
  Config: ${configPath()}
@@ -262,7 +267,7 @@ async function cmdAttach(a) {
262
267
  stdout: process.stdout,
263
268
  stderr: process.stderr,
264
269
  readClipboardImage: () => readClipboardImage(),
265
- }, row, { view, signal: ac.signal });
270
+ }, row, { view, signal: ac.signal, status: a.flags.has("no-status") ? false : undefined });
266
271
  process.stderr.write(`[cubes] ${result.message}\n`);
267
272
  return result.code;
268
273
  }
@@ -0,0 +1,275 @@
1
+ // Telling YOUR terminal what the attached session is doing: the tab title, a "needs you" signal, and
2
+ // Warp's per-tab agent status.
3
+ //
4
+ // Running Claude Code locally, a terminal tab shows its title and whether it is working or waiting.
5
+ // Attached through `cubes`, the program on the other end is tmux in a pod, which says nothing about
6
+ // either. So the CLI says it, from the server's own activity signal (the hooks every session reports:
7
+ // working / ready / waiting-with-a-reason), in the three dialects terminals understand:
8
+ //
9
+ // • OSC 0 TITLE - every terminal. `<glyph> <session title>`, the convention Claude Code itself uses:
10
+ // a spinner while working, ✳ when idle, ✋ when it is waiting for you.
11
+ // • BEL + OSC 9 - the generic "this tab wants you": a bell marks the tab / bounces the dock almost
12
+ // everywhere, and OSC 9 raises a desktop notification in iTerm2, Ghostty, WezTerm, kitty, Warp.
13
+ // • OSC 777 `warp://cli-agent` - Warp's structured agent protocol, the same events and payload as
14
+ // warpdotdev/claude-code-warp sends from Claude Code's hooks (the remote program IS Claude Code).
15
+ // Gated exactly as that plugin gates it, and used INSTEAD of BEL+OSC 9 there, not as well.
16
+ //
17
+ // ⚠ NOTHING HERE MAY BE WRITTEN MID-SEQUENCE. The session's output arrives in arbitrary chunks, so a
18
+ // chunk can end halfway through an escape sequence or a UTF-8 character; a title written into that gap
19
+ // would corrupt both. `StreamFilter` tracks where the stream is, and the caller only writes ours when
20
+ // the stream is between sequences.
21
+ export const WARP_PLUGIN_COMPAT = "2.3.0"; // the claude-code-warp release whose event set this mirrors
22
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
23
+ const IDLE = "✳";
24
+ const WAITING = "✋";
25
+ /** Title push / pop (xterm window ops): our title is set on top of yours and yours comes back after. */
26
+ export const TITLE_PUSH = "\x1b[22;0t";
27
+ export const TITLE_POP = "\x1b]0;\x07\x1b[23;0t"; // clear first, for terminals with no title stack
28
+ /** Nothing the server hands us may reach the terminal as a control character. */
29
+ export function clean(s, max = 120) {
30
+ const t = s.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
31
+ return t.length > max ? t.slice(0, max - 1) + "…" : t;
32
+ }
33
+ /**
34
+ * Passes the session's output through, minus the two things that would fight us for the title -
35
+ * OSC 0/1/2 (set title) and CSI 22/23 t (title push/pop, which the session's tmux sends on attach and
36
+ * exit and which would unbalance our own push/pop) - and knows at every chunk boundary whether it is
37
+ * safe to write a sequence of our own.
38
+ */
39
+ export class StreamFilter {
40
+ st = "ground";
41
+ seq = []; // the CSI or OSC-prefix being decided
42
+ utf8Left = 0;
43
+ oscEsc = false; // saw ESC inside a string, waiting for '\'
44
+ get safe() {
45
+ return this.st === "ground" && this.utf8Left === 0;
46
+ }
47
+ feed(chunk) {
48
+ const out = [];
49
+ for (const b of chunk) {
50
+ switch (this.st) {
51
+ case "ground":
52
+ if (b === 0x1b) {
53
+ this.st = "esc";
54
+ this.seq = [b];
55
+ break;
56
+ }
57
+ if (this.utf8Left > 0 && (b & 0xc0) === 0x80)
58
+ this.utf8Left--;
59
+ else if ((b & 0xe0) === 0xc0)
60
+ this.utf8Left = 1;
61
+ else if ((b & 0xf0) === 0xe0)
62
+ this.utf8Left = 2;
63
+ else if ((b & 0xf8) === 0xf0)
64
+ this.utf8Left = 3;
65
+ else
66
+ this.utf8Left = 0;
67
+ out.push(b);
68
+ break;
69
+ case "esc":
70
+ if (b === 0x1b) {
71
+ out.push(0x1b);
72
+ this.seq = [b];
73
+ break;
74
+ } // a bare ESC, then a new sequence
75
+ this.seq.push(b);
76
+ if (b === 0x5b) {
77
+ this.st = "csi";
78
+ break;
79
+ } // [
80
+ if (b === 0x5d) {
81
+ this.st = "osc";
82
+ break;
83
+ } // ]
84
+ if (b === 0x50 || b === 0x58 || b === 0x5e || b === 0x5f) { // P X ^ _ : DCS SOS PM APC
85
+ out.push(...this.seq);
86
+ this.st = "str";
87
+ this.oscEsc = false;
88
+ break;
89
+ }
90
+ if (b >= 0x20 && b <= 0x2f)
91
+ break; // intermediate bytes, the final follows
92
+ out.push(...this.seq);
93
+ this.st = "ground";
94
+ break;
95
+ case "csi":
96
+ this.seq.push(b);
97
+ if (b >= 0x40 && b <= 0x7e) {
98
+ const body = String.fromCharCode(...this.seq.slice(2, -1));
99
+ const drop = b === 0x74 && /^2[23](;|$)/.test(body); // CSI 22/23 ... t
100
+ if (!drop)
101
+ out.push(...this.seq);
102
+ this.st = "ground";
103
+ }
104
+ else if (this.seq.length > 64) { // not a CSI anyone sends; stop holding it
105
+ out.push(...this.seq);
106
+ this.st = "ground";
107
+ }
108
+ break;
109
+ case "osc": {
110
+ // Hold only until the number is known: 0/1/2 is a title and is dropped, anything else
111
+ // (hyperlinks, clipboard, notifications) streams through untouched.
112
+ const ends = b === 0x07 || b === 0x1b;
113
+ if (b === 0x3b || ends || this.seq.length > 8) {
114
+ const num = String.fromCharCode(...this.seq.slice(2));
115
+ const title = /^[012]$/.test(num);
116
+ if (!title)
117
+ out.push(...this.seq);
118
+ this.seq = [];
119
+ if (ends) {
120
+ if (b === 0x07) {
121
+ if (!title)
122
+ out.push(b);
123
+ this.st = "ground";
124
+ }
125
+ else {
126
+ if (!title)
127
+ out.push(b);
128
+ this.st = title ? "oscDrop" : "str";
129
+ this.oscEsc = true;
130
+ }
131
+ }
132
+ else {
133
+ if (!title)
134
+ out.push(b);
135
+ this.st = title ? "oscDrop" : "str";
136
+ this.oscEsc = false;
137
+ }
138
+ }
139
+ else {
140
+ this.seq.push(b);
141
+ }
142
+ break;
143
+ }
144
+ case "oscDrop":
145
+ case "str": {
146
+ const keep = this.st === "str";
147
+ if (keep)
148
+ out.push(b);
149
+ if (this.oscEsc && b === 0x5c) {
150
+ this.st = "ground";
151
+ this.oscEsc = false;
152
+ break;
153
+ } // ST = ESC \
154
+ this.oscEsc = b === 0x1b;
155
+ if (b === 0x07 && !this.oscEsc)
156
+ this.st = "ground"; // BEL ends an OSC (and is harmless in DCS)
157
+ break;
158
+ }
159
+ }
160
+ }
161
+ return Uint8Array.from(out);
162
+ }
163
+ }
164
+ function stateOf(r) {
165
+ if (r.status !== "running")
166
+ return "idle";
167
+ if (r.activity_status === "working")
168
+ return "working";
169
+ if (r.activity_status === "waiting")
170
+ return "waiting";
171
+ return "idle";
172
+ }
173
+ /** Warp's structured notifications, exactly as warpdotdev/claude-code-warp decides it may use them. */
174
+ export function warpProtocol(env) {
175
+ const v = Number.parseInt(env.WARP_CLI_AGENT_PROTOCOL_VERSION ?? "", 10);
176
+ if (!Number.isFinite(v) || v < 1)
177
+ return null;
178
+ const client = env.WARP_CLIENT_VERSION ?? "";
179
+ if (!client)
180
+ return null;
181
+ // Builds that advertised the protocol before they could render it.
182
+ const broken = client.includes("stable") ? "v0.2026.03.25.08.24.stable_05"
183
+ : client.includes("preview") ? "v0.2026.03.25.08.24.preview_05" : "";
184
+ if (broken && !(client > broken))
185
+ return null;
186
+ return Math.min(1, v);
187
+ }
188
+ /**
189
+ * Turns successive reads of the session row into terminal sequences. `emit` must deliver them only at
190
+ * a safe point in the stream (see StreamFilter); this class only decides WHAT to say and WHEN.
191
+ */
192
+ export class TermStatus {
193
+ emit;
194
+ state = null;
195
+ frame = 0;
196
+ shown = "";
197
+ row;
198
+ warp;
199
+ constructor(row, emit, env = process.env) {
200
+ this.emit = emit;
201
+ this.row = row;
202
+ this.warp = warpProtocol(env);
203
+ }
204
+ title() {
205
+ const glyph = this.state === "working" ? SPINNER[this.frame % SPINNER.length]
206
+ : this.state === "waiting" ? WAITING : IDLE;
207
+ return `${glyph} ${clean(this.row.title || "Cubes session", 80)}`;
208
+ }
209
+ /** Only when it changed: the activity is re-read every few seconds, and most reads change nothing. */
210
+ setTitle() {
211
+ const t = this.title();
212
+ if (t === this.shown)
213
+ return;
214
+ this.shown = t;
215
+ this.emit(`\x1b]0;${t}\x07`);
216
+ }
217
+ warpEvent(event, extra = {}) {
218
+ if (this.warp === null)
219
+ return;
220
+ const body = JSON.stringify({
221
+ v: this.warp, agent: "claude", event, session_id: this.row.id, cwd: "",
222
+ project: clean(this.row.title || "cubes", 60), ...extra,
223
+ });
224
+ this.emit(`\x1b]777;notify;warp://cli-agent;${body}\x07`);
225
+ }
226
+ /** Feed the latest row. The first call only establishes the state - nobody needs to be told that a
227
+ * session they are attaching to right now is waiting for them. */
228
+ update(row) {
229
+ this.row = row;
230
+ const next = stateOf(row);
231
+ const first = this.state === null;
232
+ const prev = this.state;
233
+ // Only the step INTO waiting. Claude Code reports one wait in several hook events (measured: the
234
+ // question itself, then "Claude needs your permission" for the same question a moment later), and
235
+ // each moves activity_at, so keying on it rang twice for one thing to answer.
236
+ const newWait = next === "waiting" && prev !== "waiting";
237
+ this.state = next;
238
+ this.setTitle();
239
+ if (first) {
240
+ this.warpEvent("session_start", { plugin_version: WARP_PLUGIN_COMPAT });
241
+ if (next === "working")
242
+ this.warpEvent("prompt_submit", { query: "" });
243
+ return;
244
+ }
245
+ if (next === "working" && prev !== "working")
246
+ this.warpEvent("prompt_submit", { query: "" });
247
+ if (next === "idle" && prev === "working") {
248
+ this.warpEvent("stop", { query: "", response: clean(row.activity_message ?? "", 200), transcript_path: "" });
249
+ }
250
+ if (newWait) {
251
+ const summary = clean(row.activity_message || "Waiting for you", 120);
252
+ if (this.warp !== null) {
253
+ if (row.activity_reason === "permission") {
254
+ this.warpEvent("permission_request", { summary, tool_name: "", tool_input: {} });
255
+ }
256
+ else {
257
+ this.warpEvent("idle_prompt", { summary });
258
+ }
259
+ }
260
+ else if (!row.muted_at) {
261
+ // Bell first (marks the tab nearly everywhere), then the desktop notification where supported.
262
+ // "Cubes" leads the text so it can never be read as ConEmu's numeric OSC 9 sub-commands.
263
+ this.emit(`\x07\x1b]9;Cubes · ${clean(row.title || "session", 60)}: ${summary}\x07`);
264
+ }
265
+ }
266
+ }
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
+ }
package/dist/upgrade.js CHANGED
@@ -37,12 +37,19 @@ export function installerFor(scriptPath) {
37
37
  return "yarn";
38
38
  return "npm";
39
39
  }
40
+ /**
41
+ * ⚠ FORCE A FRESH LOOK AT THE REGISTRY. npm keeps a local copy of a package's version list and trusts
42
+ * it for the registry's `max-age=300`, so within five minutes of any earlier install it does not know a
43
+ * release we just read from `/latest` exists: measured as `ETARGET No matching version found for
44
+ * @hellix-ai/cubes@0.1.3` four minutes after 0.1.3 was published. `--prefer-online` makes npm revalidate
45
+ * that copy; bun's `--no-cache` does the same for its manifest cache. pnpm and yarn revalidate already.
46
+ */
40
47
  export function installCommand(installer, spec) {
41
48
  switch (installer) {
42
49
  case "pnpm": return ["pnpm", "add", "-g", spec];
43
- case "bun": return ["bun", "add", "-g", spec];
50
+ case "bun": return ["bun", "add", "-g", "--no-cache", spec];
44
51
  case "yarn": return ["yarn", "global", "add", spec];
45
- default: return ["npm", "install", "-g", spec];
52
+ default: return ["npm", "install", "-g", "--prefer-online", spec];
46
53
  }
47
54
  }
48
55
  export async function latestVersion(pkg, fetchImpl = fetch) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellix-ai/cubes",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Attach to your Cubes sessions from your own terminal (Warp, iTerm, Ghostty, anything).",
5
5
  "type": "module",
6
6
  "bin": {