@shardflux/cli 0.14.1 → 0.14.3

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 CHANGED
@@ -1,11 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.3
4
+
5
+ - Poll device requests and deliver approvals independently of transcript mirroring, so a slow or failed copy cannot delay file-push approvals. Isolate mirror failures by conversation, retry with exponential backoff, and log each failure once per conversation revision with the affected entry name.
6
+ - Skip unknown, nested, symlink and oversized transcript entries while mirroring safe top-level JSONL and JSON files. Log each skipped name once per conversation, preserve the empty append-lock exception, and refuse symlink destinations or ancestors inside the stash.
7
+
8
+ ## 0.14.2
9
+
10
+ - `shard agents open` accepts conversation keys, VM keys and conversation ids, reattaching the existing Claude Code or Codex session in its conversation worktree. The menu bar's Open action uses the same path; legacy agents retain their existing behavior.
11
+ - Start local projects without Git history: initialize a repository on `main` when needed and create one empty initial commit, preserving existing files and staged work. Use a command-local Shardflux identity when Git identity is missing, and report actionable `git_error` failures.
12
+
3
13
  ## 0.14.1
4
14
 
5
15
  - `shard ssh-proxy` connects faster: host trust (host key pin and device key) runs alongside the tunnel start, with no guest byte reaching OpenSSH before the host key is pinned, and a reconnect checks its device key with one read.
6
16
 
7
17
  ## 0.14.0
8
18
 
19
+ - Mirror native conversation transcripts when the guest includes its empty append-lock file.
20
+
9
21
  - Ask for device approval before preparing the VM-backed local worktree; report a failed preparation as the request's result and bound preparation to 30 seconds.
10
22
  - Keep desktop programs running after a command returns with detached `setsid -f` guidance.
11
23
  - Allocate one VM per new ACP conversation, with cached initial model choices and live updates from that same VM.
package/README.md CHANGED
@@ -81,6 +81,7 @@ Your branch opens in `.shard/worktrees/<name>`, syncs both ways, and its dev ser
81
81
  Ctrl-C stops local sync. Your main checkout stays available for your own work.
82
82
  Device tools ask Allow once or Always for this project on your Mac before preparing the conversation’s local worktree. Live transcripts stay in
83
83
  `~/.config/shardflux/conversations/`; ended sessions enter Claude Code’s or Codex’s native history. Transcript files up to 64 MiB are supported (0.14.0+).
84
+ From 0.14.3, transcript copying runs independently of device approvals. Unknown entries are skipped, and failed copies retry with backoff; diagnostics name the conversation and affected entry.
84
85
 
85
86
  For application workspace commands, `shard signup` creates an account and saves a project key, or set
86
87
  `SHARDFLUX_API_KEY` to a key from the console and run `shard login`. `shard --help` lists every command.
@@ -1153,6 +1154,9 @@ Set `--reachable 30m` when opening a workspace to choose how long waiting agents
1153
1154
  ## Cloud agents (0.13.2+)
1154
1155
 
1155
1156
  Run `shard ./` in your project to start Claude Code or Codex, usable immediately while setup runs in the background.
1157
+
1158
+ New project folders are ready to use: `shard ./` and `shard agents new` initialize Git on `main` when needed and create an empty initial commit (CLI 0.14.2+). Existing files and staged work stay yours; the initial commit contains no files. Repositories without commits keep their current branch. If Git identity is missing, that one commit uses `Shardflux <noreply@shardflux.dev>` without changing your Git configuration.
1159
+
1156
1160
  Your **machine** carries credentials, tools, dotfiles, logins and agent config; the project's **template** carries
1157
1161
  toolchains, dependencies, services and project secrets. Both are private filesystem templates and include secrets
1158
1162
  as ordinary files. Browser logins and Claude/Codex config belong to the machine. Set up tools and secrets normally inside the VM;
@@ -8,6 +8,7 @@ import { AgentStateStore } from "./agent-state.js";
8
8
  import { attachTerminal, cellConnector } from "./agent-terminal.js";
9
9
  import { prepareProjectTemplate } from "./project-template-setup.js";
10
10
  import { projectIdentity, projectLabels } from "./project-identity.js";
11
+ import { ensureProjectGit } from "./project-git.js";
11
12
  import { json } from "./format.js";
12
13
  import { resolveWorkspace } from "./commands.js";
13
14
  import { savedMachine } from "./filesystem-state.js";
@@ -21,6 +22,8 @@ import { rememberLaunch } from "./agent-live.js";
21
22
  import { prepareAgentMachine } from "./agent-machine.js";
22
23
  import { installedTargets } from "./agent-targets.js";
23
24
  import { openDesktopWorkspace, queueT3Prewarm } from "./agent-target-commands.js";
25
+ import { conversationWorkspace, conversationsUnavailable, getConversation, listConversations, resumeConversation } from "./conversations.js";
26
+ import { conversationTerminal } from "./conversation-terminal.js";
24
27
  const active = (session) => session.state === 'running' || session.state === 'starting';
25
28
  /** `<slug>-<6 hex of sha256(identity)>`: the same repository gets the same key on every machine. */
