@shardflux/cli 0.20.0 → 0.23.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 (124) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +242 -75
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +19 -15
  6. package/dist/account-pool.js +47 -96
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-journal.js +58 -6
  10. package/dist/acp-login.d.ts +13 -5
  11. package/dist/acp-login.js +37 -8
  12. package/dist/acp-models.d.ts +6 -1
  13. package/dist/acp-models.js +29 -19
  14. package/dist/acp.js +120 -14
  15. package/dist/agent-commands.d.ts +4 -1
  16. package/dist/agent-commands.js +110 -42
  17. package/dist/agent-config-return.d.ts +46 -2
  18. package/dist/agent-config-return.js +110 -4
  19. package/dist/agent-credentials.js +7 -3
  20. package/dist/agent-repository.d.ts +16 -3
  21. package/dist/agent-repository.js +149 -74
  22. package/dist/agent-setup.d.ts +14 -3
  23. package/dist/agent-setup.js +78 -36
  24. package/dist/agent-target-commands.d.ts +20 -1
  25. package/dist/agent-target-commands.js +73 -31
  26. package/dist/agent-targets.js +6 -1
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +95 -54
  29. package/dist/bin.js +5 -1
  30. package/dist/browser.d.ts +13 -2
  31. package/dist/browser.js +30 -14
  32. package/dist/claude-cloud-login.d.ts +24 -2
  33. package/dist/claude-cloud-login.js +151 -11
  34. package/dist/cli-proxy-hub.d.ts +61 -25
  35. package/dist/cli-proxy-hub.js +202 -54
  36. package/dist/cli-proxy.d.ts +44 -1
  37. package/dist/cli-proxy.js +177 -28
  38. package/dist/client-settings.d.ts +39 -0
  39. package/dist/client-settings.js +91 -0
  40. package/dist/codex-cloud.d.ts +14 -4
  41. package/dist/codex-cloud.js +135 -31
  42. package/dist/codex-linux.d.ts +14 -0
  43. package/dist/codex-linux.js +69 -0
  44. package/dist/command-center-actions.js +18 -5
  45. package/dist/command-center-model.d.ts +2 -0
  46. package/dist/command-center-model.js +6 -1
  47. package/dist/command-center.js +3 -3
  48. package/dist/commands.js +173 -24
  49. package/dist/conversation-commands.d.ts +5 -0
  50. package/dist/conversation-commands.js +8 -5
  51. package/dist/conversation-terminal.d.ts +10 -2
  52. package/dist/conversation-terminal.js +78 -13
  53. package/dist/conversations.d.ts +86 -1
  54. package/dist/conversations.js +306 -23
  55. package/dist/desktop-linux.js +16 -3
  56. package/dist/device-bridge.d.ts +1 -1
  57. package/dist/device-bridge.js +6 -4
  58. package/dist/device-helper.js +43 -22
  59. package/dist/errors.d.ts +29 -2
  60. package/dist/errors.js +50 -7
  61. package/dist/filesystem-commands.d.ts +16 -9
  62. package/dist/filesystem-commands.js +133 -57
  63. package/dist/format.d.ts +2 -1
  64. package/dist/format.js +10 -1
  65. package/dist/handlers.js +3 -1
  66. package/dist/initial-prompt.d.ts +8 -0
  67. package/dist/initial-prompt.js +43 -1
  68. package/dist/large-media.d.ts +14 -0
  69. package/dist/large-media.js +100 -0
  70. package/dist/linux-terminal.d.ts +5 -3
  71. package/dist/linux-terminal.js +6 -20
  72. package/dist/local-sessions.d.ts +26 -2
  73. package/dist/local-sessions.js +113 -24
  74. package/dist/machine-setup.d.ts +11 -1
  75. package/dist/machine-setup.js +69 -22
  76. package/dist/machine-status.d.ts +67 -0
  77. package/dist/machine-status.js +140 -0
  78. package/dist/main.js +11 -13
  79. package/dist/menubar.d.ts +6 -5
  80. package/dist/menubar.js +38 -28
  81. package/dist/move-worktrees-restore.py +144 -0
  82. package/dist/move-worktrees.d.ts +69 -0
  83. package/dist/move-worktrees.js +252 -0
  84. package/dist/move.d.ts +10 -1
  85. package/dist/move.js +111 -16
  86. package/dist/onboarding.d.ts +11 -7
  87. package/dist/onboarding.js +63 -29
  88. package/dist/prepared-repository.d.ts +204 -0
  89. package/dist/prepared-repository.js +860 -0
  90. package/dist/prepared-repository.py +1307 -0
  91. package/dist/project-preparation.d.ts +62 -0
  92. package/dist/project-preparation.js +316 -0
  93. package/dist/project-sync-manifest.d.ts +4 -0
  94. package/dist/project-sync-manifest.js +5 -1
  95. package/dist/project-sync-transfer.js +1 -1
  96. package/dist/project-sync.d.ts +22 -4
  97. package/dist/project-sync.js +34 -8
  98. package/dist/project-template-setup.d.ts +33 -1
  99. package/dist/project-template-setup.js +59 -2
  100. package/dist/secret-ask.d.ts +72 -0
  101. package/dist/secret-ask.js +351 -0
  102. package/dist/seed-phases.d.ts +8 -1
  103. package/dist/seed-phases.js +17 -2
  104. package/dist/setup-cache.d.ts +7 -0
  105. package/dist/setup-cache.js +32 -0
  106. package/dist/t3-launch.d.ts +2 -1
  107. package/dist/t3-launch.js +12 -6
  108. package/dist/t3-mode.d.ts +8 -2
  109. package/dist/t3-mode.js +19 -4
  110. package/dist/t3-phone.d.ts +4 -2
  111. package/dist/t3-phone.js +12 -4
  112. package/dist/t3-proxy.d.ts +81 -0
  113. package/dist/t3-proxy.js +325 -0
  114. package/dist/terminal-apps.d.ts +168 -0
  115. package/dist/terminal-apps.js +574 -0
  116. package/dist/terminal-onboarding.js +6 -1
  117. package/dist/this-computer.d.ts +11 -0
  118. package/dist/this-computer.js +36 -0
  119. package/dist/thread-on-computer.js +4 -1
  120. package/dist/update.d.ts +2 -0
  121. package/dist/update.js +43 -9
  122. package/dist/worktree-twin.d.ts +10 -2
  123. package/dist/worktree-twin.js +27 -9
  124. package/package.json +5 -5
