@shardflux/cli 0.18.0 → 0.19.0
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 +96 -1
- package/README.md +28 -1
- package/dist/account-args.js +1 -0
- package/dist/agent-commands.d.ts +1 -0
- package/dist/agent-commands.js +15 -5
- package/dist/agent-config-files.d.ts +16 -2
- package/dist/agent-config-files.js +48 -61
- package/dist/agent-config-sync.d.ts +12 -1
- package/dist/agent-config-sync.js +228 -54
- package/dist/agent-repository.d.ts +39 -9
- package/dist/agent-repository.js +309 -92
- package/dist/agent-state.d.ts +3 -0
- package/dist/agent-state.js +13 -0
- package/dist/agent-target-commands.d.ts +2 -0
- package/dist/agent-target-commands.js +55 -18
- package/dist/args.d.ts +1 -1
- package/dist/args.js +16 -4
- package/dist/command-center-actions.js +31 -5
- package/dist/conversation-commands.js +6 -1
- package/dist/conversation-router.js +7 -11
- package/dist/conversation-stash.js +64 -18
- package/dist/conversation-terminal.d.ts +11 -1
- package/dist/conversation-terminal.js +28 -7
- package/dist/conversations.d.ts +17 -3
- package/dist/conversations.js +58 -6
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/files-mirror.d.ts +1 -9
- package/dist/files-mirror.js +19 -3
- package/dist/format.js +37 -2
- package/dist/handlers.js +3 -1
- package/dist/local-sessions.d.ts +60 -0
- package/dist/local-sessions.js +445 -0
- package/dist/main.js +12 -0
- package/dist/move.d.ts +44 -0
- package/dist/move.js +248 -0
- package/dist/moved-sessions.d.ts +33 -0
- package/dist/moved-sessions.js +91 -0
- package/dist/onboarding.d.ts +18 -3
- package/dist/onboarding.js +72 -46
- package/dist/project-sync-manifest.d.ts +31 -3
- package/dist/project-sync-manifest.js +192 -108
- package/dist/project-sync-transfer.d.ts +50 -4
- package/dist/project-sync-transfer.js +404 -106
- package/dist/project-sync.js +30 -33
- package/dist/seed-phases.d.ts +29 -0
- package/dist/seed-phases.js +83 -0
- package/dist/t3-launch.js +3 -1
- package/dist/t3-mode.d.ts +7 -2
- package/dist/t3-mode.js +39 -5
- package/dist/thread-on-computer.js +4 -0
- package/dist/worktree-twin.d.ts +2 -6
- package/dist/worktree-twin.js +16 -51
- package/package.json +5 -5
|
@@ -5,7 +5,7 @@ import { ensureCloudClaude } from "./claude-cloud-login.js";
|
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { Readable, Duplex } from 'node:stream';
|
|
7
7
|
import { ShardfluxApiError, WorkspaceTunnels } from '@shardflux/sdk';
|
|
8
|
-
import {
|
|
8
|
+
import { getConversation } from "./conversations.js";
|
|
9
9
|
import { out, resolveWorkspace } from "./commands.js";
|
|
10
10
|
import { MODEL_CHOICES } from "./agent-models.js";
|
|
11
11
|
import { AgentTargets, installedTargets, targetLabel } from "./agent-targets.js";
|
|
@@ -229,14 +229,21 @@ const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
|
|
|
229
229
|
if (target === 't3' && /^conv_[a-z0-9]{26}$/.test(ref))
|
|
230
230
|
return openT3Conversation(ctx, ref, values);
|
|
231
231
|
// A conversation id opens that conversation's own session on its computer (claude.ai, the desktop apps).
|
|
232
|
-
|
|
232
|
+
let conversation = /^conv_[a-z0-9]{26}$/.test(ref) ? await getConversation(ctx, ref) : undefined;
|
|
233
233
|
// A pooled thread (0.18.0+) has no provider login on its session: it opens remotely in T3 Code.
|
|
234
234
|
if (conversation && conversationPooled(conversation)) {
|
|
235
235
|
if (!detected.targets.includes('t3'))
|
|
236
236
|
throw pooledNeedsT3(ref);
|
|
237
237
|
return openT3Conversation(ctx, ref, values);
|
|
238
238
|
}
|
|
239
|
-
|
|
239
|
+
// A conversation whose VM was removed restores into a fresh one first (0.18.1+).
|
|
240
|
+
let ws;
|
|
241
|
+
if (conversation) {
|
|
242
|
+
const { reopenConversation } = await import("./conversation-terminal.js");
|
|
243
|
+
({ conversation, ws } = await reopenConversation(ctx, conversation, values));
|
|
244
|
+
}
|
|
245
|
+
else
|
|
246
|
+
ws = await resolveWorkspace(ctx, keyOf(ref));
|
|
240
247
|
if (values.reachable !== undefined)
|
|
241
248
|
ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
|
|
242
249
|
// T3 lists both agents and opens without Claude (it shows Claude as signed out); the Claude apps need it.
|
|
@@ -270,10 +277,13 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
|
|
|
270
277
|
const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
|
|
271
278
|
const entry = (await (ws.labels.shard_router === 'project' ? targets.configureProject(ws.key, ctx.apiUrl, [target], ctx.signal) : targets.configure(ws.key, ctx.apiUrl, [target], false, ctx.signal))).find(e => e.workspace === ws.key);
|
|
272
279
|
const instructions = target === 't3'
|
|
273
|
-
?
|
|
280
|
+
? '' // T3 Code: where the project is comes from its T3 Connect link (t3Line), set below.
|
|
274
281
|
: target === 'claude-desktop'
|
|
275
282
|
? `Restart Claude Desktop to load the connection, then choose ${targetLabel(entry)} in Code's environment dropdown and open ${projectWorkspacePath({ slug: entry.project })}.`
|
|
276
283
|
: `Restart Codex to import ${entry.host} and its remote project. If it has already been imported, enable ${entry.host} in Settings → Connections → SSH.`;
|
|
284
|
+
// T3 Code: the project is linked with the person's T3 Connect sign-in first, then the thread starts in it.
|
|
285
|
+
const fresh = target === 't3' && !new AgentStateStore(ctx.store.dir).t3Environments(ctx.apiUrl).some(e => e.workspace === ws.key);
|
|
286
|
+
let phone = target === 't3' ? await linkT3Phone(ctx, ws) : undefined;
|
|
277
287
|
let started;
|
|
278
288
|
if (target === 't3' && thread) {
|
|
279
289
|
try {
|
|
@@ -283,10 +293,15 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
|
|
|
283
293
|
throw withT3Context(error, { workspace_key: ws.key, conversation_id: conversation ?? (ws.labels.shard_conversation || null) });
|
|
284
294
|
}
|
|
285
295
|
}
|
|
296
|
+
// The first time a project is set up for T3 Code, T3 opens on its Connections page once the project is linked: the
|
|
297
|
+
// project is listed there (or T3's own Sign in to T3 Connect, when T3 Code is signed out).
|
|
286
298
|
if (target === 't3') {
|
|
299
|
+
if (fresh && phone?.link.status === 'linking')
|
|
300
|
+
phone = await waitT3Linked(ctx, ws, phone);
|
|
301
|
+
const page = fresh && phone?.link.status === 'linked' ? T3_CONNECTIONS_PAGE : undefined;
|
|
287
302
|
// The thread exists once startSshThread returns: a failure to show T3 names it rather than inviting a retry.
|
|
288
303
|
try {
|
|
289
|
-
await openT3(ctx.io.env, install ?? t3Install(ctx.io.env));
|
|
304
|
+
await openT3(ctx.io.env, install ?? t3Install(ctx.io.env), page);
|
|
290
305
|
}
|
|
291
306
|
catch (error) {
|
|
292
307
|
throw started ? t3OpenFailed(error, { t3_thread_id: started.t3_thread_id, conversation_id: conversation ?? (ws.labels.shard_conversation || null), workspace_key: ws.key }) : error;
|
|
@@ -297,35 +312,57 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
|
|
|
297
312
|
new AgentStateStore(ctx.store.dir).saveTarget(ws.labels.repo_remote || ws.labels.shard_project || entry.project, target);
|
|
298
313
|
if (target === 't3')
|
|
299
314
|
new AgentStateStore(ctx.store.dir).saveT3Environment({ workspace: ws.key, host: entry.host, api_url: ctx.apiUrl });
|
|
300
|
-
const
|
|
315
|
+
const where = target === 't3' ? phone?.line ?? `${targetLabel(entry)} is set up for T3 Code.\n` : '';
|
|
301
316
|
const threadLine = started ? `Started a thread in T3 Code on ${agentMachineName(ws.key)}${started.model ? ` (${started.model}${started.effort ? `, ${started.effort}` : ''})` : ''}.\n` : '';
|
|
302
|
-
out(ctx, `${threadLine}${instructions}\n
|
|
317
|
+
out(ctx, target === 't3' ? `${threadLine}${where}` : `${threadLine}${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions: target === 't3' ? where.trim() : instructions, ...(phone ? { t3_link: phone.link } : {}),
|
|
303
318
|
...(target === 't3' ? t3Fields(ws, entry.project, install ?? t3Install(ctx.io.env), started, conversation) : {}),
|
|
304
319
|
app_bundle_id: { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target] });
|
|
305
320
|
return 0;
|
|
306
321
|
}
|
|
322
|
+
/** T3 Code's Connections page (its t3code:// link), where a project linked with T3 Connect is listed. */
|
|
323
|
+
export const T3_CONNECTIONS_PAGE = 't3code://app/settings/connections';
|
|
307
324
|
/**
|
|
308
|
-
*
|
|
309
|
-
* asked for once per person; linking continues by itself once it is approved.
|
|
325
|
+
* T3 Code and the T3 app reach this workspace through the account's T3 Connect sign-in (0.17.0+; on the desktop
|
|
326
|
+
* 0.19.0+). The sign-in is asked for once per person; linking continues by itself once it is approved.
|
|
310
327
|
*/
|
|
311
328
|
async function linkT3Phone(ctx, ws) {
|
|
312
329
|
try {
|
|
313
330
|
const { link, signIn, needsLogin } = await t3PhoneLink(ctx, ws.id);
|
|
314
|
-
|
|
315
|
-
? `Phone: to use ${link.label} from the T3 app, open ${signIn.verification_uri} and confirm ${signIn.user_code} (once).\n`
|
|
316
|
-
: needsLogin ? `Phone: to use ${link.label} from the T3 app, sign in with shard auth login, then run this again.\n`
|
|
317
|
-
: `Phone: ${link.label} appears in the T3 app's environments.\n`;
|
|
318
|
-
// --json carries the sign-in shard started with its session too, so the menu bar can show it.
|
|
319
|
-
return { line, link: signIn ? { ...link, sign_in: { ...link.sign_in, ...signIn } } : needsLogin ? { ...link, needs_login: true } : link };
|
|
331
|
+
return { line: t3LinkLine(link, signIn, needsLogin), link: signIn ? { ...link, sign_in: { ...link.sign_in, ...signIn } } : needsLogin ? { ...link, needs_login: true } : link };
|
|
320
332
|
}
|
|
321
333
|
catch (error) {
|
|
322
|
-
// An API without T3 Connect leaves the
|
|
334
|
+
// An API without T3 Connect leaves the project set up for T3 Code over SSH.
|
|
323
335
|
if (describeError(error).code === 'not_found')
|
|
324
336
|
return undefined;
|
|
325
|
-
ctx.io.stderr.write(`
|
|
337
|
+
ctx.io.stderr.write(`T3 Connect: ${describeError(error).message}\n`);
|
|
326
338
|
return undefined;
|
|
327
339
|
}
|
|
328
340
|
}
|
|
341
|
+
const t3LinkLine = (link, signIn, needsLogin) => signIn
|
|
342
|
+
? `To have ${link.label} appear in T3 Code and the T3 app, open ${signIn.verification_uri} and confirm ${signIn.user_code} (once).\n`
|
|
343
|
+
: needsLogin ? `To have ${link.label} appear in T3 Code and the T3 app, sign in with shard auth login, then run this again.\n`
|
|
344
|
+
: link.status === 'linked' ? `${link.label} is linked to your T3 account.\n`
|
|
345
|
+
: `Linking ${link.label} to your T3 account…\n`;
|
|
346
|
+
/** A project linking for the first time: T3 lists it once linked, so its Connections page waits for that (at most 20 s). */
|
|
347
|
+
async function waitT3Linked(ctx, ws, phone) {
|
|
348
|
+
const until = Date.now() + 20_000;
|
|
349
|
+
while (Date.now() < until && !ctx.signal?.aborted) {
|
|
350
|
+
await delay(2000);
|
|
351
|
+
try {
|
|
352
|
+
const known = (await ctx.cloud().t3.status()).links.find(l => l.workspace_id === ws.id);
|
|
353
|
+
if (known?.status === 'linked') {
|
|
354
|
+
const link = { ...phone.link, status: 'linked' };
|
|
355
|
+
return { line: t3LinkLine(link), link };
|
|
356
|
+
}
|
|
357
|
+
if (known && known.status !== 'linking')
|
|
358
|
+
return phone;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return phone;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return phone;
|
|
365
|
+
}
|
|
329
366
|
export async function queueT3Prewarm(ctx, ws, version) {
|
|
330
367
|
try {
|
|
331
368
|
await prewarmT3(ctx, ws, version);
|
|
@@ -341,7 +378,7 @@ function configuredTargets(targets, e, t3 = false) {
|
|
|
341
378
|
{ target: 'ssh', label: 'SSH', file: targets.sshFile, name: e.host, detail: `(included from ${tilde(targets, join(targets.home, '.ssh', 'config'))}): ssh ${e.host}` },
|
|
342
379
|
...(e.targets.includes('claude-desktop') ? [{ target: 'claude-desktop', label: 'Claude Desktop', file: targets.claudeFile, name: targetLabel(e), detail: `(sshConfigs: ${targetLabel(e)}); restart Claude Desktop, then pick it in Code's environment list` }] : []),
|
|
343
380
|
...(e.targets.includes('codex-app') ? [{ target: 'codex-app', label: 'Codex app', file: targets.codexFile, name: targetLabel(e), detail: `(remoteConnections: ${targetLabel(e)}); restart Codex to import it` }] : []),
|
|
344
|
-
...(t3 ? [{ target: 't3', label: 'T3 Code', file: targets.sshFile, name: agentMachineName(e.workspace), detail: `
|
|
381
|
+
...(t3 ? [{ target: 't3', label: 'T3 Code', file: targets.sshFile, name: agentMachineName(e.workspace), detail: `SSH host ${e.host}; project ${projectWorkspacePath({ slug: e.project })}; in T3 Code and the T3 app through T3 Connect` }] : []),
|
|
345
382
|
];
|
|
346
383
|
}
|
|
347
384
|
function targetFiles(targets, entries) {
|
package/dist/args.d.ts
CHANGED
package/dist/args.js
CHANGED
|
@@ -13,7 +13,7 @@ import { parseArgs } from 'node:util';
|
|
|
13
13
|
import { ACCOUNT_COMMANDS } from "./account-args.js";
|
|
14
14
|
import { UsageError } from "./errors.js";
|
|
15
15
|
import { DEFAULT_CHOICE, MODEL_CHOICES } from "./agent-models.js";
|
|
16
|
-
export const CLI_VERSION = '0.
|
|
16
|
+
export const CLI_VERSION = '0.19.0';
|
|
17
17
|
export const GLOBAL_OPTIONS = {
|
|
18
18
|
'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
|
|
19
19
|
json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
|
|
@@ -1252,6 +1252,17 @@ export const CENTER_COMMAND = { path: ['<center>'], summary: 'Open the cloud age
|
|
|
1252
1252
|
/** Bare `shard` without a terminal, or in a coding agent's shell (0.17.1+): the setup question and both paths' steps. */
|
|
1253
1253
|
export const GUIDE_COMMAND = { path: ['<guide>'], summary: 'Show how to set up Shardflux with a coding agent', positionals: [], options: {} };
|
|
1254
1254
|
const AGENT_COMMANDS = [
|
|
1255
|
+
{ path: ['move'], summary: 'Move a Claude Code or Codex session from this computer to the cloud: the same session, with its history',
|
|
1256
|
+
description: 'Run it in the project folder. The session continues in its own cloud VM as the same session (claude --resume, codex resume), from your project exactly as it is, uncommitted edits included; your working tree, index and branch stay as they are. A running turn finishes here first. The cloud work comes back to this folder by itself, and typing in the local copy says it now continues in the cloud. Sessions from Claude Desktop and the Codex app move the same way.',
|
|
1257
|
+
positionals: [], options: {
|
|
1258
|
+
last: { type: 'boolean', description: 'This project’s most recent session.' },
|
|
1259
|
+
session: { type: 'string', value: '<id>', description: 'The session to move: a Claude Code session id or Codex thread id.' },
|
|
1260
|
+
path: { type: 'string', value: '<path>', description: 'The project folder (default: the current folder).' },
|
|
1261
|
+
claude: { type: 'boolean', description: 'With --last or --list: Claude Code sessions only.' },
|
|
1262
|
+
codex: { type: 'boolean', description: 'With --last or --list: Codex sessions only.' },
|
|
1263
|
+
list: { type: 'boolean', description: 'List this project’s sessions on this computer, and where the moved ones continue.' },
|
|
1264
|
+
detach: { type: 'boolean', description: 'Start it in the cloud without opening it here.' },
|
|
1265
|
+
}, examples: ['shard move --last', 'shard move --list', 'shard move --session 3d085e35-aa66-4866-9a53-f09719d6767c --detach --json'] },
|
|
1255
1266
|
{ path: ['device', 'requests'], summary: 'Review local device requests waiting for approval', positionals: [], options: {} },
|
|
1256
1267
|
...['approve', 'deny'].map((action) => ({ path: ['device', action], summary: `${action} a local device request`, positionals: [{ name: 'id', required: true }], options: action === 'approve' ? { always: { type: 'boolean', description: 'Allow matching requests for this project; never for outside-folder access.' } } : {} })),
|
|
1257
1268
|
{ path: ['conversations', 'try'], summary: 'Try a conversation in a synced local worktree', positionals: [{ name: 'conversation', required: true }], options: { path: { type: 'string', value: '<path>', description: 'Local project checkout.' }, stop: { type: 'boolean', description: 'Stop the device helper’s local sync and ports.' } } },
|
|
@@ -1288,8 +1299,9 @@ const AGENT_COMMANDS = [
|
|
|
1288
1299
|
} },
|
|
1289
1300
|
{ path: ['agents', 'fork'], summary: 'Fork an agent’s memory and disk', positionals: [{ name: 'agent', required: true }, { name: 'name', required: true }], options: WAIT },
|
|
1290
1301
|
{ path: ['agents', 'park'], summary: 'Suspend an agent when idle', positionals: [{ name: 'agent', required: true }], options: { 'when-idle': { type: 'string', value: '<duration>', description: 'Idle grace before suspend (default 1s).' } } },
|
|
1291
|
-
{ path: ['agents', '
|
|
1292
|
-
|
|
1302
|
+
{ path: ['agents', 'config-sync'], hidden: true, summary: 'Copy this computer’s Claude and Codex config into a conversation VM (started in the background after a start, 0.19.0+)', positionals: [{ name: 'workspace', required: true }], options: {} },
|
|
1303
|
+
{ path: ['agents', 'logs'], summary: 'Tail an agent’s terminal from its output offset: a conversation id (0.18.1+) or an agent workspace key', positionals: [{ name: 'agent', required: true }], options: {
|
|
1304
|
+
app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice; a conversation uses its own).' },
|
|
1293
1305
|
offset: { type: 'string', value: '<bytes>', description: 'Read from this output byte offset (default last 64 KiB).' },
|
|
1294
1306
|
follow: { type: 'boolean', description: 'Follow output until interrupted.' },
|
|
1295
1307
|
} },
|
|
@@ -1719,7 +1731,7 @@ export const EXIT_CODE_HELP = [
|
|
|
1719
1731
|
/** The top help's sections: every command's group (first word) is in exactly one (args.test.ts checks). */
|
|
1720
1732
|
export const HELP_SECTIONS = [
|
|
1721
1733
|
{ title: 'Account (a user session: shard signup or shard auth login)', groups: ['signup', 'verify', 'auth', 'setup', 'env', 'orgs', 'projects', 'api-keys', 'members', 'invitations', 'billing', 'audit', 'account'] },
|
|
1722
|
-
{ title: 'Cloud agents (shard ./ starts one; a project API key)', groups: ['conversations', 'machine', 'template', 'agents', 'accounts', 'device', 'cli-proxy', 'ssh-proxy'] },
|
|
1734
|
+
{ title: 'Cloud agents (shard ./ starts one; a project API key)', groups: ['conversations', 'move', 'machine', 'template', 'agents', 'accounts', 'device', 'cli-proxy', 'ssh-proxy'] },
|
|
1723
1735
|
{ title: 'Workspaces for your app (a project API key: SHARDFLUX_API_KEY, else the one shard setup saved)', groups: ['login', 'whoami', 'status', 'usage', 'workspaces', 'files', 'operations', 'executions', 'secrets', 'credentials', 'volumes', 'egress'] },
|
|
1724
1736
|
{ title: 'Templates for your app’s workspaces (not for cloud agents; publish and archive use the session, the rest the API key)', groups: ['templates'] },
|
|
1725
1737
|
{ title: 'Other', groups: ['menubar', 'update', 'feedback', 'version'] },
|
|
@@ -11,9 +11,11 @@ import { agentName as gitAgentName } from "./agent-git.js";
|
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
12
12
|
import { prepareAgentMachine } from "./agent-machine.js";
|
|
13
13
|
import { OperationFailedError } from '@shardflux/sdk';
|
|
14
|
+
import { conversationPtyId, getConversation } from "./conversations.js";
|
|
14
15
|
import { launchConversationProject, launchProjectCommand } from "./conversation-terminal.js";
|
|
15
16
|
import { startThreadOn } from "./thread-on-computer.js";
|
|
16
17
|
import { launchT3 } from "./t3-launch.js";
|
|
18
|
+
const CONVERSATION_ID = /^conv_[a-z0-9]{26}$/;
|
|
17
19
|
/** L9 can register target-specific handlers after detecting the installed applications. */
|
|
18
20
|
export async function availableTargets(ctx) {
|
|
19
21
|
const { HANDLERS } = await import("./handlers.js");
|
|
@@ -124,14 +126,38 @@ export const CENTER_HANDLERS = {
|
|
|
124
126
|
},
|
|
125
127
|
async 'agents logs'(ctx, values, [ref]) {
|
|
126
128
|
if (!ref)
|
|
127
|
-
throw new UsageError('agents logs requires an agent workspace key');
|
|
129
|
+
throw new UsageError('agents logs requires a conversation id or an agent workspace key');
|
|
128
130
|
if (values.app !== undefined && values.app !== 'claude' && values.app !== 'codex')
|
|
129
131
|
throw new UsageError('--app must be claude or codex');
|
|
130
|
-
|
|
132
|
+
// A conversation (0.18.1+): its id, or its VM's key. Its PTY is the guest's record, never derived from the VM key.
|
|
133
|
+
let conversation;
|
|
134
|
+
let ws;
|
|
135
|
+
if (CONVERSATION_ID.test(ref)) {
|
|
136
|
+
conversation = await getConversation(ctx, ref);
|
|
137
|
+
ws = await ctx.cloud().workspaces.get(conversation.workspace_id);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
ws = await resolveWorkspace(ctx, ref);
|
|
141
|
+
if (ws.labels.shard_conversation && CONVERSATION_ID.test(ws.labels.shard_conversation))
|
|
142
|
+
conversation = await getConversation(ctx, ws.labels.shard_conversation);
|
|
143
|
+
}
|
|
144
|
+
if (conversation?.route === 'codex-cloud')
|
|
145
|
+
throw new UsageError(`Codex Cloud runs this conversation; follow it with shard agents open ${conversation.id}.`);
|
|
146
|
+
if (conversation?.route === 'acp')
|
|
147
|
+
throw new UsageError('This conversation runs through its ACP client, which shows its output; it has no terminal to follow.');
|
|
148
|
+
if (conversation && values.app !== undefined && values.app !== conversation.harness)
|
|
149
|
+
throw new UsageError(`This conversation runs ${conversation.harness}; omit --app.`);
|
|
131
150
|
const project = ws.labels.repo_remote || ws.labels.shard_project || ws.key;
|
|
132
|
-
const app = str(values.app) ?? new AgentStateStore(ctx.store.dir).app(project, ws.labels.app === 'codex' ? 'codex' : 'claude');
|
|
133
|
-
const id = `shard-${ws.labels.agent_pty_name || agentName(ws.key, ws.labels)}-${app}`;
|
|
151
|
+
const app = conversation?.harness ?? str(values.app) ?? new AgentStateStore(ctx.store.dir).app(project, ws.labels.app === 'codex' ? 'codex' : 'claude');
|
|
134
152
|
const cell = ws.cell({ agentLabel: ctx.agentLabel, ...(ctx.wake ? {} : { wake: null }), transitionTimeoutMs: ctx.wakeTimeoutMs });
|
|
153
|
+
let id;
|
|
154
|
+
try {
|
|
155
|
+
id = conversation ? await conversationPtyId(cell, conversation, ctx.signal) : `shard-${ws.labels.agent_pty_name || agentName(ws.key, ws.labels)}-${app}`;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
cell.close();
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
135
161
|
const offsetText = str(values.offset);
|
|
136
162
|
if (offsetText !== undefined && (!/^\d+$/.test(offsetText) || !Number.isSafeInteger(Number(offsetText))))
|
|
137
163
|
throw new UsageError('--offset must be a nonnegative safe integer');
|
|
@@ -175,7 +201,7 @@ export const CENTER_HANDLERS = {
|
|
|
175
201
|
}
|
|
176
202
|
const remainder = decoder.decode();
|
|
177
203
|
if (ctx.json)
|
|
178
|
-
ctx.io.stdout.write(json({ pty_id: id, output: output + remainder, nextOffset: cursor.offset, session, exited }));
|
|
204
|
+
ctx.io.stdout.write(json({ pty_id: id, ...(conversation ? { conversation: { id: conversation.id } } : {}), output: output + remainder, nextOffset: cursor.offset, session, exited }));
|
|
179
205
|
else
|
|
180
206
|
ctx.io.stdout.write(remainder);
|
|
181
207
|
return 0;
|
|
@@ -22,7 +22,12 @@ export const CONVERSATION_HANDLERS = {
|
|
|
22
22
|
return 0;
|
|
23
23
|
},
|
|
24
24
|
async 'conversations get'(ctx, _values, [id]) { const c = await conversationClient(ctx).conversations.get(id); const q = questionLine(c, 200); out(ctx, `${c.title}\n${c.state}\n${c.branch}\n${q ? `${q}\n` : ''}`, c); return 0; },
|
|
25
|
-
|
|
25
|
+
// A finished conversation whose VM was removed restores into a fresh one, then takes the message (APIs that do; 0.18.1+).
|
|
26
|
+
async 'conversations send'(ctx, _values, [id, text]) {
|
|
27
|
+
const r = await conversationClient(ctx).conversations.send(id, text, { signal: ctx.signal });
|
|
28
|
+
out(ctx, `${r.restored ? 'Restored\n' : ''}Message sent (${r.seq}).\n`, r);
|
|
29
|
+
return 0;
|
|
30
|
+
},
|
|
26
31
|
async 'conversations wait'(ctx, values, [id]) {
|
|
27
32
|
const seconds = values.timeout === undefined ? 900 : Number(values.timeout);
|
|
28
33
|
if (!Number.isInteger(seconds) || seconds < 0 || seconds > 900)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
3
|
-
import { workspaceRepositoryPath, projectWorkspacePath
|
|
4
|
-
import {
|
|
3
|
+
import { workspaceRepositoryPath, projectWorkspacePath } from "./agent-repository.js";
|
|
4
|
+
import { copySyncBlob } from "./project-sync-transfer.js";
|
|
5
5
|
import { UsageError } from "./errors.js";
|
|
6
6
|
import { conversationClient } from "./conversation-commands.js";
|
|
7
7
|
export const ROUTER_CONFIG = '/home/user/.config/shardflux/conversation-router.json';
|
|
@@ -58,18 +58,14 @@ export async function prepareDesktopRouter(ctx, source) {
|
|
|
58
58
|
const archive = `/tmp/shard-router-${randomUUID()}.bundle`;
|
|
59
59
|
try {
|
|
60
60
|
if (created) {
|
|
61
|
-
const packed = await cell.exec.run(['git', '-C', workspaceRepositoryPath(source), '-c', 'core.hooksPath=/dev/null', 'bundle', 'create', archive, 'HEAD'], { timeoutMs:
|
|
61
|
+
const packed = await cell.exec.run(['git', '-C', workspaceRepositoryPath(source), '-c', 'core.hooksPath=/dev/null', 'bundle', 'create', archive, 'HEAD'], { timeoutMs: 600_000, signal: ctx.signal });
|
|
62
62
|
if (packed.exitCode !== 0)
|
|
63
63
|
throw new Error(`Project router seed failed: ${packed.stderr.trim()}`);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const bytes = await cell.files.read(archive, { length: info.size + 1 });
|
|
68
|
-
if (bytes.length !== info.size)
|
|
69
|
-
throw new Error('Project router history changed during copying.');
|
|
70
|
-
await writeSyncBlob(destination, archive, bytes);
|
|
64
|
+
// Any history size: the bundle goes VM to VM one chunk at a time, and the import may take a second per 8 MiB.
|
|
65
|
+
const { size } = await cell.files.stat(archive);
|
|
66
|
+
await copySyncBlob(cell, archive, size, destination, archive, ctx.signal);
|
|
71
67
|
await destination.files.mkdir(root, { parents: true });
|
|
72
|
-
const imported = await destination.exec.run(['sh', '-c', 'set -eu; git -C "$1" init -q; git -C "$1" -c core.hooksPath=/dev/null fetch -q "$2" HEAD:refs/heads/shardflux-router; git -C "$1" -c core.hooksPath=/dev/null checkout -q shardflux-router', 'sh', root, archive], { timeoutMs: 120_000, signal: ctx.signal });
|
|
68
|
+
const imported = await destination.exec.run(['sh', '-c', 'set -eu; git -C "$1" init -q; git -C "$1" -c core.hooksPath=/dev/null fetch -q "$2" HEAD:refs/heads/shardflux-router; git -C "$1" -c core.hooksPath=/dev/null checkout -q shardflux-router', 'sh', root, archive], { timeoutMs: 120_000 + Math.ceil(size / (8 << 20)) * 1000, signal: ctx.signal });
|
|
73
69
|
if (imported.exitCode !== 0)
|
|
74
70
|
throw new Error(`Project router repository could not be prepared: ${imported.stderr.trim()}`);
|
|
75
71
|
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { open, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
5
6
|
import { conversationName, listConversations, localConversations, rememberConversation } from "./conversations.js";
|
|
6
7
|
import { gitRunner } from "./agent-git.js";
|
|
7
8
|
import { shardRemote } from "./git-remote.js";
|
|
8
9
|
import { writePrivateFile } from "./credentials.js";
|
|
9
10
|
import { conversationClient } from "./conversation-commands.js";
|
|
10
|
-
|
|
11
|
+
import { movedSession, movedUnchanged } from "./moved-sessions.js";
|
|
12
|
+
import { claudeProjectName } from "./local-sessions.js";
|
|
13
|
+
import { remoteManifest, SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
|
|
11
14
|
export function rewriteTranscript(bytes, remoteCwd, localCwd) {
|
|
12
15
|
const text = new TextDecoder('utf8', { fatal: true }).decode(bytes);
|
|
13
16
|
const rewrite = (value) => {
|
|
@@ -30,7 +33,7 @@ export function transcriptDestination(c, folder, filename, env) {
|
|
|
30
33
|
throw new Error('Transcript filename is not a native JSONL file.');
|
|
31
34
|
const home = env.HOME || homedir();
|
|
32
35
|
if (c.harness === 'claude')
|
|
33
|
-
return join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects', resolve(folder)
|
|
36
|
+
return join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects', claudeProjectName(resolve(folder)), filename);
|
|
34
37
|
const match = /^rollout-(\d{4})-(\d{2})-(\d{2})T/.exec(filename);
|
|
35
38
|
if (!match)
|
|
36
39
|
throw new Error('Codex transcript has no native rollout date.');
|
|
@@ -61,6 +64,43 @@ function readStashFile(file) {
|
|
|
61
64
|
closeSync(fd);
|
|
62
65
|
}
|
|
63
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Copies one transcript into the stash in SYNC_CHUNK_BYTES reads (any size, never whole in memory): checked as UTF-8
|
|
69
|
+
* and against the manifest hash on the way, written private (0600) next to its destination and renamed into place.
|
|
70
|
+
*/
|
|
71
|
+
async function copyTranscript(cell, path, entry, dir, file, signal) {
|
|
72
|
+
const tmp = join(dir, `.${entry.path}.${randomUUID()}.tmp`);
|
|
73
|
+
try {
|
|
74
|
+
const fd = await open(tmp, 'wx', 0o600);
|
|
75
|
+
try {
|
|
76
|
+
const hash = createHash('sha256'), utf8 = new TextDecoder('utf8', { fatal: true });
|
|
77
|
+
for (let offset = 0; offset < entry.size;) {
|
|
78
|
+
signal.throwIfAborted();
|
|
79
|
+
const want = Math.min(SYNC_CHUNK_BYTES, entry.size - offset);
|
|
80
|
+
// A read below the gateway's per-read cap returns what was asked unless the file shrank.
|
|
81
|
+
const chunk = await cell.files.read(path, { offset, length: want });
|
|
82
|
+
if (chunk.length !== want)
|
|
83
|
+
throw new Error('Transcript changed during copying; retaining the previous stash.');
|
|
84
|
+
hash.update(chunk);
|
|
85
|
+
utf8.decode(chunk, { stream: true });
|
|
86
|
+
for (let at = 0; at < chunk.length;)
|
|
87
|
+
at += (await fd.write(chunk, at, chunk.length - at, offset + at)).bytesWritten;
|
|
88
|
+
offset += chunk.length;
|
|
89
|
+
}
|
|
90
|
+
utf8.decode();
|
|
91
|
+
if (hash.digest('hex') !== entry.sha256)
|
|
92
|
+
throw new Error('Transcript changed during copying; retaining the previous stash.');
|
|
93
|
+
await fd.sync();
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await fd.close();
|
|
97
|
+
}
|
|
98
|
+
await rename(tmp, file);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await rm(tmp, { force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
64
104
|
/** Keep growing copies away from native session discovery until the control plane ends the conversation. */
|
|
65
105
|
export async function mirrorConversation(ctx, record, source, skipped = new Set()) {
|
|
66
106
|
const c = record.conversation;
|
|
@@ -85,7 +125,8 @@ export async function mirrorConversation(ctx, record, source, skipped = new Set(
|
|
|
85
125
|
ctx.signal.addEventListener('abort', close, { once: true });
|
|
86
126
|
try {
|
|
87
127
|
ctx.signal.throwIfAborted();
|
|
88
|
-
|
|
128
|
+
// Transcripts of any size: no per-file bound (an older guest's size skips are hashed in the VM).
|
|
129
|
+
const manifest = await remoteManifest(cell, root, undefined, ctx.signal);
|
|
89
130
|
for (const entry of manifest.skipped)
|
|
90
131
|
skip(entry.path, entry.reason);
|
|
91
132
|
let count = 0;
|
|
@@ -98,8 +139,8 @@ export async function mirrorConversation(ctx, record, source, skipped = new Set(
|
|
|
98
139
|
skip(entry.path, 'not a safe top-level JSONL or JSON file');
|
|
99
140
|
continue;
|
|
100
141
|
}
|
|
101
|
-
if (!Number.isSafeInteger(entry.size) || entry.size < 0
|
|
102
|
-
skip(entry.path, '
|
|
142
|
+
if (!Number.isSafeInteger(entry.size) || entry.size < 0) {
|
|
143
|
+
skip(entry.path, 'invalid size');
|
|
103
144
|
continue;
|
|
104
145
|
}
|
|
105
146
|
try {
|
|
@@ -109,12 +150,8 @@ export async function mirrorConversation(ctx, record, source, skipped = new Set(
|
|
|
109
150
|
continue;
|
|
110
151
|
}
|
|
111
152
|
const expected = existing ? createHash('sha256').update(readStashFile(file)).digest('hex') : '';
|
|
112
|
-
if (expected !== entry.sha256)
|
|
113
|
-
|
|
114
|
-
if (bytes.length !== entry.size || createHash('sha256').update(bytes).digest('hex') !== entry.sha256)
|
|
115
|
-
throw new Error('Transcript changed during copying; retaining the previous stash.');
|
|
116
|
-
writePrivateFile(dir, file, new TextDecoder('utf8', { fatal: true }).decode(bytes));
|
|
117
|
-
}
|
|
153
|
+
if (expected !== entry.sha256)
|
|
154
|
+
await copyTranscript(cell, `${root}/${entry.path}`, entry, dir, file, ctx.signal);
|
|
118
155
|
}
|
|
119
156
|
catch (e) {
|
|
120
157
|
throw new Error(`Entry ${JSON.stringify(entry.path)}: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
@@ -148,10 +185,16 @@ export async function handoffConversation(ctx, c, folder, dir, source) {
|
|
|
148
185
|
const files = readdirSync(dir).filter(f => !f.includes('..') && /^[a-zA-Z0-9_.-]+\.jsonl$/.test(f) && lstatSync(join(dir, f)).isFile());
|
|
149
186
|
if (!files.length)
|
|
150
187
|
throw new Error('Conversation has no transcript to hand off.');
|
|
151
|
-
|
|
188
|
+
// A session moved in from this computer (`shard move`) comes back to the file it left; it replaces that local copy only
|
|
189
|
+
// while the copy holds exactly what moved (nobody continued it here meanwhile).
|
|
190
|
+
const moved = c.harness_session_id ? movedSession(ctx.store.dir, ctx.apiUrl, c.harness, c.harness_session_id) : undefined;
|
|
191
|
+
const writes = files.map(name => {
|
|
192
|
+
const dest = moved && name === basename(moved.file) ? moved.file : transcriptDestination(c, folder, name, ctx.io.env);
|
|
193
|
+
return { dest, bytes: rewriteTranscript(readStashFile(join(dir, name)), c.worktree_path, folder), replace: !!moved && dest === moved.file && movedUnchanged(moved) };
|
|
194
|
+
});
|
|
152
195
|
// Check every destination before the first write. Preserve local native history on any collision.
|
|
153
|
-
for (const { dest, bytes } of writes) {
|
|
154
|
-
if (existsSync(dest) && (lstatSync(dest).isSymbolicLink() || !readFileSync(dest).equals(Buffer.from(bytes))))
|
|
196
|
+
for (const { dest, bytes, replace } of writes) {
|
|
197
|
+
if (existsSync(dest) && (lstatSync(dest).isSymbolicLink() || (!replace && !readFileSync(dest).equals(Buffer.from(bytes)))))
|
|
155
198
|
throw new Error(`Native transcript already exists with different bytes: ${dest}`);
|
|
156
199
|
}
|
|
157
200
|
if (!source)
|
|
@@ -160,9 +203,12 @@ export async function handoffConversation(ctx, c, folder, dir, source) {
|
|
|
160
203
|
const run = gitRunner(folder, { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl }, ctx.signal);
|
|
161
204
|
const hub = 'hub' in source ? source.hub : shardRemote(source.key, '/shardflux/project/git/hub.git');
|
|
162
205
|
await run(['-c', 'core.hooksPath=/dev/null', 'fetch', '--no-tags', hub, `+${c.state === 'archived' ? `refs/shard/archive/${name}` : `refs/heads/${c.branch}`}:refs/remotes/shardflux/${name}`]);
|
|
163
|
-
for (const { dest, bytes } of writes) {
|
|
206
|
+
for (const { dest, bytes, replace } of writes) {
|
|
164
207
|
privateDirectory(dirname(dest));
|
|
165
|
-
|
|
208
|
+
// The local copy it replaces stays in the stash.
|
|
209
|
+
if (replace && existsSync(dest) && !readFileSync(dest).equals(Buffer.from(bytes)))
|
|
210
|
+
writePrivateFile(dir, join(dir, `local-${basename(dest)}`), readStashFile(dest).toString('utf8'));
|
|
211
|
+
if (!existsSync(dest) || replace)
|
|
166
212
|
writePrivateFile(dirname(dest), dest, new TextDecoder('utf8', { fatal: true }).decode(bytes));
|
|
167
213
|
}
|
|
168
214
|
rememberConversation(ctx, c, folder, true);
|
|
@@ -3,7 +3,17 @@ import type { Ctx, Handler } from './commands.js';
|
|
|
3
3
|
import type { Values } from './args.js';
|
|
4
4
|
import type { Conversation } from './conversations.js';
|
|
5
5
|
/** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
|
|
6
|
-
export declare function conversationTerminal(ctx: Ctx, ws: Workspace, c: Conversation, project: string, values: Values): Promise<number>;
|
|
6
|
+
export declare function conversationTerminal(ctx: Ctx, ws: Workspace, c: Conversation, project: string, values: Values, afterStart?: () => Promise<void> | undefined): Promise<number>;
|
|
7
|
+
/**
|
|
8
|
+
* A conversation and its VM, for an app to open (Claude Desktop, claude.ai, T3 Code, the menu bar). A conversation
|
|
9
|
+
* whose VM was removed after it finished restores first (0.18.1+): a fresh VM from its saved branch and transcript, the
|
|
10
|
+
* same conversation and its session resumed. A terminal conversation's session starts detached, as a new one does, so
|
|
11
|
+
* the app reaches it at once; the guest starts the others.
|
|
12
|
+
*/
|
|
13
|
+
export declare function reopenConversation(ctx: Ctx, c: Conversation, values: Values): Promise<{
|
|
14
|
+
conversation: Conversation;
|
|
15
|
+
ws: Workspace;
|
|
16
|
+
}>;
|
|
7
17
|
/**
|
|
8
18
|
* `shard ./` (and `agents new --in auto`): new conversations open where `agents targets` says (open_in, 0.17.0+): T3
|
|
9
19
|
* Code mode, Claude Desktop or claude.ai for Claude (the conversation's Remote Control session), else this terminal.
|
|
@@ -9,7 +9,7 @@ import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from
|
|
|
9
9
|
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
10
10
|
import { prepareProjectTemplate } from "./project-template-setup.js";
|
|
11
11
|
import { projectWorkspacePath, seedAgentRepository } from "./agent-repository.js";
|
|
12
|
-
import { syncAgentConfig } from "./agent-config-sync.js";
|
|
12
|
+
import { syncAgentConfig, syncAgentConfigAfterStart } from "./agent-config-sync.js";
|
|
13
13
|
import { savedMachine } from "./filesystem-state.js";
|
|
14
14
|
import { ensureMenubar, MenubarManager } from "./menubar.js";
|
|
15
15
|
import { UsageError } from "./errors.js";
|
|
@@ -23,14 +23,35 @@ import { launchT3 } from "./t3-launch.js";
|
|
|
23
23
|
import { openClaudeDesktopSession, remoteControlUrl } from "./agent-target-commands.js";
|
|
24
24
|
import { openUrl } from "./browser.js";
|
|
25
25
|
import { out } from "./commands.js";
|
|
26
|
+
import { progress } from "./progress.js";
|
|
26
27
|
/** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
|
|
27
|
-
export async function conversationTerminal(ctx, ws, c, project, values) {
|
|
28
|
+
export async function conversationTerminal(ctx, ws, c, project, values, afterStart) {
|
|
28
29
|
if (c.route === 'codex-cloud')
|
|
29
30
|
return openCodexConversation(ctx, c);
|
|
30
31
|
await restoreConversationBinding(ctx, c, ws);
|
|
31
32
|
// A thread on another conversation's computer has its own PTY there, never the VM's own (0.16.0+).
|
|
32
33
|
const pty = ws.labels.shard_conversation && ws.labels.shard_conversation !== c.id ? conversationName(c) : undefined;
|
|
33
|
-
return terminal(ctx, ws, c.harness, conversationName(c), project, values, undefined, c.worktree_path, { id: c.id, session: c.harness_session_id, title: c.title, ...(pty ? { pty } : {}), choice: recordedChoice(c) });
|
|
34
|
+
return terminal(ctx, ws, c.harness, conversationName(c), project, values, undefined, c.worktree_path, { id: c.id, session: c.harness_session_id, title: c.title, ...(pty ? { pty } : {}), choice: recordedChoice(c), ...(afterStart ? { afterStart } : {}) });
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A conversation and its VM, for an app to open (Claude Desktop, claude.ai, T3 Code, the menu bar). A conversation
|
|
38
|
+
* whose VM was removed after it finished restores first (0.18.1+): a fresh VM from its saved branch and transcript, the
|
|
39
|
+
* same conversation and its session resumed. A terminal conversation's session starts detached, as a new one does, so
|
|
40
|
+
* the app reaches it at once; the guest starts the others.
|
|
41
|
+
*/
|
|
42
|
+
export async function reopenConversation(ctx, c, values) {
|
|
43
|
+
if (!c.ended_at || c.route === 'codex-cloud')
|
|
44
|
+
return { conversation: c, ws: await conversationWorkspace(ctx, c) };
|
|
45
|
+
const restored = await resumeConversation(ctx, c);
|
|
46
|
+
const ws = await conversationWorkspace(ctx, restored);
|
|
47
|
+
if (restored.route !== 'terminal') {
|
|
48
|
+
progress(ctx, 'Restored');
|
|
49
|
+
return { conversation: restored, ws };
|
|
50
|
+
}
|
|
51
|
+
const afterStart = (await savedMachine(ctx))?.template ? undefined : () => syncAgentConfigAfterStart(ctx, ws);
|
|
52
|
+
const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
|
|
53
|
+
await conversationTerminal(quiet, ws, restored, ws.labels.repo_remote || ws.labels.shard_project || ws.key, { ...values, detach: true }, afterStart);
|
|
54
|
+
return { conversation: restored, ws };
|
|
34
55
|
}
|
|
35
56
|
/**
|
|
36
57
|
* `shard ./` (and `agents new --in auto`): new conversations open where `agents targets` says (open_in, 0.17.0+): T3
|
|
@@ -126,13 +147,13 @@ async function startConversation(ctx, values, [path = './', explicit], openIn) {
|
|
|
126
147
|
await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
|
|
127
148
|
if (!signedIn)
|
|
128
149
|
await loginClaudeInWorkspace(ctx, ws);
|
|
129
|
-
|
|
130
|
-
|
|
150
|
+
// Without a saved machine the laptop's Claude and Codex config is copied in, after the agent has its first prompt.
|
|
151
|
+
const afterStart = (await savedMachine(ctx))?.template ? undefined : () => syncAgentConfigAfterStart(ctx, ws);
|
|
131
152
|
await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
|
|
132
153
|
if (openIn) {
|
|
133
154
|
// The agent starts detached in its VM; the conversation then opens in Claude Desktop or claude.ai (Remote Control).
|
|
134
155
|
const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
|
|
135
|
-
await conversationTerminal(quiet, ws, c, project.remote ?? project.folder, { ...values, detach: true });
|
|
156
|
+
await conversationTerminal(quiet, ws, c, project.remote ?? project.folder, { ...values, detach: true }, afterStart);
|
|
136
157
|
if (openIn === 'claude-desktop')
|
|
137
158
|
return openClaudeDesktopSession(ctx, ws, c);
|
|
138
159
|
const url = await remoteControlUrl(ctx, ws, c);
|
|
@@ -141,5 +162,5 @@ async function startConversation(ctx, values, [path = './', explicit], openIn) {
|
|
|
141
162
|
out(ctx, `${url}\n`, { target: 'web', workspace: ws.data, conversation_id: c.id, url });
|
|
142
163
|
return 0;
|
|
143
164
|
}
|
|
144
|
-
return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values);
|
|
165
|
+
return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values, afterStart);
|
|
145
166
|
}
|
package/dist/conversations.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Workspace, WorkspaceView } from '@shardflux/sdk';
|
|
1
|
+
import type { CellClient, Workspace, WorkspaceView } from '@shardflux/sdk';
|
|
2
2
|
import type { Ctx } from './commands.js';
|
|
3
3
|
import type { ModelChoice } from './agent-models.js';
|
|
4
4
|
export type Harness = 'claude' | 'codex';
|
|
@@ -65,6 +65,13 @@ export interface LocalConversation {
|
|
|
65
65
|
*/
|
|
66
66
|
export declare const conversationEnded: (c: Pick<Conversation, "state" | "ended_at" | "route" | "landed">) => boolean;
|
|
67
67
|
export declare const conversationName: (c: Conversation) => string;
|
|
68
|
+
/**
|
|
69
|
+
* The managed PTY a conversation's harness runs in (0.18.1+). The guest records it per conversation
|
|
70
|
+
* (sf-agent's conversation runtime record), which also keeps the PTY a fork inherited from its parent; without a
|
|
71
|
+
* record it is the one the guest names after the conversation, `shard-<name>-<harness>`. Never the VM key's name: a
|
|
72
|
+
* conversation's VM is `<project>/conv-<name>`.
|
|
73
|
+
*/
|
|
74
|
+
export declare function conversationPtyId(cell: Pick<CellClient, 'exec'>, c: Conversation, signal?: AbortSignal): Promise<string>;
|
|
68
75
|
export declare function validateConversation(c: Conversation): Conversation;
|
|
69
76
|
export declare function localConversations(ctx: Pick<Ctx, 'store' | 'apiUrl'>): Record<string, LocalConversation>;
|
|
70
77
|
export declare function rememberConversation(ctx: Pick<Ctx, 'store' | 'apiUrl'>, c: Conversation, folder: string, handedOff?: boolean, baseBranch?: string): void;
|
|
@@ -80,8 +87,15 @@ export declare function conversationWorkspace(ctx: Ctx, c: Conversation): Promis
|
|
|
80
87
|
*/
|
|
81
88
|
export declare function waitThreadReady(ctx: Ctx, c: Conversation, pollMs?: number): Promise<Conversation>;
|
|
82
89
|
export declare function restoreConversationBinding(ctx: Ctx, c: Conversation, ws: Workspace): Promise<void>;
|
|
83
|
-
|
|
84
|
-
export
|
|
90
|
+
/** A native session moved in from this computer (`shard move`, 0.19.0+): the conversation starts by resuming it. */
|
|
91
|
+
export interface MovedInSession {
|
|
92
|
+
session_id: string;
|
|
93
|
+
title?: string;
|
|
94
|
+
}
|
|
95
|
+
export declare function createConversationRecord(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, allocated?: (workspace: WorkspaceView) => void, idempotencyKey?: string, choice?: ModelChoice, movedIn?: MovedInSession): Promise<Conversation>;
|
|
96
|
+
export declare function createConversation(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, choice?: ModelChoice, movedIn?: MovedInSession & {
|
|
97
|
+
beforeSeed(ws: Workspace, c: Conversation): Promise<void>;
|
|
98
|
+
}): Promise<{
|
|
85
99
|
conversation: Conversation;
|
|
86
100
|
workspace: Workspace;
|
|
87
101
|
}>;
|