@shardflux/cli 0.25.0 → 0.28.1

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 (93) hide show
  1. package/CHANGELOG.md +154 -1
  2. package/README.md +212 -36
  3. package/dist/account-commands.d.ts +2 -4
  4. package/dist/account-commands.js +2 -6
  5. package/dist/account-pool.d.ts +11 -1
  6. package/dist/account-pool.js +48 -0
  7. package/dist/agent-commands.js +4 -3
  8. package/dist/agent-git-commands.d.ts +1 -2
  9. package/dist/agent-git-commands.js +2 -21
  10. package/dist/agent-models.d.ts +31 -1
  11. package/dist/agent-models.js +27 -4
  12. package/dist/agent-setup.d.ts +4 -2
  13. package/dist/agent-setup.js +6 -4
  14. package/dist/agent-shell.d.ts +2 -0
  15. package/dist/agent-shell.js +5 -0
  16. package/dist/args.d.ts +1 -1
  17. package/dist/args.js +67 -12
  18. package/dist/board-args.js +8 -3
  19. package/dist/board-commands.d.ts +7 -3
  20. package/dist/board-commands.js +22 -12
  21. package/dist/brain-commands.d.ts +2 -0
  22. package/dist/brain-commands.js +78 -0
  23. package/dist/cli-proxy.d.ts +1 -13
  24. package/dist/cli-proxy.js +2 -27
  25. package/dist/client-settings.d.ts +2 -1
  26. package/dist/client-settings.js +14 -2
  27. package/dist/codex-cloud.d.ts +2 -0
  28. package/dist/codex-cloud.js +17 -12
  29. package/dist/commands.js +8 -0
  30. package/dist/conversation-commands.d.ts +5 -1
  31. package/dist/conversation-commands.js +58 -6
  32. package/dist/conversation-terminal.js +2 -2
  33. package/dist/desktop-linux.d.ts +12 -1
  34. package/dist/desktop-linux.js +47 -18
  35. package/dist/device-approvals.js +24 -2
  36. package/dist/device-bridge.d.ts +24 -4
  37. package/dist/device-bridge.js +181 -8
  38. package/dist/device-folders.d.ts +6 -1
  39. package/dist/device-folders.js +6 -2
  40. package/dist/device-helper.d.ts +8 -0
  41. package/dist/device-helper.js +71 -12
  42. package/dist/device-mode.d.ts +44 -0
  43. package/dist/device-mode.js +119 -0
  44. package/dist/drive-commands.d.ts +2 -2
  45. package/dist/drive-commands.js +1 -1
  46. package/dist/format.js +3 -0
  47. package/dist/forwarder-refresh.d.ts +15 -0
  48. package/dist/forwarder-refresh.js +32 -0
  49. package/dist/handler-loader.d.ts +3 -0
  50. package/dist/handler-loader.js +43 -0
  51. package/dist/handlers.js +4 -2
  52. package/dist/land.d.ts +10 -5
  53. package/dist/land.js +56 -9
  54. package/dist/linux-helper.js +1 -2
  55. package/dist/mac-app.d.ts +11 -0
  56. package/dist/mac-app.js +11 -0
  57. package/dist/machine-setup-guard.d.ts +9 -0
  58. package/dist/machine-setup-guard.js +22 -0
  59. package/dist/machine-setup.d.ts +1 -8
  60. package/dist/machine-setup.js +2 -22
  61. package/dist/main.js +19 -16
  62. package/dist/menubar.d.ts +18 -13
  63. package/dist/menubar.js +60 -50
  64. package/dist/orchestrator-args.d.ts +7 -0
  65. package/dist/orchestrator-args.js +64 -0
  66. package/dist/orchestrator-commands.d.ts +43 -0
  67. package/dist/orchestrator-commands.js +260 -0
  68. package/dist/person-scope.d.ts +20 -0
  69. package/dist/person-scope.js +61 -0
  70. package/dist/project-hub-seed.d.ts +20 -0
  71. package/dist/project-hub-seed.js +59 -0
  72. package/dist/registry.d.ts +1 -1
  73. package/dist/registry.js +2 -1
  74. package/dist/router-commands.d.ts +12 -21
  75. package/dist/router-commands.js +5 -59
  76. package/dist/startup-housekeeping.d.ts +5 -0
  77. package/dist/startup-housekeeping.js +30 -0
  78. package/dist/t3-launch.js +2 -2
  79. package/dist/t3-provider.js +4 -2
  80. package/dist/task-land-hub.py +16 -1
  81. package/dist/this-computer.d.ts +4 -2
  82. package/dist/this-computer.js +20 -2
  83. package/dist/thread-on-computer.js +2 -2
  84. package/dist/timing.js +3 -1
  85. package/dist/transcribe-commands.js +6 -2
  86. package/dist/update.js +4 -2
  87. package/dist/wait-status.d.ts +1 -1
  88. package/dist/wait-status.js +2 -0
  89. package/dist/worktree-twin-engine.d.ts +1 -1
  90. package/dist/worktree-twin-engine.js +10 -7
  91. package/dist/worktree-twin.d.ts +6 -0
  92. package/dist/worktree-twin.js +8 -1
  93. package/package.json +12 -6
