@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.
- package/CHANGELOG.md +21 -0
- package/README.md +12 -5
- package/dist/acp-catalog.d.ts +7 -152
- package/dist/acp-catalog.js +39 -286
- package/dist/acp-models.d.ts +36 -0
- package/dist/acp-models.js +161 -0
- package/dist/acp.d.ts +5 -3
- package/dist/acp.js +216 -42
- package/dist/agent-commands.js +50 -7
- package/dist/agent-git.d.ts +1 -1
- package/dist/agent-git.js +14 -7
- package/dist/agent-repository.d.ts +2 -0
- package/dist/agent-repository.js +43 -32
- package/dist/agent-target-commands.d.ts +1 -1
- package/dist/agent-target-commands.js +51 -23
- package/dist/args.d.ts +1 -1
- package/dist/args.js +2 -2
- package/dist/conversation-router.js +3 -0
- package/dist/conversation-stash.js +5 -1
- package/dist/conversation-terminal.d.ts +6 -1
- package/dist/conversation-terminal.js +8 -4
- package/dist/conversations.d.ts +2 -2
- package/dist/conversations.js +6 -3
- package/dist/device-bridge.d.ts +1 -1
- package/dist/device-bridge.js +27 -16
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +18 -0
- package/dist/git-remote-bin.js +3 -1
- package/dist/git-remote.d.ts +3 -1
- package/dist/git-remote.js +2 -1
- package/dist/project-git.d.ts +3 -0
- package/dist/project-git.js +52 -0
- package/dist/project-sync-manifest.js +13 -6
- package/package.json +2 -2
package/dist/agent-commands.js
CHANGED
|
@@ -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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
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
|
};
|
package/dist/agent-git.d.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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;
|
package/dist/agent-repository.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
stdout.once
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
229
|
-
|
|
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
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.
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
|
77
|
+
return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values);
|
|
74
78
|
};
|
package/dist/conversations.d.ts
CHANGED
|
@@ -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;
|
package/dist/conversations.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/dist/device-bridge.d.ts
CHANGED
|
@@ -39,7 +39,7 @@ type Result = {
|
|
|
39
39
|
stderr_b64?: string;
|
|
40
40
|
files_artifact?: string;
|
|
41
41
|
};
|
|
42
|
-
/** Resolve
|
|
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;
|