@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
@@ -1,7 +1,7 @@
1
1
  import { spawn, execFile } from 'node:child_process';
2
2
  import { createRequire } from 'node:module';
3
- import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, chmodSync, mkdtempSync } from 'node:fs';
4
- import { dirname, join } from 'node:path';
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync, chmodSync, mkdtempSync } from 'node:fs';
4
+ import { basename, dirname, join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { promisify } from 'node:util';
7
7
  import { UsageError } from "./errors.js";
@@ -18,6 +18,41 @@ export function linuxDesktopPackage(arch = process.arch) {
18
18
  }
19
19
  }
20
20
  export function graphicalSession(env) { return !!(env.DISPLAY || env.WAYLAND_DISPLAY); }
21
+ /**
22
+ * Whether `pid` runs the app installed at `app`: the GTK tray app (shardflux-desktop) or the one Shardflux app
23
+ * (shardflux-desktop-bin behind its launcher). By /proc/<pid>/exe, because Chromium rewrites an Electron process's
24
+ * cmdline into one space-joined title; a recycled PID runs something else, and a zombie has exited.
25
+ */
26
+ export function isAppProcess(pid, app, proc = '/proc') {
27
+ try {
28
+ const exe = readlinkSync(`${proc}/${pid}/exe`).replace(/ \(deleted\)$/, '');
29
+ let real = app;
30
+ try {
31
+ real = realpathSync(app);
32
+ }
33
+ catch { /* not installed */ }
34
+ if (![app, real].includes(dirname(exe)) || !/^shardflux-desktop(-bin)?$/.test(basename(exe)))
35
+ return false;
36
+ const stat = readFileSync(`${proc}/${pid}/stat`, 'utf8');
37
+ if (stat.slice(stat.lastIndexOf(')') + 2).startsWith('Z'))
38
+ return false;
39
+ process.kill(pid, 0);
40
+ return true;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /**
47
+ * The libraries ldd could not find, named as the loader names them: the app in the package decides which it needs (the
48
+ * GTK tray app's WebKit and app indicator, the one app's GTK, NSS and ALSA).
49
+ */
50
+ export function missingLibrariesMessage(ldd) {
51
+ const missing = [...new Set([...ldd.matchAll(/^\s*(\S+)\s+=>\s+not found/gm)].map(m => m[1]))];
52
+ return `Shardflux needs desktop libraries this computer does not have${missing.length ? ` (${missing.join(', ')})` : ''}. Install them with your package manager, then run shard.`;
53
+ }
54
+ /** The Electron executable of a one-app directory (behind its launcher), else the GTK app's executable. */
55
+ const appBinary = (dir) => existsSync(join(dir, 'shardflux-desktop-bin')) ? join(dir, 'shardflux-desktop-bin') : join(dir, 'shardflux-desktop');
21
56
  const desktopString = (text) => `"${text.replace(/(["`$\\])/g, '\\$1')}"`;
22
57
  // fnm's multishell aliases belong to the launching shell; the desktop and its autostart entry outlive it.
23
58
  function stableDesktopRuntime(runtime) {
@@ -43,15 +78,7 @@ export class LinuxDesktopManager {
43
78
  this.#env = { ...process.env, ...options.env, HOME: this.home };
44
79
  this.#exec = options.exec ?? exec;
45
80
  this.#spawn = options.spawn ?? spawn;
46
- this.#isProcess = options.isProcess ?? ((pid, executable) => { try {
47
- if (readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0')[0] !== executable)
48
- return false;
49
- process.kill(pid, 0);
50
- return true;
51
- }
52
- catch {
53
- return false;
54
- } });
81
+ this.#isProcess = options.isProcess ?? ((pid, app) => isAppProcess(pid, app));
55
82
  }
56
83
  #version(path) {
