@shardflux/cli 0.13.6 → 0.14.1

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +44 -4
  3. package/dist/acp-catalog.d.ts +7 -0
  4. package/dist/acp-catalog.js +40 -0
  5. package/dist/acp-journal.d.ts +18 -0
  6. package/dist/acp-journal.js +232 -0
  7. package/dist/acp-models.d.ts +36 -0
  8. package/dist/acp-models.js +161 -0
  9. package/dist/acp-rpc.d.ts +41 -0
  10. package/dist/acp-rpc.js +115 -0
  11. package/dist/acp.d.ts +29 -0
  12. package/dist/acp.js +464 -0
  13. package/dist/agent-commands.d.ts +10 -2
  14. package/dist/agent-commands.js +37 -18
  15. package/dist/agent-machine.d.ts +6 -0
  16. package/dist/agent-machine.js +93 -0
  17. package/dist/agent-repository.d.ts +2 -0
  18. package/dist/agent-repository.js +42 -31
  19. package/dist/agent-state.d.ts +3 -0
  20. package/dist/agent-state.js +33 -12
  21. package/dist/agent-target-commands.d.ts +4 -1
  22. package/dist/agent-target-commands.js +110 -40
  23. package/dist/agent-targets.d.ts +6 -4
  24. package/dist/agent-targets.js +72 -31
  25. package/dist/args.d.ts +1 -1
  26. package/dist/args.js +53 -14
  27. package/dist/command-center-actions.js +26 -3
  28. package/dist/command-center.js +20 -4
  29. package/dist/conversation-commands.d.ts +3 -0
  30. package/dist/conversation-commands.js +36 -0
  31. package/dist/conversation-local-resume.d.ts +8 -0
  32. package/dist/conversation-local-resume.js +56 -0
  33. package/dist/conversation-local.d.ts +7 -0
  34. package/dist/conversation-local.js +61 -0
  35. package/dist/conversation-router.d.ts +12 -0
  36. package/dist/conversation-router.js +92 -0
  37. package/dist/conversation-stash.d.ts +11 -0
  38. package/dist/conversation-stash.js +158 -0
  39. package/dist/conversation-terminal.d.ts +3 -0
  40. package/dist/conversation-terminal.js +74 -0
  41. package/dist/conversation-worktree.d.ts +4 -0
  42. package/dist/conversation-worktree.js +22 -0
  43. package/dist/conversations.d.ts +53 -0
  44. package/dist/conversations.js +119 -0
  45. package/dist/device-bridge.d.ts +80 -0
  46. package/dist/device-bridge.js +409 -0
  47. package/dist/device-helper.d.ts +3 -0
  48. package/dist/device-helper.js +104 -0
  49. package/dist/errors.js +5 -0
  50. package/dist/format.js +3 -2
  51. package/dist/git-remote-bin.js +3 -1
  52. package/dist/git-remote.d.ts +3 -1
  53. package/dist/git-remote.js +2 -1
  54. package/dist/handlers.js +3 -1
  55. package/dist/main.js +15 -3
  56. package/dist/project-sync.d.ts +5 -1
  57. package/dist/project-sync.js +7 -6
  58. package/dist/t3-provider.d.ts +6 -0
  59. package/dist/t3-provider.js +99 -0
  60. package/dist/t3-runtime.d.ts +6 -0
  61. package/dist/t3-runtime.js +63 -0
  62. package/dist/target-lock.d.ts +3 -0
  63. package/dist/target-lock.js +143 -0
  64. package/package.json +3 -3
@@ -1,8 +1,16 @@
1
- import type { CellClient, PtySession } from '@shardflux/sdk';
1
+ import type { CellClient, PtySession, Workspace } from '@shardflux/sdk';
2
2
  import type { Ctx, Handler } from './commands.js';
3
+ import type { Values } from './args.js';
3
4
  /** `<slug>-<6 hex of sha256(identity)>`: the same repository gets the same key on every machine. */
