@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
package/dist/handlers.js CHANGED
@@ -5,6 +5,7 @@ import { CONVERSATION_HANDLERS } from "./conversation-commands.js";
5
5
  import { BOARD_HANDLERS } from "./board-commands.js";
6
6
  import { TRANSCRIPT_HANDLERS } from "./conversation-transcript.js";
7
7
  import { ROUTER_HANDLERS } from "./router-commands.js";
8
+ import { ORCHESTRATOR_HANDLERS } from "./orchestrator-commands.js";
8
9
  import { TRANSCRIBE_HANDLERS } from "./transcribe-commands.js";
9
10
  /**
10
11
  * Every command's handler, keyed by its path ("workspaces open", "auth login"): the project-key commands of
@@ -25,11 +26,12 @@ import { TWIN_HANDLERS } from "./worktree-twin-engine.js";
25
26
  import { CODEX_CLOUD_HANDLERS } from "./codex-cloud.js";
26
27
  import { THREAD_HANDLERS } from "./thread-on-computer.js";
27
28
  import { CLI_PROXY_HANDLERS } from "./cli-proxy.js";
28
- import { ACCOUNT_POOL_HANDLERS } from "./account-pool.js";
29
+ import { ACCOUNT_POOL_HANDLERS, AGENT_MODELS_HANDLERS } from "./account-pool.js";
29
30
  import { MOVE_HANDLERS } from "./move.js";
30
31
  import { AGENT_CONFIG_HANDLERS } from "./agent-config-sync.js";
31
32
  import { MACHINE_SETUP_HANDLERS } from "./machine-setup.js";
32
33
  import { SETTINGS_HANDLERS } from "./client-settings.js";
33
34
  import { PROJECT_PREPARATION_HANDLERS } from "./project-preparation.js";
34
35
  import { DRIVE_HANDLERS } from "./drive-commands.js";
35
- export const HANDLERS = { ...PROJECT_PREPARATION_HANDLERS, ...PROJECT_LINK_HANDLERS, ...MOVE_HANDLERS, ...DEVICE_HANDLERS, ...CODEX_CLOUD_HANDLERS, ...CLI_PROXY_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...BOARD_HANDLERS, ...TRANSCRIPT_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...ACCOUNT_POOL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...MACHINE_SETUP_HANDLERS, ...SETTINGS_HANDLERS, ...UPDATE_HANDLERS, ...AGENT_CONFIG_HANDLERS, ...ROUTER_HANDLERS, ...TRANSCRIBE_HANDLERS, ...DRIVE_HANDLERS };
36
+ import { BRAIN_HANDLERS } from "./brain-commands.js";
37
+ export const HANDLERS = { ...PROJECT_PREPARATION_HANDLERS, ...PROJECT_LINK_HANDLERS, ...MOVE_HANDLERS, ...DEVICE_HANDLERS, ...CODEX_CLOUD_HANDLERS, ...CLI_PROXY_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...ORCHESTRATOR_HANDLERS, ...BOARD_HANDLERS, ...TRANSCRIPT_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...ACCOUNT_POOL_HANDLERS, ...AGENT_MODELS_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...MACHINE_SETUP_HANDLERS, ...SETTINGS_HANDLERS, ...UPDATE_HANDLERS, ...AGENT_CONFIG_HANDLERS, ...ROUTER_HANDLERS, ...TRANSCRIBE_HANDLERS, ...DRIVE_HANDLERS, ...BRAIN_HANDLERS };
package/dist/land.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { TwinCell } from './worktree-twin.js';
4
4
  /** The hub side of a landing, shipped next to this module (src in the monorepo, dist when published). */
5
5
  export declare function hubScript(): string;
