@hellix-ai/cubes 0.2.2 → 0.3.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.
@@ -0,0 +1,166 @@
1
+ // Moving a conversation between this computer and Cubes - the bytes half of `cubes import` and
2
+ // `cubes export`. No prompts and no printing here: progress is a callback, failures are thrown.
3
+ //
4
+ // UP: the server takes a conversation in windows (`chunkBytes`, which it states - Cloudflare refuses a
5
+ // request body over 100 MiB, and conversations are routinely bigger), each at an exact offset, so a
6
+ // window whose response was lost is simply sent again and a window the server did not expect is
7
+ // answered with where it actually is. The file is checksummed first and the server verifies it once the
8
+ // last byte lands.
9
+ //
10
+ // DOWN: one streamed GET with an exact Content-Length. It is written next to its destination and only
11
+ // renamed into place once every byte has arrived, so an interrupted export never leaves half a
12
+ // conversation where `claude --resume` would find it.
13
+ import { createHash, randomBytes } from "node:crypto";
14
+ import { open, rm, stat, truncate } from "node:fs/promises";
15
+ import { dirname, join } from "node:path";
16
+ import { mkdir } from "node:fs/promises";
17
+ import { ApiError } from "./api.js";
18
+ import { CliError } from "./auth.js";
19
+ import { isConversationId } from "./local.js";
20
+ const WINDOW = 1024 * 1024;
21
+ /**
22
+ * How much of a conversation is WHOLE LINES right now. A conversation Claude Code is still writing on
23
+ * this computer can end mid-line; the upload stops at the last newline, and the bytes before it never
24
+ * change (the file is only ever appended to), so the checksum taken first still holds at the end.
25
+ */
26
+ export async function wholeLinesLength(path) {
27
+ const size = (await stat(path)).size;
28
+ const fh = await open(path, "r");
29
+ try {
30
+ const buf = Buffer.alloc(WINDOW);
31
+ for (let end = size; end > 0;) {
32
+ const from = Math.max(0, end - WINDOW);
33
+ const { bytesRead } = await fh.read(buf, 0, end - from, from);
34
+ const nl = buf.subarray(0, bytesRead).lastIndexOf(0x0a);
35
+ if (nl >= 0)
36
+ return from + nl + 1;
37
+ end = from;
38
+ }
39
+ return 0;
40
+ }
41
+ finally {
42
+ await fh.close().catch(() => { });
43
+ }
44
+ }
45
+ export async function sha256Of(path, len) {
46
+ const h = createHash("sha256");
47
+ const fh = await open(path, "r");
48
+ try {
49
+ const buf = Buffer.alloc(4 * WINDOW);
50
+ for (let at = 0; at < len;) {
51
+ const { bytesRead } = await fh.read(buf, 0, Math.min(buf.length, len - at), at);
52
+ if (!bytesRead)
53
+ throw new CliError(`${path} got shorter while it was being read`);
54
+ h.update(buf.subarray(0, bytesRead));
55
+ at += bytesRead;
56
+ }
57
+ }
58
+ finally {
59
+ await fh.close().catch(() => { });
60
+ }
61
+ return h.digest("hex");
62
+ }
63
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
64
+ /** Upload one conversation file and return the import id POST /api/sessions takes. */
65
+ export async function uploadConversation(api, conv, onProgress = () => { }, opts = {}) {
66
+ const bytes = await wholeLinesLength(conv.path);
67
+ if (!bytes)
68
+ throw new CliError(`${conv.path} has no complete line in it - there is nothing to import`);
69
+ const sha256 = await sha256Of(conv.path, bytes);
70
+ const begin = await api.post("/session-imports", {
71
+ conversationId: conv.id, bytes, sha256, sourceCwd: conv.cwd,
72
+ });
73
+ const id = begin.id;
74
+ const chunk = Math.max(1, begin.chunkBytes);
75
+ const retries = opts.retries ?? 4;
76
+ const fh = await open(conv.path, "r");
77
+ try {
78
+ let at = 0;
79
+ onProgress(0, bytes);
80
+ while (at < bytes) {
81
+ const n = Math.min(chunk, bytes - at);
82
+ const buf = Buffer.alloc(n);
83
+ const { bytesRead } = await fh.read(buf, 0, n, at);
84
+ if (bytesRead !== n)
85
+ throw new CliError(`${conv.path} got shorter while it was being uploaded`);
86
+ for (let attempt = 0;; attempt++) {
87
+ try {
88
+ const v = await api.put(`/session-imports/${encodeURIComponent(id)}/content?offset=${at}`, undefined, { raw: buf });
89
+ at = v.received;
90
+ break;
91
+ }
92
+ catch (e) {
93
+ // The server is somewhere else than we thought (a retried window it had already taken, or
94
+ // one it lost): carry on from where IT is.
95
+ const received = e instanceof ApiError && e.status === 409
96
+ ? e.body?.received : undefined;
97
+ if (typeof received === "number") {
98
+ at = received;
99
+ break;
100
+ }
101
+ // Only a network failure or a server error is worth repeating; a refusal is final.
102
+ const transient = !(e instanceof ApiError) || e.status >= 500;
103
+ if (!transient || attempt >= retries)
104
+ throw e;
105
+ await sleep((opts.retryDelayMs ?? 1000) * (attempt + 1));
106
+ }
107
+ }
108
+ onProgress(at, bytes);
109
+ }
110
+ return { importId: id, bytes };
111
+ }
112
+ catch (e) {
113
+ await api.del(`/session-imports/${encodeURIComponent(id)}`).catch(() => undefined);
114
+ throw e;
115
+ }
116
+ finally {
117
+ await fh.close().catch(() => { });
118
+ }
119
+ }
120
+ /**
121
+ * Download a session's conversation. `targetFor` names where it will end up once its id is known (the
122
+ * server says it in a header), so the temporary file is written in the same directory.
123
+ */
124
+ export async function downloadConversation(api, sessionId, targetFor, onProgress = () => { }) {
125
+ const res = await api.stream(`/sessions/${encodeURIComponent(sessionId)}/conversation`);
126
+ const conversationId = res.headers.get("x-conversation-id") ?? "";
127
+ if (!isConversationId(conversationId)) {
128
+ await res.body?.cancel().catch(() => undefined);
129
+ throw new CliError("the server did not say which conversation this is - is it older than this CLI?");
130
+ }
131
+ const total = Number(res.headers.get("content-length"));
132
+ const target = targetFor(conversationId);
133
+ await mkdir(dirname(target), { recursive: true });
134
+ const part = join(dirname(target), `.${conversationId}.cubes-export-${randomBytes(4).toString("hex")}.part`);
135
+ const fh = await open(part, "w", 0o600);
136
+ let got = 0;
137
+ try {
138
+ if (!res.body)
139
+ throw new CliError("the server sent no conversation");
140
+ onProgress(0, total);
141
+ for await (const piece of res.body) {
142
+ await fh.write(piece);
143
+ got += piece.length;
144
+ onProgress(got, total);
145
+ }
146
+ }
147
+ catch (e) {
148
+ await fh.close().catch(() => { });
149
+ await rm(part, { force: true });
150
+ throw e instanceof CliError ? e : new CliError(`the download was cut off after ${got} bytes: ${e.message}`);
151
+ }
152
+ await fh.close();
153
+ if (Number.isFinite(total) && got !== total) {
154
+ await rm(part, { force: true });
155
+ throw new CliError(`the download was cut off: ${got} of ${total} bytes arrived - nothing was written, try again`);
156
+ }
157
+ // The session may have been halfway through writing its last line when the snapshot was taken.
158
+ const whole = await wholeLinesLength(part);
159
+ if (whole !== got)
160
+ await truncate(part, whole);
161
+ if (!whole) {
162
+ await rm(part, { force: true });
163
+ throw new CliError("that session's conversation has no complete line in it yet");
164
+ }
165
+ return { conversationId, file: part, bytes: whole };
166
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hellix-ai/cubes",
3
- "version": "0.2.2",
4
- "description": "Attach to your Cubes sessions from your own terminal (Warp, iTerm, Ghostty, anything).",
3
+ "version": "0.3.0",
4
+ "description": "The Cubes command line: your Cubes sessions in your own terminal (attach, a split view of all of them, image paste, clipboard, tab status and notifications), and Claude Code conversations moved between your computer and Cubes (import, export).",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cubes": "dist/cli.js"
@@ -22,7 +22,13 @@
22
22
  "keywords": [
23
23
  "cubes",
24
24
  "claude",
25
+ "claude-code",
25
26
  "terminal",
27
+ "tmux",
28
+ "remote",
29
+ "sessions",
30
+ "import",
31
+ "export",
26
32
  "cli"
27
33
  ],
28
34
  "license": "UNLICENSED",