57
84
  try {
@@ -67,7 +94,7 @@ export class LinuxDesktopManager {
67
94
  try {
68
95
  const pid = Number(readFileSync(join(dir, 'desktop-linux.pid'), 'utf8'));
69
96
  // Recycled PIDs must never make us stop an unrelated process.
70
- if (Number.isInteger(pid) && pid > 0 && this.#isProcess(pid, join(this.app, 'shardflux-desktop')))
97
+ if (Number.isInteger(pid) && pid > 0 && this.#isProcess(pid, this.app))
71
98
  pids.push(pid);
72
99
  }
73
100
  catch { /* no running app */ }
@@ -84,7 +111,8 @@ export class LinuxDesktopManager {
84
111
  const entry = join(this.home, '.local/share/applications/dev.shardflux.Desktop.desktop');
85
112
  if (!existsSync(entry))
86
113
  return Promise.reject(new UsageError('Run shard menubar start first.'));
87
- cpSync(entry, file);
114
+ // A login start shows only the tray icon (the one app reads --background; 0.27.0+).
115
+ writeFileSync(file, readFileSync(entry, 'utf8').replace(/^Exec=(.*)$/m, (_m, cmd) => `Exec=${cmd.includes('--background') ? cmd : `${cmd} --background`}`), { mode: 0o644 });
88
116
  return Promise.resolve();
89
117
  }
90
118
  async stop(dir) {
@@ -109,7 +137,7 @@ export class LinuxDesktopManager {
109
137
  return { result: 'current', from: before.version, to: version };
110
138
  if (before.running) {
111
139
  try {
112
- const { stdout } = await this.#exec(join(this.app, 'shardflux-desktop'), ['--control', 'idle'], { env: this.#env, timeout: 5000 });
140
+ const { stdout } = await this.#exec(join(this.app, 'shardflux-desktop'), ['--control', 'idle'], { env: { ...this.#env, SHARDFLUX_CONFIG_DIR: dir }, timeout: 5000 });
113
141
  if (!JSON.parse(stdout).idle)
114
142
  return { result: 'busy', from: before.version, to: version };
115
143
  }
@@ -132,14 +160,15 @@ export class LinuxDesktopManager {
132
160
  if (existsSync(this.app) && (lstatSync(this.app).isSymbolicLink() || !current.version))
133
161
  throw new UsageError(`Refusing to replace an unrelated directory at ${this.app}.`);
134
162
  // The loader probes every shared library before any installed copy is stopped or replaced.
135
- await this.#exec('/usr/bin/ldd', [join(source, 'shardflux-desktop')], { timeout: 5_000 }).then(({ stdout }) => { if (stdout.includes('not found'))
136
- throw new UsageError('The Shardflux window needs libwebkit2gtk-4.1-0 and libayatana-appindicator3-1. Install those desktop libraries, then run shard.'); });
163
+ await this.#exec('/usr/bin/ldd', [appBinary(source)], { timeout: 5_000 }).then(({ stdout }) => { if (stdout.includes('not found'))
164
+ throw new UsageError(missingLibrariesMessage(stdout)); });
137
165
  mkdirSync(dirname(this.app), { recursive: true });
138
166
  const staging = mkdtempSync(join(dirname(this.app), '.desktop-'));
139
167
  const staged = join(staging, 'app'), backup = join(staging, 'previous');
140
168
  try {
141
- cpSync(source, staged, { recursive: true });
169
+ cpSync(source, staged, { recursive: true, verbatimSymlinks: true });
142
170
  chmodSync(join(staged, 'shardflux-desktop'), 0o755);
171
+ chmodSync(appBinary(staged), 0o755);
143
172
  if (current.running)
144
173
  await this.stop(dir);
145
174
  if (existsSync(this.app))
@@ -165,7 +194,7 @@ export class LinuxDesktopManager {
165
194
  writeFileSync(desktop, `[Desktop Entry]\nType=Application\nName=Shardflux\nComment=Your agents, a shortcut away\nExec=${args.map(desktopString).join(' ')}\nIcon=${join(this.app, 'ui/assets/shardflux-icon.png')}\nTerminal=false\nCategories=Development;\nStartupWMClass=dev.shardflux.Desktop\n`, { mode: 0o644 });
166
195
  const autostart = join(this.home, '.config/autostart/shardflux.desktop');
167
196
  if (existsSync(autostart))
168
- cpSync(desktop, autostart);
197
+ await this.login(true);
169
198
  if ((await this.status(dir)).running)
170
199
  await this.#exec(join(this.app, 'shardflux-desktop'), ['--control', view], { env, timeout: 5_000 });
171
200
  else {
@@ -6,6 +6,8 @@ import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { writePrivateFile } from "./credentials.js";
7
7
  import { UsageError } from "./errors.js";
8
8
  import { json } from "./format.js";
9
+ import { thisComputer } from "./this-computer.js";
10
+ import { chooseLine, MODE_LABELS, deviceActivity, readDeviceSettings, setDeviceApprovalMode } from "./device-mode.js";
9
11
  const paths = (dir, id) => {
10
12
  if (!/^[a-zA-Z0-9_-]{1,96}$/.test(id))
11
13
  throw new UsageError('Invalid device request id.');
@@ -39,12 +41,19 @@ export function answerDeviceApproval(dir, id, choice) {
39
41
  throw new UsageError('Access outside the conversation folder must be approved once.');
40
42
  writePrivateFile(join(dir, 'device-answers'), paths(dir, id).answer, JSON.stringify({ nonce: pending.nonce, choice }));
41
43
  }
44
+ /** Config dirs whose helper printed the choose line (once per helper, not per request). */
45
+ const toldChoice = new Set();
42
46
  /** Local user-owned files carry approvals; cloud data can never answer its own request. */
43
47
  export async function askLocalDeviceApproval(ctx, approval) {
44
48
  const file = paths(ctx.store.dir, approval.request_id), nonce = randomUUID();
45
49
  rmSync(file.answer, { force: true });
46
50
  writePrivateFile(join(ctx.store.dir, 'device-approvals'), file.pending, JSON.stringify({ ...approval, nonce, pid: process.pid }));
47
- spawnSync('notify-send', ['--', 'Shardflux approval', `${approval.summary}\nReview: shard device requests`], { env: ctx.io.env, stdio: 'ignore', timeout: 2000 });
51
+ // Not chosen yet (device-mode.ts): one line with the exact commands; choosing one answers this request too.
52
+ if (approval.choose_mode && !toldChoice.has(ctx.store.dir)) {
53
+ toldChoice.add(ctx.store.dir);
54
+ ctx.io.stderr.write(`Device bridge: ${chooseLine()}\n`);
55
+ }
56
+ spawnSync('notify-send', ['--', 'Shardflux approval', `${approval.summary}\n${approval.choose_mode ? chooseLine() : 'Review: shard device requests'}`], { env: ctx.io.env, stdio: 'ignore', timeout: 2000 });
48
57
  try {
49
58
  while (!ctx.signal.aborted) {
50
59
  try {
@@ -62,10 +71,23 @@ export async function askLocalDeviceApproval(ctx, approval) {
62
71
  rmSync(file.answer, { force: true });
63
72
  }
64
73
  }
74
+ const activityLine = (a) => `${a.at.slice(0, 16).replace('T', ' ')} ${a.state}${a.exit_code !== undefined ? ` (exit ${a.exit_code})` : ''} ${a.summary}${a.conversation_title ? ` · ${a.conversation_title}` : ''}\n`;
65
75
  export const DEVICE_HANDLERS = {
76
+ /** `shard device approvals [auto|read|ask]` (0.27.0+): how this computer answers your agents, and what ran without asking. */
77
+ 'device approvals'(ctx, _values, [mode]) {
78
+ const settings = mode ? setDeviceApprovalMode(ctx.store.dir, mode, ctx.store.profile(ctx.apiUrl).session?.user?.id) : readDeviceSettings(ctx.store.dir);
79
+ const recent = deviceActivity(ctx.store.dir, 10);
80
+ if (ctx.json) {
81
+ ctx.io.stdout.write(json({ approvals: settings.approvals ?? null, chosen_at: settings.chosen_at ?? null, recent }));
82
+ return Promise.resolve(0);
83
+ }
84
+ const now = settings.approvals ? `Your agents on ${thisComputer()}: ${MODE_LABELS[settings.approvals]}.\n` : `Your agents on ${thisComputer()}: not chosen yet; each request asks.\n${chooseLine()}\n`;
85
+ ctx.io.stdout.write(now + (recent.length ? `Ran without asking:\n${recent.map(activityLine).join('')}` : ''));
86
+ return Promise.resolve(0);
87
+ },
66
88
  'device requests'(ctx) {
67
89
  const requests = pendingDeviceApprovals(ctx.store.dir);
68
- ctx.io.stdout.write(ctx.json ? json({ requests }) : requests.length ? requests.map(r => `${r.request_id}: ${r.summary}\n Project: ${r.project_name ?? r.project_id}; conversation: ${r.conversation_title ?? r.conversation_id}\n Local: ${r.local_path ?? '(none)'}; remote: ${r.remote_path ?? '(none)'}${r.argv ? `\n Command: ${JSON.stringify(r.argv)}` : ''}${r.local_port ? `\n Ports: ${r.local_port} → ${r.remote_port}` : ''}${r.repo_remote ? `\n Repository: ${r.repo_remote}` : ''}${r.brief ? `\n First message: ${r.brief.split('\n')[0].slice(0, 200)}` : ''}\n shard device approve ${r.request_id}${r.scope === 'folder' ? ` [--always: for ${r.folder}]` : ''} | shard device deny ${r.request_id}\n`).join('') : 'No device requests waiting for approval.\n');
90
+ ctx.io.stdout.write(ctx.json ? json({ requests }) : requests.length ? requests.map(r => `${r.request_id}: ${r.summary}\n Project: ${r.project_name ?? r.project_id}; conversation: ${r.conversation_title ?? r.conversation_id}\n Local: ${r.local_path ?? '(none)'}; remote: ${r.remote_path ?? '(none)'}${r.argv ? `\n Command: ${JSON.stringify(r.argv)}` : ''}${r.local_port ? `\n Ports: ${r.local_port} → ${r.remote_port}` : ''}${r.repo_remote ? `\n Repository: ${r.repo_remote}` : ''}${r.brief ? `\n First message: ${r.brief.split('\n')[0].slice(0, 200)}` : ''}\n shard device approve ${r.request_id}${r.scope === 'folder' ? ` [--always: for ${r.folder}]` : ''} | shard device deny ${r.request_id}\n`).join('') + (requests.some(r => r.choose_mode) ? `${chooseLine()}\n` : '') : 'No device requests waiting for approval.\n');
69
91
  return Promise.resolve(0);
70
92
  },
71
93
  ...Object.fromEntries(['approve', 'deny'].map(action => [`device ${action}`, (ctx, values, [id]) => {
@@ -62,8 +62,15 @@ export interface DeviceApproval {
62
62
  brief?: string;
63
63
  /** dir_list: how many levels below the folder are listed. */
64
64
  depth?: number;
65
+ /**
66
+ * 0.27.0+: this person has not chosen how this computer answers their agents (device-mode.ts). A surface that knows
67
+ * it asks that first ("Allow everything" / "Ask each time", saved with `shard device approvals auto|ask`): once the
68
+ * mode lets this request run, the helper takes it by itself and this approval goes away; with "Ask each time" the
69
+ * request is then answered as usual. A surface that predates it shows the approval as before.
70
+ */
71
+ choose_mode?: true;
65
72
  }
66
- type Result = {
73
+ export type Result = {
67
74
  state: 'approved' | 'denied' | 'running' | 'done' | 'failed';
68
75
  exit_code?: number;
69
76
  stdout_b64?: string;
@@ -72,6 +79,12 @@ type Result = {
72
79
  dir?: DirListing;
73
80
  project?: LinkedProject;
74
81
  };
82
+ /**
83
+ * Posts a request's answer. An API before a linked folder's start point (train 47) refuses a link's commit and branch
84
+ * (422 validation_failed): the link is done, so it is posted again without them (staging 2026-10-17: the answer was
85
+ * refused and the request read as failed).
86
+ */
87
+ export declare function postResult(post: (answer: Result, again: boolean) => Promise<void>, result: Result): Promise<void>;
75
88
  /** Resolve existing ancestors even before the conversation worktree is created. */
76
89
  export declare function devicePath(root: string, requested?: string): {
77
90
  path: string;
@@ -105,10 +118,15 @@ export declare function folderApproval(r: DeviceRequest, c: Conversation, target
105
118
  export declare function tryLocallyAction(r: DeviceRequest): 'start' | 'stop' | null;
106
119
  export declare function ensureConversationWorktree(ctx: Ctx, c: Conversation, folder: string, ws?: Workspace): Promise<string>;
107
120
  export declare function deviceExec(argv: string[], cwd: string, timeoutSeconds?: number, signal?: AbortSignal): Promise<Result>;
121
+ export type DeviceAsk = (approval: DeviceApproval, signal?: AbortSignal) => Promise<DeviceChoice>;
108
122
  export declare class DeviceExecutor {
109
123
  #private;
110
- constructor(ctx: Ctx, ask: (approval: DeviceApproval) => Promise<DeviceChoice>, deviceId: string);
111
- result(r: DeviceRequest, result: Result): Promise<void>;
124
+ constructor(ctx: Ctx, ask: DeviceAsk, deviceId: string);
125
+ /** A new device id for a new sign-in (device-helper.ts): requests for the old one are no longer this device's. */
126
+ set deviceId(id: string);
127
+ get deviceId(): string;
128
+ /** `again`: a second answer of the same state with another body (its own idempotency key). */
129
+ result(r: DeviceRequest, result: Result, again?: string): Promise<void>;
112
130
  execute(r: DeviceRequest): Promise<void>;
113
131
  reap(): Promise<void>;
114
132
  close(): Promise<void>;
@@ -131,4 +149,3 @@ export declare function askConnectedAcp(dir: string, approval: DeviceApproval, s
131
149
  */
132
150
  export declare function approverSocket(dir: string): string;
133
151
  export declare function askDeviceApprover(dir: string, approval: DeviceApproval, signal: AbortSignal): Promise<DeviceChoice | null>;
134
- export {};
@@ -16,6 +16,29 @@ import { withTargetLock } from "./target-lock.js";
16
16
  import { checkConversationWorktree, rememberConversationWorktree } from "./conversation-worktree.js";
17
17
  import { yourComputer } from "./this-computer.js";
18
18
  import { canonicalDevicePath, deviceHome, displayPath, expandDevicePath, FOLDER_KINDS, isDevicePath, linkLocalProject, listDeviceDir } from "./device-folders.js";
19
+ import { ownConversation } from "./agent-config-return.js";
20
+ import { deviceApprovalMode, recordDeviceActivity, runsWithoutAsking } from "./device-mode.js";
21
+ import { setTimeout as delay } from 'node:timers/promises';
22
+ /**
23
+ * Posts a request's answer. An API before a linked folder's start point (train 47) refuses a link's commit and branch
24
+ * (422 validation_failed): the link is done, so it is posted again without them (staging 2026-10-17: the answer was
25
+ * refused and the request read as failed).
26
+ */
27
+ export async function postResult(post, result) {
28
+ try {
29
+ await post(result, false);
30
+ }
31
+ catch (e) {
32
+ const project = result.project, status = e.status, code = e.code;
33
+ if (!project?.commit || !(status === 400 || status === 422 || code === 'validation_failed'))
34
+ throw e;
35
+ const older = { ...project };
36
+ delete older.commit;
37
+ delete older.branch;
38
+ // Another body, so its own idempotency key.
39
+ await post({ ...result, project: older }, true);
40
+ }
41
+ }
19
42
  const LIMIT = 256 << 20, OUTPUT = (192 << 10) - 128;
20
43
  const inside = (root, target) => target === root || target.startsWith(root + sep);
21
44
  /**
@@ -203,9 +226,82 @@ export class DeviceExecutor {
203
226
  #ask;
204
227
  #deviceId;
205
228
  #handles = new Map();
229
+ /** Requests that run without asking (device-mode.ts), until their outcome is recorded. */
230
+ #auto = new Map();
231
+ #identity = { at: 0 };
206
232
  constructor(ctx, ask, deviceId) { this.#ctx = ctx; this.#ask = ask; this.#deviceId = deviceId; }
207
- async result(r, result) {
208
- await conversationClient(this.#ctx).deviceRequests.result(safeId(r.id), result, { idempotencyKey: `${this.#deviceId}:${r.id}:${result.state}`, signal: this.#ctx.signal });
233
+ /** A new device id for a new sign-in (device-helper.ts): requests for the old one are no longer this device's. */
234
+ set deviceId(id) { this.#deviceId = id; this.#identity = { at: 0 }; }
235
+ get deviceId() { return this.#deviceId; }
236
+ /** `again`: a second answer of the same state with another body (its own idempotency key). */
237
+ async result(r, result, again = '') {
238
+ // How a request that ran without asking ended is kept here first: it ran on this computer whether or not the
239
+ // answer reaches the API (a stopped helper, a network error).
240
+ const auto = this.#auto.get(r.id);
241
+ if (auto && result.state !== 'approved' && result.state !== 'running') {
242
+ this.#auto.delete(r.id);
243
+ recordDeviceActivity(this.#ctx.store.dir, { ...auto, at: new Date().toISOString(), state: result.state, ...(result.exit_code !== undefined ? { exit_code: result.exit_code } : {}) });
244
+ }
245
+ await conversationClient(this.#ctx).deviceRequests.result(safeId(r.id), result, { idempotencyKey: `${this.#deviceId}:${r.id}:${result.state}${again}`, signal: this.#ctx.signal });
246
+ }
247
+ /** This computer's person (the signed-in user, or who created its API key), checked every 10 minutes. */
248
+ async #me() {
249
+ if (Date.now() - this.#identity.at < 10 * 60_000)
250
+ return this.#identity.userId;
251
+ let userId = this.#ctx.store.profile(this.#ctx.apiUrl).session?.user?.id;
252
+ if (!userId) {
253
+ try {
254
+ const me = await conversationClient(this.#ctx).me();
255
+ userId = me.user?.id ?? me.api_key?.created_by_user_id ?? undefined;
256
+ }
257
+ catch { /* unknown: only conversations started here are this person's */ }
258
+ }
259
+ this.#identity = { at: Date.now(), ...(userId ? { userId } : {}) };
260
+ return userId;
261
+ }
262
+ /**
263
+ * Allow once by a grant, by this person's approval mode (device-mode.ts), or by asking. Only this person's own
264
+ * agents run without asking. Until they choose a mode, their first request asks for it (`choose_mode`), and a mode
265
+ * chosen while a request waits (the prompt, Settings, `shard device approvals`) takes the request by itself.
266
+ */
267
+ async #decide(r, c, approval, granted) {
268
+ if (granted)
269
+ return 'once';
270
+ const dir = this.#ctx.store.dir, me = await this.#me();
271
+ const mine = ownConversation(c, localConversations(this.#ctx), me);
272
+ const mode = deviceApprovalMode(dir, me);
273
+ const auto = (m) => {
274
+ this.#auto.set(r.id, { at: new Date().toISOString(), request_id: r.id, conversation_id: c.id, kind: r.kind, summary: approval.summary, mode: m, state: 'running',
275
+ ...(approval.conversation_title ? { conversation_title: approval.conversation_title } : {}), ...(approval.project_name ? { project_name: approval.project_name } : {}),
276
+ ...(approval.argv ? { argv: approval.argv } : {}), ...(approval.local_path ? { local_path: approval.local_path } : {}) });
277
+ recordDeviceActivity(dir, this.#auto.get(r.id));
278
+ return 'once';
279
+ };
280
+ if (mine && mode && runsWithoutAsking(mode, r.kind))
281
+ return auto(mode);
282
+ if (!mine)
283
+ return this.#ask(approval);
284
+ const stop = new AbortController();
285
+ let chosen;
286
+ const watch = (async () => {
287
+ while (!stop.signal.aborted) {
288
+ await delay(400, undefined, { signal: stop.signal }).catch(() => undefined);
289
+ const now = deviceApprovalMode(dir, me);
290
+ if (!stop.signal.aborted && now && runsWithoutAsking(now, r.kind)) {
291
+ chosen = now;
292
+ stop.abort();
293
+ }
294
+ }
295
+ })();
296
+ try {
297
+ const choice = await this.#ask(mode ? approval : { ...approval, choose_mode: true }, stop.signal).catch((e) => { if (chosen)
298
+ return 'once'; throw e; });
299
+ return chosen ? auto(chosen) : choice;
300
+ }
301
+ finally {
302
+ stop.abort();
303
+ await watch;
304
+ }
209
305
  }
210
306
  async execute(r) {
211
307
  const ctx = this.#ctx;
@@ -218,8 +314,10 @@ export class DeviceExecutor {
218
314
  const matching = Object.values(records).filter(record => record.project_id === r.project_id);
219
315
  const folders = new Set(matching.map(record => record.folder));
220
316
  const record = records[r.conversation_id] ?? (folders.size === 1 ? matching[0] : undefined);
317
+ // No folder of this conversation on this computer: a home conversation's command (an orchestrator thread asking the
318
+ // person's computer) runs in their home folder once they allow it; anything else is answered, never left waiting.
221
319
  if (!record || record.project_id !== r.project_id)
222
- return; // No inferred local path for unknown projects.
320
+ return this.#unplaced(r);
223
321
  let c = await getConversation(ctx, r.conversation_id);
224
322
  if (c.project_id !== r.project_id)
225
323
  throw new Error('Device request project changed.');
@@ -227,7 +325,7 @@ export class DeviceExecutor {
227
325
  const requested = r.exec?.cwd_relative ?? r.files?.local_path_relative ?? '.';
228
326
  const target = devicePath(planned.path, requested), grants = new DeviceGrants(ctx.store.dir);
229
327
  target.outside ||= planned.outside;
230
- const choice = grants.allows(ctx.apiUrl, r.project_id, record.folder, r.kind, target.outside) ? 'once' : await this.#ask(deviceApproval(r, c, target, record.folder));
328
+ const choice = await this.#decide(r, c, deviceApproval(r, c, target, record.folder), grants.allows(ctx.apiUrl, r.project_id, record.folder, r.kind, target.outside));
231
329
  if (choice === 'deny') {
232
330
  await this.result(r, { state: 'denied' });
233
331
  return;
@@ -331,6 +429,74 @@ export class DeviceExecutor {
331
429
  ws?.cell().close();
332
430
  }
333
431
  }
432
+ /**
433
+ * A request whose conversation has no folder on this device (0.27.0+). A home conversation's exec runs in the home
434
+ * folder, or the ~/ or absolute directory it names, after the person allows this one request (never Always: commands
435
+ * never run under a grant). Everything else is refused with the reason, so its agent hears it instead of waiting forever.
436
+ */
437
+ async #unplaced(r) {
438
+ const ctx = this.#ctx, home = deviceHome(ctx), say = (state, message) => this.result(r, { state, stderr_b64: Buffer.from(message).toString('base64') });
439
+ let c;
440
+ try {
441
+ c = await getConversation(ctx, r.conversation_id);
442
+ if (c.project_id !== r.project_id)
443
+ throw new Error('Device request project changed.');
444
+ }
445
+ catch (e) {
446
+ if (ctx.signal.aborted)
447
+ return;
448
+ await say('denied', e instanceof Error ? e.message : String(e));
449
+ return;
450
+ }
451
+ if (archived(c)) {
452
+ await say('denied', 'The conversation has ended.');
453
+ return;
454
+ }
455
+ if (r.kind !== 'exec' || !r.exec || c.home !== true) {
456
+ await say('denied', `This conversation has no folder on ${yourComputer()}: commands and file moves need its folder here (open the conversation on this computer), or a ~/ path for a file.`);
457
+ return;
458
+ }
459
+ let target;
460
+ try {
461
+ const requested = r.exec.cwd_relative && r.exec.cwd_relative !== '.' ? r.exec.cwd_relative : '~';
462
+ target = canonicalDevicePath(expandDevicePath(isDevicePath(requested) ? requested : `~/${requested}`, home));
463
+ }
464
+ catch (e) {
465
+ await say('denied', e instanceof Error ? e.message : String(e));
466
+ return;
467
+ }
468
+ const approval = { ...deviceApproval(r, c, { path: target, outside: true }, home),
469
+ summary: `Run on ${yourComputer()}: ${r.exec.argv.join(' ')} · in ${displayPath(target, home)}` };
470
+ let choice;
471
+ try {
472
+ choice = await this.#decide(r, c, approval, false);
473
+ }
474
+ catch (e) {
475
+ if (ctx.signal.aborted)
476
+ return;
477
+ await say('denied', e instanceof Error ? e.message : String(e));
478
+ return;
479
+ }
480
+ if (ctx.signal.aborted)
481
+ return;
482
+ if (choice !== 'once') {
483
+ await this.result(r, { state: 'denied' });
484
+ return;
485
+ }
486
+ const current = await getConversation(ctx, r.conversation_id).catch(() => undefined);
487
+ if (!current || current.project_id !== c.project_id || archived(current)) {
488
+ await say('denied', 'The conversation changed or ended during device approval.');
489
+ return;
490
+ }
491
+ await this.result(r, { state: 'approved' });
492
+ await this.result(r, { state: 'running' });
493
+ try {
494
+ await this.result(r, await deviceExec(r.exec.argv, target, r.exec.timeout_seconds, ctx.signal));
495
+ }
496
+ catch (e) {
497
+ await say('failed', e instanceof Error ? e.message : String(e));
498
+ }
499
+ }
334
500
  /**
335
501
  * A folder anywhere on this device (device-folders.ts). Every outcome is answered, so a request is never asked twice:
336
502
  * denied before it was approved, failed after.
@@ -371,7 +537,7 @@ export class DeviceExecutor {
371
537
  const grants = new DeviceGrants(ctx.store.dir);
372
538
  let choice;
373
539
  try {
374
- choice = grants.allowsFolder(ctx.apiUrl, target, r.kind) ? 'once' : await this.#ask(folderApproval(r, c, target, folder, home, remote));
540
+ choice = await this.#decide(r, c, folderApproval(r, c, target, folder, home, remote), grants.allowsFolder(ctx.apiUrl, target, r.kind));
375
541
  }
376
542
  catch (e) {
377
543
  if (ctx.signal.aborted)
@@ -412,7 +578,7 @@ export class DeviceExecutor {
412
578
  if (r.kind === 'dir_list')
413
579
  result = { state: 'done', dir: await listDeviceDir(target, r.dir ?? {}, ctx.signal) };
414
580
  else if (r.kind === 'project_link')
415
- result = { state: 'done', project: await linkLocalProject(ctx, target, { ...(r.link?.brief ? { brief: r.link.brief } : {}), harness: r.link?.harness ?? current.harness, projectId: current.project_id }) };
581
+ result = { state: 'done', project: await linkLocalProject(ctx, target, { ...(r.link?.brief ? { brief: r.link.brief } : {}), harness: r.link?.harness ?? current.harness, projectId: current.project_id, seed: true }) };
416
582
  else {
417
583
  const f = r.files;
418
584
  if (!f.remote_path.startsWith('/') || /[\0\r\n]/.test(f.remote_path))
@@ -445,7 +611,7 @@ export class DeviceExecutor {
445
611
  }
446
612
  result = { state: 'done', files_artifact: r.kind === 'files_pull' ? f.remote_path : target };
447
613
  }
448
- await this.result(r, result);
614
+ await postResult((answer, again) => this.result(r, answer, again ? ':older-api' : ''), result);
449
615
  }
450
616
  catch (e) {
451
617
  await this.result(r, { state: 'failed', stderr_b64: Buffer.from(e instanceof Error ? e.message : String(e)).toString('base64') });
@@ -1,4 +1,5 @@
1
1
  import type { Ctx, Handler } from './commands.js';
2
+ import type { HubSeed } from './project-hub-seed.js';
2
3
  export declare const FOLDER_KINDS: Set<string>;
3
4
  /** An absolute or `~/…` path: one the device resolves on its own, outside any conversation's folder. */
4
5
  export declare function isDevicePath(path: string): boolean;
@@ -25,12 +26,15 @@ export declare function listDeviceDir(root: string, options?: {
25
26
  max_entries?: number;
26
27
  include_hidden?: boolean;
27
28
  }, signal?: AbortSignal): Promise<DirListing>;
29
+ /** `commit` and `branch`: where work on it starts, in the project hub (an agent's link, 0.28.0+). */
28
30
  export interface LinkedProject {
29
31
  project_id: string;
30
32
  repo_remote: string;
31
33
  folder: string;
32
34
  name: string;
33
35
  conversation_id?: string;
36
+ commit?: string;
37
+ branch?: string;
34
38
  }
35
39
  type Starter = (ctx: Ctx, folder: string, harness: 'claude' | 'codex', brief: string) => Promise<{
36
40
  id: string;
@@ -42,6 +46,14 @@ type Starter = (ctx: Ctx, folder: string, harness: 'claude' | 'codex', brief: st
42
46
  * prompt. The brief goes in on standard input, never in a command line another user can read.
43
47
  */
44
48
  export declare const startFromFolder: Starter;
49
+ /**
50
+ * The conversation `shard agents new --json` started, from its whole output: one pretty-printed JSON document
51
+ * (format.ts json), never its last line (staging 2026-10-16: the last line is `}`).
52
+ */
53
+ export declare function startedConversation(stdout: string): {
54
+ id: string;
55
+ project_id: string;
56
+ };
45
57
  /**
46
58
  * `folder` becomes one of your projects: the repository its Git origin names (else the folder itself), in your
47
59
  * project on this API. It is selected for background preparation, so its files are in the cloud before the next chat,
@@ -52,7 +64,8 @@ export declare function linkLocalProject(ctx: Ctx, path: string, options?: {
52
64
  brief?: string;
53
65
  harness?: 'claude' | 'codex';
54
66
  projectId?: string;
55
- }, start?: Starter): Promise<LinkedProject>;
67
+ seed?: boolean;
68
+ }, start?: Starter, seedHub?: (ctx: Ctx, folder: string, projectId: string) => Promise<HubSeed>): Promise<LinkedProject>;
56
69
  /** `shard projects link <path>` (0.25.0+): what the desktop app's "Add a folder…" runs, the same as a device's project_link. */
57
70
  export declare const PROJECT_LINK_HANDLERS: Record<string, Handler>;
58
71
  export {};
@@ -18,6 +18,7 @@ import { UsageError } from "./errors.js";
18
18
  import { ensureProjectGit } from "./project-git.js";
19
19
  import { normalizeRemote, projectIdentity } from "./project-identity.js";
20
20
  import { selectProject } from "./project-preparation.js";
21
+ import { seedProjectHub } from "./project-hub-seed.js";
21
22
  export const FOLDER_KINDS = new Set(['dir_list', 'project_link']);
22
23
  /** An absolute or `~/…` path: one the device resolves on its own, outside any conversation's folder. */
23
24
  export function isDevicePath(path) { return isAbsolute(path) || path === '~' || path.startsWith('~/'); }
@@ -148,10 +149,11 @@ export const startFromFolder = (ctx, folder, harness, brief) => {
148
149
  env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, CI: '1' },
149
150
  });
150
151
  const out = [], err = [];
152
+ let outBytes = 0;
151
153
  const stop = () => child.kill('SIGTERM');
152
154
  const timer = setTimeout(stop, 300_000);
153
155
  ctx.signal.addEventListener('abort', stop, { once: true });
154
- child.stdout.on('data', (b) => { if (out.length < 256)
156
+ child.stdout.on('data', (b) => { if ((outBytes += b.length) <= 8 << 20)
155
157
  out.push(b); });
156
158
  child.stderr.on('data', (b) => { if (err.length < 64)
157
159
  err.push(b); });
@@ -163,11 +165,7 @@ export const startFromFolder = (ctx, folder, harness, brief) => {
163
165
  try {
164
166
  if (code !== 0)
165
167
  throw new Error(lastLine(Buffer.concat(err).toString('utf8')) || `The conversation did not start (exit ${code}).`);
166
- const started = JSON.parse(text.trim().split('\n').at(-1));
167
- const id = started.conversation?.id ?? started.workspace?.labels?.shard_conversation;
168
- if (!id)
169
- throw new Error('The conversation did not start.');
170
- resolveStart({ id, project_id: started.conversation?.project_id ?? started.workspace?.project_id ?? '' });
168
+ resolveStart(startedConversation(text));
171
169
  }
172
170
  catch (error) {
173
171
  reject(error instanceof Error ? error : new Error(String(error)));
@@ -176,6 +174,17 @@ export const startFromFolder = (ctx, folder, harness, brief) => {
176
174
  child.stdin.end(brief);
177
175
  });
178
176
  };
177
+ /**
178
+ * The conversation `shard agents new --json` started, from its whole output: one pretty-printed JSON document
179
+ * (format.ts json), never its last line (staging 2026-10-16: the last line is `}`).
180
+ */
181
+ export function startedConversation(stdout) {
182
+ const started = JSON.parse(stdout);
183
+ const id = started.conversation?.id ?? started.workspace?.labels?.shard_conversation;
184
+ if (!id)
185
+ throw new Error('The conversation did not start.');
186
+ return { id, project_id: started.conversation?.project_id ?? started.workspace?.project_id ?? '' };
187
+ }
179
188
  function lastLine(stderr) {
180
189
  const text = stderr.trim();
181
190
  try {
@@ -192,7 +201,7 @@ function lastLine(stderr) {
192
201
  * and kept as this device's live copy: what conversations started from it finish lands back in it. With `brief`, a
193
202
  * conversation starts there at once. A folder without Git gets a repository, as a new chat from it does.
194
203
  */
195
- export async function linkLocalProject(ctx, path, options = {}, start = startFromFolder) {
204
+ export async function linkLocalProject(ctx, path, options = {}, start = startFromFolder, seedHub = seedProjectHub) {
196
205
  const { folder } = projectIdentity(path);
197
206
  await ensureProjectGit(ctx, folder);
198
207
  const identity = projectIdentity(folder);
@@ -210,7 +219,10 @@ export async function linkLocalProject(ctx, path, options = {}, start = startFro
210
219
  if (!projectId)
211
220
  throw new UsageError('Choose your default project first: shard projects use <id|slug>.');
212
221
  const name = [...(identity.remote ? identity.slug : basename(folder))].slice(0, 200).join('') || identity.slug;
213
- return { project_id: projectId, repo_remote: identity.remote ?? `file://${folder}`, folder, name, ...(conversationId ? { conversation_id: conversationId } : {}) };
222
+ // An agent's link: the folder as it is now goes to the project hub, so its work can start in the cloud at once.
223
+ const seed = options.seed ? await seedHub(ctx, folder, projectId) : null;
224
+ return { project_id: projectId, repo_remote: identity.remote ?? `file://${folder}`, folder, name, ...(conversationId ? { conversation_id: conversationId } : {}),
225
+ ...(seed ? { commit: seed.commit, ...(seed.branch ? { branch: seed.branch } : {}) } : {}) };
214
226
  }
215
227
  /** `shard projects link <path>` (0.25.0+): what the desktop app's "Add a folder…" runs, the same as a device's project_link. */
216
228
  export const PROJECT_LINK_HANDLERS = {
@@ -9,3 +9,11 @@ export declare function runDeviceHelper(ctx: Ctx, options?: {
9
9
  * A home conversation, an Orchestrator master or a chat the desktop app started asks from these.
10
10
  */
11
11
  export declare function deviceProjects(ctx: Ctx): Promise<string[]>;
12
+ /** 409 `device_id_taken`: this device id is registered in a project by another identity (POST /v1/device-requests/heartbeat). */
13
+ export declare function deviceIdTaken(e: unknown): boolean;
14
+ /**
15
+ * A new device id for this computer, replacing `old` (0.27.0+). Local state stays: grants are kept by API and folder
16
+ * or project (device-bridge.ts DeviceGrants), the approval mode by person (device-mode.ts), and the twin's refs by the
17
+ * id that made them. The old id is kept in `device-id-history.json` (the last 20) for support.
18
+ */
19
+ export declare function renewDeviceId(dir: string, old: string, projectId: string): string;