@shardflux/cli 0.14.5 → 0.15.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 +61 -0
- package/README.md +72 -10
- package/dist/account-args.js +28 -7
- package/dist/account-commands.js +140 -11
- package/dist/acp-catalog.js +2 -2
- package/dist/acp-login.d.ts +20 -0
- package/dist/acp-login.js +122 -0
- package/dist/acp.js +36 -13
- package/dist/agent-commands.js +55 -4
- package/dist/agent-git-commands.js +2 -1
- package/dist/agent-reachability.js +1 -1
- package/dist/agent-target-commands.js +26 -1
- package/dist/agent-targets.d.ts +7 -0
- package/dist/agent-targets.js +44 -32
- package/dist/args.d.ts +3 -1
- package/dist/args.js +21 -9
- package/dist/browser-login.d.ts +35 -0
- package/dist/browser-login.js +145 -0
- package/dist/codex-cloud.d.ts +49 -0
- package/dist/codex-cloud.js +157 -0
- package/dist/command-center-actions.js +7 -0
- package/dist/command-center-renderer.d.ts +3 -0
- package/dist/command-center-renderer.js +42 -38
- package/dist/command-center.js +65 -11
- package/dist/conversation-terminal.js +19 -0
- package/dist/conversations.d.ts +1 -1
- package/dist/conversations.js +21 -8
- package/dist/credential-prompts.d.ts +4 -2
- package/dist/credential-prompts.js +38 -16
- package/dist/device-bridge.d.ts +13 -0
- package/dist/device-bridge.js +14 -4
- package/dist/device-helper.js +2 -1
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +15 -2
- package/dist/handlers.js +2 -1
- package/dist/initial-prompt.d.ts +26 -0
- package/dist/initial-prompt.js +196 -0
- package/dist/legal.d.ts +36 -0
- package/dist/legal.js +44 -0
- package/dist/main.js +6 -2
- package/dist/menubar.d.ts +8 -0
- package/dist/menubar.js +9 -1
- package/dist/onboarding-app/AGENTS.md +19 -0
- package/dist/onboarding-app/CLAUDE.md +19 -0
- package/dist/onboarding-app/README.md +12 -0
- package/dist/onboarding-app/dev.mjs +18 -0
- package/dist/onboarding-app/index.html +25 -0
- package/dist/onboarding-app/package.json +12 -0
- package/dist/onboarding-app/server.mjs +41 -0
- package/dist/onboarding-sample.d.ts +3 -0
- package/dist/onboarding-sample.js +29 -0
- package/dist/onboarding.d.ts +258 -0
- package/dist/onboarding.js +371 -0
- package/dist/terminal-brand.d.ts +5 -0
- package/dist/terminal-brand.js +18 -0
- package/package.json +3 -3
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { describeError } from "./errors.js";
|
|
6
|
+
import { cloudClaudeLogin, loginClaudeInWorkspace } from "./claude-cloud-login.js";
|
|
7
|
+
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
8
|
+
/**
|
|
9
|
+
* The ACP terminal sign-in method. An editor (T3 Code) runs `shard acp --login` in its own terminal when the user
|
|
10
|
+
* chooses it, then reconnects; ACP appends these args to the configured `shard acp` invocation.
|
|
11
|
+
*/
|
|
12
|
+
export const ACP_LOGIN_METHOD = {
|
|
13
|
+
id: 'shardflux-login',
|
|
14
|
+
name: 'Sign in to Shardflux',
|
|
15
|
+
description: 'Create or sign in to your Shardflux account and connect Claude Code and Codex.',
|
|
16
|
+
type: 'terminal',
|
|
17
|
+
args: ['--login'],
|
|
18
|
+
};
|
|
19
|
+
/** Whether an API key is configured (no request; a revoked key surfaces as auth_required on its first call). */
|
|
20
|
+
export function acpSignedIn(ctx) {
|
|
21
|
+
try {
|
|
22
|
+
ctx.cloud();
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
// Only a missing or malformed key means signed out; anything else surfaces on the call that needs the API.
|
|
27
|
+
return describeError(error).code !== 'unauthenticated';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** The sign-in flow checks the key against the API, so a revoked one is replaced. */
|
|
31
|
+
async function accountReady(ctx) {
|
|
32
|
+
if (!acpSignedIn(ctx))
|
|
33
|
+
return false;
|
|
34
|
+
try {
|
|
35
|
+
await ctx.cloud().agentCredentials.list();
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (describeError(error).code === 'unauthenticated')
|
|
40
|
+
return false;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const say = (ctx, text) => { ctx.io.stderr.write(text); };
|
|
45
|
+
/**
|
|
46
|
+
* `shard acp --login`: everything a first conversation needs, each step skipped when it is already done: the
|
|
47
|
+
* account (signed up or in with this Mac's Codex login, else an email), its API key, the Codex login copy and the
|
|
48
|
+
* cloud Claude sign-in.
|
|
49
|
+
*/
|
|
50
|
+
export async function runAcpLogin(ctx) {
|
|
51
|
+
const { HANDLERS } = await import("./handlers.js");
|
|
52
|
+
const home = ctx.io.env.HOME ?? homedir();
|
|
53
|
+
const codex = existsSync(join(home, '.codex', 'auth.json'));
|
|
54
|
+
// The account commands' own reports (paths, next steps) are for the shard terminal, not an editor's sign-in panel.
|
|
55
|
+
const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
|
|
56
|
+
// An editor's conversations need no Codex Cloud executor; setup here makes the account, project and key only.
|
|
57
|
+
const setup = () => HANDLERS.setup(quiet, { codex: false }, []);
|
|
58
|
+
say(ctx, 'Shardflux sign-in\n\n');
|
|
59
|
+
if (!await accountReady(ctx)) {
|
|
60
|
+
const session = ctx.store.profile(ctx.apiUrl).session;
|
|
61
|
+
if (session?.state === 'active') {
|
|
62
|
+
say(ctx, 'Creating your API key…\n');
|
|
63
|
+
await setup();
|
|
64
|
+
}
|
|
65
|
+
else if (codex) {
|
|
66
|
+
say(ctx, 'Signing in with the Codex login on this Mac…\n');
|
|
67
|
+
await HANDLERS.signup(quiet, { setup: false }, []);
|
|
68
|
+
await setup();
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const email = (await ctx.input.answer('Email: '))?.trim();
|
|
72
|
+
if (!email) {
|
|
73
|
+
say(ctx, 'An email address is needed to create your account.\n');
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
await HANDLERS.signup(quiet, { email, codex: false, setup: false }, []);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
// An existing account signs in with its password instead.
|
|
81
|
+
if (describeError(error).code !== 'conflict')
|
|
82
|
+
throw error;
|
|
83
|
+
await HANDLERS['auth login'](quiet, { email }, []);
|
|
84
|
+
}
|
|
85
|
+
await setup();
|
|
86
|
+
}
|
|
87
|
+
if (!await accountReady(ctx)) {
|
|
88
|
+
say(ctx, '\nThe account is not ready yet. Finish the step above, then choose Sign in again.\n');
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
const user = ctx.store.profile(ctx.apiUrl).session?.user;
|
|
92
|
+
say(ctx, user?.email ? `Signed in as ${user.email}.\n` : 'Signed in.\n');
|
|
93
|
+
}
|
|
94
|
+
else
|
|
95
|
+
say(ctx, 'Already signed in.\n');
|
|
96
|
+
if (codex) {
|
|
97
|
+
await syncAgentCredentials(ctx.cloud(), { home, redact: (value) => ctx.addSecret(value) });
|
|
98
|
+
say(ctx, 'Codex: connected with the login on this Mac.\n');
|
|
99
|
+
}
|
|
100
|
+
if (await cloudClaudeLogin(ctx))
|
|
101
|
+
say(ctx, 'Claude Code: connected.\n');
|
|
102
|
+
else {
|
|
103
|
+
const answer = (await ctx.input.answer('\nConnect Claude Code now? It opens a Claude sign-in page. [Y/n] '))?.trim().toLowerCase() ?? 'n';
|
|
104
|
+
if (answer === '' || answer.startsWith('y')) {
|
|
105
|
+
// A session workspace exists only for this sign-in and is deleted with it.
|
|
106
|
+
const ws = await ctx.cloud().workspaces.open({ key: `shardflux-sign-in/${randomBytes(4).toString('hex')}`, template: 'coding', lifetime: 'session',
|
|
107
|
+
wait: { signal: ctx.signal, timeoutMs: ctx.wakeTimeoutMs } });
|
|
108
|
+
try {
|
|
109
|
+
await loginClaudeInWorkspace(ctx, ws);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await ws.close().catch(() => { });
|
|
113
|
+
}
|
|
114
|
+
say(ctx, 'Claude Code: connected.\n');
|
|
115
|
+
}
|
|
116
|
+
else if (!codex) {
|
|
117
|
+
say(ctx, 'Claude Code: not connected. Choose Sign in again to connect it.\n');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
say(ctx, '\nShardflux is ready. Return to your editor and start a conversation.\n');
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
package/dist/acp.js
CHANGED
|
@@ -12,6 +12,7 @@ import { describeError, ownHint } from "./errors.js";
|
|
|
12
12
|
import { serveAcpDeviceApprovals } from "./device-bridge.js";
|
|
13
13
|
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
14
14
|
import { cloudClaudeLogin } from "./claude-cloud-login.js";
|
|
15
|
+
import { ACP_LOGIN_METHOD, acpSignedIn } from "./acp-login.js";
|
|
15
16
|
import { ensureMenubar, MenubarManager } from "./menubar.js";
|
|
16
17
|
const PREFIX = 'shardflux:';
|
|
17
18
|
export function acpModel(app, id) { return `${PREFIX}${app}:${id}`; }
|
|
@@ -23,21 +24,30 @@ export function parseAcpModel(value) {
|
|
|
23
24
|
throw new RpcError(-32602, 'Choose a model from Shardflux.');
|
|
24
25
|
return { app: match[1], id: match[2] };
|
|
25
26
|
}
|
|
27
|
+
/** An editor's first choice comes from the probe (`<app>:default`); every conversation keeps it valid as the app's own default model. */
|
|
28
|
+
const DEFAULT_MODEL = 'default';
|
|
29
|
+
const hasNativeDefault = (native) => nativeModelOptions(native).some(o => o.value === DEFAULT_MODEL);
|
|
26
30
|
export function acpModelOptions(catalog) {
|
|
27
31
|
return ['claude', 'codex'].flatMap(app => {
|
|
28
|
-
|
|
32
|
+
const native = nativeModelOptions(catalog[app] ?? {});
|
|
33
|
+
if (!native.length)
|
|
34
|
+
return [];
|
|
35
|
+
const options = hasNativeDefault(catalog[app] ?? {}) ? native : [{ value: DEFAULT_MODEL, name: 'Default' }, ...native];
|
|
36
|
+
return options.map((option) => {
|
|
29
37
|
return { ...option, value: acpModel(app, option.value), name: `${app === 'claude' ? 'Claude Code' : 'Codex'} · ${option.name}` };
|
|
30
38
|
});
|
|
31
39
|
});
|
|
32
40
|
}
|
|
41
|
+
const SIGN_IN = 'Sign in to Shardflux from your editor (or run shard acp --login).';
|
|
33
42
|
export function acpError(error) {
|
|
34
43
|
if (error instanceof RpcError) {
|
|
35
44
|
if (error.code === -32000 && /authentication required|oauth.*(?:revoked|expired)|not (?:signed|logged) in/i.test(error.message))
|
|
36
|
-
return new RpcError(-32000, `${error.message}
|
|
45
|
+
return new RpcError(-32000, `${error.message.replace(/[.!]?$/, '.')} ${SIGN_IN}`, { code: 'unauthenticated', fix: SIGN_IN });
|
|
37
46
|
return error;
|
|
38
47
|
}
|
|
39
48
|
const info = describeError(error);
|
|
40
|
-
|
|
49
|
+
// ACP `auth_required` (-32000) with an unauthenticated code makes the editor offer the advertised sign-in method.
|
|
50
|
+
const fix = info.code === 'unauthenticated' ? SIGN_IN : 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.');
|
|
41
51
|
return new RpcError(-32000, `${info.message} ${fix}`, { code: info.code, fix, ...(info.details?.reason ? { reason: info.details.reason } : {}) });
|
|
42
52
|
}
|
|
43
53
|
/** Rewrite structural session/file references, never the agent's prose or command output. */
|
|
@@ -94,8 +104,9 @@ export class ShardfluxAcp {
|
|
|
94
104
|
if (params.protocolVersion !== 1 && params.protocolVersion !== 2)
|
|
95
105
|
throw new RpcError(-32602, 'Unsupported protocol version');
|
|
96
106
|
this.#initialized = true;
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
// T3 Code's model probe and its post-sign-in check open a session to test the provider; neither gets a VM.
|
|
108
|
+
this.#probe = ['t3-code-provider-test', 't3-code-provider-auth'].includes(String(objectValue(params.clientInfo).name));
|
|
109
|
+
return { protocolVersion: 1, agentInfo: { name: 'shardflux', title: 'Shardflux', version: CLI_VERSION }, authMethods: [ACP_LOGIN_METHOD],
|
|
99
110
|
agentCapabilities: { loadSession: true, promptCapabilities: { image: true, embeddedContext: true }, sessionCapabilities: { list: {}, resume: {}, close: {} } } };
|
|
100
111
|
}
|
|
101
112
|
if (!this.#initialized)
|
|
@@ -105,6 +116,9 @@ export class ShardfluxAcp {
|
|
|
105
116
|
return { sessions: sessions.map(r => ({ sessionId: r.id, cwd: r.folder, updatedAt: r.updatedAt })) };
|
|
106
117
|
}
|
|
107
118
|
if (method === 'session/new') {
|
|
119
|
+
// Signed out, the editor gets ACP auth_required and offers the sign-in method, even on its model probe.
|
|
120
|
+
if (!acpSignedIn(this.#ctx))
|
|
121
|
+
throw new RpcError(-32000, 'Authentication required', { code: 'unauthenticated', fix: SIGN_IN });
|
|
108
122
|
if (this.#probe)
|
|
109
123
|
return this.#configuration(acpModel('claude', 'default'), { sessionId: 'shardflux-probe' }, ACP_PROBE_CATALOG);
|
|
110
124
|
if (typeof params.cwd !== 'string' || !isAbsolute(params.cwd))
|
|
@@ -163,12 +177,17 @@ export class ShardfluxAcp {
|
|
|
163
177
|
if (!acpModelOptions(record.catalog ?? {}).some(o => o.value === model))
|
|
164
178
|
throw new RpcError(-32602, 'This model is no longer available. Choose a model from this conversation.');
|
|
165
179
|
let result;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
180
|
+
// Our own default keeps the app's current model; there is nothing to tell the native session.
|
|
181
|
+
if (selected.id === DEFAULT_MODEL && !hasNativeDefault(record.catalog?.[selected.app] ?? {}))
|
|
182
|
+
result = objectValue(record.setup);
|
|
183
|
+
else {
|
|
184
|
+
try {
|
|
185
|
+
result = objectValue(await session.transport.rpc.request('session/set_config_option', { sessionId: record.nativeId, configId: 'model', value: selected.id }));
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
await this.#rejectModel(session, selected.id, e);
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
172
191
|
}
|
|
173
192
|
record.model = model;
|
|
174
193
|
this.#save(record);
|
|
@@ -327,7 +346,7 @@ export class ShardfluxAcp {
|
|
|
327
346
|
await session.transport?.close();
|
|
328
347
|
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
|
|
329
348
|
if (app === 'claude' && !(await cloudClaudeLogin(ctx)))
|
|
330
|
-
throw new RpcError(-32000, '
|
|
349
|
+
throw new RpcError(-32000, 'Authentication required: connect Claude Code.', { code: 'unauthenticated', fix: SIGN_IN });
|
|
331
350
|
if (!record.conversationId) {
|
|
332
351
|
this.#prepare(session);
|
|
333
352
|
await session.prepared;
|
|
@@ -366,8 +385,12 @@ export class ShardfluxAcp {
|
|
|
366
385
|
throw new Error('ACP supervisor identity does not match this conversation.');
|
|
367
386
|
const owner = `${session.ws.id}:${status.pid}:${status.start}`;
|
|
368
387
|
const initialize = !record.nativeId || record.owner !== owner;
|
|
369
|
-
|
|
388
|
+
// A cold restore can replace the journal owner without changing the VM id.
|
|
389
|
+
// Its sequence starts at zero; an old cursor would drop initialize/load replies.
|
|
390
|
+
if (record.workspaceId !== session.ws.id || record.owner !== owner) {
|
|
370
391
|
record.from_seq = 0;
|
|
392
|
+
delete record.prompt_id;
|
|
393
|
+
}
|
|
371
394
|
record.workspaceId = session.ws.id;
|
|
372
395
|
const transport = new AcpJournal(cell, c.id, app, c.worktree_path, record, () => this.#save(record), ctx.signal);
|
|
373
396
|
transport.rpc.handler = async (method, params, request) => {
|
package/dist/agent-commands.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { prepareSetupCache } from "./setup-cache.js";
|
|
2
|
+
import { launchCodexCloud, openCodexConversation } from "./codex-cloud.js";
|
|
2
3
|
import { agentReachability } from "./agent-reachability.js";
|
|
3
4
|
import { configureCredentialFill, watchCredentialPrompts } from "./credential-prompts.js";
|
|
4
5
|
import { createHash } from 'node:crypto';
|
|
@@ -22,6 +23,7 @@ import { rememberLaunch } from "./agent-live.js";
|
|
|
22
23
|
import { prepareAgentMachine } from "./agent-machine.js";
|
|
23
24
|
import { installedTargets } from "./agent-targets.js";
|
|
24
25
|
import { openDesktopWorkspace, queueT3Prewarm } from "./agent-target-commands.js";
|
|
26
|
+
import { submitInitialPrompt, prepareInitialCodexProject } from "./initial-prompt.js";
|
|
25
27
|
import { conversationWorkspace, conversationsUnavailable, getConversation, listConversations, resumeConversation } from "./conversations.js";
|
|
26
28
|
import { conversationTerminal } from "./conversation-terminal.js";
|
|
27
29
|
const active = (session) => session.state === 'running' || session.state === 'starting';
|
|
@@ -98,9 +100,16 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
|
|
|
98
100
|
// Laptop-local MCP servers are reachable while a terminal is attached (L6); --json/--detach start no tunnels.
|
|
99
101
|
const tunnels = attach ? await startAgentConfigTunnels(ctx, ws) : null;
|
|
100
102
|
try {
|
|
103
|
+
if (app === 'codex' && typeof values.initialPrompt === 'string') {
|
|
104
|
+
await prepareInitialCodexProject(cell, cwd, `shard-${ws.labels.agent_pty_name || agent}-${app}`);
|
|
105
|
+
}
|
|
101
106
|
const credentialFill = await configureCredentialFill(ctx, ws);
|
|
102
107
|
// A cold launch is remembered before it starts, so the listing can show it before the projection does (agent-live.ts).
|
|
103
|
-
const
|
|
108
|
+
const term = ctx.io.env.TERM && ctx.io.env.TERM !== 'dumb' ? ctx.io.env.TERM : 'xterm-256color';
|
|
109
|
+
const session = await agentPty(cell, app, agent, values.approvals === true, term, 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);
|
|
110
|
+
// A new conversation's guest can start the agent before this command reaches it, so the launch above never ran.
|
|
111
|
+
// Remember it either way: listing and `agents open` then read the guest until the projection lists the session.
|
|
112
|
+
rememberLaunch(ctx, ws.id);
|
|
104
113
|
// The real agent is running before any cache provisioning or mount wait.
|
|
105
114
|
const preparation = prepareSetupCache(ctx, ws).catch((error) => {
|
|
106
115
|
ctx.io.stderr.write(`Account setup cache unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
@@ -112,9 +121,12 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
|
|
|
112
121
|
throw new Error(`project setup could not be queued: ${queued.stderr.trim() || `exit ${queued.exitCode}`}`);
|
|
113
122
|
});
|
|
114
123
|
store.save(project, app);
|
|
124
|
+
if (typeof values.initialPrompt === 'string') {
|
|
125
|
+
await submitInitialPrompt(signal => cellConnector(cell, session.session_id)(0, signal), values.initialPrompt, ws.key, ctx.signal, app, bytes => cell.pty.input(session.session_id, bytes));
|
|
126
|
+
}
|
|
115
127
|
if (!attach) {
|
|
116
128
|
await preparation;
|
|
117
|
-
ctx.io.stdout.write(json({ workspace: ws.data, session }));
|
|
129
|
+
ctx.io.stdout.write(json({ workspace: ws.data, session, ...(typeof values.initialPrompt === 'string' ? { initial_prompt: { submitted: true } } : {}) }));
|
|
118
130
|
return 0;
|
|
119
131
|
}
|
|
120
132
|
ctx.io.stderr.write(`Shardflux · ${ws.key}\nDetach: Ctrl-] then d\n`);
|
|
@@ -167,6 +179,8 @@ export const launchProject = async (ctx, values, [path = './', explicitApp]) =>
|
|
|
167
179
|
if (target && (!['claude-desktop', 'codex-app', 't3'].includes(target) || !detected?.targets.includes(target)))
|
|
168
180
|
throw new UsageError(`${target} is not an installed desktop target`);
|
|
169
181
|
const app = explicitApp ?? store.app(projectKey, like?.labels.app === 'codex' ? 'codex' : 'claude');
|
|
182
|
+
if (target === 'codex-app' || (app === 'codex' && !like && values.in === undefined))
|
|
183
|
+
return launchCodexCloud(ctx, values, path);
|
|
170
184
|
const agent = typeof values.name === 'string' ? values.name : 'main';
|
|
171
185
|
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(agent))
|
|
172
186
|
throw new UsageError('agent name must match ^[a-z0-9][a-z0-9._-]{0,63}$');
|
|
@@ -236,6 +250,35 @@ export const AGENT_HANDLERS = {
|
|
|
236
250
|
async 'agents open'(ctx, values, [agent = 'main']) {
|
|
237
251
|
if (values.app !== undefined && values.app !== 'claude' && values.app !== 'codex')
|
|
238
252
|
throw new UsageError('--app must be claude or codex');
|
|
253
|
+
let resolvedConversation;
|
|
254
|
+
if (/^conv_[a-z0-9]{26}$/.test(agent)) {
|
|
255
|
+
resolvedConversation = await getConversation(ctx, agent);
|
|
256
|
+
if (resolvedConversation.route === 'codex-cloud')
|
|
257
|
+
return openCodexConversation(ctx, resolvedConversation);
|
|
258
|
+
}
|
|
259
|
+
if (values.in === 'auto') {
|
|
260
|
+
// The menu and other launchers use the same CLI-owned target selection. Never put provider URLs in a client.
|
|
261
|
+
const ws = await resolveWorkspace(ctx, agent);
|
|
262
|
+
const project = ws.labels.repo_remote || ws.labels.shard_project || ws.key;
|
|
263
|
+
const store = new AgentStateStore(ctx.store.dir);
|
|
264
|
+
const app = values.app ?? ws.labels.shard_app ?? ws.labels.app ?? store.app(project);
|
|
265
|
+
const detected = installedTargets(ctx.io.env);
|
|
266
|
+
const saved = store.read().projects?.[project]?.target ??
|
|
267
|
+
(project.startsWith('file://') ? store.read().projects?.[new URL(project).pathname]?.target : undefined);
|
|
268
|
+
// Codex's default open implementation owns Cloud/desktop routing. PAS-XB replaces that path; do not pin
|
|
269
|
+
// the menu to the old per-VM SSH target. An explicit saved desktop target still takes precedence.
|
|
270
|
+
if ((!saved || !detected.targets.includes(saved)) && app === 'codex') {
|
|
271
|
+
const next = { ...values };
|
|
272
|
+
delete next.in;
|
|
273
|
+
return AGENT_HANDLERS['agents open'](ctx, next, [ws.key]);
|
|
274
|
+
}
|
|
275
|
+
const target = saved && detected.targets.includes(saved) ? saved : 'web';
|
|
276
|
+
const { HANDLERS } = await import("./handlers.js");
|
|
277
|
+
const handler = HANDLERS[`agents open ${target}`];
|
|
278
|
+
if (!handler)
|
|
279
|
+
throw new UsageError('Choose an installed open target with shard agents targets.');
|
|
280
|
+
return handler(ctx, { ...values, in: target }, [ws.key]);
|
|
281
|
+
}
|
|
239
282
|
if (values.in !== undefined && values.in !== 'terminal') {
|
|
240
283
|
const { HANDLERS } = await import("./handlers.js");
|
|
241
284
|
const target = typeof values.in === 'string' ? HANDLERS[`agents open ${values.in}`] : undefined;
|
|
@@ -247,7 +290,10 @@ export const AGENT_HANDLERS = {
|
|
|
247
290
|
let ws;
|
|
248
291
|
let conversation;
|
|
249
292
|
if (/^conv_[a-z0-9]{26}$/.test(agent)) {
|
|
250
|
-
conversation =
|
|
293
|
+
conversation = resolvedConversation ?? await getConversation(ctx, agent);
|
|
294
|
+
if (conversation.route === 'codex-cloud')
|
|
295
|
+
return openCodexConversation(ctx, conversation);
|
|
296
|
+
conversation = await resumeConversation(ctx, conversation);
|
|
251
297
|
ws = await conversationWorkspace(ctx, conversation);
|
|
252
298
|
}
|
|
253
299
|
else {
|
|
@@ -278,12 +324,17 @@ export const AGENT_HANDLERS = {
|
|
|
278
324
|
throw error;
|
|
279
325
|
if (matches.length !== 1)
|
|
280
326
|
throw new UsageError('Use the conversation id.');
|
|
327
|
+
if (matches[0].route === 'codex-cloud')
|
|
328
|
+
return openCodexConversation(ctx, matches[0]);
|
|
281
329
|
conversation = await resumeConversation(ctx, matches[0]);
|
|
282
330
|
ws = await conversationWorkspace(ctx, conversation);
|
|
283
331
|
}
|
|
284
332
|
// The menu bar supplies workspace_key from agents list, not the conversation key.
|
|
285
333
|
if (!conversation && ws.labels.shard_conversation) {
|
|
286
|
-
conversation = await
|
|
334
|
+
conversation = await getConversation(ctx, ws.labels.shard_conversation);
|
|
335
|
+
if (conversation.route === 'codex-cloud')
|
|
336
|
+
return openCodexConversation(ctx, conversation);
|
|
337
|
+
conversation = await resumeConversation(ctx, conversation);
|
|
287
338
|
ws = await conversationWorkspace(ctx, conversation);
|
|
288
339
|
}
|
|
289
340
|
}
|
|
@@ -61,7 +61,8 @@ async function workspace(ctx, git, name, key) {
|
|
|
61
61
|
if (ws.data.deleted_at)
|
|
62
62
|
throw new UsageError(`workspace ${key} is gone; use shard agents restore ${name} if it was archived`);
|
|
63
63
|
const project = projectIdentity(git.root);
|
|
64
|
-
|
|
64
|
+
// A folder without an origin is identified by its path, as conversations label it (agent-repository.ts).
|
|
65
|
+
if (ws.labels.repo_remote && ws.labels.repo_remote !== (project.remote ?? `file://${project.folder}`))
|
|
65
66
|
throw new UsageError('agent repository does not match this local repository');
|
|
66
67
|
const saved = git.entries()[name];
|
|
67
68
|
if (saved && saved.workspace_id !== ws.id)
|
|
@@ -8,6 +8,6 @@ export function agentReachability(values) {
|
|
|
8
8
|
throw new UsageError('--reachable requires a duration');
|
|
9
9
|
const ms = /^0+(?:\.0+)?(?:ms|s|m|h)?$/.test(text.trim()) ? 0 : parseDuration(text, 'reachable');
|
|
10
10
|
if (ms % 1000 !== 0 || ms > 86_400_000)
|
|
11
|
-
throw new UsageError('--reachable must be whole seconds from 0 to 24h (
|
|
11
|
+
throw new UsageError('--reachable must be whole seconds from 0 to 24h (deprecated; accepted for compatibility)');
|
|
12
12
|
return { agentReachableSeconds: ms / 1000 };
|
|
13
13
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { guestSessions, LAUNCH_WINDOW_MS } from "./agent-live.js";
|
|
1
3
|
import { agentReachability } from "./agent-reachability.js";
|
|
2
4
|
import { ensureCloudClaude } from "./claude-cloud-login.js";
|
|
3
5
|
import { spawn } from 'node:child_process';
|
|
@@ -16,6 +18,8 @@ import { prewarmT3 } from "./t3-runtime.js";
|
|
|
16
18
|
import { AgentStateStore } from "./agent-state.js";
|
|
17
19
|
const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
|
|
18
20
|
const keyOf = (ref) => ref.includes('/') ? ref : `${projectIdentity('./').slug}/${ref}`;
|
|
21
|
+
/** How long opening a Claude conversation this computer just started waits for Claude to publish its Remote Control URL. */
|
|
22
|
+
const RECENT_LAUNCH_URL_WAIT_MS = 30_000;
|
|
19
23
|
/** Optimistic file replacement preserves keys installed by other devices connecting at the same time. */
|
|
20
24
|
export async function installDeviceKey(cell, key, signal) {
|
|
21
25
|
const path = '/home/user/.ssh/authorized_keys';
|
|
@@ -145,6 +149,26 @@ const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
|
|
|
145
149
|
url = page.data.find(a => a.workspace_id === ws.id && a.app === 'claude' && a.state !== 'exited' && a.remote_control_url)?.remote_control_url;
|
|
146
150
|
cursor = page.nextCursor ?? undefined;
|
|
147
151
|
} while (!url && cursor && !ctx.signal.aborted);
|
|
152
|
+
// A new Claude process can accept its first prompt before Remote Control registers.
|
|
153
|
+
// Only an explicit open of our recent launch probes the guest; history browsing stays passive.
|
|
154
|
+
const launchedAt = new AgentStateStore(ctx.store.dir).read().agent_launches?.[ws.id];
|
|
155
|
+
if (!url && typeof launchedAt === 'number' && Date.now() - launchedAt < LAUNCH_WINDOW_MS && ws.ready) {
|
|
156
|
+
const waitMs = Math.min(ctx.wakeTimeoutMs, RECENT_LAUNCH_URL_WAIT_MS);
|
|
157
|
+
const deadline = Date.now() + waitMs;
|
|
158
|
+
const cell = ws.cell({ agentLabel: ctx.agentLabel });
|
|
159
|
+
while (!url && Date.now() < deadline) {
|
|
160
|
+
const result = await cell.exec.run(['sf-agent', 'list'], {
|
|
161
|
+
timeoutMs: Math.min(10_000, Math.max(1, deadline - Date.now())), signal: ctx.signal, maxOutputBytes: 1 << 20,
|
|
162
|
+
});
|
|
163
|
+
if (result.exitCode !== 0)
|
|
164
|
+
break;
|
|
165
|
+
url = guestSessions(result.stdout).find(a => a.app === 'claude' && a.state !== 'exited' && a.remote_control_url)?.remote_control_url;
|
|
166
|
+
if (!url && Date.now() < deadline)
|
|
167
|
+
await delay(Math.min(250, Math.max(1, deadline - Date.now())), undefined, { signal: ctx.signal });
|
|
168
|
+
}
|
|
169
|
+
if (!url)
|
|
170
|
+
throw new Error(`Claude in ${ws.key} did not publish a Remote Control URL within ${Math.ceil(waitMs / 1000)} s; run shard agents open ${ws.key} again`);
|
|
171
|
+
}
|
|
148
172
|
if (!url)
|
|
149
173
|
throw new UsageError(`${ws.key} has no active Remote Control URL; start Claude with Remote Control first`);
|
|
150
174
|
const parsed = new URL(url);
|
|
@@ -173,7 +197,8 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
|
|
|
173
197
|
if (!ctx.json)
|
|
174
198
|
await openApp(target, ctx.io.env);
|
|
175
199
|
new AgentStateStore(ctx.store.dir).saveTarget(ws.labels.repo_remote || ws.labels.shard_project || entry.project, target);
|
|
176
|
-
out(ctx, `${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions
|
|
200
|
+
out(ctx, `${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions,
|
|
201
|
+
app_bundle_id: { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target] });
|
|
177
202
|
return 0;
|
|
178
203
|
}
|
|
179
204
|
export async function queueT3Prewarm(ctx, ws, version) {
|
package/dist/agent-targets.d.ts
CHANGED
|
@@ -10,6 +10,13 @@ export interface TargetEntry {
|
|
|
10
10
|
export declare function targetIdentity(key: string): Pick<TargetEntry, 'host' | 'workspace' | 'project' | 'agent'>;
|
|
11
11
|
/** Replace only a marked block, retaining every byte outside it. Put Includes before user Host blocks. */
|
|
12
12
|
export declare function managedBlock(text: string, body: string): string;
|
|
13
|
+
/** The installed T3 Code apps (any channel), with their path and version, in the given app folders. */
|
|
14
|
+
export declare function t3Apps(dirs: string[], platform?: NodeJS.Platform): Array<{
|
|
15
|
+
path: string;
|
|
16
|
+
version: string | null;
|
|
17
|
+
}>;
|
|
18
|
+
/** The app folders scanned for desktop apps: SHARDFLUX_APPLICATIONS_DIRS, else /Applications and ~/Applications. */
|
|
19
|
+
export declare function applicationDirs(env: Record<string, string | undefined>): string[];
|
|
13
20
|
export declare function installedTargets(env: Record<string, string | undefined>, platform?: NodeJS.Platform, appDirs?: string[]): {
|
|
14
21
|
targets: string[];
|
|
15
22
|
clients: string[];
|
package/dist/agent-targets.js
CHANGED
|
@@ -33,10 +33,47 @@ export function managedBlock(text, body) {
|
|
|
33
33
|
const stop = end + END.length + (text[end + END.length] === '\n' ? 1 : 0);
|
|
34
34
|
return text.slice(0, start) + block + text.slice(stop);
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
/** The installed T3 Code apps (any channel), with their path and version, in the given app folders. */
|
|
37
|
+
export function t3Apps(dirs, platform = process.platform) {
|
|
38
|
+
const found = [];
|
|
39
|
+
if (platform !== 'darwin')
|
|
40
|
+
return found;
|
|
41
|
+
for (const dir of dirs) {
|
|
42
|
+
let names;
|
|
43
|
+
try {
|
|
44
|
+
names = readdirSync(dir);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const name of names.filter(name => name.endsWith('.app'))) {
|
|
50
|
+
const info = join(dir, name, 'Contents', 'Info.plist');
|
|
51
|
+
// XML and binary plists store this ASCII bundle id verbatim. Avoid a
|
|
52
|
+
// subprocess for every unrelated app on the normal no-T3 path.
|
|
53
|
+
try {
|
|
54
|
+
if (!readFileSync(info).includes('com.t3tools.t3code'))
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const result = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', '-c', 'Print :CFBundleShortVersionString', info], { input: '', encoding: 'utf8', timeout: 1000 });
|
|
61
|
+
const [bundle, version] = result.stdout?.trim().split('\n') ?? [];
|
|
62
|
+
if (bundle !== 'com.t3tools.t3code')
|
|
63
|
+
continue;
|
|
64
|
+
found.push({ path: join(dir, name), version: result.status === 0 && version && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version) ? version : null });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
/** The app folders scanned for desktop apps: SHARDFLUX_APPLICATIONS_DIRS, else /Applications and ~/Applications. */
|
|
70
|
+
export function applicationDirs(env) {
|
|
37
71
|
const home = env.HOME || homedir();
|
|
72
|
+
return env.SHARDFLUX_APPLICATIONS_DIRS !== undefined ? env.SHARDFLUX_APPLICATIONS_DIRS.split(':').filter(Boolean) : ['/Applications', join(home, 'Applications')];
|
|
73
|
+
}
|
|
74
|
+
export function installedTargets(env, platform = process.platform, appDirs) {
|
|
38
75
|
// SHARDFLUX_APPLICATIONS_DIRS (colon-separated) replaces the scanned app folders, like SHARDFLUX_CONFIG_DIR does for config.
|
|
39
|
-
const dirs = appDirs ?? (env
|
|
76
|
+
const dirs = appDirs ?? applicationDirs(env);
|
|
40
77
|
const hasApp = (name, bundle) => platform === 'darwin' && dirs.map(dir => join(dir, name)).some(path => {
|
|
41
78
|
const info = join(path, 'Contents', 'Info.plist');
|
|
42
79
|
if (!existsSync(info))
|
|
@@ -45,36 +82,11 @@ export function installedTargets(env, platform = process.platform, appDirs) {
|
|
|
45
82
|
return result.status === 0 && result.stdout.trim() === bundle;
|
|
46
83
|
});
|
|
47
84
|
const versions = {};
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
names = readdirSync(dir);
|
|
54
|
-
}
|
|
55
|
-
catch {
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
for (const name of names.filter(name => name.endsWith('.app'))) {
|
|
59
|
-
const info = join(dir, name, 'Contents', 'Info.plist');
|
|
60
|
-
// XML and binary plists store this ASCII bundle id verbatim. Avoid a
|
|
61
|
-
// subprocess for every unrelated app on the normal no-T3 path.
|
|
62
|
-
try {
|
|
63
|
-
if (!readFileSync(info).includes('com.t3tools.t3code'))
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
const result = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', '-c', 'Print :CFBundleShortVersionString', info], { input: '', encoding: 'utf8', timeout: 1000 });
|
|
70
|
-
const [bundle, version] = result.stdout?.trim().split('\n') ?? [];
|
|
71
|
-
if (bundle !== 'com.t3tools.t3code')
|
|
72
|
-
continue;
|
|
73
|
-
t3 = true;
|
|
74
|
-
if (result.status === 0 && version && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version))
|
|
75
|
-
versions.t3 ??= version;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
85
|
+
const t3Bundles = t3Apps(dirs, platform);
|
|
86
|
+
const t3 = t3Bundles.length > 0;
|
|
87
|
+
for (const app of t3Bundles)
|
|
88
|
+
if (app.version)
|
|
89
|
+
versions.t3 ??= app.version;
|
|
78
90
|
const executable = (name) => (env.PATH ?? '').split(':').filter(Boolean).some(p => { try {
|
|
79
91
|
accessSync(join(p, name), constants.X_OK);
|
|
80
92
|
return true;
|
package/dist/args.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.
|
|
1
|
+
export declare const CLI_VERSION = "0.15.0";
|
|
2
2
|
export interface OptionSpec {
|
|
3
3
|
type: 'string' | 'boolean';
|
|
4
4
|
short?: string;
|
|
@@ -6,6 +6,8 @@ export interface OptionSpec {
|
|
|
6
6
|
/** Placeholder shown in help, e.g. `<slug>`. */
|
|
7
7
|
value?: string;
|
|
8
8
|
description: string;
|
|
9
|
+
/** Accept the option for compatibility but omit it from help. */
|
|
10
|
+
hidden?: boolean;
|
|
9
11
|
}
|
|
10
12
|
export interface PositionalSpec {
|
|
11
13
|
name: string;
|