@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
@@ -56,19 +56,46 @@ export interface EngineDeps {
56
56
  hub?: string;
57
57
  vmTmp?: string;
58
58
  quietMs?: number;
59
+ /**
60
+ * Long-polls the conversation's next finished turn after `after` (RFC 3339): the turn's conversation and report time,
61
+ * null when the wait timed out. Throws TurnWaitUnsupported when the API has no turn waits.
62
+ */
63
+ waitTurn?(c: TwinConversation, after: string, signal: AbortSignal): Promise<{
64
+ conversation: TwinConversation;
65
+ reported_at: string;
66
+ } | null | 'ended'>;
59
67
  }
60
68
  /** A turn ended with committed work only; the agent is not asking anything. */
61
69
  export declare function finished(c: TwinConversation, agents: TwinAgent[]): TwinAgent | 'state' | null;
62
70
  export declare function eligible(c: TwinConversation, ledger: TwinLedger, now: number): boolean;
63
71
  export declare function conflictMessage(into: string, files: string[]): string;
72
+ type Ready = {
73
+ c: TwinConversation;
74
+ record: LocalConversation;
75
+ mode: Exclude<LandingMode, 'off'>;
76
+ agent: TwinAgent | null;
77
+ at: number;
78
+ };
79
+ /** Refused by an API without turn waits (POST /wait with turn_after, 422): the twin polls instead. */
80
+ export declare class TurnWaitUnsupported extends Error {
81
+ }
64
82
  export declare class TwinEngine {
65
83
  #private;
66
84
  constructor(deps: EngineDeps);
67
- /** One pass over every project with a local conversation; returns the suggested wait before the next. */
85
+ /** Resolves when a pushed turn is waiting to land (the loop's sleep ends at once). */
86
+ pushed(): Promise<void>;
87
+ /** Stops every turn wait. */
88
+ stop(): void;
89
+ /**
90
+ * One pass; returns the suggested wait before the next. Turns pushed by the waits land first, without a listing;
91
+ * otherwise (and at least every 15 s, or every second when the API has no turn waits) every project is listed.
92
+ */
68
93
  tick(signal?: AbortSignal): Promise<number>;
69
94
  /** Back online or awake: every conversation's retry backoff is cleared, so the next pass tries all of them. */
70
95
  resetBackoff(): void;
71
96
  landOne(c: TwinConversation, record: LocalConversation, mode: Exclude<LandingMode, 'off'>, agent: TwinAgent | null, signal?: AbortSignal): Promise<number | undefined>;
97
+ /** Several finished conversations for one branch: merged in their VMs at once, the files written once. */
98
+ landBatch(items: Ready[], signal?: AbortSignal): Promise<number | undefined>;
72
99
  }
