@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.
- package/CHANGELOG.md +148 -0
- package/README.md +263 -1
- package/dist/account-args.js +5 -0
- package/dist/account-pool.js +5 -1
- package/dist/acp-journal.js +23 -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 +172 -9
- package/dist/board-args.d.ts +6 -0
- package/dist/board-args.js +172 -0
- package/dist/board-commands.d.ts +62 -0
- package/dist/board-commands.js +412 -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 +328 -9
- package/dist/conversation-transcript.d.ts +44 -0
- package/dist/conversation-transcript.js +206 -0
- package/dist/conversations.d.ts +7 -3
- 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 +66 -0
- package/dist/device-folders.js +236 -0
- package/dist/device-helper.d.ts +6 -0
- package/dist/device-helper.js +98 -14
- package/dist/drive-commands.d.ts +25 -0
- package/dist/drive-commands.js +201 -0
- package/dist/errors.js +2 -1
- package/dist/format.js +6 -2
- package/dist/handlers.js +7 -1
- package/dist/land.d.ts +116 -0
- package/dist/land.js +238 -0
- package/dist/main.js +10 -1
- 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 +10 -6
- 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 +33 -0
- package/dist/router-commands.js +179 -0
- package/dist/task-land-hub.py +150 -0
- package/dist/transcribe-commands.d.ts +7 -0
- package/dist/transcribe-commands.js +80 -0
- package/dist/worktree-twin-engine.d.ts +8 -0
- package/dist/worktree-twin-engine.js +28 -22
- package/package.json +2 -2
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { out, principal, str, strs } from "./commands.js";
|
|
2
|
+
import { detailLine, pickAnswers } from "./conversation-commands.js";
|
|
3
|
+
import { NotFoundError, UsageError } from "./errors.js";
|
|
4
|
+
import { time } from "./format.js";
|
|
5
|
+
import { progress } from "./progress.js";
|
|
6
|
+
import { landDeps, landTask, landedText } from "./land.js";
|
|
7
|
+
const BUCKETS = ['now', 'next', 'later'];
|
|
8
|
+
const BUCKET_TITLES = { now: 'Now', next: 'Next', later: 'Later' };
|
|
9
|
+
const MODES = ['ask', 'routine', 'trusted'];
|
|
10
|
+
/** The board's client: the project API key's. */
|
|
11
|
+
const boardClient = (ctx) => ctx.cloud();
|
|
12
|
+
const oneLine = (s, max) => {
|
|
13
|
+
const line = s.replace(/\s+/g, ' ').trim();
|
|
14
|
+
return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
|
|
15
|
+
};
|
|
16
|
+
/** A task as people read it: its title once it has one, else your words, on one line of at most `max` characters. */
|
|
17
|
+
export function taskLabel(t, max = 80) {
|
|
18
|
+
return oneLine(t.title?.trim() || t.text, max);
|
|
19
|
+
}
|
|
20
|
+
/** Why a task waits to start, or what a working one does now: "Getting its computer ready", "Fixing the login cookie". */
|
|
21
|
+
function stateNote(t, max) {
|
|
22
|
+
const note = t.waiting?.message.trim() || (t.state === 'working' ? t.activity?.trim() : '') || '';
|
|
23
|
+
return note ? ` (${oneLine(note, max)})` : '';
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* One task as `shard board` lists it: id, state (with why it waits to start, "queued (Getting its computer ready)", or
|
|
27
|
+
* what it does now, "working (Fixing the login cookie)"), title or words (a follow-up ↳, a suggestion marked), project.
|
|
28
|
+
*/
|
|
29
|
+
export function taskLine(t) {
|
|
30
|
+
const state = `${t.state}${stateNote(t, 60)}`;
|
|
31
|
+
const label = `${t.parent_task_id ? '↳ ' : ''}${taskLabel(t)}${t.suggested ? ' (suggested)' : ''}`;
|
|
32
|
+
return `${t.id}\t${state}\t${label}\t${t.project_id}${t.home ? ' (home)' : ''}`;
|
|
33
|
+
}
|
|
34
|
+
/** One item that waits on you: its id, what is asked, then the command, the suggested answers or the branch behind it. */
|
|
35
|
+
export function needsLine(n) {
|
|
36
|
+
const detail = n.permission ? detailLine(n.permission.detail) : n.options?.length ? n.options.join(' / ') : n.land ? n.land.branch : '';
|
|
37
|
+
return `${n.id}\t${oneLine(n.ask, 100)}${detail ? `\t${detail}` : ''}`;
|
|
38
|
+
}
|
|
39
|
+
/** A sealed report as --follow prints it: id, milestone, title, then its evidence labels. */
|
|
40
|
+
export function reportLine(r) {
|
|
41
|
+
const labels = r.evidence.map((e) => e.label).join(', ');
|
|
42
|
+
return `${r.id}\t${r.milestone}\t${oneLine(r.title, 100)}${labels ? `\t${labels}` : ''}`;
|
|
43
|
+
}
|
|
44
|
+
/** The board as text: Needs you first (oldest first), then Now, Next and Later in their order. */
|
|
45
|
+
export function boardText(b) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
if (b.needs_you.length)
|
|
48
|
+
parts.push(['Needs you', ...b.needs_you.map(needsLine)].join('\n'));
|
|
49
|
+
for (const bucket of BUCKETS) {
|
|
50
|
+
const tasks = b.tasks.filter((t) => t.bucket === bucket && !t.deleted_at && !t.cleared_at).sort((x, y) => x.position - y.position);
|
|
51
|
+
if (tasks.length)
|
|
52
|
+
parts.push([BUCKET_TITLES[bucket], ...tasks.map(taskLine)].join('\n'));
|
|
53
|
+
}
|
|
54
|
+
return parts.length ? `${parts.join('\n\n')}\n` : 'Nothing on the board yet: shard task add <words> adds a task.\n';
|
|
55
|
+
}
|
|
56
|
+
/** The approval policy on one line: the default, each project's own mode, the number of rules. */
|
|
57
|
+
export function policyLine(p) {
|
|
58
|
+
const projects = Object.entries(p.projects).map(([id, mode]) => `; ${id}: ${mode}`).join('');
|
|
59
|
+
return `${p.default}${projects}${p.rules.length ? `; ${p.rules.length} rule${p.rules.length === 1 ? '' : 's'}` : ''}`;
|
|
60
|
+
}
|
|
61
|
+
/** One always-allow rule: id, tool, the pattern it allows, its project. */
|
|
62
|
+
export function ruleLine(r) {
|
|
63
|
+
return `${r.id}\t${r.tool}\t${r.pattern || '(every use)'}\t${r.project_id ?? 'every project'}`;
|
|
64
|
+
}
|
|
65
|
+
/** `shard approvals`: the default mode, each project's own mode, then each rule. */
|
|
66
|
+
export function policyText(p) {
|
|
67
|
+
return [`Default: ${p.default}`, ...Object.entries(p.projects).map(([id, mode]) => `Project ${id}: ${mode}`), ...p.rules.map(ruleLine)].join('\n') + '\n';
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* `shard board --follow`: the lines of each feed answer. A changed task is its board line plus its bucket (deleted,
|
|
71
|
+
* cleared once gone); a sealed report its report line; an item that started waiting on you its needs line, one that
|
|
72
|
+
* stopped `<id>\tresolved`; a policy change one line. After a reset the whole board is printed again.
|
|
73
|
+
*/
|
|
74
|
+
export class BoardFollower {
|
|
75
|
+
#waiting;
|
|
76
|
+
constructor(board) {
|
|
77
|
+
this.#waiting = new Set(board.needs_you.map((n) => n.id));
|
|
78
|
+
}
|
|
79
|
+
next(u) {
|
|
80
|
+
if (u.reset) {
|
|
81
|
+
this.#waiting = new Set(u.board.needs_you.map((n) => n.id));
|
|
82
|
+
return `\n${boardText(u.board)}`;
|
|
83
|
+
}
|
|
84
|
+
const lines = [];
|
|
85
|
+
if (u.needs_you) {
|
|
86
|
+
const now = new Set(u.needs_you.map((n) => n.id));
|
|
87
|
+
for (const n of u.needs_you)
|
|
88
|
+
if (!this.#waiting.has(n.id))
|
|
89
|
+
lines.push(needsLine(n));
|
|
90
|
+
for (const id of this.#waiting)
|
|
91
|
+
if (!now.has(id))
|
|
92
|
+
lines.push(`${id}\tresolved`);
|
|
93
|
+
this.#waiting = now;
|
|
94
|
+
}
|
|
95
|
+
for (const t of u.tasks)
|
|
96
|
+
lines.push(`${taskLine(t)}\t${t.deleted_at ? 'deleted' : t.cleared_at ? 'cleared' : t.bucket}`);
|
|
97
|
+
for (const r of u.reports)
|
|
98
|
+
lines.push(reportLine(r));
|
|
99
|
+
if (u.policy)
|
|
100
|
+
lines.push(`Approvals: ${policyLine(u.policy)}`);
|
|
101
|
+
return lines.length ? `${lines.join('\n')}\n` : '';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** A feed answer as `board --follow --json` prints it: the API's fields (with `board` after a reset). */
|
|
105
|
+
export function feedJson(u) {
|
|
106
|
+
const page = { ...u };
|
|
107
|
+
delete page.nextCursor;
|
|
108
|
+
delete page.held;
|
|
109
|
+
return page;
|
|
110
|
+
}
|
|
111
|
+
const evidenceText = (e) => {
|
|
112
|
+
const behind = e.detail ?? e.path;
|
|
113
|
+
return behind ? `${e.label} (${detailLine(behind, 60)})` : e.label;
|
|
114
|
+
};
|
|
115
|
+
/** `shard task show`: the task, its plan and conversations, then every report oldest first with its evidence. */
|
|
116
|
+
export function taskDetailText(d) {
|
|
117
|
+
const t = d.task;
|
|
118
|
+
const titled = Boolean(t.title?.trim());
|
|
119
|
+
const lines = [titled ? oneLine(t.title, 200) : oneLine(t.text.split('\n').find((l) => l.trim()) ?? t.text, 200)];
|
|
120
|
+
lines.push([`${t.state}${stateNote(t, 120)}`, t.bucket, `${t.project_id}${t.home ? ' (home)' : ''}`, ...(t.suggested ? ['suggested'] : []), ...(t.deleted_at ? ['deleted'] : t.cleared_at ? ['cleared'] : [])].join('\t'));
|
|
121
|
+
// Your words exactly, when the first line does not already show them whole.
|
|
122
|
+
if (titled || t.text.trim() !== lines[0])
|
|
123
|
+
lines.push('', t.text);
|
|
124
|
+
if (t.plan) {
|
|
125
|
+
lines.push('', `Plan: ${t.plan.summary}`);
|
|
126
|
+
for (const s of t.plan.steps)
|
|
127
|
+
lines.push(` ${s.id}\t${s.state}\t${oneLine(s.text, 100)}${s.now?.trim() ? `\t(${oneLine(s.now, 80)})` : ''}${s.evidence.length ? `\t${s.evidence.join(', ')}` : ''}`);
|
|
128
|
+
}
|
|
129
|
+
if (t.conversations.length)
|
|
130
|
+
lines.push('', `Conversations: ${t.conversations.map((c) => `${c.id} (${c.role}${c.step_id ? ` ${c.step_id}` : ''})`).join(', ')}`);
|
|
131
|
+
if (d.reports.length) {
|
|
132
|
+
lines.push('', 'Reports');
|
|
133
|
+
for (const r of d.reports) {
|
|
134
|
+
lines.push(`${r.id}\t${r.milestone}\t${time(r.sealed_at)}\t${oneLine(r.title, 120)}`);
|
|
135
|
+
if (r.outcome.trim())
|
|
136
|
+
lines.push(` ${oneLine(r.outcome, 200)}`);
|
|
137
|
+
if (r.options?.length)
|
|
138
|
+
lines.push(` Options: ${r.options.join(' / ')}`);
|
|
139
|
+
if (r.evidence.length)
|
|
140
|
+
lines.push(` Evidence: ${r.evidence.map(evidenceText).join('; ')}`);
|
|
141
|
+
if (r.media.length)
|
|
142
|
+
lines.push(` Media: ${r.media.map((m) => `${m.name} (${m.path})`).join(', ')}`);
|
|
143
|
+
if (r.landed)
|
|
144
|
+
lines.push(` Landed: ${r.landed.how} ${r.landed.ref}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return `${lines.join('\n')}\n`;
|
|
148
|
+
}
|
|
149
|
+
/** --now, --next or --later (one at most); undefined when none was given. */
|
|
150
|
+
function bucketOf(values) {
|
|
151
|
+
const chosen = BUCKETS.filter((b) => values[b] === true);
|
|
152
|
+
if (chosen.length > 1)
|
|
153
|
+
throw new UsageError('Choose one of --now, --next or --later.');
|
|
154
|
+
return chosen[0];
|
|
155
|
+
}
|
|
156
|
+
/** --after <task-id> or --first (null: first); undefined when neither was given. */
|
|
157
|
+
function orderOf(values) {
|
|
158
|
+
const after = str(values.after), first = values.first === true;
|
|
159
|
+
if (after !== undefined && first)
|
|
160
|
+
throw new UsageError('Choose --after <task-id> or --first.');
|
|
161
|
+
return first ? null : after;
|
|
162
|
+
}
|
|
163
|
+
// ---- needs you -------------------------------------------------------------------------------
|
|
164
|
+
const KINDS = { perm: 'permission', cq: 'question', ask: 'question', cred: 'credential', land: 'land', prune: 'prune' };
|
|
165
|
+
/** An item's kind from its id (perm_, cq_, ask_, cred_, land_, prune_); undefined for another id. */
|
|
166
|
+
export function itemKind(id) {
|
|
167
|
+
return KINDS[id.slice(0, Math.max(0, id.indexOf('_')))];
|
|
168
|
+
}
|
|
169
|
+
/** The answer words: allow, always and deny (or the API's names), land, keep and discard. */
|
|
170
|
+
const WORDS = { allow: 'allow_once', allow_once: 'allow_once', always: 'allow_always', allow_always: 'allow_always', deny: 'deny', land: 'land', keep: 'keep', discard: 'discard' };
|
|
171
|
+
/** Whether the answer needs the item itself: the questions of a permission request, every task of a prune prompt. */
|
|
172
|
+
export const answerNeedsItem = (id, a) => a.picks.length > 0 || (itemKind(id) === 'prune' && a.word?.toLowerCase() === 'keep');
|
|
173
|
+
/**
|
|
174
|
+
* The API answer for item `id` from the command's words and options, checked before anything is sent: each kind takes
|
|
175
|
+
* its own (needs-you.ts). `item` is the waiting item when answerNeedsItem says it is needed; without it such an answer
|
|
176
|
+
* is only checked (its `answers` or `keep` come back empty).
|
|
177
|
+
*/
|
|
178
|
+
export function needsAnswer(id, a, item) {
|
|
179
|
+
const kind = item?.kind ?? itemKind(id);
|
|
180
|
+
const decision = a.word === undefined ? undefined : WORDS[a.word.toLowerCase().replace(/-/g, '_')];
|
|
181
|
+
const only = (allowed, message) => {
|
|
182
|
+
const given = ['text', 'picks', 'keep', 'everywhere'].filter((k) => (k === 'text' ? a.text !== undefined : k === 'everywhere' ? a.everywhere : a[k].length > 0));
|
|
183
|
+
if (given.some((k) => !allowed.includes(k)))
|
|
184
|
+
throw new UsageError(message);
|
|
185
|
+
};
|
|
186
|
+
switch (kind) {
|
|
187
|
+
case 'permission': {
|
|
188
|
+
const message = 'Choose allow, always or deny (an agent’s question takes --pick <option>).';
|
|
189
|
+
only(['picks', 'everywhere'], message);
|
|
190
|
+
if (a.picks.length) {
|
|
191
|
+
if (a.word !== undefined)
|
|
192
|
+
throw new UsageError('Answer the question with --pick, or decline it with deny, not both.');
|
|
193
|
+
if (a.everywhere)
|
|
194
|
+
throw new UsageError('--everywhere goes with always.');
|
|
195
|
+
if (!item)
|
|
196
|
+
return { answers: {} };
|
|
197
|
+
return { answers: pickAnswers({ questions: (item.permission?.questions ?? []) }, a.picks) };
|
|
198
|
+
}
|
|
199
|
+
if (decision !== 'allow_once' && decision !== 'allow_always' && decision !== 'deny')
|
|
200
|
+
throw new UsageError(message);
|
|
201
|
+
if (a.everywhere && decision !== 'allow_always')
|
|
202
|
+
throw new UsageError('--everywhere goes with always.');
|
|
203
|
+
return decision === 'allow_always' ? { decision, scope: a.everywhere ? 'everywhere' : 'project' } : { decision };
|
|
204
|
+
}
|
|
205
|
+
case 'question': {
|
|
206
|
+
const message = 'Answer the question with --text <answer>.';
|
|
207
|
+
only(['text'], message);
|
|
208
|
+
if (a.word !== undefined || !a.text?.trim())
|
|
209
|
+
throw new UsageError(message);
|
|
210
|
+
return { text: a.text };
|
|
211
|
+
}
|
|
212
|
+
case 'credential':
|
|
213
|
+
only([], 'Provide the value through the credential request, sealed for its computer; deny declines it here.');
|
|
214
|
+
if (decision !== 'deny')
|
|
215
|
+
throw new UsageError('Provide the value through the credential request, sealed for its computer; deny declines it here.');
|
|
216
|
+
return { decision };
|
|
217
|
+
case 'land':
|
|
218
|
+
only([], 'Choose land, keep or discard.');
|
|
219
|
+
if (decision !== 'land' && decision !== 'keep' && decision !== 'discard')
|
|
220
|
+
throw new UsageError('Choose land, keep or discard.');
|
|
221
|
+
return { decision };
|
|
222
|
+
case 'prune': {
|
|
223
|
+
const message = 'Choose --keep <task-id> for each task to keep, keep for all of them, or discard to clear them.';
|
|
224
|
+
only(['keep'], message);
|
|
225
|
+
if (a.keep.length) {
|
|
226
|
+
if (a.word !== undefined)
|
|
227
|
+
throw new UsageError(message);
|
|
228
|
+
return { keep: [...new Set(a.keep)] };
|
|
229
|
+
}
|
|
230
|
+
if (decision === 'discard')
|
|
231
|
+
return { keep: [] };
|
|
232
|
+
if (decision === 'keep')
|
|
233
|
+
return { keep: item?.prune ? [...item.prune.task_ids] : [] };
|
|
234
|
+
throw new UsageError(message);
|
|
235
|
+
}
|
|
236
|
+
default: {
|
|
237
|
+
// An id of a kind this shard does not know yet: what was given goes to the API, which says what it takes.
|
|
238
|
+
const answer = {};
|
|
239
|
+
if (decision)
|
|
240
|
+
answer.decision = decision;
|
|
241
|
+
if (a.text !== undefined)
|
|
242
|
+
answer.text = a.text;
|
|
243
|
+
if (a.keep.length)
|
|
244
|
+
answer.keep = [...a.keep];
|
|
245
|
+
if (a.everywhere)
|
|
246
|
+
answer.scope = 'everywhere';
|
|
247
|
+
return answer;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const ANSWERED = { allow_once: 'Allowed once', allow_always: 'Always allowed', deny: 'Denied', land: 'Landing', keep: 'Kept', discard: 'Discarded' };
|
|
252
|
+
/** What an answer did, in one line. */
|
|
253
|
+
export function answeredText(r, a) {
|
|
254
|
+
const item = r.item;
|
|
255
|
+
if (a.answers)
|
|
256
|
+
return `Answered: ${Object.values(a.answers).map((v) => (Array.isArray(v) ? v.join(', ') : v)).join('; ')}\n`;
|
|
257
|
+
if (item.kind === 'prune' && a.keep) {
|
|
258
|
+
const all = item.prune?.task_ids.length ?? a.keep.length;
|
|
259
|
+
return a.keep.length === all ? `Kept all ${all} Later tasks.\n` : `Kept ${a.keep.length} of ${all} Later tasks; the rest are cleared.\n`;
|
|
260
|
+
}
|
|
261
|
+
if (item.kind === 'question')
|
|
262
|
+
return `Answered: ${oneLine(item.ask, 100)}\n`;
|
|
263
|
+
const what = item.kind === 'land' ? item.land?.branch ?? oneLine(item.ask, 100) : oneLine(item.ask, 100).replace(/\?$/, '');
|
|
264
|
+
return `${ANSWERED[a.decision ?? ''] ?? 'Answered'}: ${what}${r.rule_id ? ` (rule ${r.rule_id})` : ''}\n`;
|
|
265
|
+
}
|
|
266
|
+
// ---- handlers --------------------------------------------------------------------------------
|
|
267
|
+
const projectScope = (values) => (str(values.project) !== undefined ? { projectId: str(values.project) } : {});
|
|
268
|
+
export const BOARD_HANDLERS = {
|
|
269
|
+
// The board, or with --follow the board and then each change until Ctrl-C (the SDK's watch over the held feed,
|
|
270
|
+
// from the board's own cursor: nothing between the read and the first wait is missed).
|
|
271
|
+
async board(ctx, values) {
|
|
272
|
+
const cloud = boardClient(ctx), scope = projectScope(values);
|
|
273
|
+
const board = await cloud.board.get({ ...scope, signal: ctx.signal });
|
|
274
|
+
if (values.follow !== true) {
|
|
275
|
+
out(ctx, boardText(board), board);
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
ctx.io.stdout.write(ctx.json ? `${JSON.stringify(board)}\n` : boardText(board));
|
|
279
|
+
progress(ctx, 'Following the board. Ctrl-C stops.');
|
|
280
|
+
const follower = new BoardFollower(board);
|
|
281
|
+
for await (const u of cloud.board.watch({ ...scope, cursor: board.cursor, signal: ctx.signal })) {
|
|
282
|
+
ctx.io.stdout.write(ctx.json ? `${JSON.stringify(feedJson(u))}\n` : follower.next(u));
|
|
283
|
+
}
|
|
284
|
+
return 0;
|
|
285
|
+
},
|
|
286
|
+
async 'task add'(ctx, values, words) {
|
|
287
|
+
const text = words.join(' ');
|
|
288
|
+
if (!text.trim())
|
|
289
|
+
throw new UsageError('missing <words...> for "shard task add"');
|
|
290
|
+
if (values.next === true && values.later === true)
|
|
291
|
+
throw new UsageError('Choose --next or --later.');
|
|
292
|
+
const bucket = values.later === true ? 'later' : values.next === true ? 'next' : undefined;
|
|
293
|
+
const after = orderOf(values);
|
|
294
|
+
const task = await boardClient(ctx).tasks.create({ text, ...projectScope(values), ...(values.home === true ? { home: true } : {}), ...(bucket ? { bucket } : {}), ...(after !== undefined ? { after } : {}) }, { signal: ctx.signal });
|
|
295
|
+
out(ctx, `${task.id}\t${task.bucket}\t${task.state}\n`, { task });
|
|
296
|
+
return 0;
|
|
297
|
+
},
|
|
298
|
+
async 'task show'(ctx, _values, [id]) {
|
|
299
|
+
const detail = await boardClient(ctx).tasks.get(id, { signal: ctx.signal });
|
|
300
|
+
out(ctx, taskDetailText(detail), detail);
|
|
301
|
+
return 0;
|
|
302
|
+
},
|
|
303
|
+
async 'task edit'(ctx, values, [id]) {
|
|
304
|
+
const p = {};
|
|
305
|
+
const text = str(values.text), title = str(values.title);
|
|
306
|
+
if (text !== undefined) {
|
|
307
|
+
if (!text.trim())
|
|
308
|
+
throw new UsageError('--text needs words.');
|
|
309
|
+
p.text = text;
|
|
310
|
+
}
|
|
311
|
+
if (title !== undefined)
|
|
312
|
+
p.title = title.trim() ? title : null;
|
|
313
|
+
const bucket = bucketOf(values), after = orderOf(values);
|
|
314
|
+
if (bucket)
|
|
315
|
+
p.bucket = bucket;
|
|
316
|
+
if (after !== undefined)
|
|
317
|
+
p.after = after;
|
|
318
|
+
if (values.keep === true)
|
|
319
|
+
p.suggested = false;
|
|
320
|
+
if (typeof values.clear === 'boolean')
|
|
321
|
+
p.cleared = values.clear;
|
|
322
|
+
if (Object.keys(p).length === 0)
|
|
323
|
+
throw new UsageError('Nothing to change: give --text, --title, --now, --next, --later, --after, --first, --keep or --clear.');
|
|
324
|
+
const task = await boardClient(ctx).tasks.update(id, p, { signal: ctx.signal });
|
|
325
|
+
out(ctx, `${taskLine(task)}\n`, { task });
|
|
326
|
+
return 0;
|
|
327
|
+
},
|
|
328
|
+
async 'task rm'(ctx, _values, [id]) {
|
|
329
|
+
const task = await boardClient(ctx).tasks.delete(id, { signal: ctx.signal });
|
|
330
|
+
out(ctx, `Deleted: ${taskLabel(task)}\n`, { task });
|
|
331
|
+
return 0;
|
|
332
|
+
},
|
|
333
|
+
async 'task steer'(ctx, _values, [id, ...words]) {
|
|
334
|
+
const text = words.join(' ');
|
|
335
|
+
if (!text.trim())
|
|
336
|
+
throw new UsageError('missing <words...> for "shard task steer"');
|
|
337
|
+
const task = await boardClient(ctx).tasks.steer(id, text, { signal: ctx.signal });
|
|
338
|
+
out(ctx, `Steered: ${taskLabel(task)}\n`, { task });
|
|
339
|
+
return 0;
|
|
340
|
+
},
|
|
341
|
+
async 'task land'(ctx, values, [id]) {
|
|
342
|
+
const o = await landTask(landDeps(ctx), id, { path: str(values.path) });
|
|
343
|
+
out(ctx, landedText(o), o);
|
|
344
|
+
return 0;
|
|
345
|
+
},
|
|
346
|
+
async 'task clear-done'(ctx, values) {
|
|
347
|
+
const cleared = await boardClient(ctx).tasks.clearDone(projectScope(values), { signal: ctx.signal });
|
|
348
|
+
out(ctx, cleared.length ? `Cleared ${cleared.length} finished task${cleared.length === 1 ? '' : 's'}.\n` : 'No finished tasks to clear.\n', { cleared });
|
|
349
|
+
return 0;
|
|
350
|
+
},
|
|
351
|
+
async 'needs-you'(ctx, values) {
|
|
352
|
+
const data = await boardClient(ctx).needsYou.list({ ...projectScope(values), signal: ctx.signal });
|
|
353
|
+
out(ctx, data.length ? `${data.map(needsLine).join('\n')}\n` : 'Nothing waits on you.\n', { data });
|
|
354
|
+
return 0;
|
|
355
|
+
},
|
|
356
|
+
async 'needs-you answer'(ctx, values, [id, word]) {
|
|
357
|
+
const input = { word, text: str(values.text), picks: strs(values.pick), keep: strs(values.keep), everywhere: values.everywhere === true };
|
|
358
|
+
const cloud = boardClient(ctx);
|
|
359
|
+
// Land merges here (shard task land) and records itself after; the board only takes keep and discard.
|
|
360
|
+
if (itemKind(id) === 'land' && word?.toLowerCase() === 'land') {
|
|
361
|
+
const o = await landTask(landDeps(ctx), id.replace(/^land_/, ''), {});
|
|
362
|
+
out(ctx, landedText(o), o);
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
|
365
|
+
// Checked first, so a wrong combination is a usage error before any request.
|
|
366
|
+
let answer = needsAnswer(id, input);
|
|
367
|
+
if (answerNeedsItem(id, input)) {
|
|
368
|
+
const item = (await cloud.needsYou.list({ signal: ctx.signal })).find((n) => n.id === id);
|
|
369
|
+
if (!item)
|
|
370
|
+
throw new NotFoundError('Nothing waits on you there any more.', 'shard needs-you lists what does', { reason: 'needs_you_not_found' });
|
|
371
|
+
answer = needsAnswer(id, input, item);
|
|
372
|
+
}
|
|
373
|
+
const r = await cloud.needsYou.answer(id, answer, { signal: ctx.signal });
|
|
374
|
+
out(ctx, answeredText(r, answer), r);
|
|
375
|
+
return 0;
|
|
376
|
+
},
|
|
377
|
+
async approvals(ctx) {
|
|
378
|
+
const policy = await boardClient(ctx).approvals.getPolicy({ signal: ctx.signal });
|
|
379
|
+
out(ctx, policyText(policy), policy);
|
|
380
|
+
return 0;
|
|
381
|
+
},
|
|
382
|
+
async 'approvals set'(ctx, values, [word]) {
|
|
383
|
+
const w = word.toLowerCase();
|
|
384
|
+
if (w !== 'default' && !MODES.includes(w))
|
|
385
|
+
throw new UsageError('Choose ask, routine or trusted (default puts the project back on the default mode).');
|
|
386
|
+
const mode = w === 'default' ? null : w, project = str(values.project);
|
|
387
|
+
if (values.default === true && project !== undefined)
|
|
388
|
+
throw new UsageError('Choose --project <id> or --default.');
|
|
389
|
+
if (values.default === true && mode === null)
|
|
390
|
+
throw new UsageError('--default takes ask, routine or trusted.');
|
|
391
|
+
const cloud = boardClient(ctx);
|
|
392
|
+
const params = values.default === true ? { default: mode } : { projects: { [project ?? (await principal(ctx)).project_id]: mode } };
|
|
393
|
+
const policy = await cloud.approvals.setPolicy(params, { signal: ctx.signal });
|
|
394
|
+
out(ctx, policyText(policy), policy);
|
|
395
|
+
return 0;
|
|
396
|
+
},
|
|
397
|
+
async 'approvals allow'(ctx, values, [tool, ...pattern]) {
|
|
398
|
+
const project = str(values.project), everywhere = values.everywhere === true;
|
|
399
|
+
if (project !== undefined && everywhere)
|
|
400
|
+
throw new UsageError('Choose --project <id> or --everywhere.');
|
|
401
|
+
if (!tool.trim())
|
|
402
|
+
throw new UsageError('Name the tool, such as Bash, WebFetch or Edit.');
|
|
403
|
+
const rule = await boardClient(ctx).approvals.addRule({ tool: tool, pattern: pattern.join(' '), ...(everywhere ? { projectId: null } : project !== undefined ? { projectId: project } : {}) }, { signal: ctx.signal });
|
|
404
|
+
out(ctx, `${ruleLine(rule)}\n`, rule);
|
|
405
|
+
return 0;
|
|
406
|
+
},
|
|
407
|
+
async 'approvals remove'(ctx, _values, [id]) {
|
|
408
|
+
const policy = await boardClient(ctx).approvals.removeRule(id, { signal: ctx.signal });
|
|
409
|
+
out(ctx, `Removed ${id}.\n`, policy);
|
|
410
|
+
return 0;
|
|
411
|
+
},
|
|
412
|
+
};
|
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) {
|