@shardflux/cli 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +263 -1
  3. package/dist/account-args.js +5 -0
  4. package/dist/account-pool.js +5 -1
  5. package/dist/acp-journal.js +23 -1
  6. package/dist/acp-permission-elsewhere.d.ts +38 -0
  7. package/dist/acp-permission-elsewhere.js +85 -0
  8. package/dist/acp-rpc.d.ts +2 -1
  9. package/dist/acp-rpc.js +11 -2
  10. package/dist/acp.js +95 -22
  11. package/dist/agent-credentials.d.ts +3 -0
  12. package/dist/agent-credentials.js +7 -0
  13. package/dist/args.d.ts +1 -1
  14. package/dist/args.js +172 -9
  15. package/dist/board-args.d.ts +6 -0
  16. package/dist/board-args.js +172 -0
  17. package/dist/board-commands.d.ts +62 -0
  18. package/dist/board-commands.js +412 -0
  19. package/dist/codex-cloud.d.ts +30 -3
  20. package/dist/codex-cloud.js +86 -12
  21. package/dist/conversation-commands.d.ts +30 -0
  22. package/dist/conversation-commands.js +328 -9
  23. package/dist/conversation-transcript.d.ts +44 -0
  24. package/dist/conversation-transcript.js +206 -0
  25. package/dist/conversations.d.ts +7 -3
  26. package/dist/conversations.js +20 -10
  27. package/dist/device-approvals.js +4 -3
  28. package/dist/device-bridge.d.ts +42 -1
  29. package/dist/device-bridge.js +223 -12
  30. package/dist/device-folders.d.ts +66 -0
  31. package/dist/device-folders.js +236 -0
  32. package/dist/device-helper.d.ts +6 -0
  33. package/dist/device-helper.js +98 -14
  34. package/dist/drive-commands.d.ts +25 -0
  35. package/dist/drive-commands.js +201 -0
  36. package/dist/errors.js +2 -1
  37. package/dist/format.js +6 -2
  38. package/dist/handlers.js +7 -1
  39. package/dist/land.d.ts +116 -0
  40. package/dist/land.js +238 -0
  41. package/dist/main.js +10 -1
  42. package/dist/move-files.d.ts +39 -0
  43. package/dist/move-files.js +167 -0
  44. package/dist/move.js +12 -11
  45. package/dist/onboarding.d.ts +10 -6
  46. package/dist/onboarding.js +24 -6
  47. package/dist/project-git.d.ts +7 -1
  48. package/dist/project-git.js +16 -3
  49. package/dist/project-preparation.d.ts +7 -0
  50. package/dist/project-preparation.js +14 -0
  51. package/dist/project-template-setup.js +32 -11
  52. package/dist/router-commands.d.ts +33 -0
  53. package/dist/router-commands.js +179 -0
  54. package/dist/task-land-hub.py +150 -0
  55. package/dist/transcribe-commands.d.ts +7 -0
  56. package/dist/transcribe-commands.js +80 -0
  57. package/dist/worktree-twin-engine.d.ts +8 -0
  58. package/dist/worktree-twin-engine.js +28 -22
  59. package/package.json +2 -2
@@ -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,39 @@
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 { landConversation, landDeps, landedText } from "./land.js";
13
+ import { UUID } from "./args.js";
14
+ import { findOrg } from "./account-scope.js";
4
15
  export function conversationClient(ctx) {
5
16
  const key = Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim()) || ctx.store.profile(ctx.apiUrl).api_key !== undefined;
6
17
  const session = Boolean(ctx.io.env.SHARDFLUX_SESSION_TOKEN?.trim()) || ctx.store.profile(ctx.apiUrl).session !== undefined;
7
18
  return key || !session ? ctx.cloud() : ctx.account();
8
19
  }
