@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +72 -10
  3. package/dist/account-args.js +28 -7
  4. package/dist/account-commands.js +140 -11
  5. package/dist/acp-catalog.js +2 -2
  6. package/dist/acp-login.d.ts +20 -0
  7. package/dist/acp-login.js +122 -0
  8. package/dist/acp.js +36 -13
  9. package/dist/agent-commands.js +55 -4
  10. package/dist/agent-git-commands.js +2 -1
  11. package/dist/agent-reachability.js +1 -1
  12. package/dist/agent-target-commands.js +26 -1
  13. package/dist/agent-targets.d.ts +7 -0
  14. package/dist/agent-targets.js +44 -32
  15. package/dist/args.d.ts +3 -1
  16. package/dist/args.js +21 -9
  17. package/dist/browser-login.d.ts +35 -0
  18. package/dist/browser-login.js +145 -0
  19. package/dist/codex-cloud.d.ts +49 -0
  20. package/dist/codex-cloud.js +157 -0
  21. package/dist/command-center-actions.js +7 -0
  22. package/dist/command-center-renderer.d.ts +3 -0
  23. package/dist/command-center-renderer.js +42 -38
  24. package/dist/command-center.js +65 -11
  25. package/dist/conversation-terminal.js +19 -0
  26. package/dist/conversations.d.ts +1 -1
  27. package/dist/conversations.js +21 -8
  28. package/dist/credential-prompts.d.ts +4 -2
  29. package/dist/credential-prompts.js +38 -16
  30. package/dist/device-bridge.d.ts +13 -0
  31. package/dist/device-bridge.js +14 -4
  32. package/dist/device-helper.js +2 -1
  33. package/dist/errors.d.ts +9 -0
  34. package/dist/errors.js +15 -2
  35. package/dist/handlers.js +2 -1
  36. package/dist/initial-prompt.d.ts +26 -0
  37. package/dist/initial-prompt.js +196 -0
  38. package/dist/legal.d.ts +36 -0
  39. package/dist/legal.js +44 -0
  40. package/dist/main.js +6 -2
  41. package/dist/menubar.d.ts +8 -0
  42. package/dist/menubar.js +9 -1
  43. package/dist/onboarding-app/AGENTS.md +19 -0
  44. package/dist/onboarding-app/CLAUDE.md +19 -0
  45. package/dist/onboarding-app/README.md +12 -0
  46. package/dist/onboarding-app/dev.mjs +18 -0
  47. package/dist/onboarding-app/index.html +25 -0
  48. package/dist/onboarding-app/package.json +12 -0
  49. package/dist/onboarding-app/server.mjs +41 -0
  50. package/dist/onboarding-sample.d.ts +3 -0
  51. package/dist/onboarding-sample.js +29 -0
  52. package/dist/onboarding.d.ts +258 -0
  53. package/dist/onboarding.js +371 -0
  54. package/dist/terminal-brand.d.ts +5 -0
  55. package/dist/terminal-brand.js +18 -0
  56. 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
- return nativeModelOptions(catalog[app] ?? {}).map((option) => {
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} Run shard setup.`, { code: 'unauthenticated', fix: 'Run shard setup.' });
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
- 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.');
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
- this.#probe = objectValue(params.clientInfo).name === 't3-code-provider-test';
98
- return { protocolVersion: 1, agentInfo: { name: 'shardflux', title: 'Shardflux', version: CLI_VERSION }, authMethods: [],
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
- try {
167
- result = objectValue(await session.transport.rpc.request('session/set_config_option', { sessionId: record.nativeId, configId: 'model', value: selected.id }));
168
- }
169
- catch (e) {
170
- await this.#rejectModel(session, selected.id, e);
171
- throw e;
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, 'Sign in to Claude Code with shard agents credentials login claude.', { code: 'unauthenticated', fix: 'Run shard agents credentials login claude.' });
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
- if (record.workspaceId !== session.ws.id)
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) => {
@@ -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 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);
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 = await resumeConversation(ctx, await getConversation(ctx, agent));
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 resumeConversation(ctx, await getConversation(ctx, ws.labels.shard_conversation));
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
- if (ws.labels.repo_remote && ws.labels.repo_remote !== project.remote)
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 (e.g. 30m; 0 off)');
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) {
@@ -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[];
@@ -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
- export function installedTargets(env, platform = process.platform, appDirs) {
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.SHARDFLUX_APPLICATIONS_DIRS !== undefined ? env.SHARDFLUX_APPLICATIONS_DIRS.split(':').filter(Boolean) : ['/Applications', join(home, 'Applications')]);
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
- let t3 = false;
49
- if (platform === 'darwin')
50
- for (const dir of dirs) {
51
- let names;
52
- try {
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.14.5";
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;