@shardflux/cli 0.16.0 → 0.18.0-next.4

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 (74) hide show
  1. package/CHANGELOG.md +67 -2
  2. package/README.md +47 -8
  3. package/dist/agent-commands.d.ts +9 -1
  4. package/dist/agent-commands.js +65 -16
  5. package/dist/agent-models.d.ts +61 -0
  6. package/dist/agent-models.js +79 -0
  7. package/dist/agent-state.d.ts +10 -0
  8. package/dist/agent-state.js +13 -0
  9. package/dist/agent-target-commands.d.ts +6 -1
  10. package/dist/agent-target-commands.js +113 -16
  11. package/dist/agent-targets.d.ts +17 -5
  12. package/dist/agent-targets.js +27 -11
  13. package/dist/args.d.ts +5 -1
  14. package/dist/args.js +49 -9
  15. package/dist/cli-proxy-forward.d.ts +45 -0
  16. package/dist/cli-proxy-forward.js +432 -0
  17. package/dist/cli-proxy-hub.d.ts +273 -0
  18. package/dist/cli-proxy-hub.js +749 -0
  19. package/dist/cli-proxy.d.ts +325 -0
  20. package/dist/cli-proxy.js +1016 -0
  21. package/dist/codex-cloud.d.ts +2 -1
  22. package/dist/codex-cloud.js +16 -6
  23. package/dist/command-center-actions.js +16 -1
  24. package/dist/conversation-local.js +2 -2
  25. package/dist/conversation-stash.js +4 -3
  26. package/dist/conversation-terminal.d.ts +5 -0
  27. package/dist/conversation-terminal.js +45 -6
  28. package/dist/conversations.d.ts +18 -2
  29. package/dist/conversations.js +12 -5
  30. package/dist/desktop-linux.d.ts +40 -0
  31. package/dist/desktop-linux.js +186 -0
  32. package/dist/device-approvals.d.ts +12 -0
  33. package/dist/device-approvals.js +75 -0
  34. package/dist/device-helper.d.ts +3 -1
  35. package/dist/device-helper.js +5 -2
  36. package/dist/errors.d.ts +2 -0
  37. package/dist/errors.js +1 -1
  38. package/dist/git-remote-bin.js +0 -0
  39. package/dist/handlers.js +3 -1
  40. package/dist/index.d.ts +4 -0
  41. package/dist/index.js +3 -0
  42. package/dist/linux-helper.d.ts +12 -0
  43. package/dist/linux-helper.js +88 -0
  44. package/dist/linux-terminal.d.ts +11 -0
  45. package/dist/linux-terminal.js +42 -0
  46. package/dist/main.js +30 -6
  47. package/dist/menubar.js +44 -4
  48. package/dist/onboarding.d.ts +2 -0
  49. package/dist/onboarding.js +59 -9
  50. package/dist/t3-asar.d.ts +10 -0
  51. package/dist/t3-asar.js +91 -0
  52. package/dist/t3-launch.d.ts +37 -0
  53. package/dist/t3-launch.js +133 -0
  54. package/dist/t3-linux.d.ts +68 -0
  55. package/dist/t3-linux.js +343 -0
  56. package/dist/t3-mode.d.ts +129 -0
  57. package/dist/t3-mode.js +676 -0
  58. package/dist/t3-phone.d.ts +17 -0
  59. package/dist/t3-phone.js +15 -0
  60. package/dist/t3-provider.d.ts +5 -3
  61. package/dist/t3-provider.js +12 -49
  62. package/dist/t3-thread-script.d.ts +18 -0
  63. package/dist/t3-thread-script.js +245 -0
  64. package/dist/terminal-onboarding.d.ts +11 -0
  65. package/dist/terminal-onboarding.js +75 -0
  66. package/dist/thread-on-computer.d.ts +2 -2
  67. package/dist/thread-on-computer.js +13 -7
  68. package/dist/update.d.ts +1 -1
  69. package/dist/update.js +28 -1
  70. package/dist/worktree-twin-engine.d.ts +31 -1
  71. package/dist/worktree-twin-engine.js +253 -40
  72. package/dist/worktree-twin.d.ts +17 -0
  73. package/dist/worktree-twin.js +147 -22
  74. package/package.json +7 -19