20
+ /**
21
+ * The client and organization of `conversations list` and `feed` (0.25.0+). Without --org: the API key's project, or
22
+ * signed in without a key every project the person works in. --org acts as the signed-in person when there is a sign-in
23
+ * (an organization id, or a slug looked up among theirs); with only an API key it is the key's own organization.
24
+ */
25
+ async function listScope(ctx, values) {
26
+ const org = str(values.org);
27
+ if (org === undefined)
28
+ return { client: conversationClient(ctx) };
29
+ if (ctx.sessionSource() !== null) {
30
+ const account = ctx.account();
31
+ return { client: account, organizationId: UUID.test(org) ? org : (await findOrg(account, org)).id };
32
+ }
33
+ if (!UUID.test(org))
34
+ 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');
35
+ return { client: ctx.cloud(), organizationId: org };
36
+ }
9
37
  /** Why a conversation needs you, as one line of at most `max` characters; undefined in other states (0.18.0+). */
10
38
  export function questionLine(c, max = 100) {
11
39
  const text = c.state === 'needs_you' ? c.question?.replace(/\s+/g, ' ').trim() : undefined;
@@ -13,7 +41,156 @@ export function questionLine(c, max = 100) {
13
41
  }
14
42
  /** A conversation's title as people read it: "New conversation" while it is still named by its generated id, before a
15
43
  * 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; }
44
+ export function displayTitle(c) {
45
+ // The agent's own short name for it, once it has one (APIs from 0221; CLI 0.25.0+).
46
+ if (c.title_short?.trim())
47
+ return c.title_short.trim();
48
+ return !c.title || /^(conv[-_])?[a-z0-9]{26}$/.test(c.title) ? 'New conversation' : c.title;
49
+ }
50
+ /**
51
+ * `--file <path>` (repeatable, 0.25.0+): files for a message, read whole and checked before anything is sent. Each one
52
+ * keeps its file name in the conversation's computer.
53
+ */
54
+ export async function readAttachments(paths) {
55
+ if (paths.length > MAX_ATTACHMENTS)
56
+ throw new UsageError(`Attach at most ${MAX_ATTACHMENTS} files to a message.`);
57
+ const files = [];
58
+ for (const path of paths) {
59
+ const handle = await open(path, 'r').catch((e) => { throw new UsageError(`--file: cannot read "${path}" (${e.code ?? e.message})`); });
60
+ try {
61
+ const info = await handle.stat();
62
+ if (!info.isFile())
63
+ throw new UsageError(`--file: "${path}" is not a file.`);
64
+ if (info.size > MAX_ATTACHMENT_BYTES)
65
+ throw new UsageError(`--file: "${path}" is larger than ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MiB.`);
66
+ files.push({ name: basename(path), data: await handle.readFile() });
67
+ }
68
+ finally {
69
+ await handle.close();
70
+ }
71
+ }
72
+ return files;
73
+ }
74
+ /** The SDK's checks of a message with files (names, sizes, the text with its list), as usage errors before any request. */
75
+ function checkMessage(text, files) {
76
+ try {
77
+ planAttachments(`conv_${'a'.repeat(26)}`, randomUUID(), text, files);
78
+ }
79
+ catch (e) {
80
+ if (e instanceof TypeError || e instanceof RangeError)
81
+ throw new UsageError(e.message);
82
+ throw e;
83
+ }
84
+ }
85
+ const sentLines = (r) => `${r.restored ? 'Restored\n' : ''}${(r.files ?? []).map(f => `Attached ${f.path}\n`).join('')}Message sent (${r.seq}).\n`;
86
+ /** A permission request's command, path or URL as one line of at most `max` characters: its first line (0.25.0+). */
87
+ export function detailLine(detail, max = 80) {
88
+ const line = detail.split('\n').find(l => l.trim())?.replace(/\s+/g, ' ').trim() ?? '';
89
+ return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
90
+ }
91
+ /** One permission request as a list row (0.25.0+): id, state, title, detail, and the decision once answered. */
92
+ export function permissionLine(p) { return `${p.id}\t${p.state}\t${p.title}\t${detailLine(p.detail)}${p.decision ? `\t${p.decision}` : ''}`; }
93
+ /** `shard conversations answer` words (0.25.0+): allow, always and deny, or the API's own decision names. */
94
+ export const PERMISSION_DECISIONS = { allow: 'allow_once', allow_once: 'allow_once', always: 'allow_always', allow_always: 'allow_always', deny: 'deny' };
95
+ const ANSWERED = { allow_once: 'Allowed once', allow_always: 'Always allowed', deny: 'Denied' };
96
+ const PERMISSION_STATES = ['pending', 'answered', 'canceled'];
97
+ /** --pick values as a question's answers (0.25.0+): "<question>=<option>" names the question; a bare option answers
98
+ * the request's only question (repeated, a multi-select one's several options). `shard needs-you answer` passes the
99
+ * questions of a board item. */
100
+ export function pickAnswers(p, picks) {
101
+ const questions = p.questions ?? [];
102
+ if (!questions.length)
103
+ throw new UsageError('This request is not a question: answer it with allow, always or deny.');
104
+ const answers = {};
105
+ for (const pick of picks) {
106
+ const named = questions.find(q => pick.startsWith(`${q.question}=`));
107
+ const q = named ?? (questions.length === 1 ? questions[0] : undefined);
108
+ if (!q)
109
+ throw new UsageError(`This request asks ${questions.length} questions: --pick "<question>=<option>", e.g. --pick "${questions[0].question}=${questions[0].options[0]?.label ?? '…'}".`);
110
+ const label = named ? pick.slice(q.question.length + 1) : pick;
111
+ if (!label.trim())
112
+ throw new UsageError(`--pick needs an option for "${q.question}".`);
113
+ (answers[q.question] ??= []).push(label);
114
+ }
115
+ for (const q of questions)
116
+ if (!answers[q.question])
117
+ throw new UsageError(`Pick an answer for "${q.question}" too.`);
118
+ return Object.fromEntries(Object.entries(answers).map(([k, v]) => [k, v.length === 1 ? v[0] : v]));
119
+ }
120
+ /** A listening port as `conversations get` prints it: the port, its program, then its URL once exposed, else where it
121
+ * listens ("5173 node https://… (exposed)", "5432 postgres 127.0.0.1 only"). */
122
+ export function portLine(p) {
123
+ const loopback = /^127\./.test(p.address) || p.address === '::1' || p.address === 'localhost';
124
+ const where = p.exposed ? `${p.url ?? p.address} (exposed)` : loopback ? `${p.address} only` : `${p.address} (not exposed)`;
125
+ return [String(p.port), p.process, where].filter(Boolean).join(' ');
126
+ }
127
+ /** How much of the terminal's past `conversations watch` shows before the live output: its last 256 KiB. */
128
+ export const WATCH_TAIL_BYTES = 256 * 1024;
129
+ /** Why a conversation has no terminal to watch (its `pty_id` is null), with the command that shows its output. */
130
+ function noTerminal(c) {
131
+ if (c.route === 'codex-cloud')
132
+ return `Codex Cloud runs this conversation, so its output is in its Codex thread: open it with shard agents open ${c.id}.`;
133
+ if (c.route !== 'terminal')
134
+ return `This conversation runs without a terminal (route ${c.route}): its app shows its output, and shard conversations get ${c.id} shows its state.`;
135
+ if (c.ended_at)
136
+ return `This conversation has finished: shard conversations get ${c.id} shows its result, and shard conversations resume ${c.id} continues it.`;
137
+ return `This conversation's terminal is starting: shard conversations get ${c.id} shows its pty_id once it runs.`;
138
+ }
139
+ /**
140
+ * Streams a conversation's terminal read-only from its last WATCH_TAIL_BYTES until its session exits or ctx.signal
141
+ * aborts (Ctrl-C), writing the terminal's bytes to stdout as they arrive. Nothing is ever typed into it, and the attach is
142
+ * passive: it never wakes the computer or keeps it awake while the terminal is idle (reading the terminal's size first
143
+ * wakes a sleeping computer, as every cell call does unless --no-wake). A host without passive attach gets an ordinary
144
+ * read-only attach next, from the same byte.
145
+ */
146
+ export async function watchTerminal(ctx, cell, ptyId) {
147
+ const session = await cell.pty.get(ptyId);
148
+ const cursor = new PtyOutputCursor();
149
+ cursor.offset = Math.max(0, session.output_size - WATCH_TAIL_BYTES);
150
+ let passive = true, wrote = false;
151
+ try {
152
+ for (;;) {
153
+ if (ctx.signal.aborted)
154
+ return 0;
155
+ let connection;
156
+ try {
157
+ connection = await PtyConnection.attach(cell, ptyId, cursor.offset, ctx.signal, { readOnly: true, ...(passive ? { passive: true } : {}) });
158
+ for await (const m of connection.messages()) {
159
+ const bytes = cursor.consume(m);
160
+ if (bytes?.length) {
161
+ ctx.io.stdout.write(bytes);
162
+ wrote = true;
163
+ }
164
+ if (m.type === 'exit')
165
+ return 0;
166
+ }
167
+ return 0;
168
+ }
169
+ catch (error) {
170
+ if (ctx.signal.aborted)
171
+ return 0;
172
+ if (passive && passiveUnavailable(error)) {
173
+ passive = false;
174
+ continue;
175
+ }
176
+ // The computer parked under the watch: the agent is idle, and watching never wakes it.
177
+ if (sleepingAttach(error)) {
178
+ progress(ctx, `${wrote ? '\r\n' : ''}The conversation's computer is asleep; watching ended.`);
179
+ return 0;
180
+ }
181
+ throw error;
182
+ }
183
+ finally {
184
+ await connection?.close();
185
+ }
186
+ }
187
+ }
188
+ finally {
189
+ // The terminal's own modes (colours, a hidden cursor) end with the watch.
190
+ if (wrote && ctx.io.stdout.isTTY)
191
+ ctx.io.stdout.write('\x1b[0m\x1b[?25h\r\n');
192
+ }
193
+ }
17
194
  export const CONVERSATION_HANDLERS = {
18
195
  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
196
  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 +200,12 @@ export const CONVERSATION_HANDLERS = {
23
200
  let cursor = str(values.cursor), next;
24
201
  if (cursor)
25
202
  seen.add(cursor);
203
+ const updatedAfter = str(values['updated-after']);
204
+ if (updatedAfter !== undefined && Number.isNaN(Date.parse(updatedAfter)))
205
+ throw new UsageError('--updated-after must be an RFC 3339 time, such as 2026-10-15T09:00:00Z.');
206
+ const { client, organizationId } = await listScope(ctx, values);
26
207
  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 });
208
+ 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
209
  data.push(...page.data);
29
210
  next = page.nextCursor;
30
211
  cursor = next ?? undefined;
@@ -35,14 +216,138 @@ export const CONVERSATION_HANDLERS = {
35
216
  }
36
217
  } while (values.all && cursor);