4
5
  export declare function disambiguatedSlug(slug: string, identity: string): string;
5
6
  export declare function projectTemplate(ctx: Ctx, remote: string | null): Promise<string>;
6
- export declare function agentPty(cell: CellClient, app: 'claude' | 'codex', agent: string, approvals: boolean, term: string, signal?: AbortSignal, project?: string, setupHash?: string, cwd?: string, ptyName?: string, onLaunch?: () => void, deferSetup?: boolean): Promise<PtySession>;
7
+ export declare function agentPty(cell: CellClient, app: 'claude' | 'codex', agent: string, approvals: boolean, term: string, signal?: AbortSignal, project?: string, setupHash?: string, cwd?: string, ptyName?: string, onLaunch?: () => void, deferSetup?: boolean, conversation?: {
8
+ id: string;
9
+ session: string | null;
10
+ }): Promise<PtySession>;
11
+ export declare function terminal(ctx: Ctx, ws: Workspace, app: 'claude' | 'codex', agent: string, project: string, values: Values, setupHash?: string, cwd?: string, conversation?: {
12
+ id: string;
13
+ session: string | null;
14
+ }): Promise<number>;
7
15
  export declare const launchProject: Handler;
8
16
  export declare const AGENT_HANDLERS: Record<string, Handler>;
@@ -4,7 +4,6 @@ import { configureCredentialFill, watchCredentialPrompts } from "./credential-pr
4
4
  import { createHash } from 'node:crypto';
5
5
  import { ShardfluxApiError } from '@shardflux/sdk';
6
6
  import { UsageError } from "./errors.js";
7
- import { startProjectSync } from "./project-sync.js";
8
7
  import { AgentStateStore } from "./agent-state.js";
9
8
  import { attachTerminal, cellConnector } from "./agent-terminal.js";
10
9
  import { prepareProjectTemplate } from "./project-template-setup.js";
@@ -16,9 +15,12 @@ import { startAgentConfigTunnels, syncAgentConfig } from "./agent-config-sync.js
16
15
  import { rememberGitAgent } from "./agent-git-commands.js";
17
16
  import { projectWorkspacePath, seedAgentRepository, workspaceRepositoryPath } from "./agent-repository.js";
18
17
  import { agentName } from "./agent-git.js";
