@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.
- package/CHANGELOG.md +119 -0
- package/README.md +227 -1
- package/dist/account-args.js +5 -0
- package/dist/account-pool.js +5 -1
- package/dist/acp-permission-elsewhere.d.ts +38 -0
- package/dist/acp-permission-elsewhere.js +85 -0
- package/dist/acp-rpc.d.ts +2 -1
- package/dist/acp-rpc.js +11 -2
- package/dist/acp.js +95 -22
- package/dist/agent-credentials.d.ts +3 -0
- package/dist/agent-credentials.js +7 -0
- package/dist/args.d.ts +1 -1
- package/dist/args.js +143 -8
- package/dist/board-args.d.ts +6 -0
- package/dist/board-args.js +155 -0
- package/dist/board-commands.d.ts +62 -0
- package/dist/board-commands.js +395 -0
- package/dist/codex-cloud.d.ts +30 -3
- package/dist/codex-cloud.js +86 -12
- package/dist/conversation-commands.d.ts +30 -0
- package/dist/conversation-commands.js +312 -9
- package/dist/conversation-transcript.d.ts +44 -0
- package/dist/conversation-transcript.js +206 -0
- package/dist/conversations.d.ts +5 -2
- package/dist/conversations.js +20 -10
- package/dist/device-approvals.js +4 -3
- package/dist/device-bridge.d.ts +42 -1
- package/dist/device-bridge.js +223 -12
- package/dist/device-folders.d.ts +58 -0
- package/dist/device-folders.js +228 -0
- package/dist/device-helper.d.ts +6 -0
- package/dist/device-helper.js +87 -14
- package/dist/drive-commands.d.ts +25 -0
- package/dist/drive-commands.js +201 -0
- package/dist/handlers.js +6 -1
- package/dist/main.js +3 -0
- package/dist/move-files.d.ts +39 -0
- package/dist/move-files.js +167 -0
- package/dist/move.js +12 -11
- package/dist/onboarding.d.ts +6 -2
- package/dist/onboarding.js +24 -6
- package/dist/project-git.d.ts +7 -1
- package/dist/project-git.js +16 -3
- package/dist/project-preparation.d.ts +7 -0
- package/dist/project-preparation.js +14 -0
- package/dist/project-template-setup.js +32 -11
- package/dist/router-commands.d.ts +22 -0
- package/dist/router-commands.js +179 -0
- package/package.json +2 -2
package/dist/codex-cloud.d.ts
CHANGED
|
@@ -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,
|
|
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 {};
|
package/dist/codex-cloud.js
CHANGED
|
@@ -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
|
|
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
|
|
66
|
-
ctx.io.stdout.write(`Earlier history:
|
|
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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) {
|
|
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
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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]) {
|