37
218
  // 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 });
219
+ 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 });
220
+ return 0;
221
+ },
222
+ // Each change on its own line as it happens, until interrupted: "<id>\t<title>\t<state>[\t<why>]", or with --json one
223
+ // object per line, {conversation, cursor} (the cursor resumes after it with --cursor). 0.25.0+.
224
+ async 'conversations feed'(ctx, values) {
225
+ const { client, organizationId } = await listScope(ctx, values);
226
+ let cursor = str(values.cursor);
227
+ for await (const c of client.conversations.watch({ projectId: str(values.project), organizationId, cursor, signal: ctx.signal, onCursor: next => { cursor = next; } })) {
228
+ // A deleted conversation comes once, its content cleared: its line says deleted (0.25.0+).
229
+ const q = questionLine(c), line = c.deleted_at ? `${c.id}\tDeleted conversation\tdeleted` : `${c.id}\t${displayTitle(c)}\t${c.state}${q ? `\t${q}` : ''}`;
230
+ ctx.io.stdout.write(ctx.json ? JSON.stringify({ conversation: c, cursor }) + '\n' : `${line}\n`);
231
+ }
232
+ return 0;
233
+ },
234
+ async 'conversations get'(ctx, _values, [id]) {
235
+ const client = conversationClient(ctx), c = await client.conversations.get(id);
236
+ const q = questionLine(c, 200);
237
+ // needs_you with no question: its agent waits on permission requests; text output names them (0.25.0+). JSON stays the
238
+ // conversation. The lookup only adds to what get shows, so an API without permission requests still shows it.
239
+ const waits = !ctx.json && c.state === 'needs_you' && !q ? await client.conversations.permissions(c.id, { state: 'pending' }).then(p => p.data, () => []) : [];
240
+ // The ports processes listen on in its computer follow, one line each, once its guest reported them.
241
+ const ports = (c.listening_ports ?? []).map(p => `${portLine(p)}\n`).join('');
242
+ 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);
243
+ return 0;
244
+ },
245
+ // Permission requests (0.25.0+): what the conversation's agent asked before a tool ran, oldest first; every state
246
+ // unless --state, --all reads every page as conversations list does.
247
+ async 'conversations permissions'(ctx, values, [id]) {
248
+ const state = str(values.state);
249
+ if (state !== undefined && !PERMISSION_STATES.includes(state))
250
+ throw new UsageError('--state must be pending, answered or canceled.');
251
+ const client = conversationClient(ctx), data = [], seen = new Set();
252
+ let cursor = str(values.cursor), next;
253
+ if (cursor)
254
+ seen.add(cursor);
255
+ do {
256
+ const page = await client.conversations.permissions(id, { state, cursor, limit: values.limit ? Number(values.limit) : values.all ? 200 : undefined });
257
+ data.push(...page.data);
258
+ next = page.nextCursor;
259
+ cursor = next ?? undefined;
260
+ if (values.all && cursor) {
261
+ if (seen.has(cursor) || seen.size >= 100)
262
+ throw new UsageError('The permission request list repeated its cursor. Try again.');
263
+ seen.add(cursor);
264
+ }
265
+ } while (values.all && cursor);
266
+ out(ctx, data.map(p => `${permissionLine(p)}\n`).join(''), { data, nextCursor: next });
39
267
  return 0;
40
268
  },
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; },
269
+ async 'conversations answer'(ctx, values, [id, permissionId, word]) {
270
+ const picks = strs(values.pick), client = conversationClient(ctx);
271
+ // The agent's question (0.25.0+): --pick names the answer, which the agent gets exactly as picked.
272
+ if (picks.length) {
273
+ if (word !== undefined)
274
+ throw new UsageError('Answer a question with --pick, or decline it with deny, not both.');
275
+ const p = await client.conversations.permission(id, permissionId), answers = pickAnswers(p, picks);
276
+ const r = await client.conversations.answerQuestion(id, permissionId, answers, { signal: ctx.signal });
277
+ out(ctx, `Answered: ${Object.values(r.permission.answers ?? answers).map(a => Array.isArray(a) ? a.join(', ') : a).join('; ')}\n`, r);
278
+ return 0;
279
+ }
280
+ const decision = word === undefined ? undefined : PERMISSION_DECISIONS[word.toLowerCase().replace(/-/g, '_')];
281
+ if (!decision)
282
+ throw new UsageError('Choose allow, always or deny (a question takes --pick <option>).');
283
+ const r = await client.conversations.answerPermission(id, permissionId, decision, { signal: ctx.signal });
284
+ out(ctx, `${ANSWERED[r.permission.decision ?? decision]}: ${r.permission.title}\n`, r);
285
+ return 0;
286
+ },
287
+ // Its terminal, live and read-only, from the conversation's pty_id; never typed into, never keeping the computer awake.
288
+ async 'conversations watch'(ctx, _values, [id]) {
289
+ if (ctx.json)
290
+ throw new UsageError(`conversations watch streams the terminal itself, so it has no --json form; shard agents logs ${id} --json returns its recent output.`);
291
+ const client = conversationClient(ctx), c = await client.conversations.get(id);
292
+ if (!c.pty_id)
293
+ throw new UsageError(noTerminal(c));
294
+ const ws = await client.workspaces.get(c.workspace_id);
295
+ if (ws.data.deleted_at)
296
+ throw new UsageError(`This conversation is saved: shard conversations resume ${c.id} continues it in a fresh VM.`);
297
+ const cell = ws.cell({ agentLabel: ctx.agentLabel, ...(ctx.wake ? {} : { wake: null }), transitionTimeoutMs: ctx.wakeTimeoutMs });
298
+ try {
299
+ progress(ctx, `Watching ${displayTitle(c)}, read-only. Ctrl-C stops.`);
300
+ return await watchTerminal(ctx, cell, c.pty_id);
301
+ }
302
+ finally {
303
+ cell.close();
304
+ }
305
+ },
42
306
  // 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);
