@shardflux/cli 0.25.0 → 0.28.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 (69) hide show
  1. package/CHANGELOG.md +130 -1
  2. package/README.md +145 -34
  3. package/dist/account-pool.d.ts +11 -1
  4. package/dist/account-pool.js +48 -0
  5. package/dist/agent-commands.js +4 -3
  6. package/dist/agent-models.d.ts +31 -1
  7. package/dist/agent-models.js +27 -4
  8. package/dist/args.d.ts +1 -1
  9. package/dist/args.js +67 -12
  10. package/dist/board-args.js +8 -3
  11. package/dist/board-commands.d.ts +7 -3
  12. package/dist/board-commands.js +22 -12
  13. package/dist/brain-commands.d.ts +2 -0
  14. package/dist/brain-commands.js +78 -0
  15. package/dist/client-settings.d.ts +2 -1
  16. package/dist/client-settings.js +14 -2
  17. package/dist/codex-cloud.d.ts +2 -0
  18. package/dist/codex-cloud.js +14 -11
  19. package/dist/commands.js +8 -0
  20. package/dist/conversation-commands.d.ts +5 -1
  21. package/dist/conversation-commands.js +58 -6
  22. package/dist/conversation-terminal.js +2 -2
  23. package/dist/desktop-linux.d.ts +12 -1
  24. package/dist/desktop-linux.js +47 -18
  25. package/dist/device-approvals.js +24 -2
  26. package/dist/device-bridge.d.ts +21 -4
  27. package/dist/device-bridge.js +173 -7
  28. package/dist/device-folders.d.ts +6 -1
  29. package/dist/device-folders.js +6 -2
  30. package/dist/device-helper.d.ts +8 -0
  31. package/dist/device-helper.js +71 -12
  32. package/dist/device-mode.d.ts +44 -0
  33. package/dist/device-mode.js +119 -0
  34. package/dist/drive-commands.d.ts +2 -2
  35. package/dist/drive-commands.js +1 -1
  36. package/dist/handlers.js +4 -2
  37. package/dist/land.d.ts +9 -4
  38. package/dist/land.js +52 -6
  39. package/dist/mac-app.d.ts +11 -0
  40. package/dist/mac-app.js +11 -0
  41. package/dist/main.js +2 -0
  42. package/dist/menubar.d.ts +18 -13
  43. package/dist/menubar.js +60 -50
  44. package/dist/orchestrator-args.d.ts +7 -0
  45. package/dist/orchestrator-args.js +64 -0
  46. package/dist/orchestrator-commands.d.ts +43 -0
  47. package/dist/orchestrator-commands.js +262 -0
  48. package/dist/person-scope.d.ts +20 -0
  49. package/dist/person-scope.js +61 -0
  50. package/dist/project-hub-seed.d.ts +20 -0
  51. package/dist/project-hub-seed.js +59 -0
  52. package/dist/registry.d.ts +1 -1
  53. package/dist/registry.js +2 -1
  54. package/dist/router-commands.d.ts +12 -21
  55. package/dist/router-commands.js +5 -59
  56. package/dist/t3-launch.js +2 -2
  57. package/dist/task-land-hub.py +16 -1
  58. package/dist/this-computer.d.ts +4 -2
  59. package/dist/this-computer.js +20 -2
  60. package/dist/thread-on-computer.js +2 -2
  61. package/dist/timing.js +3 -1
  62. package/dist/transcribe-commands.js +6 -2
  63. package/dist/update.js +4 -2
  64. package/dist/wait-status.d.ts +1 -1
  65. package/dist/wait-status.js +2 -0
  66. package/dist/worktree-twin-engine.js +1 -1
  67. package/dist/worktree-twin.d.ts +6 -0
  68. package/dist/worktree-twin.js +8 -1
  69. package/package.json +6 -5
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Your orchestrator (0.27.0+, SDK 0.29.0): `shard orchestrator say` (with --wait), `list`, `retry` and `dismiss`, and
3
+ * `restore` (0.28.0+, SDK 0.30.0), through @shardflux/sdk's `orchestrator` namespace. The command table is orchestrator-args.ts.
4
+ *
5
+ * The signed-in session reaches the person's orchestrator; SHARDFLUX_API_KEY in the environment (or a saved key without
6
+ * a session) reaches the orchestrator of the member who created that key (person-scope.ts). Text output is one line
7
+ * per input, tab-separated, id first; --json prints the API's objects.
8
+ */
9
+ import { inputSettled, ORCHESTRATOR_TEXT_MAX } from '@shardflux/sdk';
10
+ import { parseDuration } from "./args.js";
11
+ import { out, str } from "./commands.js";
12
+ import { EXIT, UsageError } from "./errors.js";
13
+ import { humanBytes } from "./files-mirror.js";
14
+ import { inScope } from "./person-scope.js";
15
+ import { progress } from "./progress.js";
16
+ /** --wait's default: how long `say --wait` follows an input before it stops (the input goes on). */
17
+ export const SAY_WAIT_MS = 10 * 60_000;
18
+ const oneLine = (s, max) => {
19
+ const line = s.replace(/\s+/g, ' ').trim();
20
+ return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
21
+ };
22
+ /** The selection flags and the API's id shape for each. */
23
+ const POINTERS = [
24
+ ['task', 'task_id', /^task_[a-z2-7]{26}$/, 'task_…'],
25
+ ['report', 'report_id', /^rep_[a-z2-7]{26}$/, 'rep_…'],
26
+ ['needs-you', 'needs_you_id', /^[a-z]{2,6}_[A-Za-z0-9_-]{1,80}$/, 'perm_…, oq_…'],
27
+ ['input', 'input_id', /^in_[a-z2-7]{26}$/, 'in_…'],
28
+ ['conversation', 'conversation_id', /^conv_[a-z2-7]{26}$/, 'conv_…'],
29
+ ];
30
+ /** The row the person points at, from --task, --report, --needs-you, --input and --conversation; undefined for none. */
31
+ export function selectionOf(values) {
32
+ const selection = {};
33
+ for (const [flag, field, pattern, example] of POINTERS) {
34
+ const v = str(values[flag]);
35
+ if (v === undefined)
36
+ continue;
37
+ if (!pattern.test(v))
38
+ throw new UsageError(`--${flag} must be an id (${example})`);
39
+ selection[field] = v;
40
+ }
41
+ return Object.keys(selection).length ? selection : undefined;
42
+ }
43
+ /** An input's state with what explains it: "queued (2 in line)", "waiting (Waiting for your computer: list ~/code)". */
44
+ export function stateLabel(i) {
45
+ if (i.state === 'queued')
46
+ return i.waiting ? `queued (${oneLine(i.waiting.message, 80)})` : i.queue_position ? `queued (${i.queue_position} in line)` : 'queued';
47
+ if (i.state === 'waiting' && i.waiting)
48
+ return `waiting (${oneLine(i.waiting.message, 80)})`;
49
+ return i.state;
50
+ }
51
+ /** What an input became, in a few words: the answer's headline, its tasks, the input it went to, or why it failed. */
52
+ export function becameText(i) {
53
+ if (i.state === 'merged' && i.merged_into)
54
+ return `added to ${i.merged_into}`;
55
+ if (i.state === 'failed' && i.error)
56
+ return oneLine(i.error.message, 100);
57
+ const parts = [];
58
+ if (i.answer)
59
+ parts.push(oneLine(i.answer.headline, 100));
60
+ if (i.task_ids.length)
61
+ parts.push(`${i.task_ids.length} task${i.task_ids.length === 1 ? '' : 's'}: ${i.task_ids.join(', ')}`);
62
+ return parts.join('; ');
63
+ }
64
+ /** One input as `shard orchestrator list` prints it: id, state, your words, what it became. */
65
+ export function inputLine(i) {
66
+ const became = becameText(i);
67
+ return `${i.id}\t${stateLabel(i)}\t${oneLine(i.text, 60)}${became ? `\t${became}` : ''}`;
68
+ }
69
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
70
+ const two = (n) => String(n).padStart(2, '0');
71
+ /**
72
+ * When an answer's point happened, short and in this computer's local time: "14:02" today, "Oct 16 14:02" this year,
73
+ * else "2025-10-16 14:02". A time without an offset is the person's local time and prints as written.
74
+ */
75
+ export function shortTime(at, now = new Date()) {
76
+ const d = new Date(at);
77
+ if (!Number.isFinite(d.getTime()))
78
+ return at;
79
+ const clock = `${two(d.getHours())}:${two(d.getMinutes())}`;
80
+ if (d.getFullYear() !== now.getFullYear())
81
+ return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${clock}`;
82
+ if (d.getMonth() !== now.getMonth() || d.getDate() !== now.getDate())
83
+ return `${MONTHS[d.getMonth()]} ${d.getDate()} ${clock}`;
84
+ return clock;
85
+ }
86
+ /** An answer's body: its points under their group headings when the agent wrote points (0.27.0+), else its facts. */
87
+ export function answerLines(answer, now = new Date()) {
88
+ if (!answer.points?.length)
89
+ return answer.facts.map((f) => ` - ${f}`);
90
+ const lines = [];
91
+ let group;
92
+ for (const p of answer.points) {
93
+ if (p.group !== group && p.group)
94
+ lines.push(` ${p.group}`);
95
+ group = p.group;
96
+ // A point the agent wrote without a lead is its words alone; every word prints, however long.
97
+ const words = p.lead ? `${p.lead}${p.detail ? `: ${p.detail}` : ''}` : p.detail ?? '';
98
+ lines.push(` - ${words}${p.at ? ` (${shortTime(p.at, now)})` : ''}`);
99
+ }
100
+ return lines;
101
+ }
102
+ /** One file an answer shows (0.27.0+): ` media: loss-curve.png (image, 1200×800, 84.0 KB)`, the size in pixels once known. */
103
+ export function mediaLine(m) {
104
+ return ` media: ${m.name} (${[m.kind, ...(m.width && m.height ? [`${m.width}×${m.height}`] : []), humanBytes(m.size)].join(', ')})`;
105
+ }
106
+ /**
107
+ * What `say --wait` prints once an input is done, merged, failed or waiting: its curated result.
108
+ *
109
+ * Training is 73% done, on pace to finish at 16:40
110
+ * Moved
111
+ * - Loss is down to 1.84: from 2.31 at step 5,000 (14:02)
112
+ * - Checkpoint saved: at step 7,000
113
+ * Still open
114
+ * - Eval has not run: it starts after the last checkpoint
115
+ * media: loss-curve.png (image, 1200×800, 84.0 KB)
116
+ * Sources: train.log (~/code/lm-train/train.log), wandb.ai (https://wandb.ai/acme/lm)
117
+ * Follow-ups: Check again in an hour
118
+ * Tasks: task_… (shard task show <id>)
119
+ *
120
+ * An answer written as facts prints them as ` - <fact>` lines instead of the points. Each file the answer shows prints
121
+ * one media line after them (shard drive get <path> --sha256 <sha256> reads it; --json has both).
122
+ */
123
+ export function resultText(i, now = new Date()) {
124
+ const lines = [];
125
+ if (i.answer) {
126
+ lines.push(i.answer.headline);
127
+ lines.push(...answerLines(i.answer, now));
128
+ for (const m of i.answer.media ?? [])
129
+ lines.push(mediaLine(m));
130
+ if (i.answer.sources.length)
131
+ lines.push(` Sources: ${i.answer.sources.map((s) => (s.label === s.ref ? s.label : `${s.label} (${s.ref})`)).join(', ')}`);
132
+ if (i.answer.actions.length)
133
+ lines.push(` Follow-ups: ${i.answer.actions.map((a) => a.label).join(' / ')}`);
134
+ }
135
+ if (i.task_ids.length)
136
+ lines.push(`Tasks: ${i.task_ids.join(', ')} (shard task show <id>)`);
137
+ if (i.state === 'merged')
138
+ lines.push(`Added to ${i.merged_into}: its thread has your words.`);
139
+ if (i.state === 'failed') {
140
+ lines.push(`Failed: ${i.error?.message ?? 'its thread stopped.'}`);
141
+ if (i.error?.retryable !== false)
142
+ lines.push(`Send it again: shard orchestrator retry ${i.id}`);
143
+ }
144
+ if (i.state === 'waiting' && i.waiting) {
145
+ lines.push(i.waiting.message);
146
+ const asked = i.question_ids.at(-1);
147
+ if (i.waiting.for === 'you' && asked)
148
+ lines.push(`Answer it: shard needs-you answer ${asked} --text <answer>`);
149
+ if (i.waiting.for === 'computer')
150
+ lines.push('It runs when your computer is back (the Shardflux app open).');
151
+ }
152
+ if (!lines.length)
153
+ lines.push(i.state === 'done' ? 'Done.' : stateLabel(i));
154
+ return `${lines.join('\n')}\n`;
155
+ }
156
+ /** Where `say --wait` stops following: nothing more happens to the input, or it waits on you or your computer. */
157
+ export const stopsWaiting = (i) => inputSettled(i) || i.state === 'waiting';
158
+ const orgOnly = (scope) => (scope.organizationId ? { organizationId: scope.organizationId } : {});
159
+ /**
160
+ * Follows input `id` until it stops waiting (stopsWaiting), `timeoutMs` passes, or Ctrl-C: a plain read, then the SDK's
161
+ * watch from its cursor, so no change between them is missed. Progress lines (its place in line, working) go to stderr.
162
+ */
163
+ async function follow(ctx, client, scope, first, timeoutMs) {
164
+ let last = first, shown = '';
165
+ const show = (i) => {
166
+ const line = i.state === 'queued' ? `Queued${i.queue_position ? `: ${i.queue_position} in line` : ''}${i.waiting ? ` (${i.waiting.message})` : ''}` : i.state === 'working' ? 'Working' : '';
167
+ if (line && line !== shown)
168
+ progress(ctx, line);
169
+ shown = line;
170
+ };
171
+ const page = await client.orchestrator.inputs({ ...orgOnly(scope), signal: ctx.signal });
172
+ last = page.inputs?.find((i) => i.id === first.id) ?? last;
173
+ if (stopsWaiting(last))
174
+ return { input: last, timedOut: false };
175
+ show(last);
176
+ const abort = new AbortController();
177
+ const stop = () => abort.abort();
178
+ ctx.signal.addEventListener('abort', stop, { once: true });
179
+ let timedOut = false;
180
+ const timer = setTimeout(() => { timedOut = true; abort.abort(); }, timeoutMs);
181
+ try {
182
+ for await (const u of client.orchestrator.watch({ ...orgOnly(scope), cursor: page.cursor, signal: abort.signal })) {
183
+ last = u.inputs.find((i) => i.id === first.id) ?? last;
184
+ if (stopsWaiting(last))
185
+ return { input: last, timedOut: false };
186
+ show(last);
187
+ }
188
+ }
189
+ finally {
190
+ clearTimeout(timer);
191
+ ctx.signal.removeEventListener('abort', stop);
192
+ }
193
+ return { input: last, timedOut };
194
+ }
195
+ const idOf = (id) => {
196
+ if (!id || !/^in_[a-z2-7]{26}$/.test(id))
197
+ throw new UsageError('Name the input by its id (in_…): shard orchestrator list shows them.');
198
+ return id;
199
+ };
200
+ export const ORCHESTRATOR_HANDLERS = {
201
+ async 'orchestrator say'(ctx, values, words) {
202
+ const text = words.join(' ');
203
+ if (!text.trim())
204
+ throw new UsageError('missing <words...> for "shard orchestrator say"');
205
+ if (text.length > ORCHESTRATOR_TEXT_MAX)
206
+ throw new UsageError(`your words are ${text.length} characters; one input takes up to ${ORCHESTRATOR_TEXT_MAX}`);
207
+ const selection = selectionOf(values);
208
+ const wait = values.wait === true;
209
+ const timeoutMs = str(values.timeout) === undefined ? SAY_WAIT_MS : parseDuration(str(values.timeout), 'timeout');
210
+ if (!wait && str(values.timeout) !== undefined)
211
+ throw new UsageError('--timeout goes with --wait.');
212
+ return inScope(ctx, values, async (client, scope) => {
213
+ const input = await client.orchestrator.say(text, { ...orgOnly(scope), ...(selection ? { selection } : {}), signal: ctx.signal });
214
+ if (!wait) {
215
+ out(ctx, `${input.id}\t${stateLabel(input)}\n`, { input });
216
+ return EXIT.ok;
217
+ }
218
+ progress(ctx, `Sent: ${input.id}`);
219
+ let followed;
220
+ try {
221
+ followed = await follow(ctx, client, scope, input, timeoutMs);
222
+ }
223
+ catch (err) {
224
+ if (ctx.signal.aborted)
225
+ return EXIT.interrupted;
226
+ throw err;
227
+ }
228
+ if (ctx.signal.aborted && !followed.timedOut && !stopsWaiting(followed.input))
229
+ return EXIT.interrupted;
230
+ const end = followed.input;
231
+ if (followed.timedOut) {
232
+ out(ctx, `${end.id}\t${stateLabel(end)}\n`, { input: end });
233
+ ctx.io.stderr.write(`Still ${end.state} after ${Math.round(timeoutMs / 1000)} s; it goes on: shard orchestrator list shows it.\n`);
234
+ return EXIT.timeout;
235
+ }
236
+ out(ctx, resultText(end), { input: end });
237
+ return end.state === 'failed' ? EXIT.operationFailed : EXIT.ok;
238
+ });
239
+ },
240
+ async 'orchestrator list'(ctx, values) {
241
+ const page = await inScope(ctx, values, (client, scope) => client.orchestrator.inputs({ ...orgOnly(scope), signal: ctx.signal }));
242
+ const shown = (page.inputs ?? []).filter((i) => values.all === true || !i.dismissed_at);
243
+ const human = shown.length ? `${shown.map(inputLine).join('\n')}\n` : 'Nothing to show yet: shard orchestrator say <words> sends your orchestrator something.\n';
244
+ out(ctx, human, { inputs: page.inputs ?? [], cursor: page.cursor });
245
+ return EXIT.ok;
246
+ },
247
+ async 'orchestrator retry'(ctx, values, [id]) {
248
+ const input = await inScope(ctx, values, (client, scope) => client.orchestrator.retry(idOf(id), { ...orgOnly(scope), signal: ctx.signal }));
249
+ out(ctx, `${input.id}\t${stateLabel(input)}\n`, { input });
250
+ return EXIT.ok;
251
+ },
252
+ async 'orchestrator dismiss'(ctx, values, [id]) {
253
+ const input = await inScope(ctx, values, (client, scope) => client.orchestrator.dismiss(idOf(id), { ...orgOnly(scope), signal: ctx.signal }));
254
+ out(ctx, `Dismissed: ${oneLine(input.text, 80)}\n`, { input });
255
+ return EXIT.ok;
256
+ },
257
+ async 'orchestrator restore'(ctx, values, [id]) {
258
+ const input = await inScope(ctx, values, (client, scope) => client.orchestrator.restore(idOf(id), { ...orgOnly(scope), signal: ctx.signal }));
259
+ out(ctx, `Restored: ${oneLine(input.text, 80)}\n`, { input });
260
+ return EXIT.ok;
261
+ },
262
+ };
@@ -0,0 +1,20 @@
1
+ import type { Shardflux, ShardfluxAccount } from '@shardflux/sdk';
2
+ import type { Values } from './args.js';
3
+ import type { Ctx } from './commands.js';
4
+ export interface BoardClient {
5
+ client: Shardflux | ShardfluxAccount;
6
+ /** The signed-in session: the organization's whole board. */
7
+ session: boolean;
8
+ }
9
+ /** SHARDFLUX_API_KEY in the environment wins; else the signed-in session; else the saved key (its error names both). */
10
+ export declare function boardClient(ctx: Ctx): BoardClient;
11
+ export interface Scope {
12
+ organizationId?: string;
13
+ projectId?: string;
14
+ }
15
+ /**
16
+ * Runs `call` in the board's scope. With the session and no --org/--project: the saved default organization, else the
17
+ * only one; when the person is in several and none is saved (422), or the saved one is gone (404), the error lists
18
+ * theirs and names the fix (resolveOrg), and an organization that resolves is used.
19
+ */
20
+ export declare function inScope<T>(ctx: Ctx, values: Values, call: (client: Shardflux | ShardfluxAccount, scope: Scope) => Promise<T>): Promise<T>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Which client and scope a person's command uses (0.25.0+): the signed-in session reads every project of an
3
+ * organization; SHARDFLUX_API_KEY in the environment (or a saved key without a session) reads that key's project. Used
4
+ * by `shard orchestrator`, `shard transcribe`, `shard route` and `shard ask`.
5
+ */
6
+ import { ShardfluxApiError } from '@shardflux/sdk';
7
+ import { UUID } from "./args.js";
8
+ import { findOrg, resolveOrg, resolveProject } from "./account-scope.js";
9
+ import { str } from "./commands.js";
10
+ import { UsageError } from "./errors.js";
11
+ /** SHARDFLUX_API_KEY in the environment wins; else the signed-in session; else the saved key (its error names both). */
12
+ export function boardClient(ctx) {
13
+ if (!ctx.io.env.SHARDFLUX_API_KEY?.trim() && ctx.sessionSource() !== null)
14
+ return { client: ctx.account(), session: true };
15
+ return { client: ctx.cloud(), session: false };
16
+ }
17
+ /** --org / --project as ids (slugs resolved with the session); with neither, the saved default organization. */
18
+ async function scopeOf(ctx, values, b) {
19
+ const org = str(values.org), project = str(values.project);
20
+ if (!b.session) {
21
+ for (const [flag, v] of [['--org', org], ['--project', project]]) {
22
+ if (v !== undefined && !UUID.test(v))
23
+ throw new UsageError(`${flag} must be an id (UUID) with a project API key`, 'or sign in (shard auth login) to name it by slug');
24
+ }
25
+ return { scope: { ...(org === undefined ? {} : { organizationId: org }), ...(project === undefined ? {} : { projectId: project }) }, defaulted: false };
26
+ }
27
+ const account = b.client;
28
+ const scope = {};
29
+ if (org !== undefined)
30
+ scope.organizationId = UUID.test(org) ? org : (await findOrg(account, org)).id;
31
+ if (project !== undefined)
32
+ scope.projectId = UUID.test(project) ? project : (await resolveProject(ctx, account, values)).id;
33
+ if (org !== undefined || project !== undefined)
34
+ return { scope, defaulted: false };
35
+ const saved = ctx.store.profile(ctx.apiUrl).organization_id;
36
+ return { scope: saved ? { organizationId: saved } : {}, defaulted: true };
37
+ }
38
+ /**
39
+ * Runs `call` in the board's scope. With the session and no --org/--project: the saved default organization, else the
40
+ * only one; when the person is in several and none is saved (422), or the saved one is gone (404), the error lists
41
+ * theirs and names the fix (resolveOrg), and an organization that resolves is used.
42
+ */
43
+ export async function inScope(ctx, values, call) {
44
+ const b = boardClient(ctx);
45
+ const { scope, defaulted } = await scopeOf(ctx, values, b);
46
+ try {
47
+ return await call(b.client, scope);
48
+ }
49
+ catch (err) {
50
+ if (!b.session || !defaulted || !(err instanceof ShardfluxApiError))
51
+ throw err;
52
+ const several = err.status === 422 && scope.organizationId === undefined && /organization/i.test(err.message);
53
+ const gone = err.status === 404 && scope.organizationId !== undefined;
54
+ if (!several && !gone)
55
+ throw err;
56
+ const org = await resolveOrg(ctx, b.client, {});
57
+ if (org.id === scope.organizationId)
58
+ throw err;
59
+ return call(b.client, { organizationId: org.id });
60
+ }
61
+ }
@@ -0,0 +1,20 @@
1
+ import type { Ctx } from './commands.js';
2
+ import type { TwinCell } from './worktree-twin.js';
3
+ import { projectHubCell } from './worktree-twin-engine.js';
4
+ /** Where work on a linked folder starts: this commit (in the project hub), on the folder's branch. */
5
+ export interface HubSeed {
6
+ commit: string;
7
+ branch: string | null;
8
+ }
9
+ /** Copies `commit` and its history from `folder` into the hub, kept under `ref`, unless the hub holds it already. */
10
+ export declare function sendToHub(cell: TwinCell, folder: string, commit: string, ref: string, opts?: {
11
+ hub?: string;
12
+ vmTmp?: string;
13
+ signal?: AbortSignal;
14
+ }): Promise<void>;
15
+ /** The folder's start point in project `projectId`'s hub, through a computer of the project that reads the hub. */
16
+ export declare function seedProjectHub(ctx: Ctx, folder: string, projectId: string, opts?: {
17
+ open?: typeof projectHubCell;
18
+ hub?: string;
19
+ vmTmp?: string;
20
+ }): Promise<HubSeed>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A linked folder's start point in the project hub (0.28.0+). When an agent links a folder of the person's (a device's
3
+ * project_link), the folder as it is now (its commit, with uncommitted files as a snapshot commit on top, as a chat
4
+ * started from it begins) is copied into the project hub. Work on it can then start in the cloud with no conversation
5
+ * of that folder yet: a task names the repository, the branch and this commit, and its master checks the commit out
6
+ * of the hub. The hub keeps it under refs/shard/seed/link-<folder id>; nothing else in the hub moves.
7
+ */
8
+ import { randomUUID } from 'node:crypto';
9
+ import { rm } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { UsageError } from "./errors.js";
13
+ import { writeSyncFileFrom } from "./project-sync-transfer.js";
14
+ import { HUB_PATH, git, inspectWorktree, twinSeed, worktreeId } from "./worktree-twin.js";
15
+ import { projectHubCell } from "./worktree-twin-engine.js";
16
+ /** Copies `commit` and its history from `folder` into the hub, kept under `ref`, unless the hub holds it already. */
17
+ export async function sendToHub(cell, folder, commit, ref, opts = {}) {
18
+ const hub = opts.hub ?? HUB_PATH;
19
+ const run = (...args) => cell.exec.run(['git', `--git-dir=${hub}`, '-c', 'gc.auto=0', ...args], { timeoutMs: 300_000, ...(opts.signal ? { signal: opts.signal } : {}) });
20
+ const holds = async () => (await run('cat-file', '-e', `${commit}^{commit}`)).exitCode === 0;
21
+ if (await holds()) {
22
+ const r = await run('update-ref', ref, commit);
23
+ if (r.exitCode !== 0)
24
+ throw new Error(`the project hub could not keep the folder's commit: ${r.stderr.trim().slice(-400)}`);
25
+ return;
26
+ }
27
+ const tmpRef = `refs/shard/seed-tmp/${randomUUID()}`, local = join(tmpdir(), `shard-hub-seed-${randomUUID()}.bundle`), remote = `${opts.vmTmp ?? '/tmp'}/shard-hub-seed-${randomUUID()}.bundle`;
28
+ await git(folder, ['update-ref', tmpRef, commit]);
29
+ try {
30
+ await git(folder, ['bundle', 'create', '-q', local, tmpRef]);
31
+ await writeSyncFileFrom(cell, remote, local, opts.signal);
32
+ const r = await run('fetch', '-q', '--no-tags', remote, `+${tmpRef}:${ref}`);
33
+ if (r.exitCode !== 0)
34
+ throw new Error(`the project hub could not take the folder: ${r.stderr.trim().slice(-400)}`);
35
+ }
36
+ finally {
37
+ await git(folder, ['update-ref', '-d', tmpRef], { ok: [0, 1] });
38
+ await rm(local, { force: true });
39
+ await cell.files.remove(remote).catch(() => undefined);
40
+ }
41
+ if (!await holds())
42
+ throw new Error('the project hub does not hold the folder after copying it');
43
+ }
44
+ /** The folder's start point in project `projectId`'s hub, through a computer of the project that reads the hub. */
45
+ export async function seedProjectHub(ctx, folder, projectId, opts = {}) {
46
+ const open = opts.open ?? projectHubCell;
47
+ const info = await inspectWorktree(folder);
48
+ if (!info.head)
49
+ throw new UsageError('Commit this folder once before linking it.');
50
+ const seed = await twinSeed(ctx.store.dir, folder, info.head, projectId);
51
+ const vm = await open(ctx, projectId, ctx.signal, 'shard-link');
52
+ try {
53
+ await sendToHub(vm.cell, folder, seed.commit, `refs/shard/seed/link-${worktreeId(folder)}`, { signal: ctx.signal, ...(opts.hub ? { hub: opts.hub } : {}), ...(opts.vmTmp ? { vmTmp: opts.vmTmp } : {}) });
54
+ }
55
+ finally {
56
+ vm.close();
57
+ }
58
+ return { commit: seed.commit, branch: info.branch?.replace(/^refs\/heads\//, '') || null };
59
+ }
@@ -1,7 +1,7 @@
1
1
  import type { RegistryOptions } from '@shardflux/sdk';
2
2
  export { NPM_REGISTRY, npmLatest, withRegistryLatest } from '@shardflux/sdk';
3
3
  /** The npm packages whose versions `shard update` compares. */
4
- export declare const NPM_CLIENTS: readonly ["shardflux", "@shardflux/cli", "@shardflux/sdk", "@shardflux/mcp", "@shardflux/menubar-darwin"];
4
+ export declare const NPM_CLIENTS: readonly string[];
5
5
  /** The registry to ask: SHARDFLUX_NPM_REGISTRY, else npm's public registry. */
6
6
  export declare function npmRegistry(env: Record<string, string | undefined>): string;
7
7
  /** `latest` of every package in `packages`, asked in parallel; a package the registry did not answer is absent. */
package/dist/registry.js CHANGED
@@ -4,9 +4,10 @@
4
4
  * `hold` (see withRegistryLatest).
5
5
  */
6
6
  import { npmLatest, registryFromEnv } from '@shardflux/sdk';
7
+ import { macAppPackage } from "./mac-app.js";
7
8
  export { NPM_REGISTRY, npmLatest, withRegistryLatest } from '@shardflux/sdk';
8
9
  /** The npm packages whose versions `shard update` compares. */
9
- export const NPM_CLIENTS = ['shardflux', '@shardflux/cli', '@shardflux/sdk', '@shardflux/mcp', '@shardflux/menubar-darwin'];
10
+ export const NPM_CLIENTS = ['shardflux', '@shardflux/cli', '@shardflux/sdk', '@shardflux/mcp', ...new Set([macAppPackage('arm64'), macAppPackage('x64')])];
10
11
  /** The registry to ask: SHARDFLUX_NPM_REGISTRY, else npm's public registry. */
11
12
  export function npmRegistry(env) {
12
13
  return registryFromEnv(env, 'npm');
@@ -1,25 +1,15 @@
1
- import type { RouterResult, Shardflux, ShardfluxAccount } from '@shardflux/sdk';
2
- import type { Values } from './args.js';
3
- import type { Ctx, Handler } from './commands.js';
4
- /** The API's bound on one input or question (characters). */
5
- export declare const BOARD_TEXT_MAX = 4000;
6
- interface BoardClient {
7
- client: Shardflux | ShardfluxAccount;
8
- /** The signed-in session: the organization's whole board. */
9
- session: boolean;
10
- }
11
- /** SHARDFLUX_API_KEY in the environment wins; else the signed-in session; else the saved key (its error names both). */
12
- export declare function boardClient(ctx: Ctx): BoardClient;
13
- interface Scope {
14
- organizationId?: string;
15
- projectId?: string;
16
- }
17
1
  /**
18
- * Runs `call` in the board's scope. With the session and no --org/--project: the saved default organization, else the
19
- * only one; when the person is in several and none is saved (422), or the saved one is gone (404), the error lists
20
- * theirs and names the fix (resolveOrg), and an organization that resolves is used.
2
+ * `shard route` and `shard ask` (0.25.0+): the board's input bar from a terminal, through @shardflux/sdk's
3
+ * `router.route()` and `questions.stream()` / `questions.ask()`. The signed-in session reads every project of an
4
+ * organization; SHARDFLUX_API_KEY in the environment (or a saved key without a session) reads that key's project.
5
+ *
6
+ * Deprecated since 0.27.0: each prints a one-line notice on stderr naming
7
+ * `shard orchestrator say`, which sends the input to the person's orchestrator agent (orchestrator-commands.ts).
21
8
  */
22
- export declare function inScope<T>(ctx: Ctx, values: Values, call: (client: Shardflux | ShardfluxAccount, scope: Scope) => Promise<T>): Promise<T>;
9
+ import type { RouterResult } from '@shardflux/sdk';
10
+ import type { Handler } from './commands.js';
11
+ /** The API's bound on one input or question (characters). */
12
+ export declare const BOARD_TEXT_MAX = 4000;
23
13
  /**
24
14
  * The readable form of a RouterResult: the kind and where it goes, then each part (a split's parts numbered, with
25
15
  * their titles).
@@ -29,5 +19,6 @@ export declare function inScope<T>(ctx: Ctx, values: Values, call: (client: Shar
29
19
  * fix the hero image on the landing page
30
20
  */
31
21
  export declare function routeText(r: RouterResult, names: ReadonlyMap<string, string>): string;
22
+ /** The one-line notice `shard route` and `shard ask` print on stderr (also with --json, whose output stays the same). */
23
+ export declare const deprecationNotice: (command: "route" | "ask") => string;
32
24
  export declare const ROUTER_HANDLERS: Record<string, Handler>;
33
- export {};
@@ -1,67 +1,9 @@
1
- /**
2
- * `shard route` and `shard ask` (0.25.0+): the board's input bar from a terminal, through @shardflux/sdk's
3
- * `router.route()` and `questions.stream()` / `questions.ask()`. The signed-in session reads every project of an
4
- * organization; SHARDFLUX_API_KEY in the environment (or a saved key without a session) reads that key's project.
5
- */
6
- import { ShardfluxApiError } from '@shardflux/sdk';
7
- import { UUID } from "./args.js";
8
- import { findOrg, resolveOrg, resolveProject } from "./account-scope.js";
1
+ import { inScope } from "./person-scope.js";
9
2
  import { out, str } from "./commands.js";
10
3
  import { EXIT, UsageError } from "./errors.js";
11
4
  /** The API's bound on one input or question (characters). */
12
5
  export const BOARD_TEXT_MAX = 4000;
13
6
  const TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
14
- /** SHARDFLUX_API_KEY in the environment wins; else the signed-in session; else the saved key (its error names both). */
15
- export function boardClient(ctx) {
16
- if (!ctx.io.env.SHARDFLUX_API_KEY?.trim() && ctx.sessionSource() !== null)
17
- return { client: ctx.account(), session: true };
18
- return { client: ctx.cloud(), session: false };
19
- }
20
- /** --org / --project as ids (slugs resolved with the session); with neither, the saved default organization. */
21
- async function scopeOf(ctx, values, b) {
22
- const org = str(values.org), project = str(values.project);
23
- if (!b.session) {
24
- for (const [flag, v] of [['--org', org], ['--project', project]]) {
25
- if (v !== undefined && !UUID.test(v))
26
- throw new UsageError(`${flag} must be an id (UUID) with a project API key`, 'or sign in (shard auth login) to name it by slug');
27
- }
28
- return { scope: { ...(org === undefined ? {} : { organizationId: org }), ...(project === undefined ? {} : { projectId: project }) }, defaulted: false };
29
- }
30
- const account = b.client;
31
- const scope = {};
32
- if (org !== undefined)
33
- scope.organizationId = UUID.test(org) ? org : (await findOrg(account, org)).id;
34
- if (project !== undefined)
35
- scope.projectId = UUID.test(project) ? project : (await resolveProject(ctx, account, values)).id;
36
- if (org !== undefined || project !== undefined)
37
- return { scope, defaulted: false };
38
- const saved = ctx.store.profile(ctx.apiUrl).organization_id;
39
- return { scope: saved ? { organizationId: saved } : {}, defaulted: true };
40
- }
41
- /**
42
- * Runs `call` in the board's scope. With the session and no --org/--project: the saved default organization, else the
43
- * only one; when the person is in several and none is saved (422), or the saved one is gone (404), the error lists
44
- * theirs and names the fix (resolveOrg), and an organization that resolves is used.
45
- */
46
- export async function inScope(ctx, values, call) {
47
- const b = boardClient(ctx);
48
- const { scope, defaulted } = await scopeOf(ctx, values, b);
49
- try {
50
- return await call(b.client, scope);
51
- }
52
- catch (err) {
53
- if (!b.session || !defaulted || !(err instanceof ShardfluxApiError))
54
- throw err;
55
- const several = err.status === 422 && scope.organizationId === undefined && /organization/i.test(err.message);
56
- const gone = err.status === 404 && scope.organizationId !== undefined;
57
- if (!several && !gone)
58
- throw err;
59
- const org = await resolveOrg(ctx, b.client, {});
60
- if (org.id === scope.organizationId)
61
- throw err;
62
- return call(b.client, { organizationId: org.id });
63
- }
64
- }
65
7
  function textOf(words, what, command) {
66
8
  const text = words.join(' ').trim();
67
9
  if (!text)
@@ -118,6 +60,8 @@ export function routeText(r, names) {
118
60
  }
119
61
  return `${lines.join('\n')}\n`;
120
62
  }
63
+ /** The one-line notice `shard route` and `shard ask` print on stderr (also with --json, whose output stays the same). */
64
+ export const deprecationNotice = (command) => `shard ${command} is deprecated; use shard orchestrator say <words> instead.\n`;
121
65
  const interrupted = (ctx, err) => {
122
66
  if (ctx.signal.aborted)
123
67
  return EXIT.interrupted;
@@ -125,6 +69,7 @@ const interrupted = (ctx, err) => {
125
69
  };
126
70
  export const ROUTER_HANDLERS = {
127
71
  async route(ctx, values, words) {
72
+ ctx.io.stderr.write(deprecationNotice('route'));
128
73
  const text = textOf(words, 'text', 'route');
129
74
  const selectedTaskId = taskOf(values);
130
75
  try {
@@ -144,6 +89,7 @@ export const ROUTER_HANDLERS = {
144
89
  }
145
90
  },
146
91
  async ask(ctx, values, words) {
92
+ ctx.io.stderr.write(deprecationNotice('ask'));
147
93
  const text = textOf(words, 'question', 'ask');
148
94
  const taskId = taskOf(values);
149
95
  const params = { text, ...(taskId === undefined ? {} : { taskId }), signal: ctx.signal };
package/dist/t3-launch.js CHANGED
@@ -8,7 +8,7 @@ import { agentReachability } from "./agent-reachability.js";
8
8
  import { conversationWorkspace, getConversation } from "./conversations.js";
9
9
  import { resolveOn } from "./thread-on-computer.js";
10
10
  import { ensureCloudClaude } from "./claude-cloud-login.js";
11
- import { modelChoice } from "./agent-models.js";
11
+ import { liveModels, modelChoice } from "./agent-models.js";
12
12
  /**
13
13
  * A `t3_*` refusal raised once the workspace, conversation or thread exists names them (details.workspace_key,
14
14
  * conversation_id, t3_thread_id), so a client opens what exists instead of starting it again.
@@ -32,7 +32,7 @@ export async function t3ThreadRequest(ctx, values, app) {
32
32
  return undefined;
33
33
  }
34
34
  // The same choices as every other new thread (agent-models.ts); "default" leaves the agent's own.
35
- const choice = modelChoice(values, app);
35
+ const choice = modelChoice(values, app, await liveModels(ctx, values, app));
36
36
  return { app, prompt, ...(choice.model ? { model: choice.model } : {}), ...(choice.effort ? { effort: choice.effort } : {}), ...(images.length ? { images } : {}) };
37
37
  }
38
38
  /**
@@ -11,7 +11,10 @@ Input: one JSON object on stdin. Output: one JSON object on stdout (exit 0; "sta
11
11
  title for the merge commit's message
12
12
 
13
13
  The result commit T lands in the default branch D: a fast-forward when D is an ancestor of T, nothing when T is already
14
- in D, else a merge commit when the merge is clean (a conflict: state "conflict", stage "default"; nothing changes). The
14
+ in D, else a merge commit when the merge is clean (a conflict: state "conflict", stage "default"; nothing changes).
15
+ Commits with no history in common (a result started from another repository than the one D holds, or two workers from
16
+ different repositories) never merge: state "unrelated" with its stage, nothing changes. Unlike a conflict it does not
17
+ clear by trying again. The
15
18
  hub's branches move only by `git push` from a temporary repository, never by force, so a branch someone else moved
16
19
  meanwhile is refused (stage "raced") instead of overwritten. Before answering "landed" it checks on the hub itself that
17
20
  T is contained in D, and which sources are contained in T.
@@ -55,6 +58,11 @@ def ancestor(cwd, a, b):
55
58
  return git(cwd, 'merge-base', '--is-ancestor', a, b, ok=(0, 1)).returncode == 0
56
59
 
57
60
 
61
+ def related(cwd, a, b):
62
+ """Whether a and b have any history in common (git finds a merge base)."""
63
+ return git(cwd, 'merge-base', a, b, ok=(0, 1)).returncode == 0
64
+
65
+
58
66
  def merge(repo, ours, theirs, message):
59
67
  """A merge commit of theirs into ours when the merge is clean, else (None, conflicting paths)."""
60
68
  r = git(repo, 'merge-tree', '--write-tree', '--name-only', '--no-messages', '-z', ours, theirs, ok=(0, 1))
@@ -107,6 +115,8 @@ def land(a):
107
115
  if not t or ancestor(work, t, st):
108
116
  t = st
109
117
  elif not ancestor(work, st, t):
118
+ if not related(work, t, st):
119
+ return {'state': 'unrelated', 'stage': 'integrate', 'into': into, 'source': s}
110
120
  t, conflicts = merge(work, t, st, 'Merge %s into %s' % (s, result))
111
121
  if not t:
112
122
  return {'state': 'conflict', 'stage': 'integrate', 'into': into, 'source': s, 'conflicts': conflicts}
@@ -120,6 +130,11 @@ def land(a):
120
130
  elif not before or ancestor(work, before, t):
121
131
  method, after = ('created' if not before else 'ff'), t
122
132
  else:
133
+ if not related(work, before, t):
134
+ # Another repository's branch (a project's hub holds every folder its people linked): the result still
135
+ # lands in its own folder; the sources it holds say which workers' work that is.
136
+ contained = [s for s in sources if have.get(s) and ancestor(work, have[s], t)]
137
+ return {'state': 'unrelated', 'stage': 'default', 'into': into, 'result': t, 'before': before, 'contained': contained}
123
138
  after, conflicts = merge(work, before, t, 'Land %s (%s)' % (title, result))
124
139
  if not after:
125
140
  return {'state': 'conflict', 'stage': 'default', 'into': into, 'result': t, 'before': before, 'conflicts': conflicts}