@shardflux/cli 0.14.0 → 0.14.2

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.
@@ -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) {
@@ -12,6 +12,8 @@ export declare function needsGitSeed(ctx: Ctx, ws: Workspace): boolean;
12
12
  export declare function seedAgentRepository(ctx: Ctx, ws: Workspace, folder: string, options?: {
13
13
  root?: string;
14
14
  populate?: boolean;
15
+ initialFiles?: readonly string[];
16
+ onInitialFiles?: () => void;
15
17
  }): Promise<void>;
16
18
  /** History above this goes through the project's origin instead of the bounded files path. */
17
19
  export declare const HISTORY_BUNDLE_LIMIT: number;
@@ -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";
@@ -32,7 +32,13 @@ export async function seedAgentRepository(ctx, ws, folder, options = {}) {
32
32
  state.update(doc => { const seeds = (doc.git_seeding ?? {}); seeds[ws.id] = { api_url: ctx.apiUrl, folder: project.folder }; doc.git_seeding = seeds; });
33
33
  const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
34
34
  await cell.files.mkdir(root, { parents: true });
35
- await initializeProjectGit(cell, project.folder, root, ctx.signal, options.populate, retry, message => { ctx.io.stderr.write(`${message}\n`); });
35
+ let initialReady = false;
36
+ const initial = options.onInitialFiles ? { paths: new Set(options.initialFiles ?? []), ready: () => { if (!initialReady) {
37
+ initialReady = true;
38
+ options.onInitialFiles();
39
+ } } } : undefined;
40
+ await initializeProjectGit(cell, project.folder, root, ctx.signal, options.populate, retry, message => { ctx.io.stderr.write(`${message}\n`); }, initial);
41
+ initial?.ready();
36
42
  state.update(doc => { const seeds = (doc.git_seeding ?? {}); delete seeds[ws.id]; doc.git_seeding = seeds; });
37
43
  }
38
44
  /** History above this goes through the project's origin instead of the bounded files path. */
@@ -116,7 +122,7 @@ export async function importProjectHistory(cell, folder, root, head, branch, ori
116
122
  await cell.files.remove(remote).catch(() => undefined);
117
123
  }
118
124
  }
119
- async function initializeProjectGit(cell, folder, root, signal, populate = false, retry = false, report = () => undefined) {
125
+ async function initializeProjectGit(cell, folder, root, signal, populate = false, retry = false, report = () => undefined, initial) {
120
126
  let top;
121
127
  try {
122
128
  top = (await projectGit(folder, ['rev-parse', '--show-toplevel'], signal)).trim();
@@ -125,11 +131,11 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
125
131
  if (signal?.aborted || error.code !== 128)
126
132
  throw error;
127
133
  if (populate)
128
- await seedProjectFiles(cell, folder, root, '', new Set(), true, signal, report);
134
+ await seedProjectFiles(cell, folder, root, '', new Set(), true, signal, report, initial);
129
135
  return;
130
136
  }
131
137
  if (top !== folder)
132
- 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');
133
139
  const existing = await cell.exec.run(['git', '-C', root, 'rev-parse', '--verify', 'HEAD'], { timeoutMs: 5000, signal });
134
140
  if (existing.exitCode !== 0 && existing.exitCode !== 128)
135
141
  throw new Error('could not check workspace Git history');
@@ -192,7 +198,7 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
192
198
  // template's dependency outputs would look like agent-created source.
193
199
  // For unborn repos, intent-to-add preserves tracked ignored paths too.
194
200
  const tracked = new Set((await projectGit(folder, ['ls-files', '-z', '--cached'], signal)).split('\0'));
195
- await seedProjectFiles(cell, folder, root, head, tracked, populate, signal, report);
201
+ await seedProjectFiles(cell, folder, root, head, tracked, populate, signal, report, initial);
196
202
  }
197
203
  // Git's info/exclude and laptop-wide excludes are selection metadata, copied
198
204
  // into a dedicated Git exclude file without syncing credentials or .git data.
@@ -221,38 +227,43 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
221
227
  if (result.exitCode !== 0)
222
228
  throw new Error('could not apply project Git exclude rules');
223
229
  }