@@ -516,19 +516,23 @@ export async function landingRound(cell, input) {
516
516
  await cell.exec.run(['rm', '-rf', '--', dir], { timeoutMs: 60_000 }).catch(() => undefined);
517
517
  }
518
518
  }
519
- async function landingRoundIn(cell, input) {
520
- const now = input.now ?? Date.now, t0 = now(), timings = {};
521
- const mark = (name) => { timings[name] = now() - t0; };
522
- const c = input.conversation, branch = `refs/heads/${input.into}`, hub = input.hub ?? HUB_PATH, vmTmp = input.vmTmp ?? '/tmp';
519
+ /** The twin of the checkout where the branch is checked out (or of the branch itself), or why not now. */
520
+ async function takeSnapshot(input) {
521
+ const branch = `refs/heads/${input.into}`;
523
522
  if (input.worktree) {
524
523
  const reason = pauseReason(input.worktree);
525
524
  if (reason)
526
- return { state: 'paused', reason, timings };
525
+ return { state: 'paused', reason };
527
526
  }
528
527
  const snap = input.worktree ? await snapshotWorktree(input.worktree, { device: input.device, stateDir: input.stateDir, signal: input.signal }) : await refTwin(input.repo, branch, input.device);
529
528
  if (input.worktree && snap.base !== await verifyCommit(input.repo, branch))
530
- return { state: 'moved', reason: 'branch_switched', timings };
531
- mark('snapshot');
529
+ return { state: 'moved', reason: 'branch_switched' };
530
+ return snap;
531
+ }
532
+ const landRefs = (name) => ({ tip: `refs/shard/land/${name}/tip`, result: `refs/shard/land/${name}/result` });
533
+ /** The landing script in the conversation's VM against the twin; a ready landing's tip and result are fetched here. */
534
+ async function mergeInVm(cell, input, snap, mark) {
535
+ const c = input.conversation, hub = input.hub ?? HUB_PATH, vmTmp = input.vmTmp ?? '/tmp';
532
536
  const out = `${vmTmp}/shard-land-${randomUUID()}.bundle`;
533
537
  const send = async (known) => {
534
538
  // The VM has the conversation's base and what earlier landings made there; the bundle carries the rest.
@@ -582,9 +586,8 @@ async function landingRoundIn(cell, input) {
582
586
  await runLandingScript(cell, { op: 'confirm', repo: c.worktree_path, hub, name: c.name, upto: land.upto, into: input.into }, input.signal).catch(() => undefined);
583
587
  }
584
588
  if (land.state !== 'ready')
585
- return { state: land.state === 'error' ? 'error' : land.state, land, timings };
586
- const fetched = join(tmpdir(), `shard-land-${randomUUID()}.bundle`);
587
- const tipRef = `refs/shard/land/${c.name}/tip`, resultRef = `refs/shard/land/${c.name}/result`;
589
+ return land;
590
+ const fetched = join(tmpdir(), `shard-land-${randomUUID()}.bundle`), refs = landRefs(c.name);
588
591
  try {
589
592
  let bytes;
590
593
  if (land.bundle_b64)
@@ -598,8 +601,8 @@ async function landingRoundIn(cell, input) {
598
601
  throw new Error('the landing bundle changed while it was read');
599
602
  }
600
603
  await writeFile(fetched, bytes, { mode: 0o600 });
601
- await git(input.repo, ['fetch', '-q', '--no-tags', fetched, `+refs/shard/land/tip:${tipRef}`, `+refs/shard/land/result:${resultRef}`]);
602
- if (await verifyCommit(input.repo, tipRef) !== land.tip || await verifyCommit(input.repo, resultRef) !== land.result)
604
+ await git(input.repo, ['fetch', '-q', '--no-tags', fetched, `+refs/shard/land/tip:${refs.tip}`, `+refs/shard/land/result:${refs.result}`]);
605
+ if (await verifyCommit(input.repo, refs.tip) !== land.tip || await verifyCommit(input.repo, refs.result) !== land.result)
603
606
  throw new Error('the landing bundle does not match the landing');
604
607
  }
605
608
  finally {
@@ -608,22 +611,144 @@ async function landingRoundIn(cell, input) {
608
611
  await cell.files.remove(out).catch(() => undefined);
609
612
  }
610
613
  mark('download');
611
- const outcome = await applyLanding({ worktree: input.worktree, repo: input.repo, branch, base: land.base, twin: land.twin, tip: land.tip, result: land.result }, { backupRoot: input.backupRoot, quietMs: input.quietMs, now, label: { conversation_id: c.id, name: c.name, title: c.title } });
612
- mark('apply');
613
- await git(input.repo, ['update-ref', '-d', resultRef], { ok: [0, 1] });
614
- await git(input.repo, ['update-ref', '-d', tipRef], { ok: [0, 1] });
615
- if (outcome.state !== 'applied')
616
- return { ...outcome, reason: 'reason' in outcome ? outcome.reason : 'busy', land, timings };
617
- // The files are on disk. Recording the landing in the VM and copying it to the project hub (shared storage, seconds)
618
- // runs after, without holding up the next landing.
614
+ return land;
615
+ }
616
+ async function dropLandRefs(repo, name) {
617
+ const refs = landRefs(name);
618
+ await git(repo, ['update-ref', '-d', refs.result], { ok: [0, 1] });
619
+ await git(repo, ['update-ref', '-d', refs.tip], { ok: [0, 1] });
620
+ }
621
+ /**
622
+ * The files are on disk. Recording the landing in the VM and copying it to the project hub (shared storage, seconds)
623
+ * runs after, without holding up the next landing.
624
+ */
625
+ function confirmInVm(cell, input, snap, land, timings, since, now) {
626
+ const c = input.conversation, hub = input.hub ?? HUB_PATH;
619
627
  const confirmed = runLandingScript(cell, { op: 'confirm', repo: c.worktree_path, hub, name: c.name, upto: land.upto, into: input.into,
620
628
  twin: land.twin, hub_twin_ref: hubTwinRef(input.device, snap.id), tip: land.tip, result: land.result }, input.signal).then(r => {
621
629
  if (r.state === 'error')
622
630
  throw new Error(`landing confirmation failed: ${r.message ?? r.reason}`);
623
- timings.confirm = now() - t0;
631
+ timings.confirm = now() - since;
624
632
  });
625
633
  confirmed.catch(() => undefined);
626
- return { state: 'landed', land, applied: outcome, timings, confirmed };
634
+ return confirmed;
635
+ }
636
+ async function landingRoundIn(cell, input) {
637
+ const now = input.now ?? Date.now, t0 = now(), timings = {};
638
+ const mark = (name) => { timings[name] = now() - t0; };
639
+ const c = input.conversation, branch = `refs/heads/${input.into}`;
640
+ const snap = await takeSnapshot(input);
641
+ if ('state' in snap)
642
+ return { ...snap, timings };
643
+ mark('snapshot');
644
+ const land = await mergeInVm(cell, input, snap, mark);
645
+ if (land.state !== 'ready')
646
+ return { state: land.state === 'error' ? 'error' : land.state, land, timings };
647
+ const outcome = await applyLanding({ worktree: input.worktree, repo: input.repo, branch, base: land.base, twin: land.twin, tip: land.tip, result: land.result }, { backupRoot: input.backupRoot, quietMs: input.quietMs, now, label: { conversation_id: c.id, name: c.name, title: c.title } });
648
+ mark('apply');
649
+ await dropLandRefs(input.repo, c.name);
650
+ if (outcome.state !== 'applied')
651
+ return { ...outcome, reason: 'reason' in outcome ? outcome.reason : 'busy', land, timings };
652
+ return { state: 'landed', land, applied: outcome, timings, confirmed: confirmInVm(cell, input, snap, land, timings, t0, now) };
653
+ }
654
+ async function mergeTree(repo, base, ours, theirs, strategy) {
655
+ const r = await git(repo, ['merge-tree', '--write-tree', '--name-only', '--no-messages', '-z', `--merge-base=${base}`, ...(strategy ? ['-X', strategy] : []), ours, theirs], { ok: [0, 1] });
656
+ const parts = r.stdout.toString('utf8').split('\0');
657
+ return { tree: parts[0].trim(), conflicts: r.code === 1 ? [...new Set(parts.slice(1).filter(Boolean))].sort() : [] };
658
+ }
659
+ /** cur with each first-parent commit of base..tip replayed on it (author and message kept), or null on a conflict. */
660
+ async function replayOnto(repo, cur, base, tip) {
661
+ const commits = (await gitText(repo, ['rev-list', '--reverse', '--first-parent', tip, `^${base}`])).split('\n').filter(Boolean);
662
+ for (const x of commits) {
663
+ const parent = await verifyCommit(repo, `${x}^1`);
664
+ if (!parent)
665
+ return null;
666
+ const { tree, conflicts } = await mergeTree(repo, parent, cur, x);
667
+ if (conflicts.length)
668
+ return null;
669
+ if (tree === await gitText(repo, ['rev-parse', `${cur}^{tree}`]))
670
+ continue;
671
+ const [name, email, date] = (await gitText(repo, ['log', '-1', '--format=%an%x00%ae%x00%ad', '--date=raw', x])).split('\0');
672
+ const raw = (await git(repo, ['cat-file', 'commit', x])).stdout, message = raw.subarray(raw.indexOf('\n\n') + 2);
673
+ cur = await gitText(repo, ['commit-tree', tree, '-p', cur, '-F', '-'], { input: message, env: { GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date } });
674
+ }
675
+ return cur;
676
+ }
677
+ /** The user's working state with the branch's new commits merged in; where both changed the same lines, the user's text. */
678
+ async function resultCommit(repo, base, twin, tip) {
679
+ let { tree, conflicts } = await mergeTree(repo, base, twin, tip);
680
+ const shadowed = conflicts;
681
+ if (conflicts.length) {
682
+ ({ tree, conflicts } = await mergeTree(repo, base, twin, tip, 'ours'));
683
+ if (conflicts.length) {
684
+ const dir = join(tmpdir(), `shard-index-${randomUUID()}`), env = { GIT_INDEX_FILE: dir };
685
+ try {
686
+ await git(repo, ['read-tree', tree], { env });
687
+ await git(repo, ['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', ...conflicts], { env });
688
+ const listing = (await git(repo, ['ls-tree', '-r', '-z', '--full-tree', twin, '--', ...conflicts])).stdout.toString('utf8').split('\0').filter(Boolean)
689
+ .map(l => l.replace(/ (blob|commit) /, ' '));
690
+ if (listing.length)
691
+ await git(repo, ['update-index', '-z', '--index-info'], { env, input: listing.join('\0') + '\0' });
692
+ tree = await gitText(repo, ['write-tree'], { env });
693
+ }
694
+ finally {
695
+ rmSync(dir, { force: true });
696
+ }
697
+ }
698
+ }
699
+ const commit = await gitText(repo, ['commit-tree', tree, '-p', tip, '-F', '-'], { input: 'Shardflux landing result\n', env: { GIT_AUTHOR_NAME: 'Shardflux', GIT_AUTHOR_EMAIL: 'landing@shardflux.dev', GIT_COMMITTER_NAME: 'Shardflux', GIT_COMMITTER_EMAIL: 'landing@shardflux.dev' } });
700
+ return { commit, shadowed };
701
+ }
702
+ /**
703
+ * Lands several finished conversations into one branch with one apply: one twin, the merges in their VMs in parallel,
704
+ * then their new commits stacked here in the given (finish) order and the files written once. A conversation whose
705
+ * commits do not stack cleanly on the earlier ones is 'deferred' (it lands next, against the new state).
706
+ */
707
+ export async function landingBatch(items, shared) {
708
+ const now = shared.now ?? Date.now, t0 = now(), timings = {};
709
+ const mark = (name) => { timings[name] = now() - t0; };
710
+ const snap = await takeSnapshot(shared);
711
+ if ('state' in snap)
712
+ return items.map(() => ({ ...snap, timings }));
713
+ mark('snapshot');
714
+ const inputs = items.map(it => ({ ...shared, mode: 'auto', conversation: it.conversation, upto: it.upto, known: it.known }));
715
+ const lands = await Promise.all(items.map((it, i) => mergeInVm(it.cell, inputs[i], snap, () => undefined)
716
+ .catch((error) => ({ state: 'error', reason: 'exception', message: error instanceof Error ? error.message : String(error) }))));
717
+ mark('merge');
718
+ const outcomes = lands.map(land => land.state === 'ready' ? undefined : { state: land.state === 'error' ? 'error' : land.state, land, timings });
719
+ const branch = `refs/heads/${shared.into}`, base = snap.base, included = [];
720
+ let cur = base;
721
+ for (const [i, land] of lands.entries()) {
722
+ if (land.state !== 'ready')
723
+ continue;
724
+ const next = cur === base ? land.tip : await replayOnto(shared.repo, cur, base, land.tip);
725
+ if (next) {
726
+ cur = next;
727
+ included.push(i);
728
+ }
729
+ else
730
+ outcomes[i] = { state: 'deferred', reason: 'stacked', land, timings };
731
+ }
732
+ try {
733
+ if (!included.length)
734
+ return outcomes.map(o => o);
735
+ const result = included.length === 1 ? { commit: lands[included[0]].result, shadowed: lands[included[0]].shadowed ?? [] } : await resultCommit(shared.repo, base, snap.commit, cur);
736
+ mark('stack');
737
+ const first = items[included[0]].conversation;
738
+ const applied = await applyLanding({ worktree: shared.worktree, repo: shared.repo, branch, base, twin: snap.commit, tip: cur, result: result.commit }, { backupRoot: shared.backupRoot, quietMs: shared.quietMs, now, label: { conversation_id: first.id, name: included.map(i => items[i].conversation.name).join('+'), title: first.title } });
739
+ mark('apply');
740
+ for (const i of included) {
741
+ const land = { ...lands[i], shadowed: result.shadowed };
742
+ outcomes[i] = applied.state === 'applied'
743
+ ? { state: 'landed', land, applied, timings, confirmed: confirmInVm(items[i].cell, inputs[i], snap, land, timings, t0, now) }
744
+ : { ...applied, reason: 'reason' in applied ? applied.reason : 'busy', land, timings };
745
+ }
746
+ return outcomes.map(o => o);
747
+ }
748
+ finally {
749
+ for (const it of items)
750
+ await dropLandRefs(shared.repo, it.conversation.name);
751
+ }
627
752
  }
628
753
  export class TwinLedger {
629
754
  store;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.16.0",
3
+ "version": "0.18.0-next.4",
4
4
  "type": "module",
5
5
  "description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
6
6
  "license": "Apache-2.0",
@@ -43,29 +43,17 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
+ "@shardflux/sdk": "0.24.0-next.4",
46
47
  "classic-level": "3.0.0",
47
48
  "smol-toml": "1.9.0",
48
49
  "snappyjs": "0.7.0",
49
- "yaml": "2.9.1",
50
- "@shardflux/sdk": "^0.22.0"
51
- },
52
- "devDependencies": {
53
- "@eslint/js": "10.0.1",
54
- "@types/node": "24.13.6",
55
- "eslint": "10.11.0",
56
- "playwright": "1.62.1",
57
- "typescript": "5.9.3",
58
- "typescript-eslint": "8.70.1"
50
+ "yaml": "2.9.1"
59
51
  },
60
52
  "optionalDependencies": {
61
- "@shardflux/menubar-darwin": "^0.4.0"
62
- },
63
- "scripts": {
64
- "build": "node scripts/build.mjs",
65
- "typecheck": "tsc -p tsconfig.json --noEmit",
66
- "lint": "eslint .",
67
- "test": "node --test test/*.test.ts"
53
+ "@shardflux/menubar-darwin": "^0.5.0",
54
+ "@shardflux/desktop-linux-x64": "0.1.0-next.4",
55
+ "@shardflux/desktop-linux-arm64": "0.1.0-next.4"
68
56
  },
69
57
  "main": "./dist/index.js",
70
58
  "types": "./dist/index.d.ts"
71
- }
59
+ }