@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
@@ -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;
@@ -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[];
@@ -0,0 +1,119 @@
1
+ /**
2
+ * How this computer answers its own agents (0.27.0+): the person chooses once, per device, whether their agents run
3
+ * things here without asking each time. Kept in `<config>/device-settings.json`; changed in the app's Settings or with
4
+ * `shard device approvals auto|read|ask`. Requests that ran without asking are kept in `<config>/device-activity.jsonl`
5
+ * (what ran, for which conversation, and how it ended), shown by the app and `shard device approvals`.
6
+ *
7
+ * - `ask`: every request asks (Allow once, or Always where a grant applies).
8
+ * - `read`: listing a folder and copying a file to a conversation run without asking; anything that changes this
9
+ * computer (commands, files saved here, ports, linking a folder) asks. Commands are never classified by their text.
10
+ * - `auto`: every request from this person's own agents runs without asking.
11
+ *
12
+ * Only this person's own conversations run without asking (`ownConversation`): a teammate's request always asks.
13
+ */
14
+ import { appendFileSync, lstatSync, readFileSync, statSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { writePrivateFile, ensurePrivateDir } from "./credentials.js";
17
+ import { UsageError } from "./errors.js";
18
+ import { thisComputer } from "./this-computer.js";
19
+ export const DEVICE_APPROVAL_MODES = ['auto', 'read', 'ask'];
20
+ /**
21
+ * The mode before the person chooses one. `undefined`: nothing is decided for them; the first request asks which they
22
+ * want (the app's prompt, or one line from `shard`), and the app's onboarding asks too. 'auto' or 'ask' here chooses
23
+ * for everyone who has not.
24
+ */
25
+ export const DEFAULT_DEVICE_APPROVALS = undefined;
26
+ /** The kinds that only read this computer: a folder listing, and a file copied to the conversation. */
27
+ export const READ_KINDS = new Set(['dir_list', 'files_pull']);
28
+ export const MODE_LABELS = {
29
+ auto: 'Allow everything from my agents',
30
+ read: 'Allow reading, ask before changes',
31
+ ask: 'Ask every time',
32
+ };
33
+ /** The one line a person without the app reads when a request needs the choice ("this Mac" on macOS). */
34
+ export const chooseLine = (platform = process.platform) => `Let your agents run things on ${thisComputer(platform)} without asking each time? Allow everything: shard device approvals auto · Ask each time: shard device approvals ask`;
35
+ const SETTINGS = 'device-settings.json', ACTIVITY = 'device-activity.jsonl';
36
+ export function readDeviceSettings(dir) {
37
+ const file = join(dir, SETTINGS);
38
+ try {
39
+ if (lstatSync(file).isSymbolicLink() || (statSync(file).mode & 0o077))
40
+ return {};
41
+ const value = JSON.parse(readFileSync(file, 'utf8'));
42
+ if (!value || typeof value !== 'object' || Array.isArray(value))
43
+ return {};
44
+ return DEVICE_APPROVAL_MODES.includes(value.approvals) ? value : { ...value, approvals: undefined };
45
+ }
46
+ catch {
47
+ return {};
48
+ }
49
+ }
50
+ /** This person's mode on this computer; undefined until they choose one (and DEFAULT_DEVICE_APPROVALS is unset). */
51
+ export function deviceApprovalMode(dir, userId) {
52
+ const s = readDeviceSettings(dir);
53
+ if (s.approvals && !(s.user_id && userId && s.user_id !== userId))
54
+ return s.approvals;
55
+ return DEFAULT_DEVICE_APPROVALS;
56
+ }
57
+ export function setDeviceApprovalMode(dir, mode, userId) {
58
+ if (!DEVICE_APPROVAL_MODES.includes(mode))
59
+ throw new UsageError(`Unknown approval mode "${mode}": auto, read or ask.`);
60
+ const next = { ...readDeviceSettings(dir), approvals: mode, chosen_at: new Date().toISOString() };
61
+ if (userId)
62
+ next.user_id = userId;
63
+ else
64
+ delete next.user_id;
65
+ writePrivateFile(dir, join(dir, SETTINGS), JSON.stringify(next, null, 2) + '\n');
66
+ return next;
67
+ }
68
+ /** Whether a request of this kind runs without asking under this mode (for the person's own agents). */
69
+ export function runsWithoutAsking(mode, kind) {
70
+ return mode === 'auto' || (mode === 'read' && READ_KINDS.has(kind));
71
+ }
72
+ const ACTIVITY_MAX_BYTES = 512 << 10, ACTIVITY_KEEP = 400;
73
+ export function recordDeviceActivity(dir, entry) {
74
+ const file = join(dir, ACTIVITY);
75
+ try {
76
+ ensurePrivateDir(dir);
77
+ let size = 0;
78
+ try {
79
+ size = statSync(file).size;
80
+ }
81
+ catch { /* first entry */ }
82
+ if (size > ACTIVITY_MAX_BYTES) {
83
+ const keep = readFileSync(file, 'utf8').split('\n').filter(Boolean).slice(-ACTIVITY_KEEP);
84
+ writePrivateFile(dir, file, keep.join('\n') + '\n');
85
+ }
86
+ appendFileSync(file, JSON.stringify(entry) + '\n', { mode: 0o600 });
87
+ }
88
+ catch { /* the request's own result still reaches its conversation */ }
89
+ }
90
+ /** What ran without asking, newest first: one entry per request with its latest state. */
91
+ export function deviceActivity(dir, limit = 20) {
92
+ let text;
93
+ try {
94
+ if (lstatSync(join(dir, ACTIVITY)).isSymbolicLink())
95
+ return [];
96
+ text = readFileSync(join(dir, ACTIVITY), 'utf8');
97
+ }
98
+ catch {
99
+ return [];
100
+ }
101
+ const byId = new Map();
102
+ for (const line of text.split('\n')) {
103
+ if (!line)
104
+ continue;
105
+ let e;
106
+ try {
107
+ e = JSON.parse(line);
108
+ }
109
+ catch {
110
+ continue;
111
+ }
112
+ if (!e || typeof e.request_id !== 'string')
113
+ continue;
114
+ const had = byId.get(e.request_id);
115
+ byId.delete(e.request_id);
116
+ byId.set(e.request_id, had ? { ...had, ...e, at: had.at, ...(e.state !== 'running' ? { finished_at: e.at } : {}) } : e);
117
+ }
118
+ return [...byId.values()].reverse().slice(0, limit);
119
+ }
@@ -14,8 +14,8 @@ export interface ProjectScope {
14
14
  * `needId` false leaves the key's project unnamed (the API defaults to it), saving a lookup.
15
15
  */
16
16
  export declare function projectScope(ctx: Ctx, values: Values, needId?: boolean): Promise<ProjectScope>;
17
- /** `drive ls` rows: size, saved time, type and path (the path last, as `files ls` puts the name). */
18
- export declare function driveTable(rows: readonly DriveFile[]): string;
17
+ /** `drive ls` and `brain ls` rows: size, saved time, type and path (the path last, as `files ls` puts the name). */
18
+ export declare function driveTable(rows: readonly Pick<DriveFile, 'size' | 'created_at' | 'content_type' | 'path'>[]): string;
19
19
  /**
20
20
  * The model template as people read it: the primary provider and why, the connected accounts, then one row per role
21
21
  * with what it runs and, for a role whose provider is out of usage, until when it waits and what the other provider
@@ -51,7 +51,7 @@ function drivePath(path, what = 'drive path') {
51
51
  }
52
52
  return path;
53
53
  }
54
- /** `drive ls` rows: size, saved time, type and path (the path last, as `files ls` puts the name). */
54
+ /** `drive ls` and `brain ls` rows: size, saved time, type and path (the path last, as `files ls` puts the name). */
55
55
  export function driveTable(rows) {
56
56
  return table(['SIZE', 'SAVED', 'TYPE', 'PATH'], rows.map((f) => [humanBytes(f.size), time(f.created_at), f.content_type, f.path]));
57
57
  }