26
29
  export function disambiguatedSlug(slug, identity) {
@@ -169,6 +172,10 @@ export const launchProject = async (ctx, values, [path = './', explicitApp]) =>
169
172
  if (agent !== 'main')
170
173
  agentName(agent);
171
174
  const labels = projectLabels(project, agent, app);
175
+ const local = like ? projectIdentity(path) : project;
176
+ // A cloud-only --like launch must not initialize an unrelated local checkout.
177
+ if (!like?.labels.repo_remote || like.labels.repo_remote === (local.remote ?? `file://${local.folder}`))
178
+ await ensureProjectGit(ctx, local.folder);
172
179
  const cloud = ctx.cloud();
173
180
  let existing = await cloud.workspaces.findByKey(`${project.slug}/${agent}`, { includeDeleted: false, signal: ctx.signal });
174
181
  const owner = agent === 'main' ? existing : like ?? await cloud.workspaces.findByKey(`${project.slug}/main`, { includeDeleted: false, signal: ctx.signal });
@@ -237,18 +244,52 @@ export const AGENT_HANDLERS = {
237
244
  }
238
245
  await syncCredentials(ctx);
239
246
  let ws;
240
- if (agent.includes('/'))
241
- ws = await ctx.cloud().workspace(agent, { create: false }).open();
247
+ let conversation;
248
+ if (/^conv_[a-z0-9]{26}$/.test(agent)) {
249
+ conversation = await resumeConversation(ctx, await getConversation(ctx, agent));
250
+ ws = await conversationWorkspace(ctx, conversation);
251
+ }
242
252
  else {
243
- const project = projectIdentity('./');
244
- if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(agent))
245
- throw new UsageError('agent must be a workspace key or an agent name');
246
- ws = await ctx.cloud().workspace(`${project.slug}/${agent}`, { create: false }).open();
253
+ let key = agent;
254
+ if (!agent.includes('/')) {
255
+ const project = projectIdentity('./');
256
+ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(agent))
257
+ throw new UsageError('agent must be a workspace key or an agent name');
258
+ key = `${project.slug}/${agent}`;
259
+ }
260
+ try {
261
+ ws = await ctx.cloud().workspace(key, { create: false }).open();
262
+ }
263
+ catch (error) {
264
+ if (!missing(error))
265
+ throw error;
266
+ // A conversation key differs from its VM key (and survives VM moves).
267
+ let matches;
268
+ try {
269
+ matches = (await listConversations(ctx)).filter(c => c.key === key);
270
+ }
271
+ catch (e) {
272
+ if (conversationsUnavailable(e))
273
+ throw error;
274
+ throw e;
275
+ }
276
+ if (!matches.length)
277
+ throw error;
278
+ if (matches.length !== 1)
279
+ throw new UsageError('Use the conversation id.');
280
+ conversation = await resumeConversation(ctx, matches[0]);
281
+ ws = await conversationWorkspace(ctx, conversation);
282
+ }
283
+ // The menu bar supplies workspace_key from agents list, not the conversation key.
284
+ if (!conversation && ws.labels.shard_conversation) {
285
+ conversation = await resumeConversation(ctx, await getConversation(ctx, ws.labels.shard_conversation));
286
+ ws = await conversationWorkspace(ctx, conversation);
287
+ }
247
288
  }
248
289
  if (values.reachable !== undefined)
249
290
  ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
250
291
  const projectKey = typeof values.project === 'string' ? values.project : ws.labels.repo_remote || ws.labels.shard_project || ws.key;
251
- const app = values.app ?? new AgentStateStore(ctx.store.dir).app(projectKey, ws.labels.app === 'codex' ? 'codex' : 'claude');
292
+ const app = conversation?.harness ?? values.app ?? new AgentStateStore(ctx.store.dir).app(projectKey, ws.labels.app === 'codex' ? 'codex' : 'claude');
252
293
  if (app === 'claude' && !await cloudClaudeLogin(ctx)) {
253
294
  progress(ctx, 'Signing in to cloud Claude (once per account)…');
254
295
  await loginClaudeInWorkspace(ctx, ws);
@@ -258,6 +299,8 @@ export const AGENT_HANDLERS = {
258
299
  if (ws.labels.shard_layer !== 'machine' && !(await savedMachine(ctx))?.template)
259
300
  await syncAgentConfig(ctx, ws);
260
301
  await prepareAgentMachine(ctx, ws);
302
+ if (conversation)
303
+ return conversationTerminal(ctx, ws, conversation, projectKey, values);
261
304
  return terminal(ctx, ws, app, (ws.labels.agent ?? agent).split('/').at(-1), projectKey, values);
262
305
  },
263
306
  };
@@ -16,7 +16,7 @@ export interface GitRepository {
16
16
  }
17
17
  export type GitRepositories = Record<string, GitRepository>;
18
18
  export type GitRun = (args: string[]) => Promise<string>;
19
- export declare function gitRunner(root: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal): GitRun;
19
+ export declare function gitRunner(root: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal, remoteHelper?: boolean): GitRun;
20
20
  export declare function agentName(name: string): string;
21
21
  export declare class AgentGit {
22
22
  readonly root: string;
package/dist/agent-git.js CHANGED
@@ -2,18 +2,25 @@ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join, resolve } from 'node:path';
5
- import { UsageError } from "./errors.js";
5
+ import { GitError, UsageError } from "./errors.js";
6
6
  import { AgentStateStore } from "./agent-state.js";
7
7
  import { gitPath } from "./git-remote-helper.js";
8
8
  const execute = promisify(execFile);
9
9
  const DAY = 86_400_000;