6
6
  export interface HubLanding {
7
- state: 'landed' | 'conflict' | 'missing' | 'raced' | 'error';
7
+ state: 'landed' | 'conflict' | 'unrelated' | 'missing' | 'raced' | 'error';
8
8
  into: string;
9
9
  before?: string | null;
10
10
  after?: string;
@@ -61,8 +61,13 @@ export declare function folderBranch(folder: string, fallback: string): Promise<
61
61
  * edits stay, and the landed commit is checked to be on the branch.
62
62
  */
63
63
  export declare function landInFolder(cell: TwinCell, f: FolderInput): Promise<FolderLanding>;
64
- /** The person's folder for a project on this computer: a linked folder (shard projects link) or where it was started. */
65
- export declare function linkedFolder(ctx: Pick<Ctx, 'store' | 'apiUrl'>, projectId: string, conversationId?: string): string | null;
64
+ /** The repository a folder holds, as conversations started from it name it (its origin, else the folder itself). */
65
+ export declare function folderRepository(folder: string): string | null;
66
+ /**
67
+ * The person's folder for a project on this computer: a linked folder (shard projects link) or where it was started.
68
+ * With `repository` (a project holds every folder its people linked), only a folder of that repository.
69
+ */
70
+ export declare function linkedFolder(ctx: Pick<Ctx, 'store' | 'apiUrl'>, projectId: string, conversationId?: string, repository?: string): string | null;
66
71
  export declare const displayPath: (path: string) => string;
67
72
  export interface LandOutcome {
68
73
  landed: true;
@@ -96,7 +101,7 @@ export interface LandDeps {
96
101
  cell: TwinCell;
97
102
  close(): void;
98
103
  }>;
99
- folder(projectId: string, conversationId?: string): string | null;
104
+ folder(projectId: string, conversationId?: string, repository?: string): string | null;
100
105
  device: string;
101
106
  stateDir: string;
102
107
  hub?: string;
package/dist/land.js CHANGED
@@ -21,6 +21,7 @@ import { conversationClient } from "./conversation-commands.js";
21
21
  import { localConversations } from "./conversations.js";
22
22
  import { CodedError } from "./errors.js";
23
23
  import { selectedProjects } from "./project-preparation.js";
24
+ import { projectIdentity } from "./project-identity.js";
24
25
  import { HUB_PATH, backupRoot, deviceId, git, gitText, inspectWorktree, landingRound, listWorktrees } from "./worktree-twin.js";
25
26
  import { projectHubCell } from "./worktree-twin-engine.js";
26
27
  let script;
@@ -84,9 +85,22 @@ export async function landInFolder(cell, f) {
84
85
  }
85
86
  }
86
87
  }
87
- /** The person's folder for a project on this computer: a linked folder (shard projects link) or where it was started. */
88
- export function linkedFolder(ctx, projectId, conversationId) {
89
- const repo = (folder) => folder && existsSync(join(folder, '.git')) ? folder : null;
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;
90
104
  const records = localConversations(ctx);
91
105
  const own = conversationId ? repo(records[conversationId]?.folder) : null;
92
106
  if (own)
@@ -113,7 +127,7 @@ function message(o) {
113
127
  return hub;
114
128
  }
115
129
  export function landDeps(ctx) {
116
- return { client: conversationClient(ctx), hubCell: p => projectHubCell(ctx, p, ctx.signal, 'shard-land'), folder: (p, c) => linkedFolder(ctx, p, c),
130
+ return { client: conversationClient(ctx), hubCell: p => projectHubCell(ctx, p, ctx.signal, 'shard-land'), folder: (p, c, r) => linkedFolder(ctx, p, c, r),
117
131
  device: deviceId(ctx.store.dir), stateDir: ctx.store.dir, signal: ctx.signal };
118
132
  }
119
133
  async function land(d, l, onConflict) {
@@ -127,6 +141,19 @@ async function land(d, l, onConflict) {
127
141
  await onConflict(hub);
128
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 ?? [] });
129
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
+ }
130
157
  if (hub.state === 'missing')
131
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 });
132
159
  if (hub.state !== 'landed')
@@ -149,6 +176,23 @@ async function land(d, l, onConflict) {
149
176
  vm.close();
150
177
  }
151
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
+ }
152
196
  /** What a landing did, for people: the line, then what was not recorded. */
153
197
  export function landedText(o) {
154
198
  return `${o.message}\n${o.recorded ? '' : `Not recorded on the board: ${o.record_error ?? 'unknown'}.\n`}`;
@@ -180,9 +224,11 @@ export async function landTask(d, taskId, opts = {}) {
180
224
  if (w.join_mode !== 'reviewer' && (w.changes?.commits ?? 0) > 0 && w.landed?.how !== 'discarded')
181
225
  workers.push(w);
182
226
  }
183
- const folder = opts.path ?? d.folder(task.project_id);
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);
184
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 })),
185
- base: workers.find(w => w.base_commit)?.base_commit ?? null, folder }, async (hub) => {
231
+ base: workers.find(w => w.base_commit)?.base_commit ?? null, folder, repository }, async (hub) => {
186
232
  const what = hub.stage === 'integrate' ? `${hub.source} into ${resultBranch(taskId)}` : `${resultBranch(taskId)} into ${hub.into}`;
187
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);
188
234
  });
