@shardflux/cli 0.24.1 → 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 (76) hide show
  1. package/CHANGELOG.md +159 -1
  2. package/README.md +182 -35
  3. package/dist/account-pool.d.ts +11 -1
  4. package/dist/account-pool.js +48 -0
  5. package/dist/acp-journal.js +23 -1
  6. package/dist/agent-commands.js +4 -3
  7. package/dist/agent-models.d.ts +31 -1
  8. package/dist/agent-models.js +27 -4
  9. package/dist/args.d.ts +1 -1
  10. package/dist/args.js +97 -14
  11. package/dist/board-args.js +26 -4
  12. package/dist/board-commands.d.ts +9 -5
  13. package/dist/board-commands.js +43 -16
  14. package/dist/brain-commands.d.ts +2 -0
  15. package/dist/brain-commands.js +78 -0
  16. package/dist/client-settings.d.ts +2 -1
  17. package/dist/client-settings.js +14 -2
  18. package/dist/codex-cloud.d.ts +2 -0
  19. package/dist/codex-cloud.js +14 -11
  20. package/dist/commands.js +8 -0
  21. package/dist/conversation-commands.d.ts +5 -1
  22. package/dist/conversation-commands.js +76 -8
  23. package/dist/conversation-terminal.js +2 -2
  24. package/dist/conversations.d.ts +2 -1
  25. package/dist/desktop-linux.d.ts +12 -1
  26. package/dist/desktop-linux.js +47 -18
  27. package/dist/device-approvals.js +24 -2
  28. package/dist/device-bridge.d.ts +21 -4
  29. package/dist/device-bridge.js +173 -7
  30. package/dist/device-folders.d.ts +14 -1
  31. package/dist/device-folders.js +20 -8
  32. package/dist/device-helper.d.ts +8 -0
  33. package/dist/device-helper.js +83 -13
  34. package/dist/device-mode.d.ts +44 -0
  35. package/dist/device-mode.js +119 -0
  36. package/dist/drive-commands.d.ts +2 -2
  37. package/dist/drive-commands.js +1 -1
  38. package/dist/errors.js +2 -1
  39. package/dist/format.js +6 -2
  40. package/dist/handlers.js +5 -2
  41. package/dist/land.d.ts +121 -0
  42. package/dist/land.js +284 -0
  43. package/dist/mac-app.d.ts +11 -0
  44. package/dist/mac-app.js +11 -0
  45. package/dist/main.js +9 -1
  46. package/dist/menubar.d.ts +18 -13
  47. package/dist/menubar.js +60 -50
  48. package/dist/onboarding.d.ts +4 -4
  49. package/dist/orchestrator-args.d.ts +7 -0
  50. package/dist/orchestrator-args.js +64 -0
  51. package/dist/orchestrator-commands.d.ts +43 -0
  52. package/dist/orchestrator-commands.js +262 -0
  53. package/dist/person-scope.d.ts +20 -0
  54. package/dist/person-scope.js +61 -0
  55. package/dist/project-hub-seed.d.ts +20 -0
  56. package/dist/project-hub-seed.js +59 -0
  57. package/dist/registry.d.ts +1 -1
  58. package/dist/registry.js +2 -1
  59. package/dist/router-commands.d.ts +12 -10
  60. package/dist/router-commands.js +5 -59
  61. package/dist/t3-launch.js +2 -2
  62. package/dist/task-land-hub.py +165 -0
  63. package/dist/this-computer.d.ts +4 -2
  64. package/dist/this-computer.js +20 -2
  65. package/dist/thread-on-computer.js +2 -2
  66. package/dist/timing.js +3 -1
  67. package/dist/transcribe-commands.d.ts +7 -0
  68. package/dist/transcribe-commands.js +84 -0
  69. package/dist/update.js +4 -2
  70. package/dist/wait-status.d.ts +1 -1
  71. package/dist/wait-status.js +2 -0
  72. package/dist/worktree-twin-engine.d.ts +8 -0
  73. package/dist/worktree-twin-engine.js +29 -23
  74. package/dist/worktree-twin.d.ts +6 -0
  75. package/dist/worktree-twin.js +8 -1
  76. package/package.json +6 -5
