@shardflux/cli 0.18.1 → 0.19.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +27 -0
  3. package/dist/account-args.js +1 -0
  4. package/dist/agent-commands.d.ts +1 -0
  5. package/dist/agent-commands.js +15 -5
  6. package/dist/agent-config-files.d.ts +16 -2
  7. package/dist/agent-config-files.js +48 -61
  8. package/dist/agent-config-sync.d.ts +12 -1
  9. package/dist/agent-config-sync.js +228 -54
  10. package/dist/agent-repository.d.ts +39 -9
  11. package/dist/agent-repository.js +309 -92
  12. package/dist/agent-state.d.ts +3 -0
  13. package/dist/agent-state.js +13 -0
  14. package/dist/agent-target-commands.d.ts +2 -0
  15. package/dist/agent-target-commands.js +45 -15
  16. package/dist/args.d.ts +1 -1
  17. package/dist/args.js +14 -2
  18. package/dist/conversation-router.js +7 -11
  19. package/dist/conversation-stash.js +64 -18
  20. package/dist/conversation-terminal.d.ts +1 -1
  21. package/dist/conversation-terminal.js +9 -10
  22. package/dist/conversations.d.ts +9 -2
  23. package/dist/conversations.js +23 -6
  24. package/dist/errors.d.ts +1 -1
  25. package/dist/errors.js +1 -1
  26. package/dist/files-mirror.d.ts +1 -9
  27. package/dist/files-mirror.js +19 -3
  28. package/dist/format.js +37 -2
  29. package/dist/handlers.js +3 -1
  30. package/dist/initial-prompt.d.ts +15 -2
  31. package/dist/initial-prompt.js +29 -4
  32. package/dist/local-sessions.d.ts +60 -0
  33. package/dist/local-sessions.js +445 -0
  34. package/dist/main.js +12 -0
  35. package/dist/move.d.ts +44 -0
  36. package/dist/move.js +248 -0
  37. package/dist/moved-sessions.d.ts +33 -0
  38. package/dist/moved-sessions.js +91 -0
  39. package/dist/onboarding.d.ts +18 -3
  40. package/dist/onboarding.js +72 -46
  41. package/dist/project-sync-manifest.d.ts +31 -3
  42. package/dist/project-sync-manifest.js +192 -108
  43. package/dist/project-sync-transfer.d.ts +50 -4
  44. package/dist/project-sync-transfer.js +404 -106
  45. package/dist/project-sync.js +30 -33
  46. package/dist/seed-phases.d.ts +29 -0
  47. package/dist/seed-phases.js +83 -0
  48. package/dist/t3-mode.d.ts +7 -2
  49. package/dist/t3-mode.js +39 -5
  50. package/dist/thread-on-computer.js +4 -0
  51. package/dist/worktree-twin.d.ts +2 -6
  52. package/dist/worktree-twin.js +16 -51
  53. package/package.json +5 -5
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Claude Code and Codex sessions on this computer, for `shard move` (0.19.0+): which ones belong to a project, whether
3
+ * a turn is running, and their transcripts with paths rewritten for another folder.
4
+ *
5
+ * Ground truth (claude 2.1.296, codex 0.160.0; docs/progress/move-to-cloud-evidence/ground-truth.md):
6
+ * - Claude writes <config>/projects/<folder name>/<session id>.jsonl, the folder name being the working directory with
7
+ * every character but letters and digits replaced by "-" (cut at 200, plus a hash, when longer). A running session
8
+ * is <config>/sessions/<pid>.json with its sessionId and `status` (busy during a turn, idle between turns). A turn
9
+ * ends with a `system`/`turn_duration` record, an interrupt with "[Request interrupted by user…]", and a print-mode
10
+ * turn with an assistant message whose stop_reason is end_turn.
11
+ * - Codex writes <CODEX_HOME>/sessions/YYYY/MM/DD/rollout-<time>-<thread id>.jsonl, first line session_meta (cwd).
12
+ * A turn starts with event_msg task_started and ends with task_complete or turn_aborted. While a process has the
13
+ * thread open it holds an flock on <CODEX_HOME>/thread-writer-locks/<thread id>.lock.
14
+ * Claude Desktop's Code tab and the Codex app write to the same stores.
15
+ */
16
+ import { spawnSync } from 'node:child_process';
17
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { basename, join } from 'node:path';
20
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
21
+ const ROLLOUT = /^rollout-\d{4}-\d{2}-\d{2}T[0-9-]+-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
22
+ export const isSessionId = (id) => UUID.test(id);
23
+ export const claudeConfigDir = (env) => env.CLAUDE_CONFIG_DIR || join(env.HOME || homedir(), '.claude');
24
+ export const codexHome = (env) => env.CODEX_HOME || join(env.HOME || homedir(), '.codex');
25
+ /** Java's String.hashCode, which Claude Code uses for the suffix of a long folder name. */
26
+ function javaHash(text) {
27
+ let h = 0;
28
+ for (let i = 0; i < text.length; i++)
29
+ h = (Math.imul(31, h) + text.charCodeAt(i)) | 0;
30
+ return h;
31
+ }
32
+ /** Claude Code's folder name under projects/ for a working directory (measured: a 268-character path → 200 + "-1qkjv5"). */
33
+ export function claudeProjectName(cwd) {
34
+ const name = cwd.replace(/[^a-zA-Z0-9]/g, '-');
35
+ return name.length <= 200 ? name : `${name.slice(0, 200)}-${Math.abs(javaHash(cwd)).toString(36)}`;
36
+ }
37
+ // ---------------------------------------------------------------------------------------------------------------
38
+ // Reading JSONL heads and tails
39
+ function readRange(file, start, length) {
40
+ const fd = openSync(file, 'r');
41
+ try {
42
+ const out = Buffer.alloc(length);
43
+ let at = 0;
44
+ while (at < length) {
45
+ const n = readSync(fd, out, at, length - at, start + at);
46
+ if (n <= 0)
47
+ break;
48
+ at += n;
49
+ }
50
+ return out.subarray(0, at);
51
+ }
52
+ finally {
53
+ closeSync(fd);
54
+ }
55
+ }
56
+ function parseLines(text) {
57
+ const out = [];
58
+ for (const line of text.split('\n')) {
59
+ if (!line.trim())
60
+ continue;
61
+ try {
62
+ const v = JSON.parse(line);
63
+ if (v && typeof v === 'object' && !Array.isArray(v))
64
+ out.push(v);
65
+ }
66
+ catch { /* a partial line */ }
67
+ }
68
+ return out;
69
+ }
70
+ /** The first `max` bytes' complete records. */
71
+ function head(file, max = 256 << 10) {
72
+ const bytes = readRange(file, 0, max);
73
+ const text = bytes.toString('utf8');
74
+ return parseLines(bytes.length < max ? text : text.slice(0, text.lastIndexOf('\n') + 1));
75
+ }
76
+ /** The last `max` bytes' complete records (a cut first line is dropped). */
77
+ function tail(file, size, max = 512 << 10) {
78
+ const start = Math.max(0, size - max);
79
+ const text = readRange(file, start, size - start).toString('utf8');
80
+ return parseLines(start === 0 ? text : text.slice(text.indexOf('\n') + 1));
81
+ }
82
+ /** Records appended after `offset` (whole lines only). */
83
+ export function recordsAfter(file, offset) {
84
+ const size = statSync(file).size;
85
+ if (size <= offset)
86
+ return [];
87
+ return parseLines(readRange(file, offset, size - offset).toString('utf8'));
88
+ }
89
+ const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
90
+ const str = (v) => (typeof v === 'string' ? v : '');
91
+ const oneLine = (text, max = 80) => {
92
+ const line = text.replace(/\s+/g, ' ').trim();
93
+ return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
94
+ };
95
+ // ---------------------------------------------------------------------------------------------------------------
96
+ // Claude
97
+ function claudeText(content) {
98
+ if (typeof content === 'string')
99
+ return content;
100
+ if (Array.isArray(content))
101
+ return content.map(c => (obj(c).type === 'text' ? str(obj(c).text) : '')).join(' ');
102
+ return '';
103
+ }
104
+ /** A user record that starts a model turn: a typed prompt, not a tool result, a meta or a local slash command record. */
105
+ function claudePrompt(r) {
106
+ if (r.type !== 'user' || r.isMeta === true || r.isSidechain === true)
107
+ return false;
108
+ const content = obj(r.message).content;
109
+ if (Array.isArray(content) && content.some(c => obj(c).type === 'tool_result'))
110
+ return false;
111
+ const text = claudeText(content).trimStart();
112
+ return !!text && !text.startsWith('<command-name>') && !text.startsWith('<local-command') && !text.startsWith('[Request interrupted by user');
113
+ }
114
+ /** Whether the transcript's last turn is still running, from its records (see the module comment). */
115
+ export function claudeTurnFromRecords(records) {
116
+ for (let i = records.length - 1; i >= 0; i--) {
117
+ const r = records[i];
118
+ if (r.isSidechain === true)
119
+ continue;
120
+ if (r.type === 'system') {
121
+ if (r.subtype === 'turn_duration')
122
+ return 'idle';
123
+ continue;
124
+ }
125
+ if (r.type === 'assistant') {
126
+ const m = obj(r.message);
127
+ if (r.isApiErrorMessage === true)
128
+ return 'idle';
129
+ const blocks = Array.isArray(m.content) ? m.content : [];
130
+ if (m.stop_reason === 'tool_use' || blocks.some(b => obj(b).type === 'tool_use'))
131
+ return 'working';
132
+ return ['end_turn', 'stop_sequence', 'max_tokens', 'refusal'].includes(str(m.stop_reason)) ? 'idle' : 'working';
133
+ }
134
+ if (r.type === 'user') {
135
+ const content = obj(r.message).content;
136
+ if (Array.isArray(content) && content.some(c => obj(c).type === 'tool_result'))
137
+ return 'working';
138
+ if (claudeText(content).trimStart().startsWith('[Request interrupted by user'))
139
+ return 'idle';
140
+ return claudePrompt(r) ? 'working' : 'idle';
141
+ }
142
+ }
143
+ return 'idle';
144
+ }
145
+ function claudeSession(file) {
146
+ const id = basename(file, '.jsonl');
147
+ if (!UUID.test(id))
148
+ return null;
149
+ const st = statSync(file);
150
+ const first = head(file), last = tail(file, st.size);
151
+ const prompt = [...first, ...last].find(claudePrompt);
152
+ if (!prompt)
153
+ return null;
154
+ const titled = [...last].reverse().find(r => (r.type === 'custom-title' && str(r.customTitle)) || (r.type === 'ai-title' && str(r.aiTitle)) || (r.type === 'summary' && str(r.summary)));
155
+ const title = titled ? str(titled.customTitle) || str(titled.aiTitle) || str(titled.summary) : claudeText(obj(prompt.message).content);
156
+ // Where the session started: its folder (a later `cd` in a tool call moves later records' cwd, not the session).
157
+ const started = [...first, ...last].find(r => str(r.cwd)) ?? prompt;
158
+ return { harness: 'claude', session_id: id, file, cwd: str(started.cwd), title: oneLine(title), updated_at: st.mtime.toISOString(), bytes: st.size,
159
+ source: str(prompt.entrypoint) || 'cli' };
160
+ }
161
+ function claudeProcesses(env) {
162
+ const dir = join(claudeConfigDir(env), 'sessions');
163
+ if (!existsSync(dir))
164
+ return null;
165
+ const out = [];
166
+ for (const name of readdirSync(dir)) {
167
+ if (!/^\d+\.json$/.test(name))
168
+ continue;
169
+ try {
170
+ const r = JSON.parse(readFileSync(join(dir, name), 'utf8'));
171
+ if (Number.isInteger(r.pid) && r.pid > 0 && typeof r.sessionId === 'string' && alive(r.pid))
172
+ out.push(r);
173
+ }
174
+ catch { /* being rewritten */ }
175
+ }
176
+ return out;
177
+ }
178
+ function alive(pid) {
179
+ try {
180
+ process.kill(pid, 0);
181
+ return true;
182
+ }
183
+ catch (e) {
184
+ return e.code === 'EPERM';
185
+ }
186
+ }
187
+ // ---------------------------------------------------------------------------------------------------------------
188
+ // Codex
189
+ function codexPrompt(r) {
190
+ const p = obj(r.payload);
191
+ if (r.type !== 'event_msg')
192
+ return '';
193
+ if (p.type === 'user_message')
194
+ return str(p.message);
195
+ const item = obj(p.item);
196
+ if (p.type === 'item_completed' && (item.type === 'UserMessage' || item.type === 'userMessage') && Array.isArray(item.content))
197
+ return item.content.map(c => str(obj(c).text)).join(' ');
198
+ return '';
199
+ }
200
+ export function codexTurnFromRecords(records) {
201
+ for (let i = records.length - 1; i >= 0; i--) {
202
+ const r = records[i], p = obj(r.payload);
203
+ if (r.type !== 'event_msg')
204
+ continue;
205
+ if (p.type === 'task_started')
206
+ return 'working';
207
+ if (p.type === 'task_complete' || p.type === 'turn_aborted')
208
+ return 'idle';
209
+ }
210
+ return 'idle';
211
+ }
212
+ function codexSession(file, metaOnly) {
213
+ const match = ROLLOUT.exec(basename(file));
214
+ if (!match)
215
+ return null;
216
+ const st = statSync(file);
217
+ const first = head(file);
218
+ const meta = first.find(r => r.type === 'session_meta');
219
+ const cwd = str(obj(meta?.payload).cwd);
220
+ if (!meta || !cwd || (metaOnly && !metaOnly(cwd)))
221
+ return null;
222
+ const last = tail(file, st.size);
223
+ const prompt = [...first, ...last].map(codexPrompt).find(Boolean);
224
+ if (!prompt)
225
+ return null;
226
+ return { harness: 'codex', session_id: match[1], file, cwd, title: oneLine(prompt), updated_at: st.mtime.toISOString(), bytes: st.size,
227
+ source: str(obj(meta.payload).originator) || 'codex' };
228
+ }
229
+ /** Every rollout file, newest first (one stat each; heads are read only for the ones a caller looks at). */
230
+ function codexRollouts(env) {
231
+ const root = join(codexHome(env), 'sessions');
232
+ const out = [];
233
+ const walk = (dir, depth) => {
234
+ let names;
235
+ try {
236
+ names = readdirSync(dir);
237
+ }
238
+ catch {
239
+ return;
240
+ }
241
+ for (const name of names) {
242
+ const path = join(dir, name);
243
+ if (depth < 3) {
244
+ if (/^\d+$/.test(name))
245
+ walk(path, depth + 1);
246
+ continue;
247
+ }
248
+ if (!ROLLOUT.test(name))
249
+ continue;
250
+ try {
251
+ out.push({ file: path, mtime: statSync(path).mtimeMs });
252
+ }
253
+ catch { /* removed meanwhile */ }
254
+ }
255
+ };
256
+ walk(root, 0);
257
+ return out.sort((a, b) => b.mtime - a.mtime);
258
+ }
259
+ /** perl's flock(LOCK_EX|LOCK_NB) on the thread's writer lock: held while a Codex process has the thread open. */
260
+ function codexRunning(env, id) {
261
+ const lock = join(codexHome(env), 'thread-writer-locks', `${id}.lock`);
262
+ if (!existsSync(lock))
263
+ return false;
264
+ const r = spawnSync('perl', ['-e', 'use Fcntl qw(:flock); open(my $f, "<", $ARGV[0]) or exit 2; exit(flock($f, LOCK_EX|LOCK_NB) ? 0 : 1)', lock], { stdio: 'ignore', timeout: 5000 });
265
+ return r.status === 1;
266
+ }
267
+ // ---------------------------------------------------------------------------------------------------------------
268
+ // Listing, finding, state
269
+ const within = (folder) => (cwd) => cwd === folder || cwd.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
270
+ const real = (path) => { try {
271
+ return realpathSync(path);
272
+ }
273
+ catch {
274
+ return path;
275
+ } };
276
+ /** A project's sessions on this computer, newest first: those started in the folder or below it. */
277
+ export function listLocalSessions(folder, env, options = {}) {
278
+ const root = real(folder), limit = options.limit ?? 10, inside = within(root), out = [];
279
+ if (options.harness !== 'codex') {
280
+ const projects = join(claudeConfigDir(env), 'projects'), exact = claudeProjectName(root);
281
+ let dirs = [];
282
+ try {
283
+ dirs = readdirSync(projects).filter(d => d === exact || d.startsWith(`${exact}-`));
284
+ }
285
+ catch { /* no Claude sessions */ }
286
+ const files = [];
287
+ for (const dir of dirs) {
288
+ let names;
289
+ try {
290
+ names = readdirSync(join(projects, dir));
291
+ }
292
+ catch {
293
+ continue;
294
+ }
295
+ for (const name of names)
296
+ if (name.endsWith('.jsonl')) {
297
+ const file = join(projects, dir, name);
298
+ try {
299
+ files.push({ file, mtime: statSync(file).mtimeMs });
300
+ }
301
+ catch { /* removed */ }
302
+ }
303
+ }
304
+ let found = 0;
305
+ for (const { file } of files.sort((a, b) => b.mtime - a.mtime)) {
306
+ if (found >= limit)
307
+ break;
308
+ const s = claudeSession(file);
309
+ // A folder named like another's prefix ("app" and "app-web") is told apart by the recorded cwd.
310
+ if (s && inside(real(s.cwd))) {
311
+ out.push(s);
312
+ found++;
313
+ }
314
+ }
315
+ }
316
+ if (options.harness !== 'claude') {
317
+ let found = 0;
318
+ for (const { file } of codexRollouts(env)) {
319
+ if (found >= limit)
320
+ break;
321
+ const s = codexSession(file, cwd => inside(real(cwd)));
322
+ if (s) {
323
+ out.push(s);
324
+ found++;
325
+ }
326
+ }
327
+ }
328
+ return out.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
329
+ }
330
+ /** One session by id, wherever it was started. */
331
+ export function findLocalSession(id, env) {
332
+ if (!UUID.test(id))
333
+ return null;
334
+ const projects = join(claudeConfigDir(env), 'projects');
335
+ let dirs = [];
336
+ try {
337
+ dirs = readdirSync(projects);
338
+ }
339
+ catch { /* none */ }
340
+ for (const dir of dirs) {
341
+ const file = join(projects, dir, `${id}.jsonl`);
342
+ if (existsSync(file))
343
+ return claudeSession(file);
344
+ }
345
+ for (const { file } of codexRollouts(env))
346
+ if (ROLLOUT.exec(basename(file))?.[1] === id)
347
+ return codexSession(file);
348
+ return null;
349
+ }
350
+ /** Whether a turn is running now. A session no process has open is between turns, whatever its last record says. */
351
+ export function sessionState(s, env, now = Date.now()) {
352
+ const size = statSync(s.file).size;
353
+ if (s.harness === 'claude') {
354
+ const fromRecords = claudeTurnFromRecords(tail(s.file, size));
355
+ const processes = claudeProcesses(env);
356
+ if (processes === null) {
357
+ // An older Claude Code keeps no session registry: the transcript decides, and a long-silent one has stopped.
358
+ const quiet = now - statSync(s.file).mtimeMs > 15 * 60_000;
359
+ return { running: !quiet, turn: quiet ? 'idle' : fromRecords };
360
+ }
361
+ const proc = processes.find(p => p.sessionId === s.session_id);
362
+ if (!proc)
363
+ return { running: false, turn: 'idle' };
364
+ return { running: true, pid: proc.pid, turn: proc.status === 'busy' || fromRecords === 'working' ? 'working' : 'idle' };
365
+ }
366
+ const running = codexRunning(env, s.session_id);
367
+ return { running, turn: running ? codexTurnFromRecords(tail(s.file, size)) : 'idle' };
368
+ }
369
+ /** Whether someone typed in the local copy after it moved: a new prompt or turn after `offset`. */
370
+ export function continuedAfter(s, offset) {
371
+ let records;
372
+ try {
373
+ records = recordsAfter(s.file, offset);
374
+ }
375
+ catch {
376
+ return false;
377
+ }
378
+ return records.some(r => s.harness === 'claude' ? claudePrompt(r) : !!codexPrompt(r) || (r.type === 'event_msg' && obj(r.payload).type === 'task_started'));
379
+ }
380
+ // ---------------------------------------------------------------------------------------------------------------
381
+ // Moving a transcript
382
+ /**
383
+ * The transcript as whole lines, cut after its last complete record: a harness may be appending a record while a
384
+ * move reads it (between turns that is metadata, never part of a turn).
385
+ */
386
+ export function completeRecords(bytes) {
387
+ const end = bytes.lastIndexOf(0x0a);
388
+ return end < 0 ? Buffer.alloc(0) : bytes.subarray(0, end + 1);
389
+ }
390
+ /**
391
+ * The session's records with its working directory moved from `from` to `to`: Claude's and Codex's `cwd` fields (and
392
+ * Codex's workdir and workspace roots). Lines that do not mention `from` keep their exact bytes; message text,
393
+ * tool output and signed thinking are never changed.
394
+ */
395
+ export function rewriteSessionPaths(bytes, from, to) {
396
+ if (from === to)
397
+ return bytes;
398
+ const needles = [from, JSON.stringify(from).slice(1, -1)];
399
+ const move = (path) => (path === from || path.startsWith(`${from}/`) ? to + path.slice(from.length) : path);
400
+ const rewrite = (value) => {
401
+ if (Array.isArray(value))
402
+ return value.map(rewrite);
403
+ if (!value || typeof value !== 'object')
404
+ return value;
405
+ return Object.fromEntries(Object.entries(value).map(([key, leaf]) => {
406
+ if (typeof leaf === 'string' && ['cwd', 'workdir', 'working_directory'].includes(key))
407
+ return [key, move(leaf)];
408
+ if (Array.isArray(leaf) && ['workspace_roots', 'runtime_workspace_roots', 'writable_roots'].includes(key))
409
+ return [key, leaf.map(v => (typeof v === 'string' ? move(v) : rewrite(v)))];
410
+ return [key, rewrite(leaf)];
411
+ }));
412
+ };
413
+ const lines = bytes.toString('utf8').split('\n');
414
+ return Buffer.from(lines.map(line => {
415
+ if (!line || !needles.some(n => line.includes(n)))
416
+ return line;
417
+ try {
418
+ return JSON.stringify(rewrite(JSON.parse(line)));
419
+ }
420
+ catch {
421
+ return line;
422
+ }
423
+ }).join('\n'), 'utf8');
424
+ }
425
+ /** Where the session's transcript lives under a home folder, relative to it (the VM's and the project space's layout). */
426
+ export function homeRelativeTranscript(s, cwd) {
427
+ if (s.harness === 'claude')
428
+ return join('.claude', 'projects', claudeProjectName(cwd), `${s.session_id}.jsonl`);
429
+ const parts = s.file.split('/').slice(-4);
430
+ if (parts.length !== 4 || !/^\d{4}$/.test(parts[0]) || !/^\d{2}$/.test(parts[1]) || !/^\d{2}$/.test(parts[2]))
431
+ throw new Error('Codex rollout is outside sessions/YYYY/MM/DD.');
432
+ return join('.codex', 'sessions', ...parts);
433
+ }
434
+ /** The transcript file is a regular file: never follow a link out of the harness's store. */
435
+ export function readTranscript(file) {
436
+ const fd = openSync(file, 'r');
437
+ try {
438
+ if (!fstatSync(fd).isFile())
439
+ throw new Error(`Session transcript is not a regular file: ${file}`);
440
+ return readFileSync(fd);
441
+ }
442
+ finally {
443
+ closeSync(fd);
444
+ }
445
+ }
package/dist/main.js CHANGED
@@ -105,6 +105,8 @@ function hintFor(info, cred, file, command = '') {
105
105
  return 'this API has no billing configured: plans cannot be bought here';
106
106
  if (info.code === 'allowance_exhausted')
107
107
  return allowanceHint(info);
108
+ if (info.code === 'entitlement_required' && reasonOf(info) === 'not_included')
109
+ return planHint(info);
108
110
  if (info.code === 'quota_exceeded') {
109
111
  const quota = quotaHint(info);
110
112
  if (quota)
@@ -177,6 +179,16 @@ function allowanceHint(info) {
177
179
  return undefined;
178
180
  }
179
181
  }
182
+ /**
183
+ * 402 entitlement_required not_included (0.19.0): the plan does not include the feature (the GPU, shared volumes). The
184
+ * plans that do come from details.required_plans when the API names them (the GPU: Startup or Scale).
185
+ */
186
+ function planHint(info) {
187
+ const plans = Array.isArray(info.details?.required_plans) ? info.details.required_plans.filter((p) => typeof p === 'string' && p.length > 0) : [];
188
+ const names = plans.map((p) => p.charAt(0).toUpperCase() + p.slice(1));
189
+ const which = names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names[names.length - 1]}` : names[0];
190
+ return which ? `upgrade to ${which}: shard billing plans` : 'upgrade to a plan that includes it: shard billing plans';
191
+ }
180
192
  /**
181
193
  * quota_exceeded (0.9.0): the cell gateway's 429 when every workspace the plan allows to work at once is busy (the SDK
182
194
  * already retried it after Retry-After), and the API's 403 when the plan's Retained state is used up (new keys and
package/dist/move.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { Workspace } from '@shardflux/sdk';
2
+ import type { Ctx, Handler } from './commands.js';
3
+ import type { Values } from './args.js';
4
+ import type { Conversation } from './conversations.js';
5
+ import type { LocalSession, SessionHarness, SessionState } from './local-sessions.js';
6
+ type Env = Record<string, string | undefined>;
7
+ /** The project a folder belongs to: its Git repository's top, or the folder itself. */
8
+ export declare function projectRoot(folder: string): string;
9
+ export declare function chooseSession(values: Values, env: Env, folder: string): LocalSession;
10
+ /** Until the session's turn has ended: a running turn is never cut off. */
11
+ export declare function waitForTurnEnd(ctx: Pick<Ctx, 'io' | 'json' | 'signal'>, s: LocalSession, env: Env, pollMs?: number): Promise<SessionState>;
12
+ type MoveCell = Pick<ReturnType<Workspace['cell']>, 'exec' | 'files'>;
13
+ /**
14
+ * The session's transcript in the conversation's VM: at its native path (where the harness resumes it) and, when the
15
+ * project space is mounted, as the conversation's durable copy with the same checkpoint metadata the guest writes
16
+ * (transcripts/<id>/meta.json), so the session survives the VM from its first minute.
17
+ */
18
+ export declare function placeTranscript(cell: MoveCell, c: Pick<Conversation, 'id' | 'harness' | 'worktree_path'>, s: LocalSession, bytes: Buffer, folder: string, signal?: AbortSignal): Promise<{
19
+ native: string;
20
+ durable: boolean;
21
+ }>;
22
+ export declare function moveSession(ctx: Ctx, values: Values): Promise<number>;
23
+ /** `shard move --list`: this project's sessions on this computer, and where each moved one continues. */
24
+ export declare function listMovable(ctx: Pick<Ctx, 'store' | 'apiUrl'>, values: Values, env: Env, now?: number): {
25
+ folder: string;
26
+ sessions: {
27
+ harness: SessionHarness;
28
+ session_id: string;
29
+ title: string;
30
+ updated_at: string;
31
+ source: string;
32
+ cwd: string;
33
+ running: boolean;
34
+ turn: "working" | "idle";
35
+ moved: {
36
+ conversation_id: string;
37
+ key: string;
38
+ moved_at: string;
39
+ diverged: boolean;
40
+ } | null;
41
+ }[];
42
+ };
43
+ export declare const MOVE_HANDLERS: Record<string, Handler>;
44
+ export {};