@shardflux/cli 0.24.0 → 0.24.1

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 (49) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +227 -1
  3. package/dist/account-args.js +5 -0
  4. package/dist/account-pool.js +5 -1
  5. package/dist/acp-permission-elsewhere.d.ts +38 -0
  6. package/dist/acp-permission-elsewhere.js +85 -0
  7. package/dist/acp-rpc.d.ts +2 -1
  8. package/dist/acp-rpc.js +11 -2
  9. package/dist/acp.js +95 -22
  10. package/dist/agent-credentials.d.ts +3 -0
  11. package/dist/agent-credentials.js +7 -0
  12. package/dist/args.d.ts +1 -1
  13. package/dist/args.js +143 -8
  14. package/dist/board-args.d.ts +6 -0
  15. package/dist/board-args.js +155 -0
  16. package/dist/board-commands.d.ts +62 -0
  17. package/dist/board-commands.js +395 -0
  18. package/dist/codex-cloud.d.ts +30 -3
  19. package/dist/codex-cloud.js +86 -12
  20. package/dist/conversation-commands.d.ts +30 -0
  21. package/dist/conversation-commands.js +312 -9
  22. package/dist/conversation-transcript.d.ts +44 -0
  23. package/dist/conversation-transcript.js +206 -0
  24. package/dist/conversations.d.ts +5 -2
  25. package/dist/conversations.js +20 -10
  26. package/dist/device-approvals.js +4 -3
  27. package/dist/device-bridge.d.ts +42 -1
  28. package/dist/device-bridge.js +223 -12
  29. package/dist/device-folders.d.ts +58 -0
  30. package/dist/device-folders.js +228 -0
  31. package/dist/device-helper.d.ts +6 -0
  32. package/dist/device-helper.js +87 -14
  33. package/dist/drive-commands.d.ts +25 -0
  34. package/dist/drive-commands.js +201 -0
  35. package/dist/handlers.js +6 -1
  36. package/dist/main.js +3 -0
  37. package/dist/move-files.d.ts +39 -0
  38. package/dist/move-files.js +167 -0
  39. package/dist/move.js +12 -11
  40. package/dist/onboarding.d.ts +6 -2
  41. package/dist/onboarding.js +24 -6
  42. package/dist/project-git.d.ts +7 -1
  43. package/dist/project-git.js +16 -3
  44. package/dist/project-preparation.d.ts +7 -0
  45. package/dist/project-preparation.js +14 -0
  46. package/dist/project-template-setup.js +32 -11
  47. package/dist/router-commands.d.ts +22 -0
  48. package/dist/router-commands.js +179 -0
  49. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import type { CodexCloudTask } from '@shardflux/sdk';
1
+ import type { CodexCloudTask, LocalCodexLogin } from '@shardflux/sdk';
2
2
  import type { Ctx, Handler } from './commands.js';
3
3
  import type { Values } from './args.js';
4
4
  import type { ModelChoice } from './agent-models.js';
