@shardflux/cli 0.13.5 → 0.14.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +40 -4
  3. package/dist/acp-catalog.d.ts +152 -0
  4. package/dist/acp-catalog.js +287 -0
  5. package/dist/acp-journal.d.ts +18 -0
  6. package/dist/acp-journal.js +232 -0
  7. package/dist/acp-rpc.d.ts +41 -0
  8. package/dist/acp-rpc.js +115 -0
  9. package/dist/acp.d.ts +27 -0
  10. package/dist/acp.js +290 -0
  11. package/dist/agent-commands.d.ts +10 -2
  12. package/dist/agent-commands.js +37 -18
  13. package/dist/agent-machine.d.ts +6 -0
  14. package/dist/agent-machine.js +93 -0
  15. package/dist/agent-repository.js +1 -1
  16. package/dist/agent-state.d.ts +3 -0
  17. package/dist/agent-state.js +33 -12
  18. package/dist/agent-target-commands.d.ts +4 -1
  19. package/dist/agent-target-commands.js +64 -22
  20. package/dist/agent-targets.d.ts +6 -4
  21. package/dist/agent-targets.js +72 -31
  22. package/dist/args.d.ts +1 -1
  23. package/dist/args.js +53 -14
  24. package/dist/command-center-actions.js +26 -3
  25. package/dist/command-center.js +20 -4
  26. package/dist/conversation-commands.d.ts +3 -0
  27. package/dist/conversation-commands.js +36 -0
  28. package/dist/conversation-local-resume.d.ts +8 -0
  29. package/dist/conversation-local-resume.js +56 -0
  30. package/dist/conversation-local.d.ts +7 -0
  31. package/dist/conversation-local.js +61 -0
  32. package/dist/conversation-router.d.ts +12 -0
  33. package/dist/conversation-router.js +89 -0
  34. package/dist/conversation-stash.d.ts +11 -0
  35. package/dist/conversation-stash.js +158 -0
  36. package/dist/conversation-terminal.d.ts +3 -0
  37. package/dist/conversation-terminal.js +74 -0
  38. package/dist/conversation-worktree.d.ts +4 -0
  39. package/dist/conversation-worktree.js +22 -0
  40. package/dist/conversations.d.ts +53 -0
  41. package/dist/conversations.js +118 -0
  42. package/dist/device-bridge.d.ts +80 -0
  43. package/dist/device-bridge.js +398 -0
  44. package/dist/device-helper.d.ts +3 -0
  45. package/dist/device-helper.js +104 -0
  46. package/dist/errors.js +5 -0
  47. package/dist/format.js +3 -2
  48. package/dist/handlers.js +3 -1
  49. package/dist/main.js +15 -3
  50. package/dist/menubar.d.ts +5 -0
  51. package/dist/menubar.js +22 -1
  52. package/dist/project-sync.d.ts +5 -1
  53. package/dist/project-sync.js +7 -6
  54. package/dist/t3-provider.d.ts +6 -0
  55. package/dist/t3-provider.js +99 -0
  56. package/dist/t3-runtime.d.ts +6 -0
  57. package/dist/t3-runtime.js +63 -0
  58. package/dist/target-lock.d.ts +3 -0
  59. package/dist/target-lock.js +143 -0
  60. package/package.json +3 -3