package/dist/land.js ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Landing a result for real (0.25.0+): `shard task land <task>` and `shard conversations land <id> merge`.
3
+ *
4
+ * A board task has one result: its master merges its workers' branches into shard/<task id> on the project hub before it
5
+ * seals done (a master that did not is helped here: its workers' branches are merged into it when they merge cleanly).
6
+ * Landing then
7
+ * 1. merges the result into the project's default branch on the hub, in a VM of the project (task-land-hub.py): a
8
+ * fast-forward, or a merge commit when the merge is clean, never by force; and checks there that the result
9
+ * commit is contained in the branch;
10
+ * 2. brings it into the person's linked folder on this computer through the live twin's own landing
11
+ * (worktree-twin.ts landingRound from the hub): the branch moves to the new commits, uncommitted edits stay, every
12
+ * rewritten file is backed up first, and a Git operation in progress or a file being edited waits;
13
+ * 3. only then records it (POST /v1/tasks/{id}/land with the commit; for a conversation POST /land merge into the
14
+ * branch). A conflict changes nothing: the task's master is asked to merge (a steer) and nothing is recorded.
15
+ */
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { setTimeout as delay } from 'node:timers/promises';
20
+ import { conversationClient } from "./conversation-commands.js";
21
+ import { localConversations } from "./conversations.js";
22
+ import { CodedError } from "./errors.js";
23
+ import { selectedProjects } from "./project-preparation.js";
24
+ import { projectIdentity } from "./project-identity.js";
25
+ import { HUB_PATH, backupRoot, deviceId, git, gitText, inspectWorktree, landingRound, listWorktrees } from "./worktree-twin.js";
26
+ import { projectHubCell } from "./worktree-twin-engine.js";
27
+ let script;
28
+ /** The hub side of a landing, shipped next to this module (src in the monorepo, dist when published). */
29
+ export function hubScript() { return script ??= readFileSync(new URL('./task-land-hub.py', import.meta.url), 'utf8'); }
30
+ /** Merges `result` (made from `sources` when missing) into the hub's default branch, in a VM that reads the hub. */
31
+ export async function landOnHub(cell, input, signal) {
32
+ const body = { hub: input.hub ?? HUB_PATH, result: input.result, sources: input.sources, title: input.title, ...(input.into ? { default: input.into } : {}) };
33
+ const r = await cell.exec.run(['python3', '-c', hubScript()], { stdin: JSON.stringify(body), timeoutMs: 300_000, maxOutputBytes: 1 << 20, ...(signal ? { signal } : {}) });
34
+ const line = r.stdout.trim().split('\n').at(-1) ?? '';
35
+ if (r.exitCode !== 0 || !line.startsWith('{'))
36
+ return { state: 'error', into: input.into ?? '', reason: 'script', message: `the landing script exited ${r.exitCode}: ${r.stderr.trim().slice(-400)}` };
37
+ return JSON.parse(line);
38
+ }
39
+ /** The branch to land in: the one checked out in the folder, else the hub's default branch when the folder has it. */
40
+ export async function folderBranch(folder, fallback) {
41
+ const info = await inspectWorktree(folder);
42
+ const own = info.branch?.replace(/^refs\/heads\//, '');
43
+ if (own && !own.startsWith('shard/'))
44
+ return own;
45
+ const has = await git(folder, ['rev-parse', '-q', '--verify', `refs/heads/${fallback}^{commit}`], { ok: [0, 1, 128] });
46
+ return has.code === 0 ? fallback : null;
47
+ }
48
+ /**
49
+ * The result into the folder through the twin's landing (from the hub): the branch moves, the person's uncommitted
50
+ * edits stay, and the landed commit is checked to be on the branch.
51
+ */
52
+ export async function landInFolder(cell, f) {
53
+ const deadline = Date.now() + (f.patienceMs ?? 20_000);
54
+ const target = (await listWorktrees(f.folder)).find(w => w.branch === `refs/heads/${f.into}`);
55
+ const repo = target?.root ?? f.folder;
56
+ for (let attempt = 0;; attempt++) {
57
+ const worktree = target ? await inspectWorktree(target.root) : null;
58
+ const o = await landingRound(cell, {
59
+ conversation: { id: f.name, name: f.name, title: f.title, summary: '', branch: f.branch, base_commit: f.base, worktree_path: '' },
60
+ into: f.into, mode: 'auto', device: f.device, repo, worktree, stateDir: f.stateDir, backupRoot: backupRoot(f.stateDir), fromHub: {},
61
+ ...(f.hub ? { hub: f.hub } : {}), ...(f.quietMs !== undefined ? { quietMs: f.quietMs } : {}), ...(f.signal ? { signal: f.signal } : {}),
62
+ });
63
+ const tip = async () => gitText(repo, ['rev-parse', `refs/heads/${f.into}`]);
64
+ switch (o.state) {
65
+ case 'landed': {
66
+ await o.confirmed.catch(() => undefined);
67
+ const landed = o.land.tip, now = await tip();
68
+ const on = await git(repo, ['merge-base', '--is-ancestor', landed, `refs/heads/${f.into}`], { ok: [0, 1] });
69
+ if (on.code !== 0)
70
+ throw new CodedError('land_unverified', `${f.into} in ${f.folder} does not contain the landed commit ${landed.slice(0, 7)}.`);
71
+ return { path: f.folder, into: f.into, state: 'landed', sha: now, method: o.land.method ?? 'landed', files: o.applied.files.length, kept: o.applied.raced, backup: o.applied.backup };
72
+ }
73
+ case 'noop': return { path: f.folder, into: f.into, state: 'already', sha: await tip() };
74
+ case 'conflict': return { path: f.folder, into: f.into, state: 'conflict', conflicts: o.land.conflicts ?? [] };
75
+ case 'error': throw new CodedError('land_failed', `Could not land into ${f.folder}: ${o.land.message ?? o.land.reason ?? 'the landing failed'}.`);
76
+ case 'pr':
77
+ case 'deferred': return { path: f.folder, into: f.into, state: 'waiting', reason: o.state };
78
+ default: {
79
+ // paused (a Git operation), moved (the branch or a file changed while landing), busy (a file just saved).
80
+ const wait = o.state === 'busy' ? Math.min(o.waitMs ?? 1000, 3000) : o.state === 'moved' ? 200 : 1000;
81
+ if (Date.now() + wait > deadline || attempt >= 20)
82
+ return { path: f.folder, into: f.into, state: 'waiting', reason: o.reason };
83
+ await delay(wait, undefined, f.signal ? { signal: f.signal } : {});
84
+ }
85
+ }
86
+ }
87
+ }
88
+ /** The repository a folder holds, as conversations started from it name it (its origin, else the folder itself). */
89
+ export function folderRepository(folder) {
90
+ try {
91
+ const id = projectIdentity(folder);
92
+ return id.remote ?? `file://${id.folder}`;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ /**
99
+ * The person's folder for a project on this computer: a linked folder (shard projects link) or where it was started.
100
+ * With `repository` (a project holds every folder its people linked), only a folder of that repository.
101
+ */
102
+ export function linkedFolder(ctx, projectId, conversationId, repository) {
103
+ const repo = (folder) => folder && existsSync(join(folder, '.git')) && (!repository || folderRepository(folder) === repository) ? folder : null;
104
+ const records = localConversations(ctx);
105
+ const own = conversationId ? repo(records[conversationId]?.folder) : null;
106
+ if (own)
107
+ return own;
108
+ // A folder linked with `shard projects link` belongs to the signed-in project until its preparation names one.
109
+ const profile = ctx.store.profile(ctx.apiUrl), mine = profile.project_id ?? profile.api_key?.project_id;
110
+ const linked = selectedProjects(ctx).filter(p => (p.project_id ?? mine) === projectId).map(p => repo(p.folder)).find(Boolean);
111
+ if (linked)
112
+ return linked;
113
+ return Object.values(records).filter(r => r.project_id === projectId).map(r => repo(r.folder)).find(Boolean) ?? null;
114
+ }
115
+ export const displayPath = (path) => { const home = homedir(); return path === home ? '~' : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; };
116
+ const short = (sha) => (sha ?? '').slice(0, 7);
117
+ const list = (files) => files.length > 4 ? `${files.slice(0, 4).join(', ')} and ${files.length - 4} more` : files.join(', ');
118
+ function message(o) {
119
+ const f = o.folder;
120
+ if (f && (f.state === 'landed' || f.state === 'already'))
121
+ return `Landed in ${f.display} · ${short(f.sha)}`;
122
+ const hub = `Landed in ${o.hub.into} on the project hub · ${short(o.hub.sha)}`;
123
+ if (f?.state === 'conflict')
124
+ return `${hub}. Not in ${f.display}: your commits on ${f.into} change ${list(f.conflicts ?? [])} too.`;
125
+ if (f?.state === 'waiting')
126
+ return `${hub}. ${f.display} gets it once it is not busy (${f.reason ?? 'busy'}).`;
127
+ return hub;
128
+ }
129
+ export function landDeps(ctx) {
130
+ return { client: conversationClient(ctx), hubCell: p => projectHubCell(ctx, p, ctx.signal, 'shard-land'), folder: (p, c, r) => linkedFolder(ctx, p, c, r),
131
+ device: deviceId(ctx.store.dir), stateDir: ctx.store.dir, signal: ctx.signal };
132
+ }
133
+ async function land(d, l, onConflict) {
134
+ const vm = await d.hubCell(l.projectId);
135
+ try {
136
+ const input = { result: l.result, sources: l.sources.map(s => s.branch), title: l.title, ...(d.hub ? { hub: d.hub } : {}) };
137
+ let hub = await landOnHub(vm.cell, input, d.signal);
138
+ if (hub.state === 'raced')
139
+ hub = await landOnHub(vm.cell, input, d.signal);
140
+ if (hub.state === 'conflict') {
141
+ await onConflict(hub);
142
+ throw new CodedError('land_conflict', `Not landed: ${list(hub.conflicts ?? [])} changed on both sides (${hub.stage === 'integrate' ? `${hub.source} and ${l.result}` : `${l.result} and ${hub.into}`}).`, 'its master merges them and asks to land again', { stage: hub.stage, into: hub.into, conflicts: hub.conflicts ?? [] });
143
+ }
144
+ // The hub's default branch is another repository's (a project holds every folder its people linked): a result
145
+ // whose repository has its folder here lands in that folder, and the hub's default branch stays as it is.
146
+ // Awaited here: the computer that reads the hub closes in `finally` (staging 2026-10-17: returned unawaited, the
147
+ // landing was cut off mid-way, "Interrupted.").
148
+ if (hub.state === 'unrelated' && hub.stage === 'default' && l.repository && l.folder && hub.result)
149
+ return await ownFolder(d, vm.cell, l, hub);
150
+ // No history in common (started from another repository than the branch it lands in): it never clears by trying
151
+ // again, so it says what to do instead.
152
+ if (hub.state === 'unrelated') {
153
+ throw new CodedError('land_unrelated', hub.stage === 'integrate'
154
+ ? `Not landed: ${hub.source} and ${l.result} have no history in common; they were started from different repositories.`
155
+ : `Not landed: ${l.result} has no history in common with ${hub.into} on the project hub; it was started from another repository than the one ${hub.into} holds.`, 'land it from the folder of the repository it was started from, or start the task again from this project’s own folder', { stage: hub.stage, into: hub.into });
156
+ }
157
+ if (hub.state === 'missing')
158
+ throw new CodedError('land_nothing', `Nothing to land: the project hub has no ${l.result} yet.`, 'land after the work is done', { branch: l.result });
159
+ if (hub.state !== 'landed')
160
+ throw new CodedError('land_failed', `Could not land on the project hub: ${hub.message ?? hub.reason ?? hub.state}.`, 'try again in a moment', { stage: hub.stage ?? null });
161
+ const contained = l.sources.filter(s => hub.contained?.includes(s.branch)).map(s => s.conversation_id);
162
+ let folder = null;
163
+ if (l.folder) {
164
+ const into = await folderBranch(l.folder, hub.into);
165
+ if (!into)
166
+ folder = { path: l.folder, display: displayPath(l.folder), into: hub.into, state: 'skipped', reason: 'detached' };
167
+ else {
168
+ const f = await landInFolder(vm.cell, { folder: l.folder, into, name: l.name, title: l.title, branch: l.result, base: l.base ?? hub.merge_base ?? hub.before ?? hub.result,
169
+ device: d.device, stateDir: d.stateDir, ...(d.hub ? { hub: d.hub } : {}), ...(d.quietMs !== undefined ? { quietMs: d.quietMs } : {}), ...(d.signal ? { signal: d.signal } : {}) });
170
+ folder = { ...f, display: displayPath(f.path) };
171
+ }
172
+ }
173
+ return { title: l.title, result: { branch: l.result, sha: hub.result }, hub: { into: hub.into, sha: hub.after, method: hub.method }, folder, contained };
174
+ }
175
+ finally {
176
+ vm.close();
177
+ }
178
+ }
179
+ /** A result into the folder of its own repository only (the hub's default branch holds another repository). */
180
+ async function ownFolder(d, cell, l, hub) {
181
+ const folder = l.folder, into = await folderBranch(folder, 'main');
182
+ const contained = l.sources.filter(s => hub.contained?.includes(s.branch)).map(s => s.conversation_id);
183
+ if (!into)
184
+ return { title: l.title, result: { branch: l.result, sha: hub.result }, hub: { into: '', sha: hub.result, method: 'folder' }, contained,
185
+ folder: { path: folder, display: displayPath(folder), into: '', state: 'skipped', reason: 'detached' } };
186
+ const f = await landInFolder(cell, { folder, into, name: l.name, title: l.title, branch: l.result, base: l.base ?? hub.result, device: d.device, stateDir: d.stateDir,
187
+ ...(d.hub ? { hub: d.hub } : {}), ...(d.quietMs !== undefined ? { quietMs: d.quietMs } : {}), ...(d.signal ? { signal: d.signal } : {}) });
188
+ if (f.state !== 'landed' && f.state !== 'already') {
189
+ // Nothing else holds it: a folder that could not take it yet is not a landing.
190
+ throw new CodedError('land_failed', f.state === 'conflict'
191
+ ? `Not landed yet: ${list(f.conflicts ?? [])} changed in ${displayPath(folder)} too since ${l.result} started.`
192
+ : `Not landed yet: ${displayPath(folder)} is busy (${f.reason ?? 'busy'}).`, f.state === 'conflict' ? 'commit or put aside those changes, and it lands' : 'it lands once the folder is quiet', { into, folder: displayPath(folder) });
193
+ }
194
+ return { title: l.title, result: { branch: l.result, sha: hub.result }, hub: { into, sha: f.sha ?? hub.result, method: 'folder' }, folder: { ...f, display: displayPath(f.path) }, contained };
195
+ }
196
+ /** What a landing did, for people: the line, then what was not recorded. */
197
+ export function landedText(o) {
198
+ return `${o.message}\n${o.recorded ? '' : `Not recorded on the board: ${o.record_error ?? 'unknown'}.\n`}`;
199
+ }
200
+ const recordFailure = (error) => {
201
+ const e = error;
202
+ return e.status === 404 ? 'this API does not record task landings yet' : (e.message ?? String(error));
203
+ };
204
+ /** The task's current result: its master's latest final report, when it is done. */
205
+ function currentResult(d) {
206
+ const own = d.task.master_id ? d.reports.filter(r => r.author_id === d.task.master_id) : d.reports;
207
+ const final = [...own].reverse().find(r => r.milestone === 'done' || r.milestone === 'failed' || r.milestone === 'planned');
208
+ return final?.milestone === 'done' ? final.id : null;
209
+ }
210
+ const resultBranch = (taskId) => `shard/${taskId}`;
211
+ /** `shard task land <task>`: the task's result into the project's default branch and the linked folder, then recorded. */
212
+ export async function landTask(d, taskId, opts = {}) {
213
+ const client = d.client, signal = d.signal ? { signal: d.signal } : {};
214
+ const detail = await client.tasks.get(taskId, signal);
215
+ const task = detail.task, title = task.title ?? task.text.split('\n')[0].slice(0, 80);
216
+ const report = currentResult(detail);
217
+ if (!report || (task.state !== 'done' && task.state !== 'unverified'))
218
+ throw new CodedError('task_not_done', 'This task has no finished result to land yet.', 'land it after its master’s done report', { state: task.state });
219
+ const workers = [];
220
+ for (const c of task.conversations) {
221
+ if (c.role === 'master' || c.role === 'reviewer')
222
+ continue;
223
+ const w = await client.conversations.get(c.id);
224
+ if (w.join_mode !== 'reviewer' && (w.changes?.commits ?? 0) > 0 && w.landed?.how !== 'discarded')
225
+ workers.push(w);
226
+ }
227
+ // The repository the result is of: its workers' (or its master's), so it lands only in a folder of that repository.
228
+ const repository = workers.find(w => w.repo_remote)?.repo_remote ?? (task.master_id ? (await client.conversations.get(task.master_id).catch(() => null))?.repo_remote : undefined) ?? null;
229
+ const folder = opts.path ?? d.folder(task.project_id, undefined, repository ?? undefined);
230
+ const done = await land(d, { projectId: task.project_id, title, name: taskId, result: resultBranch(taskId), sources: workers.map(w => ({ branch: w.branch, conversation_id: w.id })),
231
+ base: workers.find(w => w.base_commit)?.base_commit ?? null, folder, repository }, async (hub) => {
232
+ const what = hub.stage === 'integrate' ? `${hub.source} into ${resultBranch(taskId)}` : `${resultBranch(taskId)} into ${hub.into}`;
233
+ await client.tasks.steer(taskId, `Land could not merge ${what}: ${list(hub.conflicts ?? [])} changed on both sides. Merge ${hub.into} from the project hub and every worker branch into ${resultBranch(taskId)}, resolve the conflicts so both changes work (ask the person only if the choice is theirs), run the tests, push ${resultBranch(taskId)} to the hub and seal done again.`, signal);
234
+ });
235
+ let recorded = false, record_error;
236
+ try {
237
+ await client.tasks.recordLanding(taskId, { sha: done.result.sha, into: done.hub.into, branch: done.result.branch, reportId: report, conversationIds: done.contained }, signal);
238
+ recorded = true;
239
+ }
240
+ catch (error) {
241
+ record_error = recordFailure(error);
242
+ // An API without task landings (before train 41) keeps a land item per worker: the workers whose branches are in
243
+ // the landed commit are recorded as merged into the branch, which is true, so those items go.
244
+ if (error.status === 404 && done.contained.length) {
245
+ let all = true;
246
+ for (const id of done.contained) {
247
+ try {
248
+ await client.conversations.land(id, 'merge', { into: done.hub.into, ...signal });
249
+ }
250
+ catch {
251
+ all = false;
252
+ }
253
+ }
254
+ if (all) {
255
+ recorded = true;
256
+ record_error = undefined;
257
+ }
258
+ }
259
+ }
260
+ const { title: t, result, hub, folder: f } = done;
261
+ const o = { title: t, result, hub, folder: f, landed: true, task_id: taskId, recorded, ...(record_error ? { record_error } : {}) };
262
+ return { ...o, message: message(o) };
263
+ }
264
+ /** `shard conversations land <id> merge`: the conversation's branch into the default branch and its folder, then recorded. */
265
+ export async function landConversation(d, id, opts = {}) {
266
+ const client = d.client;
267
+ const c = await client.conversations.get(id);
268
+ if (c.changes?.uncommitted)
269
+ throw new CodedError('uncommitted_changes', 'The conversation has uncommitted changes.', 'ask its agent to commit them, then land');
270
+ const folder = opts.path ?? d.folder(c.project_id, c.id);
271
+ const done = await land(d, { projectId: c.project_id, title: c.title || c.branch, name: c.name ?? c.branch.replace(/^shard\//, ''), result: c.branch, sources: [],
272
+ base: c.base_commit ?? null, folder }, () => Promise.resolve());
273
+ let recorded = false, record_error;
274
+ try {
275
+ await client.conversations.land(id, 'merge', { into: done.hub.into, ...(d.signal ? { signal: d.signal } : {}) });
276
+ recorded = true;
277
+ }
278
+ catch (error) {
279
+ record_error = recordFailure(error);
280
+ }
281
+ const { title: t, result, hub, folder: f } = done;
282
+ const o = { title: t, result, hub, folder: f, landed: true, conversation_id: id, recorded, ...(record_error ? { record_error } : {}) };
283
+ return { ...o, message: message(o) };
284
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Which macOS app this CLI installs and updates. On (0.27.0+): the one Shardflux app from
3
+ * @shardflux/desktop-darwin-<arch>, which replaces the menu bar app (@shardflux/menubar-darwin) in place. The CLI's
4
+ * optionalDependencies carry the same packages (test/one-app.test.ts keeps the two in step): with the desktop package
5
+ * in the install, `shard`, its refresh and the running menu bar app's self-update replace the menu bar app with it.
6
+ */
7
+ export declare const ONE_APP_ON_MAC = true;
8
+ /** The npm package that carries the one Shardflux app for this Mac's CPU (it replaces @shardflux/menubar-darwin). */
9
+ export declare const desktopDarwinPackage: (arch?: string) => string;
10
+ /** The npm package that carries the macOS app this CLI installs and updates (see ONE_APP_ON_MAC). */
11
+ export declare const macAppPackage: (arch?: string) => string;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Which macOS app this CLI installs and updates. On (0.27.0+): the one Shardflux app from
3
+ * @shardflux/desktop-darwin-<arch>, which replaces the menu bar app (@shardflux/menubar-darwin) in place. The CLI's
4
+ * optionalDependencies carry the same packages (test/one-app.test.ts keeps the two in step): with the desktop package
5
+ * in the install, `shard`, its refresh and the running menu bar app's self-update replace the menu bar app with it.
6
+ */
7
+ export const ONE_APP_ON_MAC = true;
8
+ /** The npm package that carries the one Shardflux app for this Mac's CPU (it replaces @shardflux/menubar-darwin). */
9
+ export const desktopDarwinPackage = (arch = process.arch) => `@shardflux/desktop-darwin-${arch === 'x64' ? 'x64' : 'arm64'}`;
10
+ /** The npm package that carries the macOS app this CLI installs and updates (see ONE_APP_ON_MAC). */
11
+ export const macAppPackage = (arch = process.arch) => ONE_APP_ON_MAC ? desktopDarwinPackage(arch) : '@shardflux/menubar-darwin';
package/dist/main.js CHANGED
@@ -66,7 +66,11 @@ function hintFor(info, cred, file, command = '') {
66
66
  if (info.code === 'timeout' && info.operation_id)
67
67
  return `the operation continues server side; keep waiting with: shard operations wait ${info.operation_id}`;
68
68
  if (info.code === 'operation_failed' && info.retryable) {
69
- return info.details?.error_code === 'capacity_unavailable' ? 'nothing was started; run the same command again' : 'the operation may succeed if you retry it later.';
69
+ if (info.details?.error_code === 'capacity_unavailable') {
70
+ const retry = info.details.retry_after_seconds;
71
+ return `nothing was started; run the same command again${typeof retry === 'number' && retry > 0 ? ` after ${retry} s` : ''}`;
72
+ }
73
+ return 'the operation may succeed if you retry it later.';
70
74
  }
71
75
  if (info.code === 'interrupted' && info.operation_id)
72
76
  return `keep waiting with: shard operations wait ${info.operation_id}`;
@@ -133,6 +137,8 @@ function hintFor(info, cred, file, command = '') {
133
137
  const current = typeof info.details?.current_revision === 'string' ? info.details.current_revision : 'unknown';
134
138
  return `the file changed since that revision (current revision: ${current}); read it again, then retry with --expected-revision ${current}`;
135
139
  }
140
+ if (reason === 'workspace_disk_full' || info.code === 'workspace_disk_full')
141
+ return 'free space with shard files delete <id|key> <path>, or run a cleanup command; retry the write after space is available';
136
142
  if (reason === 'edit_not_found')
137
143
  return `--old${index} does not occur in the file (it must match exactly, including whitespace)`;
138
144
  if (reason === 'edit_ambiguous')
@@ -175,6 +181,8 @@ function allowanceHint(info) {
175
181
  const resets = typeof info.details?.resets_at === 'string' ? ` (the period resets ${time(info.details.resets_at)})` : '';
176
182
  switch (info.details?.reason) {
177
183
  case 'allowance_used':
184
+ if (info.details?.spend_cap === null)
185
+ return 'choose a paid plan: shard billing plans; overage is unavailable on the current plan';
178
186
  return `upgrade (shard billing plans), or turn on overage: shard billing overage on --cap <dollars>${resets}`;
179
187
  case 'spend_cap_reached':
180
188
  return `the spend cap is reached: raise it (shard billing overage cap <dollars>, at most the plan price) or upgrade${resets}`;
package/dist/menubar.d.ts CHANGED
@@ -30,12 +30,6 @@ export declare function compareAppVersions(left: string, right: string): number;
30
30
  export declare const SELF_UPDATING_APP = "0.3.2";
31
31
  /** CFBundleShortVersionString of a Shardflux app from its XML Info.plist, read without any tool (null: not readable). */
32
32
  export declare function plistVersion(app: string): string | null;
33
- /**
34
- * Whether any of these processes shows a window: the menu, the launcher, Settings, onboarding, an approval. The status
35
- * item itself (a strip at the top of the screen on the status layer, owned by the app before macOS 26) does not count.
36
- * Null when the probe could not answer: then nothing may be replaced.
37
- */
38
- export declare function windowsOpen(text: string): boolean | null;
39
33
  export declare function parseNativeStatus(text: string): NativeStatus;
40
34
  /**
41
35
  * The app's executables keep their execute permission (0.13.6+). @shardflux/menubar-darwin 0.1.1 and 0.1.2 reached npm
@@ -56,17 +50,15 @@ export declare class MenubarManager {
56
50
  get platform(): NodeJS.Platform;
57
51
  /** The version of the app this CLI's npm package carries (what `start` installs). */
58
52
  packagedVersion(): Promise<string>;
59
- /** Whether the app shows any window (see windowsOpen); null when that could not be read. */
60
- windowsOpen(pids: number[]): Promise<boolean | null>;
61
53
  status(configDir?: string): Promise<MenubarStatus>;
62
54
  stop(configDir?: string): Promise<MenubarStatus>;
63
55
  login(value: 'on' | 'off', configDir?: string): Promise<MenubarStatus>;
64
56
  start(configDir: string, apiUrl: string): Promise<MenubarStatus>;
65
57
  /**
66
58
  * Brings an installed app up to the one this CLI's npm package carries, without disturbing the person: nothing when
67
- * the versions are equal (each swap of an ad-hoc signed app costs its privacy grants), the copy on disk alone when it
68
- * is not running, and a running app only when it shows no window and no `shard update` runs. A running app that
69
- * swaps itself in (SELF_UPDATING_APP or later) is left to do so: it also knows when it is in the middle of something.
59
+ * the versions are equal, the copy on disk alone when it is not running. A running app swaps itself in when it is
60
+ * idle (every app from SELF_UPDATING_APP on: the menu bar app, and the one Shardflux app that replaces it, which runs
61
+ * `shard menubar start` when nothing is open); an older running one is restarted unless a `shard update` runs.
70
62
  */
71
63
  refresh(configDir: string, apiUrl: string): Promise<{
72
64
  result: 'current' | 'not_installed' | 'installed' | 'restarted' | 'deferred' | 'busy';
@@ -79,8 +71,21 @@ export declare class MenubarManager {
79
71
  export declare function ensureMenubar(dir: string, apiUrl: string, manager?: MenubarManager, platform?: NodeJS.Platform): Promise<string | undefined>;
80
72
  /** How often a command may start a background refresh while the app is busy (a window open). */
81
73
  export declare const REFRESH_RETRY_MS: number;
82
- /** Where this CLI's npm package keeps the app it carries, or null when the optional package is missing. */
83
- export declare function packagedAppPath(): string | null;
74
+ /**
75
+ * Where this CLI's npm package keeps the app it carries, or null when the optional package is missing. The links npm
76
+ * leaves out of the package are put back first (restorePackagedAppLinks), so the path is the complete, signed app.
77
+ */
78
+ export declare function packagedAppPath(arch?: string): string | null;
79
+ /**
80
+ * Puts back the symbolic links of the package's Shardflux.app that npm leaves out of a package (0.27.0+): an Electron
81
+ * app's frameworks are made of them (Versions/Current -> A, ...), and without them the app neither starts nor verifies.
82
+ * The package lists them in links.json; its postinstall restores them too, but pnpm and bun skip install scripts. Only
83
+ * relative links inside the app; a path that exists is left as it is. `missing`: links that could not be created.
84
+ */
85
+ export declare function restorePackagedAppLinks(packageDir: string): {
86
+ restored: string[];
87
+ missing: string[];
88
+ };
84
89
  export interface BackgroundRefreshOptions {
85
90
  dir: string;
86
91
  home: string;
package/dist/menubar.js CHANGED
@@ -1,15 +1,16 @@
1
1
  import { desktopAccount } from "./desktop-account.js";
2
2
  import { execFile, spawn } from 'node:child_process';
3
- import { chmodSync, closeSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs';
3
+ import { chmodSync, closeSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeSync } from 'node:fs';
4
4
  import { createRequire } from 'node:module';
5
5
  import { homedir } from 'node:os';
6
- import { dirname, join } from 'node:path';
6
+ import { dirname, isAbsolute, join, resolve as resolvePath, sep } from 'node:path';
7
7
  import { promisify } from 'node:util';
8
8
  import { compareVersions } from '@shardflux/sdk';
9
9
  import { AgentStateStore } from "./agent-state.js";
10
10
  import { UsageError } from "./errors.js";
11
11
  import { json } from "./format.js";
12
12
  import { LinuxDesktopManager, linuxDesktopPackage } from "./desktop-linux.js";
13
+ import { macAppPackage } from "./mac-app.js";
13
14
  const execute = promisify(execFile);
14
15
  /**
15
16
  * Every native tool a default MenubarManager runs (plutil, codesign, xattr, open, the app's --control) goes through
@@ -56,35 +57,6 @@ export function plistVersion(app) {
56
57
  return null;
57
58
  }
58
59
  }
59
- const WINDOW_PROBE = `ObjC.import('CoreGraphics');
60
- function run(argv) {
61
- const pids = argv.map(Number);
62
- const list = ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly, $.kCGNullWindowID))) || [];
63
- return JSON.stringify(list.filter(w => pids.includes(w.kCGWindowOwnerPID)).map(w => ({ pid: w.kCGWindowOwnerPID, layer: w.kCGWindowLayer,
64
- x: w.kCGWindowBounds.X, y: w.kCGWindowBounds.Y, width: w.kCGWindowBounds.Width, height: w.kCGWindowBounds.Height, alpha: w.kCGWindowAlpha })));
65
- }`;
66
- /**
67
- * Whether any of these processes shows a window: the menu, the launcher, Settings, onboarding, an approval. The status
68
- * item itself (a strip at the top of the screen on the status layer, owned by the app before macOS 26) does not count.
69
- * Null when the probe could not answer: then nothing may be replaced.
70
- */
71
- export function windowsOpen(text) {
72
- let rows;
73
- try {
74
- rows = JSON.parse(text);
75
- }
76
- catch {
77
- return null;
78
- }
79
- if (!Array.isArray(rows))
80
- return null;
81
- const windows = rows;
82
- if (!windows.every(w => w && typeof w === 'object' && ['layer', 'y', 'width', 'height'].every(k => typeof w[k] === 'number')))
83
- return null;
84
- // A status item is a strip on the status layer at the top of the screen (39 pt high on a notched display).
85
- const statusItem = (w) => w.layer === 25 && w.y <= 0 && w.height <= 50;
86
- return windows.some(w => w.layer >= 0 && !statusItem(w) && (w.alpha ?? 1) > 0 && w.width > 1 && w.height > 1);
87
- }
88
60
  export function parseNativeStatus(text) {
89
61
  const value = JSON.parse(text);
90
62
  if (!value || typeof value !== 'object')
@@ -129,6 +101,8 @@ export class MenubarManager {
129
101
  const app = packagedAppPath();
130
102
  if (!app)
131
103
  throw new UsageError('The macOS app package is missing. Reinstall shard with optional dependencies enabled, then run shard menubar start.');
104
+ if (restorePackagedAppLinks(dirname(app)).missing.length)
105
+ throw new UsageError(`The Shardflux app in ${dirname(app)} is incomplete and that folder is not writable. Reinstall shard (npm i -g shardflux), then run shard.`);
132
106
  return app;
133
107
  });
134
108
  }
@@ -150,17 +124,6 @@ export class MenubarManager {
150
124
  compareBundleVersions(version, version);
151
125
  return version;
152
126
  }
153
- /** Whether the app shows any window (see windowsOpen); null when that could not be read. */
154
- async windowsOpen(pids) {
155
- if (!pids.length)
156
- return false;
157
- try {
158
- return windowsOpen(await this.#run('/usr/bin/osascript', ['-l', 'JavaScript', '-e', WINDOW_PROBE, ...pids.map(String)]));
159
- }
160
- catch {
161
- return null;
162
- }
163
- }
164
127
  /**
165
128
  * The app's --control for `configDir`: its pids are the app running for that config directory, wherever it runs from
166
129
  * (menu bar 0.8.4+, one app per config directory), and any app running from the installed bundle.
@@ -213,7 +176,9 @@ export class MenubarManager {
213
176
  const staged = join(stage, 'Shardflux.app'), backup = join(stage, 'previous.app');
214
177
  let committed = false, restored = false;
215
178
  try {
216
- cpSync(source, staged, { recursive: true, dereference: false });
179
+ // Relative symlinks stay relative (an app's frameworks are made of them): Node's default rewrites them to absolute
180
+ // paths into the npm package, which breaks the bundle's seal and its helpers (0.27.0+).
181
+ cpSync(source, staged, { recursive: true, dereference: false, verbatimSymlinks: true });
217
182
  if (current.running)
218
183
  await this.stop(configDir);
219
184
  if (current.installed)
@@ -265,9 +230,9 @@ export class MenubarManager {
265
230
  }
266
231
  /**
267
232
  * Brings an installed app up to the one this CLI's npm package carries, without disturbing the person: nothing when
268
- * the versions are equal (each swap of an ad-hoc signed app costs its privacy grants), the copy on disk alone when it
269
- * is not running, and a running app only when it shows no window and no `shard update` runs. A running app that
270
- * swaps itself in (SELF_UPDATING_APP or later) is left to do so: it also knows when it is in the middle of something.
233
+ * the versions are equal, the copy on disk alone when it is not running. A running app swaps itself in when it is
234
+ * idle (every app from SELF_UPDATING_APP on: the menu bar app, and the one Shardflux app that replaces it, which runs
235
+ * `shard menubar start` when nothing is open); an older running one is restarted unless a `shard update` runs.
271
236
  */
272
237
  async refresh(configDir, apiUrl) {
273
238
  this.#requireMac();
@@ -280,7 +245,7 @@ export class MenubarManager {
280
245
  if (current.running) {
281
246
  if (compareAppVersions(from, SELF_UPDATING_APP) >= 0)
282
247
  return { result: 'deferred', from, to };
283
- if (updateRunning(configDir) || (await this.windowsOpen(current.pids)) !== false)
248
+ if (updateRunning(configDir))
284
249
  return { result: 'busy', from, to };
285
250
  await this.start(configDir, apiUrl);
286
251
  return { result: 'restarted', from, to };
@@ -332,14 +297,59 @@ const REFRESH_MARK = 'menubar-refresh.json';
332
297
  const REFRESH_LOCK = 'menubar-refresh.lock';
333
298
  /** How often a command may start a background refresh while the app is busy (a window open). */
334
299
  export const REFRESH_RETRY_MS = 5 * 60_000;
335
- /** Where this CLI's npm package keeps the app it carries, or null when the optional package is missing. */
336
- export function packagedAppPath() {
300
+ /**
301
+ * Where this CLI's npm package keeps the app it carries, or null when the optional package is missing. The links npm
302
+ * leaves out of the package are put back first (restorePackagedAppLinks), so the path is the complete, signed app.
303
+ */
304
+ export function packagedAppPath(arch = process.arch) {
305
+ let dir;
337
306
  try {
338
- return join(dirname(createRequire(import.meta.url).resolve('@shardflux/menubar-darwin/package.json')), 'Shardflux.app');
307
+ dir = dirname(createRequire(import.meta.url).resolve(`${macAppPackage(arch)}/package.json`));
339
308
  }
340
309
  catch {
341
310
  return null;
342
311
  }
312
+ restorePackagedAppLinks(dir);
313
+ return join(dir, 'Shardflux.app');
314
+ }
315
+ /**
316
+ * Puts back the symbolic links of the package's Shardflux.app that npm leaves out of a package (0.27.0+): an Electron
317
+ * app's frameworks are made of them (Versions/Current -> A, ...), and without them the app neither starts nor verifies.
318
+ * The package lists them in links.json; its postinstall restores them too, but pnpm and bun skip install scripts. Only
319
+ * relative links inside the app; a path that exists is left as it is. `missing`: links that could not be created.
320
+ */
321
+ export function restorePackagedAppLinks(packageDir) {
322
+ const restored = [], missing = [];
323
+ let spec;
324
+ try {
325
+ spec = JSON.parse(readFileSync(join(packageDir, 'links.json'), 'utf8'));
326
+ }
327
+ catch {
328
+ return { restored, missing };
329
+ }
330
+ const app = join(packageDir, 'Shardflux.app');
331
+ const links = spec.links;
332
+ for (const entry of Array.isArray(links) ? links : []) {
333
+ const [path, target] = Array.isArray(entry) ? entry : [];
334
+ if (typeof path !== 'string' || typeof target !== 'string' || !path || !target || isAbsolute(path) || isAbsolute(target))
335
+ continue;
336
+ const at = resolvePath(app, path);
337
+ if (!at.startsWith(app + sep) || !resolvePath(dirname(at), target).startsWith(app + sep))
338
+ continue;
339
+ try {
340
+ lstatSync(at);
341
+ continue;
342
+ }
343
+ catch { /* missing: put it back */ }
344
+ try {
345
+ symlinkSync(target, at);
346
+ restored.push(path);
347
+ }
348
+ catch {
349
+ missing.push(path);
350
+ }
351
+ }
352
+ return { restored, missing };
343
353
  }
344
354
  /**
345
355
  * Every `shard` command (0.15.2+): when the npm package carries a newer app than the installed one (two Info.plist
@@ -111,7 +111,7 @@ export declare function onboardingReport(ctx: Ctx, values: Values, platform?: No
111
111
  };
112
112
  conversation: {
113
113
  id: string;
114
- state: "archived" | "working" | "needs_you" | "unresolved" | "done";
114
+ state: "deleted" | "archived" | "working" | "needs_you" | "unresolved" | "done";
115
115
  summary: string;
116
116
  } | null;
117
117
  commands: never[];
@@ -142,7 +142,7 @@ export declare function onboardingReport(ctx: Ctx, values: Values, platform?: No
142
142
  };
143
143
  conversation: {
144
144
  id: string;
145
- state: "archived" | "working" | "needs_you" | "unresolved" | "done";
145
+ state: "deleted" | "archived" | "working" | "needs_you" | "unresolved" | "done";
146
146
  summary: string;
147
147
  } | null;
148
148
  commands: string[];
@@ -195,7 +195,7 @@ export declare function liveActivity(ctx: Ctx, ref: string): Promise<{
195
195
  };
196
196
  conversation: {
197
197
  id: string;
198
- state: "archived" | "working" | "needs_you" | "unresolved" | "done";
198
+ state: "deleted" | "archived" | "working" | "needs_you" | "unresolved" | "done";
199
199
  summary: string;
200
200
  } | null;
201
201
  commands: never[];
@@ -226,7 +226,7 @@ export declare function liveActivity(ctx: Ctx, ref: string): Promise<{
226
226
  };
227
227
  conversation: {
228
228
  id: string;
229
- state: "archived" | "working" | "needs_you" | "unresolved" | "done";
229
+ state: "deleted" | "archived" | "working" | "needs_you" | "unresolved" | "done";
230
230
  summary: string;
231
231
  } | null;
232
232
  commands: string[];
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Your orchestrator's command table (0.27.0+, SDK 0.29.0): `shard orchestrator say|list|retry|dismiss`, and `restore`
3
+ * (0.28.0+, SDK 0.30.0). Pure data like
4
+ * args.ts, which merges it into COMMANDS; orchestrator-commands.ts has the handlers.
5
+ */
6
+ import type { CommandSpec } from './args.js';
7
+ export declare const ORCHESTRATOR_COMMANDS: CommandSpec[];