@@ -15,6 +15,25 @@ export declare function printCloudThread(ctx: Ctx, c: Conversation, task: CodexC
15
15
  * answer, and how to answer and follow it from here. Never a new start: the conversation exists.
16
16
  */
17
17
  export declare function cloudChatElsewhere(c: Pick<Conversation, 'id' | 'key' | 'state'>, url: string): string;
18
+ type ThreadAccount = NonNullable<CodexCloudTask['account']>;
19
+ /**
20
+ * The line a client shows when the Codex app here would not open a thread of `account` (0.25.0+): it is signed out, or
21
+ * signed in to another account, and opening the thread there fails without saying why. It names the account to sign
22
+ * in as and the thread's web link.
23
+ */
24
+ export declare function codexAppMismatchLine(account: ThreadAccount, login: LocalCodexLogin, url: string): string;
25
+ /**
26
+ * After the active Codex account changes (0.25.0+): the Codex app's sign-in here and, when it is not that account, the
27
+ * line that says so (its new Cloud threads open in the app only once it signs in to that account). Null without the app.
28
+ */
29
+ export declare function codexAppAccountNotice(ctx: Ctx, account: {
30
+ email: string | null;
31
+ label: string;
32
+ chatgpt_account_id?: string | null;
33
+ }): Promise<{
34
+ login: LocalCodexLogin;
35
+ line: string | null;
36
+ } | null>;
18
37
  /** How long the account's Codex remote device may take to come up before it counts as stuck. */
19
38
  export declare const CODEX_STARTING_LIMIT_MS: number;
20
39
  export type CodexConnectorView = {
@@ -23,12 +42,18 @@ export type CodexConnectorView = {
23
42
  last_error: string | null;
24
43
  workspace_id?: string;
25
44
  generation?: string;
45
+ reason?: string | null;
26
46
  };
27
47
  /**
28
48
  * The account's Codex remote device, as a person needs to know it: missing or disabled (enable it), starting (wait),
29
- * ready, or failed, stuck (starting for longer than CODEX_STARTING_LIMIT_MS) or attention (retry once).
49
+ * ready, failed, stuck (starting for longer than CODEX_STARTING_LIMIT_MS) or attention (retry once), or sign_in
50
+ * (0.25.0+): Codex Cloud refused the account's ChatGPT sign-in, so it waits for a new one (never restarted).
30
51
  */
31
- export type CodexHealth = 'missing' | 'disabled' | 'starting' | 'ready' | 'failed' | 'stuck' | 'attention';
52
+ export type CodexHealth = 'missing' | 'disabled' | 'starting' | 'ready' | 'failed' | 'stuck' | 'attention' | 'sign_in';
53
+ /** The connector's reason while Codex Cloud refuses the account's ChatGPT sign-in. */
54
+ export declare const CODEX_SIGN_IN_REQUIRED = "codex_sign_in_required";
55
+ /** What every client says when Codex Cloud needs a new ChatGPT sign-in (0.25.0+). */
56
+ export declare const CODEX_SIGN_IN_LINE = "Your ChatGPT sign-in for Codex expired. Sign in again with codex login, then run shard agents credentials sync.";
32
57
  /** When a connector generation started: its UUIDv7 time. */
33
58
  export declare function generationStarted(generation: string | undefined): number | null;
34
59
  export declare function codexHealth(c: CodexConnectorView | null, workspaceState: string | null, now?: number): CodexHealth;
@@ -52,6 +77,7 @@ export declare function ensureCodexCloud(ctx: Ctx): Promise<{
52
77
  environment_id: string | null;
53
78
  last_error: string | null;
54
79
  last_seen_at: string | null;
80
+ reason: string | null;
55
81
  }>;
56
82
  export declare function launchCodexCloud(ctx: Ctx, values: Values, path: string, images?: readonly InitialImage[]): Promise<number>;
57
83
  /** A start whose carrying process said so in the last 45 s. */
@@ -60,3 +86,4 @@ export declare function openCodexConversation(ctx: Ctx, c: Conversation, history
60
86
  /** Continues the same accepted identity, never creates a replacement VM/chat. */
61
87
  export declare function resumeCloudStartup(ctx: Ctx, c: Conversation): Promise<CodexCloudTask>;
62
88
  export declare const CODEX_CLOUD_HANDLERS: Record<string, Handler>;
89
+ export {};
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
+ import { ShardfluxApiError, codexAccountMatches, localCodexLogin } from '@shardflux/sdk';
3
4
  import { CodedError, UsageError } from "./errors.js";
4
5
  import { json } from "./format.js";
5
6
  import { syncAgentCredentials } from "./agent-credentials.js";
@@ -50,20 +51,66 @@ export function cloudChatElsewhere(c, url) {
50
51
  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
52
  `Answer it from here: shard conversations send ${c.id} "<your answer>"\nWhere it stands: shard conversations get ${c.id}\n`;
52
53
  }
54
+ /** An account as a person names it. */
55
+ const who = (a) => a.email ?? a.label ?? 'its Codex account';
56
+ /** The earlier threads of a Cloud task with their accounts (an API before 2026-10-15 lists only their ids). */
57
+ const earlierThreads = (task) => (task.previous_thread_ids ?? []).map(id => ({ thread_id: id, url: threadLink({ thread_id: id }),
58
+ account: task.previous_threads?.find(t => t.thread_id === id)?.account ?? null }));
59
+ /**
60
+ * The line a client shows when the Codex app here would not open a thread of `account` (0.25.0+): it is signed out, or
61
+ * signed in to another account, and opening the thread there fails without saying why. It names the account to sign
62
+ * in as and the thread's web link.
63
+ */
64
+ export function codexAppMismatchLine(account, login, url) {
65
+ const target = who(account), sameEmail = !!login.email && !!account.email && login.email.toLowerCase() === account.email.toLowerCase();
66
+ if (login.signed_in && sameEmail)
67
+ return `This Codex thread belongs to another ChatGPT workspace of ${target} than the Codex app here. Switch the Codex app to that workspace, or open the thread in a browser signed in to it: ${url}`;
68
+ return `This Codex thread belongs to ${target}, ${login.signed_in ? `but the Codex app here is signed in as ${login.email ?? 'another account'}` : 'and the Codex app here is signed out'}. Sign in to the Codex app as ${target}, or open the thread in a browser signed in to that account: ${url}`;
69
+ }
70
+ /** The Codex app's sign-in here and, when it is not the thread's account, what to do: null without the Codex app. */
71
+ async function appAccount(ctx, account, url, app) {
72
+ if (!app)
73
+ return { login: null, mismatch: null };
74
+ const login = await localCodexLogin(ctx.io.env ?? process.env);
75
+ const mismatch = account && codexAccountMatches(account, login) === false ? { line: codexAppMismatchLine(account, login, url), sign_in_as: who(account), web_url: url } : null;
76
+ return { login, mismatch };
77
+ }
78
+ /**
79
+ * After the active Codex account changes (0.25.0+): the Codex app's sign-in here and, when it is not that account, the
80
+ * line that says so (its new Cloud threads open in the app only once it signs in to that account). Null without the app.
81
+ */
82
+ export async function codexAppAccountNotice(ctx, account) {
83
+ if (!codexAppHere(ctx.io.env))
84
+ return null;
85
+ const login = await localCodexLogin(ctx.io.env ?? process.env);
86
+ if (codexAccountMatches(account, login) !== false)
87
+ return { login, line: null };
88
+ const target = who(account), sameEmail = login.signed_in && !!login.email && !!account.email && login.email.toLowerCase() === account.email.toLowerCase();
89
+ const line = sameEmail ? `The Codex app here uses another ChatGPT workspace of ${target}: switch it to this account’s workspace to open its Cloud threads there.`
90
+ : `The Codex app here is ${login.signed_in ? `signed in as ${login.email ?? 'another account'}` : 'signed out'}: sign in to it as ${target} to open this account’s Cloud threads there.`;
91
+ return { login, line };
92
+ }
53
93
  async function output(ctx, c, task, submitted = false, choice) {
54
- const url = threadLink(task), app = codexAppHere(ctx.io.env);
94
+ const url = threadLink(task), app = codexAppHere(ctx.io.env), account = task.account ?? null;
55
95
  // model_choice (0.17.0+): an admitted thread's record keeps the model and effort only when Cloud started it with them.
56
96
  const applied = hasChoice(choice) ? await cloudChoiceApplied(ctx, c, choice) : true;
97
+ // account, codex_app_login and account_mismatch (0.25.0+): the Codex app opens a thread only while signed in to its account.
98
+ const { login, mismatch } = await appAccount(ctx, account, url, app);
57
99
  // codex_app and reply_command (0.23.0+): without the Codex app, the conversation is answered with that command.
58
100
  if (ctx.json) {
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) } : {}) }));
101
+ ctx.io.stdout.write(json({ conversation: c, cloud: task, url, account, previous_threads: earlierThreads(task), codex_app: app, codex_app_login: login, account_mismatch: mismatch, reply_command: ['shard', 'conversations', 'send', c.id, '<message>'], ...(submitted ? { initial_prompt: { submitted: true } } : {}), ...(hasChoice(choice) ? { model_choice: choiceReport(choice, applied) } : {}) }));
60
102
  return;
