@shardflux/cli 0.14.2 → 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,5 +1,10 @@
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
+
3
8
  ## 0.14.2
4
9
 
5
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.
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.
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.14.2";
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.2';
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,35 +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
93
  // Flush holds this empty regular file while appending the transcript.
66
94
  // It is synchronization metadata, not native history to mirror.
67
95
  if (entry.path === '.lock' && !entry.symlink && entry.size === 0 && entry.sha256 === 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
68
96
  continue;
69
- if (entry.symlink || entry.path.includes('/') || !/^[a-zA-Z0-9_.-]+\.(jsonl|json)$/.test(entry.path))
70
- throw new Error('Project transcript copy has an unsafe entry.');
71
- const file = join(dir, entry.path), expected = existsSync(file) ? createHash('sha256').update(readFileSync(file)).digest('hex') : '';
72
- if (expected !== entry.sha256) {
73
- const bytes = await cell.files.read(`${root}/${entry.path}`, { length: Math.min(MAX + 1, entry.size + 1) });
74
- if (bytes.length !== entry.size || createHash('sha256').update(bytes).digest('hex') !== entry.sha256)
75
- throw new Error('Transcript changed during copying; retaining the previous stash.');
76
- writePrivateFile(dir, file, new TextDecoder('utf8', { fatal: true }).decode(bytes));
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 });
77
121
  }
78
122
  if (entry.path.endsWith('.jsonl'))
79
123
  count++;
@@ -84,26 +128,27 @@ export async function mirrorConversation(ctx, record, source) {
84
128
  const binding = await conversationClient(ctx).workspaces.get(c.workspace_id);
85
129
  if (!binding.data.deleted_at)
86
130
  return;
87
- const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8'));
88
- 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))
89
- throw new Error('Transcript handoff metadata does not match the ended conversation.');
90
- 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));
91
135
  if (bytes.length !== meta.bytes || createHash('sha256').update(bytes).digest('hex') !== meta.sha256)
92
- 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.`);
93
137
  await handoffConversation(ctx, { ...c, worktree_path: meta.cwd }, record.folder, dir, source);
94
138
  }
95
139
  }
96
140
  finally {
141
+ ctx.signal.removeEventListener('abort', close);
97
142
  cell.close();
98
143
  }
99
144
  }
100
145
  export async function handoffConversation(ctx, c, folder, dir, source) {
101
146
  if (c.state !== 'done' && c.state !== 'archived')
102
147
  throw new Error('Only an ended conversation can enter native history.');
103
- 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());
104
149
  if (!files.length)
105
150
  throw new Error('Conversation has no transcript to hand off.');
106
- 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) }));
107
152
  // Check every destination before the first write. Preserve local native history on any collision.
108
153
  for (const { dest, bytes } of writes) {
109
154
  if (existsSync(dest) && (lstatSync(dest).isSymbolicLink() || !readFileSync(dest).equals(Buffer.from(bytes))))
@@ -123,7 +168,12 @@ export async function handoffConversation(ctx, c, folder, dir, source) {
123
168
  rememberConversation(ctx, c, folder, true);
124
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');
125
170
  }
126
- 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()) {
127
177
  const records = localConversations(ctx);
128
178
  if (!Object.keys(records).length)
129
179
  return;
@@ -133,30 +183,57 @@ export async function mirrorProjectConversations(ctx, mirrored = new Map()) {
133
183
  if (!c || record.handed_off || !c.harness_session_id)
134
184
  continue;
135
185
  const revision = JSON.stringify([c.workspace_id, c.harness_session_id, c.state, c.durable, c.last_activity_at, c.summary]);
136
- if (mirrored.get(c.id) === revision)
186
+ if (state.mirrored.get(c.id) === revision)
137
187
  continue;
138
- rememberConversation(ctx, c, record.folder);
139
- let source;
140
- for (const candidate of [c, ...all.filter(other => other.project_id === c.project_id && other.id !== c.id)]) {
141
- try {
142
- const ws = await conversationClient(ctx).workspaces.get(candidate.workspace_id);
143
- if (!ws.data.deleted_at && ws.ready) {
144
- source = ws;
145
- 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
+ }
146
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);
147
219
  }
148
- 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);
149
226
  }
150
- if (!source) {
151
- const client = conversationClient(ctx);
152
- const reader = await client.conversations.spaceReader(c.project_id, { signal: ctx.signal });
153
- source = await client.workspaces.get(reader.workspace.id);
154
- if (!source.ready)
155
- 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
+ }
156
237
  }
157
- await mirrorConversation(ctx, { ...record, conversation: c }, source);
158
- // An ended record may precede its VM tombstone; retry until the handoff receipt is written.
159
- if (c.state !== 'done' && c.state !== 'archived')
160
- mirrored.set(c.id, revision);
161
238
  }
162
239
  }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.14.2",
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",