@shardflux/cli 0.14.4 → 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 (59) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +73 -11
  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 +58 -6
  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/agent-terminal.d.ts +24 -2
  16. package/dist/agent-terminal.js +82 -4
  17. package/dist/args.d.ts +3 -1
  18. package/dist/args.js +21 -9
  19. package/dist/browser-login.d.ts +35 -0
  20. package/dist/browser-login.js +145 -0
  21. package/dist/claude-cloud-login.js +2 -2
  22. package/dist/codex-cloud.d.ts +49 -0
  23. package/dist/codex-cloud.js +157 -0
  24. package/dist/command-center-actions.js +7 -0
  25. package/dist/command-center-renderer.d.ts +3 -0
  26. package/dist/command-center-renderer.js +42 -38
  27. package/dist/command-center.js +65 -11
  28. package/dist/conversation-terminal.js +19 -0
  29. package/dist/conversations.d.ts +1 -1
  30. package/dist/conversations.js +21 -8
  31. package/dist/credential-prompts.d.ts +4 -2
  32. package/dist/credential-prompts.js +38 -16
  33. package/dist/device-bridge.d.ts +13 -0
  34. package/dist/device-bridge.js +14 -4
  35. package/dist/device-helper.js +2 -1
  36. package/dist/errors.d.ts +9 -0
  37. package/dist/errors.js +15 -2
  38. package/dist/handlers.js +2 -1
  39. package/dist/initial-prompt.d.ts +26 -0
  40. package/dist/initial-prompt.js +196 -0
  41. package/dist/legal.d.ts +36 -0
  42. package/dist/legal.js +44 -0
  43. package/dist/main.js +6 -2
  44. package/dist/menubar.d.ts +8 -0
  45. package/dist/menubar.js +9 -1
  46. package/dist/onboarding-app/AGENTS.md +19 -0
  47. package/dist/onboarding-app/CLAUDE.md +19 -0
  48. package/dist/onboarding-app/README.md +12 -0
  49. package/dist/onboarding-app/dev.mjs +18 -0
  50. package/dist/onboarding-app/index.html +25 -0
  51. package/dist/onboarding-app/package.json +12 -0
  52. package/dist/onboarding-app/server.mjs +41 -0
  53. package/dist/onboarding-sample.d.ts +3 -0
  54. package/dist/onboarding-sample.js +29 -0
  55. package/dist/onboarding.d.ts +258 -0
  56. package/dist/onboarding.js +371 -0
  57. package/dist/terminal-brand.d.ts +5 -0
  58. package/dist/terminal-brand.js +18 -0
  59. package/package.json +3 -3
@@ -0,0 +1,20 @@
1
+ import type { Ctx } from './commands.js';
2
+ /**
3
+ * The ACP terminal sign-in method. An editor (T3 Code) runs `shard acp --login` in its own terminal when the user
4
+ * chooses it, then reconnects; ACP appends these args to the configured `shard acp` invocation.
5
+ */
6
+ export declare const ACP_LOGIN_METHOD: {
7
+ readonly id: "shardflux-login";
8
+ readonly name: "Sign in to Shardflux";
9
+ readonly description: "Create or sign in to your Shardflux account and connect Claude Code and Codex.";
10
+ readonly type: "terminal";
11
+ readonly args: readonly ["--login"];
12
+ };
13
+ /** Whether an API key is configured (no request; a revoked key surfaces as auth_required on its first call). */
14
+ export declare function acpSignedIn(ctx: Ctx): boolean;
15
+ /**
16
+ * `shard acp --login`: everything a first conversation needs, each step skipped when it is already done: the
17
+ * account (signed up or in with this Mac's Codex login, else an email), its API key, the Codex login copy and the
18
+ * cloud Claude sign-in.
19
+ */
20
+ export declare function runAcpLogin(ctx: Ctx): Promise<number>;
@@ -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,11 +1,12 @@
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';
5
6
  import { ShardfluxApiError } from '@shardflux/sdk';
6
7
  import { UsageError } from "./errors.js";
7
8
  import { AgentStateStore } from "./agent-state.js";
8
- import { attachTerminal, cellConnector } from "./agent-terminal.js";
9
+ import { attachTerminal, cellConnector, workspaceAsleep } from "./agent-terminal.js";
9
10
  import { prepareProjectTemplate } from "./project-template-setup.js";
10
11
  import { projectIdentity, projectLabels } from "./project-identity.js";
11
12
  import { ensureProjectGit } from "./project-git.js";