19
- import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
18
+ import { cloudClaudeLogin, ensureCloudClaude, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
20
19
  import { progress } from "./progress.js";
21
20
  import { rememberLaunch } from "./agent-live.js";
21
+ import { prepareAgentMachine } from "./agent-machine.js";
22
+ import { installedTargets } from "./agent-targets.js";
23
+ import { openDesktopWorkspace, queueT3Prewarm } from "./agent-target-commands.js";
22
24
  const active = (session) => session.state === 'running' || session.state === 'starting';
23
25
  /** `<slug>-<6 hex of sha256(identity)>`: the same repository gets the same key on every machine. */
24
26
  export function disambiguatedSlug(slug, identity) {
@@ -40,7 +42,7 @@ export async function projectTemplate(ctx, remote) {
40
42
  throw error;
41
43
  }
42
44
  }
43
- export async function agentPty(cell, app, agent, approvals, term, signal, project, setupHash, cwd, ptyName, onLaunch, deferSetup = false) {
45
+ export async function agentPty(cell, app, agent, approvals, term, signal, project, setupHash, cwd, ptyName, onLaunch, deferSetup = false, conversation) {
44
46
  const id = `shard-${ptyName || agent}-${app}`;
45
47
  // Arrival and changed locks queue the same durable guest job even when the
46
48
  // native agent is already alive. Setup never becomes its first user turn.
@@ -59,7 +61,13 @@ export async function agentPty(cell, app, agent, approvals, term, signal, projec
59
61
  throw error;
60
62
  }
61
63
  onLaunch?.();
62
- const launch = await cell.exec.run(['sf-agent', 'run', app, '--name', agent, '--detach', ...(ptyName ? ['--pty-name', ptyName] : []), ...(approvals ? ['--approvals'] : []), ...(setupHash ? ['--setup', '--setup-deps-hash', setupHash] : [])], {
64
+ let conversationArgs = [];
65
+ if (conversation) {
66
+ const help = await cell.exec.run(['sf-agent', 'run', app, '--help'], { timeoutMs: 10_000, maxOutputBytes: 8192, signal });
67
+ if ((help.stdout + help.stderr).includes('-conversation'))
68
+ conversationArgs = ['--conversation', conversation.id, ...(conversation.session ? ['--resume-session', conversation.session] : [])];
69
+ }
70
+ const launch = await cell.exec.run(['sf-agent', 'run', app, '--name', agent, '--detach', ...conversationArgs, ...(ptyName ? ['--pty-name', ptyName] : []), ...(approvals ? ['--approvals'] : []), ...(setupHash ? ['--setup', '--setup-deps-hash', setupHash] : [])], {
63
71
  env: { TERM: term, ...(project ? { SF_PROJECT_NAME: project } : {}), ...(setupHash && deferSetup ? { SF_SETUP_DEFERRED: '1' } : {}) }, timeoutMs: 30_000, maxOutputBytes: 4096, signal, ...(cwd ? { cwd } : {}),
64
72
  });
65
73
  if (launch.exitCode !== 0)
@@ -78,7 +86,7 @@ export async function agentPty(cell, app, agent, approvals, term, signal, projec
78
86
  throw new Error(`agent exited before attach: ${session.state}`);
79
87
  return session;
80
88
  }
81
- async function terminal(ctx, ws, app, agent, project, values, setupHash, cwd = workspaceRepositoryPath(ws)) {
89
+ export async function terminal(ctx, ws, app, agent, project, values, setupHash, cwd = workspaceRepositoryPath(ws), conversation) {
82
90
  const store = new AgentStateStore(ctx.store.dir);
83
91
  if (!ws.ready)
84
92
  await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
@@ -89,7 +97,7 @@ async function terminal(ctx, ws, app, agent, project, values, setupHash, cwd = w
89
97
  try {
90
98
  const credentialFill = await configureCredentialFill(ctx, ws);
91
99
  // A cold launch is remembered before it starts, so the listing can show it before the projection does (agent-live.ts).
92
- const session = await agentPty(cell, app, agent, values.approvals === true, ctx.io.env.TERM || 'xterm-256color', ctx.signal, ws.labels.shard_project || ws.key.split('/')[0], setupHash, cwd, ws.labels.agent_pty_name, () => { rememberLaunch(ctx, ws.id); progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} in ${ws.key}…`); }, true);
100
+ const session = await agentPty(cell, app, agent, values.approvals === true, ctx.io.env.TERM || 'xterm-256color', ctx.signal, ws.labels.shard_project || ws.key.split('/')[0], setupHash, cwd, ws.labels.agent_pty_name, () => { rememberLaunch(ctx, ws.id); progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} in ${ws.key}…`); }, true, conversation);
93
101
  // The real agent is running before any cache provisioning or mount wait.
94
102
  const preparation = prepareSetupCache(ctx, ws).catch((error) => {
95
103
  ctx.io.stderr.write(`Account setup cache unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -150,6 +158,10 @@ export const launchProject = async (ctx, values, [path = './', explicitApp]) =>
150
158
  const project = like ? { folder: path, slug: like.labels.shard_project || like.key.split('/')[0], remote: like.labels.repo_remote || null } : projectIdentity(path);
151
159
  const projectKey = project.remote ?? (like ? project.slug : project.folder);
152
160
  const store = new AgentStateStore(ctx.store.dir);
161
+ const target = typeof values.in === 'string' && values.in !== 'terminal' ? values.in : undefined;
162
+ const detected = target ? installedTargets(ctx.io.env) : undefined;
163
+ if (target && (!['claude-desktop', 'codex-app', 't3'].includes(target) || !detected?.targets.includes(target)))
164
+ throw new UsageError(`${target} is not an installed desktop target`);
153
165
  const app = explicitApp ?? store.app(projectKey, like?.labels.app === 'codex' ? 'codex' : 'claude');
154
166
  const agent = typeof values.name === 'string' ? values.name : 'main';
155
167
  if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(agent))
@@ -177,7 +189,7 @@ export const launchProject = async (ctx, values, [path = './', explicitApp]) =>
177
189
  const claudeReady = app === 'claude' ? await cloudClaudeLogin(ctx) : undefined;
178
190
  if (app === 'claude' && !claudeReady)
179
191
  requireClaudeTerminal(ctx);
180
- if (!ctx.json && values.detach !== true)
192
+ if (!target && !ctx.json && values.detach !== true)
181
193
  requireTerminal(ctx);
182
194
  // A `like` agent inherits the main workspace's template; only a project's own workspace prepares one (L7).
183
195
  const prepared = like ? undefined : await prepareProjectTemplate(ctx, project, existing);
@@ -188,23 +200,29 @@ export const launchProject = async (ctx, values, [path = './', explicitApp]) =>
188
200
  ...(!existing && agent !== 'main' ? { retention: { delete_after_idle_days: 7 } } : {}),
189
201
  wait: { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal },
190
202
  });
203
+ if (target === 't3' && detected?.versions.t3)
204
+ await queueT3Prewarm(ctx, ws, detected.versions.t3);
191
205
  if (app === 'claude' && !claudeReady)
192
206
  await loginClaudeInWorkspace(ctx, ws);
207
+ if (target === 't3' && app === 'codex')
208
+ await ensureCloudClaude(ctx, ws);
193
209
  if (!(await savedMachine(ctx))?.template)
194
210
  await syncAgentConfig(ctx, ws);
195
- // Extra/like agents return work through hidden refs; only the main owns local sync.
196
- const sync = agent === 'main' && !like ? await startProjectSync(ctx, ws, project) : undefined;
197
- try {
198
- if (!sync) {
199
- await ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs }).files.mkdir(workspaceRepositoryPath(ws), { parents: true });
200
- await seedAgentRepository(ctx, ws, project.folder, { populate: true });
211
+ // Seed once; two-way sync is scoped to explicit Try locally worktrees.
212
+ await ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs }).files.mkdir(workspaceRepositoryPath(ws), { parents: true });
213
+ await seedAgentRepository(ctx, ws, project.folder, { populate: true });
214
+ await rememberGitAgent(ctx, ws, project.folder);
215
+ if (target) {
216
+ if (prepared?.setupHash) {
217
+ const setup = await ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs }).exec.run(['sf-agent', 'setup-start', '--deps-hash', prepared.setupHash], { cwd: workspaceRepositoryPath(ws), timeoutMs: 30_000, maxOutputBytes: 4096, signal: ctx.signal });
218
+ if (setup.exitCode !== 0)
219
+ throw new Error(`project setup could not be queued: ${setup.stderr.trim() || `exit ${setup.exitCode}`}`);
201
220
  }
202
- await rememberGitAgent(ctx, ws, project.folder);
203
- return await terminal(ctx, ws, app, agent, projectKey, values, prepared?.setupHash, sync?.root);
204
- }
205
- finally {
206
- await sync?.stop();
221
+ store.saveTarget(projectKey, target);
222
+ return openDesktopWorkspace(ctx, ws, target, detected?.versions.t3, true);
207
223
  }
224
+ await prepareAgentMachine(ctx, ws);
225
+ return await terminal(ctx, ws, app, agent, projectKey, values, prepared?.setupHash);
208
226
  };
209
227
  export const AGENT_HANDLERS = {
210
228
  async 'agents open'(ctx, values, [agent = 'main']) {
@@ -239,6 +257,7 @@ export const AGENT_HANDLERS = {
239
257
  requireTerminal(ctx);
240
258
  if (ws.labels.shard_layer !== 'machine' && !(await savedMachine(ctx))?.template)
241
259
  await syncAgentConfig(ctx, ws);
260
+ await prepareAgentMachine(ctx, ws);
242
261
  return terminal(ctx, ws, app, (ws.labels.agent ?? agent).split('/').at(-1), projectKey, values);
243
262
  },
244
263
  };
@@ -0,0 +1,6 @@
1
+ import type { Workspace } from '@shardflux/sdk';
2
+ import type { Ctx } from './commands.js';
3
+ export declare function agentMachineName(key: string): string;
4
+ /** Root owns machine-info; T3 reads the label and caches its environment identity at server start. */
5
+ export declare const AGENT_MACHINE_SCRIPT = "import os, pathlib, pwd, select, shutil, signal, sys, tempfile\nlabel, identity = sys.argv[1:3]\npath = pathlib.Path(sys.argv[3]) if len(sys.argv) > 3 else pathlib.Path('/etc/machine-info')\nif path.is_symlink():\n raise RuntimeError('machine-info must not be a symlink')\nprevious = path.read_text() if path.exists() else ''\nlines = [line for line in previous.splitlines() if not line.startswith('PRETTY_HOSTNAME=')]\nlines.append('PRETTY_HOSTNAME=\"' + label + '\"')\ncontent = '\\n'.join(lines) + '\\n'\nif previous != content:\n fd, tmp = tempfile.mkstemp(prefix='.machine-info-', dir=path.parent)\n try:\n with os.fdopen(fd, 'w') as file:\n file.write(content)\n os.fchmod(file.fileno(), 0o644)\n os.replace(tmp, path)\n finally:\n if os.path.exists(tmp): os.unlink(tmp)\nif identity:\n user = pwd.getpwnam('user')\n home = pathlib.Path(user.pw_dir)\n state = home / '.t3/userdata'\n target = state / 'environment-id'\n if target.is_symlink() or any(p.is_symlink() for p in [home / '.t3', state]):\n raise RuntimeError('T3 identity paths must not be symlinks')\n if not target.exists() or target.read_text().strip() != identity:\n # Inspect the executable and owner, never trust a stale PID file after a fork.\n servers = []\n try:\n for proc in pathlib.Path('/proc').iterdir():\n if not proc.name.isdigit(): continue\n fd = None\n try:\n if proc.stat().st_uid != user.pw_uid: continue\n fd = os.pidfd_open(int(proc.name))\n executable = os.readlink(proc / 'exe').removesuffix(' (deleted)')\n args = (proc / 'cmdline').read_bytes().split(b'\\0')\n if not executable.startswith(str(home / '.t3/runtime') + '/') or b'serve' not in args:\n continue\n servers.append(fd)\n signal.pidfd_send_signal(fd, signal.SIGTERM)\n except (FileNotFoundError, ProcessLookupError):\n continue\n except PermissionError as error:\n raise RuntimeError('could not inspect user T3 processes; identity left unchanged') from error\n finally:\n if fd is not None and fd not in servers: os.close(fd)\n if servers:\n stopped, _, _ = select.select(servers, [], [], 2)\n remaining = [fd for fd in servers if fd not in stopped]\n for fd in remaining:\n try: signal.pidfd_send_signal(fd, signal.SIGKILL)\n except ProcessLookupError: pass\n if remaining and len(select.select(remaining, [], [], 2)[0]) != len(remaining):\n raise RuntimeError('T3 server did not stop')\n finally:\n for fd in servers: os.close(fd)\n launch = home / '.t3/ssh-launch'\n if launch.is_symlink(): launch.unlink()\n elif launch.exists(): shutil.rmtree(launch)\n (state / 'server-runtime.json').unlink(missing_ok=True)\n state.mkdir(parents=True, exist_ok=True)\n os.chown(home / '.t3', user.pw_uid, user.pw_gid)\n os.chown(state, user.pw_uid, user.pw_gid)\n fd, tmp = tempfile.mkstemp(prefix='.environment-id-', dir=state)\n try:\n with os.fdopen(fd, 'w') as file:\n file.write(identity + '\\n')\n os.fchown(file.fileno(), user.pw_uid, user.pw_gid)\n os.replace(tmp, target)\n recovery = state / 'environment-id.recovery'\n recovery.unlink(missing_ok=True)\n finally:\n if os.path.exists(tmp): os.unlink(tmp)\n";
6
+ export declare function prepareAgentMachine(ctx: Ctx, ws: Workspace): Promise<void>;
@@ -0,0 +1,93 @@
1
+ import { targetIdentity } from "./agent-targets.js";
2
+ export function agentMachineName(key) {
3
+ const { project, agent } = targetIdentity(key);
4
+ return `Shardflux · ${project}${agent === 'main' ? '' : ` · ${agent}`}`;
5
+ }
6
+ /** Root owns machine-info; T3 reads the label and caches its environment identity at server start. */
7
+ export const AGENT_MACHINE_SCRIPT = `import os, pathlib, pwd, select, shutil, signal, sys, tempfile
8
+ label, identity = sys.argv[1:3]
9
+ path = pathlib.Path(sys.argv[3]) if len(sys.argv) > 3 else pathlib.Path('/etc/machine-info')
10
+ if path.is_symlink():
11
+ raise RuntimeError('machine-info must not be a symlink')
12
+ previous = path.read_text() if path.exists() else ''
13
+ lines = [line for line in previous.splitlines() if not line.startswith('PRETTY_HOSTNAME=')]
14
+ lines.append('PRETTY_HOSTNAME="' + label + '"')
15
+ content = '\\n'.join(lines) + '\\n'
16
+ if previous != content:
17
+ fd, tmp = tempfile.mkstemp(prefix='.machine-info-', dir=path.parent)
18
+ try:
19
+ with os.fdopen(fd, 'w') as file:
20
+ file.write(content)
21
+ os.fchmod(file.fileno(), 0o644)
22
+ os.replace(tmp, path)
23
+ finally:
24
+ if os.path.exists(tmp): os.unlink(tmp)
25
+ if identity:
26
+ user = pwd.getpwnam('user')
27
+ home = pathlib.Path(user.pw_dir)
28
+ state = home / '.t3/userdata'
29
+ target = state / 'environment-id'
30
+ if target.is_symlink() or any(p.is_symlink() for p in [home / '.t3', state]):
31
+ raise RuntimeError('T3 identity paths must not be symlinks')
32
+ if not target.exists() or target.read_text().strip() != identity:
33
+ # Inspect the executable and owner, never trust a stale PID file after a fork.
34
+ servers = []
35
+ try:
36
+ for proc in pathlib.Path('/proc').iterdir():
37
+ if not proc.name.isdigit(): continue
38
+ fd = None
39
+ try:
40
+ if proc.stat().st_uid != user.pw_uid: continue
41
+ fd = os.pidfd_open(int(proc.name))
42
+ executable = os.readlink(proc / 'exe').removesuffix(' (deleted)')
43
+ args = (proc / 'cmdline').read_bytes().split(b'\\0')
44
+ if not executable.startswith(str(home / '.t3/runtime') + '/') or b'serve' not in args:
45
+ continue
46
+ servers.append(fd)
47
+ signal.pidfd_send_signal(fd, signal.SIGTERM)
48
+ except (FileNotFoundError, ProcessLookupError):
49
+ continue
50
+ except PermissionError as error:
51
+ raise RuntimeError('could not inspect user T3 processes; identity left unchanged') from error
52
+ finally:
53
+ if fd is not None and fd not in servers: os.close(fd)
54
+ if servers:
55
+ stopped, _, _ = select.select(servers, [], [], 2)
56
+ remaining = [fd for fd in servers if fd not in stopped]
57
+ for fd in remaining:
58
+ try: signal.pidfd_send_signal(fd, signal.SIGKILL)
59
+ except ProcessLookupError: pass
60
+ if remaining and len(select.select(remaining, [], [], 2)[0]) != len(remaining):
61
+ raise RuntimeError('T3 server did not stop')
62
+ finally:
63
+ for fd in servers: os.close(fd)
64
+ launch = home / '.t3/ssh-launch'
65
+ if launch.is_symlink(): launch.unlink()
66
+ elif launch.exists(): shutil.rmtree(launch)
67
+ (state / 'server-runtime.json').unlink(missing_ok=True)
68
+ state.mkdir(parents=True, exist_ok=True)
69
+ os.chown(home / '.t3', user.pw_uid, user.pw_gid)
70
+ os.chown(state, user.pw_uid, user.pw_gid)
71
+ fd, tmp = tempfile.mkstemp(prefix='.environment-id-', dir=state)
72
+ try:
73
+ with os.fdopen(fd, 'w') as file:
74
+ file.write(identity + '\\n')
75
+ os.fchown(file.fileno(), user.pw_uid, user.pw_gid)
76
+ os.replace(tmp, target)
77
+ recovery = state / 'environment-id.recovery'
78
+ recovery.unlink(missing_ok=True)
79
+ finally:
80
+ if os.path.exists(tmp): os.unlink(tmp)
81
+ `;
82
+ export async function prepareAgentMachine(ctx, ws) {
83
+ if (ws.labels.shard_layer === 'machine')
84
+ return;
85
+ const identity = ws.labels?.t3_environment_id ?? '';
86
+ if (identity && !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(identity))
87
+ throw new Error('invalid fork T3 environment identity');
88
+ const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
89
+ const label = ws.labels.shard_router === 'project' ? `Shardflux · ${ws.key.split('/')[0]}` : agentMachineName(ws.key);
90
+ const result = await cell.exec.run(['python3', '-c', AGENT_MACHINE_SCRIPT, label, identity], { user: 'root', timeoutMs: 10_000, maxOutputBytes: 4096, signal: ctx.signal });
91
+ if (result.exitCode !== 0)
92
+ throw new Error(`could not prepare ${ws.key} machine identity: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
93
+ }
@@ -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;
@@ -24,7 +24,7 @@ export function needsGitSeed(ctx, ws) {
24
24
  export async function seedAgentRepository(ctx, ws, folder, options = {}) {
25
25
  const project = projectIdentity(folder);
26
26
  // A cloud-only `like` launch must never seed an unrelated laptop repository.
27
- if (ws.labels.repo_remote && ws.labels.repo_remote !== project.remote)
27
+ if (ws.labels.repo_remote && ws.labels.repo_remote !== (project.remote ?? `file://${project.folder}`))
28
28
  return;
29
29
  const root = options.root ?? workspaceRepositoryPath(ws);
30
30
  const retry = needsGitSeed(ctx, ws);
@@ -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,7 +131,7 @@ 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)
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  type State = Record<string, unknown> & {
2
2
  projects?: Record<string, {
3
3
  app?: string;
4
+ target?: string;
4
5
  }>;
5
6
  };
6
7
  /** Keep other lanes' sync manifests and project settings when remembering the app choice. */
@@ -10,6 +11,8 @@ export declare class AgentStateStore {
10
11
  constructor(dir: string);
11
12
  read(): State;
12
13
  app(project: string, fallback?: 'claude' | 'codex'): 'claude' | 'codex';
14
+ target(project: string, available: string[]): string;
15
+ saveTarget(project: string, target: string): void;
13
16
  update(change: (state: State) => void): void;
14
17
  save(project: string, app: 'claude' | 'codex'): void;
15
18
  }
@@ -24,25 +24,46 @@ export class AgentStateStore {
24
24
  const app = this.read().projects?.[project]?.app;
25
25
  return app === 'claude' || app === 'codex' ? app : fallback;
26
26
  }
27
+ target(project, available) {
28
+ const target = this.read().projects?.[project]?.target;
29
+ return target && available.includes(target) ? target : available.includes('t3') ? 't3' : 'terminal';
30
+ }
31
+ saveTarget(project, target) {
32
+ this.update(doc => {
33
+ doc.projects ??= {};
34
+ doc.projects[project] = { ...doc.projects[project], target };
35
+ });
36
+ }
27
37
  update(change) {
28
38
  ensurePrivateDir(this.dir);
29
39
  const lock = `${this.file}.lock`;
30
- try {
31
- writeFileSync(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
32
- }
33
- catch (error) {
34
- if (error.code !== 'EEXIST')
35
- throw error;
40
+ const deadline = performance.now() + 2000;
41
+ const wait = new Int32Array(new SharedArrayBuffer(4));
42
+ for (;;) {
36
43
  try {
37
- process.kill(Number(readFileSync(lock, 'utf8')), 0);
44
+ writeFileSync(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
45
+ break;
38
46
  }
39
- catch (probe) {
40
- if (probe.code === 'ESRCH') {
41
- rmSync(lock);
42
- return this.update(change);
47
+ catch (error) {
48
+ if (error.code !== 'EEXIST')
49
+ throw error;
50
+ try {
51
+ process.kill(Number(readFileSync(lock, 'utf8')), 0);
52
+ }
53
+ catch (probe) {
54
+ if (probe.code === 'ENOENT')
55
+ continue;
56
+ if (probe.code === 'ESRCH') {
57
+ rmSync(lock, { force: true });
58
+ continue;
59
+ }
43
60
  }
61
+ if (performance.now() >= deadline)
62
+ throw new UsageError('another shard process is updating agent state; run the command again');
63
+ // Updates are synchronous and short. The competing writer is another
64
+ // process, so it can finish while this process waits without spinning.
65
+ Atomics.wait(wait, 0, 0, Math.min(10, deadline - performance.now()));
44
66
  }
45
- throw new UsageError('another shard process is updating agent state; run the command again');
46
67
  }
47
68
  try {
48
69
  const state = this.read();
@@ -2,9 +2,12 @@ import { Duplex } from 'node:stream';
2
2
  import type { CellClient, Workspace } from '@shardflux/sdk';
3
3
  import type { Ctx, Handler, Io } from './commands.js';
4
4
  import { AgentTargets } from './agent-targets.js';
5
+ import type { DesktopTarget } from './agent-targets.js';
5
6
  /** Optimistic file replacement preserves keys installed by other devices connecting at the same time. */
6
7
  export declare function installDeviceKey(cell: CellClient, key: string, signal?: AbortSignal): Promise<void>;
7
8
  /** Host trust is established over the authenticated files API, before sending any SSH protocol bytes. */
8
9
  export declare function prepareSsh(ws: Workspace, ctx: Ctx, targets: AgentTargets, host: string): Promise<CellClient>;
9
- export declare function proxyStream(io: Io): Duplex;
10
+ export declare function proxyStream(io: Io, ended?: () => void, outputGate?: Promise<unknown>): Duplex;
11
+ export declare function openDesktopWorkspace(ctx: Ctx, ws: Workspace, target: DesktopTarget, t3Version?: string, prewarmed?: boolean): Promise<number>;
12
+ export declare function queueT3Prewarm(ctx: Ctx, ws: Workspace, version: string): Promise<void>;
10
13
  export declare const TARGET_HANDLERS: Record<string, Handler>;