224
- async function seedProjectFiles(cell, folder, root, head, tracked, populate, signal, report) {
230
+ async function seedProjectFiles(cell, folder, root, head, tracked, populate, signal, report, initial) {
225
231
  const raw = await localManifest(folder, signal);
226
232
  for (const skip of raw.skipped)
227
233
  report(`Project seed skipped ${skip.path}: ${skip.reason}`);
228
234
  const seed = raw.entries.filter((entry) => populate || entry.path.split('/').at(-1) === '.gitignore' || (!head && tracked.has(entry.path)));
229
- for (let offset = 0; offset < seed.length; offset += 32) {
230
- const entries = seed.slice(offset, offset + 32);
231
- const probe = await cell.exec.run(['python3', '-c', 'import os,sys,json;root=sys.argv[1];print(json.dumps([p for p in json.loads(sys.argv[2]) if not os.path.lexists(root+"/"+p)]))', root, JSON.stringify(entries.map((entry) => entry.path))], { timeoutMs: 5000, signal });
232
- if (probe.exitCode !== 0)
233
- throw new Error('could not inspect initial project ignore files');
234
- const missing = new Set(JSON.parse(probe.stdout));
235
- let seeds = new Map(), bytes = 0;
236
- for (const entry of entries)
237
- if (missing.has(entry.path)) {
238
- if (bytes + entry.size > 8 << 20 && seeds.size) {
239
- await applyRemoteSyncBatch(cell, root, seeds, {}, signal);
240
- seeds = new Map();
241
- bytes = 0;
235
+ const groups = initial ? [seed.filter(e => initial.paths.has(e.path)), seed.filter(e => !initial.paths.has(e.path))] : [seed];
236
+ for (let group = 0; group < groups.length; group++) {
237
+ for (let offset = 0; offset < groups[group].length; offset += 32) {
238
+ const entries = groups[group].slice(offset, offset + 32);
239
+ const probe = await cell.exec.run(['python3', '-c', 'import os,sys,json;root=sys.argv[1];print(json.dumps([p for p in json.loads(sys.argv[2]) if not os.path.lexists(root+"/"+p)]))', root, JSON.stringify(entries.map((entry) => entry.path))], { timeoutMs: 5000, signal });
240
+ if (probe.exitCode !== 0)
241
+ throw new Error('could not inspect initial project ignore files');
242
+ const missing = new Set(JSON.parse(probe.stdout));
243
+ let seeds = new Map(), bytes = 0;
244
+ for (const entry of entries)
245
+ if (missing.has(entry.path)) {
246
+ if (bytes + entry.size > 8 << 20 && seeds.size) {
247
+ await applyRemoteSyncBatch(cell, root, seeds, {}, signal);
248
+ seeds = new Map();
249
+ bytes = 0;
250
+ }
251
+ const file = await readLocalSyncFile(folder, entry);
252
+ seeds.set(entry.path, file);
253
+ bytes += file.data.length;
254
+ }
255
+ if (seeds.size)
256
+ await applyRemoteSyncBatch(cell, root, seeds, {}, signal);
257
+ if (!head) {
258
+ const add = entries.filter((entry) => tracked.has(entry.path)).map((entry) => entry.path);
259
+ if (add.length) {
260
+ const result = await cell.exec.run(['git', '-C', root, '-c', 'core.hooksPath=/dev/null', 'add', '-N', '-f', '--', ...add], { timeoutMs: 5000, signal });
261
+ if (result.exitCode !== 0)
262
+ throw new Error(`initial tracked project files failed: ${result.stderr.trim()}`);
242
263
  }
243
- const file = await readLocalSyncFile(folder, entry);
244
- seeds.set(entry.path, file);
245
- bytes += file.data.length;
246
- }
247
- if (seeds.size)
248
- await applyRemoteSyncBatch(cell, root, seeds, {}, signal);
249
- if (!head) {
250
- const add = entries.filter((entry) => tracked.has(entry.path)).map((entry) => entry.path);
251
- if (add.length) {
252
- const result = await cell.exec.run(['git', '-C', root, '-c', 'core.hooksPath=/dev/null', 'add', '-N', '-f', '--', ...add], { timeoutMs: 5000, signal });
253
- if (result.exitCode !== 0)
254
- throw new Error(`initial tracked project files failed: ${result.stderr.trim()}`);
255
264
  }
256
265
  }
266
+ if (group === 0)
267
+ initial?.ready();
257
268
  }
258
269
  }
@@ -7,7 +7,7 @@ import type { DesktopTarget } from './agent-targets.js';
7
7
  export declare function installDeviceKey(cell: CellClient, key: string, signal?: AbortSignal): Promise<void>;
8
8
  /** Host trust is established over the authenticated files API, before sending any SSH protocol bytes. */
9
9
  export declare function prepareSsh(ws: Workspace, ctx: Ctx, targets: AgentTargets, host: string): Promise<CellClient>;
10
- export declare function proxyStream(io: Io, ended?: () => void): Duplex;
10
+ export declare function proxyStream(io: Io, ended?: () => void, outputGate?: Promise<unknown>): Duplex;
11
11
  export declare function openDesktopWorkspace(ctx: Ctx, ws: Workspace, target: DesktopTarget, t3Version?: string, prewarmed?: boolean): Promise<number>;
12
12
  export declare function queueT3Prewarm(ctx: Ctx, ws: Workspace, version: string): Promise<void>;
13
13
  export declare const TARGET_HANDLERS: Record<string, Handler>;
@@ -19,7 +19,6 @@ const keyOf = (ref) => ref.includes('/') ? ref : `${projectIdentity('./').slug}/
19
19
  /** Optimistic file replacement preserves keys installed by other devices connecting at the same time. */
20
20
  export async function installDeviceKey(cell, key, signal) {
21
21
  const path = '/home/user/.ssh/authorized_keys';
22
- await cell.files.mkdir('/home/user/.ssh', { mode: '0700', parents: true });
23
22
  for (let attempt = 0; attempt < 4; attempt++) {
24
23
  let text = '', revision = 'absent';
25
24
  try {
@@ -34,8 +33,11 @@ export async function installDeviceKey(cell, key, signal) {
34
33
  throw e;
35
34
  }
36
35
  const publicPart = key.split(' ').slice(0, 2).join(' ');
36
+ // A reconnect finds its key with one read; only a first key creates the directory.
37
37
  if (text.split('\n').some(line => line.trim().split(/\s+/).slice(0, 2).join(' ') === publicPart))
38
38
  return;
39
+ if (revision === 'absent')
40
+ await cell.files.mkdir('/home/user/.ssh', { mode: '0700', parents: true });
39
41
  try {
40
42
  await cell.files.patch({ path, content: text + (text && !text.endsWith('\n') ? '\n' : '') + key + '\n', expectedRevision: revision, mode: '0600' }, { signal });
41
43
  return;
@@ -47,16 +49,22 @@ export async function installDeviceKey(cell, key, signal) {
47
49
  }
48
50
  throw new Error('authorized_keys kept changing; retry the SSH connection');
49
51
  }
50
- /** Host trust is established over the authenticated files API, before sending any SSH protocol bytes. */
51
- export async function prepareSsh(ws, ctx, targets, host) {
52
+ async function sshCell(ws, ctx) {
52
53
  if (!ws.ready)
53
54
  await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
54
55
  if (ws.labels?.t3_environment_id)
55
56
  await prepareAgentMachine(ctx, ws);
56
- const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
57
- const hostKey = await cell.files.readText('/etc/ssh/ssh_host_ed25519_key.pub', { length: 4096 });
58
- await targets.pin(host, hostKey, ctx.signal);
59
- await installDeviceKey(cell, targets.deviceKey(), ctx.signal);
57
+ return ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
58
+ }
59
+ /** Host trust over the authenticated files API: the host key pin and this device's key, read together. */
60
+ async function trustSsh(cell, targets, host, signal) {
61
+ const [hostKey] = await Promise.all([cell.files.readText('/etc/ssh/ssh_host_ed25519_key.pub', { length: 4096 }), installDeviceKey(cell, targets.deviceKey(), signal)]);
62
+ await targets.pin(host, hostKey, signal);
63
+ }
64
+ /** Host trust is established over the authenticated files API, before sending any SSH protocol bytes. */
65
+ export async function prepareSsh(ws, ctx, targets, host) {
66
+ const cell = await sshCell(ws, ctx);
67
+ await trustSsh(cell, targets, host, ctx.signal);
60
68
  return cell;
61
69
  }
62
70
  async function openApp(target, env) {
@@ -70,10 +78,10 @@ async function openApp(target, env) {
70
78
  reject(new Error(`app launcher exited ${code}`)); });
71
79
  });
72
80
  }
73
- export function proxyStream(io, ended) {
81
+ export function proxyStream(io, ended, outputGate) {
74
82
  const stdout = io.stdout;
75
83
  const source = Readable.from(io.stdin, { objectMode: false });
76
- let blocked = false;
84
+ let blocked = false, gate = outputGate;
77
85
  const pump = () => {
78
86
  while (!blocked && !stream.destroyed) {
79
87
  const bytes = source.read();
@@ -86,17 +94,24 @@ export function proxyStream(io, ended) {
86
94
  allowHalfOpen: true,
87
95
  read() { blocked = false; pump(); },
88
96
  write(bytes, _encoding, done) {
89
- try {
90
- if (stdout.write(bytes))
91
- done();
92
- else if (stdout.once)
93
- stdout.once('drain', done);
94
- else
95
- done(new Error('stdout cannot signal backpressure'));
96
- }
97
- catch (e) {
98
- done(e);
99
- }
97
+ const send = () => {
98
+ try {
99
+ if (stdout.write(bytes))
100
+ done();
101
+ else if (stdout.once)
102
+ stdout.once('drain', done);
103
+ else
104
+ done(new Error('stdout cannot signal backpressure'));
105
+ }
106
+ catch (e) {
107
+ done(e);
108
+ }
109
+ };
110
+ // No guest byte reaches the SSH client before the gate opens (its host key pinned).
111
+ if (gate)
112
+ void gate.then(() => { gate = undefined; send(); }, (e) => done(e instanceof Error ? e : new Error(String(e))));
113
+ else
114
+ send();
100
115
  },
101
116
  // A guest can close before SSH authenticates (e.g. sshd MaxStartups).
102
117
  // Once output drains, end the proxy so SSH sees EOF instead of waiting
@@ -220,13 +235,26 @@ export const TARGET_HANDLERS = {
220
235
  const controller = new AbortController();
221
236
  const signal = AbortSignal.any([ctx.signal, controller.signal]);
222
237
  const proxyCtx = { ...ctx, signal };
223
- const stream = proxyStream(ctx.io, () => controller.abort(new Error('SSH proxy stream ended')));
238
+ let trusted, distrusted;
239
+ const trust = new Promise((resolve, reject) => { trusted = resolve; distrusted = reject; });
240
+ trust.catch(() => { });
241
+ const stream = proxyStream(ctx.io, () => controller.abort(new Error('SSH proxy stream ended')), trust);
224
242
  stream.on('error', error => controller.abort(error));
225
243
  try {
226
244
  const ws = await resolveWorkspace(proxyCtx, entry.workspace);
227
245
  void ws.hint({ signal }).catch(() => undefined);
228
- const cell = await prepareSsh(ws, proxyCtx, targets, host);
229
- const tunnel = await new WorkspaceTunnels(() => cell, () => ws.grantedTools).forward({ remotePort: 22, stream, signal, passive: true });
246
+ const cell = await sshCell(ws, proxyCtx);
247
+ // Trust and the tunnel start run together; the stream holds guest bytes until the host key is pinned.
248
+ const trusting = trustSsh(cell, targets, host, signal).then(trusted, (e) => { distrusted(e); controller.abort(e); throw e; });
249
+ const [opened, checked] = await Promise.allSettled([new WorkspaceTunnels(() => cell, () => ws.grantedTools).forward({ remotePort: 22, stream, signal, passive: true }), trusting]);
250
+ if (checked.status === 'rejected') {
251
+ if (opened.status === 'fulfilled')
252
+ await opened.value.close().catch(() => { });
253
+ throw checked.reason;
254
+ }
255
+ if (opened.status === 'rejected')
256
+ throw opened.reason;
257
+ const tunnel = opened.value;
230
258
  try {
231
259
  await tunnel.closed;
232
260
  }
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.14.0";
1
+ export declare const CLI_VERSION = "0.14.2";
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.0';
15
+ export const CLI_VERSION = '0.14.2';
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).' },
@@ -1614,7 +1614,7 @@ export function topHelp() {
1614
1614
  ' Codex and the agent runtime. "shard templates" is for your own app’s workspaces, not for agents: a repository',
1615
1615
  ' bound to such a template is refused (template_not_agent_capable).',
1616
1616
  ' In the VM, Claude Code and Codex have Shardflux computer use next to their own tools: the shardflux-computer MCP',
1617
- ' server’s computer and computer_batch drive the VM’s desktop (start programs with DISPLAY=:1).',
1617
+ ' server’s computer and computer_batch drive the VM’s desktop (start programs with DISPLAY=:1 setsid -f).',
1618
1618
  ...sections,
1619
1619
  '',
1620
1620
  'Aliases: ws = workspaces, ops = operations, org = orgs, keys = api-keys; "shard workspaces files|operations|executions ..." also works.',
@@ -73,6 +73,9 @@ export async function prepareDesktopRouter(ctx, source) {
73
73
  if (imported.exitCode !== 0)
74
74
  throw new Error(`Project router repository could not be prepared: ${imported.stderr.trim()}`);
75
75
  }
76
+ const seed = await cell.exec.run(['git', '-C', workspaceRepositoryPath(source), '-c', 'core.hooksPath=/dev/null', 'push', '/shardflux/project/git/hub.git', `${commit}:refs/shard/router/seeds/${commit}`], { timeoutMs: 120_000, signal: ctx.signal });
77
+ if (seed.exitCode !== 0)
78
+ throw new Error(`Project router hub seed failed: ${seed.stderr.trim()}`);
76
79
  await destination.files.mkdir('/home/user/.config/shardflux', { parents: true, mode: '0700' });
77
80
  await destination.files.write(ROUTER_CONFIG, JSON.stringify({ ...config, cwd: root }) + '\n', { mode: '0600' });
78
81
  return router;
@@ -7,7 +7,7 @@ import { gitRunner } from "./agent-git.js";
7
7
  import { shardRemote } from "./git-remote.js";
8
8
  import { writePrivateFile } from "./credentials.js";
9
9
  import { conversationClient } from "./conversation-commands.js";
10
- const MAX = 256 << 20;
10
+ const MAX = 64 << 20; // Guest FileManifest uses the project-sync 64 MiB per-file bound.
11
11
  export function rewriteTranscript(bytes, remoteCwd, localCwd) {
12
12
  const text = new TextDecoder('utf8', { fatal: true }).decode(bytes);
13
13
  const rewrite = (value) => {
@@ -62,6 +62,10 @@ export async function mirrorConversation(ctx, record, source) {
62
62
  throw new Error('Project transcript copy includes files outside the mirror bound.');
63
63
  let count = 0;
64
64
  for (const entry of manifest.entries) {
65
+ // Flush holds this empty regular file while appending the transcript.
66
+ // It is synchronization metadata, not native history to mirror.
67
+ if (entry.path === '.lock' && !entry.symlink && entry.size === 0 && entry.sha256 === 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
68
+ continue;
65
69
  if (entry.symlink || entry.path.includes('/') || !/^[a-zA-Z0-9_.-]+\.(jsonl|json)$/.test(entry.path))
66
70
  throw new Error('Project transcript copy has an unsafe entry.');
67
71
  const file = join(dir, entry.path), expected = existsSync(file) ? createHash('sha256').update(readFileSync(file)).digest('hex') : '';
@@ -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
  };
@@ -1,4 +1,4 @@
1
- import type { Workspace } from '@shardflux/sdk';
1
+ import type { Workspace, WorkspaceView } from '@shardflux/sdk';
2
2
  import type { Ctx } from './commands.js';
3
3
  export type Harness = 'claude' | 'codex';
4
4
  export interface Conversation {
@@ -45,7 +45,7 @@ export declare function resolveConversation(ctx: Ctx, ref: string, folder?: stri
45
45
  /** Re-resolve the binding on every attach: a move changes the VM, never the conversation identity. */
46
46
  export declare function conversationWorkspace(ctx: Ctx, c: Conversation): Promise<Workspace>;
47
47
  export declare function restoreConversationBinding(ctx: Ctx, c: Conversation, ws: Workspace): Promise<void>;
48
- export declare function createConversationRecord(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string): Promise<Conversation>;
48
+ export declare function createConversationRecord(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, allocated?: (workspace: WorkspaceView) => void, idempotencyKey?: string): Promise<Conversation>;
49
49
  export declare function createConversation(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string): Promise<{
50
50
  conversation: Conversation;
51
51
  workspace: Workspace;
@@ -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";
@@ -85,16 +86,18 @@ export async function restoreConversationBinding(ctx, c, ws) {
85
86
  if (result.exitCode !== 0)
86
87
  throw new Error(`Conversation restore failed: ${result.stderr.trim()}`);
87
88
  }
88
- export async function createConversationRecord(ctx, folder, harness, route, name) {
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']);
94
96
  const branch = await git(['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => undefined);
95
97
  const response = await conversationClient(ctx).conversations.create({ repoRemote: project.remote ?? `file://${project.folder}`, harness, route, mode: 'new',
96
- base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), projectId: ctx.store.profile(ctx.apiUrl).project_id }, { idempotencyKey: randomUUID(), signal: ctx.signal });
98
+ base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), projectId: ctx.store.profile(ctx.apiUrl).project_id }, { idempotencyKey, signal: ctx.signal });
97
99
  const c = validateConversation({ ...response.conversation, restore: response.restore });
100
+ allocated?.(response.workspace);
98
101
  rememberConversation(ctx, c, project.folder);
99
102
  return c;
100
103
  }
@@ -39,7 +39,7 @@ type Result = {
39
39
  stderr_b64?: string;
40
40
  files_artifact?: string;
41
41
  };
42
- /** Resolve every existing ancestor, including symlinks. Missing leaf files do not hide an outside parent. */
42
+ /** Resolve existing ancestors even before the conversation worktree is created. */
43
43
  export declare function devicePath(root: string, requested?: string): {
44
44
  path: string;
45
45
  outside: boolean;