@@ -1,29 +1,34 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { spawn } from 'node:child_process';
2
3
  import { CodedError, UsageError } from "./errors.js";
3
4
  import { json } from "./format.js";
4
5
  import { syncAgentCredentials } from "./agent-credentials.js";
5
- import { createConversation, getConversation, recordedChoice, resumeConversation } from "./conversations.js";
6
+ import { createConversation, conversationWorkspace, getConversation, localConversations, recordedChoice, resumeConversation, seedConversation } from "./conversations.js";
6
7
  import { CHOICE_PENDING, choiceReport, hasChoice, modelChoice } from "./agent-models.js";
7
8
  import { AgentStateStore } from "./agent-state.js";
9
+ import { installedTargets } from "./agent-targets.js";
8
10
  import { ensureProjectGit } from "./project-git.js";
9
11
  import { gitRunner } from "./agent-git.js";
10
12
  import { projectIdentity } from "./project-identity.js";
11
13
  import { uploadInitialImages } from "./initial-prompt.js";
12
- /** Only provider-returned task ids enter the registered Codex deep-link route. */
13
- export async function openCodexThread(task, env) {
14
+ /** Cloud threads open in the browser, preserving the durable provider destination on every OS. */
15
+ export async function openCodexThread(task, env, platform = process.platform) {
14
16
  const url = threadLink(task);
15
- const program = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
16
- const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
17
+ const program = platform === 'darwin' ? 'open' : platform === 'win32' ? 'rundll32' : 'xdg-open';
18
+ const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
17
19
  const child = spawn(program, args, { env, stdio: 'ignore' });
18
- await new Promise((resolve, reject) => { const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Codex app did not open in time')); }, 10000); child.once('error', error => { clearTimeout(timer); reject(error); }); child.once('exit', code => { clearTimeout(timer); if (code === 0)
20
+ await new Promise((resolve, reject) => { const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Cloud chat did not open in time')); }, 10000); child.once('error', error => { clearTimeout(timer); reject(error); }); child.once('exit', code => { clearTimeout(timer); if (code === 0)
19
21
  resolve();
20
22
  else
21
- reject(new Error(`Open this Cloud thread in Codex: ${task.desktop_url}`)); }); });
23
+ reject(new Error(`Open this Cloud thread: ${url}`)); }); });
22
24
  }