@@ -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
@@ -137,6 +137,8 @@ function hintFor(info, cred, file, command = '') {
137
137
  const current = typeof info.details?.current_revision === 'string' ? info.details.current_revision : 'unknown';
138
138
  return `the file changed since that revision (current revision: ${current}); read it again, then retry with --expected-revision ${current}`;
139
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';
140
142
  if (reason === 'edit_not_found')
141
143
  return `--old${index} does not occur in the file (it must match exactly, including whitespace)`;
142
144
  if (reason === 'edit_ambiguous')
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
@@ -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[];
@@ -0,0 +1,64 @@
1
+ const ORG = {
2
+ org: { type: 'string', value: '<id|slug>', description: 'Organization whose orchestrator it is (default: the saved one, shard orgs use, else your only organization). Slugs need your signed-in session.' },
3
+ };
4
+ const WHO = 'Your orchestrator is yours: one agent on its own Shardflux computer, the same on every device. Uses your signed-in session, or SHARDFLUX_API_KEY when it is set (a project key you created: a new orchestrator is filed under its project).';
5
+ export const ORCHESTRATOR_COMMANDS = [
6
+ {
7
+ path: ['orchestrator', 'say'],
8
+ summary: 'Say something to your orchestrator; one of its threads hears each input at once (0.27.0+)',
9
+ positionals: [{ name: 'words', variadic: true, words: true, required: true }],
10
+ options: {
11
+ task: { type: 'string', value: '<task-id>', description: 'Point at a task (task_…).' },
12
+ report: { type: 'string', value: '<report-id>', description: 'Point at a report (rep_…).' },
13
+ 'needs-you': { type: 'string', value: '<item-id>', description: 'Point at something that waits on you (perm_…, oq_…).' },
14
+ input: { type: 'string', value: '<input-id>', description: 'Point at an earlier input (in_…).' },
15
+ conversation: { type: 'string', value: '<conversation-id>', description: 'Point at a conversation (conv_…).' },
16
+ wait: { type: 'boolean', description: 'Follow it until it is done, merged, failed or waiting, then print what it became.' },
17
+ timeout: { type: 'string', value: '<duration>', description: 'With --wait: stop following after this long (default 10m). The input goes on.' },
18
+ ...ORG,
19
+ },
20
+ description: [
21
+ 'Your exact words go to your orchestrator, with the row you point at. It answers at once: the input id and its state (queued, with its place in line; every input waits its turn in order). One of your orchestrator’s threads hears each input at once, all in parallel: a follow-up reaches the thread of the request it is about (the input reads merged), the row you point at reaches the thread of that row’s input, and anything new gets a thread of its own, which answers it, puts tasks on the board (shard board) or asks you something (shard needs-you, oq_…).',
22
+ '--wait follows it and prints its curated result: the answer’s headline and its points under their group headings (with when each happened) or its facts, a media line per file it shows (an image with its size in pixels), with their sources and follow-ups, the tasks it created, the line it waits on (a question names the shard needs-you answer command), or the input it was added to. Exit 6 when it failed (shard orchestrator retry sends it again), 5 when --timeout passed first.',
23
+ WHO,
24
+ 'With --json: {input}, as it was stored, or with --wait as it ended.',
25
+ ].join('\n\n'),
26
+ examples: ['shard orchestrator say Lets check on our language model training progress --wait', 'shard orchestrator say --task task_h7g4d7tqyv2pz3a2npyo4rpgzq use postgres instead', 'shard orchestrator say "what is blocking pricing?" --wait --json'],
27
+ },
28
+ {
29
+ path: ['orchestrator', 'list'],
30
+ summary: 'List what you said to your orchestrator and what each became, newest first (0.27.0+)',
31
+ positionals: [],
32
+ options: { all: { type: 'boolean', description: 'Also list the inputs you dismissed.' }, ...ORG },
33
+ description: [
34
+ 'One line per input: its id, its state (queued with its place in line, working, waiting with what it waits on, done, merged, failed), your words, and what it became (the answer’s headline, its tasks, the input it was added to, or why it failed). Inputs still in flight are always listed.',
35
+ WHO,
36
+ 'With --json: {inputs, cursor}, every input as the API answered it.',
37
+ ].join('\n\n'),
38
+ examples: ['shard orchestrator list', 'shard orchestrator list --json | jq -r \'.inputs[] | select(.state=="done") | .answer.headline\''],
39
+ },
40
+ {
41
+ path: ['orchestrator', 'retry'],
42
+ summary: 'Send a failed input to your orchestrator again (0.27.0+)',
43
+ positionals: [{ name: 'input-id', required: true }],
44
+ options: ORG,
45
+ description: 'It goes back in line and a new thread takes it. Prints its id and state. Only a failed input is sent again.',
46
+ },
47
+ {
48
+ path: ['orchestrator', 'dismiss'],
49
+ summary: 'Take an input out of your orchestrator’s list on every device (0.27.0+)',
50
+ positionals: [{ name: 'input-id', required: true }],
51
+ options: ORG,
52
+ description: 'What it created stays on the board. shard orchestrator list --all still shows it; shard orchestrator restore brings it back.',
53
+ },
54
+ {
55
+ path: ['orchestrator', 'restore'],
56
+ summary: 'Bring a dismissed input back into your orchestrator’s list on every device (0.28.0+)',
57
+ positionals: [{ name: 'input-id', required: true }],
58
+ options: ORG,
59
+ description: [
60
+ 'Undoes shard orchestrator dismiss: the input is listed again with what it became. Restoring an input that is not dismissed changes nothing. Questions the dismiss closed stay closed.',
61
+ 'With --json: {input}.',
62
+ ].join('\n\n'),
63
+ },
64
+ ];
@@ -0,0 +1,43 @@
1
+ import type { OrchestratorInput, OrchestratorSelection } from '@shardflux/sdk';
2
+ import type { Values } from './args.js';
3
+ import type { Handler } from './commands.js';
4
+ /** --wait's default: how long `say --wait` follows an input before it stops (the input goes on). */
5
+ export declare const SAY_WAIT_MS: number;
6
+ /** The row the person points at, from --task, --report, --needs-you, --input and --conversation; undefined for none. */
7
+ export declare function selectionOf(values: Values): OrchestratorSelection | undefined;
8
+ /** An input's state with what explains it: "queued (2 in line)", "waiting (Waiting for your computer: list ~/code)". */
9
+ export declare function stateLabel(i: OrchestratorInput): string;
10
+ /** What an input became, in a few words: the answer's headline, its tasks, the input it went to, or why it failed. */
11
+ export declare function becameText(i: OrchestratorInput): string;
12
+ /** One input as `shard orchestrator list` prints it: id, state, your words, what it became. */
13
+ export declare function inputLine(i: OrchestratorInput): string;
14
+ /**
15
+ * When an answer's point happened, short and in this computer's local time: "14:02" today, "Oct 16 14:02" this year,
16
+ * else "2025-10-16 14:02". A time without an offset is the person's local time and prints as written.
17
+ */
18
+ export declare function shortTime(at: string, now?: Date): string;
19
+ /** An answer's body: its points under their group headings when the agent wrote points (0.27.0+), else its facts. */
20
+ export declare function answerLines(answer: NonNullable<OrchestratorInput['answer']>, now?: Date): string[];
21
+ /** One file an answer shows (0.27.0+): ` media: loss-curve.png (image, 1200×800, 84.0 KB)`, the size in pixels once known. */
22
+ export declare function mediaLine(m: NonNullable<NonNullable<OrchestratorInput['answer']>['media']>[number]): string;
23
+ /**
24
+ * What `say --wait` prints once an input is done, merged, failed or waiting: its curated result.
25
+ *
26
+ * Training is 73% done, on pace to finish at 16:40
27
+ * Moved
28
+ * - Loss is down to 1.84: from 2.31 at step 5,000 (14:02)
29
+ * - Checkpoint saved: at step 7,000
30
+ * Still open
31
+ * - Eval has not run: it starts after the last checkpoint
32
+ * media: loss-curve.png (image, 1200×800, 84.0 KB)
33
+ * Sources: train.log (~/code/lm-train/train.log), wandb.ai (https://wandb.ai/acme/lm)
34
+ * Follow-ups: Check again in an hour
35
+ * Tasks: task_… (shard task show <id>)
36
+ *
37
+ * An answer written as facts prints them as ` - <fact>` lines instead of the points. Each file the answer shows prints
38
+ * one media line after them (shard drive get <path> --sha256 <sha256> reads it; --json has both).
39
+ */
40
+ export declare function resultText(i: OrchestratorInput, now?: Date): string;
41
+ /** Where `say --wait` stops following: nothing more happens to the input, or it waits on you or your computer. */
42
+ export declare const stopsWaiting: (i: OrchestratorInput) => boolean;
43
+ export declare const ORCHESTRATOR_HANDLERS: Record<string, Handler>;