61
103
  }
62
104
  if (!applied)
63
105
  ctx.io.stderr.write(`${CHOICE_PENDING}\n`);
64
106
  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`);
107
+ for (const t of earlierThreads(task))
108
+ ctx.io.stdout.write(`Earlier history: ${t.url}${t.account ? ` (${who(t.account)})` : ''}\n`);
109
+ // Opening it in the Codex app would fail on another account: say which account opens it instead.
110
+ if (mismatch) {
111
+ ctx.io.stdout.write(`${mismatch.line}\n`);
112
+ return;
113
+ }
67
114
  await openCodexThread(task, ctx.io.env);
68
115
  }
69
116
  async function cloudChoiceApplied(ctx, c, choice) {
@@ -72,6 +119,10 @@ async function cloudChoiceApplied(ctx, c, choice) {
72
119
  }
73
120
  /** How long the account's Codex remote device may take to come up before it counts as stuck. */
74
121
  export const CODEX_STARTING_LIMIT_MS = 5 * 60_000;
122
+ /** The connector's reason while Codex Cloud refuses the account's ChatGPT sign-in. */
123
+ export const CODEX_SIGN_IN_REQUIRED = 'codex_sign_in_required';
124
+ /** What every client says when Codex Cloud needs a new ChatGPT sign-in (0.25.0+). */
125
+ export const CODEX_SIGN_IN_LINE = 'Your ChatGPT sign-in for Codex expired. Sign in again with codex login, then run shard agents credentials sync.';
75
126
  /** When a connector generation started: its UUIDv7 time. */
76
127
  export function generationStarted(generation) {
77
128
  const hex = generation?.replace(/-/g, '').slice(0, 12);
@@ -82,10 +133,13 @@ export function codexHealth(c, workspaceState, now = Date.now()) {
82
133
  return 'missing';
83
134
  if (c.state === 'disabled' || c.state === 'disabling')
84
135
  return 'disabled';
136
+ if (c.reason === CODEX_SIGN_IN_REQUIRED)
137
+ return 'sign_in';
85
138
  if (c.state === 'ready' && c.environment_id)
86
139
  return 'ready';
87
140
  const started = generationStarted(c.generation);
88
- const late = started !== null && now - started > CODEX_STARTING_LIMIT_MS;
141
+ // A device that connected before and starts again (with a new sign-in) is starting however old its generation is.
142
+ const late = started !== null && now - started > CODEX_STARTING_LIMIT_MS && !(c.state === 'starting' && c.environment_id);
89
143
  // A new device reports attention while its executor is still connecting, and the API tries again every 10 s on its
90
144
  // own (staging: about 40 s after enable, ready 9 s later). Until the starting limit that is starting, not Retry.
91
145
  if (c.state === 'attention')
@@ -125,16 +179,36 @@ export async function restartCodexConnector(ctx, timeoutMs = 60_000) {
125
179
  }
126
180
  return await codex.enable({ signal: ctx.signal });
127
181
  }
182
+ const signInRequired = () => new CodedError(CODEX_SIGN_IN_REQUIRED, CODEX_SIGN_IN_LINE, undefined, { reason: CODEX_SIGN_IN_REQUIRED });
128
183
  export async function ensureCodexCloud(ctx) {
129
- await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME ?? ctx.io.env.USERPROFILE, redact: value => ctx.addSecret(value) });
130
- // A remote device that failed or is stuck starting gets one new start; a starting or ready one is left alone.
184
+ const sync = () => syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME ?? ctx.io.env.USERPROFILE, redact: value => ctx.addSecret(value) });
131
185
  const { connector, workspaceState } = await readCodexConnector(ctx);
132
186
  const health = codexHealth(connector, workspaceState);
133
- if (health === 'failed' || health === 'stuck' || health === 'attention')
134
- await restartCodexConnector(ctx);
135
- else
187
+ if (health === 'sign_in') {
188
+ // Codex Cloud refused the copied login (0.25.0+). This computer's login starts it again only if it is a different
189
+ // one; the same login means Codex itself needs a new sign-in first. Never a restart: the device keeps its threads.
190
+ const refused = (await ctx.cloud().agentCredentials.list()).find(row => row.app === 'codex')?.fingerprint ?? null;
191
+ const copied = (await sync()).find(row => row.app === 'codex')?.fingerprint ?? null;
192
+ if (!copied || copied === refused)
193
+ throw signInRequired();
136
194
  await ctx.cloud().codex.enable({ signal: ctx.signal });
137
- return ctx.cloud().codex.waitConnector({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
195
+ }
196
+ else {
197
+ await sync();
198
+ // A remote device that failed or is stuck starting gets one new start; a starting or ready one is left alone.
199
+ if (health === 'failed' || health === 'stuck' || health === 'attention')
200
+ await restartCodexConnector(ctx);
201
+ else
202
+ await ctx.cloud().codex.enable({ signal: ctx.signal });
203
+ }
204
+ try {
205
+ return await ctx.cloud().codex.waitConnector({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
206
+ }
207
+ catch (error) {
208
+ if (error instanceof ShardfluxApiError && error.reason === CODEX_SIGN_IN_REQUIRED)
209
+ throw signInRequired();
210
+ throw error;
211
+ }
138
212
  }
139
213
  export async function launchCodexCloud(ctx, values, path, images = []) {
140
214
  if (values.in !== undefined && values.in !== 'codex-app')
@@ -207,7 +281,7 @@ export async function openCodexConversation(ctx, c, history) {
207
281
  const id = history === true ? ids[0] : history;
208
282
  if (!id || !ids.includes(id))
209
283
  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);
284
+ return output(ctx, c, { ...task, thread_id: id, desktop_url: null, web_url: null, account: task.previous_threads?.find(t => t.thread_id === id)?.account ?? null }, false).then(() => 0);
211
285
  }
212
286
  const local = localConversations(ctx)[c.id];
213
287
  if (local?.cloud_start && local.cloud_start.state !== 'active' && !c.ended_at) {
@@ -1,4 +1,5 @@
1
1
  import type { Ctx, Handler } from './commands.js';
2
+ import type { CellClient, ConversationPermission, ListeningPort, MessageAttachment, PermissionDecision } from '@shardflux/sdk';
2
3
  export declare function conversationClient(ctx: Ctx): import("@shardflux/sdk").Shardflux | import("@shardflux/sdk").ShardfluxAccount;
3
4
  /** Why a conversation needs you, as one line of at most `max` characters; undefined in other states (0.18.0+). */
4
5
  export declare function questionLine(c: {
@@ -9,5 +10,34 @@ export declare function questionLine(c: {
9
10
  * prompt names it; a name someone chose stays (0.21.1+). The menu bar's AgentNaming.isRawIdentifier rule. */
10
11
  export declare function displayTitle(c: {
11
12
  title: string;
13
+ title_short?: string | null;
12
14
  }): string;
15
+ /**
16
+ * `--file <path>` (repeatable, 0.25.0+): files for a message, read whole and checked before anything is sent. Each one
17
+ * keeps its file name in the conversation's computer.
18
+ */
19
+ export declare function readAttachments(paths: readonly string[]): Promise<MessageAttachment[]>;
20
+ /** A permission request's command, path or URL as one line of at most `max` characters: its first line (0.25.0+). */
21
+ export declare function detailLine(detail: string, max?: number): string;
22
+ /** One permission request as a list row (0.25.0+): id, state, title, detail, and the decision once answered. */
23
+ export declare function permissionLine(p: ConversationPermission): string;
24
+ /** `shard conversations answer` words (0.25.0+): allow, always and deny, or the API's own decision names. */
25
+ export declare const PERMISSION_DECISIONS: Readonly<Record<string, PermissionDecision>>;
26
+ /** --pick values as a question's answers (0.25.0+): "<question>=<option>" names the question; a bare option answers
27
+ * the request's only question (repeated, a multi-select one's several options). `shard needs-you answer` passes the
28
+ * questions of a board item. */
29
+ export declare function pickAnswers(p: Pick<ConversationPermission, 'questions'>, picks: readonly string[]): Record<string, string | string[]>;
30
+ /** A listening port as `conversations get` prints it: the port, its program, then its URL once exposed, else where it
31
+ * listens ("5173 node https://… (exposed)", "5432 postgres 127.0.0.1 only"). */
32
+ export declare function portLine(p: ListeningPort): string;
33
+ /** How much of the terminal's past `conversations watch` shows before the live output: its last 256 KiB. */
34
+ export declare const WATCH_TAIL_BYTES: number;
35
+ /**
36
+ * Streams a conversation's terminal read-only from its last WATCH_TAIL_BYTES until its session exits or ctx.signal
37
+ * aborts (Ctrl-C), writing the terminal's bytes to stdout as they arrive. Nothing is ever typed into it, and the attach is
38
+ * passive: it never wakes the computer or keeps it awake while the terminal is idle (reading the terminal's size first
39
+ * wakes a sleeping computer, as every cell call does unless --no-wake). A host without passive attach gets an ordinary
40
+ * read-only attach next, from the same byte.
41
+ */
42
+ export declare function watchTerminal(ctx: Ctx, cell: CellClient, ptyId: string): Promise<number>;
13
43
  export declare const CONVERSATION_HANDLERS: Record<string, Handler>;
@@ -1,11 +1,38 @@
1
- import { out, str } from "./commands.js";
2
- import { UsageError } from "./errors.js";
1
+ import { randomUUID } from 'node:crypto';
2
+ import { open } from 'node:fs/promises';
3
+ import { basename } from 'node:path';
4
+ import { out, str, strs } from "./commands.js";
5
+ import { CodedError, UsageError } from "./errors.js";
6
+ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENTS, PtyConnection, planAttachments } from '@shardflux/sdk';
3
7
  import { FilesMirror, humanBytes, listMirrorConversations, workspaceHub } from "./files-mirror.js";
8
+ import { modelChoice } from "./agent-models.js";
9
+ import { storedTitlePrefix } from "./client-settings.js";
10
+ import { PtyOutputCursor, passiveUnavailable, sleepingAttach } from "./agent-terminal.js";
11
+ import { progress } from "./progress.js";
12
+ import { UUID } from "./args.js";
13
+ import { findOrg } from "./account-scope.js";
4
14
  export function conversationClient(ctx) {
5
15
  const key = Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim()) || ctx.store.profile(ctx.apiUrl).api_key !== undefined;
6
16
  const session = Boolean(ctx.io.env.SHARDFLUX_SESSION_TOKEN?.trim()) || ctx.store.profile(ctx.apiUrl).session !== undefined;
7
17
  return key || !session ? ctx.cloud() : ctx.account();
8
18
  }
19
+ /**
20
+ * The client and organization of `conversations list` and `feed` (0.25.0+). Without --org: the API key's project, or
21
+ * signed in without a key every project the person works in. --org acts as the signed-in person when there is a sign-in
22
+ * (an organization id, or a slug looked up among theirs); with only an API key it is the key's own organization.
23
+ */
24
+ async function listScope(ctx, values) {
25
+ const org = str(values.org);
26
+ if (org === undefined)
27
+ return { client: conversationClient(ctx) };
28
+ if (ctx.sessionSource() !== null) {
29
+ const account = ctx.account();
30
+ return { client: account, organizationId: UUID.test(org) ? org : (await findOrg(account, org)).id };
31
+ }
32
+ if (!UUID.test(org))
33
+ throw new UsageError(`--org with an API key takes the organization id (got "${org}").`, 'or sign in to name organizations by slug: shard auth login');
34
+ return { client: ctx.cloud(), organizationId: org };
35
+ }
9
36
  /** Why a conversation needs you, as one line of at most `max` characters; undefined in other states (0.18.0+). */
10
37
  export function questionLine(c, max = 100) {
11
38
  const text = c.state === 'needs_you' ? c.question?.replace(/\s+/g, ' ').trim() : undefined;
@@ -13,7 +40,156 @@ export function questionLine(c, max = 100) {
13
40
  }
14
41
  /** A conversation's title as people read it: "New conversation" while it is still named by its generated id, before a
15
42
  * prompt names it; a name someone chose stays (0.21.1+). The menu bar's AgentNaming.isRawIdentifier rule. */
16
- export function displayTitle(c) { return !c.title || /^(conv[-_])?[a-z0-9]{26}$/.test(c.title) ? 'New conversation' : c.title; }
43
+ export function displayTitle(c) {
44
+ // The agent's own short name for it, once it has one (APIs from 0221; CLI 0.25.0+).
45
+ if (c.title_short?.trim())
46
+ return c.title_short.trim();
47
+ return !c.title || /^(conv[-_])?[a-z0-9]{26}$/.test(c.title) ? 'New conversation' : c.title;
48
+ }
49
+ /**
50
+ * `--file <path>` (repeatable, 0.25.0+): files for a message, read whole and checked before anything is sent. Each one
51
+ * keeps its file name in the conversation's computer.
52
+ */
53
+ export async function readAttachments(paths) {
54
+ if (paths.length > MAX_ATTACHMENTS)
55
+ throw new UsageError(`Attach at most ${MAX_ATTACHMENTS} files to a message.`);
56
+ const files = [];
57
+ for (const path of paths) {
58
+ const handle = await open(path, 'r').catch((e) => { throw new UsageError(`--file: cannot read "${path}" (${e.code ?? e.message})`); });
59
+ try {
60
+ const info = await handle.stat();
61
+ if (!info.isFile())
62
+ throw new UsageError(`--file: "${path}" is not a file.`);
63
+ if (info.size > MAX_ATTACHMENT_BYTES)
64
+ throw new UsageError(`--file: "${path}" is larger than ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MiB.`);
65
+ files.push({ name: basename(path), data: await handle.readFile() });
66
+ }
67
+ finally {
68
+ await handle.close();
69
+ }
70
+ }
71
+ return files;
72
+ }
73
+ /** The SDK's checks of a message with files (names, sizes, the text with its list), as usage errors before any request. */
74
+ function checkMessage(text, files) {
75
+ try {
76
+ planAttachments(`conv_${'a'.repeat(26)}`, randomUUID(), text, files);
77
+ }
78
+ catch (e) {
79
+ if (e instanceof TypeError || e instanceof RangeError)
80
+ throw new UsageError(e.message);
81
+ throw e;
82
+ }
83
+ }
84
+ const sentLines = (r) => `${r.restored ? 'Restored\n' : ''}${(r.files ?? []).map(f => `Attached ${f.path}\n`).join('')}Message sent (${r.seq}).\n`;
85
+ /** A permission request's command, path or URL as one line of at most `max` characters: its first line (0.25.0+). */
86
+ export function detailLine(detail, max = 80) {
87
+ const line = detail.split('\n').find(l => l.trim())?.replace(/\s+/g, ' ').trim() ?? '';
88
+ return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
89
+ }
90
+ /** One permission request as a list row (0.25.0+): id, state, title, detail, and the decision once answered. */
91
+ export function permissionLine(p) { return `${p.id}\t${p.state}\t${p.title}\t${detailLine(p.detail)}${p.decision ? `\t${p.decision}` : ''}`; }
92
+ /** `shard conversations answer` words (0.25.0+): allow, always and deny, or the API's own decision names. */
93
+ export const PERMISSION_DECISIONS = { allow: 'allow_once', allow_once: 'allow_once', always: 'allow_always', allow_always: 'allow_always', deny: 'deny' };
94
+ const ANSWERED = { allow_once: 'Allowed once', allow_always: 'Always allowed', deny: 'Denied' };
95
+ const PERMISSION_STATES = ['pending', 'answered', 'canceled'];
96
+ /** --pick values as a question's answers (0.25.0+): "<question>=<option>" names the question; a bare option answers
97
+ * the request's only question (repeated, a multi-select one's several options). `shard needs-you answer` passes the
98
+ * questions of a board item. */
99
+ export function pickAnswers(p, picks) {
100
+ const questions = p.questions ?? [];
101
+ if (!questions.length)
102
+ throw new UsageError('This request is not a question: answer it with allow, always or deny.');
103
+ const answers = {};
104
+ for (const pick of picks) {
105
+ const named = questions.find(q => pick.startsWith(`${q.question}=`));
106
+ const q = named ?? (questions.length === 1 ? questions[0] : undefined);
107
+ if (!q)
108
+ throw new UsageError(`This request asks ${questions.length} questions: --pick "<question>=<option>", e.g. --pick "${questions[0].question}=${questions[0].options[0]?.label ?? '…'}".`);
109
+ const label = named ? pick.slice(q.question.length + 1) : pick;
110
+ if (!label.trim())
111
+ throw new UsageError(`--pick needs an option for "${q.question}".`);
112
+ (answers[q.question] ??= []).push(label);
113
+ }
114
+ for (const q of questions)
115
+ if (!answers[q.question])
116
+ throw new UsageError(`Pick an answer for "${q.question}" too.`);
117
+ return Object.fromEntries(Object.entries(answers).map(([k, v]) => [k, v.length === 1 ? v[0] : v]));
118
+ }
119
+ /** A listening port as `conversations get` prints it: the port, its program, then its URL once exposed, else where it
120
+ * listens ("5173 node https://… (exposed)", "5432 postgres 127.0.0.1 only"). */
121
+ export function portLine(p) {
122
+ const loopback = /^127\./.test(p.address) || p.address === '::1' || p.address === 'localhost';
123
+ const where = p.exposed ? `${p.url ?? p.address} (exposed)` : loopback ? `${p.address} only` : `${p.address} (not exposed)`;
124
+ return [String(p.port), p.process, where].filter(Boolean).join(' ');
125
+ }
126
+ /** How much of the terminal's past `conversations watch` shows before the live output: its last 256 KiB. */
127
+ export const WATCH_TAIL_BYTES = 256 * 1024;
128
+ /** Why a conversation has no terminal to watch (its `pty_id` is null), with the command that shows its output. */
129
+ function noTerminal(c) {
130
+ if (c.route === 'codex-cloud')
131
+ return `Codex Cloud runs this conversation, so its output is in its Codex thread: open it with shard agents open ${c.id}.`;
132
+ if (c.route !== 'terminal')
133
+ return `This conversation runs without a terminal (route ${c.route}): its app shows its output, and shard conversations get ${c.id} shows its state.`;
134
+ if (c.ended_at)
135
+ return `This conversation has finished: shard conversations get ${c.id} shows its result, and shard conversations resume ${c.id} continues it.`;
136
+ return `This conversation's terminal is starting: shard conversations get ${c.id} shows its pty_id once it runs.`;
137
+ }
138
+ /**
139
+ * Streams a conversation's terminal read-only from its last WATCH_TAIL_BYTES until its session exits or ctx.signal
140
+ * aborts (Ctrl-C), writing the terminal's bytes to stdout as they arrive. Nothing is ever typed into it, and the attach is
141
+ * passive: it never wakes the computer or keeps it awake while the terminal is idle (reading the terminal's size first
142
+ * wakes a sleeping computer, as every cell call does unless --no-wake). A host without passive attach gets an ordinary
143
+ * read-only attach next, from the same byte.
144
+ */
145
+ export async function watchTerminal(ctx, cell, ptyId) {
146
+ const session = await cell.pty.get(ptyId);
147
+ const cursor = new PtyOutputCursor();
148
+ cursor.offset = Math.max(0, session.output_size - WATCH_TAIL_BYTES);
149
+ let passive = true, wrote = false;
150
+ try {
151
+ for (;;) {
152
+ if (ctx.signal.aborted)
153
+ return 0;
154
+ let connection;
155
+ try {
156
+ connection = await PtyConnection.attach(cell, ptyId, cursor.offset, ctx.signal, { readOnly: true, ...(passive ? { passive: true } : {}) });
157
+ for await (const m of connection.messages()) {
158
+ const bytes = cursor.consume(m);
159
+ if (bytes?.length) {
160
+ ctx.io.stdout.write(bytes);
161
+ wrote = true;
162
+ }
163
+ if (m.type === 'exit')
164
+ return 0;
165
+ }
166
+ return 0;
167
+ }
168
+ catch (error) {
169
+ if (ctx.signal.aborted)
170
+ return 0;
171
+ if (passive && passiveUnavailable(error)) {
172
+ passive = false;
173
+ continue;
174
+ }
175
+ // The computer parked under the watch: the agent is idle, and watching never wakes it.
176
+ if (sleepingAttach(error)) {
177
+ progress(ctx, `${wrote ? '\r\n' : ''}The conversation's computer is asleep; watching ended.`);
178
+ return 0;
179
+ }
180
+ throw error;
181
+ }
182
+ finally {
183
+ await connection?.close();
184
+ }
185
+ }
186
+ }
187
+ finally {
188
+ // The terminal's own modes (colours, a hidden cursor) end with the watch.
189
+ if (wrote && ctx.io.stdout.isTTY)
190
+ ctx.io.stdout.write('\x1b[0m\x1b[?25h\r\n');
191
+ }
192
+ }
17
193
  export const CONVERSATION_HANDLERS = {
18
194
  async 'conversations resume'(ctx, _values, [id]) { const r = await conversationClient(ctx).conversations.resume(id, { signal: ctx.signal }); out(ctx, `${displayTitle(r.conversation)}: resumed.\n`, r); return 0; },
19
195
  async 'conversations reader'(ctx, _values, [id]) { const r = await conversationClient(ctx).conversations.spaceReader(id, { signal: ctx.signal }); out(ctx, `${r.workspace.workspace_key}\n`, r); return 0; },
@@ -23,8 +199,12 @@ export const CONVERSATION_HANDLERS = {
23
199
  let cursor = str(values.cursor), next;
24
200
  if (cursor)
25
201
  seen.add(cursor);
202
+ const updatedAfter = str(values['updated-after']);
203
+ if (updatedAfter !== undefined && Number.isNaN(Date.parse(updatedAfter)))
204
+ throw new UsageError('--updated-after must be an RFC 3339 time, such as 2026-10-15T09:00:00Z.');
205
+ const { client, organizationId } = await listScope(ctx, values);
26
206
  do {
27
- const page = await conversationClient(ctx).conversations.list({ projectId: str(values.project), state, parentId: str(values.parent), cursor, limit: values.limit ? Number(values.limit) : values.all ? 200 : undefined });
207
+ const page = await client.conversations.list({ projectId: str(values.project), organizationId, state, parentId: str(values.parent), updatedAfter, cursor, limit: values.limit ? Number(values.limit) : values.all ? 200 : undefined });
28
208
  data.push(...page.data);
29
209
  next = page.nextCursor;
30
210
  cursor = next ?? undefined;
@@ -35,14 +215,137 @@ export const CONVERSATION_HANDLERS = {
35
215
  }
36
216
  } while (values.all && cursor);
37
217
  // A conversation that needs you adds a fifth column: why (0.18.0+).
38
- out(ctx, data.map(c => { const q = questionLine(c); return `${c.id}\t${displayTitle(c)}\t${c.state}\t${c.branch}${q ? `\t${q}` : ''}`; }).join('\n') + '\n', { data, nextCursor: next });
218
+ out(ctx, data.map(c => { const q = questionLine(c); return `${c.id}\t${displayTitle(c)}\t${c.state}\t${c.home ? 'home' : c.branch}${q ? `\t${q}` : ''}`; }).join('\n') + '\n', { data, nextCursor: next });
219
+ return 0;
220
+ },
221
+ // Each change on its own line as it happens, until interrupted: "<id>\t<title>\t<state>[\t<why>]", or with --json one
222
+ // object per line, {conversation, cursor} (the cursor resumes after it with --cursor). 0.25.0+.
223
+ async 'conversations feed'(ctx, values) {
224
+ const { client, organizationId } = await listScope(ctx, values);
225
+ let cursor = str(values.cursor);
226
+ for await (const c of client.conversations.watch({ projectId: str(values.project), organizationId, cursor, signal: ctx.signal, onCursor: next => { cursor = next; } })) {
227
+ const q = questionLine(c);
228
+ ctx.io.stdout.write(ctx.json ? JSON.stringify({ conversation: c, cursor }) + '\n' : `${c.id}\t${displayTitle(c)}\t${c.state}${q ? `\t${q}` : ''}\n`);
229
+ }
230
+ return 0;
231
+ },
232
+ async 'conversations get'(ctx, _values, [id]) {
233
+ const client = conversationClient(ctx), c = await client.conversations.get(id);
234
+ const q = questionLine(c, 200);
235
+ // needs_you with no question: its agent waits on permission requests; text output names them (0.25.0+). JSON stays the
236
+ // conversation. The lookup only adds to what get shows, so an API without permission requests still shows it.
237
+ const waits = !ctx.json && c.state === 'needs_you' && !q ? await client.conversations.permissions(c.id, { state: 'pending' }).then(p => p.data, () => []) : [];
238
+ // The ports processes listen on in its computer follow, one line each, once its guest reported them.
239
+ const ports = (c.listening_ports ?? []).map(p => `${portLine(p)}\n`).join('');
240
+ out(ctx, `${displayTitle(c)}\n${c.state}\n${c.home ? 'home' : c.branch}\n${q ? `${q}\n` : ''}${waits.map(p => `Waiting on: ${p.title}\n`).join('')}${ports}`, c);
241
+ return 0;
242
+ },
243
+ // Permission requests (0.25.0+): what the conversation's agent asked before a tool ran, oldest first; every state
244
+ // unless --state, --all reads every page as conversations list does.
245
+ async 'conversations permissions'(ctx, values, [id]) {
246
+ const state = str(values.state);
247
+ if (state !== undefined && !PERMISSION_STATES.includes(state))
248
+ throw new UsageError('--state must be pending, answered or canceled.');
249
+ const client = conversationClient(ctx), data = [], seen = new Set();
250
+ let cursor = str(values.cursor), next;
251
+ if (cursor)
252
+ seen.add(cursor);
253
+ do {
254
+ const page = await client.conversations.permissions(id, { state, cursor, limit: values.limit ? Number(values.limit) : values.all ? 200 : undefined });
255
+ data.push(...page.data);
256
+ next = page.nextCursor;
257
+ cursor = next ?? undefined;
258
+ if (values.all && cursor) {
259
+ if (seen.has(cursor) || seen.size >= 100)
260
+ throw new UsageError('The permission request list repeated its cursor. Try again.');
261
+ seen.add(cursor);
262
+ }
263
+ } while (values.all && cursor);
264
+ out(ctx, data.map(p => `${permissionLine(p)}\n`).join(''), { data, nextCursor: next });
39
265
  return 0;
40
266
  },
41
- async 'conversations get'(ctx, _values, [id]) { const c = await conversationClient(ctx).conversations.get(id); const q = questionLine(c, 200); out(ctx, `${displayTitle(c)}\n${c.state}\n${c.branch}\n${q ? `${q}\n` : ''}`, c); return 0; },
267
+ async 'conversations answer'(ctx, values, [id, permissionId, word]) {
268
+ const picks = strs(values.pick), client = conversationClient(ctx);
269
+ // The agent's question (0.25.0+): --pick names the answer, which the agent gets exactly as picked.
270
+ if (picks.length) {
271
+ if (word !== undefined)
272
+ throw new UsageError('Answer a question with --pick, or decline it with deny, not both.');
273
+ const p = await client.conversations.permission(id, permissionId), answers = pickAnswers(p, picks);
274
+ const r = await client.conversations.answerQuestion(id, permissionId, answers, { signal: ctx.signal });
275
+ out(ctx, `Answered: ${Object.values(r.permission.answers ?? answers).map(a => Array.isArray(a) ? a.join(', ') : a).join('; ')}\n`, r);
276
+ return 0;
277
+ }
278
+ const decision = word === undefined ? undefined : PERMISSION_DECISIONS[word.toLowerCase().replace(/-/g, '_')];
279
+ if (!decision)
280
+ throw new UsageError('Choose allow, always or deny (a question takes --pick <option>).');
281
+ const r = await client.conversations.answerPermission(id, permissionId, decision, { signal: ctx.signal });
282
+ out(ctx, `${ANSWERED[r.permission.decision ?? decision]}: ${r.permission.title}\n`, r);
283
+ return 0;
284
+ },
285
+ // Its terminal, live and read-only, from the conversation's pty_id; never typed into, never keeping the computer awake.
286
+ async 'conversations watch'(ctx, _values, [id]) {
287
+ if (ctx.json)
288
+ throw new UsageError(`conversations watch streams the terminal itself, so it has no --json form; shard agents logs ${id} --json returns its recent output.`);
289
+ const client = conversationClient(ctx), c = await client.conversations.get(id);
290
+ if (!c.pty_id)
291
+ throw new UsageError(noTerminal(c));
292
+ const ws = await client.workspaces.get(c.workspace_id);
293
+ if (ws.data.deleted_at)
294
+ throw new UsageError(`This conversation is saved: shard conversations resume ${c.id} continues it in a fresh VM.`);
295
+ const cell = ws.cell({ agentLabel: ctx.agentLabel, ...(ctx.wake ? {} : { wake: null }), transitionTimeoutMs: ctx.wakeTimeoutMs });
296
+ try {
297
+ progress(ctx, `Watching ${displayTitle(c)}, read-only. Ctrl-C stops.`);
298
+ return await watchTerminal(ctx, cell, c.pty_id);
299
+ }
300
+ finally {
301
+ cell.close();
302
+ }
303
+ },
42
304
  // A finished conversation whose VM was removed restores into a fresh one, then takes the message (APIs that do; 0.18.1+).
43
- async 'conversations send'(ctx, _values, [id, text]) {
44
- const r = await conversationClient(ctx).conversations.send(id, text, { signal: ctx.signal });
45
- out(ctx, `${r.restored ? 'Restored\n' : ''}Message sent (${r.seq}).\n`, r);
305
+ // --file (0.25.0+): the files land in the conversation's computer before the message, which lists their paths.
306
+ async 'conversations send'(ctx, values, [id, ...words]) {
307
+ const files = await readAttachments(strs(values.file)), text = words.length ? words.join(' ') : undefined;
308
+ if (text === undefined && !files.length)
309
+ throw new UsageError('missing <text> for "shard conversations send"');
310
+ if (files.length)
311
+ checkMessage(text ?? '', files);
312
+ const r = await conversationClient(ctx).conversations.send(id, text ?? '', { signal: ctx.signal, ...(files.length ? { files } : {}) });
313
+ out(ctx, sentLines(r), r);
314
+ return 0;
315
+ },
316
+ /**
317
+ * A conversation started without a folder (0.25.0+): on the machine's home (--home), or in one of the project's
318
+ * repositories from the project hub. With --prompt its first message follows, with any --file.
319
+ */
320
+ async 'conversations start'(ctx, values) {
321
+ const home = values.home === true, repo = str(values.repo), branch = str(values.branch), prompt = str(values.prompt), title = str(values.title);
322
+ if (home && (repo !== undefined || branch !== undefined))
323
+ throw new UsageError('--home starts on your machine without a repository: leave out --repo and --branch.');
324
+ const app = str(values.app) ?? 'claude';
325
+ if (app !== 'claude' && app !== 'codex')
326
+ throw new UsageError('Choose --app claude or codex.');
327
+ const choice = modelChoice(values, app);
328
+ if (prompt !== undefined && !prompt.trim())
329
+ throw new UsageError('--prompt needs text.');
330
+ const files = await readAttachments(strs(values.file));
331
+ if (files.length && prompt === undefined)
332
+ throw new UsageError('--file goes with the first message: add --prompt.');
333
+ if (prompt !== undefined)
334
+ checkMessage(prompt, files);
335
+ const projectId = str(values.project) ?? ctx.store.profile(ctx.apiUrl).project_id, titlePrefix = storedTitlePrefix(ctx.store.dir);
336
+ const client = conversationClient(ctx);
337
+ const created = await client.conversations.create({ ...(projectId ? { projectId } : {}), harness: app, route: 'terminal', mode: 'new', ...(home ? { home: true } : {}),
338
+ ...(repo !== undefined ? { repoRemote: repo } : {}), ...(branch !== undefined ? { base: { branch } } : {}), ...(title !== undefined ? { title } : {}), ...choice, ...(titlePrefix !== undefined ? { titlePrefix } : {}) }, { idempotencyKey: randomUUID(), signal: ctx.signal });
339
+ const c = created.conversation;
340
+ let message;
341
+ try {
342
+ if (prompt !== undefined)
343
+ message = await client.conversations.send(c.id, prompt, { idempotencyKey: randomUUID(), signal: ctx.signal, ...(files.length ? { files } : {}) });
344
+ }
345
+ catch (e) {
346
+ throw new CodedError('initial_prompt_failed', `Started ${c.key} (${c.id}), but its first message was not sent: ${e instanceof Error ? e.message : String(e)}`, `shard conversations send ${c.id} "<message>"`, { conversation_id: c.id, workspace_key: c.key });
347
+ }
348
+ out(ctx, `${c.id}\t${c.key}\t${c.state}\n${message ? sentLines(message) : ''}`, { ...created, ...(message ? { message } : {}) });
46
349
  return 0;
47
350
  },
48
351
  async 'conversations wait'(ctx, values, [id]) {