10
- export function gitRunner(root, env = process.env, signal) {
10
+ export function gitRunner(root, env = process.env, signal, remoteHelper = true) {
11
11
  return async (args) => {
12
- const result = await execute('git', ['--no-pager', '-C', root, ...args], {
13
- env: { ...env, PATH: gitPath(env), GIT_TERMINAL_PROMPT: '0', GIT_MERGE_AUTOEDIT: 'no' },
14
- timeout: 120_000, maxBuffer: 4 * 1024 * 1024, signal,
15
- });
16
- return result.stdout.trim();
12
+ try {
13
+ const result = await execute('git', ['--no-pager', '-C', root, ...args], {
14
+ env: { ...env, PATH: remoteHelper ? gitPath(env) : env.PATH, GIT_TERMINAL_PROMPT: '0', GIT_MERGE_AUTOEDIT: 'no' },
15
+ timeout: 120_000, maxBuffer: 4 * 1024 * 1024, signal,
16
+ });
17
+ return result.stdout.trim();
18
+ }
19
+ catch (error) {
20
+ if (signal?.aborted)
21
+ throw error;
22
+ throw new GitError(root, error);
23
+ }
17
24
  };
18
25
  }
19
26
  export function agentName(name) {
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { join, resolve } from 'node:path';
5
- import { CodedError } from "./errors.js";
5
+ import { CodedError, UsageError } from "./errors.js";
6
6
  import { AgentStateStore } from "./agent-state.js";
7
7
  import { projectIdentity } from "./project-identity.js";
8
8
  import { localManifest, projectGit } from "./project-sync-manifest.js";
@@ -135,7 +135,7 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
135
135
  return;
136
136
  }
137
137
  if (top !== folder)
138
- throw new Error('project sync requires the Git repository root; run shard there');
138
+ throw new UsageError('project sync requires the Git repository root; run shard there');
139
139
  const existing = await cell.exec.run(['git', '-C', root, 'rev-parse', '--verify', 'HEAD'], { timeoutMs: 5000, signal });
140
140
  if (existing.exitCode !== 0 && existing.exitCode !== 128)
141
141
  throw new Error('could not check workspace Git history');
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.14.1";
1
+ export declare const CLI_VERSION = "0.14.3";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
- export const CLI_VERSION = '0.14.1';
15
+ export const CLI_VERSION = '0.14.3';
16
16
  export const GLOBAL_OPTIONS = {
17
17
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
18
18
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -4,8 +4,18 @@ import type { Conversation, LocalConversation } from './conversations.js';
4
4
  export declare function rewriteTranscript(bytes: Uint8Array, remoteCwd: string, localCwd: string): Uint8Array;
5
5
  export declare function transcriptDestination(c: Conversation, folder: string, filename: string, env: Record<string, string | undefined>): string;
6
6
  /** Keep growing copies away from native session discovery until the control plane ends the conversation. */
7
- export declare function mirrorConversation(ctx: Ctx, record: LocalConversation, source: Workspace): Promise<void>;
7
+ export declare function mirrorConversation(ctx: Ctx, record: LocalConversation, source: Workspace, skipped?: Set<string>): Promise<void>;
8
8
  export declare function handoffConversation(ctx: Pick<Ctx, 'io' | 'store' | 'apiUrl' | 'signal'>, c: Conversation, folder: string, dir: string, source?: Pick<Workspace, 'key'> | {
9
9
  hub: string;
10
10
  }): Promise<void>;
11
- export declare function mirrorProjectConversations(ctx: Ctx, mirrored?: Map<string, string>): Promise<void>;
11
+ export declare class ConversationMirrorState {
12
+ readonly mirrored: Map<string, string>;
13
+ readonly skipped: Map<string, Set<string>>;
14
+ readonly retries: Map<string, {
15
+ revision: string;
16
+ failures: number;
17
+ retryAt: number;
18
+ reported: Set<string>;
19
+ }>;
20
+ }
21
+ export declare function mirrorProjectConversations(ctx: Ctx, state?: ConversationMirrorState): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { conversationName, listConversations, localConversations, rememberConversation } from "./conversations.js";
@@ -45,31 +45,79 @@ function privateDirectory(path) {
45
45
  at = parent;
46
46
  }
47
47
  if (existsSync(at) && lstatSync(at).isSymbolicLink())
48
- throw new Error('Transcript directory must not be a symlink.');
48
+ throw new Error(`Transcript directory must not be a symlink: ${at}`);
49
49
  mkdirSync(path, { recursive: true, mode: 0o700 });
50
50
  if (lstatSync(path).isSymbolicLink())
51
- throw new Error('Transcript directory must not be a symlink.');
51
+ throw new Error(`Transcript directory must not be a symlink: ${path}`);
52
+ }
53
+ function readStashFile(file) {
54
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
55
+ try {
56
+ if (!fstatSync(fd).isFile())
57
+ throw new Error(`Stash entry is not a regular file: ${file}`);
58
+ return readFileSync(fd);
59
+ }
60
+ finally {
61
+ closeSync(fd);
62
+ }
52
63
  }
53
64
  /** Keep growing copies away from native session discovery until the control plane ends the conversation. */
54
- export async function mirrorConversation(ctx, record, source) {
65
+ export async function mirrorConversation(ctx, record, source, skipped = new Set()) {
55
66
  const c = record.conversation;
56
- const dir = join(ctx.store.dir, 'conversations', c.project_id, c.id), root = `/shardflux/project/transcripts/${c.id}`;
67
+ if (!/^[a-zA-Z0-9_-]+$/.test(c.project_id) || !/^conv_[a-z0-9]{26}$/.test(c.id))
68
+ throw new Error('Transcript stash identity is unsafe.');
69
+ let dir = ctx.store.dir;
70
+ // Check each stash component before creating children; no ancestor inside the stash may be a symlink.
57
71
  privateDirectory(dir);
72
+ for (const part of ['conversations', c.project_id, c.id]) {
73
+ dir = join(dir, part);
74
+ privateDirectory(dir);
75
+ }
76
+ const root = `/shardflux/project/transcripts/${c.id}`;
77
+ const skip = (name, reason) => {
78
+ if (skipped.has(name))
79
+ return;
80
+ skipped.add(name);
81
+ ctx.io.stderr.write(`Transcript mirror ${c.id}: skipped entry ${JSON.stringify(name)} (${reason}).\n`);
82
+ };
58
83
  const cell = source.cell({ wake: null });
84
+ const close = () => cell.close();
85
+ ctx.signal.addEventListener('abort', close, { once: true });
59
86
  try {
87
+ ctx.signal.throwIfAborted();
60
88
  const manifest = await cell.files.manifest(root, { maxFileBytes: MAX, signal: ctx.signal });
61
- if (manifest.skipped.length)
62
- throw new Error('Project transcript copy includes files outside the mirror bound.');
89
+ for (const entry of manifest.skipped)
90
+ skip(entry.path, entry.reason);
63
91
  let count = 0;
64
92
  for (const entry of manifest.entries) {
65
- if (entry.symlink || entry.path.includes('/') || !/^[a-zA-Z0-9_.-]+\.(jsonl|json)$/.test(entry.path))
66
- throw new Error('Project transcript copy has an unsafe entry.');
67
- const file = join(dir, entry.path), expected = existsSync(file) ? createHash('sha256').update(readFileSync(file)).digest('hex') : '';
68
- if (expected !== entry.sha256) {
69
- const bytes = await cell.files.read(`${root}/${entry.path}`, { length: Math.min(MAX + 1, entry.size + 1) });
70
- if (bytes.length !== entry.size || createHash('sha256').update(bytes).digest('hex') !== entry.sha256)
71
- throw new Error('Transcript changed during copying; retaining the previous stash.');
72
- writePrivateFile(dir, file, new TextDecoder('utf8', { fatal: true }).decode(bytes));
93
+ // Flush holds this empty regular file while appending the transcript.
94
+ // It is synchronization metadata, not native history to mirror.
95
+ if (entry.path === '.lock' && !entry.symlink && entry.size === 0 && entry.sha256 === 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
96
+ continue;
97
+ if (entry.symlink || entry.path.includes('..') || entry.path.includes('/') || !/^[a-zA-Z0-9_.-]+\.(jsonl|json)$/.test(entry.path)) {
98
+ skip(entry.path, 'not a safe top-level JSONL or JSON file');
99
+ continue;
100
+ }
101
+ if (!Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > MAX) {
102
+ skip(entry.path, 'outside the 64 MiB mirror bound');
103
+ continue;
104
+ }
105
+ try {
106
+ const file = join(dir, entry.path), existing = lstatSync(file, { throwIfNoEntry: false });
107
+ if (existing && !existing.isFile()) {
108
+ skip(entry.path, 'stash destination is not a regular file');
109
+ continue;
110
+ }
111
+ const expected = existing ? createHash('sha256').update(readStashFile(file)).digest('hex') : '';
112
+ if (expected !== entry.sha256) {
113
+ const bytes = await cell.files.read(`${root}/${entry.path}`, { length: entry.size + 1 });
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
+ }
118
+ }
119
+ catch (e) {
120
+ throw new Error(`Entry ${JSON.stringify(entry.path)}: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
73
121
  }
74
122
  if (entry.path.endsWith('.jsonl'))
75
123
  count++;
@@ -80,26 +128,27 @@ export async function mirrorConversation(ctx, record, source) {
80
128
  const binding = await conversationClient(ctx).workspaces.get(c.workspace_id);
81
129
  if (!binding.data.deleted_at)
82
130
  return;
83
- const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8'));
84
- if (!c.harness_session_id || meta.harness !== c.harness || meta.harness_session_id !== c.harness_session_id || !/^[a-zA-Z0-9_.-]+\.jsonl$/.test(meta.file))
85
- throw new Error('Transcript handoff metadata does not match the ended conversation.');
86
- const bytes = readFileSync(join(dir, meta.file));
131
+ const meta = JSON.parse(readStashFile(join(dir, 'meta.json')).toString('utf8'));
132
+ if (!meta || !c.harness_session_id || meta.harness !== c.harness || meta.harness_session_id !== c.harness_session_id || typeof meta.file !== 'string' || meta.file.includes('..') || !/^[a-zA-Z0-9_.-]+\.jsonl$/.test(meta.file))
133
+ throw new Error('Entry "meta.json": transcript handoff metadata does not match the ended conversation.');
134
+ const bytes = readStashFile(join(dir, meta.file));
87
135
  if (bytes.length !== meta.bytes || createHash('sha256').update(bytes).digest('hex') !== meta.sha256)
88
- throw new Error('Transcript handoff checkpoint differs from its durable metadata.');
136
+ throw new Error(`Entry ${JSON.stringify(meta.file)}: transcript handoff checkpoint differs from its durable metadata.`);
89
137
  await handoffConversation(ctx, { ...c, worktree_path: meta.cwd }, record.folder, dir, source);
90
138
  }
91
139
  }
92
140
  finally {
141
+ ctx.signal.removeEventListener('abort', close);
93
142
  cell.close();
94
143
  }
95
144
  }
96
145
  export async function handoffConversation(ctx, c, folder, dir, source) {
97
146
  if (c.state !== 'done' && c.state !== 'archived')
98
147
  throw new Error('Only an ended conversation can enter native history.');
99
- const files = readdirSync(dir).filter(f => f.endsWith('.jsonl'));
148
+ const files = readdirSync(dir).filter(f => !f.includes('..') && /^[a-zA-Z0-9_.-]+\.jsonl$/.test(f) && lstatSync(join(dir, f)).isFile());
100
149
  if (!files.length)
101
150
  throw new Error('Conversation has no transcript to hand off.');
102
- const writes = files.map(name => ({ dest: transcriptDestination(c, folder, name, ctx.io.env), bytes: rewriteTranscript(readFileSync(join(dir, name)), c.worktree_path, folder) }));
151
+ const writes = files.map(name => ({ dest: transcriptDestination(c, folder, name, ctx.io.env), bytes: rewriteTranscript(readStashFile(join(dir, name)), c.worktree_path, folder) }));
103
152
  // Check every destination before the first write. Preserve local native history on any collision.
104
153
  for (const { dest, bytes } of writes) {
105
154
  if (existsSync(dest) && (lstatSync(dest).isSymbolicLink() || !readFileSync(dest).equals(Buffer.from(bytes))))
@@ -119,7 +168,12 @@ export async function handoffConversation(ctx, c, folder, dir, source) {
119
168
  rememberConversation(ctx, c, folder, true);
120
169
  writePrivateFile(dir, join(dir, 'handoff.json'), JSON.stringify({ conversation_id: c.id, branch: c.branch, destinations: writes.map(w => w.dest), at: new Date().toISOString() }) + '\n');
121
170
  }
122
- export async function mirrorProjectConversations(ctx, mirrored = new Map()) {
171
+ export class ConversationMirrorState {
172
+ mirrored = new Map();
173
+ skipped = new Map();
174
+ retries = new Map();
175
+ }
176
+ export async function mirrorProjectConversations(ctx, state = new ConversationMirrorState()) {
123
177
  const records = localConversations(ctx);
124
178
  if (!Object.keys(records).length)
125
179
  return;
@@ -129,30 +183,57 @@ export async function mirrorProjectConversations(ctx, mirrored = new Map()) {
129
183
  if (!c || record.handed_off || !c.harness_session_id)
130
184
  continue;
131
185
  const revision = JSON.stringify([c.workspace_id, c.harness_session_id, c.state, c.durable, c.last_activity_at, c.summary]);
132
- if (mirrored.get(c.id) === revision)
186
+ if (state.mirrored.get(c.id) === revision)
133
187
  continue;
134
- rememberConversation(ctx, c, record.folder);
135
- let source;
136
- for (const candidate of [c, ...all.filter(other => other.project_id === c.project_id && other.id !== c.id)]) {
137
- try {
138
- const ws = await conversationClient(ctx).workspaces.get(candidate.workspace_id);
139
- if (!ws.data.deleted_at && ws.ready) {
140
- source = ws;
141
- break;
188
+ let retry = state.retries.get(c.id);
189
+ if (!retry || retry.revision !== revision) {
190
+ retry = { revision, failures: 0, retryAt: 0, reported: new Set() };
191
+ state.retries.set(c.id, retry);
192
+ }
193
+ if (Date.now() < retry.retryAt)
194
+ continue;
195
+ try {
196
+ rememberConversation(ctx, c, record.folder);
197
+ let source;
198
+ for (const candidate of [c, ...all.filter(other => other.project_id === c.project_id && other.id !== c.id)]) {
199
+ try {
200
+ const ws = await conversationClient(ctx).workspaces.get(candidate.workspace_id);
201
+ if (!ws.data.deleted_at && ws.ready) {
202
+ source = ws;
203
+ break;
204
+ }
142
205
  }
206
+ catch { /* Another binding may be available. */ }
207
+ }
208
+ if (!source) {
209
+ const client = conversationClient(ctx);
210
+ const reader = await client.conversations.spaceReader(c.project_id, { signal: ctx.signal });
211
+ source = await client.workspaces.get(reader.workspace.id);
212
+ if (!source.ready)
213
+ await source.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
214
+ }
215
+ let skipped = state.skipped.get(c.id);
216
+ if (!skipped) {
217
+ skipped = new Set();
218
+ state.skipped.set(c.id, skipped);
143
219
  }
144
- catch { /* Another binding may be available. */ }
220
+ await mirrorConversation(ctx, { ...record, conversation: c }, source, skipped);
221
+ retry.failures = 0;
222
+ retry.retryAt = 0;
223
+ // An ended record may precede its VM tombstone; retry until the handoff receipt is written.
224
+ if (c.state !== 'done' && c.state !== 'archived')
225
+ state.mirrored.set(c.id, revision);
145
226
  }
146
- if (!source) {
147
- const client = conversationClient(ctx);
148
- const reader = await client.conversations.spaceReader(c.project_id, { signal: ctx.signal });
149
- source = await client.workspaces.get(reader.workspace.id);
150
- if (!source.ready)
151
- await source.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
227
+ catch (e) {
228
+ if (ctx.signal.aborted)
229
+ return;
230
+ const message = e instanceof Error ? e.message : String(e);
231
+ const backoff = Math.min(300_000, 5000 * 2 ** Math.min(retry.failures++, 6));
232
+ retry.retryAt = Date.now() + backoff;
233
+ if (!retry.reported.has(message)) {
234
+ retry.reported.add(message);
235
+ ctx.io.stderr.write(`Transcript mirror ${c.id}: ${message} (retrying in ${backoff / 1000}s).\n`);
236
+ }
152
237
  }
153
- await mirrorConversation(ctx, { ...record, conversation: c }, source);
154
- // An ended record may precede its VM tombstone; retry until the handoff receipt is written.
155
- if (c.state !== 'done' && c.state !== 'archived')
156
- mirrored.set(c.id, revision);
157
238
  }
158
239
  }
@@ -1,3 +1,8 @@
1
- import type { Handler } from './commands.js';
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
+ /** 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>;
2
7
  /** Select a durable conversation, then use the harness's native terminal for all interaction. */
3
8
  export declare const launchConversationProject: Handler;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { AgentStateStore } from "./agent-state.js";
3
3
  import { projectIdentity, projectLabels } from "./project-identity.js";
4
- import { createConversation, conversationWorkspace, conversationsUnavailable, listConversations, localConversations, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
4
+ import { createConversation, conversationName, conversationWorkspace, conversationsUnavailable, listConversations, localConversations, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
5
5
  import { terminal } from "./agent-commands.js";
6
6
  import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
7
7
  import { syncAgentCredentials } from "./agent-credentials.js";
@@ -13,6 +13,11 @@ import { ensureMenubar, MenubarManager } from "./menubar.js";
13
13
  import { UsageError } from "./errors.js";
14
14
  import { localResumeTerminal } from "./conversation-local-resume.js";
15
15
  import { agentReachability } from "./agent-reachability.js";
16
+ /** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
17
+ export async function conversationTerminal(ctx, ws, c, project, values) {
18
+ await restoreConversationBinding(ctx, c, ws);
19
+ return terminal(ctx, ws, c.harness, conversationName(c), project, values, undefined, c.worktree_path, { id: c.id, session: c.harness_session_id });
20
+ }
16
21
  /** Select a durable conversation, then use the harness's native terminal for all interaction. */
17
22
  export const launchConversationProject = async (ctx, values, [path = './', explicit]) => {
18
23
  const project = projectIdentity(path), store = new AgentStateStore(ctx.store.dir);
@@ -35,8 +40,7 @@ export const launchConversationProject = async (ctx, values, [path = './', expli
35
40
  const next = await resumeConversation(ctx, c);
36
41
  rememberConversation(ctx, next, project.folder);
37
42
  const ws = await conversationWorkspace(ctx, next);
38
- await restoreConversationBinding(ctx, next, ws);
39
- return terminal(ctx, ws, app, next.key.split('/').at(-1), project.remote ?? project.folder, values, undefined, next.worktree_path, { id: next.id, session: next.harness_session_id });
43
+ return conversationTerminal(ctx, ws, next, project.remote ?? project.folder, values);
40
44
  }
41
45
  await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
42
46
  const signedIn = app === 'claude' ? await cloudClaudeLogin(ctx) : true;
@@ -70,5 +74,5 @@ export const launchConversationProject = async (ctx, values, [path = './', expli
70
74
  if (!(await savedMachine(ctx))?.template)
71
75
  await syncAgentConfig(ctx, ws);
72
76
  await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
73
- return terminal(ctx, ws, app, c.key.split('/').at(-1), project.remote ?? project.folder, values, undefined, c.worktree_path, { id: c.id, session: c.harness_session_id });
77
+ return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values);
74
78
  };
@@ -3,6 +3,7 @@ import { ShardfluxApiError } from '@shardflux/sdk';
3
3
  import { AgentStateStore } from "./agent-state.js";
4
4
  import { gitRunner } from "./agent-git.js";
5
5
  import { projectIdentity } from "./project-identity.js";
6
+ import { ensureProjectGit } from "./project-git.js";
6
7
  import { seedAgentRepository } from "./agent-repository.js";
7
8
  import { UsageError } from "./errors.js";
8
9
  import { conversationClient } from "./conversation-commands.js";
@@ -87,7 +88,8 @@ export async function restoreConversationBinding(ctx, c, ws) {
87
88
  }
88
89
  export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID()) {
89
90
  const project = projectIdentity(folder);
90
- // Detect the optional control-plane feature before applying the new Git contract to old APIs.
91
+ await ensureProjectGit(ctx, project.folder);
92
+ // Both the conversation API and its legacy workspace fallback need a usable local HEAD.
91
93
  await conversationClient(ctx).conversations.list({ limit: 1 });
92
94
  const git = gitRunner(project.folder, { ...process.env, ...ctx.io.env }, ctx.signal);
93
95
  const commit = await git(['rev-parse', 'HEAD']);
@@ -1,6 +1,6 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { DeviceExecutor, askConnectedAcp } from "./device-bridge.js";
3
- import { mirrorProjectConversations } from "./conversation-stash.js";
3
+ import { ConversationMirrorState, mirrorProjectConversations } from "./conversation-stash.js";
4
4
  import { withTargetLock } from "./target-lock.js";
5
5
  import { join } from 'node:path';
6
6
  import { conversationsUnavailable } from "./conversations.js";
@@ -49,7 +49,7 @@ export async function runDeviceHelper(ctx) {
49
49
  });
50
50
  };
51
51
  const working = new Map();
52
- const mirrored = new Map();
52
+ const mirrored = new ConversationMirrorState();
53
53
  const deviceFile = join(ctx.store.dir, 'device-id');
54
54
  let deviceId;
55
55
  try {
@@ -61,8 +61,32 @@ export async function runDeviceHelper(ctx) {
61
61
  }
62
62
  const executor = new DeviceExecutor(ctx, ask, deviceId);
63
63
  const report = (e) => { ctx.io.stderr.write(`Device bridge: ${e instanceof Error ? e.message : String(e)}\n`); };
64
+ let mirroring;
64
65
  try {
65
66
  return await withTargetLock(join(ctx.store.dir, 'device-helper-lock'), async () => {
67
+ // A slow or failed transcript copy must not delay heartbeats, request polling or approvals.
68
+ mirroring = (async () => {
69
+ let failures = 0;
70
+ const reported = new Set();
71
+ while (!signal.aborted) {
72
+ let wait = 1000;
73
+ try {
74
+ await mirrorProjectConversations(ctx, mirrored);
75
+ failures = 0;
76
+ }
77
+ catch (e) {
78
+ if (signal.aborted)
79
+ break;
80
+ wait = Math.min(300_000, 5000 * 2 ** Math.min(failures++, 6));
81
+ const message = e instanceof Error ? e.message : String(e);
82
+ if (!conversationsUnavailable(e) && !reported.has(message)) {
83
+ reported.add(message);
84
+ ctx.io.stderr.write(`Transcript mirror discovery: ${message} (retrying in ${wait / 1000}s).\n`);
85
+ }
86
+ }
87
+ await delay(wait, undefined, { signal }).catch(() => undefined);
88
+ }
89
+ })();
66
90
  while (!signal.aborted) {
67
91
  try {
68
92
  for (const projectId of new Set(Object.values(localConversations(ctx)).map(r => r.project_id))) {
@@ -80,7 +104,6 @@ export async function runDeviceHelper(ctx) {
80
104
  } while (cursor && !signal.aborted);
81
105
  }
82
106
  await executor.reap();
83
- await mirrorProjectConversations(ctx, mirrored);
84
107
  }
85
108
  catch (e) {
86
109
  if (!signal.aborted && !conversationsUnavailable(e))
@@ -99,6 +122,7 @@ export async function runDeviceHelper(ctx) {
99
122
  pending.clear();
100
123
  await executor.close();
101
124
  await Promise.all([...working.values()]);
125
+ await mirroring;
102
126
  await input.catch(() => undefined);
103
127
  }
104
128
  }
package/dist/errors.d.ts CHANGED
@@ -28,6 +28,13 @@ declare class HintedError extends Error {
28
28
  export declare class UsageError extends HintedError {
29
29
  name: string;
30
30
  }
31
+ /** Keep Git exit statuses for optional probes while reporting actionable CLI errors. */
32
+ export declare class GitError extends HintedError {
33
+ name: string;
34
+ readonly code: string | number | undefined;
35
+ readonly stderr: string;
36
+ constructor(folder: string, cause: unknown);
37
+ }
31
38
  /**
32
39
  * A refusal `shard` detects itself, with a stable code, its facts in `details` and the next step as `hint` (exit 1),
33
40
  * e.g. `repository_too_large` (0.13.3+) for Git history over the initial sync limit.
package/dist/errors.js CHANGED
@@ -36,6 +36,22 @@ class HintedError extends Error {
36
36
  export class UsageError extends HintedError {
37
37
  name = 'UsageError';
38
38
  }
39
+ /** Keep Git exit statuses for optional probes while reporting actionable CLI errors. */
40
+ export class GitError extends HintedError {
41
+ name = 'GitError';
42
+ code;
43
+ stderr;
44
+ constructor(folder, cause) {
45
+ const failure = cause;
46
+ const detail = failure.stderr?.trim() || failure.message || 'Git command failed';
47
+ super(`Git failed in ${folder}: ${detail}\n${failure.code === 'ENOENT' ? 'Install Git and make sure git is on PATH, then run shard again.' : 'Resolve this Git error, then run shard again.'}`, failure.code === 'ENOENT'
48
+ ? 'Install Git and make sure git is on PATH, then run shard again.'
49
+ : `Check Git permissions, configuration and repository state with git -C ${JSON.stringify(folder)} status, resolve the error above, then run shard again.`);
50
+ this.code = failure.code;
51
+ this.stderr = failure.stderr ?? '';
52
+ this.cause = cause;
53
+ }
54
+ }
39
55
  /**
40
56
  * A refusal `shard` detects itself, with a stable code, its facts in `details` and the next step as `hint` (exit 1),
41
57
  * e.g. `repository_too_large` (0.13.3+) for Git history over the initial sync limit.
@@ -199,6 +215,8 @@ export function describeError(err) {
199
215
  return { code: 'timeout', message: err.message, last_state: err.session.state, details: { session_id: err.session.session_id } };
200
216
  if (err instanceof UsageError)
201
217
  return { code: 'usage', message: err.message };
218
+ if (err instanceof GitError)
219
+ return { code: 'git_error', message: err.message, retryable: false };
202
220
  if (err instanceof CodedError)
203
221
  return { code: err.code, message: err.message, retryable: false, ...(err.details ? { details: err.details } : {}) };
204
222
  if (err instanceof CheckoutTimeoutError) {
@@ -0,0 +1,3 @@
1
+ import type { Ctx } from './commands.js';
2
+ /** Prepare only the selected project root. Never stage files or initialize a nested repository. */
3
+ export declare function ensureProjectGit(ctx: Ctx, folder: string): Promise<void>;
@@ -0,0 +1,52 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { gitRunner } from "./agent-git.js";
4
+ import { GitError } from "./errors.js";
5
+ /** Prepare only the selected project root. Never stage files or initialize a nested repository. */
6
+ export async function ensureProjectGit(ctx, folder) {
7
+ const git = gitRunner(folder, { ...process.env, ...ctx.io.env, LC_ALL: 'C' }, ctx.signal, false);
8
+ let initialized = false;
9
+ try {
10
+ const top = await git(['rev-parse', '--show-toplevel']);
11
+ if (realpathSync(top) !== realpathSync(folder))
12
+ return;
13
+ }
14
+ catch (error) {
15
+ if (!(error instanceof GitError) || error.code !== 128 || !error.stderr.includes('not a git repository'))
16
+ throw error;
17
+ if (existsSync(join(folder, '.git')))
18
+ throw error;
19
+ await git(['init', '-b', 'main']);
20
+ initialized = true;
21
+ }
22
+ try {
23
+ await git(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}']);
24
+ return;
25
+ }
26
+ catch (error) {
27
+ if (!(error instanceof GitError) || error.code !== 1)
28
+ throw error;
29
+ // An absent branch is unborn; a broken existing ref is a Git failure, not permission to replace it.
30
+ const branch = await git(['symbolic-ref', 'HEAD']);
31
+ const absent = await git(['show-ref', '--verify', '--quiet', branch]).then(() => false, (refError) => {
32
+ if (!(refError instanceof GitError) || refError.code !== 1)
33
+ throw refError;
34
+ return true;
35
+ });
36
+ if (!absent)
37
+ throw error;
38
+ }
39
+ // --only with no paths makes an empty commit even when the user already has staged files.
40
+ const commit = ['-c', 'core.hooksPath=/dev/null', 'commit', '--allow-empty', '--only', '-m', 'Initial commit'];
41
+ try {
42
+ await git(commit);
43
+ }
44
+ catch (error) {
45
+ if (!(error instanceof GitError) || !/Author identity unknown|Committer identity unknown|unable to auto-detect email address|empty ident name|no (?:name|email) was given/.test(error.stderr))
46
+ throw error;
47
+ await git(['-c', 'user.name=Shardflux', '-c', 'user.email=noreply@shardflux.dev', ...commit]);
48
+ }
49
+ ctx.io.stderr.write(initialized
50
+ ? `Initialized a git repository in ${folder} (shard tracks your project with git).\n`
51
+ : `Created an initial empty commit in ${folder}.\n`);
52
+ }
@@ -3,6 +3,7 @@ import { execFile } from 'node:child_process';
3
3
  import { constants } from 'node:fs';
4
4
  import { lstat, open, readdir, readlink, realpath } from 'node:fs/promises';
5
5
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
6
+ import { GitError } from "./errors.js";
6
7
  export const MAX_SYNC_FILE_BYTES = 64 * 1024 * 1024;
7
8
  export const MAX_SYNC_ENTRIES = 20_000;
8
9
  export function validSyncPath(path) {
@@ -46,13 +47,19 @@ export function parseManifest(manifest) {
46
47
  }
47
48
  export async function projectGit(folder, args, signal, input = '') {
48
49
  return new Promise((resolve, reject) => {
49
- const child = execFile('git', ['-C', folder, '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'core.hooksPath=/dev/null', ...args], { encoding: 'utf8', timeout: 30_000, maxBuffer: 4 << 20, signal, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' } }, (error, stdout) => {
50
- if (error) {
51
- const failure = Object.assign(new Error(error.message, { cause: error }), { code: error.code, stdout });
52
- reject(failure);
53
- }
54
- else
50
+ const child = execFile('git', ['-C', folder, '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'core.hooksPath=/dev/null', ...args], { encoding: 'utf8', timeout: 30_000, maxBuffer: 4 << 20, signal, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' } }, (error, stdout, stderr) => {
51
+ if (!error) {
55
52
  resolve(stdout);
53
+ return;
54
+ }
55
+ if (signal?.aborted) {
56
+ const abortError = error;
57
+ reject(abortError);
58
+ return;
59
+ }
60
+ const failure = new GitError(folder, Object.assign(error, { stderr }));
61
+ Object.assign(failure, { stdout });
62
+ reject(failure);
56
63
  });
57
64
  child.stdin?.on('error', () => undefined); // Early Git refusal is reported by the callback.
58
65
  child.stdin?.end(input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
4
4
  "type": "module",
5
5
  "description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
6
6
  "license": "Apache-2.0",