package/dist/acp.js ADDED
@@ -0,0 +1,290 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { isAbsolute } from 'node:path';
3
+ import { CLI_VERSION } from "./args.js";
4
+ import { AgentStateStore } from "./agent-state.js";
5
+ import { AcpRpc, RpcError, objectValue } from "./acp-rpc.js";
6
+ import { ACP_CATALOG } from "./acp-catalog.js";
7
+ import { AcpJournal } from "./acp-journal.js";
8
+ import { createConversation, getConversation, conversationWorkspace, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
9
+ import { projectIdentity } from "./project-identity.js";
10
+ import { describeError, ownHint } from "./errors.js";
11
+ import { serveAcpDeviceApprovals } from "./device-bridge.js";
12
+ import { syncAgentCredentials } from "./agent-credentials.js";
13
+ import { cloudClaudeLogin } from "./claude-cloud-login.js";
14
+ import { syncAgentConfig } from "./agent-config-sync.js";
15
+ import { savedMachine } from "./filesystem-state.js";
16
+ import { ensureMenubar, MenubarManager } from "./menubar.js";
17
+ const PREFIX = 'shardflux:';
18
+ export function acpModel(app, id) { return `${PREFIX}${app}:${id}`; }
19
+ export function parseAcpModel(value) {
20
+ if (typeof value !== 'string')
21
+ throw new RpcError(-32602, 'Choose a model from Shardflux.');
22
+ const match = /^shardflux:(claude|codex):(.+)$/.exec(value);
23
+ if (!match)
24
+ throw new RpcError(-32602, 'Choose a model from Shardflux.');
25
+ return { app: match[1], id: match[2] };
26
+ }
27
+ export function acpModelOptions(catalog) {
28
+ return ['claude', 'codex'].flatMap(app => {
29
+ const source = catalog[app].configOptions.find(c => c.category === 'model');
30
+ return (source?.options ?? []).flatMap(option => Array.isArray(option.options) ? option.options : [option]).map((option) => {
31
+ if (typeof option.value !== 'string' || !option.value || typeof option.name !== 'string' || !option.name)
32
+ throw new Error('Agent model metadata omitted its ID or name');
33
+ return { ...option, value: acpModel(app, option.value), name: `${app === 'claude' ? 'Claude Code' : 'Codex'} · ${option.name}` };
34
+ });
35
+ });
36
+ }
37
+ export function acpError(error) {
38
+ if (error instanceof RpcError) {
39
+ if (error.code === -32000 && /authentication required|oauth.*(?:revoked|expired)|not (?:signed|logged) in/i.test(error.message))
40
+ return new RpcError(-32000, `${error.message} Run shard setup.`, { code: 'unauthenticated', fix: 'Run shard setup.' });
41
+ return error;
42
+ }
43
+ const info = describeError(error);
44
+ const fix = info.code === 'unauthenticated' ? 'Run shard setup.' : info.code === 'not_found' || info.details?.reason === 'workspace_deleted' ? 'Open a new conversation.' : ownHint(error) ?? (info.code === 'quota_exceeded' || info.code === 'allowance_exhausted' ? 'Run shard billing plans.' : 'Try the message again.');
45
+ return new RpcError(-32000, `${info.message} ${fix}`, { code: info.code, fix, ...(info.details?.reason ? { reason: info.details.reason } : {}) });
46
+ }
47
+ /** Rewrite structural session/file references, never the agent's prose or command output. */
48
+ export function acpReferences(value, record, root, inbound = false) {
49
+ if (Array.isArray(value))
50
+ return value.map(v => acpReferences(v, record, root, inbound));
51
+ if (!value || typeof value !== 'object')
52
+ return value;
53
+ return Object.fromEntries(Object.entries(value).map(([key, leaf]) => {
54
+ if (key === 'sessionId' && leaf === (inbound ? record.id : record.nativeId))
55
+ return [key, inbound ? record.nativeId : record.id];
56
+ if (typeof leaf === 'string' && ['path', 'cwd', 'uri'].includes(key)) {
57
+ const from = inbound ? record.folder : root, to = inbound ? root : record.folder;
58
+ if (leaf === from || leaf.startsWith(from + '/'))
59
+ return [key, to + leaf.slice(from.length)];
60
+ if (leaf.startsWith(`file://${from}/`))
61
+ return [key, `file://${to}` + leaf.slice(`file://${from}`.length)];
62
+ }
63
+ return [key, acpReferences(leaf, record, root, inbound)];
64
+ }));
65
+ }
66
+ /** One provider, two harness catalogs. The model selection determines the VM's native harness. */
67
+ export class ShardfluxAcp {
68
+ #ctx;
69
+ #client;
70
+ #store;
71
+ #errors;
72
+ #sessions = new Map();
73
+ #catalog = { claude: structuredClone(ACP_CATALOG.claude.session), codex: structuredClone(ACP_CATALOG.codex.session) };
74
+ #initialized = false;
75
+ #probe = false;
76
+ constructor(ctx, client, errors = acpError) { this.#ctx = ctx; this.#client = client; this.#store = new AgentStateStore(ctx.store.dir); this.#errors = errors; }
77
+ #records() { return this.#store.read().acp_sessions ?? {}; }
78
+ #save(record) { this.#store.update(doc => { doc.acp_sessions = { ...objectValue(doc.acp_sessions), [record.id]: record }; }); }
79
+ #configuration(model, native = {}) {
80
+ const options = acpModelOptions(this.#catalog);
81
+ return { ...native, models: { currentModelId: model, availableModels: options.map(o => ({ modelId: o.value, name: o.name, ...(o.description ? { description: o.description } : {}) })) },
82
+ configOptions: [{ id: 'model', name: 'Model', category: 'model', type: 'select', currentValue: model, options }, ...(native.configOptions ?? []).filter(c => c.category !== 'model')] };
83
+ }
84
+ async handle(method, params, request) {
85
+ try {
86
+ return await this.#handle(method, params, request);
87
+ }
88
+ catch (error) {
89
+ throw this.#errors(error);
90
+ }
91
+ }
92
+ async #handle(method, params, request) {
93
+ if (method === 'initialize') {
94
+ if (this.#initialized)
95
+ throw new RpcError(-32600, 'Already initialized');
96
+ if (params.protocolVersion !== 1 && params.protocolVersion !== 2)
97
+ throw new RpcError(-32602, 'Unsupported protocol version');
98
+ this.#initialized = true;
99
+ this.#probe = objectValue(params.clientInfo).name === 't3-code-provider-test';
100
+ return { protocolVersion: 1, agentInfo: { name: 'shardflux', title: 'Shardflux', version: CLI_VERSION }, authMethods: [],
101
+ agentCapabilities: { loadSession: true, promptCapabilities: { image: true, embeddedContext: true }, sessionCapabilities: { list: {}, resume: {}, close: {} } } };
102
+ }
103
+ if (!this.#initialized)
104
+ throw new RpcError(-32600, 'Initialize first');
105
+ if (method === 'session/list') {
106
+ const sessions = Object.values(this.#records()).filter(r => r.apiUrl === this.#ctx.apiUrl && (!params.cwd || r.folder === params.cwd));
107
+ return { sessions: sessions.map(r => ({ sessionId: r.id, cwd: r.folder, updatedAt: r.updatedAt })) };
108
+ }
109
+ if (method === 'session/new') {
110
+ if (this.#probe)
111
+ return this.#configuration(acpModel('claude', 'default'), { sessionId: 'shardflux-probe' });
112
+ if (typeof params.cwd !== 'string' || !isAbsolute(params.cwd))
113
+ throw new RpcError(-32602, 'Open a project folder first.');
114
+ const folder = projectIdentity(params.cwd).folder;
115
+ const record = { id: `sf-${randomUUID()}`, apiUrl: this.#ctx.apiUrl, folder, model: acpModel('claude', 'default'), from_seq: 0, updatedAt: new Date().toISOString() };
116
+ // T3 selects its model after session/new. Reserve identity now; allocate on the selection/first prompt.
117
+ this.#save(record);
118
+ this.#sessions.set(record.id, { record, stop: new AbortController() });
119
+ return this.#configuration(record.model, { sessionId: record.id });
120
+ }
121
+ if (this.#probe && method === 'session/close')
122
+ return {};
123
+ if (typeof params.sessionId !== 'string')
124
+ throw new RpcError(-32602, 'A conversation is required');
125
+ const session = this.#session(params.sessionId, params.cwd);
126
+ const { record } = session;
127
+ if (method === 'session/close') {
128
+ await this.#close(session);
129
+ return {};
130
+ }
131
+ if (method === 'session/set_model' || (method === 'session/set_config_option' && params.configId === 'model')) {
132
+ const model = String(method === 'session/set_model' ? params.modelId : params.value), selected = parseAcpModel(model);
133
+ if (session.conversation && session.conversation.harness !== selected.app)
134
+ throw new RpcError(-32602, 'Choose a new conversation to switch between Claude Code and Codex.');
135
+ record.model = model;
136
+ this.#save(record);
137
+ await this.#connect(session);
138
+ const result = await session.transport.rpc.request('session/set_config_option', { sessionId: record.nativeId, configId: 'model', value: selected.id });
139
+ return method === 'session/set_model' ? {} : this.#configuration(model, objectValue(result));
140
+ }
141
+ await this.#connect(session);
142
+ if (method === 'session/load' || method === 'session/resume')
143
+ return this.#configuration(record.model, { ...record.setup, sessionId: record.id });
144
+ const mapped = acpReferences(params, record, session.conversation.worktree_path, true);
145
+ if (method === 'session/prompt') {
146
+ record.prompted = true;
147
+ record.updatedAt = new Date().toISOString();
148
+ this.#save(record);
149
+ }
150
+ if (!request)
151
+ return session.transport.rpc.notify(method, mapped);
152
+ const result = objectValue(await session.transport.rpc.request(method, mapped, method === 'session/prompt' ? 0 : 120_000));
153
+ return this.#configuration(record.model, acpReferences(result, record, session.conversation.worktree_path));
154
+ }
155
+ #session(id, cwd) {
156
+ const record = this.#records()[id];
157
+ if (!record || record.apiUrl !== this.#ctx.apiUrl || (cwd !== undefined && (typeof cwd !== 'string' || projectIdentity(cwd).folder !== record.folder)))
158
+ throw new RpcError(-32000, 'Conversation not found.');
159
+ let session = this.#sessions.get(id);
160
+ if (!session) {
161
+ session = { record, stop: new AbortController() };
162
+ this.#sessions.set(id, session);
163
+ }
164
+ return session;
165
+ }
166
+ async #connect(session) {
167
+ if (session.connecting)
168
+ return session.connecting;
169
+ if (session.transport && !session.transport.rpc.closed)
170
+ return;
171
+ session.connecting = this.#start(session).finally(() => { session.connecting = undefined; });
172
+ return session.connecting;
173
+ }
174
+ async #start(session) {
175
+ const ctx = this.#ctx, { record } = session, { app } = parseAcpModel(record.model);
176
+ await session.transport?.close();
177
+ await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
178
+ if (app === 'claude' && !(await cloudClaudeLogin(ctx)))
179
+ throw new RpcError(-32000, 'Sign in to Claude Code with shard agents credentials login claude.', { code: 'unauthenticated', fix: 'Run shard agents credentials login claude.' });
180
+ if (!record.conversationId) {
181
+ const created = await createConversation(ctx, record.folder, app, 'acp');
182
+ session.conversation = created.conversation;
183
+ session.ws = created.workspace;
184
+ record.conversationId = created.conversation.id;
185
+ this.#save(record);
186
+ if (!(await savedMachine(ctx))?.template)
187
+ await syncAgentConfig(ctx, created.workspace);
188
+ }
189
+ else {
190
+ const current = await getConversation(ctx, record.conversationId);
191
+ session.conversation = await resumeConversation(ctx, current);
192
+ session.ws = await conversationWorkspace(ctx, session.conversation);
193
+ rememberConversation(ctx, session.conversation, record.folder);
194
+ }
195
+ const c = session.conversation, cell = session.ws.cell();
196
+ await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
197
+ await restoreConversationBinding(ctx, c, session.ws);
198
+ session.approvals ??= await serveAcpDeviceApprovals(ctx.store.dir, c.id, async (approval) => {
199
+ const result = objectValue(await this.#client.request('session/request_permission', { sessionId: record.id,
200
+ toolCall: { toolCallId: approval.request_id, title: approval.summary, kind: 'execute', status: 'pending' },
201
+ options: [{ optionId: 'once', name: 'Allow once', kind: 'allow_once' }, ...(!approval.outside ? [{ optionId: 'always', name: 'Always for this project', kind: 'allow_always' }] : []), { optionId: 'deny', name: 'Deny', kind: 'reject_once' }] }, 0, AbortSignal.any([ctx.signal, session.stop.signal])));
202
+ const outcome = objectValue(result.outcome);
203
+ return outcome.outcome === 'selected' && (outcome.optionId === 'once' || outcome.optionId === 'always') ? outcome.optionId : 'deny';
204
+ });
205
+ const launched = await cell.exec.run(['sf-agent', 'acp', app, '--conversation', c.id, '--detach'], { cwd: c.worktree_path, timeoutMs: 30_000, signal: ctx.signal, maxOutputBytes: 1 << 20 });
206
+ if (launched.exitCode !== 0)
207
+ throw new Error(`ACP supervisor could not attach: ${launched.stderr.trim()}`);
208
+ const status = JSON.parse(await cell.files.readText(`/home/user/.local/share/shardflux/acp/${c.id}/status.json`, { length: 8192 }));
209
+ if (status.conversation_id !== c.id || status.harness !== app || !Number.isSafeInteger(status.pid) || status.pid <= 0 || !status.start || status.ended)
210
+ throw new Error('ACP supervisor identity does not match this conversation.');
211
+ const owner = `${session.ws.id}:${status.pid}:${status.start}`;
212
+ const initialize = !record.nativeId || record.owner !== owner;
213
+ if (record.workspaceId !== session.ws.id)
214
+ record.from_seq = 0;
215
+ record.workspaceId = session.ws.id;
216
+ const transport = new AcpJournal(cell, c.id, app, c.worktree_path, record, () => this.#save(record), ctx.signal);
217
+ transport.rpc.handler = async (method, params, request) => {
218
+ if (method.startsWith('fs/') || method.startsWith('terminal/'))
219
+ throw new RpcError(-32601, 'Files and commands run in your cloud workspace.');
220
+ const mapped = acpReferences(params, record, c.worktree_path);
221
+ if (method === 'session/update') {
222
+ const update = objectValue(params.update);
223
+ if (update.sessionUpdate === 'config_option_update' && Array.isArray(update.configOptions)) {
224
+ this.#catalog[app].configOptions = update.configOptions;
225
+ mapped.update = { ...update, ...this.#configuration(record.model, { configOptions: update.configOptions }) };
226
+ }
227
+ }
228
+ if (request) {
229
+ if (method === 'session/request_permission')
230
+ transport.pause();
231
+ try {
232
+ return acpReferences(await this.#client.request(method, mapped, 0, AbortSignal.any([ctx.signal, session.stop.signal])), record, c.worktree_path, true);
233
+ }
234
+ finally {
235
+ if (method === 'session/request_permission')
236
+ transport.resume();
237
+ }
238
+ }
239
+ await this.#client.notify(method, mapped);
240
+ };
241
+ session.transport = transport;
242
+ await transport.start();
243
+ if (initialize) {
244
+ const initialized = objectValue(await transport.rpc.request('initialize', { protocolVersion: 1, clientInfo: { name: 'shardflux', version: CLI_VERSION }, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } }));
245
+ if (initialized.protocolVersion !== 1)
246
+ throw new Error('Agent protocol version changed');
247
+ const nativeId = c.harness_session_id ?? record.nativeId;
248
+ const result = objectValue(await transport.rpc.request(nativeId ? 'session/load' : 'session/new', { cwd: c.worktree_path, mcpServers: [], ...(nativeId ? { sessionId: nativeId } : {}) }));
249
+ const id = result.sessionId ?? nativeId;
250
+ if (typeof id !== 'string' || !id)
251
+ throw new Error('Agent returned no native session id');
252
+ record.nativeId = id;
253
+ record.setup = result;
254
+ record.owner = owner;
255
+ if (Array.isArray(result.configOptions))
256
+ this.#catalog[app].configOptions = result.configOptions;
257
+ this.#save(record);
258
+ }
259
+ }
260
+ async #close(session) {
261
+ this.#sessions.delete(session.record.id);
262
+ session.stop.abort();
263
+ await session.transport?.close();
264
+ session.ws?.cell().close();
265
+ await session.approvals?.stop();
266
+ session.approvals = undefined;
267
+ }
268
+ async close() { await Promise.all([...this.#sessions.values()].map(s => this.#close(s))); }
269
+ }
270
+ export async function runAcp(ctx, errors) {
271
+ const controller = new AbortController();
272
+ ctx = { ...ctx, signal: AbortSignal.any([ctx.signal, controller.signal]) };
273
+ const rpc = new AcpRpc(message => { ctx.io.stdout.write(JSON.stringify(message) + '\n'); return Promise.resolve(); });
274
+ const server = new ShardfluxAcp(ctx, rpc, errors);
275
+ rpc.handler = (method, params, request) => server.handle(method, params, request);
276
+ try {
277
+ for await (const chunk of ctx.io.stdin) {
278
+ ctx.signal.throwIfAborted();
279
+ rpc.feed(Buffer.from(chunk));
280
+ }
281
+ }
282
+ finally {
283
+ controller.abort();
284
+ rpc.close();
285
+ await server.close();
286
+ await rpc.drain();
287
+ await server.close();
288
+ }
289
+ return 0;
290
+ }
@@ -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
+ }
@@ -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);
@@ -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();