23
25
  function threadLink(task) {
24
- if (!task.thread_id || !/^[a-zA-Z0-9_-]{1,128}$/.test(task.thread_id) || task.desktop_url !== `codex://threads/${encodeURIComponent(task.thread_id)}?hostId=durable`)
26
+ if (!task.thread_id || !/^[a-zA-Z0-9_-]{1,128}$/.test(task.thread_id))
25
27
  throw new Error('Cloud returned an invalid Codex thread link');
26
- return task.desktop_url;
28
+ const url = `https://chatgpt.com/local/${encodeURIComponent(task.thread_id)}`;
29
+ if (task.web_url != null && task.web_url !== url || task.desktop_url != null && task.desktop_url !== `codex://threads/${encodeURIComponent(task.thread_id)}?hostId=durable`)
30
+ throw new Error('Cloud returned an invalid Codex thread link');
31
+ return url;
27
32
  }
28
33
  export async function submitCloudInitialPrompt(ctx, c, prompt, workspaceKey) {
29
34
  try {
@@ -33,19 +38,32 @@ export async function submitCloudInitialPrompt(ctx, c, prompt, workspaceKey) {
33
38
  throw new CodedError('initial_prompt_failed', `Created ${workspaceKey}, but the initial prompt could not be confirmed. Open that conversation before starting another.`, undefined, { workspace_key: workspaceKey, conversation_id: c.id, submitted: null });
34
39
  }
35
40
  }
36
- /** Prints the Cloud thread (and its link) and opens it in the Codex app; --json prints it instead. */
41
+ /** Prints the Cloud thread (and its link) and opens it in your browser; --json prints it instead. */
37
42
  export async function printCloudThread(ctx, c, task, submitted = false, choice) { return output(ctx, c, task, submitted, choice); }
43
+ /** Whether this computer has the Codex app, which opens a Cloud thread's codex:// link (on Linux 0.23.0+). */
44
+ const codexAppHere = (env) => installedTargets(env ?? {}).clients.includes('codex-app');
45
+ /**
46
+ * A Cloud conversation where the Codex app cannot show it (0.23.0+): where its chat lives, whether it waits for an
47
+ * answer, and how to answer and follow it from here. Never a new start: the conversation exists.
48
+ */
49
+ export function cloudChatElsewhere(c, url) {
50
+ return `${c.key} runs in Codex Cloud${c.state === 'needs_you' ? ' and is waiting for your answer' : ''}. Its chat opens in your browser: ${url}\n` +
51
+ `Answer it from here: shard conversations send ${c.id} "<your answer>"\nWhere it stands: shard conversations get ${c.id}\n`;
52
+ }
38
53
  async function output(ctx, c, task, submitted = false, choice) {
39
- const url = threadLink(task);
54
+ const url = threadLink(task), app = codexAppHere(ctx.io.env);
40
55
  // model_choice (0.17.0+): an admitted thread's record keeps the model and effort only when Cloud started it with them.
41
56
  const applied = hasChoice(choice) ? await cloudChoiceApplied(ctx, c, choice) : true;
57
+ // codex_app and reply_command (0.23.0+): without the Codex app, the conversation is answered with that command.
42
58
  if (ctx.json) {
43
- ctx.io.stdout.write(json({ conversation: c, cloud: task, url, ...(submitted ? { initial_prompt: { submitted: true } } : {}), ...(hasChoice(choice) ? { model_choice: choiceReport(choice, applied) } : {}) }));
59
+ ctx.io.stdout.write(json({ conversation: c, cloud: task, url, previous_threads: (task.previous_thread_ids ?? []).map(id => ({ thread_id: id, url: threadLink({ thread_id: id }) })), codex_app: app, reply_command: ['shard', 'conversations', 'send', c.id, '<message>'], ...(submitted ? { initial_prompt: { submitted: true } } : {}), ...(hasChoice(choice) ? { model_choice: choiceReport(choice, applied) } : {}) }));
44
60
  return;
45
61
  }
46
62
  if (!applied)
47
63
  ctx.io.stderr.write(`${CHOICE_PENDING}\n`);
48
- ctx.io.stdout.write(`${c.key}\n${task.desktop_url}\n`);
64
+ ctx.io.stdout.write(`${c.key}\n${url}\n`);
65
+ for (const id of task.previous_thread_ids ?? [])
66
+ ctx.io.stdout.write(`Earlier history: https://chatgpt.com/local/${encodeURIComponent(id)}\n`);
49
67
  await openCodexThread(task, ctx.io.env);
50
68
  }
51
69
  async function cloudChoiceApplied(ctx, c, choice) {
@@ -128,24 +146,75 @@ export async function launchCodexCloud(ctx, values, path, images = []) {
128
146
  await ensureProjectGit(ctx, project.folder);
129
147
  await gitRunner(project.folder, { ...process.env, ...ctx.io.env }, ctx.signal)(['rev-parse', 'HEAD']);
130
148
  await ensureCodexCloud(ctx);
131
- const created = await createConversation(ctx, path, 'codex', 'codex-cloud', typeof values.name === 'string' ? values.name : undefined, choice);
132
- const c = created.conversation;
133
- // --computer: the conversation VM's desktop is on before Codex starts, so the thread gets the computer tools.
134
- if (values.computer === true)
135
- await created.workspace.setComputerUse(true);
136
- // The thread's tools run in this VM, so its first message can name the images by their VM paths.
137
- await uploadInitialImages(created.workspace.cell(), images, ctx.signal);
138
- // Idempotency follows the durable conversation, including a lost response.
139
- await ctx.cloud().codex.admit(c.id, { idempotencyKey: `codex-${c.id}`, signal: ctx.signal });
140
- const task = await ctx.cloud().codex.waitTask(c.id, { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
141
- new AgentStateStore(ctx.store.dir).save(project.remote ?? project.folder, 'codex');
142
- const submitted = typeof values.initialPrompt === 'string';
143
- if (submitted)
144
- await submitCloudInitialPrompt(ctx, c, values.initialPrompt, created.workspace.key);
145
- await output(ctx, c, task, submitted, choice);
146
- return 0;
149
+ const patch = (id, update) => new AgentStateStore(ctx.store.dir).update(doc => {
150
+ const records = doc.conversations;
151
+ const r = records[id];
152
+ if (r?.cloud_start)
153
+ r.cloud_start = { ...r.cloud_start, ...update };
154
+ });
155
+ let release;
156
+ try {
157
+ const created = await createConversation(ctx, path, 'codex', 'codex-cloud', typeof values.name === 'string' ? values.name : undefined, choice, undefined, undefined, undefined, {
158
+ accepted(c) {
159
+ new AgentStateStore(ctx.store.dir).update(doc => {
160
+ const records = doc.conversations;
161
+ records[c.id].cloud_start = { ...(typeof values.initialPrompt === 'string' ? { prompt: values.initialPrompt } : {}), computer: values.computer === true, attachments_pending: images.length > 0, state: 'accepted', heartbeat_at: Date.now() };
162
+ });
163
+ release = carryStart(ctx, c.id);
164
+ },
165
+ async beforeSeed(ws, c) {
166
+ if (values.computer === true)
167
+ await ws.setComputerUse(true);
168
+ await uploadInitialImages(ws.cell(), images, ctx.signal);
169
+ patch(c.id, { attachments_pending: false });
170
+ },
171
+ });
172
+ const c = created.conversation;
173
+ // --computer: the conversation VM's desktop is on before Codex starts, so the thread gets the computer tools.
174
+ // The thread's tools run in this VM, so its first message can name the images by their VM paths.
175
+ // Idempotency follows the durable conversation, including a lost response.
176
+ await ctx.cloud().codex.admit(c.id, { idempotencyKey: `codex-${c.id}`, signal: ctx.signal });
177
+ const task = await ctx.cloud().codex.waitTask(c.id, { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
178
+ new AgentStateStore(ctx.store.dir).save(project.remote ?? project.folder, 'codex');
179
+ const submitted = typeof values.initialPrompt === 'string';
180
+ if (submitted)
181
+ await submitCloudInitialPrompt(ctx, c, values.initialPrompt, created.workspace.key);
182
+ patch(c.id, { state: 'active', submitted, prompt: undefined });
183
+ await output(ctx, c, task, submitted, choice);
184
+ return 0;
185
+ }
186
+ finally {
187
+ release?.();
188
+ }
147
189
  }
148
- export async function openCodexConversation(ctx, c) {
190
+ /**
191
+ * While this process carries an accepted start it says so every 10 s (0.22.0+): the device helper resumes only a start
192
+ * nobody carries, never one a foreground command is still running.
193
+ */
194
+ function carryStart(ctx, id) {
195
+ const beat = () => new AgentStateStore(ctx.store.dir).update(doc => { const r = doc.conversations[id]; if (r?.cloud_start)
196
+ r.cloud_start.heartbeat_at = Date.now(); });
197
+ beat();
198
+ const timer = setInterval(beat, 10_000);
199
+ timer.unref?.();
200
+ return () => clearInterval(timer);
201
+ }
202
+ /** A start whose carrying process said so in the last 45 s. */
203
+ export const startCarried = (start) => Date.now() - (start.heartbeat_at ?? 0) < 45_000;
204
+ export async function openCodexConversation(ctx, c, history) {
205
+ if (history) {
206
+ const task = await ctx.cloud().codex.task(c.id), ids = task.previous_thread_ids ?? [];
207
+ const id = history === true ? ids[0] : history;
208
+ if (!id || !ids.includes(id))
209
+ throw new UsageError('This conversation has no matching earlier Cloud history.');
210
+ return output(ctx, c, { ...task, thread_id: id, desktop_url: null, web_url: null }, false).then(() => 0);
211
+ }
212
+ const local = localConversations(ctx)[c.id];
213
+ if (local?.cloud_start && local.cloud_start.state !== 'active' && !c.ended_at) {
214
+ const task = await resumeCloudStartup(ctx, c);
215
+ await output(ctx, c, task, local.cloud_start.prompt !== undefined, recordedChoice(c));
216
+ return 0;
217
+ }
149
218
  if (c.state === 'done' || c.state === 'archived' || c.ended_at) {
150
219
  c = await resumeConversation(ctx, c);
151
220
  await ctx.cloud().codex.admit(c.id, { idempotencyKey: `codex-resume-${c.id}-${c.workspace_id}`, signal: ctx.signal });
@@ -153,7 +222,15 @@ export async function openCodexConversation(ctx, c) {
153
222
  await output(ctx, c, task);
154
223
  return 0;
155
224
  }
156
- const task = await ctx.cloud().codex.task(c.id);
225
+ let task = await ctx.cloud().codex.task(c.id);
226
+ // A thread a connector change stopped (Codex Cloud turned off and on, or the active Codex account switched) continues
227
+ // in a new Cloud thread of this same conversation, on its VM and branch: an API that admits it again does so on
228
+ // request (0.23.0+); an older one answers with the stopped thread.
229
+ if (task.state === 'canceled') {
230
+ task = await ctx.cloud().codex.admit(c.id, { idempotencyKey: `codex-readmit-${c.id}-${randomUUID()}`, signal: ctx.signal });
231
+ if (task.state !== 'canceled')
232
+ task = await ctx.cloud().codex.waitTask(c.id, { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
233
+ }
157
234
  if (task.state === 'canceled')
158
235
  throw new UsageError('This conversation’s Codex executor was removed. Start a new Cloud conversation; its saved branch and history remain available.');
159
236
  if (task.state !== 'active' || !task.thread_id)
@@ -162,6 +239,33 @@ export async function openCodexConversation(ctx, c) {
162
239
  await output(ctx, c, task);
163
240
  return 0;
164
241
  }
242
+ /** Continues the same accepted identity, never creates a replacement VM/chat. */
243
+ export async function resumeCloudStartup(ctx, c) {
244
+ const local = localConversations(ctx)[c.id], start = local?.cloud_start;
245
+ if (!start)
246
+ throw new UsageError('This conversation has no local accepted startup.');
247
+ if (start.attachments_pending)
248
+ throw new CodedError('initial_images_pending', 'This accepted conversation still needs its initial images. Resume from the launcher with the retained images.', undefined, { conversation_id: c.id, workspace_key: c.key });
249
+ const release = carryStart(ctx, c.id);
250
+ try {
251
+ const ws = await conversationWorkspace(ctx, c);
252
+ // The same seed the start began: its own captured revision (never a newer checkout), or the copy seed.
253
+ await seedConversation(ctx, c, ws, local.folder);
254
+ await ctx.cloud().codex.admit(c.id, { idempotencyKey: `codex-${c.id}`, signal: ctx.signal });
255
+ const task = await ctx.cloud().codex.waitTask(c.id, { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
256
+ if (start.prompt !== undefined && !start.submitted)
257
+ await submitCloudInitialPrompt(ctx, c, start.prompt, ws.key);
258
+ new AgentStateStore(ctx.store.dir).update(doc => {
259
+ const records = doc.conversations;
260
+ if (records[c.id]?.cloud_start)
261
+ records[c.id].cloud_start = { ...start, state: 'active', submitted: start.prompt !== undefined || start.submitted, prompt: undefined, error: undefined };
262
+ });
263
+ return task;
264
+ }
265
+ finally {
266
+ release();
267
+ }
268
+ }
165
269
  export const CODEX_CLOUD_HANDLERS = {
166
270
  'agents codex status': async (ctx) => { ctx.io.stdout.write(json(await ctx.cloud().codex.connector())); return 0; },
167
271
  'agents codex enable': async (ctx) => { ctx.io.stdout.write(json(await ensureCodexCloud(ctx))); return 0; },
@@ -0,0 +1,14 @@
1
+ type Env = Record<string, string | undefined>;
2
+ /** A Codex app install: its desktop entry and that entry's Exec as argv (field codes removed). */
3
+ export interface LinuxCodexApp {
4
+ desktop_id: string;
5
+ exec: string[];
6
+ }
7
+ /** The installed Codex apps: entries that handle codex:// and whose program exists. The first entry with an id wins (XDG precedence). */
8
+ export declare function linuxCodexApps(dirs: string[], env: Env): LinuxCodexApp[];
9
+ /**
10
+ * Opens a codex:// link in that install, as the desktop does: its Exec with the link. Detached, so a cold start keeps
11
+ * running after `shard` exits; a running app takes the link in its window.
12
+ */
13
+ export declare function openLinuxCodexLink(app: LinuxCodexApp, url: string, env: Env): Promise<void>;
14
+ export {};
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The Codex app on Linux (0.23.0+): OpenAI's ChatGPT app for Linux (package `chatgpt`: `chatgpt.desktop`,
3
+ * `Exec=chatgpt %U`) and community builds (`codex-desktop.desktop`) register the codex:// scheme, which opens a Codex
4
+ * thread (`codex://threads/<id>?hostId=durable` for a Cloud conversation). The app is the desktop entry in the XDG
5
+ * application folders (linuxApplicationDirs) that handles x-scheme-handler/codex.
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import { accessSync, constants, readFileSync, readdirSync, statSync } from 'node:fs';
9
+ import { delimiter, isAbsolute, join } from 'node:path';
10
+ import { desktopEntry, desktopExec, t3OpenEnv } from "./t3-linux.js";
11
+ /** Entry names worth reading (every entry in the folders would be hundreds of files on each `shard` run). */
12
+ const MAYBE_ENTRY = /chatgpt|codex|openai/i;
13
+ const executable = (path) => { try {
14
+ accessSync(path, constants.X_OK);
15
+ return statSync(path).isFile();
16
+ }
17
+ catch {
18
+ return false;
19
+ } };
20
+ const onPath = (name, env) => isAbsolute(name) ? executable(name) : (env.PATH ?? '').split(delimiter).filter(Boolean).some(dir => executable(join(dir, name)));
21
+ /** The installed Codex apps: entries that handle codex:// and whose program exists. The first entry with an id wins (XDG precedence). */
22
+ export function linuxCodexApps(dirs, env) {
23
+ const found = [], ids = new Set();
24
+ for (const dir of dirs) {
25
+ let names;
26
+ try {
27
+ names = readdirSync(dir);
28
+ }
29
+ catch {
30
+ continue;
31
+ }
32
+ for (const name of names) {
33
+ if (!name.endsWith('.desktop') || !MAYBE_ENTRY.test(name) || ids.has(name))
34
+ continue;
35
+ let text;
36
+ try {
37
+ text = readFileSync(join(dir, name), 'utf8');
38
+ }
39
+ catch {
40
+ continue;
41
+ }
42
+ ids.add(name);
43
+ const e = desktopEntry(text);
44
+ if (e.Hidden === 'true' || (e.Type ?? 'Application') !== 'Application' || !(e.MimeType ?? '').split(';').includes('x-scheme-handler/codex'))
45
+ continue;
46
+ if (e.TryExec && !onPath(e.TryExec, env))
47
+ continue;
48
+ const exec = desktopExec(e.Exec ?? '');
49
+ // `env VAR=value /usr/bin/codex-desktop %u` (a community entry): the program after the assignments must exist.
50
+ const program = exec?.[0] === 'env' ? exec.slice(1).find(a => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(a)) : exec?.[0];
51
+ if (!exec?.length || !program || !onPath(program, env))
52
+ continue;
53
+ found.push({ desktop_id: name, exec });
54
+ }
55
+ }
56
+ return found;
57
+ }
58
+ /**
59
+ * Opens a codex:// link in that install, as the desktop does: its Exec with the link. Detached, so a cold start keeps
60
+ * running after `shard` exits; a running app takes the link in its window.
61
+ */
62
+ export async function openLinuxCodexLink(app, url, env) {
63
+ const [command, ...args] = app.exec;
64
+ await new Promise((resolve, reject) => {
65
+ const child = spawn(command, [...args, url], { env: t3OpenEnv(env), stdio: 'ignore', detached: true });
66
+ child.once('spawn', () => { child.unref(); resolve(); });
67
+ child.once('error', reject);
68
+ });
69
+ }
@@ -60,8 +60,14 @@ export const CENTER_HANDLERS = {
60
60
  }
61
61
  if (values.review === true)
62
62
  throw new UsageError('--review needs --on <conversation>.');
63
- if ((values.prompt !== undefined || values['prompt-file'] !== undefined || values.image !== undefined) && (ref || values.name !== undefined || values.like !== undefined || values.in !== undefined)) {
64
- throw new UsageError('An initial prompt needs a new conversation: use agents new --path <folder> --app claude|codex.');
63
+ if (values.prompt !== undefined || values['prompt-file'] !== undefined || values.image !== undefined) {
64
+ // --in terminal is where a new conversation opens anyway: it starts one with its first message (0.23.0+). Codex
65
+ // opens in Codex Cloud, which says so.
66
+ if (fresh && values.in === 'terminal')
67
+ return launchConversationProject(ctx, values, [str(values.path) ?? './', ...(str(values.app) ? [str(values.app)] : [])]);
68
+ const other = ref ? `the agent argument ${ref}` : values.name !== undefined ? '--name' : values.like !== undefined ? '--like' : values.in !== undefined ? `--in ${str(values.in)}` : undefined;
69
+ if (other)
70
+ throw new UsageError(`--prompt, --prompt-file and --image are for a new conversation and cannot be combined with ${other}: omit it.`, 'start one with shard agents new --path <folder> --app claude|codex --prompt "<task>"; --in auto opens it where agents targets --open-in says');
65
71
  }
66
72
  if (!ref && values.name === undefined && values.like === undefined && values.in === undefined) {
67
73
  return launchConversationProject(ctx, values, [str(values.path) ?? './', ...(str(values.app) ? [str(values.app)] : [])]);
@@ -69,7 +75,11 @@ export const CENTER_HANDLERS = {
69
75
  if (values.name !== undefined || !ref || values.path !== undefined) {
70
76
  return launchProject(ctx, { ...values, ...(ref && values.like === undefined ? { like: ref } : {}) }, [str(values.path) ?? './', ...(str(values.app) ? [str(values.app)] : [])]);
71
77
  }
72
- return agentAction(ctx, 'open', { ...values, in: 'terminal' }, [ref]);
78
+ // The agent's own terminal session: attached here, started detached with --json, or (without a terminal here) a
79
+ // tab of the person's terminal app, as agents open does (0.20.1+).
80
+ const next = { ...values };
81
+ delete next.in;
82
+ return agentAction(ctx, 'open', next, [ref]);
73
83
  },
74
84
  async 'agents fork'(ctx, values, [ref, name]) {
75
85
  if (!ref || !name || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(name))
@@ -141,8 +151,11 @@ export const CENTER_HANDLERS = {
141
151
  if (ws.labels.shard_conversation && CONVERSATION_ID.test(ws.labels.shard_conversation))
142
152
  conversation = await getConversation(ctx, ws.labels.shard_conversation);
143
153
  }
144
- if (conversation?.route === 'codex-cloud')
145
- throw new UsageError(`Codex Cloud runs this conversation; follow it with shard agents open ${conversation.id}.`);
154
+ // Codex Cloud holds the conversation's output in its Codex thread; no terminal in the VM shows it (0.23.0+: the
155
+ // refusal names the commands that do).
156
+ if (conversation?.route === 'codex-cloud') {
157
+ throw new UsageError(`Codex Cloud runs this conversation, so its output is in its Codex thread, not a terminal: open the thread with shard agents open ${conversation.id}; shard c wait ${conversation.id} waits for its reply and shard c get ${conversation.id} shows it.`);
158
+ }
146
159
  if (conversation?.route === 'acp')
147
160
  throw new UsageError('This conversation runs through its ACP client, which shows its output; it has no terminal to follow.');
148
161
  if (conversation && values.app !== undefined && values.app !== conversation.harness)
@@ -24,6 +24,8 @@ export interface CenterRow {
24
24
  /** Older K5 captures used the workspace key as the agent label. K1 needs its leaf name. */
25
25
  export declare const agentName: (key: string, labels: Record<string, string>) => string;
26
26
  export declare const projectName: (key: string, labels: Record<string, string>) => string;
27
+ /** What `shard agents open` gets for a row: a Cloud thread's conversation id, else the VM's key. */
28
+ export declare const openReference: (row: Pick<CenterRow, "key" | "agent">) => string;
27
29
  export declare function loadCenter(ctx: Ctx): Promise<CenterData>;
28
30
  export declare function scopedProject(data: CenterData, scope: ProjectScope): string;
29
31
  export declare function centerRows(data: CenterData, scope?: ProjectScope, filter?: string): CenterRow[];
@@ -6,6 +6,11 @@ import { readSetupProgress } from "./assisted-setup.js";
6
6
  export const agentName = (key, labels) => (labels.agent || key).split('/').at(-1);
7
7
  export const projectName = (key, labels) => labels.shard_project || key.split('/')[0];
8
8
  const parked = (workspace) => ['suspended', 'hibernated', 'parked'].includes(workspace?.observed_state ?? '');
9
+ /** A Codex Cloud thread (API rows labeled shard_route=codex-cloud, 0.23.0+): it runs in Codex, so its VM being parked
10
+ * says nothing about it, and it opens by its conversation id, in Codex, without waking that VM. */
11
+ const cloudThread = (agent) => agent?.labels.shard_route === 'codex-cloud' && !!agent.conversation_id;
12
+ /** What `shard agents open` gets for a row: a Cloud thread's conversation id, else the VM's key. */
13
+ export const openReference = (row) => cloudThread(row.agent) ? row.agent.conversation_id : row.key;
9
14
  export async function loadCenter(ctx) {
10
15
  const collect = async (path) => {
11
16
  const rows = [], cursors = new Set();
@@ -62,7 +67,7 @@ export function centerRows(data, scope, filter = '') {
62
67
  const workspace = workspaces.get(agent.workspace_id);
63
68
  if (workspace?.deleted_at)
64
69
  continue;
65
- const badge = agent.app === 'claude' && agent.credential_status === 'needs_sign_in' ? 'Claude sign-in needed' : agent.state === 'exited' ? 'done' : parked(workspace) ? 'parked' : agent.state === 'waiting' ? 'needs you' : 'working';
70
+ const badge = agent.app === 'claude' && agent.credential_status === 'needs_sign_in' ? 'Claude sign-in needed' : agent.state === 'exited' ? 'done' : !cloudThread(agent) && parked(workspace) ? 'parked' : agent.state === 'waiting' ? 'needs you' : 'working';
66
71
  rows.push({ id: agent.id, key: agent.workspace_key, project: projectName(agent.workspace_key, agent.labels), agent, workspace, badge,
67
72
  attention: badge === 'Claude sign-in needed' ? -1 : ['needs you', 'working', 'parked', 'done'].indexOf(badge) });
68
73
  withSessions.add(agent.workspace_id);
@@ -5,7 +5,7 @@ import { AgentStateStore } from "./agent-state.js";
5
5
  import { installedTargets } from "./agent-targets.js";
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { agentAction, availableTargets } from "./command-center-actions.js";
8
- import { centerRows, loadCenter } from "./command-center-model.js";
8
+ import { centerRows, loadCenter, openReference } from "./command-center-model.js";
9
9
  import { AnsiRenderer, renderCenter } from "./command-center-renderer.js";
10
10
  import { CenterKeys } from "./command-center-keys.js";
11
11
  import { ensureMenubar } from "./menubar.js";
@@ -320,13 +320,13 @@ export async function showCenter(ctx, scope, options = {}) {
320
320
  const app = row.agent?.app || options.app || new AgentStateStore(ctx.store.dir).app(project, row.workspace?.labels.app === 'codex' ? 'codex' : 'claude');
321
321
  if (key === 'enter') {
322
322
  const target = new AgentStateStore(ctx.store.dir).target(project, ['terminal', ...installedTargets(ctx.io.env).targets]);
323
- await dispatch('open', { app, project, in: target }, [row.key], true);
323
+ await dispatch('open', { app, project, in: target }, [openReference(row)], true);
324
324
  }
325
325
  else if (key === 'o') {
326
326
  const targets = await availableTargets(ctx);
327
327
  menu('Open in…', targets.map(target => ({ label: target, run: async () => {
328
328
  new AgentStateStore(ctx.store.dir).saveTarget(project, target);
329
- await dispatch('open', { app, project, in: target }, [row.key], true);
329
+ await dispatch('open', { app, project, in: target }, [openReference(row)], true);
330
330
  } })));
331
331
  }
332
332
  else if (key === 'N' || key === 'f') {