@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.
- package/CHANGELOG.md +33 -0
- package/README.md +40 -4
- package/dist/acp-catalog.d.ts +152 -0
- package/dist/acp-catalog.js +287 -0
- package/dist/acp-journal.d.ts +18 -0
- package/dist/acp-journal.js +232 -0
- package/dist/acp-rpc.d.ts +41 -0
- package/dist/acp-rpc.js +115 -0
- package/dist/acp.d.ts +27 -0
- package/dist/acp.js +290 -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.js +1 -1
- 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 +64 -22
- 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 +89 -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 +118 -0
- package/dist/device-bridge.d.ts +80 -0
- package/dist/device-bridge.js +398 -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/handlers.js +3 -1
- package/dist/main.js +15 -3
- package/dist/menubar.d.ts +5 -0
- package/dist/menubar.js +22 -1
- 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/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
|
+
}
|
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
|
+
}
|
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);
|
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();
|