307
+ // --file (0.25.0+): the files land in the conversation's computer before the message, which lists their paths.
308
+ async 'conversations send'(ctx, values, [id, ...words]) {
309
+ const files = await readAttachments(strs(values.file)), text = words.length ? words.join(' ') : undefined;
310
+ if (text === undefined && !files.length)
311
+ throw new UsageError('missing <text> for "shard conversations send"');
312
+ if (files.length)
313
+ checkMessage(text ?? '', files);
314
+ const r = await conversationClient(ctx).conversations.send(id, text ?? '', { signal: ctx.signal, ...(files.length ? { files } : {}) });
315
+ out(ctx, sentLines(r), r);
316
+ return 0;
317
+ },
318
+ /**
319
+ * A conversation started without a folder (0.25.0+): on the machine's home (--home), or in one of the project's
320
+ * repositories from the project hub. With --prompt its first message follows, with any --file.
321
+ */
322
+ async 'conversations start'(ctx, values) {
323
+ const home = values.home === true, repo = str(values.repo), branch = str(values.branch), prompt = str(values.prompt), title = str(values.title);
324
+ if (home && (repo !== undefined || branch !== undefined))
325
+ throw new UsageError('--home starts on your machine without a repository: leave out --repo and --branch.');
326
+ const app = str(values.app) ?? 'claude';
327
+ if (app !== 'claude' && app !== 'codex')
328
+ throw new UsageError('Choose --app claude or codex.');
329
+ const choice = modelChoice(values, app);
330
+ if (prompt !== undefined && !prompt.trim())
331
+ throw new UsageError('--prompt needs text.');
332
+ const files = await readAttachments(strs(values.file));
333
+ if (files.length && prompt === undefined)
334
+ throw new UsageError('--file goes with the first message: add --prompt.');
335
+ if (prompt !== undefined)
336
+ checkMessage(prompt, files);
337
+ const projectId = str(values.project) ?? ctx.store.profile(ctx.apiUrl).project_id, titlePrefix = storedTitlePrefix(ctx.store.dir);
338
+ const client = conversationClient(ctx);
339
+ const created = await client.conversations.create({ ...(projectId ? { projectId } : {}), harness: app, route: 'terminal', mode: 'new', ...(home ? { home: true } : {}),
340
+ ...(repo !== undefined ? { repoRemote: repo } : {}), ...(branch !== undefined ? { base: { branch } } : {}), ...(title !== undefined ? { title } : {}), ...choice, ...(titlePrefix !== undefined ? { titlePrefix } : {}) }, { idempotencyKey: randomUUID(), signal: ctx.signal });
341
+ const c = created.conversation;
342
+ let message;
343
+ try {
344
+ if (prompt !== undefined)
345
+ message = await client.conversations.send(c.id, prompt, { idempotencyKey: randomUUID(), signal: ctx.signal, ...(files.length ? { files } : {}) });
346
+ }
347
+ catch (e) {
348
+ 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 });
349
+ }
350
+ out(ctx, `${c.id}\t${c.key}\t${c.state}\n${message ? sentLines(message) : ''}`, { ...created, ...(message ? { message } : {}) });
46
351
  return 0;
