@shardflux/cli 0.23.1 → 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 +139 -0
- package/README.md +249 -4
- package/dist/account-args.js +8 -3
- package/dist/account-commands.js +15 -2
- package/dist/account-pool.js +5 -1
- package/dist/acp-journal.d.ts +2 -2
- package/dist/acp-journal.js +14 -4
- 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 +125 -30
- 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 +154 -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/default-volume-commands.d.ts +3 -0
- package/dist/default-volume-commands.js +35 -0
- package/dist/device-approvals.js +4 -3
- package/dist/device-bridge.d.ts +42 -1
- package/dist/device-bridge.js +241 -14
- 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/filesystem-commands.js +2 -1
- package/dist/handlers.js +6 -1
- package/dist/local-sessions.d.ts +7 -1
- package/dist/local-sessions.js +38 -0
- package/dist/main.js +3 -0
- package/dist/move-files.d.ts +39 -0
- package/dist/move-files.js +167 -0
- package/dist/move-interrupt.d.ts +18 -0
- package/dist/move-interrupt.js +193 -0
- package/dist/move.js +28 -17
- 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/dist/worktree-twin-engine.js +16 -6
- package/package.json +7 -5
|
@@ -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]) {
|
|
@@ -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 {};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { out, str } from "./commands.js";
|
|
2
|
+
import { conversationClient, questionLine } from "./conversation-commands.js";
|
|
3
|
+
import { EXIT, UsageError } from "./errors.js";
|
|
4
|
+
function byteOffset(text) {
|
|
5
|
+
if (text === undefined)
|
|
6
|
+
return 0;
|
|
7
|
+
if (!/^\d+$/.test(text) || !Number.isSafeInteger(Number(text)))
|
|
8
|
+
throw new UsageError('--offset must be a nonnegative safe integer');
|
|
9
|
+
return Number(text);
|
|
10
|
+
}
|
|
11
|
+
const piece = (p) => ({ session_id: p.session_id, file: p.file, offset: p.offset, next_offset: p.next_offset, size: p.size, data: p.data });
|
|
12
|
+
/**
|
|
13
|
+
* Reads pages from `offset` of a session (the latest when `sessionId` is undefined) until its stored end, or until
|
|
14
|
+
* `until` (a line boundary). Every page goes to `emit`; returns the last one.
|
|
15
|
+
*/
|
|
16
|
+
export async function readTranscript(api, id, o, emit) {
|
|
17
|
+
const limit = (at) => (o.until === undefined ? {} : { limitBytes: o.until - at });
|
|
18
|
+
let page = await api.transcript(id, { offset: o.offset, ...(o.sessionId === undefined ? {} : { sessionId: o.sessionId }), ...limit(o.offset), signal: o.signal });
|
|
19
|
+
emit(page);
|
|
20
|
+
for (;;) {
|
|
21
|
+
const end = o.until === undefined ? page.size : Math.min(o.until, page.size);
|
|
22
|
+
if (page.next_offset >= end)
|
|
23
|
+
return page;
|
|
24
|
+
const at = page.next_offset;
|
|
25
|
+
page = await api.transcript(id, { offset: at, sessionId: page.session_id ?? o.sessionId, ...limit(at), signal: o.signal });
|
|
26
|
+
// A page that does not move on would repeat forever.
|
|
27
|
+
if (page.next_offset <= at)
|
|
28
|
+
return page;
|
|
29
|
+
emit(page);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Prints a followed transcript once: each event's lines after what was printed, the stored lines a reconnect or a new
|
|
34
|
+
* harness session would otherwise skip read from the API first.
|
|
35
|
+
*/
|
|
36
|
+
export class TranscriptFollower {
|
|
37
|
+
session;
|
|
38
|
+
offset;
|
|
39
|
+
#api;
|
|
40
|
+
#id;
|
|
41
|
+
#signal;
|
|
42
|
+
#write;
|
|
43
|
+
constructor(api, id, at, signal, write) {
|
|
44
|
+
this.#api = api;
|
|
45
|
+
this.#id = id;
|
|
46
|
+
this.session = at.session;
|
|
47
|
+
this.offset = at.offset;
|
|
48
|
+
this.#signal = signal;
|
|
49
|
+
this.#write = write;
|
|
50
|
+
}
|
|
51
|
+
#print(p) {
|
|
52
|
+
if (p.session_id !== this.session || p.next_offset <= this.offset)
|
|
53
|
+
return;
|
|
54
|
+
const bytes = Buffer.from(p.data, 'utf8');
|
|
55
|
+
const skip = Math.max(0, this.offset - p.offset);
|
|
56
|
+
const data = skip === 0 ? p.data : bytes.subarray(skip).toString('utf8');
|
|
57
|
+
this.offset = p.next_offset;
|
|
58
|
+
if (data)
|
|
59
|
+
this.#write({ ...piece(p), offset: p.offset + skip, data });
|
|
60
|
+
}
|
|
61
|
+
async take(t) {
|
|
62
|
+
if (t.session_id === null)
|
|
63
|
+
return;
|
|
64
|
+
if (t.session_id !== this.session) {
|
|
65
|
+
// A new harness session (Claude Code /clear, a new Codex thread): the rest of the earlier one, then this one.
|
|
66
|
+
if (this.session !== null)
|
|
67
|
+
await readTranscript(this.#api, this.#id, { offset: this.offset, sessionId: this.session, signal: this.#signal }, (p) => this.#print(p));
|
|
68
|
+
this.session = t.session_id;
|
|
69
|
+
this.offset = 0;
|
|
70
|
+
}
|
|
71
|
+
if (t.offset > this.offset)
|
|
72
|
+
await readTranscript(this.#api, this.#id, { offset: this.offset, sessionId: t.session_id, until: t.offset, signal: this.#signal }, (p) => this.#print(p));
|
|
73
|
+
this.#print(t);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const lineCount = (text) => text.split('\n').length - 1;
|
|
77
|
+
const oneLine = (text) => text.replace(/\s+/g, ' ').trim();
|
|
78
|
+
/**
|
|
79
|
+
* The readable form of `shard conversations events`: a line per state change, transcript lines and tool call, the
|
|
80
|
+
* reply text as it streams. `end()` closes a reply still on its line.
|
|
81
|
+
*/
|
|
82
|
+
export class EventLines {
|
|
83
|
+
#state;
|
|
84
|
+
#midLine = false;
|
|
85
|
+
#tools = new Map();
|
|
86
|
+
render(ev) {
|
|
87
|
+
if (ev.type === 'conversation') {
|
|
88
|
+
const c = ev.conversation;
|
|
89
|
+
if (c.state === this.#state)
|
|
90
|
+
return '';
|
|
91
|
+
this.#state = c.state;
|
|
92
|
+
const q = questionLine(c);
|
|
93
|
+
return this.#line(`state: ${c.state}${q ? ` — ${q}` : ''}`);
|
|
94
|
+
}
|
|
95
|
+
if (ev.type === 'transcript') {
|
|
96
|
+
const n = lineCount(ev.transcript.data);
|
|
97
|
+
return n ? this.#line(`transcript: +${n} line${n === 1 ? '' : 's'}`) : '';
|
|
98
|
+
}
|
|
99
|
+
return ev.delta.updates.map((u) => this.#update(u)).join('');
|
|
100
|
+
}
|
|
101
|
+
end() {
|
|
102
|
+
const close = this.#midLine ? '\n' : '';
|
|
103
|
+
this.#midLine = false;
|
|
104
|
+
return close;
|
|
105
|
+
}
|
|
106
|
+
#update(u) {
|
|
107
|
+
if (u.sessionUpdate === 'agent_message_chunk') {
|
|
108
|
+
const c = u.content;
|
|
109
|
+
if (c?.type !== 'text' || typeof c.text !== 'string' || !c.text)
|
|
110
|
+
return '';
|
|
111
|
+
this.#midLine = !c.text.endsWith('\n');
|
|
112
|
+
return c.text;
|
|
113
|
+
}
|
|
114
|
+
if (u.sessionUpdate !== 'tool_call' && u.sessionUpdate !== 'tool_call_update')
|
|
115
|
+
return '';
|
|
116
|
+
const id = typeof u.toolCallId === 'string' ? u.toolCallId : '';
|
|
117
|
+
const title = (typeof u.title === 'string' && oneLine(u.title)) || this.#tools.get(id) || 'tool';
|
|
118
|
+
if (id) {
|
|
119
|
+
this.#tools.delete(id);
|
|
120
|
+
this.#tools.set(id, title);
|
|
121
|
+
if (this.#tools.size > 1000)
|
|
122
|
+
this.#tools.delete(this.#tools.keys().next().value);
|
|
123
|
+
}
|
|
124
|
+
const status = typeof u.status === 'string' && u.status ? u.status : undefined;
|
|
125
|
+
// An update that only adds output to a call says nothing new on its line.
|
|
126
|
+
if (u.sessionUpdate === 'tool_call_update' && !status)
|
|
127
|
+
return '';
|
|
128
|
+
return this.#line(`tool: ${title}${status ? ` (${status})` : ''}`);
|
|
129
|
+
}
|
|
130
|
+
#line(text) {
|
|
131
|
+
const lead = this.#midLine ? '\n' : '';
|
|
132
|
+
this.#midLine = false;
|
|
133
|
+
return `${lead}${text}\n`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const interrupted = (signal, err) => {
|
|
137
|
+
if (signal.aborted)
|
|
138
|
+
return EXIT.interrupted;
|
|
139
|
+
throw err;
|
|
140
|
+
};
|
|
141
|
+
export const TRANSCRIPT_HANDLERS = {
|
|
142
|
+
async 'conversations transcript'(ctx, values, [id]) {
|
|
143
|
+
const offset = byteOffset(str(values.offset));
|
|
144
|
+
const sessionId = str(values.session);
|
|
145
|
+
if (sessionId !== undefined && !sessionId.trim())
|
|
146
|
+
throw new UsageError('--session needs a session id');
|
|
147
|
+
const follow = values.follow === true;
|
|
148
|
+
const api = conversationClient(ctx).conversations;
|
|
149
|
+
// Stdout carries the transcript itself; with --json, one object (with --follow, one object per line).
|
|
150
|
+
const ndjson = ctx.json && follow;
|
|
151
|
+
const write = (p) => {
|
|
152
|
+
if (ndjson)
|
|
153
|
+
ctx.io.stdout.write(`${JSON.stringify(piece(p))}\n`);
|
|
154
|
+
else if (!ctx.json && p.data)
|
|
155
|
+
ctx.io.stdout.write(p.data);
|
|
156
|
+
};
|
|
157
|
+
const parts = [];
|
|
158
|
+
let last;
|
|
159
|
+
try {
|
|
160
|
+
last = await readTranscript(api, id, { offset, sessionId, signal: ctx.signal }, (p) => {
|
|
161
|
+
if (ctx.json && !follow)
|
|
162
|
+
parts.push(p.data);
|
|
163
|
+
else if (p.data)
|
|
164
|
+
write(p);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
return interrupted(ctx.signal, err);
|
|
169
|
+
}
|
|
170
|
+
if (!follow) {
|
|
171
|
+
if (ctx.json)
|
|
172
|
+
out(ctx, '', { ...last, offset, data: parts.join('') });
|
|
173
|
+
return EXIT.ok;
|
|
174
|
+
}
|
|
175
|
+
const follower = new TranscriptFollower(api, id, { session: last.session_id ?? sessionId ?? null, offset: last.next_offset }, ctx.signal, write);
|
|
176
|
+
try {
|
|
177
|
+
const events = api.events(id, { transcriptOffset: follower.offset, ...(sessionId === undefined ? {} : { sessionId }), signal: ctx.signal });
|
|
178
|
+
for await (const ev of events)
|
|
179
|
+
if (ev.type === 'transcript')
|
|
180
|
+
await follower.take(ev.transcript);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
return interrupted(ctx.signal, err);
|
|
184
|
+
}
|
|
185
|
+
return ctx.signal.aborted ? EXIT.interrupted : EXIT.ok;
|
|
186
|
+
},
|
|
187
|
+
async 'conversations events'(ctx, _values, [id]) {
|
|
188
|
+
const lines = new EventLines();
|
|
189
|
+
let code = EXIT.ok;
|
|
190
|
+
try {
|
|
191
|
+
for await (const ev of conversationClient(ctx).conversations.events(id, { signal: ctx.signal })) {
|
|
192
|
+
const text = ctx.json ? `${JSON.stringify(ev)}\n` : lines.render(ev);
|
|
193
|
+
if (text)
|
|
194
|
+
ctx.io.stdout.write(text);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
code = interrupted(ctx.signal, err);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
if (!ctx.json)
|
|
202
|
+
ctx.io.stdout.write(lines.end());
|
|
203
|
+
}
|
|
204
|
+
return ctx.signal.aborted ? EXIT.interrupted : code;
|
|
205
|
+
},
|
|
206
|
+
};
|