@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.
- package/CHANGELOG.md +93 -1
- package/README.md +27 -0
- package/dist/account-args.js +1 -0
- package/dist/agent-commands.d.ts +1 -0
- package/dist/agent-commands.js +15 -5
- package/dist/agent-config-files.d.ts +16 -2
- package/dist/agent-config-files.js +48 -61
- package/dist/agent-config-sync.d.ts +12 -1
- package/dist/agent-config-sync.js +228 -54
- package/dist/agent-repository.d.ts +39 -9
- package/dist/agent-repository.js +309 -92
- package/dist/agent-state.d.ts +3 -0
- package/dist/agent-state.js +13 -0
- package/dist/agent-target-commands.d.ts +2 -0
- package/dist/agent-target-commands.js +45 -15
- package/dist/args.d.ts +1 -1
- package/dist/args.js +14 -2
- package/dist/conversation-router.js +7 -11
- package/dist/conversation-stash.js +64 -18
- package/dist/conversation-terminal.d.ts +1 -1
- package/dist/conversation-terminal.js +9 -10
- package/dist/conversations.d.ts +9 -2
- package/dist/conversations.js +23 -6
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/files-mirror.d.ts +1 -9
- package/dist/files-mirror.js +19 -3
- package/dist/format.js +37 -2
- package/dist/handlers.js +3 -1
- package/dist/initial-prompt.d.ts +15 -2
- package/dist/initial-prompt.js +29 -4
- package/dist/local-sessions.d.ts +60 -0
- package/dist/local-sessions.js +445 -0
- package/dist/main.js +12 -0
- package/dist/move.d.ts +44 -0
- package/dist/move.js +248 -0
- package/dist/moved-sessions.d.ts +33 -0
- package/dist/moved-sessions.js +91 -0
- package/dist/onboarding.d.ts +18 -3
- package/dist/onboarding.js +72 -46
- package/dist/project-sync-manifest.d.ts +31 -3
- package/dist/project-sync-manifest.js +192 -108
- package/dist/project-sync-transfer.d.ts +50 -4
- package/dist/project-sync-transfer.js +404 -106
- package/dist/project-sync.js +30 -33
- package/dist/seed-phases.d.ts +29 -0
- package/dist/seed-phases.js +83 -0
- package/dist/t3-mode.d.ts +7 -2
- package/dist/t3-mode.js +39 -5
- package/dist/thread-on-computer.js +4 -0
- package/dist/worktree-twin.d.ts +2 -6
- package/dist/worktree-twin.js +16 -51
- package/package.json +5 -5
|
@@ -277,10 +277,13 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
|
|
|
277
277
|
const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
|
|
278
278
|
const entry = (await (ws.labels.shard_router === 'project' ? targets.configureProject(ws.key, ctx.apiUrl, [target], ctx.signal) : targets.configure(ws.key, ctx.apiUrl, [target], false, ctx.signal))).find(e => e.workspace === ws.key);
|
|
279
279
|
const instructions = target === 't3'
|
|
280
|
-
?
|
|
280
|
+
? '' // T3 Code: where the project is comes from its T3 Connect link (t3Line), set below.
|
|
281
281
|
: target === 'claude-desktop'
|
|
282
282
|
? `Restart Claude Desktop to load the connection, then choose ${targetLabel(entry)} in Code's environment dropdown and open ${projectWorkspacePath({ slug: entry.project })}.`
|
|
283
283
|
: `Restart Codex to import ${entry.host} and its remote project. If it has already been imported, enable ${entry.host} in Settings → Connections → SSH.`;
|
|
284
|
+
// T3 Code: the project is linked with the person's T3 Connect sign-in first, then the thread starts in it.
|
|
285
|
+
const fresh = target === 't3' && !new AgentStateStore(ctx.store.dir).t3Environments(ctx.apiUrl).some(e => e.workspace === ws.key);
|
|
286
|
+
let phone = target === 't3' ? await linkT3Phone(ctx, ws) : undefined;
|
|
284
287
|
let started;
|
|
285
288
|
if (target === 't3' && thread) {
|
|
286
289
|
try {
|
|
@@ -290,10 +293,15 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
|
|
|
290
293
|
throw withT3Context(error, { workspace_key: ws.key, conversation_id: conversation ?? (ws.labels.shard_conversation || null) });
|
|
291
294
|
}
|
|
292
295
|
}
|
|
296
|
+
// The first time a project is set up for T3 Code, T3 opens on its Connections page once the project is linked: the
|
|
297
|
+
// project is listed there (or T3's own Sign in to T3 Connect, when T3 Code is signed out).
|
|
293
298
|
if (target === 't3') {
|
|
299
|
+
if (fresh && phone?.link.status === 'linking')
|
|
300
|
+
phone = await waitT3Linked(ctx, ws, phone);
|
|
301
|
+
const page = fresh && phone?.link.status === 'linked' ? T3_CONNECTIONS_PAGE : undefined;
|
|
294
302
|
// The thread exists once startSshThread returns: a failure to show T3 names it rather than inviting a retry.
|
|
295
303
|
try {
|
|
296
|
-
await openT3(ctx.io.env, install ?? t3Install(ctx.io.env));
|
|
304
|
+
await openT3(ctx.io.env, install ?? t3Install(ctx.io.env), page);
|
|
297
305
|
}
|
|
298
306
|
catch (error) {
|
|
299
307
|
throw started ? t3OpenFailed(error, { t3_thread_id: started.t3_thread_id, conversation_id: conversation ?? (ws.labels.shard_conversation || null), workspace_key: ws.key }) : error;
|
|
@@ -304,35 +312,57 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
|
|
|
304
312
|
new AgentStateStore(ctx.store.dir).saveTarget(ws.labels.repo_remote || ws.labels.shard_project || entry.project, target);
|
|
305
313
|
if (target === 't3')
|
|
306
314
|
new AgentStateStore(ctx.store.dir).saveT3Environment({ workspace: ws.key, host: entry.host, api_url: ctx.apiUrl });
|
|
307
|
-
const
|
|
315
|
+
const where = target === 't3' ? phone?.line ?? `${targetLabel(entry)} is set up for T3 Code.\n` : '';
|
|
308
316
|
const threadLine = started ? `Started a thread in T3 Code on ${agentMachineName(ws.key)}${started.model ? ` (${started.model}${started.effort ? `, ${started.effort}` : ''})` : ''}.\n` : '';
|
|
309
|
-
out(ctx, `${threadLine}${instructions}\n
|
|
317
|
+
out(ctx, target === 't3' ? `${threadLine}${where}` : `${threadLine}${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions: target === 't3' ? where.trim() : instructions, ...(phone ? { t3_link: phone.link } : {}),
|
|
310
318
|
...(target === 't3' ? t3Fields(ws, entry.project, install ?? t3Install(ctx.io.env), started, conversation) : {}),
|
|
311
319
|
app_bundle_id: { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target] });
|
|
312
320
|
return 0;
|
|
313
321
|
}
|
|
322
|
+
/** T3 Code's Connections page (its t3code:// link), where a project linked with T3 Connect is listed. */
|
|
323
|
+
export const T3_CONNECTIONS_PAGE = 't3code://app/settings/connections';
|
|
314
324
|
/**
|
|
315
|
-
*
|
|
316
|
-
* asked for once per person; linking continues by itself once it is approved.
|
|
325
|
+
* T3 Code and the T3 app reach this workspace through the account's T3 Connect sign-in (0.17.0+; on the desktop
|
|
326
|
+
* 0.19.0+). The sign-in is asked for once per person; linking continues by itself once it is approved.
|
|
317
327
|
*/
|
|
318
328
|
async function linkT3Phone(ctx, ws) {
|
|
319
329
|
try {
|
|
320
330
|
const { link, signIn, needsLogin } = await t3PhoneLink(ctx, ws.id);
|
|
321
|
-
|
|
322
|
-
? `Phone: to use ${link.label} from the T3 app, open ${signIn.verification_uri} and confirm ${signIn.user_code} (once).\n`
|
|
323
|
-
: needsLogin ? `Phone: to use ${link.label} from the T3 app, sign in with shard auth login, then run this again.\n`
|
|
324
|
-
: `Phone: ${link.label} appears in the T3 app's environments.\n`;
|
|
325
|
-
// --json carries the sign-in shard started with its session too, so the menu bar can show it.
|
|
326
|
-
return { line, link: signIn ? { ...link, sign_in: { ...link.sign_in, ...signIn } } : needsLogin ? { ...link, needs_login: true } : link };
|
|
331
|
+
return { line: t3LinkLine(link, signIn, needsLogin), link: signIn ? { ...link, sign_in: { ...link.sign_in, ...signIn } } : needsLogin ? { ...link, needs_login: true } : link };
|
|
327
332
|
}
|
|
328
333
|
catch (error) {
|
|
329
|
-
// An API without T3 Connect leaves the
|
|
334
|
+
// An API without T3 Connect leaves the project set up for T3 Code over SSH.
|
|
330
335
|
if (describeError(error).code === 'not_found')
|
|
331
336
|
return undefined;
|
|
332
|
-
ctx.io.stderr.write(`
|
|
337
|
+
ctx.io.stderr.write(`T3 Connect: ${describeError(error).message}\n`);
|
|
333
338
|
return undefined;
|
|
334
339
|
}
|
|
335
340
|
}
|
|
341
|
+
const t3LinkLine = (link, signIn, needsLogin) => signIn
|
|
342
|
+
? `To have ${link.label} appear in T3 Code and the T3 app, open ${signIn.verification_uri} and confirm ${signIn.user_code} (once).\n`
|
|
343
|
+
: needsLogin ? `To have ${link.label} appear in T3 Code and the T3 app, sign in with shard auth login, then run this again.\n`
|
|
344
|
+
: link.status === 'linked' ? `${link.label} is linked to your T3 account.\n`
|
|
345
|
+
: `Linking ${link.label} to your T3 account…\n`;
|
|
346
|
+
/** A project linking for the first time: T3 lists it once linked, so its Connections page waits for that (at most 20 s). */
|
|
347
|
+
async function waitT3Linked(ctx, ws, phone) {
|
|
348
|
+
const until = Date.now() + 20_000;
|
|
349
|
+
while (Date.now() < until && !ctx.signal?.aborted) {
|
|
350
|
+
await delay(2000);
|
|
351
|
+
try {
|
|
352
|
+
const known = (await ctx.cloud().t3.status()).links.find(l => l.workspace_id === ws.id);
|
|
353
|
+
if (known?.status === 'linked') {
|
|
354
|
+
const link = { ...phone.link, status: 'linked' };
|
|
355
|
+
return { line: t3LinkLine(link), link };
|
|
356
|
+
}
|
|
357
|
+
if (known && known.status !== 'linking')
|
|
358
|
+
return phone;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return phone;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return phone;
|
|
365
|
+
}
|
|
336
366
|
export async function queueT3Prewarm(ctx, ws, version) {
|
|
337
367
|
try {
|
|
338
368
|
await prewarmT3(ctx, ws, version);
|
|
@@ -348,7 +378,7 @@ function configuredTargets(targets, e, t3 = false) {
|
|
|
348
378
|
{ target: 'ssh', label: 'SSH', file: targets.sshFile, name: e.host, detail: `(included from ${tilde(targets, join(targets.home, '.ssh', 'config'))}): ssh ${e.host}` },
|
|
349
379
|
...(e.targets.includes('claude-desktop') ? [{ target: 'claude-desktop', label: 'Claude Desktop', file: targets.claudeFile, name: targetLabel(e), detail: `(sshConfigs: ${targetLabel(e)}); restart Claude Desktop, then pick it in Code's environment list` }] : []),
|
|
350
380
|
...(e.targets.includes('codex-app') ? [{ target: 'codex-app', label: 'Codex app', file: targets.codexFile, name: targetLabel(e), detail: `(remoteConnections: ${targetLabel(e)}); restart Codex to import it` }] : []),
|
|
351
|
-
...(t3 ? [{ target: 't3', label: 'T3 Code', file: targets.sshFile, name: agentMachineName(e.workspace), detail: `
|
|
381
|
+
...(t3 ? [{ target: 't3', label: 'T3 Code', file: targets.sshFile, name: agentMachineName(e.workspace), detail: `SSH host ${e.host}; project ${projectWorkspacePath({ slug: e.project })}; in T3 Code and the T3 app through T3 Connect` }] : []),
|
|
352
382
|
];
|
|
353
383
|
}
|
|
354
384
|
function targetFiles(targets, entries) {
|
package/dist/args.d.ts
CHANGED
package/dist/args.js
CHANGED
|
@@ -13,7 +13,7 @@ import { parseArgs } from 'node:util';
|
|
|
13
13
|
import { ACCOUNT_COMMANDS } from "./account-args.js";
|
|
14
14
|
import { UsageError } from "./errors.js";
|
|
15
15
|
import { DEFAULT_CHOICE, MODEL_CHOICES } from "./agent-models.js";
|
|
16
|
-
export const CLI_VERSION = '0.
|
|
16
|
+
export const CLI_VERSION = '0.19.1';
|
|
17
17
|
export const GLOBAL_OPTIONS = {
|
|
18
18
|
'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
|
|
19
19
|
json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
|
|
@@ -1252,6 +1252,17 @@ export const CENTER_COMMAND = { path: ['<center>'], summary: 'Open the cloud age
|
|
|
1252
1252
|
/** Bare `shard` without a terminal, or in a coding agent's shell (0.17.1+): the setup question and both paths' steps. */
|
|
1253
1253
|
export const GUIDE_COMMAND = { path: ['<guide>'], summary: 'Show how to set up Shardflux with a coding agent', positionals: [], options: {} };
|
|
1254
1254
|
const AGENT_COMMANDS = [
|
|
1255
|
+
{ path: ['move'], summary: 'Move a Claude Code or Codex session from this computer to the cloud: the same session, with its history',
|
|
1256
|
+
description: 'Run it in the project folder. The session continues in its own cloud VM as the same session (claude --resume, codex resume), from your project exactly as it is, uncommitted edits included; your working tree, index and branch stay as they are. A running turn finishes here first. The cloud work comes back to this folder by itself, and typing in the local copy says it now continues in the cloud. Sessions from Claude Desktop and the Codex app move the same way.',
|
|
1257
|
+
positionals: [], options: {
|
|
1258
|
+
last: { type: 'boolean', description: 'This project’s most recent session.' },
|
|
1259
|
+
session: { type: 'string', value: '<id>', description: 'The session to move: a Claude Code session id or Codex thread id.' },
|
|
1260
|
+
path: { type: 'string', value: '<path>', description: 'The project folder (default: the current folder).' },
|
|
1261
|
+
claude: { type: 'boolean', description: 'With --last or --list: Claude Code sessions only.' },
|
|
1262
|
+
codex: { type: 'boolean', description: 'With --last or --list: Codex sessions only.' },
|
|
1263
|
+
list: { type: 'boolean', description: 'List this project’s sessions on this computer, and where the moved ones continue.' },
|
|
1264
|
+
detach: { type: 'boolean', description: 'Start it in the cloud without opening it here.' },
|
|
1265
|
+
}, examples: ['shard move --last', 'shard move --list', 'shard move --session 3d085e35-aa66-4866-9a53-f09719d6767c --detach --json'] },
|
|
1255
1266
|
{ path: ['device', 'requests'], summary: 'Review local device requests waiting for approval', positionals: [], options: {} },
|
|
1256
1267
|
...['approve', 'deny'].map((action) => ({ path: ['device', action], summary: `${action} a local device request`, positionals: [{ name: 'id', required: true }], options: action === 'approve' ? { always: { type: 'boolean', description: 'Allow matching requests for this project; never for outside-folder access.' } } : {} })),
|
|
1257
1268
|
{ path: ['conversations', 'try'], summary: 'Try a conversation in a synced local worktree', positionals: [{ name: 'conversation', required: true }], options: { path: { type: 'string', value: '<path>', description: 'Local project checkout.' }, stop: { type: 'boolean', description: 'Stop the device helper’s local sync and ports.' } } },
|
|
@@ -1288,6 +1299,7 @@ const AGENT_COMMANDS = [
|
|
|
1288
1299
|
} },
|
|
1289
1300
|
{ path: ['agents', 'fork'], summary: 'Fork an agent’s memory and disk', positionals: [{ name: 'agent', required: true }, { name: 'name', required: true }], options: WAIT },
|
|
1290
1301
|
{ path: ['agents', 'park'], summary: 'Suspend an agent when idle', positionals: [{ name: 'agent', required: true }], options: { 'when-idle': { type: 'string', value: '<duration>', description: 'Idle grace before suspend (default 1s).' } } },
|
|
1302
|
+
{ path: ['agents', 'config-sync'], hidden: true, summary: 'Copy this computer’s Claude and Codex config into a conversation VM (started in the background after a start, 0.19.0+)', positionals: [{ name: 'workspace', required: true }], options: {} },
|
|
1291
1303
|
{ path: ['agents', 'logs'], summary: 'Tail an agent’s terminal from its output offset: a conversation id (0.18.1+) or an agent workspace key', positionals: [{ name: 'agent', required: true }], options: {
|
|
1292
1304
|
app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice; a conversation uses its own).' },
|
|
1293
1305
|
offset: { type: 'string', value: '<bytes>', description: 'Read from this output byte offset (default last 64 KiB).' },
|
|
@@ -1719,7 +1731,7 @@ export const EXIT_CODE_HELP = [
|
|
|
1719
1731
|
/** The top help's sections: every command's group (first word) is in exactly one (args.test.ts checks). */
|
|
1720
1732
|
export const HELP_SECTIONS = [
|
|
1721
1733
|
{ title: 'Account (a user session: shard signup or shard auth login)', groups: ['signup', 'verify', 'auth', 'setup', 'env', 'orgs', 'projects', 'api-keys', 'members', 'invitations', 'billing', 'audit', 'account'] },
|
|
1722
|
-
{ title: 'Cloud agents (shard ./ starts one; a project API key)', groups: ['conversations', 'machine', 'template', 'agents', 'accounts', 'device', 'cli-proxy', 'ssh-proxy'] },
|
|
1734
|
+
{ title: 'Cloud agents (shard ./ starts one; a project API key)', groups: ['conversations', 'move', 'machine', 'template', 'agents', 'accounts', 'device', 'cli-proxy', 'ssh-proxy'] },
|
|
1723
1735
|
{ title: 'Workspaces for your app (a project API key: SHARDFLUX_API_KEY, else the one shard setup saved)', groups: ['login', 'whoami', 'status', 'usage', 'workspaces', 'files', 'operations', 'executions', 'secrets', 'credentials', 'volumes', 'egress'] },
|
|
1724
1736
|
{ title: 'Templates for your app’s workspaces (not for cloud agents; publish and archive use the session, the rest the API key)', groups: ['templates'] },
|
|
1725
1737
|
{ title: 'Other', groups: ['menubar', 'update', 'feedback', 'version'] },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
3
|
-
import { workspaceRepositoryPath, projectWorkspacePath
|
|
4
|
-
import {
|
|
3
|
+
import { workspaceRepositoryPath, projectWorkspacePath } from "./agent-repository.js";
|
|
4
|
+
import { copySyncBlob } from "./project-sync-transfer.js";
|
|
5
5
|
import { UsageError } from "./errors.js";
|
|
6
6
|
import { conversationClient } from "./conversation-commands.js";
|
|
7
7
|
export const ROUTER_CONFIG = '/home/user/.config/shardflux/conversation-router.json';
|
|
@@ -58,18 +58,14 @@ export async function prepareDesktopRouter(ctx, source) {
|
|
|
58
58
|
const archive = `/tmp/shard-router-${randomUUID()}.bundle`;
|
|
59
59
|
try {
|
|
60
60
|
if (created) {
|
|
61
|
-
const packed = await cell.exec.run(['git', '-C', workspaceRepositoryPath(source), '-c', 'core.hooksPath=/dev/null', 'bundle', 'create', archive, 'HEAD'], { timeoutMs:
|
|
61
|
+
const packed = await cell.exec.run(['git', '-C', workspaceRepositoryPath(source), '-c', 'core.hooksPath=/dev/null', 'bundle', 'create', archive, 'HEAD'], { timeoutMs: 600_000, signal: ctx.signal });
|
|
62
62
|
if (packed.exitCode !== 0)
|
|
63
63
|
throw new Error(`Project router seed failed: ${packed.stderr.trim()}`);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const bytes = await cell.files.read(archive, { length: info.size + 1 });
|
|
68
|
-
if (bytes.length !== info.size)
|
|
69
|
-
throw new Error('Project router history changed during copying.');
|
|
70
|
-
await writeSyncBlob(destination, archive, bytes);
|
|
64
|
+
// Any history size: the bundle goes VM to VM one chunk at a time, and the import may take a second per 8 MiB.
|
|
65
|
+
const { size } = await cell.files.stat(archive);
|
|
66
|
+
await copySyncBlob(cell, archive, size, destination, archive, ctx.signal);
|
|
71
67
|
await destination.files.mkdir(root, { parents: true });
|
|
72
|
-
const imported = await destination.exec.run(['sh', '-c', 'set -eu; git -C "$1" init -q; git -C "$1" -c core.hooksPath=/dev/null fetch -q "$2" HEAD:refs/heads/shardflux-router; git -C "$1" -c core.hooksPath=/dev/null checkout -q shardflux-router', 'sh', root, archive], { timeoutMs: 120_000, signal: ctx.signal });
|
|
68
|
+
const imported = await destination.exec.run(['sh', '-c', 'set -eu; git -C "$1" init -q; git -C "$1" -c core.hooksPath=/dev/null fetch -q "$2" HEAD:refs/heads/shardflux-router; git -C "$1" -c core.hooksPath=/dev/null checkout -q shardflux-router', 'sh', root, archive], { timeoutMs: 120_000 + Math.ceil(size / (8 << 20)) * 1000, signal: ctx.signal });
|
|
73
69
|
if (imported.exitCode !== 0)
|
|
74
70
|
throw new Error(`Project router repository could not be prepared: ${imported.stderr.trim()}`);
|
|
75
71
|
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { open, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
5
6
|
import { conversationName, listConversations, localConversations, rememberConversation } from "./conversations.js";
|
|
6
7
|
import { gitRunner } from "./agent-git.js";
|
|
7
8
|
import { shardRemote } from "./git-remote.js";
|
|
8
9
|
import { writePrivateFile } from "./credentials.js";
|
|
9
10
|
import { conversationClient } from "./conversation-commands.js";
|
|
10
|
-
|
|
11
|
+
import { movedSession, movedUnchanged } from "./moved-sessions.js";
|
|
12
|
+
import { claudeProjectName } from "./local-sessions.js";
|
|
13
|
+
import { remoteManifest, SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
|
|
11
14
|
export function rewriteTranscript(bytes, remoteCwd, localCwd) {
|
|
12
15
|
const text = new TextDecoder('utf8', { fatal: true }).decode(bytes);
|
|
13
16
|
const rewrite = (value) => {
|
|
@@ -30,7 +33,7 @@ export function transcriptDestination(c, folder, filename, env) {
|
|
|
30
33
|
throw new Error('Transcript filename is not a native JSONL file.');
|
|
31
34
|
const home = env.HOME || homedir();
|
|
32
35
|
if (c.harness === 'claude')
|
|
33
|
-
return join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects', resolve(folder)
|
|
36
|
+
return join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects', claudeProjectName(resolve(folder)), filename);
|
|
34
37
|
const match = /^rollout-(\d{4})-(\d{2})-(\d{2})T/.exec(filename);
|
|
35
38
|
if (!match)
|
|
36
39
|
throw new Error('Codex transcript has no native rollout date.');
|
|
@@ -61,6 +64,43 @@ function readStashFile(file) {
|
|
|
61
64
|
closeSync(fd);
|
|
62
65
|
}
|
|
63
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Copies one transcript into the stash in SYNC_CHUNK_BYTES reads (any size, never whole in memory): checked as UTF-8
|
|
69
|
+
* and against the manifest hash on the way, written private (0600) next to its destination and renamed into place.
|
|
70
|
+
*/
|
|
71
|
+
async function copyTranscript(cell, path, entry, dir, file, signal) {
|
|
72
|
+
const tmp = join(dir, `.${entry.path}.${randomUUID()}.tmp`);
|
|
73
|
+
try {
|
|
74
|
+
const fd = await open(tmp, 'wx', 0o600);
|
|
75
|
+
try {
|
|
76
|
+
const hash = createHash('sha256'), utf8 = new TextDecoder('utf8', { fatal: true });
|
|
77
|
+
for (let offset = 0; offset < entry.size;) {
|
|
78
|
+
signal.throwIfAborted();
|
|
79
|
+
const want = Math.min(SYNC_CHUNK_BYTES, entry.size - offset);
|
|
80
|
+
// A read below the gateway's per-read cap returns what was asked unless the file shrank.
|
|
81
|
+
const chunk = await cell.files.read(path, { offset, length: want });
|
|
82
|
+
if (chunk.length !== want)
|
|
83
|
+
throw new Error('Transcript changed during copying; retaining the previous stash.');
|
|
84
|
+
hash.update(chunk);
|
|
85
|
+
utf8.decode(chunk, { stream: true });
|
|
86
|
+
for (let at = 0; at < chunk.length;)
|
|
87
|
+
at += (await fd.write(chunk, at, chunk.length - at, offset + at)).bytesWritten;
|
|
88
|
+
offset += chunk.length;
|
|
89
|
+
}
|
|
90
|
+
utf8.decode();
|
|
91
|
+
if (hash.digest('hex') !== entry.sha256)
|
|
92
|
+
throw new Error('Transcript changed during copying; retaining the previous stash.');
|
|
93
|
+
await fd.sync();
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await fd.close();
|
|
97
|
+
}
|
|
98
|
+
await rename(tmp, file);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await rm(tmp, { force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
64
104
|
/** Keep growing copies away from native session discovery until the control plane ends the conversation. */
|
|
65
105
|
export async function mirrorConversation(ctx, record, source, skipped = new Set()) {
|
|
66
106
|
const c = record.conversation;
|
|
@@ -85,7 +125,8 @@ export async function mirrorConversation(ctx, record, source, skipped = new Set(
|
|
|
85
125
|
ctx.signal.addEventListener('abort', close, { once: true });
|
|
86
126
|
try {
|
|
87
127
|
ctx.signal.throwIfAborted();
|
|
88
|
-
|
|
128
|
+
// Transcripts of any size: no per-file bound (an older guest's size skips are hashed in the VM).
|
|
129
|
+
const manifest = await remoteManifest(cell, root, undefined, ctx.signal);
|
|
89
130
|
for (const entry of manifest.skipped)
|
|
90
131
|
skip(entry.path, entry.reason);
|
|
91
132
|
let count = 0;
|
|
@@ -98,8 +139,8 @@ export async function mirrorConversation(ctx, record, source, skipped = new Set(
|
|
|
98
139
|
skip(entry.path, 'not a safe top-level JSONL or JSON file');
|
|
99
140
|
continue;
|
|
100
141
|
}
|
|
101
|
-
if (!Number.isSafeInteger(entry.size) || entry.size < 0
|
|
102
|
-
skip(entry.path, '
|
|
142
|
+
if (!Number.isSafeInteger(entry.size) || entry.size < 0) {
|
|
143
|
+
skip(entry.path, 'invalid size');
|
|
103
144
|
continue;
|
|
104
145
|
}
|
|
105
146
|
try {
|
|
@@ -109,12 +150,8 @@ export async function mirrorConversation(ctx, record, source, skipped = new Set(
|
|
|
109
150
|
continue;
|
|
110
151
|
}
|
|
111
152
|
const expected = existing ? createHash('sha256').update(readStashFile(file)).digest('hex') : '';
|
|
112
|
-
if (expected !== entry.sha256)
|
|
113
|
-
|
|
114
|
-
if (bytes.length !== entry.size || createHash('sha256').update(bytes).digest('hex') !== entry.sha256)
|
|
115
|
-
throw new Error('Transcript changed during copying; retaining the previous stash.');
|
|
116
|
-
writePrivateFile(dir, file, new TextDecoder('utf8', { fatal: true }).decode(bytes));
|
|
117
|
-
}
|
|
153
|
+
if (expected !== entry.sha256)
|
|
154
|
+
await copyTranscript(cell, `${root}/${entry.path}`, entry, dir, file, ctx.signal);
|
|
118
155
|
}
|
|
119
156
|
catch (e) {
|
|
120
157
|
throw new Error(`Entry ${JSON.stringify(entry.path)}: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
@@ -148,10 +185,16 @@ export async function handoffConversation(ctx, c, folder, dir, source) {
|
|
|
148
185
|
const files = readdirSync(dir).filter(f => !f.includes('..') && /^[a-zA-Z0-9_.-]+\.jsonl$/.test(f) && lstatSync(join(dir, f)).isFile());
|
|
149
186
|
if (!files.length)
|
|
150
187
|
throw new Error('Conversation has no transcript to hand off.');
|
|
151
|
-
|
|
188
|
+
// A session moved in from this computer (`shard move`) comes back to the file it left; it replaces that local copy only
|
|
189
|
+
// while the copy holds exactly what moved (nobody continued it here meanwhile).
|
|
190
|
+
const moved = c.harness_session_id ? movedSession(ctx.store.dir, ctx.apiUrl, c.harness, c.harness_session_id) : undefined;
|
|
191
|
+
const writes = files.map(name => {
|
|
192
|
+
const dest = moved && name === basename(moved.file) ? moved.file : transcriptDestination(c, folder, name, ctx.io.env);
|
|
193
|
+
return { dest, bytes: rewriteTranscript(readStashFile(join(dir, name)), c.worktree_path, folder), replace: !!moved && dest === moved.file && movedUnchanged(moved) };
|
|
194
|
+
});
|
|
152
195
|
// Check every destination before the first write. Preserve local native history on any collision.
|
|
153
|
-
for (const { dest, bytes } of writes) {
|
|
154
|
-
if (existsSync(dest) && (lstatSync(dest).isSymbolicLink() || !readFileSync(dest).equals(Buffer.from(bytes))))
|
|
196
|
+
for (const { dest, bytes, replace } of writes) {
|
|
197
|
+
if (existsSync(dest) && (lstatSync(dest).isSymbolicLink() || (!replace && !readFileSync(dest).equals(Buffer.from(bytes)))))
|
|
155
198
|
throw new Error(`Native transcript already exists with different bytes: ${dest}`);
|
|
156
199
|
}
|
|
157
200
|
if (!source)
|
|
@@ -160,9 +203,12 @@ export async function handoffConversation(ctx, c, folder, dir, source) {
|
|
|
160
203
|
const run = gitRunner(folder, { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl }, ctx.signal);
|
|
161
204
|
const hub = 'hub' in source ? source.hub : shardRemote(source.key, '/shardflux/project/git/hub.git');
|
|
162
205
|
await run(['-c', 'core.hooksPath=/dev/null', 'fetch', '--no-tags', hub, `+${c.state === 'archived' ? `refs/shard/archive/${name}` : `refs/heads/${c.branch}`}:refs/remotes/shardflux/${name}`]);
|
|
163
|
-
for (const { dest, bytes } of writes) {
|
|
206
|
+
for (const { dest, bytes, replace } of writes) {
|
|
164
207
|
privateDirectory(dirname(dest));
|
|
165
|
-
|
|
208
|
+
// The local copy it replaces stays in the stash.
|
|
209
|
+
if (replace && existsSync(dest) && !readFileSync(dest).equals(Buffer.from(bytes)))
|
|
210
|
+
writePrivateFile(dir, join(dir, `local-${basename(dest)}`), readStashFile(dest).toString('utf8'));
|
|
211
|
+
if (!existsSync(dest) || replace)
|
|
166
212
|
writePrivateFile(dirname(dest), dest, new TextDecoder('utf8', { fatal: true }).decode(bytes));
|
|
167
213
|
}
|
|
168
214
|
rememberConversation(ctx, c, folder, true);
|
|
@@ -3,7 +3,7 @@ import type { Ctx, Handler } from './commands.js';
|
|
|
3
3
|
import type { Values } from './args.js';
|
|
4
4
|
import type { Conversation } from './conversations.js';
|
|
5
5
|
/** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
|
|
6
|
-
export declare function conversationTerminal(ctx: Ctx, ws: Workspace, c: Conversation, project: string, values: Values): Promise<number>;
|
|
6
|
+
export declare function conversationTerminal(ctx: Ctx, ws: Workspace, c: Conversation, project: string, values: Values, afterStart?: () => Promise<void> | undefined): Promise<number>;
|
|
7
7
|
/**
|
|
8
8
|
* A conversation and its VM, for an app to open (Claude Desktop, claude.ai, T3 Code, the menu bar). A conversation
|
|
9
9
|
* whose VM was removed after it finished restores first (0.18.1+): a fresh VM from its saved branch and transcript, the
|
|
@@ -9,7 +9,7 @@ import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from
|
|
|
9
9
|
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
10
10
|
import { prepareProjectTemplate } from "./project-template-setup.js";
|
|
11
11
|
import { projectWorkspacePath, seedAgentRepository } from "./agent-repository.js";
|
|
12
|
-
import { syncAgentConfig } from "./agent-config-sync.js";
|
|
12
|
+
import { syncAgentConfig, syncAgentConfigAfterStart } from "./agent-config-sync.js";
|
|
13
13
|
import { savedMachine } from "./filesystem-state.js";
|
|
14
14
|
import { ensureMenubar, MenubarManager } from "./menubar.js";
|
|
15
15
|
import { UsageError } from "./errors.js";
|
|
@@ -25,13 +25,13 @@ import { openUrl } from "./browser.js";
|
|
|
25
25
|
import { out } from "./commands.js";
|
|
26
26
|
import { progress } from "./progress.js";
|
|
27
27
|
/** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
|
|
28
|
-
export async function conversationTerminal(ctx, ws, c, project, values) {
|
|
28
|
+
export async function conversationTerminal(ctx, ws, c, project, values, afterStart) {
|
|
29
29
|
if (c.route === 'codex-cloud')
|
|
30
30
|
return openCodexConversation(ctx, c);
|
|
31
31
|
await restoreConversationBinding(ctx, c, ws);
|
|
32
32
|
// A thread on another conversation's computer has its own PTY there, never the VM's own (0.16.0+).
|
|
33
33
|
const pty = ws.labels.shard_conversation && ws.labels.shard_conversation !== c.id ? conversationName(c) : undefined;
|
|
34
|
-
return terminal(ctx, ws, c.harness, conversationName(c), project, values, undefined, c.worktree_path, { id: c.id, session: c.harness_session_id, title: c.title, ...(pty ? { pty } : {}), choice: recordedChoice(c) });
|
|
34
|
+
return terminal(ctx, ws, c.harness, conversationName(c), project, values, undefined, c.worktree_path, { id: c.id, session: c.harness_session_id, title: c.title, ...(pty ? { pty } : {}), choice: recordedChoice(c), ...(afterStart ? { afterStart } : {}) });
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
37
|
* A conversation and its VM, for an app to open (Claude Desktop, claude.ai, T3 Code, the menu bar). A conversation
|
|
@@ -48,10 +48,9 @@ export async function reopenConversation(ctx, c, values) {
|
|
|
48
48
|
progress(ctx, 'Restored');
|
|
49
49
|
return { conversation: restored, ws };
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
await syncAgentConfig(ctx, ws);
|
|
51
|
+
const afterStart = (await savedMachine(ctx))?.template ? undefined : () => syncAgentConfigAfterStart(ctx, ws);
|
|
53
52
|
const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
|
|
54
|
-
await conversationTerminal(quiet, ws, restored, ws.labels.repo_remote || ws.labels.shard_project || ws.key, { ...values, detach: true });
|
|
53
|
+
await conversationTerminal(quiet, ws, restored, ws.labels.repo_remote || ws.labels.shard_project || ws.key, { ...values, detach: true }, afterStart);
|
|
55
54
|
return { conversation: restored, ws };
|
|
56
55
|
}
|
|
57
56
|
/**
|
|
@@ -148,13 +147,13 @@ async function startConversation(ctx, values, [path = './', explicit], openIn) {
|
|
|
148
147
|
await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
|
|
149
148
|
if (!signedIn)
|
|
150
149
|
await loginClaudeInWorkspace(ctx, ws);
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
// Without a saved machine the laptop's Claude and Codex config is copied in, after the agent has its first prompt.
|
|
151
|
+
const afterStart = (await savedMachine(ctx))?.template ? undefined : () => syncAgentConfigAfterStart(ctx, ws);
|
|
153
152
|
await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
|
|
154
153
|
if (openIn) {
|
|
155
154
|
// The agent starts detached in its VM; the conversation then opens in Claude Desktop or claude.ai (Remote Control).
|
|
156
155
|
const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
|
|
157
|
-
await conversationTerminal(quiet, ws, c, project.remote ?? project.folder, { ...values, detach: true });
|
|
156
|
+
await conversationTerminal(quiet, ws, c, project.remote ?? project.folder, { ...values, detach: true }, afterStart);
|
|
158
157
|
if (openIn === 'claude-desktop')
|
|
159
158
|
return openClaudeDesktopSession(ctx, ws, c);
|
|
160
159
|
const url = await remoteControlUrl(ctx, ws, c);
|
|
@@ -163,5 +162,5 @@ async function startConversation(ctx, values, [path = './', explicit], openIn) {
|
|
|
163
162
|
out(ctx, `${url}\n`, { target: 'web', workspace: ws.data, conversation_id: c.id, url });
|
|
164
163
|
return 0;
|
|
165
164
|
}
|
|
166
|
-
return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values);
|
|
165
|
+
return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values, afterStart);
|
|
167
166
|
}
|
package/dist/conversations.d.ts
CHANGED
|
@@ -87,8 +87,15 @@ export declare function conversationWorkspace(ctx: Ctx, c: Conversation): Promis
|
|
|
87
87
|
*/
|
|
88
88
|
export declare function waitThreadReady(ctx: Ctx, c: Conversation, pollMs?: number): Promise<Conversation>;
|
|
89
89
|
export declare function restoreConversationBinding(ctx: Ctx, c: Conversation, ws: Workspace): Promise<void>;
|
|
90
|
-
|
|
91
|
-
export
|
|
90
|
+
/** A native session moved in from this computer (`shard move`, 0.19.0+): the conversation starts by resuming it. */
|
|
91
|
+
export interface MovedInSession {
|
|
92
|
+
session_id: string;
|
|
93
|
+
title?: string;
|
|
94
|
+
}
|
|
95
|
+
export declare function createConversationRecord(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, allocated?: (workspace: WorkspaceView) => void, idempotencyKey?: string, choice?: ModelChoice, movedIn?: MovedInSession): Promise<Conversation>;
|
|
96
|
+
export declare function createConversation(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, choice?: ModelChoice, movedIn?: MovedInSession & {
|
|
97
|
+
beforeSeed(ws: Workspace, c: Conversation): Promise<void>;
|
|
98
|
+
}): Promise<{
|
|
92
99
|
conversation: Conversation;
|
|
93
100
|
workspace: Workspace;
|
|
94
101
|
}>;
|
package/dist/conversations.js
CHANGED
|
@@ -4,12 +4,13 @@ import { AgentStateStore } from "./agent-state.js";
|
|
|
4
4
|
import { gitRunner } from "./agent-git.js";
|
|
5
5
|
import { projectIdentity } from "./project-identity.js";
|
|
6
6
|
import { ensureProjectGit } from "./project-git.js";
|
|
7
|
-
import { seedAgentRepository } from "./agent-repository.js";
|
|
7
|
+
import { preflightProjectSeed, seedAgentRepository } from "./agent-repository.js";
|
|
8
8
|
import { UsageError } from "./errors.js";
|
|
9
9
|
import { conversationClient } from "./conversation-commands.js";
|
|
10
10
|
import { recordSeedSnapshot } from "./files-mirror.js";
|
|
11
11
|
import { rememberTwinSeed, sendTwinSeed, twinCheckoutArgv, twinSeed } from "./worktree-twin.js";
|
|
12
12
|
import { progress } from "./progress.js";
|
|
13
|
+
import { seedPhase, startProgress } from "./seed-phases.js";
|
|
13
14
|
/**
|
|
14
15
|
* Whether the conversation has ended: its VM was reclaimed, or it was archived or discarded. A finished turn is `done`
|
|
15
16
|
* while its VM still takes the next turn; on Codex Cloud, `done` ends the conversation.
|
|
@@ -156,9 +157,11 @@ export async function restoreConversationBinding(ctx, c, ws) {
|
|
|
156
157
|
if (c.restore.kind === 'resume')
|
|
157
158
|
progress(ctx, 'Restored');
|
|
158
159
|
}
|
|
159
|
-
export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID(), choice = {}) {
|
|
160
|
+
export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID(), choice = {}, movedIn) {
|
|
160
161
|
const project = projectIdentity(folder);
|
|
161
162
|
await ensureProjectGit(ctx, project.folder);
|
|
163
|
+
// A start refuses before a VM is allocated, never after (0.19.0+).
|
|
164
|
+
await preflightProjectSeed(project.folder, ctx.signal);
|
|
162
165
|
// Both the conversation API and its legacy workspace fallback need a usable local HEAD.
|
|
163
166
|
await conversationClient(ctx).conversations.list({ limit: 1 });
|
|
164
167
|
const git = gitRunner(project.folder, { ...process.env, ...ctx.io.env }, ctx.signal);
|
|
@@ -166,32 +169,46 @@ export async function createConversationRecord(ctx, folder, harness, route, name
|
|
|
166
169
|
const branch = await git(['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => undefined);
|
|
167
170
|
// Uncommitted edits ride along as the twin snapshot the conversation starts from (worktree-twin.ts).
|
|
168
171
|
const seed = await twinSeed(ctx.store.dir, project.folder, head, ctx.store.profile(ctx.apiUrl).project_id).catch(() => ({ commit: head, snapshot: false }));
|
|
172
|
+
seedPhase('local_snapshot');
|
|
169
173
|
const commit = seed.commit;
|
|
170
174
|
const response = await conversationClient(ctx).conversations.create({ repoRemote: project.remote ?? `file://${project.folder}`, harness, route, mode: 'new',
|
|
171
|
-
base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), ...choice, projectId: ctx.store.profile(ctx.apiUrl).project_id
|
|
175
|
+
base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), ...choice, projectId: ctx.store.profile(ctx.apiUrl).project_id,
|
|
176
|
+
...(movedIn ? { harnessSessionId: movedIn.session_id, ...(movedIn.title ? { title: movedIn.title.slice(0, 200) } : {}) } : {}) }, { idempotencyKey, signal: ctx.signal });
|
|
172
177
|
const c = validateConversation({ ...response.conversation, restore: response.restore });
|
|
173
178
|
allocated?.(response.workspace);
|
|
174
179
|
rememberTwinSeed(ctx.store.dir, c.id, project.folder, seed, branch);
|
|
175
180
|
rememberConversation(ctx, c, project.folder, false, branch);
|
|
176
181
|
return c;
|
|
177
182
|
}
|
|
178
|
-
export async function createConversation(ctx, folder, harness, route, name, choice = {}) {
|
|
183
|
+
export async function createConversation(ctx, folder, harness, route, name, choice = {}, movedIn) {
|
|
179
184
|
const project = projectIdentity(folder);
|
|
180
|
-
|
|
185
|
+
seedPhase('start');
|
|
186
|
+
// The launcher's phase line (SHARDFLUX_PROGRESS_FILE, 0.19.0+): a computer starts, then the seed's own marks move it on.
|
|
187
|
+
startProgress(harness, ctx.io.env);
|
|
188
|
+
const c = await createConversationRecord(ctx, project.folder, harness, route, name, undefined, undefined, choice, movedIn);
|
|
189
|
+
seedPhase('conversation_create');
|
|
181
190
|
try {
|
|
182
191
|
const ws = await conversationWorkspace(ctx, c);
|
|
192
|
+
seedPhase('vm_running');
|
|
193
|
+
// A moved-in session's transcript is in place before the repository: its guest resumes it once the seed is done.
|
|
194
|
+
await movedIn?.beforeSeed(ws, c);
|
|
183
195
|
// This is a one-time seed. A conversation never watches or writes the owner's checkout.
|
|
184
196
|
await seedAgentRepository(ctx, ws, project.folder, { root: c.worktree_path, populate: true });
|
|
185
197
|
const cell = ws.cell();
|
|
198
|
+
seedPhase('seed_done');
|
|
186
199
|
await sendTwinSeed(ctx.store.dir, cell, c, project.folder, ctx.signal);
|
|
200
|
+
seedPhase('twin_seed');
|
|
187
201
|
const result = await cell.exec.run(twinCheckoutArgv(ctx.store.dir, c), { timeoutMs: 30_000, signal: ctx.signal });
|
|
188
202
|
if (result.exitCode !== 0)
|
|
189
203
|
throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
|
|
204
|
+
seedPhase('branch_checkout');
|
|
190
205
|
await recordSeedSnapshot(ws, c, ctx.signal);
|
|
206
|
+
seedPhase('seed_snapshot');
|
|
191
207
|
return { conversation: c, workspace: ws };
|
|
192
208
|
}
|
|
193
209
|
catch (error) {
|
|
194
|
-
|
|
210
|
+
// A failed move leaves nothing behind: its session can move again (the API counts only a live VM's move).
|
|
211
|
+
if (route === 'codex-cloud' || movedIn) {
|
|
195
212
|
try {
|
|
196
213
|
await ctx.cloud().workspaces.delete(c.workspace_id);
|
|
197
214
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -37,7 +37,7 @@ export declare class GitError extends HintedError {
|
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
39
|
* A refusal `shard` detects itself, with a stable code, its facts in `details` and the next step as `hint` (exit 1),
|
|
40
|
-
* e.g. `
|
|
40
|
+
* e.g. `t3_not_installed` (0.17.0+) when T3 Code is missing.
|
|
41
41
|
*/
|
|
42
42
|
export declare class CodedError extends HintedError {
|
|
43
43
|
name: string;
|
package/dist/errors.js
CHANGED
|
@@ -54,7 +54,7 @@ export class GitError extends HintedError {
|
|
|
54
54
|
}
|
|
55
55
|
/**
|
|
56
56
|
* A refusal `shard` detects itself, with a stable code, its facts in `details` and the next step as `hint` (exit 1),
|
|
57
|
-
* e.g. `
|
|
57
|
+
* e.g. `t3_not_installed` (0.17.0+) when T3 Code is missing.
|
|
58
58
|
*/
|
|
59
59
|
export class CodedError extends HintedError {
|
|
60
60
|
name = 'CodedError';
|
package/dist/files-mirror.d.ts
CHANGED
|
@@ -146,15 +146,7 @@ export declare class FilesMirror {
|
|
|
146
146
|
already: boolean;
|
|
147
147
|
}>;
|
|
148
148
|
}
|
|
149
|
-
|
|
150
|
-
* The seed snapshot (sh; $1 repository, $2 conversation name, $3 hub): HEAD plus the working tree the conversation was
|
|
151
|
-
* seeded with, committed in the conversation's VM with a temporary index (never on this computer), kept per worktree
|
|
152
|
-
* as refs/worktree/shard/seed and in the hub as refs/shard/seed/<name>. Mirrors leave out what it already contains,
|
|
153
|
-
* so the user's own uncommitted edits never show as the conversation's change. Threads on one computer are worktrees
|
|
154
|
-
* of one repository (0.16.0+), so the seed is per worktree; the shared refs/shard/seed of earlier seeds is read, and
|
|
155
|
-
* still written, only in the repository's main worktree.
|
|
156
|
-
*/
|
|
157
|
-
export declare const SEED_SNAPSHOT = "set -eu\ncd \"$1\"\nmain=0; [ \"$(git rev-parse --absolute-git-dir)\" = \"$(git rev-parse --path-format=absolute --git-common-dir)\" ] && main=1\nif git rev-parse -q --verify refs/worktree/shard/seed >/dev/null; then seed=$(git rev-parse refs/worktree/shard/seed)\nelif [ \"$main\" = 1 ] && git rev-parse -q --verify refs/shard/seed >/dev/null; then seed=$(git rev-parse refs/shard/seed); else\nidx=$(mktemp); trap 'rm -f \"$idx\"' EXIT\ncp \"$(git rev-parse --path-format=absolute --git-path index)\" \"$idx\" 2>/dev/null || { rm -f \"$idx\"; GIT_INDEX_FILE=\"$idx\" git read-tree HEAD; }\nGIT_INDEX_FILE=\"$idx\" git add -A\ntree=$(GIT_INDEX_FILE=\"$idx\" git write-tree)\nif [ \"$tree\" = \"$(git rev-parse 'HEAD^{tree}')\" ]; then seed=$(git rev-parse HEAD); else\nseed=$(GIT_AUTHOR_NAME=Shardflux GIT_AUTHOR_EMAIL=seed@shardflux.dev GIT_COMMITTER_NAME=Shardflux GIT_COMMITTER_EMAIL=seed@shardflux.dev git commit-tree \"$tree\" -p HEAD -m \"Shardflux: the working tree this conversation started from\"); fi\ngit update-ref refs/worktree/shard/seed \"$seed\"\nif [ \"$main\" = 1 ]; then git update-ref refs/shard/seed \"$seed\"; fi\nfi\nif [ -d \"$3\" ]; then git -c core.hooksPath=/dev/null push -q \"$3\" \"+$seed:refs/shard/seed/$2\" >/dev/null 2>&1 || true; fi\nprintf '%s\n' \"$seed\"\n";
|
|
149
|
+
export declare const SEED_SNAPSHOT = "set -eu\ncd \"$1\"\nmain=0; [ \"$(git rev-parse --absolute-git-dir)\" = \"$(git rev-parse --path-format=absolute --git-common-dir)\" ] && main=1\nif git rev-parse -q --verify refs/worktree/shard/seed >/dev/null; then seed=$(git rev-parse refs/worktree/shard/seed)\nelif [ \"$main\" = 1 ] && git rev-parse -q --verify refs/shard/seed >/dev/null; then seed=$(git rev-parse refs/shard/seed); else\nidx=$(mktemp); trap 'rm -f \"$idx\"' EXIT\ncp \"$(git rev-parse --path-format=absolute --git-path index)\" \"$idx\" 2>/dev/null || { rm -f \"$idx\"; GIT_INDEX_FILE=\"$idx\" git read-tree HEAD; }\nGIT_INDEX_FILE=\"$idx\" git add -A\ntree=$(GIT_INDEX_FILE=\"$idx\" git write-tree)\nif [ \"$tree\" = \"$(git rev-parse 'HEAD^{tree}')\" ]; then seed=$(git rev-parse HEAD); else\nseed=$(GIT_AUTHOR_NAME=Shardflux GIT_AUTHOR_EMAIL=seed@shardflux.dev GIT_COMMITTER_NAME=Shardflux GIT_COMMITTER_EMAIL=seed@shardflux.dev git commit-tree \"$tree\" -p HEAD -m \"Shardflux: the working tree this conversation started from\"); fi\ngit update-ref refs/worktree/shard/seed \"$seed\"\nif [ \"$main\" = 1 ]; then git update-ref refs/shard/seed \"$seed\"; fi\nfi\npush='set -u\nseed=$1; name=$2; hub=$3; cd \"$4\"\npush=1; if [ \"$(git config --bool remote.origin.promisor 2>/dev/null || true)\" = true ]; then push=0\nfor c in \"$seed\" \"$seed^1\"; do o=$(git rev-parse -q --verify \"$c^{commit}\" 2>/dev/null) && git --git-dir=\"$hub\" cat-file -e \"$o^{commit}\" 2>/dev/null && push=1 && break; done; fi\nif [ -d \"$hub\" ] && [ \"$push\" = 1 ]; then git -c core.hooksPath=/dev/null push -q \"$hub\" \"+$seed:refs/shard/seed/$name\" >/dev/null 2>&1 || true; fi\n'\nif [ \"${4:-}\" = background ]; then\n if command -v setsid >/dev/null 2>&1; then (setsid sh -c \"$push\" shard-seed-push \"$seed\" \"$2\" \"$3\" \"$PWD\" >/dev/null 2>&1 </dev/null &)\n else (sh -c \"$push\" shard-seed-push \"$seed\" \"$2\" \"$3\" \"$PWD\" >/dev/null 2>&1 </dev/null &); fi\nelse sh -c \"$push\" shard-seed-push \"$seed\" \"$2\" \"$3\" \"$PWD\"; fi\nprintf '%s\n' \"$seed\"\n";
|
|
158
150
|
/** Best effort: without a snapshot the folder also lists the start state's own edits. */
|
|
159
151
|
export declare function recordSeedSnapshot(ws: Pick<Workspace, 'cell'>, c: Pick<Conversation, 'worktree_path' | 'branch'>, signal?: AbortSignal): Promise<string | null>;
|
|
160
152
|
/** A ready VM of the project (never woken), else the project's space reader, woken when needed. */
|