73
100
  export interface LoopDeps {
74
101
  tick(signal: AbortSignal): Promise<number>;
@@ -81,6 +108,8 @@ export interface LoopDeps {
81
108
  network(): string;
82
109
  /** Whether the API host answers now (a TCP connect, no request). */
83
110
  reachable(): Promise<boolean>;
111
+ /** Resolves when a pushed turn waits to land: the loop's sleep ends at once. */
112
+ kicked?(): Promise<void>;
84
113
  log(line: string): void;
85
114
  }
86
115
  /**
@@ -93,3 +122,4 @@ export declare function runTwinLoop(deps: LoopDeps, signal: AbortSignal): Promis
93
122
  /** The device helper's twin. Nothing here asks the user anything. */
94
123
  export declare function runWorktreeTwin(ctx: Ctx, device: string, signal: AbortSignal): Promise<void>;
95
124
  export declare const TWIN_HANDLERS: Record<string, Handler>;
125
+ export {};
@@ -14,7 +14,7 @@ import { conversationClient } from "./conversation-commands.js";
14
14
  import { conversationName, conversationsUnavailable, listConversations, localConversations } from "./conversations.js";
15
15
  import { UsageError } from "./errors.js";
16
16
  import { projectIdentity } from "./project-identity.js";
17
- import { TwinLedger, backupRoot, inspectWorktree, landingRound, listWorktrees, pauseReason, restoreBackup } from "./worktree-twin.js";
17
+ import { TwinLedger, backupRoot, inspectWorktree, landingBatch, landingRound, listWorktrees, pauseReason, restoreBackup } from "./worktree-twin.js";
18
18
  import { readdirSync, readFileSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
20
  /** Its VM is gone: the conversation ended (archived after the retention window, or done). */
@@ -51,41 +51,165 @@ export function conflictMessage(into, files) {
51
51
  + `Resolve each conflict without asking: where you and the user changed the same lines, keep the user's lines and keep your change only where it fits around them; then run the checks and commit. `
52
52
  + `Your work lands on the user's computer automatically when you finish. Ask the user only if the result cannot work either way.`;
53
53
  }
54
+ /** Refused by an API without turn waits (POST /wait with turn_after, 422): the twin polls instead. */
55
+ export class TurnWaitUnsupported extends Error {
56
+ }
57
+ /** The full pass's interval when finished turns are pushed (a safety net), and when they are not. */
58
+ const PUSHED_PASS_MS = 15_000, POLLED_PASS_MS = 1000;
54
59
  export class TwinEngine {
55
60
  #deps;
56
61
  #reported = new Map();
57
62
  #posted = new Map();
63
+ #watchers = new Map();
64
+ /** Conversations whose turn just ended (pushed by a turn wait), landed by the next pass without listing anything. */
65
+ #pushed = new Map();
66
+ #wake;
67
+ #pushes;
68
+ #nextFull = -Infinity;
58
69
  constructor(deps) { this.#deps = deps; }
59
70
  #report(key, line) { if (this.#reported.get(key) !== line) {
60
71
  this.#reported.set(key, line);
61
72
  this.#deps.log(line);
62
73
  } }
63
- /** One pass over every project with a local conversation; returns the suggested wait before the next. */
74
+ /** Resolves when a pushed turn is waiting to land (the loop's sleep ends at once). */
75
+ pushed() {
76
+ if (this.#pushed.size)
77
+ return Promise.resolve();
78
+ return new Promise(resolve => { this.#wake = resolve; });
79
+ }
80
+ /** Stops every turn wait. */
81
+ stop() { for (const w of this.#watchers.values())
82
+ w.abort(); this.#watchers.clear(); }
83
+ /**
84
+ * One pass; returns the suggested wait before the next. Turns pushed by the waits land first, without a listing;
85
+ * otherwise (and at least every 15 s, or every second when the API has no turn waits) every project is listed.
86
+ */
64
87
  async tick(signal) {
65
- const d = this.#deps, records = d.records().filter(r => !r.handed_off);
66
- let wait = 1000;
88
+ const d = this.#deps;
89
+ if (this.#pushed.size) {
90
+ const pushed = [...this.#pushed.values()];
91
+ this.#pushed.clear();
92
+ const records = new Map(d.records().filter(r => !r.handed_off).map(r => [r.conversation.id, r]));
93
+ const ready = [];
94
+ for (const { c, at } of pushed) {
95
+ const record = records.get(c.id), mode = record ? d.ledger.mode(record.project_id) : 'off';
96
+ if (!record || mode === 'off' || !eligible(c, d.ledger, d.now()))
97
+ continue;
98
+ ready.push({ c, record, mode, agent: { id: '', workspace_id: c.workspace_id, state: 'waiting', since: new Date(at).toISOString() }, at });
99
+ }
100
+ const wait = await this.#land(ready, signal);
101
+ // A landing that has to wait (quiet period, a Git operation) is retried by a full pass at that time.
102
+ if (wait < POLLED_PASS_MS)
103
+ this.#nextFull = Math.min(this.#nextFull, d.now() + wait);
104
+ return this.#pushed.size ? 0 : Math.max(0, Math.min(wait, this.#nextFull - d.now()));
105
+ }
106
+ const interval = this.#pushes ? PUSHED_PASS_MS : POLLED_PASS_MS;
107
+ if (d.now() < this.#nextFull)
108
+ return this.#nextFull - d.now();
109
+ const records = d.records().filter(r => !r.handed_off);
110
+ let wait = interval;
111
+ const watched = new Set();
67
112
  for (const project of new Set(records.map(r => r.project_id))) {
68
113
  signal?.throwIfAborted();
69
114
  const mode = d.ledger.mode(project);
70
115
  if (mode === 'off')
71
116
  continue;
72
117
  const mine = new Map(records.filter(r => r.project_id === project).map(r => [r.conversation.id, r]));
73
- const candidates = (await d.conversations(project)).filter(c => mine.has(c.id) && eligible(c, d.ledger, d.now()));
118
+ const listedAt = d.now();
119
+ const all = (await d.conversations(project)).filter(c => mine.has(c.id));
120
+ for (const c of all)
121
+ if (!ended(c) && c.route !== 'codex-cloud' && c.join_mode !== 'reviewer') {
122
+ watched.add(c.id);
123
+ this.#watch(c, listedAt, signal);
124
+ }
125
+ const candidates = all.filter(c => eligible(c, d.ledger, d.now()));
74
126
  if (!candidates.length)
75
127
  continue;
76
128
  const agents = candidates.some(c => !ended(c)) ? await d.agents(project) : [];
77
- // In the order the turns finished (several may have finished while this computer was away); each lands against
78
- // the checkout as it is when its turn comes.
79
- const ready = candidates.map(c => ({ c, turn: finished(c, agents) })).filter((x) => x.turn !== null)
80
- .map(x => ({ ...x, at: x.turn === 'state' ? Date.parse(x.c.last_activity_at ?? x.c.created_at) : Date.parse(x.turn.since) }))
81
- .sort((a, b) => (a.at || 0) - (b.at || 0));
82
- for (const { c, turn } of ready) {
83
- const next = await this.landOne(c, mine.get(c.id), mode, turn === 'state' ? null : turn, signal);
84
- if (next !== undefined)
85
- wait = Math.min(wait, next);
129
+ const ready = [];
130
+ for (const c of candidates) {
131
+ const turn = finished(c, agents);
132
+ if (!turn)
133
+ continue;
134
+ ready.push({ c, record: mine.get(c.id), mode, agent: turn === 'state' ? null : turn,
135
+ at: turn === 'state' ? Date.parse(c.last_activity_at ?? c.created_at) : Date.parse(turn.since) });
86
136
  }
137
+ wait = Math.min(wait, await this.#land(ready, signal));
87
138
  }
88
- return wait;
139
+ for (const [id, w] of this.#watchers)
140
+ if (!watched.has(id)) {
141
+ w.abort();
142
+ this.#watchers.delete(id);
143
+ }
144
+ this.#nextFull = d.now() + Math.min(wait, interval);
145
+ return this.#pushed.size ? 0 : Math.min(wait, interval);
146
+ }
147
+ /**
148
+ * In the order the turns finished (several may have finished at once, or while this computer was away), each
149
+ * against the checkout as it is when its turn comes; finished conversations for the same branch land together.
150
+ */
151
+ async #land(ready, signal) {
152
+ let wait = Infinity;
153
+ ready.sort((a, b) => (a.at || 0) - (b.at || 0));
154
+ const key = (r) => `${r.record.folder}\0${r.c.base_branch ?? r.record.base_branch ?? ''}`;
155
+ for (let i = 0; i < ready.length;) {
156
+ let j = i + 1;
157
+ while (j < ready.length && key(ready[j]) === key(ready[i]))
158
+ j++;
159
+ const group = ready.slice(i, j);
160
+ i = j;
161
+ const batchable = group.filter(r => r.mode === 'auto' && !ended(r.c));
162
+ const next = batchable.length > 1 ? await this.landBatch(batchable, signal) : undefined;
163
+ for (const r of group)
164
+ if (batchable.length <= 1 || !batchable.includes(r)) {
165
+ const one = await this.landOne(r.c, r.record, r.mode, r.agent, signal);
166
+ if (one !== undefined)
167
+ wait = Math.min(wait, one);
168
+ }
169
+ if (next !== undefined)
170
+ wait = Math.min(wait, next);
171
+ }
172
+ return wait === Infinity ? POLLED_PASS_MS : wait;
173
+ }
174
+ /** One long-poll per active conversation: its next finished turn is pushed to the loop. */
175
+ #watch(c, listedAt, signal) {
176
+ const d = this.#deps;
177
+ if (!d.waitTurn || this.#pushes === false || this.#watchers.has(c.id))
178
+ return;
179
+ const abort = new AbortController();
180
+ this.#watchers.set(c.id, abort);
181
+ const stop = AbortSignal.any([abort.signal, ...(signal ? [signal] : [])]);
182
+ // A turn that ended just before the listing is caught by the listing itself; the overlap only repeats it.
183
+ let after = new Date(listedAt - 2000).toISOString(), failures = 0;
184
+ void (async () => {
185
+ while (!stop.aborted) {
186
+ try {
187
+ const turn = await d.waitTurn(c, after, stop);
188
+ this.#pushes = true;
189
+ failures = 0;
190
+ if (turn === 'ended') {
191
+ this.#watchers.delete(c.id);
192
+ return;
193
+ }
194
+ if (!turn)
195
+ continue;
196
+ after = turn.reported_at;
197
+ this.#pushed.set(c.id, { c: turn.conversation, at: Date.parse(turn.reported_at) });
198
+ this.#wake?.();
199
+ this.#wake = undefined;
200
+ }
201
+ catch (error) {
202
+ if (stop.aborted)
203
+ return;
204
+ if (error instanceof TurnWaitUnsupported) {
205
+ this.#pushes = false;
206
+ this.stop();
207
+ return;
208
+ }
209
+ await delay(Math.min(60_000, 1000 * 2 ** Math.min(failures++, 6)), undefined, { signal: stop }).catch(() => undefined);
210
+ }
211
+ }
212
+ })();
89
213
  }
90
214
  /** Back online or awake: every conversation's retry backoff is cleared, so the next pass tries all of them. */
91
215
  resetBackoff() {
@@ -94,8 +218,10 @@ export class TwinEngine {
94
218
  st.retry_at = 0;
95
219
  } });
96
220
  this.#reported.clear();
221
+ this.#nextFull = -Infinity;
97
222
  }
98
- async landOne(c, record, mode, agent, signal) {
223
+ /** Everything a landing needs here, or undefined (nothing to do now; reported). */
224
+ async #prepare(c, record, agent) {
99
225
  const d = this.#deps, st = d.ledger.conversation(c.id), rev = revision(c);
100
226
  const into = c.base_branch ?? record.base_branch ?? st.base_branch;
101
227
  const name = c.name ?? conversationName(c);
@@ -118,15 +244,17 @@ export class TwinEngine {
118
244
  }
119
245
  return;
120
246
  }
121
- const branch = `refs/heads/${into}`;
122
- let worktree = null;
123
- const target = (await listWorktrees(record.folder)).find(w => w.branch === branch);
124
- if (target) {
125
- worktree = await inspectWorktree(target.root);
126
- if (pauseReason(worktree))
127
- return 1000;
128
- }
129
- const started = d.now();
247
+ const target = (await listWorktrees(record.folder)).find(w => w.branch === `refs/heads/${into}`);
248
+ const worktree = target ? await inspectWorktree(target.root) : null;
249
+ if (worktree && pauseReason(worktree))
250
+ return 1000;
251
+ return { c, record, agent, into, name, label, rev, st, target, worktree };
252
+ }
253
+ async landOne(c, record, mode, agent, signal) {
254
+ const d = this.#deps, started = d.now();
255
+ const p = await this.#prepare(c, record, agent);
256
+ if (p === undefined || typeof p === 'number')
257
+ return p;
130
258
  let vm, fromHub = false;
131
259
  try {
132
260
  vm = ended(c) ? null : await d.cell(c);
@@ -139,31 +267,92 @@ export class TwinEngine {
139
267
  catch (error) {
140
268
  if (signal?.aborted)
141
269
  throw error;
142
- return this.#fail(c, label, error instanceof Error ? error.message : String(error));
270
+ return this.#fail(c, p.label, error instanceof Error ? error.message : String(error));
143
271
  }
144
- let outcome, keep = false;
272
+ let outcome;
145
273
  try {
274
+ let worktree = p.worktree;
146
275
  for (let attempt = 0; attempt < 3; attempt++) {
147
- if (target && attempt)
148
- worktree = await inspectWorktree(target.root);
149
- outcome = await landingRound(vm.cell, { conversation: { id: c.id, name, title: c.title, summary: c.summary, branch: c.branch, base_commit: c.base_commit, worktree_path: c.worktree_path },
150
- into, mode: mode === 'pr' ? 'pr' : 'auto', device: d.device, repo: target?.root ?? record.folder, worktree, stateDir: d.stateDir, backupRoot: backupRoot(d.stateDir),
151
- upto: st.upto, known: [st.tip, st.upto].filter((x) => !!x), hub: d.hub, vmTmp: d.vmTmp, quietMs: d.quietMs, signal, now: () => d.now(),
276
+ if (p.target && attempt)
277
+ worktree = await inspectWorktree(p.target.root);
278
+ outcome = await landingRound(vm.cell, { ...this.#input(p, worktree, signal), mode: mode === 'pr' ? 'pr' : 'auto',
152
279
  ...(fromHub ? { fromHub: { ...(c.repo_remote ? { repoRemote: c.repo_remote } : {}) } } : {}) });
153
280
  if (outcome.state !== 'moved')
154
281
  break;
155
282
  }
156
- keep = outcome?.state === 'landed';
157
283
  }
158
284
  catch (error) {
285
+ vm.close();
159
286
  if (signal?.aborted)
160
287
  throw error;
161
- return this.#fail(c, label, error instanceof Error ? error.message : String(error));
288
+ return this.#fail(c, p.label, error instanceof Error ? error.message : String(error));
289
+ }
290
+ return this.#settle(p, outcome, vm, started, fromHub);
291
+ }
292
+ /** Several finished conversations for one branch: merged in their VMs at once, the files written once. */
293
+ async landBatch(items, signal) {
294
+ const d = this.#deps, started = d.now();
295
+ const prepared = [];
296
+ let wait;
297
+ for (const r of items) {
298
+ const p = await this.#prepare(r.c, r.record, r.agent);
299
+ if (typeof p === 'number')
300
+ wait = Math.min(wait ?? p, p);
301
+ else if (p)
302
+ prepared.push(p);
303
+ }
304
+ if (prepared.length < 2) {
305
+ for (const p of prepared) {
306
+ const one = await this.landOne(p.c, p.record, 'auto', p.agent, signal);
307
+ if (one !== undefined)
308
+ wait = Math.min(wait ?? one, one);
309
+ }
310
+ return wait;
162
311
  }
163
- finally {
164
- if (!keep)
312
+ const vms = await Promise.all(prepared.map(p => d.cell(p.c).catch((error) => error instanceof Error ? error : new Error(String(error)))));
313
+ const ok = prepared.flatMap((p, i) => { const vm = vms[i]; return vm && !(vm instanceof Error) ? [{ p, vm }] : []; });
314
+ for (const [i, vm] of vms.entries()) {
315
+ // Without its VM now, it lands on its own (from the hub, or after a retry).
316
+ if (!vm || vm instanceof Error) {
317
+ const one = await this.landOne(prepared[i].c, prepared[i].record, 'auto', prepared[i].agent, signal);
318
+ if (one !== undefined)
319
+ wait = Math.min(wait ?? one, one);
320
+ }
321
+ }
322
+ if (!ok.length)
323
+ return wait;
324
+ const first = ok[0].p;
325
+ let outcomes;
326
+ try {
327
+ outcomes = await landingBatch(ok.map(({ p, vm }) => ({ cell: vm.cell, conversation: this.#input(p, p.worktree, signal).conversation, upto: p.st.upto,
328
+ known: [p.st.tip, p.st.upto].filter((x) => !!x) })), { ...this.#input(first, first.worktree, signal) });
329
+ }
330
+ catch (error) {
331
+ for (const { vm } of ok)
165
332
  vm.close();
333
+ if (signal?.aborted)
334
+ throw error;
335
+ for (const { p } of ok)
336
+ wait = Math.min(wait ?? Infinity, this.#fail(p.c, p.label, error instanceof Error ? error.message : String(error)));
337
+ return wait;
338
+ }
339
+ for (const [i, { p, vm }] of ok.entries()) {
340
+ const next = await this.#settle(p, outcomes[i], vm, started, false);
341
+ if (next !== undefined)
342
+ wait = Math.min(wait ?? next, next);
166
343
  }
344
+ return wait;
345
+ }
346
+ #input(p, worktree, signal) {
347
+ const d = this.#deps, c = p.c;
348
+ return { conversation: { id: c.id, name: p.name, title: c.title, summary: c.summary, branch: c.branch, base_commit: c.base_commit, worktree_path: c.worktree_path },
349
+ into: p.into, mode: 'auto', device: d.device, repo: p.target?.root ?? p.record.folder, worktree, stateDir: d.stateDir, backupRoot: backupRoot(d.stateDir),
350
+ upto: p.st.upto, known: [p.st.tip, p.st.upto].filter((x) => !!x), hub: d.hub, vmTmp: d.vmTmp, quietMs: d.quietMs, signal, now: () => d.now() };
351
+ }
352
+ async #settle(p, o, vm, started, fromHub) {
353
+ const d = this.#deps, { c, into, label, rev, st, agent } = p;
354
+ if (o.state !== 'landed')
355
+ vm.close();
167
356
  const post = async (action, ref) => {
168
357
  // An ended conversation cannot record a landing any more (the API refuses); the ledger keeps it.
169
358
  if (ended(c))
@@ -171,7 +360,6 @@ export class TwinEngine {
171
360
  this.#posted.set(c.id, d.now());
172
361
  await d.land(c, action, ref);
173
362
  };
174
- const o = outcome;
175
363
  switch (o.state) {
176
364
  case 'landed': {
177
365
  // The VM records the landing and copies it to the project hub on its own time; the connection closes after.
@@ -181,7 +369,7 @@ export class TwinEngine {
181
369
  d.ledger.patch(c.id, { upto: o.land.upto, tip: o.land.tip, landed_revision: rev, landed_at: new Date(o.applied.at).toISOString(), method: o.land.method,
182
370
  failures: 0, retry_at: 0, last_error: undefined, ...(latency !== undefined ? { latency_ms: latency } : {}) });
183
371
  await post('merge', into);
184
- d.log(`Twin: landed ${label} into ${into} in ${target?.root ?? record.folder}${fromHub ? ' from the project hub' : ''} (${o.land.method}; ${o.applied.files.length} file${o.applied.files.length === 1 ? '' : 's'}`
372
+ d.log(`Twin: landed ${label} into ${into} in ${p.target?.root ?? p.record.folder}${fromHub ? ' from the project hub' : ''} (${o.land.method}; ${o.applied.files.length} file${o.applied.files.length === 1 ? '' : 's'}`
185
373
  + `${o.applied.raced.length ? `, kept your version of ${o.applied.raced.join(', ')}` : ''}${o.land.shadowed?.length ? `, your edits win in ${o.land.shadowed.join(', ')}` : ''}`
186
374
  + `${latency !== undefined ? `; ${(latency / 1000).toFixed(1)} s after the turn ended, round started at ${((started - Date.parse(agent.since)) / 1000).toFixed(1)} s` : ''}`
187
375
  + `; round ${Object.entries(o.timings).map(([k, v]) => `${k} ${v} ms`).join(', ')}; in the VM ${Object.entries(o.land.phases ?? {}).map(([k, v]) => `${k} ${v}`).join(', ')}).`);
@@ -207,6 +395,10 @@ export class TwinEngine {
207
395
  d.ledger.patch(c.id, { conflict_tip: o.land.conversation_tip, conflict_revision: rev });
208
396
  d.log(`Twin: ${label} conflicts with your commits on ${into} (${(o.land.conflicts ?? []).join(', ')}); its agent is resolving it.`);
209
397
  return;
398
+ // It lands next, against the state the others left.
399
+ case 'deferred':
400
+ this.#pushed.set(c.id, { c, at: agent ? Date.parse(agent.since) : d.now() });
401
+ return 0;
210
402
  case 'busy':
211
403
  d.ledger.patch(c.id, { retry_at: d.now() + (o.waitMs ?? 1000) });
212
404
  return o.waitMs ?? 1000;
@@ -257,7 +449,12 @@ export async function runTwinLoop(deps, signal) {
257
449
  let left = wait, probed = deps.mono();
258
450
  while (left > 0 && !signal.aborted) {
259
451
  const slice = Math.min(1000, left), wall = deps.wall(), mono = deps.mono();
260
- await deps.sleep(slice, signal);
452
+ let kicked = false;
453
+ const sleeping = new AbortController(), both = AbortSignal.any([signal, sleeping.signal]);
454
+ await Promise.race([deps.sleep(slice, both), ...(deps.kicked ? [deps.kicked().then(() => { kicked = true; })] : [])]);
455
+ sleeping.abort();
456
+ if (kicked)
457
+ break;
261
458
  left -= slice;
262
459
  const woke = deps.wall() - wall - (deps.mono() - mono) > JUMP_MS;
263
460
  const now = deps.network(), changed = now !== net;
@@ -353,13 +550,29 @@ export async function runWorktreeTwin(ctx, device, signal) {
353
550
  resume: async (c) => { await client().conversations.resume(c.id, { signal }); },
354
551
  land: async (c, action, into) => { await client().conversations.land(c.id, action, { into, signal }); },
355
552
  message: async (c, text) => { await client().conversations.send(c.id, text, { signal }); },
553
+ waitTurn: async (c, after, s) => {
554
+ let r;
555
+ try {
556
+ r = await client().conversations.wait(c.id, { until: ['archived'], timeoutSeconds: 300, turnAfter: after, signal: s });
557
+ }
558
+ catch (error) {
559
+ if (error.status === 422)
560
+ throw new TurnWaitUnsupported('the API has no turn waits');
561
+ throw error;
562
+ }
563
+ if ('timeout' in r)
564
+ return null;
565
+ if (r.event.kind !== 'turn_ended' || !r.event.reported_at)
566
+ return 'ended';
567
+ return { conversation: r.conversation, reported_at: r.event.reported_at };
568
+ },
356
569
  ledger: new TwinLedger(ctx.store.dir), stateDir: ctx.store.dir, device,
357
570
  log: line => { ctx.io.stderr.write(line + '\n'); }, now: Date.now,
358
571
  });
359
572
  await runTwinLoop({
360
573
  tick: s => engine.tick(s), resetBackoff: () => engine.resetBackoff(),
361
574
  sleep: (ms, s) => delay(ms, undefined, { signal: s }).catch(() => undefined),
362
- wall: Date.now, mono: () => performance.now(), network: networkSignature, reachable: () => reachable(ctx.apiUrl),
575
+ wall: Date.now, mono: () => performance.now(), network: networkSignature, reachable: () => reachable(ctx.apiUrl), kicked: () => engine.pushed(),
363
576
  log: line => { ctx.io.stderr.write(line + '\n'); },
364
577
  }, signal);
365
578
  }
@@ -221,6 +221,11 @@ export type RoundOutcome = {
221
221
  waitMs?: number;
222
222
  land?: LandResult;
223
223
  timings: Record<string, number>;
224
+ } | {
225
+ state: 'deferred';
226
+ reason: string;
227
+ land: LandResult;
228
+ timings: Record<string, number>;
224
229
  };
225
230
  /** The landing script, shipped next to this module (src in the monorepo, dist when published). */
226
231
  export declare function landingScript(): string;
@@ -228,6 +233,18 @@ export declare function runLandingScript(cell: TwinCell, input: Record<string, u
228
233
  /** A temporary repository in the cell with the conversation's branch (and its earlier landing refs) from the hub. */
229
234
  export declare const HUB_CHECKOUT = "set -eu\nd=$(mktemp -d \"${TMPDIR:-/tmp}/shard-land-XXXXXX\")\ngit init -q \"$d\"\ncd \"$d\"\ngit fetch -q --no-tags \"$1\" \"+refs/heads/$2:refs/heads/$2\"\ngit fetch -q --no-tags \"$1\" \"+refs/shard/land/$3/upto:refs/shard/land/upto\" 2>/dev/null || true\ngit symbolic-ref HEAD \"refs/heads/$2\"\nif [ -n \"$4\" ]; then git remote add origin \"$4\"; fi\nprintf '%s\\n' \"$d\"\n";
230
235
  export declare function landingRound(cell: TwinCell, input: RoundInput): Promise<RoundOutcome>;
236
+ export interface BatchItem {
237
+ cell: TwinCell;
238
+ conversation: RoundConversation;
239
+ upto?: string;
240
+ known?: string[];
241
+ }
242
+ /**
243
+ * Lands several finished conversations into one branch with one apply: one twin, the merges in their VMs in parallel,
244
+ * then their new commits stacked here in the given (finish) order and the files written once. A conversation whose
245
+ * commits do not stack cleanly on the earlier ones is 'deferred' (it lands next, against the new state).
246
+ */
247
+ export declare function landingBatch(items: BatchItem[], shared: Omit<RoundInput, 'conversation' | 'upto' | 'known' | 'fromHub' | 'mode'>): Promise<RoundOutcome[]>;
231
248
  export type LandingMode = 'auto' | 'pr' | 'off';
232
249
  export interface TwinConversationState {
233
250
  base_branch?: string;