@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.
- package/CHANGELOG.md +42 -0
- package/README.md +44 -4
- package/dist/acp-catalog.d.ts +7 -0
- package/dist/acp-catalog.js +40 -0
- package/dist/acp-journal.d.ts +18 -0
- package/dist/acp-journal.js +232 -0
- package/dist/acp-models.d.ts +36 -0
- package/dist/acp-models.js +161 -0
- package/dist/acp-rpc.d.ts +41 -0
- package/dist/acp-rpc.js +115 -0
- package/dist/acp.d.ts +29 -0
- package/dist/acp.js +464 -0
- package/dist/agent-commands.d.ts +10 -2
- package/dist/agent-commands.js +37 -18
- package/dist/agent-machine.d.ts +6 -0
- package/dist/agent-machine.js +93 -0
- package/dist/agent-repository.d.ts +2 -0
- package/dist/agent-repository.js +42 -31
- package/dist/agent-state.d.ts +3 -0
- package/dist/agent-state.js +33 -12
- package/dist/agent-target-commands.d.ts +4 -1
- package/dist/agent-target-commands.js +110 -40
- package/dist/agent-targets.d.ts +6 -4
- package/dist/agent-targets.js +72 -31
- package/dist/args.d.ts +1 -1
- package/dist/args.js +53 -14
- package/dist/command-center-actions.js +26 -3
- package/dist/command-center.js +20 -4
- package/dist/conversation-commands.d.ts +3 -0
- package/dist/conversation-commands.js +36 -0
- package/dist/conversation-local-resume.d.ts +8 -0
- package/dist/conversation-local-resume.js +56 -0
- package/dist/conversation-local.d.ts +7 -0
- package/dist/conversation-local.js +61 -0
- package/dist/conversation-router.d.ts +12 -0
- package/dist/conversation-router.js +92 -0
- package/dist/conversation-stash.d.ts +11 -0
- package/dist/conversation-stash.js +158 -0
- package/dist/conversation-terminal.d.ts +3 -0
- package/dist/conversation-terminal.js +74 -0
- package/dist/conversation-worktree.d.ts +4 -0
- package/dist/conversation-worktree.js +22 -0
- package/dist/conversations.d.ts +53 -0
- package/dist/conversations.js +119 -0
- package/dist/device-bridge.d.ts +80 -0
- package/dist/device-bridge.js +409 -0
- package/dist/device-helper.d.ts +3 -0
- package/dist/device-helper.js +104 -0
- package/dist/errors.js +5 -0
- package/dist/format.js +3 -2
- 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/handlers.js +3 -1
- package/dist/main.js +15 -3
- package/dist/project-sync.d.ts +5 -1
- package/dist/project-sync.js +7 -6
- package/dist/t3-provider.d.ts +6 -0
- package/dist/t3-provider.js +99 -0
- package/dist/t3-runtime.d.ts +6 -0
- package/dist/t3-runtime.js +63 -0
- package/dist/target-lock.d.ts +3 -0
- package/dist/target-lock.js +143 -0
- package/package.json +3 -3
package/dist/agent-commands.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/agent-commands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
return
|
|
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;
|
package/dist/agent-repository.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/agent-state.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/agent-state.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
44
|
+
writeFileSync(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
45
|
+
break;
|
|
38
46
|
}
|
|
39
|
-
catch (
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
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>;
|