@@ -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]) => {
@@ -2,6 +2,7 @@ import type { CellClient, Workspace } from '@shardflux/sdk';
2
2
  import type { Ctx } from './commands.js';
3
3
  import type { Conversation } from './conversations.js';
4
4
  import type { DirListing, LinkedProject } from './device-folders.js';
5
+ import type { HubSeed } from './project-hub-seed.js';
5
6
  export type DeviceChoice = 'once' | 'always' | 'deny';
6
7
  export interface DeviceRequest {
7
8
  id: string;
@@ -62,8 +63,15 @@ export interface DeviceApproval {
62
63
  brief?: string;
63
64
  /** dir_list: how many levels below the folder are listed. */
64
65
  depth?: number;
66
+ /**
67
+ * 0.27.0+: this person has not chosen how this computer answers their agents (device-mode.ts). A surface that knows
68
+ * it asks that first ("Allow everything" / "Ask each time", saved with `shard device approvals auto|ask`): once the
69
+ * mode lets this request run, the helper takes it by itself and this approval goes away; with "Ask each time" the
70
+ * request is then answered as usual. A surface that predates it shows the approval as before.
71
+ */
72
+ choose_mode?: true;
65
73
  }
66
- type Result = {
74
+ export type Result = {
67
75
  state: 'approved' | 'denied' | 'running' | 'done' | 'failed';
68
76
  exit_code?: number;
69
77
  stdout_b64?: string;
@@ -72,6 +80,12 @@ type Result = {
72
80
  dir?: DirListing;
73
81
  project?: LinkedProject;
74
82
  };
83
+ /**
84
+ * Posts a request's answer. An API before a linked folder's start point (train 47) refuses a link's commit and branch
85
+ * (422 validation_failed): the link is done, so it is posted again without them (staging 2026-10-17: the answer was
86
+ * refused and the request read as failed).
87
+ */
88
+ export declare function postResult(post: (answer: Result, again: boolean) => Promise<void>, result: Result): Promise<void>;
75
89
  /** Resolve existing ancestors even before the conversation worktree is created. */
76
90
  export declare function devicePath(root: string, requested?: string): {
77
91
  path: string;
@@ -105,10 +119,17 @@ export declare function folderApproval(r: DeviceRequest, c: Conversation, target
105
119
  export declare function tryLocallyAction(r: DeviceRequest): 'start' | 'stop' | null;
106
120
  export declare function ensureConversationWorktree(ctx: Ctx, c: Conversation, folder: string, ws?: Workspace): Promise<string>;
107
121
  export declare function deviceExec(argv: string[], cwd: string, timeoutSeconds?: number, signal?: AbortSignal): Promise<Result>;
122
+ export type DeviceAsk = (approval: DeviceApproval, signal?: AbortSignal) => Promise<DeviceChoice>;
108
123
  export declare class DeviceExecutor {
109
124
  #private;
110
- constructor(ctx: Ctx, ask: (approval: DeviceApproval) => Promise<DeviceChoice>, deviceId: string);
111
- result(r: DeviceRequest, result: Result): Promise<void>;
125
+ constructor(ctx: Ctx, ask: DeviceAsk, deviceId: string, options?: {
126
+ seedHub?: (ctx: Ctx, folder: string, projectId: string) => Promise<HubSeed>;
127
+ });
128
+ /** A new device id for a new sign-in (device-helper.ts): requests for the old one are no longer this device's. */
129
+ set deviceId(id: string);
130
+ get deviceId(): string;
131
+ /** `again`: a second answer of the same state with another body (its own idempotency key). */
132
+ result(r: DeviceRequest, result: Result, again?: string): Promise<void>;
112
133
  execute(r: DeviceRequest): Promise<void>;
113
134
  reap(): Promise<void>;
114
135
  close(): Promise<void>;
@@ -131,4 +152,3 @@ export declare function askConnectedAcp(dir: string, approval: DeviceApproval, s
131
152
  */
132
153
  export declare function approverSocket(dir: string): string;
133
154
  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,89 @@ export class DeviceExecutor {
203
226
  #ask;
204
227
  #deviceId;
205
228
  #handles = new Map();
206
- 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 });
229
+ /** Requests that run without asking (device-mode.ts), until their outcome is recorded. */
230
+ #auto = new Map();
231
+ #identity = { at: 0 };
232
+ /** How an agent's folder link reaches the project hub (project-hub-seed.ts unless a test gives its own hub). */
233
+ #seedHub;
234
+ constructor(ctx, ask, deviceId, options = {}) {
235
+ this.#ctx = ctx;
236
+ this.#ask = ask;
237
+ this.#deviceId = deviceId;
238
+ this.#seedHub = options.seedHub;
239
+ }
240
+ /** A new device id for a new sign-in (device-helper.ts): requests for the old one are no longer this device's. */
241
+ set deviceId(id) { this.#deviceId = id; this.#identity = { at: 0 }; }
242
+ get deviceId() { return this.#deviceId; }
243
+ /** `again`: a second answer of the same state with another body (its own idempotency key). */
244
+ async result(r, result, again = '') {
245
+ // How a request that ran without asking ended is kept here first: it ran on this computer whether or not the
246
+ // answer reaches the API (a stopped helper, a network error).
247
+ const auto = this.#auto.get(r.id);
248
+ if (auto && result.state !== 'approved' && result.state !== 'running') {
249
+ this.#auto.delete(r.id);
250
+ recordDeviceActivity(this.#ctx.store.dir, { ...auto, at: new Date().toISOString(), state: result.state, ...(result.exit_code !== undefined ? { exit_code: result.exit_code } : {}) });
251
+ }
252
+ await conversationClient(this.#ctx).deviceRequests.result(safeId(r.id), result, { idempotencyKey: `${this.#deviceId}:${r.id}:${result.state}${again}`, signal: this.#ctx.signal });
253
+ }
254
+ /** This computer's person (the signed-in user, or who created its API key), checked every 10 minutes. */
255
+ async #me() {
256
+ if (Date.now() - this.#identity.at < 10 * 60_000)
257
+ return this.#identity.userId;
258
+ let userId = this.#ctx.store.profile(this.#ctx.apiUrl).session?.user?.id;
259
+ if (!userId) {
260
+ try {
261
+ const me = await conversationClient(this.#ctx).me();
262
+ userId = me.user?.id ?? me.api_key?.created_by_user_id ?? undefined;
263
+ }
264
+ catch { /* unknown: only conversations started here are this person's */ }
265
+ }
266
+ this.#identity = { at: Date.now(), ...(userId ? { userId } : {}) };
267
+ return userId;
268
+ }
269
+ /**
270
+ * Allow once by a grant, by this person's approval mode (device-mode.ts), or by asking. Only this person's own
271
+ * agents run without asking. Until they choose a mode, their first request asks for it (`choose_mode`), and a mode
272
+ * chosen while a request waits (the prompt, Settings, `shard device approvals`) takes the request by itself.
273
+ */
274
+ async #decide(r, c, approval, granted) {
275
+ if (granted)
276
+ return 'once';
277
+ const dir = this.#ctx.store.dir, me = await this.#me();
278
+ const mine = ownConversation(c, localConversations(this.#ctx), me);
279
+ const mode = deviceApprovalMode(dir, me);
280
+ const auto = (m) => {
281
+ 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',
282
+ ...(approval.conversation_title ? { conversation_title: approval.conversation_title } : {}), ...(approval.project_name ? { project_name: approval.project_name } : {}),
283
+ ...(approval.argv ? { argv: approval.argv } : {}), ...(approval.local_path ? { local_path: approval.local_path } : {}) });
284
+ recordDeviceActivity(dir, this.#auto.get(r.id));
285
+ return 'once';
286
+ };
287
+ if (mine && mode && runsWithoutAsking(mode, r.kind))
288
+ return auto(mode);
289
+ if (!mine)
290
+ return this.#ask(approval);
291
+ const stop = new AbortController();
292
+ let chosen;
293
+ const watch = (async () => {
294
+ while (!stop.signal.aborted) {
295
+ await delay(400, undefined, { signal: stop.signal }).catch(() => undefined);
296
+ const now = deviceApprovalMode(dir, me);
297
+ if (!stop.signal.aborted && now && runsWithoutAsking(now, r.kind)) {
298
+ chosen = now;
299
+ stop.abort();
300
+ }
301
+ }
302
+ })();
303
+ try {
304
+ const choice = await this.#ask(mode ? approval : { ...approval, choose_mode: true }, stop.signal).catch((e) => { if (chosen)
305
+ return 'once'; throw e; });
306
+ return chosen ? auto(chosen) : choice;
307
+ }
308
+ finally {
309
+ stop.abort();
310
+ await watch;
311
+ }
209
312
  }
210
313
  async execute(r) {
211
314
  const ctx = this.#ctx;
@@ -218,8 +321,10 @@ export class DeviceExecutor {
218
321
  const matching = Object.values(records).filter(record => record.project_id === r.project_id);
219
322
  const folders = new Set(matching.map(record => record.folder));
220
323
  const record = records[r.conversation_id] ?? (folders.size === 1 ? matching[0] : undefined);
324
+ // No folder of this conversation on this computer: a home conversation's command (an orchestrator thread asking the
325
+ // person's computer) runs in their home folder once they allow it; anything else is answered, never left waiting.
221
326
  if (!record || record.project_id !== r.project_id)
222
- return; // No inferred local path for unknown projects.
327
+ return this.#unplaced(r);
223
328
  let c = await getConversation(ctx, r.conversation_id);
224
329
  if (c.project_id !== r.project_id)
225
330
  throw new Error('Device request project changed.');
@@ -227,7 +332,7 @@ export class DeviceExecutor {
227
332
  const requested = r.exec?.cwd_relative ?? r.files?.local_path_relative ?? '.';
228
333
  const target = devicePath(planned.path, requested), grants = new DeviceGrants(ctx.store.dir);
229
334
  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));
335
+ 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
336
  if (choice === 'deny') {
232
337
  await this.result(r, { state: 'denied' });
233
338
  return;
@@ -331,6 +436,74 @@ export class DeviceExecutor {
331
436
  ws?.cell().close();
332
437
  }
333
438
  }
439
+ /**
440
+ * A request whose conversation has no folder on this device (0.27.0+). A home conversation's exec runs in the home
441
+ * folder, or the ~/ or absolute directory it names, after the person allows this one request (never Always: commands
442
+ * never run under a grant). Everything else is refused with the reason, so its agent hears it instead of waiting forever.
443
+ */
444
+ async #unplaced(r) {
445
+ const ctx = this.#ctx, home = deviceHome(ctx), say = (state, message) => this.result(r, { state, stderr_b64: Buffer.from(message).toString('base64') });
446
+ let c;
447
+ try {
448
+ c = await getConversation(ctx, r.conversation_id);
449
+ if (c.project_id !== r.project_id)
450
+ throw new Error('Device request project changed.');
451
+ }
452
+ catch (e) {
453
+ if (ctx.signal.aborted)
454
+ return;
455
+ await say('denied', e instanceof Error ? e.message : String(e));
456
+ return;
457
+ }
458
+ if (archived(c)) {
459
+ await say('denied', 'The conversation has ended.');
460
+ return;
461
+ }
462
+ if (r.kind !== 'exec' || !r.exec || c.home !== true) {
463
+ 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.`);
464
+ return;
465
+ }
466
+ let target;
467
+ try {
468
+ const requested = r.exec.cwd_relative && r.exec.cwd_relative !== '.' ? r.exec.cwd_relative : '~';
469
+ target = canonicalDevicePath(expandDevicePath(isDevicePath(requested) ? requested : `~/${requested}`, home));
470
+ }
471
+ catch (e) {
472
+ await say('denied', e instanceof Error ? e.message : String(e));
473
+ return;
474
+ }
475
+ const approval = { ...deviceApproval(r, c, { path: target, outside: true }, home),
476
+ summary: `Run on ${yourComputer()}: ${r.exec.argv.join(' ')} · in ${displayPath(target, home)}` };
477
+ let choice;
478
+ try {
479
+ choice = await this.#decide(r, c, approval, false);
480
+ }
481
+ catch (e) {
482
+ if (ctx.signal.aborted)
483
+ return;
484
+ await say('denied', e instanceof Error ? e.message : String(e));
485
+ return;
486
+ }
487
+ if (ctx.signal.aborted)
488
+ return;
489
+ if (choice !== 'once') {
490
+ await this.result(r, { state: 'denied' });
491
+ return;
492
+ }
493
+ const current = await getConversation(ctx, r.conversation_id).catch(() => undefined);
494
+ if (!current || current.project_id !== c.project_id || archived(current)) {
495
+ await say('denied', 'The conversation changed or ended during device approval.');
496
+ return;
497
+ }
498
+ await this.result(r, { state: 'approved' });
499
+ await this.result(r, { state: 'running' });
500
+ try {
501
+ await this.result(r, await deviceExec(r.exec.argv, target, r.exec.timeout_seconds, ctx.signal));
502
+ }
503
+ catch (e) {
504
+ await say('failed', e instanceof Error ? e.message : String(e));
505
+ }
506
+ }
334
507
  /**
335
508
  * A folder anywhere on this device (device-folders.ts). Every outcome is answered, so a request is never asked twice:
336
509
  * denied before it was approved, failed after.
@@ -371,7 +544,7 @@ export class DeviceExecutor {
371
544
  const grants = new DeviceGrants(ctx.store.dir);
372
545
  let choice;
373
546
  try {
374
- choice = grants.allowsFolder(ctx.apiUrl, target, r.kind) ? 'once' : await this.#ask(folderApproval(r, c, target, folder, home, remote));
547
+ choice = await this.#decide(r, c, folderApproval(r, c, target, folder, home, remote), grants.allowsFolder(ctx.apiUrl, target, r.kind));
375
548
  }
376
549
  catch (e) {
377
550
  if (ctx.signal.aborted)
@@ -412,7 +585,7 @@ export class DeviceExecutor {
412
585
  if (r.kind === 'dir_list')
413
586
  result = { state: 'done', dir: await listDeviceDir(target, r.dir ?? {}, ctx.signal) };
414
587
  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 }) };
588
+ 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 }, undefined, this.#seedHub) };
416
589
  else {
417
590
  const f = r.files;
418
591
  if (!f.remote_path.startsWith('/') || /[\0\r\n]/.test(f.remote_path))
@@ -445,7 +618,7 @@ export class DeviceExecutor {
445
618
  }
446
619
  result = { state: 'done', files_artifact: r.kind === 'files_pull' ? f.remote_path : target };
447
620
  }
448
- await this.result(r, result);
621
+ await postResult((answer, again) => this.result(r, answer, again ? ':older-api' : ''), result);
449
622
  }
450
623
  catch (e) {
451
624
  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;
@@ -60,7 +64,8 @@ export declare function linkLocalProject(ctx: Ctx, path: string, options?: {
60
64
  brief?: string;
61
65
  harness?: 'claude' | 'codex';
62
66
  projectId?: string;
63
- }, start?: Starter): Promise<LinkedProject>;
67
+ seed?: boolean;
68
+ }, start?: Starter, seedHub?: (ctx: Ctx, folder: string, projectId: string) => Promise<HubSeed>): Promise<LinkedProject>;
64
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. */
65
70
  export declare const PROJECT_LINK_HANDLERS: Record<string, Handler>;
66
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('~/'); }
@@ -200,7 +201,7 @@ function lastLine(stderr) {
200
201
  * and kept as this device's live copy: what conversations started from it finish lands back in it. With `brief`, a
201
202
  * conversation starts there at once. A folder without Git gets a repository, as a new chat from it does.
202
203
  */
203
- export async function linkLocalProject(ctx, path, options = {}, start = startFromFolder) {
204
+ export async function linkLocalProject(ctx, path, options = {}, start = startFromFolder, seedHub = seedProjectHub) {
204
205
  const { folder } = projectIdentity(path);
205
206
  await ensureProjectGit(ctx, folder);
206
207
  const identity = projectIdentity(folder);
@@ -218,7 +219,10 @@ export async function linkLocalProject(ctx, path, options = {}, start = startFro
218
219
  if (!projectId)
219
220
  throw new UsageError('Choose your default project first: shard projects use <id|slug>.');
220
221
  const name = [...(identity.remote ? identity.slug : basename(folder))].slice(0, 200).join('') || identity.slug;
221
- 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 } : {}) } : {}) };
222
226
  }
223
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. */
224
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;
@@ -42,22 +42,24 @@ export async function runDeviceHelper(ctx, options = {}) {
42
42
  }
43
43
  })().finally(() => abort.abort());
44
44
  void input.catch(() => abort.abort());
45
- const ask = async (approval) => {
46
- const acp = await askConnectedAcp(ctx.store.dir, approval, signal);
45
+ // `until` ends one question early: the person chose a mode that lets the request run by itself (device-mode.ts).
46
+ const ask = async (approval, until) => {
47
+ const asking = until ? AbortSignal.any([signal, until]) : signal;
48
+ const acp = await askConnectedAcp(ctx.store.dir, approval, asking);
47
49
  if (acp)
48
50
  return acp;
49
51
  // The desktop app, while it runs, shows it as a Needs-you item (0.25.0+); else the menu bar or the service files.
50
- const app = await askDeviceApprover(ctx.store.dir, approval, signal);
52
+ const app = await askDeviceApprover(ctx.store.dir, approval, asking);
51
53
  if (app)
52
54
  return app;
53
55
  if (options.service)
54
- return askLocalDeviceApproval(ctx, approval);
56
+ return askLocalDeviceApproval({ ...ctx, signal: asking }, approval);
55
57
  return new Promise(resolve => {
56
58
  const denied = () => { pending.delete(approval.request_id); resolve('deny'); };
57
- signal.addEventListener('abort', denied, { once: true });
58
- pending.set(approval.request_id, choice => { signal.removeEventListener('abort', denied); resolve(choice); });
59
+ asking.addEventListener('abort', denied, { once: true });
60
+ pending.set(approval.request_id, choice => { asking.removeEventListener('abort', denied); resolve(choice); });
59
61
  ctx.io.stdout.write(JSON.stringify(approval) + '\n');
60
- if (signal.aborted)
62
+ if (asking.aborted)
61
63
  denied();
62
64
  }).finally(() => { if (!signal.aborted)
63
65
  ctx.io.stdout.write(JSON.stringify({ type: 'approval_resolved', request_id: approval.request_id }) + '\n'); });
@@ -74,6 +76,10 @@ export async function runDeviceHelper(ctx, options = {}) {
74
76
  writePrivateFile(ctx.store.dir, deviceFile, deviceId + '\n');
75
77
  }
76
78
  const executor = new DeviceExecutor(ctx, ask, deviceId);
79
+ // A device id another identity registered (an earlier API key `shard setup` replaced, another account) can never
80
+ // register for this one: a refusal gives this computer a new id (0.27.0+), at most once every 10 minutes, so a second
81
+ // sign-in change later renews again and a refusal right after a renewal stops (never a loop).
82
+ let renewedAt = 0;
77
83
  const report = (e) => { ctx.io.stderr.write(`Device bridge: ${e instanceof Error ? e.message : String(e)}\n`); };
78
84
  let mirroring, twin, returning, preparing, projects;
79
85
  try {
@@ -150,6 +156,7 @@ export async function runDeviceHelper(ctx, options = {}) {
150
156
  // Registered for every project an agent may ask from (deviceProjects); one poll covers them all where the API
151
157
  // lists by device (0.25.0+), else one per project.
152
158
  const registered = new Map();
159
+ const registrationFailures = new Map();
153
160
  let all, deviceWide = true, links = true;
154
161
  // This device's local conversations of a project (started here or opened here): their requests come here first.
155
162
  const linked = (projectId) => Object.values(localConversations(ctx)).filter(r => !r.handed_off && (!projectId || r.project_id === projectId))
@@ -176,13 +183,42 @@ export async function runDeviceHelper(ctx, options = {}) {
176
183
  all = { ids: await deviceProjects(ctx).catch(() => all?.ids ?? []), at: Date.now() };
177
184
  const projects = [...new Set([...Object.values(localConversations(ctx)).map(r => r.project_id), ...all.ids])];
178
185
  for (const projectId of projects) {
186
+ const failed = registrationFailures.get(projectId);
187
+ if (failed && (failed.next === null || Date.now() < failed.next))
188
+ continue;
179
189
  if (Date.now() - (registered.get(projectId) ?? 0) < 10_000)
180
190
  continue;
181
- await beat(projectId).then(() => registered.set(projectId, Date.now()), (e) => {
182
- // A project this sign-in no longer reaches is skipped until the next listing, never the others.
183
- if (!(e instanceof ShardfluxApiError && [403, 404].includes(e.status)))
184
- throw e;
185
- registered.set(projectId, Date.now() + 290_000);
191
+ await beat(projectId).then(() => {
192
+ registered.set(projectId, Date.now());
193
+ registrationFailures.delete(projectId);
194
+ }, (e) => {
195
+ if (deviceIdTaken(e) && Date.now() - renewedAt > 600_000) {
196
+ renewedAt = Date.now();
197
+ const old = deviceId;
198
+ deviceId = renewDeviceId(ctx.store.dir, old, projectId);
199
+ executor.deviceId = deviceId;
200
+ // Every project registers again under the new id, at once.
201
+ registered.clear();
202
+ registrationFailures.clear();
203
+ report(new Error(`this computer's device id ${old} belongs to another sign-in (an earlier API key or account); it registers as ${deviceId} for this one.`));
204
+ return;
205
+ }
206
+ // A refused registration belongs to this project alone. Repeating a
207
+ // device identity conflict cannot repair it or hold every later project.
208
+ const attempts = (failed?.attempts ?? 0) + 1;
209
+ const permanent = e instanceof ShardfluxApiError && !e.retryable;
210
+ const stopped = permanent || attempts >= 8;
211
+ const wait = Math.min(300_000, 5000 * 2 ** Math.min(attempts - 1, 6));
212
+ registrationFailures.set(projectId, { attempts, next: stopped ? null : Date.now() + wait });
213
+ const message = e instanceof Error ? e.message : String(e);
214
+ if (stopped) {
215
+ const action = e instanceof ShardfluxApiError && e.details?.reason === 'device_id_taken'
216
+ ? 'Its device id was renewed moments ago and is refused again; restart the device helper.'
217
+ : 'Check connectivity or sign in again, then restart the device helper to retry.';
218
+ report(new Error(`Device helper needs attention for project ${projectId || 'default'} after ${attempts} failed registrations: ${message} ${action}`));
219
+ }
220
+ else
221
+ report(new Error(`Device registration for project ${projectId || 'default'} failed (attempt ${attempts}/8): ${message} Retrying in ${wait / 1000}s.`));
186
222
  });
187
223
  }
188
224
  let polled = false;
@@ -269,3 +305,26 @@ export async function deviceProjects(ctx) {
269
305
  }
270
306
  return [...ids];
271
307
  }
308
+ /** 409 `device_id_taken`: this device id is registered in a project by another identity (POST /v1/device-requests/heartbeat). */
309
+ export function deviceIdTaken(e) {
310
+ return e instanceof ShardfluxApiError && e.status === 409 && e.details?.reason === 'device_id_taken';
311
+ }
312
+ /**
313
+ * A new device id for this computer, replacing `old` (0.27.0+). Local state stays: grants are kept by API and folder
314
+ * or project (device-bridge.ts DeviceGrants), the approval mode by person (device-mode.ts), and the twin's refs by the
315
+ * id that made them. The old id is kept in `device-id-history.json` (the last 20) for support.
316
+ */
317
+ export function renewDeviceId(dir, old, projectId) {
318
+ const id = randomUUID(), historyFile = join(dir, 'device-id-history.json');
319
+ let history = [];
320
+ try {
321
+ const value = JSON.parse(readFileSync(historyFile, 'utf8'));
322
+ if (Array.isArray(value))
323
+ history = value;
324
+ }
325
+ catch { /* first one */ }
326
+ history = [...history, { id: old, replaced_at: new Date().toISOString(), replaced_by: id, project_id: projectId || null }].slice(-20);
327
+ writePrivateFile(dir, historyFile, JSON.stringify(history, null, 2) + '\n');
328
+ writePrivateFile(dir, join(dir, 'device-id'), id + '\n');
329
+ return id;
330
+ }
@@ -0,0 +1,44 @@
1
+ export type DeviceApprovalMode = 'auto' | 'read' | 'ask';
2
+ export declare const DEVICE_APPROVAL_MODES: readonly DeviceApprovalMode[];
3
+ /**
4
+ * The mode before the person chooses one. `undefined`: nothing is decided for them; the first request asks which they
5
+ * want (the app's prompt, or one line from `shard`), and the app's onboarding asks too. 'auto' or 'ask' here chooses
6
+ * for everyone who has not.
7
+ */
8
+ export declare const DEFAULT_DEVICE_APPROVALS: DeviceApprovalMode | undefined;
9
+ /** The kinds that only read this computer: a folder listing, and a file copied to the conversation. */
10
+ export declare const READ_KINDS: ReadonlySet<string>;
11
+ export declare const MODE_LABELS: Record<DeviceApprovalMode, string>;
12
+ /** The one line a person without the app reads when a request needs the choice ("this Mac" on macOS). */
13
+ export declare const chooseLine: (platform?: NodeJS.Platform) => string;
14
+ export interface DeviceSettings {
15
+ approvals?: DeviceApprovalMode;
16
+ chosen_at?: string;
17
+ /** Who chose it, when known: another person signed in on this computer chooses for themselves. */
18
+ user_id?: string;
19
+ }
20
+ export declare function readDeviceSettings(dir: string): DeviceSettings;
21
+ /** This person's mode on this computer; undefined until they choose one (and DEFAULT_DEVICE_APPROVALS is unset). */
22
+ export declare function deviceApprovalMode(dir: string, userId?: string): DeviceApprovalMode | undefined;
23
+ export declare function setDeviceApprovalMode(dir: string, mode: string, userId?: string): DeviceSettings;
24
+ /** Whether a request of this kind runs without asking under this mode (for the person's own agents). */
25
+ export declare function runsWithoutAsking(mode: DeviceApprovalMode | undefined, kind: string): boolean;
26
+ /** One line of what ran without asking: the start (`running`) and how it ended, folded by `request_id`. */
27
+ export interface DeviceActivity {
28
+ at: string;
29
+ request_id: string;
30
+ conversation_id: string;
31
+ kind: string;
32
+ summary: string;
33
+ mode: DeviceApprovalMode;
34
+ state: 'running' | 'done' | 'failed' | 'denied';
35
+ conversation_title?: string;
36
+ project_name?: string;
37
+ argv?: string[];
38
+ local_path?: string;
39
+ exit_code?: number;
40
+ finished_at?: string;
41
+ }
42
+ export declare function recordDeviceActivity(dir: string, entry: DeviceActivity): void;
43
+ /** What ran without asking, newest first: one entry per request with its latest state. */
44
+ export declare function deviceActivity(dir: string, limit?: number): DeviceActivity[];