47
352
  },
48
353
  async 'conversations wait'(ctx, values, [id]) {
@@ -57,6 +362,12 @@ export const CONVERSATION_HANDLERS = {
57
362
  async 'conversations land'(ctx, values, [id, action]) {
58
363
  if (!['merge', 'pr', 'keep', 'discard'].includes(action))
59
364
  throw new UsageError('Choose merge, pr, keep or discard.');
365
+ // merge lands for real (land.ts) and records only after the commit is in the branch; the others record a decision.
366
+ if (action === 'merge') {
367
+ const o = await landConversation(landDeps(ctx), id, { path: str(values.path) });
368
+ out(ctx, landedText(o), o);
369
+ return 0;
370
+ }
60
371
  const r = await conversationClient(ctx).conversations.land(id, action, { into: str(values.into), signal: ctx.signal });
61
372
  out(ctx, `${displayTitle(r)}: ${r.landed.how}\n`, r);
62
373
  return 0;
@@ -67,4 +378,12 @@ export const CONVERSATION_HANDLERS = {
67
378
  return 0;
68
379
  },
69
380
  async 'conversations move'(ctx, _values, [id, to]) { const r = await conversationClient(ctx).conversations.move(id, to, { signal: ctx.signal }); out(ctx, `Move scheduled for the end of the turn.\n`, r); return 0; },
381
+ // Everywhere at once, its computer included (0.25.0+); refused without --yes, as every deletion is. --json: the API's answer.
382
+ async 'conversations delete'(ctx, values, [id]) {
383
+ if (values.yes !== true)
384
+ throw new UsageError('refusing to delete without --yes (the conversation, its computer, messages, transcript and branch are deleted everywhere)');
385
+ const r = await conversationClient(ctx).conversations.delete(id, { signal: ctx.signal });
386
+ out(ctx, `Deleted: ${r.id}\n`, r);
387
+ return 0;
388
+ },
70
389
  };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `shard conversations transcript` and `shard conversations events` (0.25.0+): a conversation's stored transcript (the
3
+ * harness's own JSONL, byte for byte) and its event stream, read through the API without the conversation's computer.
4
+ */
5
+ import type { ConversationEvent, ConversationTranscript, Shardflux } from '@shardflux/sdk';
6
+ import type { Handler } from './commands.js';
7
+ type ConversationsApi = Shardflux['conversations'];
8
+ /** A stretch of transcript: the bytes [offset, next_offset) of a session's file. */
9
+ export type TranscriptPiece = Pick<ConversationTranscript, 'session_id' | 'file' | 'offset' | 'next_offset' | 'size' | 'data'>;
10
+ /**
11
+ * Reads pages from `offset` of a session (the latest when `sessionId` is undefined) until its stored end, or until
12
+ * `until` (a line boundary). Every page goes to `emit`; returns the last one.
13
+ */
14
+ export declare function readTranscript(api: Pick<ConversationsApi, 'transcript'>, id: string, o: {
15
+ offset: number;
16
+ sessionId?: string | undefined;
17
+ until?: number;
18
+ signal: AbortSignal;
19
+ }, emit: (page: ConversationTranscript) => void): Promise<ConversationTranscript>;
20
+ /**
21
+ * Prints a followed transcript once: each event's lines after what was printed, the stored lines a reconnect or a new
22
+ * harness session would otherwise skip read from the API first.
23
+ */
24
+ export declare class TranscriptFollower {
25
+ #private;
26
+ session: string | null;
27
+ offset: number;
28
+ constructor(api: Pick<ConversationsApi, 'transcript'>, id: string, at: {
29
+ session: string | null;
30
+ offset: number;
31
+ }, signal: AbortSignal, write: (p: TranscriptPiece) => void);
32
+ take(t: TranscriptPiece): Promise<void>;
33
+ }
34
+ /**
35
+ * The readable form of `shard conversations events`: a line per state change, transcript lines and tool call, the
36
+ * reply text as it streams. `end()` closes a reply still on its line.
37
+ */
38
+ export declare class EventLines {
39
+ #private;
40
+ render(ev: ConversationEvent): string;
41
+ end(): string;
42
+ }
43
+ export declare const TRANSCRIPT_HANDLERS: Record<string, Handler>;
44
+ export {};