@@ -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,16 +121,20 @@ 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`);
121
133
  const attached = attachTerminal({
122
134
  ...(credentialFill ? { credentialPrompts: (ask, signal) => watchCredentialPrompts(ctx, ws, ask, signal) } : {}),
123
- io: ctx.io, signal: ctx.signal, connect: cellConnector(cell, session.session_id),
135
+ io: ctx.io, signal: ctx.signal, connect: cellConnector(cell, session.session_id, { passive: true }),
124
136
  wake: (signal) => ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal }),
137
+ asleep: () => workspaceAsleep(ws),
125
138
  invalidate: () => cell.tokens.invalidate(),
126
139
  sleep: (ms, signal) => new Promise((resolve) => {
127
140
  const done = () => { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); };
@@ -166,6 +179,8 @@ export const launchProject = async (ctx, values, [path = './', explicitApp]) =>
166
179
  if (target && (!['claude-desktop', 'codex-app', 't3'].includes(target) || !detected?.targets.includes(target)))
167
180
  throw new UsageError(`${target} is not an installed desktop target`);
168
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);
169
184
  const agent = typeof values.name === 'string' ? values.name : 'main';
170
185
  if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(agent))
171
186
  throw new UsageError('agent name must match ^[a-z0-9][a-z0-9._-]{0,63}$');
@@ -235,6 +250,35 @@ export const AGENT_HANDLERS = {
235
250
  async 'agents open'(ctx, values, [agent = 'main']) {
236
251
  if (values.app !== undefined && values.app !== 'claude' && values.app !== 'codex')
237
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
+ }
238
282
  if (values.in !== undefined && values.in !== 'terminal') {
239
283
  const { HANDLERS } = await import("./handlers.js");
240
284
  const target = typeof values.in === 'string' ? HANDLERS[`agents open ${values.in}`] : undefined;
@@ -246,7 +290,10 @@ export const AGENT_HANDLERS = {
246
290
  let ws;
247
291
  let conversation;
248
292
  if (/^conv_[a-z0-9]{26}$/.test(agent)) {
249
- 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);
250
297
  ws = await conversationWorkspace(ctx, conversation);
251
298
  }
252
299
  else {
@@ -277,12 +324,17 @@ export const AGENT_HANDLERS = {
277
324
  throw error;
278
325
  if (matches.length !== 1)
279
326
  throw new UsageError('Use the conversation id.');
327
+ if (matches[0].route === 'codex-cloud')
328
+ return openCodexConversation(ctx, matches[0]);
280
329
  conversation = await resumeConversation(ctx, matches[0]);
281
330
  ws = await conversationWorkspace(ctx, conversation);
282
331
  }
283
332
  // The menu bar supplies workspace_key from agents list, not the conversation key.
284
333
  if (!conversation && ws.labels.shard_conversation) {
285
- 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);
286
338
  ws = await conversationWorkspace(ctx, conversation);
287
339
  }
288
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;
@@ -1,4 +1,3 @@
1
- import { PtyConnection } from '@shardflux/sdk';
2
1
  import type { CellClient, PtyServerMessage } from '@shardflux/sdk';
3
2
  import type { Io } from './commands.js';
4
3
  export interface TerminalConnection {
@@ -7,6 +6,8 @@ export interface TerminalConnection {
7
6
  messages(): AsyncIterable<PtyServerMessage>;
8
7
  close(): Promise<void>;
9
8
  }
9
+ /** The workspace (or the VM under this terminal) is asleep: the user's next keystroke wakes it. */
10
+ export declare function sleepingAttach(error: unknown): boolean;
10
11
  export declare function retryableAttach(error: unknown): boolean;
11
12
  /** Offsets address bytes, including escape sequences and UTF-8 split across frames. */
12
13
  export declare class PtyOutputCursor {
@@ -19,6 +20,12 @@ export interface AttachOptions {
19
20
  signal: AbortSignal;
20
21
  connect(offset: number, signal: AbortSignal): Promise<TerminalConnection>;
21
22
  wake(signal: AbortSignal): Promise<unknown>;
23
+ /**
24
+ * Whether the workspace went to sleep under this terminal (suspended or suspending), read without waking it. With
25
+ * it, a dropped terminal never wakes the workspace by itself: asleep, it waits until its user types; otherwise it
26
+ * reattaches, and the host wakes a parked VM for it. Without it, every reattach wakes the workspace first.
27
+ */
28
+ asleep?(signal: AbortSignal): Promise<boolean>;
22
29
  invalidate(): void;
23
30
  sleep(ms: number, signal: AbortSignal): Promise<void>;
24
31
  dimensions?: () => {
@@ -32,4 +39,19 @@ export interface AttachOptions {
32
39
  }
33
40
  /** Detaching closes only this attachment; the workspace PTY and agent keep running. */
34
41
  export declare function attachTerminal(options: AttachOptions): Promise<number>;
35
- export declare function cellConnector(cell: CellClient, sessionId: string): (offset: number, signal: AbortSignal) => Promise<PtyConnection>;
42
+ /** Reads the workspace without waking it: suspended, or on its way there. */
43
+ export declare function workspaceAsleep(ws: {
44
+ refresh(): Promise<unknown>;
45
+ state: string;
46
+ desiredState: string;
47
+ }): Promise<boolean>;
48
+ /** A host without passive attach refuses it before any output; the terminal then attaches as an ordinary one. */
49
+ export declare function passiveUnavailable(error: unknown): boolean;
50
+ /**
51
+ * Attaches a PTY. `passive` (the agent terminal, 0.14.4+) holds the workspace only while bytes move through it, so a
52
+ * waiting agent's workspace parks and suspends under an open terminal, and input still wakes it; on a host without
53
+ * passive attach the next attach is an ordinary one. An ordinary attach wakes a parked workspace and holds it.
54
+ */
55
+ export declare function cellConnector(cell: CellClient, sessionId: string, opts?: {
56
+ passive?: boolean;
57
+ }): (offset: number, signal: AbortSignal) => Promise<TerminalConnection>;