toyon 0.23.0 → 0.24.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 (108) hide show
  1. package/README.md +26 -21
  2. package/dist/CHANGELOG.md +32 -0
  3. package/dist/cli.js +1 -1
  4. package/dist/daemon.js +1609 -1229
  5. package/dist/shell/assets/{Editor-C2e_GpDQ.js → Editor-Dq4a0gFv.js} +164 -164
  6. package/dist/shell/assets/Editor-Dq4a0gFv.js.br +0 -0
  7. package/dist/shell/assets/Editor-Dq4a0gFv.js.gz +0 -0
  8. package/dist/shell/assets/{XTerm-B84G0H5I.js → XTerm-BfuBg96X.js} +2 -2
  9. package/dist/shell/assets/XTerm-BfuBg96X.js.br +0 -0
  10. package/dist/shell/assets/XTerm-BfuBg96X.js.gz +0 -0
  11. package/dist/shell/assets/{cssMode-DhDLwjyN.js → cssMode-An1EUPnJ.js} +1 -1
  12. package/dist/shell/assets/cssMode-An1EUPnJ.js.br +0 -0
  13. package/dist/shell/assets/cssMode-An1EUPnJ.js.gz +0 -0
  14. package/dist/shell/assets/{freemarker2-rc8sP22g.js → freemarker2-PYOY2M7v.js} +1 -1
  15. package/dist/shell/assets/freemarker2-PYOY2M7v.js.br +0 -0
  16. package/dist/shell/assets/freemarker2-PYOY2M7v.js.gz +0 -0
  17. package/dist/shell/assets/{grammar-Cty-NAQC.js → grammar-e2yQ-Xdq.js} +1 -1
  18. package/dist/shell/assets/grammar-e2yQ-Xdq.js.br +0 -0
  19. package/dist/shell/assets/grammar-e2yQ-Xdq.js.gz +0 -0
  20. package/dist/shell/assets/{handlebars-BOQxV3p5.js → handlebars-BtSTwhBl.js} +1 -1
  21. package/dist/shell/assets/handlebars-BtSTwhBl.js.br +0 -0
  22. package/dist/shell/assets/handlebars-BtSTwhBl.js.gz +0 -0
  23. package/dist/shell/assets/{html-CxndUpse.js → html-lmman0qi.js} +1 -1
  24. package/dist/shell/assets/html-lmman0qi.js.br +0 -0
  25. package/dist/shell/assets/html-lmman0qi.js.gz +0 -0
  26. package/dist/shell/assets/{htmlMode-CG7eVDLo.js → htmlMode-CYUUrmIv.js} +1 -1
  27. package/dist/shell/assets/htmlMode-CYUUrmIv.js.br +0 -0
  28. package/dist/shell/assets/htmlMode-CYUUrmIv.js.gz +0 -0
  29. package/dist/shell/assets/index-BDzHAr7c.css +1 -0
  30. package/dist/shell/assets/index-BDzHAr7c.css.br +0 -0
  31. package/dist/shell/assets/index-BDzHAr7c.css.gz +0 -0
  32. package/dist/shell/assets/index-o7SjsIYr.js +294 -0
  33. package/dist/shell/assets/index-o7SjsIYr.js.br +0 -0
  34. package/dist/shell/assets/index-o7SjsIYr.js.gz +0 -0
  35. package/dist/shell/assets/{javascript-CafoevnZ.js → javascript-CHVOacyY.js} +1 -1
  36. package/dist/shell/assets/{jsonMode-Cg_4sMB_.js → jsonMode-WaLJvHyp.js} +1 -1
  37. package/dist/shell/assets/jsonMode-WaLJvHyp.js.br +0 -0
  38. package/dist/shell/assets/jsonMode-WaLJvHyp.js.gz +0 -0
  39. package/dist/shell/assets/{liquid-DxW_HNid.js → liquid-Bhzd8TLd.js} +1 -1
  40. package/dist/shell/assets/liquid-Bhzd8TLd.js.br +0 -0
  41. package/dist/shell/assets/liquid-Bhzd8TLd.js.gz +0 -0
  42. package/dist/shell/assets/{lspLanguageFeatures-Dhat9l4L.js → lspLanguageFeatures-DS22S_fR.js} +2 -2
  43. package/dist/shell/assets/lspLanguageFeatures-DS22S_fR.js.br +0 -0
  44. package/dist/shell/assets/lspLanguageFeatures-DS22S_fR.js.gz +0 -0
  45. package/dist/shell/assets/{mdx-ZdMhgPNL.js → mdx-1x45Dt5R.js} +1 -1
  46. package/dist/shell/assets/mdx-1x45Dt5R.js.br +0 -0
  47. package/dist/shell/assets/mdx-1x45Dt5R.js.gz +0 -0
  48. package/dist/shell/assets/{python-Bae_7wMM.js → python-B0fHYRyj.js} +1 -1
  49. package/dist/shell/assets/python-B0fHYRyj.js.br +0 -0
  50. package/dist/shell/assets/python-B0fHYRyj.js.gz +0 -0
  51. package/dist/shell/assets/{razor-CtwiCIJH.js → razor-C48vGnF3.js} +1 -1
  52. package/dist/shell/assets/razor-C48vGnF3.js.br +0 -0
  53. package/dist/shell/assets/razor-C48vGnF3.js.gz +0 -0
  54. package/dist/shell/assets/toyon-mono-bold-u3Ky0k2u.woff2 +0 -0
  55. package/dist/shell/assets/toyon-mono-regular-BC3CdGld.woff2 +0 -0
  56. package/dist/shell/assets/{tsMode-DH9rMbRS.js → tsMode-2SIN2sKE.js} +1 -1
  57. package/dist/shell/assets/tsMode-2SIN2sKE.js.br +0 -0
  58. package/dist/shell/assets/tsMode-2SIN2sKE.js.gz +0 -0
  59. package/dist/shell/assets/{xml-rA-vNTCn.js → xml-DPJN-pu8.js} +1 -1
  60. package/dist/shell/assets/xml-DPJN-pu8.js.br +0 -0
  61. package/dist/shell/assets/xml-DPJN-pu8.js.gz +0 -0
  62. package/dist/shell/assets/{yaml-pOtRRozm.js → yaml-CDzmW7EM.js} +1 -1
  63. package/dist/shell/assets/yaml-CDzmW7EM.js.br +0 -0
  64. package/dist/shell/assets/yaml-CDzmW7EM.js.gz +0 -0
  65. package/dist/shell/index.html +2 -2
  66. package/package.json +1 -1
  67. package/dist/shell/assets/Editor-C2e_GpDQ.js.br +0 -0
  68. package/dist/shell/assets/Editor-C2e_GpDQ.js.gz +0 -0
  69. package/dist/shell/assets/XTerm-B84G0H5I.js.br +0 -0
  70. package/dist/shell/assets/XTerm-B84G0H5I.js.gz +0 -0
  71. package/dist/shell/assets/cssMode-DhDLwjyN.js.br +0 -0
  72. package/dist/shell/assets/cssMode-DhDLwjyN.js.gz +0 -0
  73. package/dist/shell/assets/freemarker2-rc8sP22g.js.br +0 -0
  74. package/dist/shell/assets/freemarker2-rc8sP22g.js.gz +0 -0
  75. package/dist/shell/assets/grammar-Cty-NAQC.js.br +0 -0
  76. package/dist/shell/assets/grammar-Cty-NAQC.js.gz +0 -0
  77. package/dist/shell/assets/handlebars-BOQxV3p5.js.br +0 -0
  78. package/dist/shell/assets/handlebars-BOQxV3p5.js.gz +0 -0
  79. package/dist/shell/assets/html-CxndUpse.js.br +0 -0
  80. package/dist/shell/assets/html-CxndUpse.js.gz +0 -0
  81. package/dist/shell/assets/htmlMode-CG7eVDLo.js.br +0 -0
  82. package/dist/shell/assets/htmlMode-CG7eVDLo.js.gz +0 -0
  83. package/dist/shell/assets/index-DpSfPmkM.css +0 -1
  84. package/dist/shell/assets/index-DpSfPmkM.css.br +0 -0
  85. package/dist/shell/assets/index-DpSfPmkM.css.gz +0 -0
  86. package/dist/shell/assets/index-DzfHDra7.js +0 -294
  87. package/dist/shell/assets/index-DzfHDra7.js.br +0 -0
  88. package/dist/shell/assets/index-DzfHDra7.js.gz +0 -0
  89. package/dist/shell/assets/jsonMode-Cg_4sMB_.js.br +0 -0
  90. package/dist/shell/assets/jsonMode-Cg_4sMB_.js.gz +0 -0
  91. package/dist/shell/assets/liquid-DxW_HNid.js.br +0 -0
  92. package/dist/shell/assets/liquid-DxW_HNid.js.gz +0 -0
  93. package/dist/shell/assets/lspLanguageFeatures-Dhat9l4L.js.br +0 -0
  94. package/dist/shell/assets/lspLanguageFeatures-Dhat9l4L.js.gz +0 -0
  95. package/dist/shell/assets/mdx-ZdMhgPNL.js.br +0 -0
  96. package/dist/shell/assets/mdx-ZdMhgPNL.js.gz +0 -0
  97. package/dist/shell/assets/python-Bae_7wMM.js.br +0 -0
  98. package/dist/shell/assets/python-Bae_7wMM.js.gz +0 -0
  99. package/dist/shell/assets/razor-CtwiCIJH.js.br +0 -0
  100. package/dist/shell/assets/razor-CtwiCIJH.js.gz +0 -0
  101. package/dist/shell/assets/toyon-mono-bold-9c1iJO0D.woff2 +0 -0
  102. package/dist/shell/assets/toyon-mono-regular-BXyxgSUK.woff2 +0 -0
  103. package/dist/shell/assets/tsMode-DH9rMbRS.js.br +0 -0
  104. package/dist/shell/assets/tsMode-DH9rMbRS.js.gz +0 -0
  105. package/dist/shell/assets/xml-rA-vNTCn.js.br +0 -0
  106. package/dist/shell/assets/xml-rA-vNTCn.js.gz +0 -0
  107. package/dist/shell/assets/yaml-pOtRRozm.js.br +0 -0
  108. package/dist/shell/assets/yaml-pOtRRozm.js.gz +0 -0
package/dist/daemon.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // ../daemon/src/index.ts
3
3
  import { rmSync as rmSync10, writeFileSync as writeFileSync14 } from "fs";
4
4
  import { homedir as homedir15 } from "os";
5
- import { dirname as dirname16, join as join36 } from "path";
5
+ import { dirname as dirname16, join as join37 } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
 
8
8
  // ../shared/src/protocol/events.ts
@@ -649,6 +649,9 @@ function movedPastLand(wt, f) {
649
649
  function prTaken(wt) {
650
650
  return !!wt.pr && (wt.lands ?? []).some((l) => l.pr === wt.pr?.number);
651
651
  }
652
+ function prPending(wt) {
653
+ return !!wt.pr && wt.pr.state !== "closed" && !prTaken(wt);
654
+ }
652
655
  function shipNoun(op) {
653
656
  return { land: "a land", commit: "a commit", "sync-main": "a sync", "pull-main": "a pull" }[op];
654
657
  }
@@ -6784,7 +6787,7 @@ ${text.slice(text.length - (CONTEXT_CHARS - HEAD_CHARS))}`;
6784
6787
  }
6785
6788
 
6786
6789
  // ../shared/src/protocol/limits.ts
6787
- var PROTOCOL_VERSION = 148;
6790
+ var PROTOCOL_VERSION = 149;
6788
6791
  var PAGE_ERROR_MAX_CHARS = 2000;
6789
6792
  var PAGE_TITLE_MAX_CHARS = 1000;
6790
6793
  var FILE_MAX_CHARS = 5000000;
@@ -7200,7 +7203,7 @@ var clientMsgSchema = discriminatedUnion("t", [
7200
7203
  base: string2().max(64).nullable(),
7201
7204
  seq
7202
7205
  }),
7203
- object({ t: literal("list-files"), worktreeId: id }),
7206
+ object({ t: literal("list-files"), worktreeId: id, tracked: boolean2() }),
7204
7207
  object({ t: literal("list-commands"), worktreeId: id }),
7205
7208
  object({ t: literal("search"), worktreeId: id, query: string2().max(500) }),
7206
7209
  object({ t: literal("search-stop") }),
@@ -8841,7 +8844,7 @@ async function turnRemoteOff(ts, daemonPort, file, was) {
8841
8844
  // ../daemon/package.json
8842
8845
  var package_default = {
8843
8846
  name: "@toyon/daemon",
8844
- version: "0.23.0",
8847
+ version: "0.24.0",
8845
8848
  private: true,
8846
8849
  type: "module",
8847
8850
  main: "src/index.ts",
@@ -13347,7 +13350,7 @@ class RemoteSetting {
13347
13350
  }
13348
13351
  }
13349
13352
  function sameSecret(given, want) {
13350
- if (given === null)
13353
+ if (given === null || want === "")
13351
13354
  return false;
13352
13355
  const a = Buffer.from(given);
13353
13356
  const b = Buffer.from(want);
@@ -13451,7 +13454,7 @@ function passPreview(req, gate) {
13451
13454
  const code = url.searchParams.get(PREVIEW_GRANT_PARAM);
13452
13455
  if (code !== null) {
13453
13456
  url.searchParams.delete(PREVIEW_GRANT_PARAM);
13454
- const location = `${url.pathname}${url.search}`;
13457
+ const location = `${url.pathname.replace(/^\/+/, "/")}${url.search}`;
13455
13458
  if (!pass.ok && !gate.redeem(code))
13456
13459
  return { ok: false, response: refusedPreview() };
13457
13460
  const headers = { location, "cache-control": "no-store" };
@@ -14104,6 +14107,13 @@ var NO_PROMPT = {
14104
14107
  GIT_SSH_COMMAND: "ssh -o BatchMode=yes"
14105
14108
  };
14106
14109
  var SPAWN_ENV = { GIT_OPTIONAL_LOCKS: "0" };
14110
+ function spawnEnv(base = process.env, ...over) {
14111
+ const { LC_ALL: all, LANGUAGE: _language, ...rest } = base;
14112
+ return Object.assign(rest, all ? { LANG: all } : {}, { LC_MESSAGES: "C" }, SPAWN_ENV, ...over);
14113
+ }
14114
+ function nulSplit(out) {
14115
+ return out.split("\x00").filter(Boolean);
14116
+ }
14107
14117
  var RUN_CEILING_MS = 10 * 60000;
14108
14118
  async function run(cmd, args, cwd, env, opts = {}) {
14109
14119
  const started = Date.now();
@@ -14117,7 +14127,7 @@ async function run(cmd, args, cwd, env, opts = {}) {
14117
14127
  stdout: "pipe",
14118
14128
  stderr: "pipe",
14119
14129
  stdin: "ignore",
14120
- env: { ...process.env, ...SPAWN_ENV, ...env }
14130
+ env: spawnEnv(process.env, env)
14121
14131
  });
14122
14132
  const done = (async () => {
14123
14133
  const [rawOut, err, status] = await Promise.all([
@@ -14159,7 +14169,7 @@ async function runLive(cmd, args, cwd, opts = {}) {
14159
14169
  stdout: "pipe",
14160
14170
  stderr: "pipe",
14161
14171
  stdin: "ignore",
14162
- env: { ...process.env, ...SPAWN_ENV, ...opts.env }
14172
+ env: spawnEnv(process.env, opts.env)
14163
14173
  });
14164
14174
  const stop = () => p.kill();
14165
14175
  opts.signal?.addEventListener("abort", stop, { once: true });
@@ -14215,7 +14225,7 @@ async function runRows(cmd, args, cwd, opts) {
14215
14225
  stdout: "pipe",
14216
14226
  stderr: "pipe",
14217
14227
  stdin: "ignore",
14218
- env: { ...process.env, ...SPAWN_ENV }
14228
+ env: spawnEnv()
14219
14229
  });
14220
14230
  const reader = p.stdout.getReader();
14221
14231
  const stop = () => {
@@ -14342,7 +14352,7 @@ function runWatched(cmd, args, cwd, opts = {}) {
14342
14352
  cwd,
14343
14353
  stdio: opts.onHook ? ["ignore", "pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
14344
14354
  detached: true,
14345
- env: { ...process.env, ...SPAWN_ENV, ...opts.env, ...opts.onHook ? { GIT_TRACE2_EVENT: "3" } : {} }
14355
+ env: spawnEnv(process.env, opts.env, opts.onHook ? { GIT_TRACE2_EVENT: "3" } : undefined)
14346
14356
  });
14347
14357
  } catch (e) {
14348
14358
  failed(e instanceof Error ? e.message : String(e));
@@ -16635,6 +16645,12 @@ function handoffActedPrompt(pieces, outcomes, by = "plan", note) {
16635
16645
  }
16636
16646
  return `Toyon acted on the pieces you handed over with the plan's approval: ${parts.join("; ")}. ${own} The siblings read the plan at its path in this worktree; never do their pieces yourself.`;
16637
16647
  }
16648
+ function handoffDeclinedPrompt(pieces, note) {
16649
+ const titles = pieces.map((p) => `"${p.title}"`).join(", ");
16650
+ return `The person answered the handoff card with not now: nothing on the list you handed over (${titles}) was started, and none of it is yours to do from that list. They added: ${note}
16651
+
16652
+ Read that as the word on what to change. Hand over a new list with the handoff tool if a split still fits, or go on without one.`;
16653
+ }
16638
16654
  function sentFromLine(path, by) {
16639
16655
  const who = by ? `, where ${by} was working` : "";
16640
16656
  return `This message was sent from a terminal in ${path} with \`toyon send\`${who}.`;
@@ -17563,7 +17579,7 @@ class AcpSession {
17563
17579
  steered = [];
17564
17580
  steerMark;
17565
17581
  streamingId;
17566
- refused = null;
17582
+ refused = [];
17567
17583
  turn = null;
17568
17584
  stopped = false;
17569
17585
  holds = 0;
@@ -17571,6 +17587,7 @@ class AcpSession {
17571
17587
  return this.holds > 0;
17572
17588
  }
17573
17589
  conn = null;
17590
+ start = null;
17574
17591
  connecting = null;
17575
17592
  opening = null;
17576
17593
  live = null;
@@ -17744,7 +17761,7 @@ class AcpSession {
17744
17761
  }
17745
17762
  }
17746
17763
  get runningAgent() {
17747
- return this.conn?.spec.id ?? null;
17764
+ return this.conn?.spec.id ?? this.start?.spec.id ?? null;
17748
17765
  }
17749
17766
  get mcpTools() {
17750
17767
  return this.conn ? this.conn.mcpServers.length > 0 : null;
@@ -17783,7 +17800,7 @@ class AcpSession {
17783
17800
  return this.queue.length;
17784
17801
  }
17785
17802
  get unsettled() {
17786
- return this.queue.length > 0 || this.steered.length > 0 || this.refused !== null;
17803
+ return this.queue.length > 0 || this.steered.length > 0 || this.refused.length > 0;
17787
17804
  }
17788
17805
  get queueItems() {
17789
17806
  return this.waiting().map(queued);
@@ -18023,7 +18040,8 @@ class AcpSession {
18023
18040
  const rejected = !isAuthRequired(e);
18024
18041
  if (rejected && !aws)
18025
18042
  this.emit({ type: "agent-error", message: this.describe(e), ts: Date.now() });
18026
- this.refused = item;
18043
+ if (item)
18044
+ this.refused.push(item);
18027
18045
  this.emit({
18028
18046
  type: "agent-auth-required",
18029
18047
  agent: this.conn.spec.id,
@@ -18059,9 +18077,12 @@ class AcpSession {
18059
18077
  if (!turn?.open)
18060
18078
  return;
18061
18079
  turn.open = false;
18080
+ this.turnOver(stopReason);
18081
+ this.setStopping(false);
18082
+ }
18083
+ turnOver(stopReason) {
18062
18084
  this.emit({ type: "turn-end", stopReason, ts: Date.now() });
18063
18085
  this.showHeld();
18064
- this.setStopping(false);
18065
18086
  }
18066
18087
  async ask(system, prompt, opts = {}) {
18067
18088
  if (this.stopped)
@@ -18116,6 +18137,7 @@ class AcpSession {
18116
18137
  }
18117
18138
  };
18118
18139
  }
18140
+ this.armReaper();
18119
18141
  try {
18120
18142
  await conn.ctx.request(methods.agent.authenticate, {
18121
18143
  methodId,
@@ -18149,10 +18171,14 @@ class AcpSession {
18149
18171
  this.retry();
18150
18172
  }
18151
18173
  retry() {
18152
- const item = this.refused;
18153
- this.refused = null;
18154
- if (item && !this.stopped)
18155
- this.enqueue(item);
18174
+ const items = this.refused;
18175
+ this.refused = [];
18176
+ if (items.length === 0 || this.stopped)
18177
+ return;
18178
+ this.queue.unshift(...items);
18179
+ this.queueChanged();
18180
+ if (!this.running && !this.held)
18181
+ fireAndForget(this.d.worktreeId, this.drain(), "agent drain");
18156
18182
  }
18157
18183
  planWithheld(conn, methodId) {
18158
18184
  return this.d.planSignIn?.() === false && conn.spec.terminalLogins?.[methodId]?.plan === true;
@@ -18259,40 +18285,43 @@ class AcpSession {
18259
18285
  return Promise.resolve(this.conn);
18260
18286
  if (this.connecting)
18261
18287
  return this.connecting;
18262
- this.connecting = this.openConn(why).finally(() => {
18263
- this.connecting = null;
18288
+ const connecting = this.openConn(why).finally(() => {
18289
+ if (this.connecting === connecting)
18290
+ this.connecting = null;
18264
18291
  });
18265
- return this.connecting;
18292
+ this.connecting = connecting;
18293
+ return connecting;
18266
18294
  }
18267
18295
  async openConn(why) {
18296
+ if (this.stopped)
18297
+ throw new UserError("this worktree's agent is closed");
18268
18298
  const spec = this.d.spec();
18269
- const prepared = await (this.d.prepare ?? prepareLaunch)(this.d.cwd, spec);
18270
- const { bounds } = prepared;
18271
- const sandboxed = spec.confinement !== "none";
18272
- const side = new Map;
18273
- const commands = new Map;
18274
- const app = client({ name: "toyon" }).onRequest(methods.client.session.requestPermission, (c) => this.onPermission(c.params, bounds, sandboxed)).onRequest(methods.client.elicitation.create, (c) => this.onElicit(c.params, c.signal)).onNotification(methods.client.session.update, (c) => this.onUpdate(c.params, side, commands)).onNotification(AUTH_STATUS_UPDATE_METHOD, parseAuthStatus, (c) => {
18275
- if (c.params)
18276
- this.d.onAuth?.(spec.id, { status: c.params });
18277
- });
18278
- const link = this.d.connect(app, spec, prepared);
18279
- this.noteProcess(link, true);
18280
- log.info(this.d.worktreeId, `agent ${spec.id} started for ${why} (pid ${link.pid ?? "unknown"})`);
18281
- const ctx = link.conn.agent;
18282
- link.exited.then(() => {
18283
- this.noteProcess(link, false);
18284
- if (this.conn?.link === link) {
18285
- log.warn(this.d.worktreeId, "agent process exited while idle");
18286
- this.conn = null;
18287
- this.live = null;
18288
- this.d.mcp?.close();
18289
- this.cancelAsks();
18290
- this.tasks.endAll("the agent stopped, and the command with it");
18291
- this.wakeups.endAll("the agent stopped before it woke");
18292
- this.endOwn("interrupted");
18293
- }
18294
- });
18299
+ const start = { spec, link: null };
18300
+ this.start = start;
18295
18301
  try {
18302
+ const prepared = await (this.d.prepare ?? prepareLaunch)(this.d.cwd, spec);
18303
+ if (this.start !== start)
18304
+ throw stoppedStarting();
18305
+ const { bounds } = prepared;
18306
+ const sandboxed = spec.confinement !== "none";
18307
+ const side = new Map;
18308
+ const commands = new Map;
18309
+ const app = client({ name: "toyon" }).onRequest(methods.client.session.requestPermission, (c) => this.onPermission(c.params, bounds, sandboxed)).onRequest(methods.client.elicitation.create, (c) => this.onElicit(c.params, c.signal)).onNotification(methods.client.session.update, (c) => this.onUpdate(c.params, side, commands)).onNotification(AUTH_STATUS_UPDATE_METHOD, parseAuthStatus, (c) => {
18310
+ if (c.params)
18311
+ this.d.onAuth?.(spec.id, { status: c.params });
18312
+ });
18313
+ const link = this.d.connect(app, spec, prepared);
18314
+ start.link = link;
18315
+ this.noteProcess(link, true);
18316
+ log.info(this.d.worktreeId, `agent ${spec.id} started for ${why} (pid ${link.pid ?? "unknown"})`);
18317
+ const ctx = link.conn.agent;
18318
+ link.exited.then(() => {
18319
+ this.noteProcess(link, false);
18320
+ if (this.conn?.link === link) {
18321
+ log.warn(this.d.worktreeId, "agent process exited while idle");
18322
+ this.processGone();
18323
+ }
18324
+ });
18296
18325
  const init = await ctx.request(methods.agent.initialize, {
18297
18326
  protocolVersion: PROTOCOL_VERSION2,
18298
18327
  clientCapabilities: {
@@ -18302,7 +18331,9 @@ class AcpSession {
18302
18331
  },
18303
18332
  clientInfo: { name: "toyon", version: "0" }
18304
18333
  });
18305
- this.conn = {
18334
+ if (this.start !== start)
18335
+ throw stoppedStarting();
18336
+ const conn = {
18306
18337
  link,
18307
18338
  ctx,
18308
18339
  spec,
@@ -18318,10 +18349,17 @@ class AcpSession {
18318
18349
  commands
18319
18350
  };
18320
18351
  this.d.onAuth?.(spec.id, { canLogout: supportsLogout(init.agentCapabilities) });
18321
- return this.conn;
18352
+ this.start = null;
18353
+ this.conn = conn;
18354
+ return conn;
18322
18355
  } catch (e) {
18323
- fireAndForget(this.d.worktreeId, link.kill(), "kill agent after failed start");
18324
- this.noteProcess(link, false);
18356
+ if (this.start !== start)
18357
+ throw stoppedStarting();
18358
+ this.start = null;
18359
+ if (start.link) {
18360
+ fireAndForget(this.d.worktreeId, start.link.kill(), "kill agent after failed start");
18361
+ this.noteProcess(start.link, false);
18362
+ }
18325
18363
  throw e;
18326
18364
  }
18327
18365
  }
@@ -18376,6 +18414,8 @@ class AcpSession {
18376
18414
  if (!this.stopped)
18377
18415
  this.d.setSessionId(sessionId);
18378
18416
  }
18417
+ if (this.conn !== conn)
18418
+ throw stoppedStarting();
18379
18419
  const options = readOptions(configOptions);
18380
18420
  this.emit({ type: "session-info", sessionId, ...currentValues(options) });
18381
18421
  this.learn(options, !resumed);
@@ -18764,7 +18804,7 @@ class AcpSession {
18764
18804
  if (this.own.settle)
18765
18805
  clearTimeout(this.own.settle);
18766
18806
  this.own = null;
18767
- this.emit({ type: "turn-end", stopReason, ts: Date.now() });
18807
+ this.turnOver(stopReason);
18768
18808
  this.syncStatus();
18769
18809
  this.maybeArmReaper();
18770
18810
  }
@@ -18792,22 +18832,33 @@ class AcpSession {
18792
18832
  clearTimeout(this.reaper);
18793
18833
  this.reaper = null;
18794
18834
  }
18795
- async dropConn(why) {
18796
- this.cancelAsks();
18797
- this.tasks.endAll("the agent stopped, and the command with it");
18798
- this.wakeups.endAll("the agent stopped before it woke");
18799
- const conn = this.conn;
18835
+ processGone() {
18836
+ const was = this.conn ?? this.start;
18800
18837
  this.conn = null;
18801
18838
  this.live = null;
18839
+ this.start = null;
18840
+ this.connecting = null;
18802
18841
  this.d.mcp?.close();
18803
- if (!conn)
18842
+ this.cancelAsks();
18843
+ this.tasks.endAll("the agent stopped, and the command with it");
18844
+ this.wakeups.endAll("the agent stopped before it woke");
18845
+ this.endOwn("interrupted");
18846
+ return was ? { link: was.link, spec: was.spec } : null;
18847
+ }
18848
+ async dropConn(why) {
18849
+ const was = this.processGone();
18850
+ if (!was)
18804
18851
  return;
18805
- log.info(this.d.worktreeId, `agent ${conn.spec.id} stopped: ${why} (pid ${conn.link.pid ?? "unknown"})`);
18806
- conn.link.conn.close();
18807
- await conn.link.kill();
18808
- this.noteProcess(conn.link, false);
18852
+ const { link, spec } = was;
18853
+ if (!link)
18854
+ return log.info(this.d.worktreeId, `agent ${spec.id} stopped before it was spawned: ${why}`);
18855
+ log.info(this.d.worktreeId, `agent ${spec.id} stopped: ${why} (pid ${link.pid ?? "unknown"})`);
18856
+ link.conn.close();
18857
+ await link.kill();
18858
+ this.noteProcess(link, false);
18809
18859
  }
18810
18860
  }
18861
+ var stoppedStarting = () => new UserError("the agent was stopped while it was starting");
18811
18862
  function takesHttpMcp(init) {
18812
18863
  const http = init.agentCapabilities?.mcpCapabilities?.http;
18813
18864
  return http === true || typeof http === "object" && http !== null;
@@ -22373,6 +22424,23 @@ class RuntimeRegistry {
22373
22424
  }
22374
22425
  return waiting ? "waiting" : null;
22375
22426
  }
22427
+ appPorts() {
22428
+ const ports = new Set;
22429
+ for (const wt of this.deps.state.worktrees)
22430
+ ports.add(wt.proxyPort);
22431
+ for (const rt of this.runtimes.values()) {
22432
+ if (rt.proxy)
22433
+ ports.add(rt.proxy.port);
22434
+ for (const f of rt.forwards.values())
22435
+ ports.add(f.port);
22436
+ for (const st of rt.procs?.states() ?? []) {
22437
+ ports.add(st.port);
22438
+ if (st.boundPort !== undefined)
22439
+ ports.add(st.boundPort);
22440
+ }
22441
+ }
22442
+ return ports;
22443
+ }
22376
22444
  markSetup(id, on) {
22377
22445
  if (on)
22378
22446
  this.settingUp.add(id);
@@ -24821,11 +24889,7 @@ class Knocks {
24821
24889
  this.hub.emit("knocksChanged");
24822
24890
  }
24823
24891
  knock(from, client = null) {
24824
- this.sweep();
24825
- const waiting = this.live.filter((k) => k.answer === null);
24826
- if (waiting.length >= KNOCK_MAX)
24827
- return null;
24828
- if (client !== null && waiting.filter((k) => k.client === client).length >= KNOCK_PER_CLIENT)
24892
+ if (!this.room(client))
24829
24893
  return null;
24830
24894
  const id = randomBytes6(9).toString("base64url");
24831
24895
  let word = knockWord(randomInt);
@@ -24835,6 +24899,13 @@ class Knocks {
24835
24899
  this.changed();
24836
24900
  return { id, word };
24837
24901
  }
24902
+ room(client) {
24903
+ this.sweep();
24904
+ const waiting = this.live.filter((k) => k.answer === null);
24905
+ if (waiting.length >= KNOCK_MAX)
24906
+ return false;
24907
+ return client === null || waiting.filter((k) => k.client === client).length < KNOCK_PER_CLIENT;
24908
+ }
24838
24909
  pending() {
24839
24910
  this.sweep();
24840
24911
  return this.live.filter((k) => k.answer === null).map(({ id, word, from }) => ({ id, word, from }));
@@ -24871,6 +24942,8 @@ class Knocks {
24871
24942
  var ANSWER_HOLD_MS = 30000;
24872
24943
 
24873
24944
  // ../daemon/src/core/origins.ts
24945
+ var SHELL_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "toyon.localhost"]);
24946
+
24874
24947
  class Origins {
24875
24948
  d;
24876
24949
  constructor(d) {
@@ -24905,6 +24978,25 @@ class Origins {
24905
24978
  return false;
24906
24979
  }
24907
24980
  }
24981
+ isPreview(origin) {
24982
+ let u;
24983
+ try {
24984
+ u = new URL(origin);
24985
+ } catch {
24986
+ return true;
24987
+ }
24988
+ if (isLoopbackHost(u.hostname)) {
24989
+ if (!SHELL_HOSTS.has(u.hostname))
24990
+ return true;
24991
+ const port = Number(u.port || (u.protocol === "https:" ? 443 : 80));
24992
+ for (const p of this.d.appPorts())
24993
+ if (p === port)
24994
+ return true;
24995
+ return false;
24996
+ }
24997
+ const remote = this.d.setting.get();
24998
+ return remote !== null && (matchPreview(remote.previews, u.host) !== null || u.host === frameAuthority(remote));
24999
+ }
24908
25000
  }
24909
25001
 
24910
25002
  // ../daemon/src/core/remoteSwitch.ts
@@ -25100,13 +25192,12 @@ class SelfWatch {
25100
25192
  async check(repo) {
25101
25193
  if (this.booted === null || !this.own(repo))
25102
25194
  return false;
25103
- const names = await git(this.root ?? "", "diff", "--name-only", `${this.booted}..${repo.defaultBranch}`);
25195
+ const names = await gitRaw(this.root ?? "", "diff", "--name-only", "-z", `${this.booted}..${repo.defaultBranch}`);
25104
25196
  if (!names.ok) {
25105
25197
  log.debug("self", `could not diff ${this.booted}..${repo.defaultBranch}: ${names.err}`);
25106
25198
  return false;
25107
25199
  }
25108
- const paths = names.out.split(`
25109
- `).filter((l) => l !== "");
25200
+ const paths = nulSplit(names.out);
25110
25201
  const { rebuild, restart } = classify(paths);
25111
25202
  return this.set({ repoId: repo.id, rebuild, restart, building: this.state?.building ?? false });
25112
25203
  }
@@ -25235,11 +25326,19 @@ function saveState(paths, state) {
25235
25326
  writeFileSync9(tmp, JSON.stringify(state, null, 2));
25236
25327
  renameSync5(tmp, paths.stateFile);
25237
25328
  }
25238
- function loadOrCreateToken(paths) {
25329
+ var TOKEN_MIN = 32;
25330
+ function loadOrCreateToken(paths, env = process.env) {
25239
25331
  ensureDirs(paths);
25240
25332
  const TOKEN_FILE = paths.tokenFile;
25241
- if (existsSync24(TOKEN_FILE))
25242
- return readFileSync14(TOKEN_FILE, "utf8").trim();
25333
+ if (existsSync24(TOKEN_FILE)) {
25334
+ const held = readFileSync14(TOKEN_FILE, "utf8").trim();
25335
+ if (held.length >= TOKEN_MIN)
25336
+ return held;
25337
+ if (env.TOYON_CLOUD === "1") {
25338
+ throw new Error(`${TOKEN_FILE} holds no token of ${TOKEN_MIN} characters or more; set TOYON_TOKEN to one`);
25339
+ }
25340
+ log.warn("state", `${TOKEN_FILE} held no usable token; a new one was written`);
25341
+ }
25243
25342
  const token = randomBytes7(32).toString("hex");
25244
25343
  writeFileSync9(TOKEN_FILE, token, { mode: 384 });
25245
25344
  return token;
@@ -25643,7 +25742,7 @@ var PROBE_TIMEOUT_MS2 = 2000;
25643
25742
  var ANSWERED_RECENTLY_MS = 3 * 60000;
25644
25743
  var probe = async (origin, named) => {
25645
25744
  try {
25646
- const r = await fetch(`${origin}/health`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS2) });
25745
+ const r = await fetch(`${origin}/health`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS2), redirect: "error" });
25647
25746
  if (!r.ok)
25648
25747
  return null;
25649
25748
  const body = await r.json();
@@ -26208,9 +26307,8 @@ class DesignService {
26208
26307
  const wt = this.readable(worktreeId);
26209
26308
  if (!wt)
26210
26309
  throw new UserError("unknown worktree");
26211
- const listed = await git(wt.path, "ls-files", "-co", "--exclude-standard");
26212
- const paths = listed.out.split(`
26213
- `).filter(Boolean);
26310
+ const listed = await gitRaw(wt.path, "ls-files", "-z", "-co", "--exclude-standard");
26311
+ const paths = nulSplit(listed.out);
26214
26312
  const cssPaths = paths.filter((p) => CSS_EXT.test(p)).slice(0, MAX_CSS_FILES);
26215
26313
  const srcPaths = paths.filter((p) => SOURCE_EXT.test(p)).slice(0, MAX_SOURCE_FILES);
26216
26314
  const cssFiles = await readAll(wt.path, cssPaths);
@@ -27033,7 +27131,8 @@ function looksBinary(text) {
27033
27131
 
27034
27132
  // ../daemon/src/files/service.ts
27035
27133
  import { spawn as spawn5 } from "child_process";
27036
- import { stat as stat3, unlink } from "fs/promises";
27134
+ import { lstat as lstat2, stat as stat3, unlink } from "fs/promises";
27135
+ import { join as join24 } from "path";
27037
27136
 
27038
27137
  // ../daemon/src/git/forge.ts
27039
27138
  function githubRepoUrl(remoteUrl) {
@@ -27150,10 +27249,9 @@ async function treeFingerprint(worktreePath) {
27150
27249
  const [head, diff, untracked] = await Promise.all([
27151
27250
  git(worktreePath, "rev-parse", "HEAD"),
27152
27251
  gitRaw(worktreePath, "diff", "HEAD", "--no-renames"),
27153
- git(worktreePath, "ls-files", "--others", "--exclude-standard")
27252
+ gitRaw(worktreePath, "ls-files", "-z", "--others", "--exclude-standard")
27154
27253
  ]);
27155
- const others = await Promise.all(untracked.out.split(`
27156
- `).filter(Boolean).map(async (p) => {
27254
+ const others = await Promise.all(nulSplit(untracked.out).map(async (p) => {
27157
27255
  try {
27158
27256
  const f = Bun.file(join23(worktreePath, p));
27159
27257
  const body = f.size > 2000000 ? String(f.size) : Bun.hash(await f.arrayBuffer()).toString(36);
@@ -27169,7 +27267,7 @@ async function statusFiles(worktreePath, ...only) {
27169
27267
  return (await readStatus(worktreePath, only)).files;
27170
27268
  }
27171
27269
  async function readStatus(worktreePath, only) {
27172
- const args = ["status", "--porcelain", "-uall"];
27270
+ const args = ["status", "--porcelain", "-z", "-uall"];
27173
27271
  const r = only.length ? await gitRaw(worktreePath, "--literal-pathspecs", ...args, "--", ...only) : await gitRaw(worktreePath, ...args);
27174
27272
  if (!r.ok)
27175
27273
  throw new Error(`git status failed: ${r.err}`);
@@ -27194,7 +27292,7 @@ function foldDropped(entries) {
27194
27292
  };
27195
27293
  }
27196
27294
  async function diskAgainstHead(worktreePath, file) {
27197
- const r = await git(worktreePath, "diff", "--numstat", "--end-of-options", `HEAD:${file}`, file);
27295
+ const r = await gitRaw(worktreePath, "diff", "--numstat", "-z", "--end-of-options", `HEAD:${file}`, file);
27198
27296
  if (!r.ok)
27199
27297
  return {};
27200
27298
  if (!r.out)
@@ -27203,14 +27301,18 @@ async function diskAgainstHead(worktreePath, file) {
27203
27301
  return counts ?? {};
27204
27302
  }
27205
27303
  function parsePorcelain(out) {
27206
- const unquote = (p) => p.startsWith('"') && p.endsWith('"') ? JSON.parse(p) : p;
27207
- return out.split(`
27208
- `).filter((line) => line.length > 3).map((line) => {
27209
- const xy = line.slice(0, 2);
27210
- const rest = line.slice(3);
27211
- const arrow = xy.includes("R") || xy.includes("C") ? rest.lastIndexOf(" -> ") : -1;
27212
- return { xy, path: unquote(arrow >= 0 ? rest.slice(arrow + 4) : rest) };
27213
- });
27304
+ const files = [];
27305
+ const records = out.split("\x00");
27306
+ for (let i = 0;i < records.length; i++) {
27307
+ const record = records[i] ?? "";
27308
+ if (record.length <= 3)
27309
+ continue;
27310
+ const xy = record.slice(0, 2);
27311
+ files.push({ xy, path: record.slice(3) });
27312
+ if (xy.includes("R") || xy.includes("C"))
27313
+ i++;
27314
+ }
27315
+ return files;
27214
27316
  }
27215
27317
  async function fileBefore(worktreePath, defaultBr, file) {
27216
27318
  const base = await git(worktreePath, "merge-base", "HEAD", defaultBr);
@@ -27224,18 +27326,37 @@ async function fileAt(worktreePath, commit, file) {
27224
27326
  }
27225
27327
  function parseNumstat(out) {
27226
27328
  const counts = new Map;
27227
- for (const line of out.split(`
27228
- `)) {
27229
- const [a, d, ...rest] = line.split("\t");
27230
- const path = rest.join("\t").replace(/^"(.*)"$/, "$1");
27329
+ const records = out.split("\x00");
27330
+ for (let i = 0;i < records.length; i++) {
27331
+ const [a, d, ...rest] = (records[i] ?? "").split("\t");
27332
+ if (d === undefined)
27333
+ continue;
27334
+ let path = rest.join("\t");
27335
+ if (!path) {
27336
+ path = records[i + 2] ?? "";
27337
+ i += 2;
27338
+ }
27231
27339
  if (!path)
27232
27340
  continue;
27233
27341
  counts.set(path, a === "-" || d === "-" ? {} : { add: Number(a), del: Number(d) });
27234
27342
  }
27235
27343
  return counts;
27236
27344
  }
27345
+ function parseNameStatus(out) {
27346
+ const files = [];
27347
+ const records = out.split("\x00");
27348
+ for (let i = 0;i + 1 < records.length; i += 2) {
27349
+ const status = (records[i] ?? "").slice(0, 1);
27350
+ if (status === "R" || status === "C")
27351
+ i++;
27352
+ const path = records[i + 1] ?? "";
27353
+ if (status && path)
27354
+ files.push({ status, path });
27355
+ }
27356
+ return files;
27357
+ }
27237
27358
  async function numstat(worktreePath, ...range) {
27238
- const r = await git(worktreePath, "diff", "--numstat", "--no-renames", ...range);
27359
+ const r = await gitRaw(worktreePath, "diff", "--numstat", "-z", "--no-renames", ...range);
27239
27360
  return r.ok ? parseNumstat(r.out) : new Map;
27240
27361
  }
27241
27362
  async function untrackedLines(worktreePath, file, budget) {
@@ -27300,16 +27421,11 @@ async function committedFiles(worktreePath, defaultBr) {
27300
27421
  return filesBetween(worktreePath, base.out, "HEAD");
27301
27422
  }
27302
27423
  async function filesBetween(cwd, from, to) {
27303
- const r = await git(cwd, "diff", "--name-status", from, to);
27424
+ const r = await gitRaw(cwd, "diff", "--name-status", "-z", from, to);
27304
27425
  if (!r.ok || !r.out)
27305
27426
  return [];
27306
27427
  const counts = await numstat(cwd, from, to);
27307
- return r.out.split(`
27308
- `).filter(Boolean).map((line) => {
27309
- const [status, ...rest] = line.split("\t");
27310
- const path = rest[rest.length - 1] ?? "";
27311
- return { xy: `${(status ?? "M").slice(0, 1)} `, path, ...counts.get(path) ?? {} };
27312
- });
27428
+ return parseNameStatus(r.out).map(({ status, path }) => ({ xy: `${status} `, path, ...counts.get(path) ?? {} }));
27313
27429
  }
27314
27430
  async function changedRanges(worktreePath, defaultBr, file) {
27315
27431
  const { files, folded } = await readStatus(worktreePath, [file]);
@@ -27360,6 +27476,11 @@ async function remoteHead(worktreePath, branch) {
27360
27476
  async function reaches(worktreePath, sha) {
27361
27477
  return (await git(worktreePath, "merge-base", "--is-ancestor", sha, "HEAD")).ok;
27362
27478
  }
27479
+ async function aheadOf(worktreePath, base) {
27480
+ const r = await git(worktreePath, "rev-list", "--count", `${base}..HEAD`);
27481
+ const n = r.ok ? Number(r.out) : Number.NaN;
27482
+ return Number.isInteger(n) ? n : null;
27483
+ }
27363
27484
  async function aheadBehind(worktreePath, defaultBr) {
27364
27485
  const [a, b] = await Promise.all([
27365
27486
  git(worktreePath, "rev-list", "--count", `${defaultBr}..HEAD`),
@@ -27415,18 +27536,17 @@ async function aheadShas(worktreePath, defaultBr) {
27415
27536
  }
27416
27537
  async function commitFiles(worktreePath, sha) {
27417
27538
  const [names, nums] = await Promise.all([
27418
- git(worktreePath, "show", "--first-parent", "--name-status", "--no-renames", "--format=", sha),
27419
- git(worktreePath, "show", "--first-parent", "--numstat", "--no-renames", "--format=", sha)
27539
+ gitRaw(worktreePath, "show", "--first-parent", "--name-status", "-z", "--no-renames", "--format=", sha),
27540
+ gitRaw(worktreePath, "show", "--first-parent", "--numstat", "-z", "--no-renames", "--format=", sha)
27420
27541
  ]);
27421
27542
  if (!names.ok || !names.out)
27422
27543
  return [];
27423
27544
  const counts = nums.ok ? parseNumstat(nums.out) : new Map;
27424
- return names.out.split(`
27425
- `).filter(Boolean).map((line) => {
27426
- const [status, ...rest] = line.split("\t");
27427
- const path = (rest[rest.length - 1] ?? "").replace(/^"(.*)"$/, "$1");
27428
- return { xy: `${(status ?? "M").slice(0, 1)} `, path, ...counts.get(path) ?? {} };
27429
- }).filter((f) => f.path);
27545
+ return parseNameStatus(names.out).map(({ status, path }) => ({
27546
+ xy: `${status} `,
27547
+ path,
27548
+ ...counts.get(path) ?? {}
27549
+ }));
27430
27550
  }
27431
27551
  async function fileAtCommit(worktreePath, sha, file) {
27432
27552
  const [before, after] = await Promise.all([
@@ -27556,30 +27676,28 @@ function rankElementSources(el, rows) {
27556
27676
  // ../daemon/src/files/listing.ts
27557
27677
  var SUBMODULE_MODE = "160000";
27558
27678
  var entries3 = (out) => out.split("\x00").filter(Boolean);
27559
- function parseListing(staged, untracked, deleted, ignored = "") {
27560
- const gone = new Set(entries3(deleted));
27679
+ function parseTracked(staged) {
27561
27680
  const paths = new Set;
27562
27681
  const submodules = new Set;
27682
+ const unmerged = new Set;
27563
27683
  for (const line of entries3(staged)) {
27564
27684
  const tab = line.indexOf("\t");
27565
27685
  if (tab === -1)
27566
27686
  continue;
27567
27687
  const path = line.slice(tab + 1);
27568
- if (gone.has(path))
27569
- continue;
27570
27688
  if (line.startsWith(SUBMODULE_MODE))
27571
27689
  submodules.add(path);
27572
27690
  else
27573
27691
  paths.add(path);
27692
+ if (line.charAt(tab - 1) !== "0")
27693
+ unmerged.add(path);
27574
27694
  }
27575
- for (const path of entries3(untracked))
27576
- paths.add(path);
27577
- return { paths: [...paths], submodules: [...submodules], ignored: collapse(entries3(ignored)) };
27695
+ return { paths: [...paths], submodules: [...submodules], unmerged: [...unmerged] };
27578
27696
  }
27579
- function collapse(list) {
27697
+ function parseIgnored(ignored) {
27580
27698
  const out = [];
27581
27699
  let under = null;
27582
- for (const p of [...list].sort()) {
27700
+ for (const p of entries3(ignored).sort()) {
27583
27701
  if (under !== null && p.startsWith(under))
27584
27702
  continue;
27585
27703
  under = p.endsWith("/") ? p : null;
@@ -27781,15 +27899,25 @@ class FileService {
27781
27899
  return "restored";
27782
27900
  });
27783
27901
  }
27784
- async list(worktreeId) {
27902
+ async tracked(worktreeId) {
27785
27903
  const cwd = this.require(worktreeId).path;
27786
- const [staged, untracked, deleted, ignored] = await Promise.all([
27904
+ const [staged, dropped] = await Promise.all([
27787
27905
  git(cwd, "ls-files", "-z", "-s"),
27788
- git(cwd, "ls-files", "-z", "-o", "--exclude-standard"),
27789
- git(cwd, "ls-files", "-z", "-d"),
27790
- git(cwd, "ls-files", "-z", "-o", "-i", "--exclude-standard", "--directory")
27906
+ gitRaw(cwd, "diff", "--cached", "--name-only", "-z", "--diff-filter=D")
27791
27907
  ]);
27792
- return parseListing(staged.out, untracked.out, deleted.out, ignored.out);
27908
+ const { paths, submodules, unmerged } = parseTracked(staged.out);
27909
+ const there = async (list) => {
27910
+ const found = await Promise.all(list.map((p) => lstat2(join24(cwd, p)).then(() => true, () => false)));
27911
+ return new Set(list.filter((_, i) => found[i]));
27912
+ };
27913
+ const [merging, kept] = await Promise.all([there(unmerged), there(nulSplit(dropped.out))]);
27914
+ const absent = new Set(unmerged.filter((p) => !merging.has(p)));
27915
+ return { paths: [...absent.size > 0 ? paths.filter((p) => !absent.has(p)) : paths, ...kept], submodules };
27916
+ }
27917
+ async ignored(worktreeId) {
27918
+ const cwd = this.require(worktreeId).path;
27919
+ const ignored = await git(cwd, "ls-files", "-z", "-o", "-i", "--exclude-standard", "--directory");
27920
+ return parseIgnored(ignored.out);
27793
27921
  }
27794
27922
  async search(worktreeId, query, asker) {
27795
27923
  const { path: cwd } = this.require(worktreeId);
@@ -28087,7 +28215,7 @@ async function viewPrRef(repoPath, ref) {
28087
28215
  if (preview)
28088
28216
  parsed.preview = preview;
28089
28217
  }
28090
- return { pr: { ...parsed, at: Date.now() }, head };
28218
+ return { pr: { ...parsed, ...head ? { head } : {}, at: Date.now() }, head };
28091
28219
  }
28092
28220
  var QUEUE_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
28093
28221
  repository(owner: $owner, name: $name) {
@@ -28531,7 +28659,7 @@ class AfterLand {
28531
28659
  // ../daemon/src/repos/registry.ts
28532
28660
  import { existsSync as existsSync28, mkdirSync as mkdirSync8, rmSync as rmSync5, writeFileSync as writeFileSync11 } from "fs";
28533
28661
  import { homedir as homedir10 } from "os";
28534
- import { basename as basename9, dirname as dirname10, join as join24 } from "path";
28662
+ import { basename as basename9, dirname as dirname10, join as join25 } from "path";
28535
28663
 
28536
28664
  // ../daemon/src/git/base.ts
28537
28665
  async function resolveBase(repoPath, defaultBranch, route) {
@@ -28632,7 +28760,7 @@ class RepoRegistry {
28632
28760
  return `${repo.name} is set up now`;
28633
28761
  if (main.promptedAt || main.lastTurn)
28634
28762
  return `${repo.name} has a chat now`;
28635
- if (existsSync28(join24(repo.path, ".claude")))
28763
+ if (existsSync28(join25(repo.path, ".claude")))
28636
28764
  return `an agent has already run in ${repo.name}`;
28637
28765
  if ((await statusFiles(repo.path)).length > 0 || !await treeEmpty(repo.path)) {
28638
28766
  return `${repo.name} has files in it now`;
@@ -28788,10 +28916,10 @@ class RepoRegistry {
28788
28916
  throw new UserError(current.reason);
28789
28917
  const was = configTarget(repo.path, !!repo.made);
28790
28918
  const rel = configSibling(was, kind);
28791
- const moved = rel !== was && existsSync28(join24(repo.path, was));
28919
+ const moved = rel !== was && existsSync28(join25(repo.path, was));
28792
28920
  if (moved && !(kind === "local" && await isTracked(repo.path, was))) {
28793
28921
  try {
28794
- rmSync5(join24(repo.path, was));
28922
+ rmSync5(join25(repo.path, was));
28795
28923
  } catch (e) {
28796
28924
  log.warn(repoId, `could not remove ${was}`, e);
28797
28925
  }
@@ -28800,7 +28928,7 @@ class RepoRegistry {
28800
28928
  }
28801
28929
  if (isLocalConfigFile(rel))
28802
28930
  await excludeFromGit(repo.path, rel);
28803
- const file = join24(repo.path, rel);
28931
+ const file = join25(repo.path, rel);
28804
28932
  try {
28805
28933
  mkdirSync8(dirname10(file), { recursive: true });
28806
28934
  writeFileSync11(file, configText(repo.path, rel, config));
@@ -28955,7 +29083,7 @@ class RepoRegistry {
28955
29083
  }
28956
29084
  placeConfig(repo) {
28957
29085
  const rel = configTarget(repo.path, !!repo.made);
28958
- if (isLocalConfigFile(rel) && existsSync28(join24(repo.path, rel))) {
29086
+ if (isLocalConfigFile(rel) && existsSync28(join25(repo.path, rel))) {
28959
29087
  fireAndForget(repo.id, excludeFromGit(repo.path, rel), "exclude local settings");
28960
29088
  }
28961
29089
  if (repo.configFile === rel)
@@ -29048,7 +29176,7 @@ class RepoRegistry {
29048
29176
  }
29049
29177
 
29050
29178
  // ../daemon/src/routes/service.ts
29051
- import { join as join25 } from "path";
29179
+ import { join as join26 } from "path";
29052
29180
 
29053
29181
  // ../daemon/src/routes/fileRouters.ts
29054
29182
  var SKIP = /(^|\/)(node_modules|dist|build|out|coverage|\.next|\.nuxt|\.output|\.svelte-kit|\.vercel|\.netlify)\//;
@@ -30220,13 +30348,12 @@ class RouteService {
30220
30348
  return scan.inflight;
30221
30349
  }
30222
30350
  async scan(scan) {
30223
- const listed = await git(scan.root, "ls-files", "-co", "--exclude-standard");
30351
+ const listed = await gitRaw(scan.root, "ls-files", "-z", "-co", "--exclude-standard");
30224
30352
  if (!listed.ok) {
30225
30353
  log.warn("routes", `could not list the files in ${scan.root}: ${listed.err}`);
30226
30354
  return [];
30227
30355
  }
30228
- const paths = listed.out.split(`
30229
- `).filter(Boolean);
30356
+ const paths = nulSplit(listed.out);
30230
30357
  const manifests = [];
30231
30358
  const keptManifests = new Map;
30232
30359
  for (const p of paths.filter(isManifest).slice(0, MAX_MANIFESTS)) {
@@ -30261,7 +30388,7 @@ class RouteService {
30261
30388
  return mergeRoutes(fileRoutes(paths, roots), code);
30262
30389
  }
30263
30390
  async cachedRead(root, path, had, kept, decode) {
30264
- const abs = join25(root, path);
30391
+ const abs = join26(root, path);
30265
30392
  const stat = await this.fs.stat(abs);
30266
30393
  if (!stat)
30267
30394
  return null;
@@ -30286,7 +30413,7 @@ class RouteService {
30286
30413
  }
30287
30414
  }
30288
30415
  async hash(root, file) {
30289
- const abs = join25(root, file);
30416
+ const abs = join26(root, file);
30290
30417
  const stat = await this.fs.stat(abs);
30291
30418
  if (!stat)
30292
30419
  return null;
@@ -30814,7 +30941,7 @@ import { homedir as homedir12, hostname as hostname2 } from "os";
30814
30941
  // ../daemon/src/agent/config.ts
30815
30942
  import { existsSync as existsSync30, readFileSync as readFileSync17 } from "fs";
30816
30943
  import { homedir as homedir11 } from "os";
30817
- import { join as join26 } from "path";
30944
+ import { join as join27 } from "path";
30818
30945
  function agentConfigFiles(spec, repoPath, home = homedir11()) {
30819
30946
  const file = (id, label, path) => ({
30820
30947
  id,
@@ -30824,30 +30951,30 @@ function agentConfigFiles(spec, repoPath, home = homedir11()) {
30824
30951
  });
30825
30952
  if (spec.id === "claude") {
30826
30953
  return [
30827
- file("user-config", "user config (MCP servers, projects)", join26(home, ".claude.json")),
30828
- file("user-settings", "user settings (permissions, hooks)", join26(home, ".claude", "settings.json")),
30829
- ...repoPath ? [file("project-mcp", "project MCP servers", join26(repoPath, ".mcp.json"))] : [],
30830
- ...repoPath ? [file("project-settings", "project settings", join26(repoPath, ".claude", "settings.json"))] : []
30954
+ file("user-config", "user config (MCP servers, projects)", join27(home, ".claude.json")),
30955
+ file("user-settings", "user settings (permissions, hooks)", join27(home, ".claude", "settings.json")),
30956
+ ...repoPath ? [file("project-mcp", "project MCP servers", join27(repoPath, ".mcp.json"))] : [],
30957
+ ...repoPath ? [file("project-settings", "project settings", join27(repoPath, ".claude", "settings.json"))] : []
30831
30958
  ];
30832
30959
  }
30833
30960
  if (spec.id === "codex")
30834
- return [file("user-config", "config", join26(home, ".codex", "config.toml"))];
30961
+ return [file("user-config", "config", join27(home, ".codex", "config.toml"))];
30835
30962
  return [];
30836
30963
  }
30837
30964
  function describeAgentConfig(spec, repoPath, home = homedir11()) {
30838
30965
  const files = agentConfigFiles(spec, repoPath, home);
30839
30966
  const servers = [];
30840
30967
  if (spec.id === "claude") {
30841
- const user = readJson(join26(home, ".claude.json"));
30968
+ const user = readJson(join27(home, ".claude.json"));
30842
30969
  servers.push(...serversOf(user?.mcpServers, "user"));
30843
30970
  if (repoPath) {
30844
30971
  const projects = user?.projects;
30845
30972
  const mine = projects?.[repoPath];
30846
30973
  servers.push(...serversOf(mine?.mcpServers, "local"));
30847
- servers.push(...serversOf(readJson(join26(repoPath, ".mcp.json"))?.mcpServers, "project"));
30974
+ servers.push(...serversOf(readJson(join27(repoPath, ".mcp.json"))?.mcpServers, "project"));
30848
30975
  }
30849
30976
  } else if (spec.id === "codex") {
30850
- servers.push(...codexServers(join26(home, ".codex", "config.toml")));
30977
+ servers.push(...codexServers(join27(home, ".codex", "config.toml")));
30851
30978
  }
30852
30979
  return { agent: spec.id, files, servers };
30853
30980
  }
@@ -31677,7 +31804,11 @@ var handlers = {
31677
31804
  fireAndForget(msg.worktreeId, agent.warmCommands(), "warm commands");
31678
31805
  },
31679
31806
  async "list-files"(msg, ctx, s) {
31680
- ctx.reply({ t: "files", worktreeId: msg.worktreeId, ...await s.files.list(msg.worktreeId) });
31807
+ const { worktreeId } = msg;
31808
+ await Promise.all([
31809
+ msg.tracked && s.files.tracked(worktreeId).then((l) => ctx.reply({ t: "files", worktreeId, ...l })),
31810
+ s.files.ignored(worktreeId).then((ignored) => ctx.reply({ t: "ignored-files", worktreeId, ignored }))
31811
+ ]);
31681
31812
  },
31682
31813
  async search(msg, ctx, s) {
31683
31814
  const page = await s.files.search(msg.worktreeId, msg.query, ctx.socket);
@@ -32045,7 +32176,7 @@ async function dispatch(msg, ctx, s) {
32045
32176
  // ../daemon/src/server/http.ts
32046
32177
  import { existsSync as existsSync31 } from "fs";
32047
32178
  import { hostname } from "os";
32048
- import { join as join27 } from "path";
32179
+ import { join as join28 } from "path";
32049
32180
 
32050
32181
  // ../daemon/src/server/cors.ts
32051
32182
  var ALLOW_HEADERS = "authorization, content-type";
@@ -32118,14 +32249,20 @@ function pairingRoutes(d) {
32118
32249
  if (url.pathname === "/knock" && req.method === "POST") {
32119
32250
  const from = req.headers.get("origin");
32120
32251
  const own = from === null || d.origins.isOwn(from);
32121
- const placed = front || from !== null && d.origins.isLoopback(from);
32122
- if (!own && (!isShellOrigin(from) || !placed || !await d.tailnet.isToyon(from))) {
32123
- return new Response("not a Toyon page", { status: 403 });
32252
+ const client = door.kind === "shell" ? door.client : null;
32253
+ const full = () => new Response("too many devices are waiting to be let in", { status: 429 });
32254
+ if (!own) {
32255
+ const placed = front || d.origins.isLoopback(from);
32256
+ if (!isShellOrigin(from) || !placed || d.origins.isPreview(from)) {
32257
+ return new Response("not a Toyon page", { status: 403 });
32258
+ }
32259
+ if (!d.knocks.room(client))
32260
+ return full();
32261
+ if (!await d.tailnet.isToyon(from))
32262
+ return new Response("not a Toyon page", { status: 403 });
32124
32263
  }
32125
- const knocked = d.knocks.knock(own ? null : from, door.kind === "shell" ? door.client : null);
32126
- if (!knocked)
32127
- return new Response("too many devices are waiting to be let in", { status: 429 });
32128
- return json(knocked);
32264
+ const knocked = d.knocks.knock(own ? null : from, client);
32265
+ return knocked ? json(knocked) : full();
32129
32266
  }
32130
32267
  const polled = knockPolled(url);
32131
32268
  if (polled !== null && req.method === "GET") {
@@ -32235,6 +32372,11 @@ function attachedHeaders(imageType) {
32235
32372
  ...imageType ? {} : { "content-security-policy": "sandbox" }
32236
32373
  };
32237
32374
  }
32375
+ var DRAWN_HEADERS = {
32376
+ "cache-control": NO_STORE2,
32377
+ "x-content-type-options": "nosniff",
32378
+ "content-security-policy": "sandbox"
32379
+ };
32238
32380
  function createFetch(opts) {
32239
32381
  const grant = previewGrant(opts.token);
32240
32382
  const authed = (req) => sameSecret(req.headers.get("authorization"), `Bearer ${opts.token}`);
@@ -32526,7 +32668,7 @@ function createFetch(opts) {
32526
32668
  if (!path)
32527
32669
  return new Response("not found", { status: 404 });
32528
32670
  return new Response(Bun.file(path), {
32529
- headers: { "cache-control": NO_STORE2, "x-content-type-options": "nosniff" }
32671
+ headers: DRAWN_HEADERS
32530
32672
  });
32531
32673
  }
32532
32674
  if (url.pathname.startsWith("/loose/")) {
@@ -32536,11 +32678,11 @@ function createFetch(opts) {
32536
32678
  if (!path)
32537
32679
  return new Response("not found", { status: 404 });
32538
32680
  return new Response(Bun.file(path), {
32539
- headers: { "cache-control": NO_STORE2, "x-content-type-options": "nosniff" }
32681
+ headers: DRAWN_HEADERS
32540
32682
  });
32541
32683
  }
32542
32684
  const rel = url.pathname === "/" ? "/index.html" : url.pathname;
32543
- const file = join27(opts.shellDist, rel.replaceAll("..", ""));
32685
+ const file = join28(opts.shellDist, rel.replaceAll("..", ""));
32544
32686
  const hashed = rel.startsWith("/assets/");
32545
32687
  if (existsSync31(file) && Bun.file(file).size > 0) {
32546
32688
  if (rel === "/manifest.json") {
@@ -32574,7 +32716,7 @@ function createFetch(opts) {
32574
32716
  }
32575
32717
  if (hashed)
32576
32718
  return new Response("not found", { status: 404 });
32577
- const index = join27(opts.shellDist, "index.html");
32719
+ const index = join28(opts.shellDist, "index.html");
32578
32720
  if (existsSync31(index))
32579
32721
  return page(req, index);
32580
32722
  return new Response("Toyon daemon running; shell not built (run: bun run build)", { status: 200 });
@@ -33129,14 +33271,14 @@ function startServer(opts) {
33129
33271
  // ../daemon/src/themes/store.ts
33130
33272
  import { existsSync as existsSync32, readdirSync as readdirSync8, readFileSync as readFileSync18, statSync as statSync11, writeFileSync as writeFileSync12 } from "fs";
33131
33273
  import { homedir as homedir13 } from "os";
33132
- import { basename as basename10, dirname as dirname11, join as join28, resolve as resolve11 } from "path";
33274
+ import { basename as basename10, dirname as dirname11, join as join29, resolve as resolve11 } from "path";
33133
33275
  var home = homedir13();
33134
33276
  var defaultExtensionDirs = [
33135
- join28(home, ".vscode", "extensions"),
33136
- join28(home, ".vscode-insiders", "extensions"),
33137
- join28(home, ".vscode-oss", "extensions"),
33138
- join28(home, ".cursor", "extensions"),
33139
- join28(home, ".windsurf", "extensions"),
33277
+ join29(home, ".vscode", "extensions"),
33278
+ join29(home, ".vscode-insiders", "extensions"),
33279
+ join29(home, ".vscode-oss", "extensions"),
33280
+ join29(home, ".cursor", "extensions"),
33281
+ join29(home, ".windsurf", "extensions"),
33140
33282
  "/Applications/Visual Studio Code.app/Contents/Resources/app/extensions",
33141
33283
  "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/extensions",
33142
33284
  "/Applications/Cursor.app/Contents/Resources/app/extensions",
@@ -33220,7 +33362,7 @@ class ThemeStore {
33220
33362
  name: json?.name ?? base,
33221
33363
  source: "file"
33222
33364
  });
33223
- writeFileSync12(join28(this.themesDir, `${base}.json`), `${JSON.stringify(theme, null, 2)}
33365
+ writeFileSync12(join29(this.themesDir, `${base}.json`), `${JSON.stringify(theme, null, 2)}
33224
33366
  `);
33225
33367
  this.load();
33226
33368
  return theme;
@@ -33255,7 +33397,7 @@ function loadDir(dir) {
33255
33397
  for (const f of readdirSync8(dir)) {
33256
33398
  if (!/\.jsonc?$/i.test(f))
33257
33399
  continue;
33258
- const file = join28(dir, f);
33400
+ const file = join29(dir, f);
33259
33401
  const id = `file:${slug(f.replace(/\.(json|jsonc)$/i, ""))}`;
33260
33402
  try {
33261
33403
  const json = readJsonc(file);
@@ -33281,8 +33423,8 @@ function discover(dir) {
33281
33423
  return out;
33282
33424
  }
33283
33425
  for (const name of entries) {
33284
- const extDir = join28(dir, name);
33285
- const pkgFile = join28(extDir, "package.json");
33426
+ const extDir = join29(dir, name);
33427
+ const pkgFile = join29(extDir, "package.json");
33286
33428
  try {
33287
33429
  if (!statSync11(extDir).isDirectory() || !existsSync32(pkgFile))
33288
33430
  continue;
@@ -33292,7 +33434,7 @@ function discover(dir) {
33292
33434
  continue;
33293
33435
  const ext = `${pkg.publisher ?? "local"}.${pkg.name ?? name}`;
33294
33436
  let nls = {};
33295
- const nlsFile = join28(extDir, "package.nls.json");
33437
+ const nlsFile = join29(extDir, "package.nls.json");
33296
33438
  if (existsSync32(nlsFile)) {
33297
33439
  try {
33298
33440
  nls = JSON.parse(readFileSync18(nlsFile, "utf8"));
@@ -33782,7 +33924,7 @@ class BackendShare {
33782
33924
  import { createHash as createHash4 } from "crypto";
33783
33925
  import { existsSync as existsSync33, readFileSync as readFileSync19 } from "fs";
33784
33926
  import { mkdir as mkdir5, readdir as readdir2, rename, rm as rm5, stat as stat5, utimes, writeFile as writeFile3 } from "fs/promises";
33785
- import { join as join29 } from "path";
33927
+ import { join as join30 } from "path";
33786
33928
 
33787
33929
  // ../daemon/src/core/clone.ts
33788
33930
  import { mkdir as mkdir4, rm as rm4 } from "fs/promises";
@@ -33813,7 +33955,7 @@ var STALE_MS = 30 * 24 * 3600000;
33813
33955
  var TMP = ".tmp-";
33814
33956
  var MANIFEST = "manifest.json";
33815
33957
  function defaultTools(dir) {
33816
- const has = (...files) => files.some((f) => existsSync33(join29(dir, f)));
33958
+ const has = (...files) => files.some((f) => existsSync33(join30(dir, f)));
33817
33959
  const out = [];
33818
33960
  if (has("bun.lock", "bun.lockb"))
33819
33961
  out.push("bun --version");
@@ -33826,7 +33968,7 @@ function defaultTools(dir) {
33826
33968
  return out;
33827
33969
  }
33828
33970
  function withSidecars(root, rel) {
33829
- return [rel, ...SIDECARS.map((s) => rel + s)].filter((n) => existsSync33(join29(root, n)));
33971
+ return [rel, ...SIDECARS.map((s) => rel + s)].filter((n) => existsSync33(join30(root, n)));
33830
33972
  }
33831
33973
  var entryName = (base, at) => `${base.slice(0, 12)}-${at.toString(36)}`;
33832
33974
  async function listEntries(familyDir) {
@@ -33841,8 +33983,8 @@ async function listEntries(familyDir) {
33841
33983
  const m = /^([0-9a-f]{12})-([0-9a-z]+)$/.exec(name);
33842
33984
  if (!m?.[1] || !m[2])
33843
33985
  continue;
33844
- const dir = join29(familyDir, name);
33845
- if (!existsSync33(join29(dir, MANIFEST)))
33986
+ const dir = join30(familyDir, name);
33987
+ if (!existsSync33(join30(dir, MANIFEST)))
33846
33988
  continue;
33847
33989
  out.push({ dir, base: m[1], at: Number.parseInt(m[2], 36) });
33848
33990
  }
@@ -33872,7 +34014,7 @@ class ArtifactCache {
33872
34014
  try {
33873
34015
  const family = await this.family(wt, repo, policy);
33874
34016
  const base = await this.baseCommit(wt, repo);
33875
- const entries = await listEntries(join29(this.d.paths.cacheDir, repo.id, family.key));
34017
+ const entries = await listEntries(join30(this.d.paths.cacheDir, repo.id, family.key));
33876
34018
  const entry = entries.find((e) => e.base === base.slice(0, 12)) ?? entries[0];
33877
34019
  if (!entry) {
33878
34020
  say(`cache: nothing kept for these lockfiles and tools on ${family.platform}`);
@@ -33880,14 +34022,14 @@ class ArtifactCache {
33880
34022
  }
33881
34023
  const restored = [];
33882
34024
  for (const rel of policy.paths) {
33883
- if (existsSync33(join29(wt.path, rel)))
34025
+ if (existsSync33(join30(wt.path, rel)))
33884
34026
  continue;
33885
34027
  const names = withSidecars(entry.dir, rel);
33886
34028
  if (names.length === 0)
33887
34029
  continue;
33888
34030
  let whole = true;
33889
34031
  for (const name of names) {
33890
- const r = await (this.d.clone ?? cloneTree)(join29(entry.dir, name), join29(wt.path, name));
34032
+ const r = await (this.d.clone ?? cloneTree)(join30(entry.dir, name), join30(wt.path, name));
33891
34033
  if (r.ok)
33892
34034
  continue;
33893
34035
  say(`cache: could not restore ${name}: ${r.error}`);
@@ -33921,12 +34063,12 @@ class ArtifactCache {
33921
34063
  let tmp;
33922
34064
  let slot;
33923
34065
  try {
33924
- const present = policy.paths.filter((rel) => existsSync33(join29(wt.path, rel)));
34066
+ const present = policy.paths.filter((rel) => existsSync33(join30(wt.path, rel)));
33925
34067
  if (present.length === 0)
33926
34068
  return;
33927
34069
  const family = await this.family(wt, repo, policy);
33928
34070
  const base = await this.baseCommit(wt, repo);
33929
- const familyDir = join29(this.d.paths.cacheDir, repo.id, family.key);
34071
+ const familyDir = join30(this.d.paths.cacheDir, repo.id, family.key);
33930
34072
  const key = `${familyDir}/${base.slice(0, 12)}`;
33931
34073
  const kept = (await listEntries(familyDir)).find((e) => e.base === base.slice(0, 12));
33932
34074
  if (kept || this.making.has(key)) {
@@ -33937,21 +34079,21 @@ class ArtifactCache {
33937
34079
  this.making.add(key);
33938
34080
  const at = Date.now();
33939
34081
  const name = entryName(base, at);
33940
- tmp = join29(familyDir, `${TMP}${name}`);
34082
+ tmp = join30(familyDir, `${TMP}${name}`);
33941
34083
  await mkdir5(tmp, { recursive: true });
33942
34084
  let how = "clonefile";
33943
34085
  for (const rel of present) {
33944
34086
  for (const n of withSidecars(wt.path, rel)) {
33945
- const r = await (this.d.clone ?? cloneTree)(join29(wt.path, n), join29(tmp, n));
34087
+ const r = await (this.d.clone ?? cloneTree)(join30(wt.path, n), join30(tmp, n));
33946
34088
  if (!r.ok)
33947
34089
  throw new Error(`could not keep ${n}: ${r.error}`);
33948
34090
  how = r.how;
33949
34091
  }
33950
34092
  }
33951
34093
  const manifest = { at, base, platform: family.platform, tools: family.tools, paths: present };
33952
- await writeFile3(join29(tmp, MANIFEST), `${JSON.stringify(manifest, null, 2)}
34094
+ await writeFile3(join30(tmp, MANIFEST), `${JSON.stringify(manifest, null, 2)}
33953
34095
  `);
33954
- await rename(tmp, join29(familyDir, name));
34096
+ await rename(tmp, join30(familyDir, name));
33955
34097
  tmp = undefined;
33956
34098
  log.info(worktreeId, `cache: kept ${list(present)} for ${base.slice(0, 7)} via ${how} in ${Date.now() - started}ms`);
33957
34099
  await this.prune(familyDir);
@@ -33979,7 +34121,7 @@ class ArtifactCache {
33979
34121
  return;
33980
34122
  }
33981
34123
  for (const repoId of repoIds) {
33982
- const repoDir = join29(root, repoId);
34124
+ const repoDir = join30(root, repoId);
33983
34125
  if (!this.d.state.repo(repoId)) {
33984
34126
  await rm5(repoDir, { recursive: true, force: true });
33985
34127
  continue;
@@ -33991,7 +34133,7 @@ class ArtifactCache {
33991
34133
  continue;
33992
34134
  }
33993
34135
  for (const family of families) {
33994
- const familyDir = join29(repoDir, family);
34136
+ const familyDir = join30(repoDir, family);
33995
34137
  let names;
33996
34138
  try {
33997
34139
  names = await readdir2(familyDir);
@@ -34000,7 +34142,7 @@ class ArtifactCache {
34000
34142
  }
34001
34143
  for (const n of names)
34002
34144
  if (n.startsWith(TMP))
34003
- await rm5(join29(familyDir, n), { recursive: true, force: true });
34145
+ await rm5(join30(familyDir, n), { recursive: true, force: true });
34004
34146
  const entries = await listEntries(familyDir);
34005
34147
  let newest = 0;
34006
34148
  for (const e of entries) {
@@ -34020,7 +34162,7 @@ class ArtifactCache {
34020
34162
  for (const f of policy.key) {
34021
34163
  h.update(`\x00${f}\x00`);
34022
34164
  try {
34023
- h.update(readFileSync19(join29(wt.path, f)));
34165
+ h.update(readFileSync19(join30(wt.path, f)));
34024
34166
  } catch {
34025
34167
  h.update("(absent)");
34026
34168
  }
@@ -34337,7 +34479,7 @@ class FixService {
34337
34479
  // ../daemon/src/worktrees/handoff.ts
34338
34480
  import { createHash as createHash5, randomUUID as randomUUID2 } from "crypto";
34339
34481
  import { readFileSync as readFileSync20 } from "fs";
34340
- import { join as join30 } from "path";
34482
+ import { join as join31 } from "path";
34341
34483
 
34342
34484
  // ../daemon/src/worktrees/proposals.ts
34343
34485
  var PER_TURN_MAX = 3;
@@ -34853,9 +34995,9 @@ class HandoffService {
34853
34995
  if (go)
34854
34996
  await this.act(worktreeId, id, note?.trim() || undefined);
34855
34997
  else
34856
- await this.decline(worktreeId, id);
34998
+ await this.decline(worktreeId, id, undefined, note?.trim() || undefined);
34857
34999
  }
34858
- async decline(worktreeId, id, reason) {
35000
+ async decline(worktreeId, id, reason, note) {
34859
35001
  const wt = this.d.state.worktree(worktreeId);
34860
35002
  if (!wt)
34861
35003
  return;
@@ -34869,9 +35011,17 @@ class HandoffService {
34869
35011
  type: "handoff-declined",
34870
35012
  id,
34871
35013
  ...reason ? { outcome: "failed", reason } : {},
35014
+ ...note ? { note } : {},
34872
35015
  ...plan ? { plan } : {},
34873
35016
  ts: Date.now()
34874
35017
  });
35018
+ if (!note)
35019
+ return;
35020
+ const text = handoffDeclinedPrompt(proposed.pieces, note);
35021
+ fireAndForget(wt.id, this.idle(wt.id, agent).then(() => agent.send(text, {
35022
+ asked: { kind: "handoff", why: "the list was sent back with a note, so the agent reads it before going on" },
35023
+ keepCards: true
35024
+ })), "pieces declined");
34875
35025
  }
34876
35026
  async act(worktreeId, id, note, tell = false) {
34877
35027
  const wt = this.d.state.worktree(worktreeId);
@@ -35003,7 +35153,7 @@ ${p.section}`;
35003
35153
  return;
35004
35154
  let markdown;
35005
35155
  try {
35006
- markdown = readFileSync20(join30(wt.path, card.plan), "utf8");
35156
+ markdown = readFileSync20(join31(wt.path, card.plan), "utf8");
35007
35157
  } catch (e) {
35008
35158
  log.warn(worktreeId, `could not read ${card.plan} to see whether the plan moved`, e);
35009
35159
  return;
@@ -35626,6 +35776,32 @@ class LandingService {
35626
35776
  return;
35627
35777
  fireAndForget(worktreeId, this.run(wt, { at: Date.now(), ask: false, quiet: true }), "landing recheck");
35628
35778
  }
35779
+ async message(worktreeId) {
35780
+ const wt = this.d.state.worktree(worktreeId);
35781
+ if (!wt || !this.d.judge)
35782
+ return;
35783
+ const repo = this.d.state.requireRepo(wt.repoId);
35784
+ let verdict = null;
35785
+ try {
35786
+ verdict = await this.d.judge(wt, landPrompt(await this.landInput(wt, repo)));
35787
+ } catch (e) {
35788
+ log.warn(worktreeId, "landing message unanswered at the press", e);
35789
+ }
35790
+ if (!verdict?.subject)
35791
+ return;
35792
+ const now = this.d.state.worktree(worktreeId)?.landing;
35793
+ if (now && !now.subject) {
35794
+ const { unanswered: _owed, ...rest } = now;
35795
+ this.d.worktrees.setLanding(worktreeId, {
35796
+ ...rest,
35797
+ subject: verdict.subject,
35798
+ ...verdict.body ? { body: verdict.body } : {}
35799
+ });
35800
+ }
35801
+ return verdict.body ? `${verdict.subject}
35802
+
35803
+ ${verdict.body}` : verdict.subject;
35804
+ }
35629
35805
  async landInput(wt, repo) {
35630
35806
  const base = baseOf(repo);
35631
35807
  const entries = this.d.transcript(wt.id);
@@ -35793,15 +35969,16 @@ function tail(text) {
35793
35969
  const t = text.replace(/\s+$/, "");
35794
35970
  return t.length > TAIL_CHARS ? t.slice(-TAIL_CHARS) : t;
35795
35971
  }
35972
+ var PLAIN_NAMES = ["-c", "core.quotePath=false"];
35796
35973
  async function diffSummary(id, path, defaultBr) {
35797
35974
  const [base, dirty, untracked] = await Promise.all([
35798
35975
  git(path, "merge-base", "HEAD", defaultBr),
35799
- git(path, "diff", "HEAD", "--stat"),
35800
- git(path, "ls-files", "--others", "--exclude-standard")
35976
+ git(path, ...PLAIN_NAMES, "diff", "HEAD", "--stat"),
35977
+ git(path, ...PLAIN_NAMES, "ls-files", "--others", "--exclude-standard")
35801
35978
  ]);
35802
35979
  if (!base.ok)
35803
35980
  log.warn(id, `landing question: no merge base with ${defaultBr}: ${base.err.trim()}`);
35804
- const committed = base.ok && base.out ? await git(path, "diff", "--stat", base.out, "HEAD") : null;
35981
+ const committed = base.ok && base.out ? await git(path, ...PLAIN_NAMES, "diff", "--stat", base.out, "HEAD") : null;
35805
35982
  const own = [dirty.out ? `Uncommitted:
35806
35983
  ${dirty.out}` : "", untracked.out ? `New files:
35807
35984
  ${untracked.out}` : ""].filter(Boolean).join(`
@@ -36022,14 +36199,28 @@ function parseWorktreeList(out) {
36022
36199
  entries.push(cur);
36023
36200
  return entries;
36024
36201
  }
36202
+ var C_ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };
36025
36203
  function unquote(s) {
36026
- if (!s.startsWith('"') || !s.endsWith('"'))
36027
- return s;
36028
- try {
36029
- return JSON.parse(s);
36030
- } catch {
36204
+ if (s.length < 2 || !s.startsWith('"') || !s.endsWith('"'))
36031
36205
  return s;
36206
+ const bytes = [];
36207
+ const body = Buffer.from(s.slice(1, -1), "utf8");
36208
+ for (let i = 0;i < body.length; i++) {
36209
+ const b = body[i];
36210
+ if (b !== 92) {
36211
+ bytes.push(b);
36212
+ continue;
36213
+ }
36214
+ const octal = /^[0-3][0-7]{2}/.exec(body.subarray(i + 1, i + 4).toString("latin1"));
36215
+ if (octal) {
36216
+ bytes.push(Number.parseInt(octal[0], 8));
36217
+ i += 3;
36218
+ continue;
36219
+ }
36220
+ const next = String.fromCharCode(body[++i] ?? 92);
36221
+ bytes.push(C_ESCAPES[next] ?? next.charCodeAt(0));
36032
36222
  }
36223
+ return Buffer.from(bytes).toString("utf8");
36033
36224
  }
36034
36225
  async function listWorktrees(repoPath) {
36035
36226
  const r = await git(repoPath, "worktree", "list", "--porcelain");
@@ -36267,6 +36458,11 @@ class PrService {
36267
36458
  this.d = d;
36268
36459
  d.hub.on("repoTick", (repoId) => this.refreshRepo(repoId));
36269
36460
  d.hub.on("turnSettled", (id) => fireAndForget(id, this.discover(id, "turn"), "pr discovery"));
36461
+ d.hub.on("prDue", (id) => {
36462
+ const wt = this.d.state.worktree(id);
36463
+ if (wt)
36464
+ this.ask(wt);
36465
+ });
36270
36466
  this.timer = setInterval(() => this.refreshAll(), d.everyMs ?? POLL_MS3);
36271
36467
  this.timer.unref?.();
36272
36468
  this.fast = setInterval(() => this.refreshHeld(), d.fastMs ?? FAST_MS);
@@ -36298,11 +36494,15 @@ class PrService {
36298
36494
  continue;
36299
36495
  if (!wt.pr)
36300
36496
  fireAndForget(wt.id, this.discover(wt.id, why), "pr discovery");
36301
- else if (wt.pr.state === "open")
36302
- fireAndForget(wt.id, this.refresh(wt.id), "pr refresh");
36303
- else if (wt.pr.state === "merged" && !prTaken(wt)) {
36304
- fireAndForget(wt.id, this.d.worktrees.prMerged(wt.id), "pr landing");
36305
- }
36497
+ else
36498
+ this.ask(wt);
36499
+ }
36500
+ }
36501
+ ask(wt) {
36502
+ if (wt.pr?.state === "open")
36503
+ fireAndForget(wt.id, this.refresh(wt.id), "pr refresh");
36504
+ else if (wt.pr?.state === "merged" && !prTaken(wt)) {
36505
+ fireAndForget(wt.id, this.d.worktrees.prMerged(wt.id), "pr landing");
36306
36506
  }
36307
36507
  }
36308
36508
  async discover(worktreeId, why) {
@@ -36643,7 +36843,7 @@ function rankRefs({ refs, merged, prs, query, defaultBranch, rowIdForPath }) {
36643
36843
  // ../daemon/src/worktrees/service.ts
36644
36844
  import { cpSync, existsSync as existsSync38, readFileSync as readFileSync22, rmSync as rmSync9 } from "fs";
36645
36845
  import { rm as rm6 } from "fs/promises";
36646
- import { dirname as dirname15, join as join35 } from "path";
36846
+ import { dirname as dirname15, join as join36 } from "path";
36647
36847
 
36648
36848
  // ../daemon/src/git/archive.ts
36649
36849
  import { rmSync as rmSync6 } from "fs";
@@ -36654,7 +36854,7 @@ async function keepState(repoPath, wtPath, ref, indexFile) {
36654
36854
  const head = await git(wtPath, "rev-parse", "--verify", "HEAD");
36655
36855
  if (!head.ok)
36656
36856
  return null;
36657
- const status = await gitRaw(wtPath, "status", "--porcelain", "-uall");
36857
+ const status = await gitRaw(wtPath, "status", "--porcelain", "-z", "-uall");
36658
36858
  const files = status.ok ? parsePorcelain(status.out).length : null;
36659
36859
  const dirty = files === null || files > 0;
36660
36860
  const snapshot = dirty ? await snapshotCommit(wtPath, head.out, indexFile) : null;
@@ -36703,9 +36903,8 @@ async function keptAt(repoPath, ref) {
36703
36903
  const snapshot = subject === SNAPSHOT_SUBJECT && author === SNAPSHOT_AUTHOR && parents && !parents.includes(" ");
36704
36904
  if (!snapshot)
36705
36905
  return { head: commit };
36706
- const files = await git(repoPath, "diff", "--name-only", parents, commit);
36707
- const dirty = files.ok ? files.out.split(`
36708
- `).filter(Boolean).length : 0;
36906
+ const files = await gitRaw(repoPath, "diff", "--name-only", "-z", parents, commit);
36907
+ const dirty = files.ok ? nulSplit(files.out).length : 0;
36709
36908
  return { head: parents, snapshot: commit, ...dirty ? { dirty } : {} };
36710
36909
  }
36711
36910
  async function checkOutKept(repoPath, wtPath, kept, branch) {
@@ -36766,8 +36965,357 @@ async function dropLandRefs(repoPath, worktreeId) {
36766
36965
  await git(repoPath, "update-ref", "-d", ref);
36767
36966
  }
36768
36967
 
36968
+ // ../daemon/src/git/turn.ts
36969
+ import { copyFileSync, existsSync as existsSync34, rmSync as rmSync7 } from "fs";
36970
+ import { join as join32 } from "path";
36971
+ var turnRef = (worktreeId, which) => `refs/toyon/turns/${worktreeId}/${which}`;
36972
+ var SNAPSHOT_SUBJECT2 = "toyon: tree at a send";
36973
+ var queue = new Map;
36974
+ function inTurn(wtPath, job) {
36975
+ const next = (queue.get(wtPath) ?? Promise.resolve()).then(job, job);
36976
+ queue.set(wtPath, next.catch(() => {}));
36977
+ return next;
36978
+ }
36979
+ var synced = new Map;
36980
+ var MAX_PATHS = 400;
36981
+ async function treeNow(wtPath, head, dirty) {
36982
+ let was = synced.get(wtPath);
36983
+ let index = was?.index;
36984
+ if (!index) {
36985
+ const dir = await git(wtPath, "rev-parse", "--absolute-git-dir");
36986
+ if (!dir.ok || !dir.out)
36987
+ return null;
36988
+ index = join32(dir.out, "toyon-turn-index");
36989
+ }
36990
+ const env = { GIT_INDEX_FILE: index };
36991
+ const writeTree = async () => {
36992
+ const tree = await run(GIT, ["write-tree"], wtPath, env);
36993
+ return tree.ok && tree.out ? tree.out : null;
36994
+ };
36995
+ if (!existsSync34(index))
36996
+ was = undefined;
36997
+ const paths = dirty && !dirty.some((f) => /[RC]/.test(f.xy)) ? dirty.map((f) => f.path) : null;
36998
+ if (was && paths && was.head === head) {
36999
+ const again = [...new Set([...was.dirty, ...paths])];
37000
+ if (again.length <= MAX_PATHS) {
37001
+ const added = again.length === 0 || (await run(GIT, ["--literal-pathspecs", "add", "-A", "--", ...again], wtPath, env)).ok;
37002
+ const tree = added ? await writeTree() : null;
37003
+ if (tree) {
37004
+ synced.set(wtPath, { index, head, dirty: new Set(paths) });
37005
+ return tree;
37006
+ }
37007
+ }
37008
+ }
37009
+ synced.delete(wtPath);
37010
+ if (!existsSync34(index)) {
37011
+ const own = join32(index, "..", "index");
37012
+ if (existsSync34(own))
37013
+ copyFileSync(own, index);
37014
+ else if (!(await run(GIT, ["read-tree", "HEAD"], wtPath, env)).ok)
37015
+ return null;
37016
+ }
37017
+ const full = async () => (await run(GIT, ["add", "-A"], wtPath, env)).ok ? writeTree() : null;
37018
+ let tree = await full();
37019
+ if (!tree) {
37020
+ rmSync7(index, { force: true });
37021
+ if (!(await run(GIT, ["read-tree", "HEAD"], wtPath, env)).ok)
37022
+ return null;
37023
+ tree = await full();
37024
+ if (!tree)
37025
+ return null;
37026
+ }
37027
+ const differs = await gitRaw(wtPath, "diff", "--name-only", "--no-renames", "-z", head, tree);
37028
+ if (differs.ok)
37029
+ synced.set(wtPath, { index, head, dirty: new Set(nulSplit(differs.out)) });
37030
+ return tree;
37031
+ }
37032
+ async function snapshotAt(wtPath, ref) {
37033
+ const r = await git(wtPath, "rev-parse", "--quiet", `${ref}^{commit}`, `${ref}^{tree}`, `${ref}^`);
37034
+ const [commit, tree, parent] = r.ok ? r.out.split(`
37035
+ `) : [];
37036
+ return commit && tree && parent ? { commit, tree, parent } : null;
37037
+ }
37038
+ function markTurn(wtPath, worktreeId) {
37039
+ return inTurn(wtPath, async () => {
37040
+ const head = await git(wtPath, "rev-parse", "--verify", "HEAD");
37041
+ if (!head.ok)
37042
+ return;
37043
+ const tree = await treeNow(wtPath, head.out);
37044
+ if (!tree)
37045
+ return;
37046
+ const at = await snapshotAt(wtPath, turnRef(worktreeId, "at"));
37047
+ if (at?.tree === tree && at.parent === head.out)
37048
+ return;
37049
+ const commit = await git(wtPath, "-c", "user.name=toyon", "-c", "user.email=toyon@localhost", "commit-tree", tree, "-p", head.out, "-m", SNAPSHOT_SUBJECT2);
37050
+ if (!commit.ok)
37051
+ return;
37052
+ if (at && at.tree !== tree)
37053
+ await git(wtPath, "update-ref", turnRef(worktreeId, "before"), at.commit);
37054
+ await git(wtPath, "update-ref", turnRef(worktreeId, "at"), commit.out);
37055
+ });
37056
+ }
37057
+ function turnChanges(wtPath, worktreeId, defaultBr, own, dirty) {
37058
+ return inTurn(wtPath, async () => {
37059
+ const at = await snapshotAt(wtPath, turnRef(worktreeId, "at"));
37060
+ if (!at)
37061
+ return null;
37062
+ const head = await git(wtPath, "rev-parse", "HEAD", "HEAD^{tree}");
37063
+ const [headCommit, headTree] = head.ok ? head.out.split(`
37064
+ `) : [];
37065
+ if (!headCommit)
37066
+ return null;
37067
+ const tree = await treeNow(wtPath, headCommit, dirty);
37068
+ if (!tree)
37069
+ return null;
37070
+ const from = at.tree !== tree ? at : await snapshotAt(wtPath, turnRef(worktreeId, "before"));
37071
+ if (!from || from.tree === tree || from.tree === headTree)
37072
+ return null;
37073
+ if (from.parent !== headCommit) {
37074
+ if (!own)
37075
+ return null;
37076
+ const [then, now] = await Promise.all([
37077
+ git(wtPath, "merge-base", from.parent, defaultBr),
37078
+ git(wtPath, "merge-base", headCommit, defaultBr)
37079
+ ]);
37080
+ if (!then.ok || !now.ok || then.out !== now.out)
37081
+ return null;
37082
+ }
37083
+ const files = await filesBetween(wtPath, from.commit, tree);
37084
+ return files.length > 0 ? { base: from.commit, files } : null;
37085
+ });
37086
+ }
37087
+ async function dropTurnRefs(repoPath, worktreeId) {
37088
+ for (const which of ["at", "before"])
37089
+ await git(repoPath, "update-ref", "-d", turnRef(worktreeId, which));
37090
+ }
37091
+
37092
+ // ../daemon/src/worktrees/archive.ts
37093
+ import { existsSync as existsSync35, mkdirSync as mkdirSync9, readdirSync as readdirSync9, readFileSync as readFileSync21, renameSync as renameSync7, rmSync as rmSync8, writeFileSync as writeFileSync13 } from "fs";
37094
+ import { basename as basename13, dirname as dirname14, join as join33 } from "path";
37095
+ class WorktreeArchive {
37096
+ dir;
37097
+ records = new Map;
37098
+ constructor(dir) {
37099
+ this.dir = dir;
37100
+ mkdirSync9(dir, { recursive: true });
37101
+ for (const id of readdirSync9(dir)) {
37102
+ const file = this.filesOf(id).record;
37103
+ if (!existsSync35(file))
37104
+ continue;
37105
+ try {
37106
+ this.records.set(id, JSON.parse(readFileSync21(file, "utf8")));
37107
+ } catch (e) {
37108
+ log.warn("archive", `skipped an unreadable record: ${file}`, e);
37109
+ }
37110
+ }
37111
+ }
37112
+ get(id) {
37113
+ return this.records.get(id);
37114
+ }
37115
+ put(rec, from) {
37116
+ const at = this.filesOf(rec.worktree.id);
37117
+ mkdirSync9(at.dir, { recursive: true });
37118
+ move(from.transcript, at.transcript);
37119
+ move(from.attachments, at.attachments);
37120
+ move(from.plans, at.plans);
37121
+ writeFileSync13(at.record, JSON.stringify(rec, null, 2));
37122
+ this.records.set(rec.worktree.id, rec);
37123
+ return { transcript: at.transcript, attachments: at.attachments, plans: at.plans };
37124
+ }
37125
+ take(id, to) {
37126
+ if (!this.records.has(id))
37127
+ return;
37128
+ const at = this.filesOf(id);
37129
+ move(at.transcript, to.transcript);
37130
+ move(at.attachments, to.attachments);
37131
+ move(at.plans, to.plans);
37132
+ this.delete(id);
37133
+ }
37134
+ delete(id) {
37135
+ if (!this.records.has(id))
37136
+ return;
37137
+ rmSync8(this.filesOf(id).dir, { recursive: true, force: true });
37138
+ this.records.delete(id);
37139
+ }
37140
+ list(repo) {
37141
+ return this.recordsOf(repo).sort((a, b) => b.archivedAt - a.archivedAt).map((r) => summarize(r, repo.id, this.filesOf(r.worktree.id).transcript));
37142
+ }
37143
+ recordsOf(repo) {
37144
+ return [...this.records.values()].filter((r) => r.worktree.repoId === repo.id || r.repoPath === repo.path);
37145
+ }
37146
+ save(id) {
37147
+ const rec = this.records.get(id);
37148
+ if (!rec)
37149
+ return;
37150
+ writeFileSync13(this.filesOf(id).record, JSON.stringify(rec, null, 2));
37151
+ }
37152
+ chatFiles(id) {
37153
+ if (!this.records.has(id))
37154
+ return;
37155
+ const at = this.filesOf(id);
37156
+ return { transcript: at.transcript, attachments: at.attachments, plans: at.plans };
37157
+ }
37158
+ planFile(id, rel) {
37159
+ if (!this.records.has(id) || !isPlanPath(rel))
37160
+ return null;
37161
+ const file = join33(this.filesOf(id).plans, basename13(rel));
37162
+ if (!existsSync35(file))
37163
+ return null;
37164
+ try {
37165
+ return readFileSync21(file, "utf8");
37166
+ } catch (e) {
37167
+ log.warn(id, `could not read its archived plan ${rel}`, e);
37168
+ return null;
37169
+ }
37170
+ }
37171
+ filesOf(id) {
37172
+ const dir = join33(this.dir, id);
37173
+ return {
37174
+ dir,
37175
+ record: join33(dir, "record.json"),
37176
+ transcript: join33(dir, "transcript.jsonl"),
37177
+ attachments: join33(dir, "attachments"),
37178
+ plans: join33(dir, "plans")
37179
+ };
37180
+ }
37181
+ }
37182
+ function summarize(r, repoId, transcript) {
37183
+ return {
37184
+ id: r.worktree.id,
37185
+ repoId,
37186
+ title: r.worktree.title,
37187
+ branch: r.worktree.branch,
37188
+ path: r.worktree.path,
37189
+ createdAt: r.worktree.createdAt,
37190
+ archivedAt: r.archivedAt,
37191
+ ...r.prompt ? { prompt: r.prompt } : {},
37192
+ restorable: !!r.kept,
37193
+ transcript,
37194
+ ...r.sessionId ? { sessionId: r.sessionId } : {},
37195
+ ...r.kept?.snapshot ? { uncommitted: true, ...r.kept.dirty ? { dirty: r.kept.dirty } : {} } : {},
37196
+ ...r.worktree.landed ? { landed: true } : {},
37197
+ ...r.worktree.lands?.length ? { lands: r.worktree.lands } : {},
37198
+ ...r.cost !== undefined ? { cost: r.cost } : {},
37199
+ ...r.auto ? { auto: r.auto } : {}
37200
+ };
37201
+ }
37202
+ function lastUsage(transcript) {
37203
+ if (!existsSync35(transcript))
37204
+ return null;
37205
+ const lines = readFileSync21(transcript, "utf8").split(`
37206
+ `);
37207
+ for (let i = lines.length - 1;i >= 0; i--) {
37208
+ if (!lines[i]?.includes('"usage"'))
37209
+ continue;
37210
+ try {
37211
+ const e = JSON.parse(lines[i]).event;
37212
+ if (e?.type === "usage")
37213
+ return { used: e.used, size: e.size, ...e.cost !== undefined ? { cost: e.cost } : {} };
37214
+ } catch {}
37215
+ }
37216
+ return null;
37217
+ }
37218
+ function firstPrompt(transcript) {
37219
+ if (!existsSync35(transcript))
37220
+ return;
37221
+ for (const line of readFileSync21(transcript, "utf8").split(`
37222
+ `)) {
37223
+ if (!line.includes('"user-message"') && !line.includes('"handed"'))
37224
+ continue;
37225
+ try {
37226
+ const event = JSON.parse(line).event;
37227
+ if (event?.type === "user-message")
37228
+ return event.text.slice(0, 500);
37229
+ if (event?.type === "handed")
37230
+ return (event.body ?? event.text).slice(0, 500);
37231
+ } catch {}
37232
+ }
37233
+ return;
37234
+ }
37235
+ function move(from, to) {
37236
+ if (!existsSync35(from))
37237
+ return;
37238
+ mkdirSync9(dirname14(to), { recursive: true });
37239
+ rmSync8(to, { recursive: true, force: true });
37240
+ renameSync7(from, to);
37241
+ }
37242
+
37243
+ // ../daemon/src/worktrees/archivedGit.ts
37244
+ class ArchivedGit {
37245
+ repoPath;
37246
+ kept;
37247
+ lands;
37248
+ defaultBr;
37249
+ constructor(repoPath, kept, lands, defaultBr) {
37250
+ this.repoPath = repoPath;
37251
+ this.kept = kept;
37252
+ this.lands = lands;
37253
+ this.defaultBr = defaultBr;
37254
+ }
37255
+ async tailBase() {
37256
+ const r = await git(this.repoPath, "merge-base", this.kept.head, this.defaultBr);
37257
+ return r.ok && r.out ? r.out : this.kept.head;
37258
+ }
37259
+ async status() {
37260
+ const base = await this.tailBase();
37261
+ const { head, snapshot } = this.kept;
37262
+ const [files, committed] = await Promise.all([
37263
+ snapshot ? filesBetween(this.repoPath, head, snapshot) : [],
37264
+ base === head ? [] : filesBetween(this.repoPath, base, head)
37265
+ ]);
37266
+ return { files, committed, head: snapshot ?? head };
37267
+ }
37268
+ async log() {
37269
+ const seen = new Set;
37270
+ const unseen = (list) => {
37271
+ const out = list.filter((c) => !seen.has(c.sha));
37272
+ for (const c of out)
37273
+ seen.add(c.sha);
37274
+ return out;
37275
+ };
37276
+ const landed = [];
37277
+ for (const land of this.lands) {
37278
+ const commits = unseen(await logRange(this.repoPath, land.base, land.tip));
37279
+ landed.push(commits.map((c) => ({ ...c, landedAt: land.at })));
37280
+ }
37281
+ const base = await this.tailBase();
37282
+ const tail = base === this.kept.head ? [] : unseen(await logRange(this.repoPath, base, this.kept.head));
37283
+ return [...tail, ...landed.reverse().flat()];
37284
+ }
37285
+ commitFiles(sha) {
37286
+ return commitFiles(this.repoPath, sha);
37287
+ }
37288
+ async file(path, ref) {
37289
+ if (ref)
37290
+ return fileAtCommit(this.repoPath, ref, path);
37291
+ const base = await this.tailBase();
37292
+ const [before, after] = await Promise.all([
37293
+ gitRaw(this.repoPath, "show", `${base}:${path}`),
37294
+ gitRaw(this.repoPath, "show", `${this.kept.snapshot ?? this.kept.head}:${path}`)
37295
+ ]);
37296
+ return { before: before.ok ? before.out : "", after: after.ok ? after.out : "" };
37297
+ }
37298
+ }
37299
+
37300
+ // ../daemon/src/worktrees/landSubjects.ts
37301
+ function unfilled(mark) {
37302
+ return mark.subjects === undefined;
37303
+ }
37304
+ async function fillLandMarks(jobs, read, width = 8) {
37305
+ let next = 0;
37306
+ const worker = async () => {
37307
+ while (next < jobs.length) {
37308
+ const job = jobs[next++];
37309
+ if (!job)
37310
+ return;
37311
+ job.mark.subjects = await read(job.cwd, job.mark) ?? [];
37312
+ }
37313
+ };
37314
+ await Promise.all(Array.from({ length: Math.min(width, jobs.length) }, worker));
37315
+ }
37316
+
36769
37317
  // ../daemon/src/git/land.ts
36770
- import { existsSync as existsSync34 } from "fs";
37318
+ import { existsSync as existsSync36 } from "fs";
36771
37319
  var STEP_TIMEOUT_MS = RUN_CEILING_MS;
36772
37320
  function rowOf(r) {
36773
37321
  return r.shown && r.toolId && r.command ? { toolId: r.toolId, command: r.command, text: r.text } : undefined;
@@ -36864,9 +37412,8 @@ async function putBack(worktreePath, defaultBr, how, aside, taken) {
36864
37412
  await git(worktreePath, "update-ref", "-d", CARRIED);
36865
37413
  return taken;
36866
37414
  }
36867
- const unmerged = await git(worktreePath, "diff", "--name-only", "--diff-filter=U");
36868
- clash = unmerged.out.split(`
36869
- `).filter(Boolean);
37415
+ const unmerged = await gitRaw(worktreePath, "diff", "--name-only", "-z", "--diff-filter=U");
37416
+ clash = nulSplit(unmerged.out);
36870
37417
  await git(worktreePath, "reset", "--hard", "-q", aside.head);
36871
37418
  }
36872
37419
  const back = await git(worktreePath, "stash", "apply", "--index", aside.sha);
@@ -36901,7 +37448,7 @@ async function midway(worktreePath) {
36901
37448
  if (!r.ok)
36902
37449
  return null;
36903
37450
  const at = r.out.split(`
36904
- `).findIndex((file) => file && existsSync34(file));
37451
+ `).findIndex((file) => file && existsSync36(file));
36905
37452
  return MIDWAY[at]?.[1] ?? null;
36906
37453
  }
36907
37454
  async function takeMainIn(worktreePath, defaultBr, own, w = UNWATCHED, carry = false) {
@@ -37136,8 +37683,9 @@ async function pushLanding(worktreePath, sha, defaultBr, w = UNWATCHED) {
37136
37683
  }
37137
37684
  async function mergeFailure(cwd, m, conflictMessage) {
37138
37685
  const conflict = /CONFLICT|Automatic merge failed/.test(m.text);
37139
- if (conflict) {
37686
+ if (conflict || await midway(cwd) === "merge")
37140
37687
  await git(cwd, "merge", "--abort");
37688
+ if (conflict) {
37141
37689
  const step = rowOf(m);
37142
37690
  return {
37143
37691
  ok: false,
@@ -37146,6 +37694,8 @@ async function mergeFailure(cwd, m, conflictMessage) {
37146
37694
  message: m.shown ? `${conflictMessage}; the conflicts are on the chat` : `${conflictMessage} (${m.text.slice(0, 200)})`
37147
37695
  };
37148
37696
  }
37697
+ if (m.hook)
37698
+ return stepRefused("merge refused", m);
37149
37699
  return {
37150
37700
  ok: false,
37151
37701
  message: m.shown ? "merge failed: what git printed is on the chat" : `merge failed: ${m.text.slice(0, 300)}`
@@ -37164,21 +37714,43 @@ async function fetchBase(worktreePath, repo, w = UNWATCHED) {
37164
37714
  return { ok: false, message: refused3("fetch failed", f) };
37165
37715
  return { ok: true, message: `fetched ${base}` };
37166
37716
  }
37167
- async function pushBranch(worktreePath, branch, w = UNWATCHED) {
37168
- const args = ["push", "-u", "--force-with-lease", "origin", branch];
37717
+ async function pushBranch(worktreePath, branch, own, w = UNWATCHED) {
37718
+ const theirs = `${branch} on origin holds commits that are not in the branch here: take them in first, then land again`;
37719
+ const tip = own ? await originTip(worktreePath, branch) : null;
37720
+ const behind = tip !== null && !await reaches(worktreePath, tip);
37721
+ if (tip && behind && !await supersedes(worktreePath, branch, tip))
37722
+ return { ok: false, message: theirs };
37169
37723
  w.step(`pushing ${branch}`);
37170
- let push = await w.git(worktreePath, args);
37171
- if (!push.ok && /stale info/.test(push.err) && await goneFromOrigin(worktreePath, branch)) {
37172
- await git(worktreePath, "update-ref", "-d", `refs/remotes/origin/${branch}`);
37173
- push = await w.git(worktreePath, args);
37174
- }
37175
- if (!push.ok)
37176
- return stepRefused("push failed", push, 300);
37177
- return { ok: true, message: `pushed ${branch}` };
37178
- }
37179
- async function goneFromOrigin(worktreePath, branch) {
37180
- const r = await run(GIT, ["ls-remote", "--exit-code", "--heads", "origin", branch], worktreePath, NO_PROMPT);
37181
- return !r.ok && r.exit === 2;
37724
+ const lease = tip && behind ? [`--force-with-lease=${branch}:${tip}`] : [];
37725
+ const push = await w.git(worktreePath, ["push", "-u", ...lease, "origin", branch]);
37726
+ if (push.ok)
37727
+ return { ok: true, message: `pushed ${branch}` };
37728
+ if (/\[rejected\]|fetch first|non-fast-forward|stale info/.test(push.err))
37729
+ return { ok: false, message: theirs };
37730
+ return stepRefused("push failed", push, 300);
37731
+ }
37732
+ async function originTip(worktreePath, branch) {
37733
+ const ref = `refs/heads/${branch}`;
37734
+ const r = await run(GIT, ["ls-remote", "origin", ref], worktreePath, NO_PROMPT);
37735
+ if (!r.ok)
37736
+ return null;
37737
+ const line = r.out.split(`
37738
+ `).find((l) => l.split("\t")[1] === ref);
37739
+ return line?.split("\t")[0] || null;
37740
+ }
37741
+ async function supersedes(worktreePath, branch, tip) {
37742
+ const [pushed, stood, lacking] = await Promise.all([
37743
+ git(worktreePath, "reflog", "show", "--format=%H %gs", `refs/remotes/origin/${branch}`),
37744
+ git(worktreePath, "reflog", "show", "--format=%H", `refs/heads/${branch}`),
37745
+ git(worktreePath, "rev-list", "--count", "--cherry-pick", "--left-only", `${tip}...HEAD`)
37746
+ ]);
37747
+ if (pushed.ok && pushed.out.split(`
37748
+ `).includes(`${tip} update by push`))
37749
+ return true;
37750
+ if (stood.ok && stood.out.split(`
37751
+ `).includes(tip))
37752
+ return true;
37753
+ return lacking.ok && lacking.out === "0";
37182
37754
  }
37183
37755
  async function openPr(o, w = UNWATCHED) {
37184
37756
  const cErr = await requireClean(o.worktreePath);
@@ -37191,7 +37763,7 @@ async function openPr(o, w = UNWATCHED) {
37191
37763
  const remote = await git(o.worktreePath, "remote", "get-url", "origin");
37192
37764
  if (!remote.ok)
37193
37765
  return { ok: false, message: "no 'origin' remote: add one, or land by merging here" };
37194
- const push = await pushBranch(o.worktreePath, o.branch, w);
37766
+ const push = await pushBranch(o.worktreePath, o.branch, o.own, w);
37195
37767
  if (!push.ok)
37196
37768
  return push;
37197
37769
  w.step("creating the PR");
@@ -37267,358 +37839,761 @@ function compareUrl(remoteUrl, base, branch) {
37267
37839
  return `${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(branch)}?expand=1`;
37268
37840
  }
37269
37841
 
37270
- // ../daemon/src/git/turn.ts
37271
- import { copyFileSync, existsSync as existsSync35, rmSync as rmSync7 } from "fs";
37272
- import { join as join31 } from "path";
37273
- var turnRef = (worktreeId, which) => `refs/toyon/turns/${worktreeId}/${which}`;
37274
- var SNAPSHOT_SUBJECT2 = "toyon: tree at a send";
37275
- var queue = new Map;
37276
- function inTurn(wtPath, job) {
37277
- const next = (queue.get(wtPath) ?? Promise.resolve()).then(job, job);
37278
- queue.set(wtPath, next.catch(() => {}));
37279
- return next;
37280
- }
37281
- var synced = new Map;
37282
- var MAX_PATHS = 400;
37283
- async function treeNow(wtPath, head, dirty) {
37284
- let was = synced.get(wtPath);
37285
- let index = was?.index;
37286
- if (!index) {
37287
- const dir = await git(wtPath, "rev-parse", "--absolute-git-dir");
37288
- if (!dir.ok || !dir.out)
37289
- return null;
37290
- index = join31(dir.out, "toyon-turn-index");
37842
+ // ../daemon/src/worktrees/ship.ts
37843
+ var REFUSAL_CHARS = 400;
37844
+ var uncounted = (base) => ({
37845
+ ok: false,
37846
+ message: `could not count the commits ahead of ${base}; try again`
37847
+ });
37848
+ var commandLine = (args) => args.map((a) => /^[\w./:=@,+-]+$/.test(a) ? a : JSON.stringify(a.split(`
37849
+ `, 1)[0])).join(" ");
37850
+
37851
+ class Ship {
37852
+ d;
37853
+ ops = new Map;
37854
+ syncQueued = new Set;
37855
+ handChecked = new Map;
37856
+ handWaits = new Set;
37857
+ constructor(d) {
37858
+ this.d = d;
37291
37859
  }
37292
- const env = { GIT_INDEX_FILE: index };
37293
- const writeTree = async () => {
37294
- const tree = await run(GIT, ["write-tree"], wtPath, env);
37295
- return tree.ok && tree.out ? tree.out : null;
37296
- };
37297
- if (!existsSync35(index))
37298
- was = undefined;
37299
- const paths = dirty && !dirty.some((f) => /[RC]/.test(f.xy)) ? dirty.map((f) => f.path) : null;
37300
- if (was && paths && was.head === head) {
37301
- const again = [...new Set([...was.dirty, ...paths])];
37302
- if (again.length <= MAX_PATHS) {
37303
- const added = again.length === 0 || (await run(GIT, ["--literal-pathspecs", "add", "-A", "--", ...again], wtPath, env)).ok;
37304
- const tree = added ? await writeTree() : null;
37305
- if (tree) {
37306
- synced.set(wtPath, { index, head, dirty: new Set(paths) });
37307
- return tree;
37308
- }
37309
- }
37860
+ syncWaiting(id) {
37861
+ return this.syncQueued.has(id);
37310
37862
  }
37311
- synced.delete(wtPath);
37312
- if (!existsSync35(index)) {
37313
- const own = join31(index, "..", "index");
37314
- if (existsSync35(own))
37315
- copyFileSync(own, index);
37316
- else if (!(await run(GIT, ["read-tree", "HEAD"], wtPath, env)).ok)
37317
- return null;
37863
+ forget(id) {
37864
+ this.syncQueued.delete(id);
37865
+ this.handWaits.delete(id);
37866
+ this.handChecked.delete(id);
37318
37867
  }
37319
- const full = async () => (await run(GIT, ["add", "-A"], wtPath, env)).ok ? writeTree() : null;
37320
- let tree = await full();
37321
- if (!tree) {
37322
- rmSync7(index, { force: true });
37323
- if (!(await run(GIT, ["read-tree", "HEAD"], wtPath, env)).ok)
37868
+ setPr(worktreeId, pr) {
37869
+ const wt = this.d.state.worktree(worktreeId);
37870
+ if (!wt || wt.pr === undefined && pr === undefined)
37871
+ return;
37872
+ const pending = prPending(wt);
37873
+ if (pr)
37874
+ wt.pr = pr;
37875
+ else
37876
+ delete wt.pr;
37877
+ this.d.state.save();
37878
+ if (pending && !prPending(wt) && this.handChecked.delete(worktreeId))
37879
+ this.d.tree.invalidate(worktreeId);
37880
+ this.d.hub.emit("worktreesChanged");
37881
+ }
37882
+ async refreshLanded(wt, facts) {
37883
+ if (wt.kind === "main")
37884
+ return;
37885
+ const f = facts ?? await this.landedFacts(wt);
37886
+ if (f)
37887
+ this.setLanded(wt, landedNow(wt, f));
37888
+ }
37889
+ async settleLanded(wt) {
37890
+ const repo = this.d.state.repo(wt.repoId);
37891
+ const f = wt.kind === "main" || !repo ? null : await this.landedFacts(wt);
37892
+ delete wt.landing;
37893
+ let held;
37894
+ if (f && repo) {
37895
+ held = notLandedWhy(wt, f, baseOf(repo));
37896
+ if (held) {
37897
+ delete wt.landed;
37898
+ log.warn(wt.id, `landed, but not marked landed: ${held}`);
37899
+ } else
37900
+ wt.landed = true;
37901
+ }
37902
+ this.d.state.save();
37903
+ this.d.hub.emit("worktreesChanged");
37904
+ return held;
37905
+ }
37906
+ async landedFacts(wt) {
37907
+ const repo = this.d.state.repo(wt.repoId);
37908
+ if (!repo)
37324
37909
  return null;
37325
- tree = await full();
37326
- if (!tree)
37910
+ try {
37911
+ const [head, ab] = await Promise.all([git(wt.path, "rev-parse", "HEAD"), aheadBehind(wt.path, baseOf(repo))]);
37912
+ if (!head.ok)
37913
+ return null;
37914
+ return { head: head.out, ahead: ab.ahead };
37915
+ } catch (e) {
37916
+ log.warn(wt.id, "could not read the tree for the landed mark", e);
37327
37917
  return null;
37918
+ }
37328
37919
  }
37329
- const differs = await git(wtPath, "diff", "--name-only", "--no-renames", "-z", head, tree);
37330
- if (differs.ok)
37331
- synced.set(wtPath, { index, head, dirty: new Set(differs.out.split("\x00").filter(Boolean)) });
37332
- return tree;
37333
- }
37334
- async function snapshotAt(wtPath, ref) {
37335
- const r = await git(wtPath, "rev-parse", "--quiet", `${ref}^{commit}`, `${ref}^{tree}`, `${ref}^`);
37336
- const [commit, tree, parent] = r.ok ? r.out.split(`
37337
- `) : [];
37338
- return commit && tree && parent ? { commit, tree, parent } : null;
37339
- }
37340
- function markTurn(wtPath, worktreeId) {
37341
- return inTurn(wtPath, async () => {
37342
- const head = await git(wtPath, "rev-parse", "--verify", "HEAD");
37343
- if (!head.ok)
37344
- return;
37345
- const tree = await treeNow(wtPath, head.out);
37346
- if (!tree)
37920
+ setLanded(wt, landed) {
37921
+ if (!!wt.landed === landed)
37347
37922
  return;
37348
- const at = await snapshotAt(wtPath, turnRef(worktreeId, "at"));
37349
- if (at?.tree === tree && at.parent === head.out)
37923
+ if (landed)
37924
+ wt.landed = true;
37925
+ else
37926
+ delete wt.landed;
37927
+ this.d.state.save();
37928
+ this.d.hub.emit("worktreesChanged");
37929
+ }
37930
+ fingerprint(path) {
37931
+ return (this.d.fingerprint ?? treeFingerprint)(path);
37932
+ }
37933
+ setLanding(worktreeId, landing) {
37934
+ const wt = this.d.state.worktree(worktreeId);
37935
+ if (!wt || wt.landing === undefined && landing === undefined)
37350
37936
  return;
37351
- const commit = await git(wtPath, "-c", "user.name=toyon", "-c", "user.email=toyon@localhost", "commit-tree", tree, "-p", head.out, "-m", SNAPSHOT_SUBJECT2);
37352
- if (!commit.ok)
37937
+ if (landing)
37938
+ wt.landing = landing;
37939
+ else
37940
+ delete wt.landing;
37941
+ this.d.state.save();
37942
+ this.d.hub.emit("worktreesChanged");
37943
+ }
37944
+ landable(worktreeId, verb) {
37945
+ const pair = this.d.state.requireWorktreeWithRepo(worktreeId);
37946
+ if (isMain(pair.wt))
37947
+ throw new UserError(`${verb} from a worktree, not main`);
37948
+ if (!canLand(pair.wt))
37949
+ throw new UserError(`${pair.wt.title} is a PR under review; it lands upstream, not here`);
37950
+ return pair;
37951
+ }
37952
+ suggestion(wt, typed) {
37953
+ const m = typed?.trim();
37954
+ if (m)
37955
+ return m;
37956
+ const s = wt.landing?.subject;
37957
+ if (!s)
37353
37958
  return;
37354
- if (at && at.tree !== tree)
37355
- await git(wtPath, "update-ref", turnRef(worktreeId, "before"), at.commit);
37356
- await git(wtPath, "update-ref", turnRef(worktreeId, "at"), commit.out);
37357
- });
37358
- }
37359
- function turnChanges(wtPath, worktreeId, defaultBr, own, dirty) {
37360
- return inTurn(wtPath, async () => {
37361
- const at = await snapshotAt(wtPath, turnRef(worktreeId, "at"));
37362
- if (!at)
37363
- return null;
37364
- const head = await git(wtPath, "rev-parse", "HEAD", "HEAD^{tree}");
37365
- const [headCommit, headTree] = head.ok ? head.out.split(`
37366
- `) : [];
37367
- if (!headCommit)
37368
- return null;
37369
- const tree = await treeNow(wtPath, headCommit, dirty);
37370
- if (!tree)
37371
- return null;
37372
- const from = at.tree !== tree ? at : await snapshotAt(wtPath, turnRef(worktreeId, "before"));
37373
- if (!from || from.tree === tree || from.tree === headTree)
37959
+ return wt.landing?.body ? `${s}
37960
+
37961
+ ${wt.landing.body}` : s;
37962
+ }
37963
+ commitMessage(wt, typed) {
37964
+ const m = this.suggestion(wt, typed);
37965
+ if (m)
37966
+ return m;
37967
+ throw new UserError("no commit message yet: write one in the changes panel");
37968
+ }
37969
+ async commitIfDirty(wt, typed) {
37970
+ if ((await statusFiles(wt.path)).length === 0)
37374
37971
  return null;
37375
- if (from.parent !== headCommit) {
37376
- if (!own)
37377
- return null;
37378
- const [then, now] = await Promise.all([
37379
- git(wtPath, "merge-base", from.parent, defaultBr),
37380
- git(wtPath, "merge-base", headCommit, defaultBr)
37381
- ]);
37382
- if (!then.ok || !now.ok || then.out !== now.out)
37383
- return null;
37972
+ return this.commitRecorded(wt, this.commitMessage(wt, typed));
37973
+ }
37974
+ async commitRecorded(wt, message) {
37975
+ const result = await commitWorktree(wt.path, message, this.landWatch(wt));
37976
+ if (result.ok) {
37977
+ await this.verdictSurvives(wt, { dropMessage: true });
37978
+ await this.d.headMoved(wt.id);
37384
37979
  }
37385
- const files = await filesBetween(wtPath, from.commit, tree);
37386
- return files.length > 0 ? { base: from.commit, files } : null;
37387
- });
37388
- }
37389
- async function dropTurnRefs(repoPath, worktreeId) {
37390
- for (const which of ["at", "before"])
37391
- await git(repoPath, "update-ref", "-d", turnRef(worktreeId, which));
37392
- }
37393
-
37394
- // ../daemon/src/worktrees/archive.ts
37395
- import { existsSync as existsSync36, mkdirSync as mkdirSync9, readdirSync as readdirSync9, readFileSync as readFileSync21, renameSync as renameSync7, rmSync as rmSync8, writeFileSync as writeFileSync13 } from "fs";
37396
- import { basename as basename13, dirname as dirname14, join as join32 } from "path";
37397
- class WorktreeArchive {
37398
- dir;
37399
- records = new Map;
37400
- constructor(dir) {
37401
- this.dir = dir;
37402
- mkdirSync9(dir, { recursive: true });
37403
- for (const id of readdirSync9(dir)) {
37404
- const file = this.filesOf(id).record;
37405
- if (!existsSync36(file))
37406
- continue;
37407
- try {
37408
- this.records.set(id, JSON.parse(readFileSync21(file, "utf8")));
37409
- } catch (e) {
37410
- log.warn("archive", `skipped an unreadable record: ${file}`, e);
37980
+ return result;
37981
+ }
37982
+ landWatch(wt) {
37983
+ const repo = this.d.state.requireRepo(wt.repoId);
37984
+ return {
37985
+ step: (name) => this.step(wt.id, name),
37986
+ git: async (cwd, args) => {
37987
+ const where = cwd === wt.path ? "git" : `git -C ${cwd}`;
37988
+ const commit = args[0] === "commit";
37989
+ const ceiling = commit ? timeoutFor(repo.config, "commit") : STEP_TIMEOUT_MS;
37990
+ const handle = commit ? this.d.runs.begin(wt.id, "commit", { timeoutMs: ceiling }) : null;
37991
+ const run = (onText, signal) => stepRun(cwd, args, onText, signal, {
37992
+ timeoutMs: ceiling,
37993
+ ...handle ? { onSpawn: handle.spawned } : {},
37994
+ onHook: (hook) => handle?.stage(hook ? `${hook} hook` : undefined)
37995
+ });
37996
+ const watched = async () => {
37997
+ if (!this.d.watch)
37998
+ return run(() => {});
37999
+ const command = `${where} ${commandLine(args)}`;
38000
+ try {
38001
+ return { ...await this.d.watch(wt.id, command, run), command };
38002
+ } catch (e) {
38003
+ log.warn(wt.id, `landing step runs unwatched: ${e instanceof Error ? e.message : e}`);
38004
+ return run(() => {});
38005
+ }
38006
+ };
38007
+ const r = await watched();
38008
+ handle?.finish(r.exit);
38009
+ return r;
37411
38010
  }
38011
+ };
38012
+ }
38013
+ async verdictSurvives(wt, opts) {
38014
+ const l = wt.landing;
38015
+ if (!l || l.check === "pending")
38016
+ return;
38017
+ const { subject, body, unanswered, ...kept } = l;
38018
+ const message = opts.dropMessage ? {} : { ...subject ? { subject } : {}, ...body ? { body } : {}, ...unanswered ? { unanswered } : {} };
38019
+ this.setLanding(wt.id, { ...kept, ...message, fingerprint: await this.fingerprint(wt.path) });
38020
+ }
38021
+ async landedByHand(wt, mark) {
38022
+ const repo = this.d.state.requireRepo(wt.repoId);
38023
+ const base = baseOf(repo);
38024
+ log.info(wt.id, `landed on ${base} outside toyon: ${mark.base.slice(0, 7)}..${mark.tip.slice(0, 7)}`);
38025
+ await this.landing(wt, "auto", async () => ({
38026
+ result: { ok: true, message: `landed on ${base} outside toyon` },
38027
+ landed: true,
38028
+ unsaid: true,
38029
+ ...await this.settle(wt, repo, mark)
38030
+ }));
38031
+ }
38032
+ async landing(wt, by, run) {
38033
+ const out = await this.ship(wt.id, "land", by, async () => {
38034
+ await this.followBranch(wt);
38035
+ return run();
38036
+ });
38037
+ if (out.result.ok && out.landed)
38038
+ this.noteLanded(wt, out);
38039
+ return out;
38040
+ }
38041
+ async land(worktreeId, message) {
38042
+ const { wt, repo } = this.landable(worktreeId, "land");
38043
+ const out = await this.landing(wt, "press", async () => {
38044
+ const on = await git(wt.path, "branch", "--show-current");
38045
+ if (on.ok && !on.out)
38046
+ throw new UserError(`${wt.title} is detached: check out a branch in it first`);
38047
+ const moved = await this.movedMidTurn(wt);
38048
+ if (moved)
38049
+ return { result: moved };
38050
+ return this.landOp(wt, repo, message);
38051
+ });
38052
+ return { result: this.fixShip(wt, out.result, baseOf(repo)) };
38053
+ }
38054
+ fixShip(wt, result, base) {
38055
+ if (result.ok)
38056
+ return result;
38057
+ if (result.hook?.name === "commit-msg") {
38058
+ this.d.hub.emit("messageRefused", wt.id, result.hook.text.trim().slice(-REFUSAL_CHARS));
38059
+ return { ...result, message: "the commit-msg hook refused the message: another is being written" };
37412
38060
  }
38061
+ const { hook } = result;
38062
+ const failure = hook ? { kind: "hook", hook: hook.name, toolId: hook.toolId, command: hook.command, text: hook.text } : result.conflict ? {
38063
+ kind: "conflict",
38064
+ base,
38065
+ how: result.conflict,
38066
+ ...result.step ? { step: result.step } : {},
38067
+ ...result.carried ? { carried: result.carried } : {}
38068
+ } : null;
38069
+ return failure && this.d.fix.report(wt.id, failure) ? { ...result, asked: true } : result;
37413
38070
  }
37414
- get(id) {
37415
- return this.records.get(id);
38071
+ async movedMidTurn(wt) {
38072
+ if (!isBusy(this.d.runtime.agentFor(wt.id)?.status))
38073
+ return null;
38074
+ const l = wt.landing;
38075
+ if (!l || l.fingerprint === await this.fingerprint(wt.path))
38076
+ return null;
38077
+ return { ok: false, message: "the agent has changed files since the check: land once its turn ends" };
37416
38078
  }
37417
- put(rec, from) {
37418
- const at = this.filesOf(rec.worktree.id);
37419
- mkdirSync9(at.dir, { recursive: true });
37420
- move(from.transcript, at.transcript);
37421
- move(from.attachments, at.attachments);
37422
- move(from.plans, at.plans);
37423
- writeFileSync13(at.record, JSON.stringify(rec, null, 2));
37424
- this.records.set(rec.worktree.id, rec);
37425
- return { transcript: at.transcript, attachments: at.attachments, plans: at.plans };
38079
+ noteLanded(wt, o) {
38080
+ if (!o.unsaid) {
38081
+ this.d.runtime.ensureAgent(wt).agent.note({
38082
+ type: "landed",
38083
+ message: o.result.message,
38084
+ ...o.mark ? { mark: o.mark } : {},
38085
+ ...o.url ? { url: o.url } : {},
38086
+ ...o.held ? { held: o.held } : {},
38087
+ ts: Date.now()
38088
+ });
38089
+ }
38090
+ this.d.hub.emit("landed", wt.id);
37426
38091
  }
37427
- take(id, to) {
37428
- if (!this.records.has(id))
38092
+ async followBranch(wt, current) {
38093
+ if (wt.kind === "main")
37429
38094
  return;
37430
- const at = this.filesOf(id);
37431
- move(at.transcript, to.transcript);
37432
- move(at.attachments, to.attachments);
37433
- move(at.plans, to.plans);
37434
- this.delete(id);
37435
- }
37436
- delete(id) {
37437
- if (!this.records.has(id))
38095
+ const branch = current ?? (await git(wt.path, "branch", "--show-current")).out;
38096
+ if (!branch || branch === wt.branch)
37438
38097
  return;
37439
- rmSync8(this.filesOf(id).dir, { recursive: true, force: true });
37440
- this.records.delete(id);
38098
+ log.info(wt.id, `on ${branch} now, not ${wt.branch}: the row follows the checkout`);
38099
+ wt.branch = branch;
38100
+ this.d.state.save();
38101
+ this.d.hub.emit("worktreesChanged");
37441
38102
  }
37442
- list(repo) {
37443
- return this.recordsOf(repo).sort((a, b) => b.archivedAt - a.archivedAt).map((r) => summarize(r, repo.id, this.filesOf(r.worktree.id).transcript));
38103
+ shippingOf(id) {
38104
+ return this.ops.get(id);
37444
38105
  }
37445
- recordsOf(repo) {
37446
- return [...this.records.values()].filter((r) => r.worktree.repoId === repo.id || r.repoPath === repo.path);
38106
+ async ship(id, op, by, run) {
38107
+ const out = this.ops.get(id);
38108
+ if (out)
38109
+ throw new UserError(`${shipNoun(out.op)} is already running here`);
38110
+ const release = this.d.runtime.agentFor(id)?.hold();
38111
+ this.ops.set(id, { op });
38112
+ if (by === "press")
38113
+ this.d.stampPrompted(id);
38114
+ this.d.hub.emit("worktreesChanged");
38115
+ const started = Date.now();
38116
+ const watchdog = setInterval(() => {
38117
+ const step = this.ops.get(id)?.step;
38118
+ const at = step ? ` at "${step}"` : "";
38119
+ log.warn(id, `${shipNoun(op)} still running after ${describeDuration(Date.now() - started)}${at}`);
38120
+ }, RUN_CEILING_MS);
38121
+ watchdog.unref?.();
38122
+ try {
38123
+ return await run();
38124
+ } finally {
38125
+ clearInterval(watchdog);
38126
+ this.ops.delete(id);
38127
+ release?.();
38128
+ if (this.handWaits.delete(id))
38129
+ this.d.tree.invalidate(id);
38130
+ this.d.hub.emit("worktreesChanged");
38131
+ if (!isBusy(this.d.runtime.agentFor(id)?.status))
38132
+ this.syncAfterTurn(id);
38133
+ }
37447
38134
  }
37448
- save(id) {
37449
- const rec = this.records.get(id);
37450
- if (!rec)
38135
+ step(id, name) {
38136
+ const out = this.ops.get(id);
38137
+ if (!out || out.step === name)
37451
38138
  return;
37452
- writeFileSync13(this.filesOf(id).record, JSON.stringify(rec, null, 2));
38139
+ out.step = name;
38140
+ this.d.hub.emit("worktreesChanged");
37453
38141
  }
37454
- chatFiles(id) {
37455
- if (!this.records.has(id))
37456
- return;
37457
- const at = this.filesOf(id);
37458
- return { transcript: at.transcript, attachments: at.attachments, plans: at.plans };
38142
+ async messageOwed(wt, typed, w) {
38143
+ if (!this.d.message || this.suggestion(wt, typed))
38144
+ return typed;
38145
+ if ((await statusFiles(wt.path)).length === 0)
38146
+ return typed;
38147
+ w.step("writing the message");
38148
+ return await this.d.message(wt) ?? typed;
37459
38149
  }
37460
- planFile(id, rel) {
37461
- if (!this.records.has(id) || !isPlanPath(rel))
37462
- return null;
37463
- const file = join32(this.filesOf(id).plans, basename13(rel));
37464
- if (!existsSync36(file))
37465
- return null;
37466
- try {
37467
- return readFileSync21(file, "utf8");
37468
- } catch (e) {
37469
- log.warn(id, `could not read its archived plan ${rel}`, e);
37470
- return null;
38150
+ async landOp(wt, repo, typed) {
38151
+ const policy = landPolicy(repo.config);
38152
+ const w = this.landWatch(wt);
38153
+ const message = await this.messageOwed(wt, typed, w);
38154
+ const suggested = this.suggestion(wt, message);
38155
+ if (policy.land === "pr")
38156
+ return this.landPr(wt, repo, policy, message, suggested, w);
38157
+ if (policy.land === "push")
38158
+ return this.landPush(wt, repo, policy, message, suggested, w);
38159
+ return this.landLocal(wt, repo, policy, message, suggested, w);
38160
+ }
38161
+ async landPr(wt, repo, policy, message, suggested, w) {
38162
+ const base = baseOf(repo);
38163
+ const own = hasOwnBranch(wt);
38164
+ if (wt.pr?.state === "merged" && !prTaken(wt))
38165
+ return this.prMergedOp(wt, undefined, w);
38166
+ const fetched = await this.fetchBase(wt, repo, w);
38167
+ if (!fetched.ok)
38168
+ return { result: fetched };
38169
+ if (wt.pr?.state === "open") {
38170
+ const dirty = (await statusFiles(wt.path)).length > 0;
38171
+ const missing = dirty || (await aheadUpstream(wt.path, wt.branch) ?? 0) > 0;
38172
+ if (!missing) {
38173
+ const result = await mergePr(wt.path, wt.pr.number, policy.merge, w);
38174
+ if (result.ok && result.queued)
38175
+ this.setPr(wt.id, { ...wt.pr, queue: true, queued: "waiting", at: Date.now() });
38176
+ return { result };
38177
+ }
38178
+ const committed = await this.commitIfDirty(wt, message);
38179
+ if (committed && !committed.ok)
38180
+ return { result: committed };
38181
+ this.d.tree.invalidate(wt.id);
38182
+ const taken = await takeMainIn(wt.path, base, own, w);
38183
+ if (!taken.ok)
38184
+ return { result: taken };
38185
+ await this.d.headMoved(wt.id);
38186
+ this.d.tree.invalidate(wt.id);
38187
+ const pushed = await pushBranch(wt.path, wt.branch, own, w);
38188
+ if (!pushed.ok)
38189
+ return { result: pushed };
38190
+ await this.d.headMoved(wt.id);
38191
+ this.setLanding(wt.id, undefined);
38192
+ const did = committed ? "committed and pushed" : "pushed";
38193
+ if (wt.pr.queued) {
38194
+ const { queued: _, ...left } = wt.pr;
38195
+ this.setPr(wt.id, { ...left, at: Date.now() });
38196
+ return {
38197
+ result: {
38198
+ ok: true,
38199
+ url: wt.pr.url,
38200
+ message: `${did}; PR #${wt.pr.number} has the new commits and left the merge queue: land it again once it is ready`
38201
+ }
38202
+ };
38203
+ }
38204
+ return { result: { ok: true, url: wt.pr.url, message: `${did}; PR #${wt.pr.number} has the new commits` } };
38205
+ }
38206
+ const committed = await this.commitIfDirty(wt, message);
38207
+ if (committed && !committed.ok)
38208
+ return { result: committed };
38209
+ const mark = await landingMark(wt.path, base);
38210
+ this.d.tree.invalidate(wt.id);
38211
+ const taken = await takeMainIn(wt.path, base, own, w);
38212
+ if (!taken.ok)
38213
+ return { result: taken };
38214
+ await this.d.headMoved(wt.id);
38215
+ const ahead = await aheadOf(wt.path, base);
38216
+ if (ahead === null)
38217
+ return { result: uncounted(base) };
38218
+ if (ahead === 0) {
38219
+ const landed = await this.prMergedOp(wt, mark, w);
38220
+ const on = `origin's ${repo.defaultBranch} has this work already`;
38221
+ return { ...landed, result: { ...landed.result, message: `${on}; ${landed.result.message}` } };
38222
+ }
38223
+ const named = !suggested ? undefined : splitMessage(policy.merge === "squash" ? await squashMessage(wt.path, base, suggested, !!message?.trim()) : suggested);
38224
+ const result = await openPr({
38225
+ worktreePath: wt.path,
38226
+ branch: wt.branch,
38227
+ defaultBr: repo.defaultBranch,
38228
+ base,
38229
+ subject: named?.subject,
38230
+ body: named?.body,
38231
+ automerge: policy.automerge,
38232
+ own,
38233
+ method: policy.merge
38234
+ }, w);
38235
+ if (result.ok) {
38236
+ this.setLanding(wt.id, undefined);
38237
+ if (result.pr)
38238
+ this.setPr(wt.id, { ...result.pr, at: Date.now() });
37471
38239
  }
38240
+ return { result };
37472
38241
  }
37473
- filesOf(id) {
37474
- const dir = join32(this.dir, id);
37475
- return {
37476
- dir,
37477
- record: join32(dir, "record.json"),
37478
- transcript: join32(dir, "transcript.jsonl"),
37479
- attachments: join32(dir, "attachments"),
37480
- plans: join32(dir, "plans")
38242
+ landLocal(wt, repo, policy, message, suggested, w) {
38243
+ const base = baseOf(repo);
38244
+ const own = hasOwnBranch(wt);
38245
+ return withRepoLock(repo.path, async () => {
38246
+ const committed = await this.commitIfDirty(wt, message);
38247
+ if (committed && !committed.ok)
38248
+ return { result: committed };
38249
+ const method = policy.merge ?? DEFAULT_MERGE_METHOD;
38250
+ const merged = own && method === "rebase" ? await requireLinear(wt.path, base) : null;
38251
+ if (merged)
38252
+ return { result: merged };
38253
+ const taken = await takeMainIn(wt.path, base, own, w);
38254
+ if (!taken.ok)
38255
+ return { result: taken };
38256
+ const mark = await landingMark(wt.path, base);
38257
+ const squash = method === "squash" ? await squashMessage(wt.path, base, suggested, !!message?.trim()) : "";
38258
+ const landed = await landLocally(wt.path, wt.branch, repo.path, repo.defaultBranch, method, squash, w);
38259
+ if (!landed.ok)
38260
+ return { result: landed };
38261
+ const settled = await this.settle(wt, repo, mark, { squash });
38262
+ const did = committed ? "committed and merged" : "merged";
38263
+ return { result: { ...landed, message: `${did} into ${repo.defaultBranch}` }, landed: true, ...settled };
38264
+ });
38265
+ }
38266
+ async landPush(wt, repo, policy, message, suggested, w) {
38267
+ const base = baseOf(repo);
38268
+ const own = hasOwnBranch(wt);
38269
+ const method = policy.merge ?? DEFAULT_MERGE_METHOD;
38270
+ const committed = await this.commitIfDirty(wt, message);
38271
+ if (committed && !committed.ok)
38272
+ return { result: committed };
38273
+ const read = await landingMark(wt.path, base);
38274
+ if (!read)
38275
+ return { result: { ok: false, message: `nothing to land: no commits ahead of ${base}` } };
38276
+ let sha = "";
38277
+ let already = false;
38278
+ let squash = "";
38279
+ const merged = own && method === "rebase" ? await requireLinear(wt.path, base) : null;
38280
+ if (merged)
38281
+ return { result: merged };
38282
+ for (let attempt = 0;; attempt++) {
38283
+ const fetched = await this.fetchBase(wt, repo, w);
38284
+ if (!fetched.ok)
38285
+ return { result: fetched };
38286
+ this.d.tree.invalidate(wt.id);
38287
+ const taken = await takeMainIn(wt.path, base, own, w);
38288
+ if (!taken.ok)
38289
+ return { result: taken };
38290
+ await this.d.headMoved(wt.id);
38291
+ const ahead = await aheadOf(wt.path, base);
38292
+ if (ahead === null)
38293
+ return { result: uncounted(base) };
38294
+ if (ahead === 0) {
38295
+ already = true;
38296
+ sha = await commitOf(wt.path, base) ?? "";
38297
+ break;
38298
+ }
38299
+ squash = method === "squash" ? await squashMessage(wt.path, base, suggested, !!message?.trim()) : "";
38300
+ this.d.tree.invalidate(wt.id);
38301
+ const built = await landingCommit(wt.path, wt.branch, base, method, squash, own, w);
38302
+ await this.d.headMoved(wt.id);
38303
+ if (!built.ok || !built.sha)
38304
+ return { result: built };
38305
+ sha = built.sha;
38306
+ const pushed = await pushLanding(wt.path, sha, repo.defaultBranch, w);
38307
+ if (pushed.ok)
38308
+ break;
38309
+ if (!pushed.moved)
38310
+ return { result: pushed };
38311
+ if (attempt > 0) {
38312
+ return { result: { ok: false, message: `${base} moved twice while this landed; land again` } };
38313
+ }
38314
+ }
38315
+ if (await commitOf(wt.path, base) !== sha)
38316
+ await this.fetchBase(wt, repo, w);
38317
+ const settled = await withRepoLock(repo.path, () => this.settle(wt, repo, read, { squash }));
38318
+ const followed = await this.d.trunk.catchUp(repo.id);
38319
+ const here = followed.ok ? `${repo.defaultBranch} here ${followed.moved ? "pulled it" : "has it"}` : `${repo.defaultBranch} here was left where it is: ${followed.message}`;
38320
+ const how = { merge: "merged into", squash: "squashed onto", rebase: "rebased onto" }[method];
38321
+ const did = already ? `origin's ${repo.defaultBranch} has this work already` : `${committed ? `committed and ${how}` : how} ${repo.defaultBranch} and pushed`;
38322
+ return { result: { ok: true, message: `${did}; ${here}` }, landed: true, ...settled };
38323
+ }
38324
+ async settle(wt, repo, mark, opts = {}) {
38325
+ const noted = await this.noteLand(repo, wt, mark, opts);
38326
+ if (!opts.stay)
38327
+ await this.restartFromMain(wt, baseOf(repo));
38328
+ this.d.tree.invalidateAll();
38329
+ return { mark: noted, held: await this.settleLanded(wt) };
38330
+ }
38331
+ async noteLand(repo, wt, mark, opts = {}) {
38332
+ if (!mark)
38333
+ return;
38334
+ const n = wt.lands?.length ?? 0;
38335
+ const pinned = await git(repo.path, "update-ref", landRef(wt.id, n), mark.tip);
38336
+ if (!pinned.ok) {
38337
+ log.warn(wt.id, `could not keep the commits it landed: ${pinned.err}`);
38338
+ return;
38339
+ }
38340
+ const noted = {
38341
+ ...mark,
38342
+ at: Date.now(),
38343
+ ...opts.pr ? { pr: opts.pr.number } : {},
38344
+ subjects: await this.landedWhat(repo, wt, mark, opts)
37481
38345
  };
38346
+ wt.lands = [...wt.lands ?? [], noted];
38347
+ this.d.state.save();
38348
+ return noted;
37482
38349
  }
37483
- }
37484
- function summarize(r, repoId, transcript) {
37485
- return {
37486
- id: r.worktree.id,
37487
- repoId,
37488
- title: r.worktree.title,
37489
- branch: r.worktree.branch,
37490
- path: r.worktree.path,
37491
- createdAt: r.worktree.createdAt,
37492
- archivedAt: r.archivedAt,
37493
- ...r.prompt ? { prompt: r.prompt } : {},
37494
- restorable: !!r.kept,
37495
- transcript,
37496
- ...r.sessionId ? { sessionId: r.sessionId } : {},
37497
- ...r.kept?.snapshot ? { uncommitted: true, ...r.kept.dirty ? { dirty: r.kept.dirty } : {} } : {},
37498
- ...r.worktree.landed ? { landed: true } : {},
37499
- ...r.worktree.lands?.length ? { lands: r.worktree.lands } : {},
37500
- ...r.cost !== undefined ? { cost: r.cost } : {},
37501
- ...r.auto ? { auto: r.auto } : {}
37502
- };
37503
- }
37504
- function lastUsage(transcript) {
37505
- if (!existsSync36(transcript))
37506
- return null;
37507
- const lines = readFileSync21(transcript, "utf8").split(`
37508
- `);
37509
- for (let i = lines.length - 1;i >= 0; i--) {
37510
- if (!lines[i]?.includes('"usage"'))
37511
- continue;
37512
- try {
37513
- const e = JSON.parse(lines[i]).event;
37514
- if (e?.type === "usage")
37515
- return { used: e.used, size: e.size, ...e.cost !== undefined ? { cost: e.cost } : {} };
37516
- } catch {}
38350
+ async landedWhat(repo, wt, mark, opts) {
38351
+ if (opts.pr?.title)
38352
+ return [opts.pr.title];
38353
+ const subject = opts.squash ? splitMessage(opts.squash).subject : "";
38354
+ if (subject)
38355
+ return [subject];
38356
+ const read = await rangeSubjects(repo.path, mark.base, mark.tip);
38357
+ if (!read)
38358
+ log.warn(wt.id, `could not read what it landed: ${mark.base.slice(0, 7)}..${mark.tip.slice(0, 7)}`);
38359
+ return read ?? [];
37517
38360
  }
37518
- return null;
37519
- }
37520
- function firstPrompt(transcript) {
37521
- if (!existsSync36(transcript))
37522
- return;
37523
- for (const line of readFileSync21(transcript, "utf8").split(`
37524
- `)) {
37525
- if (!line.includes('"user-message"') && !line.includes('"handed"'))
37526
- continue;
37527
- try {
37528
- const event = JSON.parse(line).event;
37529
- if (event?.type === "user-message")
37530
- return event.text.slice(0, 500);
37531
- if (event?.type === "handed")
37532
- return (event.body ?? event.text).slice(0, 500);
37533
- } catch {}
38361
+ async tipMark(wt) {
38362
+ const head = await git(wt.path, "rev-parse", "HEAD");
38363
+ return head.ok ? { base: head.out, tip: head.out } : null;
37534
38364
  }
37535
- return;
37536
- }
37537
- function move(from, to) {
37538
- if (!existsSync36(from))
37539
- return;
37540
- mkdirSync9(dirname14(to), { recursive: true });
37541
- rmSync8(to, { recursive: true, force: true });
37542
- renameSync7(from, to);
37543
- }
37544
-
37545
- // ../daemon/src/worktrees/archivedGit.ts
37546
- class ArchivedGit {
37547
- repoPath;
37548
- kept;
37549
- lands;
37550
- defaultBr;
37551
- constructor(repoPath, kept, lands, defaultBr) {
37552
- this.repoPath = repoPath;
37553
- this.kept = kept;
37554
- this.lands = lands;
37555
- this.defaultBr = defaultBr;
38365
+ async restartFromMain(wt, base) {
38366
+ if (!hasOwnBranch(wt))
38367
+ return;
38368
+ this.d.tree.invalidate(wt.id);
38369
+ const r = await git(wt.path, "reset", "--keep", base);
38370
+ if (!r.ok)
38371
+ log.warn(wt.id, `could not restart ${wt.branch} from ${base}: ${r.err}`);
38372
+ await this.d.tree.refresh(wt.id);
37556
38373
  }
37557
- async tailBase() {
37558
- const r = await git(this.repoPath, "merge-base", this.kept.head, this.defaultBr);
37559
- return r.ok && r.out ? r.out : this.kept.head;
38374
+ async fetchBase(wt, repo, w) {
38375
+ if (!baseIsRemote(repo))
38376
+ return { ok: true, message: `${baseOf(repo)} here is the base` };
38377
+ const r = await withFetchLock(repo.path, () => fetchBase(wt.path, repo, w));
38378
+ this.d.trunk.noteFetch(repo.id, { ok: r.ok, err: r.message });
38379
+ return r;
37560
38380
  }
37561
- async status() {
37562
- const base = await this.tailBase();
37563
- const { head, snapshot } = this.kept;
37564
- const [files, committed] = await Promise.all([
37565
- snapshot ? filesBetween(this.repoPath, head, snapshot) : [],
37566
- base === head ? [] : filesBetween(this.repoPath, base, head)
37567
- ]);
37568
- return { files, committed, head: snapshot ?? head };
38381
+ async prMerged(worktreeId) {
38382
+ const wt = this.d.state.worktree(worktreeId);
38383
+ if (!wt)
38384
+ return { ok: false, message: "that worktree is gone" };
38385
+ const pr = wt.pr ? `PR #${wt.pr.number}` : "the PR";
38386
+ const out = this.ops.get(worktreeId);
38387
+ if (out) {
38388
+ const message = `${shipNoun(out.op)} is already running here`;
38389
+ log.info(worktreeId, `${pr} merged on GitHub; ${message}, landing waits`);
38390
+ return { ok: false, message };
38391
+ }
38392
+ if (prTaken(wt))
38393
+ return { ok: true, message: `${pr} merged; landed already` };
38394
+ log.info(worktreeId, `${pr} merged on GitHub; landing it`);
38395
+ const { result } = await this.landing(wt, "auto", () => this.prMergedOp(wt));
38396
+ if (result.ok)
38397
+ log.info(worktreeId, `landed: ${result.message}`);
38398
+ else
38399
+ log.warn(worktreeId, `landing failed: ${result.message}`);
38400
+ return result;
37569
38401
  }
37570
- async log() {
37571
- const seen = new Set;
37572
- const unseen = (list) => {
37573
- const out = list.filter((c) => !seen.has(c.sha));
37574
- for (const c of out)
37575
- seen.add(c.sha);
37576
- return out;
37577
- };
37578
- const landed = [];
37579
- for (const land of this.lands) {
37580
- const commits = unseen(await logRange(this.repoPath, land.base, land.tip));
37581
- landed.push(commits.map((c) => ({ ...c, landedAt: land.at })));
38402
+ async prMergedOp(wt, carried, w = UNWATCHED) {
38403
+ const repo = this.d.state.requireRepo(wt.repoId);
38404
+ const br = repo.defaultBranch;
38405
+ const base = baseOf(repo);
38406
+ const read = carried === undefined ? await landingMark(wt.path, base) : carried;
38407
+ const pulled = await this.d.trunk.pull(repo.id, w);
38408
+ const past = await this.pastPr(wt, base);
38409
+ const settled = await withRepoLock(repo.path, async () => this.settle(wt, repo, await this.prMark(wt, base, read, past), { pr: wt.pr, stay: !!past }));
38410
+ const took = pulled.ok ? `${br} here ${pulled.moved ? "pulled it" : "has it"}` : `${br} here was left where it is: ${pulled.message}`;
38411
+ const left = past?.count ? `; ${past.count === 1 ? "a commit" : `${past.count} commits`} made since its last push stayed on the branch` : "";
38412
+ const message = `${wt.pr ? `PR #${wt.pr.number} merged; ${took}` : took}${left}`;
38413
+ return { result: { ok: true, message }, landed: true, url: wt.pr?.url, ...settled };
38414
+ }
38415
+ async prMark(wt, base, read, past) {
38416
+ if (prTaken(wt))
38417
+ return null;
38418
+ const own = read ?? await handLanding(wt.path, wt.branch, base, wt.lands?.at(-1)?.tip);
38419
+ const mark = own ?? (wt.pr ? await this.tipMark(wt) : null);
38420
+ return mark && past?.tip ? { ...mark, tip: past.tip } : mark;
38421
+ }
38422
+ async pastPr(wt, base) {
38423
+ if (!hasOwnBranch(wt))
38424
+ return null;
38425
+ const named = [wt.pr?.head, await remoteHead(wt.path, wt.branch)];
38426
+ const known = [];
38427
+ for (const sha of named)
38428
+ if (sha && !known.includes(sha) && await commitOf(wt.path, sha))
38429
+ known.push(sha);
38430
+ const read = await git(wt.path, "rev-list", "HEAD", "--not", base, ...known);
38431
+ if (!read.ok) {
38432
+ log.warn(wt.id, `could not read what ${wt.branch} holds past its PR: ${read.err}`);
38433
+ return { count: 0 };
38434
+ }
38435
+ let past = read.out.split(`
38436
+ `).filter(Boolean);
38437
+ for (const sha of known) {
38438
+ if (past.length === 0)
38439
+ break;
38440
+ const fresh = await git(wt.path, "rev-list", "--cherry-pick", "--right-only", `${sha}...HEAD`);
38441
+ if (!fresh.ok)
38442
+ continue;
38443
+ const kept = new Set(fresh.out.split(`
38444
+ `));
38445
+ past = past.filter((c) => kept.has(c));
37582
38446
  }
37583
- const base = await this.tailBase();
37584
- const tail = base === this.kept.head ? [] : unseen(await logRange(this.repoPath, base, this.kept.head));
37585
- return [...tail, ...landed.reverse().flat()];
38447
+ const oldest = past.at(-1);
38448
+ if (!oldest)
38449
+ return null;
38450
+ const tip = await git(wt.path, "rev-parse", "--verify", "--quiet", `${oldest}^`);
38451
+ return { count: past.length, ...tip.ok && tip.out ? { tip: tip.out } : {} };
37586
38452
  }
37587
- commitFiles(sha) {
37588
- return commitFiles(this.repoPath, sha);
38453
+ async sync(worktreeId, by = "press") {
38454
+ const r = this.d.readable(worktreeId);
38455
+ if (!r)
38456
+ throw new UserError("that worktree is gone");
38457
+ if (r.wt && isMain(r.wt))
38458
+ throw new UserError("sync from a worktree, not main");
38459
+ if (r.wt && isProvisional(r.wt))
38460
+ throw new UserError("the new worktree already sits on main; it follows it on its own");
38461
+ if (!r.branch)
38462
+ throw new UserError(`${r.name} is detached: check out a branch in it first`);
38463
+ if (r.locked)
38464
+ throw new UserError(`${r.name} is held by another tool`);
38465
+ const repo = this.d.state.requireRepo(r.repoId);
38466
+ const own = r.wt ? hasOwnBranch(r.wt) : false;
38467
+ const carry = !!r.wt;
38468
+ const busy = isBusy(this.d.runtime.agentFor(worktreeId)?.status);
38469
+ if (carry && busy && (await statusFiles(r.path)).length > 0) {
38470
+ this.syncQueued.add(worktreeId);
38471
+ if (by === "press")
38472
+ this.d.stampPrompted(worktreeId);
38473
+ this.d.hub.emit("worktreesChanged");
38474
+ return { result: { ok: true, message: `${r.name} syncs from ${r.base} once the turn ends` }, base: r.base };
38475
+ }
38476
+ const w = r.wt ? this.landWatch(r.wt) : UNWATCHED;
38477
+ const result = await this.ship(worktreeId, "sync-main", by, async () => {
38478
+ if (r.wt) {
38479
+ const fetched = await this.fetchBase(r.wt, repo, w);
38480
+ if (!fetched.ok)
38481
+ return fetched;
38482
+ }
38483
+ this.d.tree.invalidate(worktreeId);
38484
+ const taken = await withRepoLock(repo.path, () => takeMainIn(r.path, r.base, own, w, carry));
38485
+ if (taken.ok) {
38486
+ await this.d.headMoved(worktreeId);
38487
+ if (r.wt)
38488
+ await this.verdictSurvives(r.wt, { dropMessage: false });
38489
+ }
38490
+ return taken;
38491
+ });
38492
+ return { result: r.wt ? this.fixShip(r.wt, result, r.base) : result, base: r.base };
37589
38493
  }
37590
- async file(path, ref) {
37591
- if (ref)
37592
- return fileAtCommit(this.repoPath, ref, path);
37593
- const base = await this.tailBase();
37594
- const [before, after] = await Promise.all([
37595
- gitRaw(this.repoPath, "show", `${base}:${path}`),
37596
- gitRaw(this.repoPath, "show", `${this.kept.snapshot ?? this.kept.head}:${path}`)
37597
- ]);
37598
- return { before: before.ok ? before.out : "", after: after.ok ? after.out : "" };
38494
+ unqueueSync(worktreeId) {
38495
+ if (!this.syncQueued.delete(worktreeId))
38496
+ return;
38497
+ this.d.hub.emit("worktreesChanged");
37599
38498
  }
37600
- }
37601
-
37602
- // ../daemon/src/worktrees/landSubjects.ts
37603
- function unfilled(mark) {
37604
- return mark.subjects === undefined;
37605
- }
37606
- async function fillLandMarks(jobs, read, width = 8) {
37607
- let next = 0;
37608
- const worker = async () => {
37609
- while (next < jobs.length) {
37610
- const job = jobs[next++];
37611
- if (!job)
37612
- return;
37613
- job.mark.subjects = await read(job.cwd, job.mark) ?? [];
38499
+ syncAfterTurn(worktreeId) {
38500
+ if (!this.syncQueued.has(worktreeId))
38501
+ return;
38502
+ if (this.ops.has(worktreeId))
38503
+ return;
38504
+ this.syncQueued.delete(worktreeId);
38505
+ const release = this.d.runtime.agentFor(worktreeId)?.hold();
38506
+ this.d.hub.emit("worktreesChanged");
38507
+ fireAndForget(worktreeId, (async () => {
38508
+ try {
38509
+ const { result } = await this.sync(worktreeId, "auto");
38510
+ if (!result.ok && !result.asked)
38511
+ this.d.hub.emit("failed", worktreeId, result.message);
38512
+ } catch (e) {
38513
+ this.d.hub.emit("failed", worktreeId, e instanceof Error ? e.message : String(e));
38514
+ } finally {
38515
+ release?.();
38516
+ }
38517
+ })(), "sync after the turn");
38518
+ }
38519
+ async pull(worktreeId) {
38520
+ const wt = this.d.state.requireWorktree(worktreeId);
38521
+ if (!isMain(wt))
38522
+ throw new UserError("pull on main; a worktree syncs from main instead");
38523
+ const repo = this.d.state.requireRepo(wt.repoId);
38524
+ const w = { step: (name) => this.step(worktreeId, name), git: UNWATCHED.git };
38525
+ return this.ship(worktreeId, "pull-main", "auto", async () => {
38526
+ const result = await this.d.trunk.pull(repo.id, w);
38527
+ if (result.ok && result.moved)
38528
+ await this.d.recountAll();
38529
+ return result;
38530
+ });
38531
+ }
38532
+ async commit(worktreeId, message) {
38533
+ const wt = this.d.state.requireWorktree(worktreeId);
38534
+ const m = message.trim();
38535
+ if (!m)
38536
+ throw new UserError("commit message required");
38537
+ const base = baseOf(this.d.state.requireRepo(wt.repoId));
38538
+ return this.fixShip(wt, await this.ship(wt.id, "commit", "press", () => this.commitRecorded(wt, m)), base);
38539
+ }
38540
+ async afterRead(r, wt, core) {
38541
+ const isMain = wt.kind === "main";
38542
+ if (core.branch)
38543
+ await this.followBranch(wt, core.branch);
38544
+ const { files, head } = core;
38545
+ const ahead = core.ahead ?? 0;
38546
+ if (!isMain && hasOwnBranch(wt) && files.length === 0 && ahead === 0 && head && this.handChecked.get(wt.id) !== head) {
38547
+ this.handChecked.set(wt.id, head);
38548
+ const mark = await handLanding(r.path, wt.branch, r.base, wt.lands?.at(-1)?.tip);
38549
+ if (mark) {
38550
+ const merged = prPending(wt) && (wt.pr?.state === "merged" || !!wt.pr?.head && await reaches(r.path, wt.pr.head));
38551
+ const out = this.ops.get(wt.id);
38552
+ if (out) {
38553
+ this.handChecked.delete(wt.id);
38554
+ this.handWaits.add(wt.id);
38555
+ log.info(wt.id, `landed on ${r.base} outside toyon; ${shipNoun(out.op)} is already running here, landing waits`);
38556
+ return false;
38557
+ }
38558
+ const pr = prPending(wt) ? wt.pr : undefined;
38559
+ if (!pr) {
38560
+ await this.landedByHand(wt, mark);
38561
+ return true;
38562
+ }
38563
+ if (merged) {
38564
+ const { queue: _had, queued: _was, ...rest } = pr;
38565
+ if (pr.state !== "merged")
38566
+ this.setPr(wt.id, { ...rest, state: "merged" });
38567
+ fireAndForget(wt.id, this.prMerged(wt.id), "pr landing");
38568
+ } else {
38569
+ log.info(wt.id, `the work is on ${r.base}; PR #${pr.number} lands it once GitHub says it merged`);
38570
+ this.d.hub.emit("prDue", wt.id);
38571
+ }
38572
+ if (this.ops.has(wt.id))
38573
+ return false;
38574
+ }
37614
38575
  }
37615
- };
37616
- await Promise.all(Array.from({ length: Math.min(width, jobs.length) }, worker));
38576
+ if (!isMain && head) {
38577
+ const facts = { head, ahead };
38578
+ if (prTaken(wt) && movedPastLand(wt, facts))
38579
+ this.setPr(wt.id, undefined);
38580
+ await this.refreshLanded(wt, facts);
38581
+ }
38582
+ const landing = wt.landing;
38583
+ if (landing && landing.check !== "pending") {
38584
+ const moved = landing.fingerprint !== await this.fingerprint(r.path);
38585
+ if (wt.landing === landing && moved !== !!landing.stale) {
38586
+ const { stale: _was, ...rest } = landing;
38587
+ this.setLanding(wt.id, moved ? { ...rest, stale: true } : rest);
38588
+ }
38589
+ }
38590
+ return false;
38591
+ }
37617
38592
  }
37618
38593
 
37619
38594
  // ../daemon/src/worktrees/spare.ts
37620
38595
  import { existsSync as existsSync37 } from "fs";
37621
- import { basename as basename14, join as join33 } from "path";
38596
+ import { basename as basename14, join as join34 } from "path";
37622
38597
  class SparePool {
37623
38598
  d;
37624
38599
  inflight = new Map;
@@ -37686,11 +38661,11 @@ class SparePool {
37686
38661
  return this.record(repoId, repo).then((wt) => this.warmUp(wt, repo, {}));
37687
38662
  }
37688
38663
  async record(repoId, repo) {
37689
- const { id, dir } = freeSlot(join33(this.d.paths.worktreesDir, repo.name));
38664
+ const { id, dir } = freeSlot(join34(this.d.paths.worktreesDir, repo.name));
37690
38665
  const wt = {
37691
38666
  id,
37692
38667
  repoId,
37693
- path: join33(this.d.paths.worktreesDir, repo.name, dir),
38668
+ path: join34(this.d.paths.worktreesDir, repo.name, dir),
37694
38669
  branch: repo.defaultBranch,
37695
38670
  kind: "spare",
37696
38671
  phase: "reserved",
@@ -37882,7 +38857,7 @@ class SparePool {
37882
38857
  // ../daemon/src/worktrees/treeState.ts
37883
38858
  import { watch as watch2 } from "fs";
37884
38859
  import { stat as stat7 } from "fs/promises";
37885
- import { join as join34 } from "path";
38860
+ import { join as join35 } from "path";
37886
38861
  var EDIT_DELAY_MS = 150;
37887
38862
  var SHELL_QUIET_MS2 = 1500;
37888
38863
  var WATCH_DELAY_MS = 300;
@@ -38143,11 +39118,10 @@ class TreeState {
38143
39118
  });
38144
39119
  w.unref?.();
38145
39120
  this.watchers.set(id, { w, ignore });
38146
- fireAndForget(id, git(r.path, "ls-files", "-o", "-i", "--exclude-standard", "--directory").then((res) => {
39121
+ fireAndForget(id, gitRaw(r.path, "ls-files", "-z", "-o", "-i", "--exclude-standard", "--directory").then((res) => {
38147
39122
  if (!res.ok)
38148
39123
  return;
38149
- for (const line of res.out.split(`
38150
- `)) {
39124
+ for (const line of nulSplit(res.out)) {
38151
39125
  const dir = line.replace(/\/$/, "");
38152
39126
  if (dir)
38153
39127
  ignore.add(dir);
@@ -38265,7 +39239,7 @@ class TreeState {
38265
39239
  xy: f.xy,
38266
39240
  ...entries.folded.has(f.path) ? { add: f.add, del: f.del } : {}
38267
39241
  }));
38268
- const stats = await Promise.all(files.slice(0, SIGNATURE_STATS).map((f) => stat7(join34(r.path, f.path)).then((s) => `${s.mtimeMs}:${s.size}`, () => "-")));
39242
+ const stats = await Promise.all(files.slice(0, SIGNATURE_STATS).map((f) => stat7(join35(r.path, f.path)).then((s) => `${s.mtimeMs}:${s.size}`, () => "-")));
38269
39243
  const t0 = performance.now();
38270
39244
  const counts = { ...ab, ...unpushed === null ? {} : { unpushed } };
38271
39245
  const signature = String(Bun.hash([
@@ -38588,7 +39562,6 @@ function stopOf(prev, next) {
38588
39562
  var LIST_FLOOR_MS = 1e4;
38589
39563
  var LOCAL_CONFIG_FILES = [".env", ".env.local", ".env.development", ".env.development.local", ".dev.vars"];
38590
39564
  var BASE_IGNORE = "base .gitignore";
38591
- var REFUSAL_CHARS = 400;
38592
39565
  async function deleteTree(path) {
38593
39566
  try {
38594
39567
  await rm6(path, { recursive: true, force: true });
@@ -38616,16 +39589,13 @@ var idsOf = (rows, trunks) => [
38616
39589
  ...Object.values(trunks).map((t) => t.id)
38617
39590
  ];
38618
39591
  var heldBy2 = (found) => `${found.name} is held by another tool${found.lockReason ? `: ${found.lockReason}` : ""}`;
38619
- var commandLine = (args) => args.map((a) => /^[\w./:=@,+-]+$/.test(a) ? a : JSON.stringify(a.split(`
38620
- `, 1)[0])).join(" ");
38621
39592
 
38622
39593
  class WorktreeService {
38623
39594
  d;
38624
39595
  spare;
38625
39596
  runs;
38626
- ops = new Map;
38627
- syncQueued = new Set;
38628
39597
  trunk;
39598
+ ship;
38629
39599
  archive;
38630
39600
  archiving = new Map;
38631
39601
  restoring = new Map;
@@ -38633,7 +39603,6 @@ class WorktreeService {
38633
39603
  arriving = new Set;
38634
39604
  firstPrompt = new Map;
38635
39605
  tree;
38636
- handChecked = new Map;
38637
39606
  naming = new Set;
38638
39607
  discoverCache = new Map;
38639
39608
  relisting = new Map;
@@ -38663,6 +39632,22 @@ class WorktreeService {
38663
39632
  afterCore: (r, core) => this.afterCore(r, core),
38664
39633
  rowsOf: (repoId) => this.rowsOf(repoId)
38665
39634
  });
39635
+ this.ship = new Ship({
39636
+ state: d.state,
39637
+ hub: d.hub,
39638
+ runtime: d.runtime,
39639
+ fix: d.fix,
39640
+ runs: this.runs,
39641
+ tree: this.tree,
39642
+ trunk: this.trunk,
39643
+ readable: (id) => this.readable(id),
39644
+ stampPrompted: (id) => this.stampPrompted(id),
39645
+ headMoved: (id) => this.headMoved(id),
39646
+ recountAll: () => this.recountAll(),
39647
+ watch: d.watch,
39648
+ fingerprint: d.fingerprint,
39649
+ message: d.message
39650
+ });
38666
39651
  this.spare = new SparePool({
38667
39652
  state: d.state,
38668
39653
  hub: d.hub,
@@ -38711,7 +39696,7 @@ class WorktreeService {
38711
39696
  this.scheduleNaming(wt, prompt);
38712
39697
  }
38713
39698
  if (turn.end !== "asking")
38714
- this.syncAfterTurn(worktreeId);
39699
+ this.ship.syncAfterTurn(worktreeId);
38715
39700
  if (turn.end !== "done" && turn.end !== "stopped")
38716
39701
  return;
38717
39702
  for (const p of d.runtime.get(worktreeId)?.procs?.states() ?? []) {
@@ -38790,8 +39775,8 @@ class WorktreeService {
38790
39775
  if (!await isGitRepo(repo.path)) {
38791
39776
  throw new UserError(`git no longer reads ${repo.name} as a checkout, so there is nothing to start a worktree from. If \`git config core.bare\` says true there, \`git config --unset core.bare\` puts it back.`);
38792
39777
  }
38793
- const { id, dir } = freeSlot(join35(this.d.paths.worktreesDir, repo.name));
38794
- const wtPath = join35(this.d.paths.worktreesDir, repo.name, dir);
39778
+ const { id, dir } = freeSlot(join36(this.d.paths.worktreesDir, repo.name));
39779
+ const wtPath = join36(this.d.paths.worktreesDir, repo.name, dir);
38795
39780
  const branch = `toyon/${dir}`;
38796
39781
  await withRepoLock(repo.path, () => gitOrThrow(repo.path, "worktree", "add", "--no-track", "-b", branch, wtPath, baseOf(repo)));
38797
39782
  const wt = {
@@ -38883,9 +39868,8 @@ class WorktreeService {
38883
39868
  `).indexOf(sha);
38884
39869
  return i < 0 ? null : `stash@{${i}}`;
38885
39870
  };
38886
- const carried = await git(repo.path, "ls-tree", "-r", "--name-only", `${sha}^3`);
38887
- const restored = (carried.ok ? carried.out.split(`
38888
- `).filter(Boolean) : []).filter((p) => !existsSync38(join35(wt.path, p)));
39871
+ const carried = await gitRaw(repo.path, "ls-tree", "-r", "-z", "--name-only", `${sha}^3`);
39872
+ const restored = (carried.ok ? nulSplit(carried.out) : []).filter((p) => !existsSync38(join36(wt.path, p)));
38889
39873
  const applied = await git(wt.path, "stash", "apply", sha);
38890
39874
  if (applied.ok) {
38891
39875
  const ref = await entry();
@@ -38922,9 +39906,9 @@ class WorktreeService {
38922
39906
  const branch = kind === "pr" ? `pr/${number}` : ref;
38923
39907
  const title = kind === "pr" ? `pr-${number}` : cleanTitle2(ref) || "branch";
38924
39908
  let slug = branchSlug(title) || "branch";
38925
- if (existsSync38(join35(this.d.paths.worktreesDir, repo.name, slug)))
39909
+ if (existsSync38(join36(this.d.paths.worktreesDir, repo.name, slug)))
38926
39910
  slug = `${slug}-${shortId().slice(0, 4)}`;
38927
- const wtPath = join35(this.d.paths.worktreesDir, repo.name, slug);
39911
+ const wtPath = join36(this.d.paths.worktreesDir, repo.name, slug);
38928
39912
  await withRepoLock(repo.path, async () => {
38929
39913
  const out = (await listWorktrees(repo.path)).find((w) => w.branch === branch);
38930
39914
  if (out)
@@ -39293,8 +40277,11 @@ class WorktreeService {
39293
40277
  this.d.drafts?.drop(worktreeId);
39294
40278
  }
39295
40279
  async takeDown(wt, archive, reason) {
40280
+ const out = this.ship.shippingOf(wt.id);
40281
+ if (out)
40282
+ throw new UserError(`${shipNoun(out.op)} is still running in ${wt.title}; try again once it ends`);
39296
40283
  this.going.add(wt.id);
39297
- this.syncQueued.delete(wt.id);
40284
+ this.ship.forget(wt.id);
39298
40285
  try {
39299
40286
  return await this.removeAndRecord(wt, archive, reason);
39300
40287
  } finally {
@@ -39308,7 +40295,7 @@ class WorktreeService {
39308
40295
  await this.teardown(wt, repo);
39309
40296
  const kept = await withRepoLock(repo.path, async () => {
39310
40297
  const k = archive ? await this.keep(repo, wt) : null;
39311
- if (archive && !k)
40298
+ if (archive && (!k || k.lost))
39312
40299
  throw new UserError(`could not keep ${wt.title}'s work, so it was left in place`);
39313
40300
  if (archive)
39314
40301
  this.keepPlans(wt);
@@ -39367,12 +40354,12 @@ class WorktreeService {
39367
40354
  }
39368
40355
  }
39369
40356
  async keep(repo, wt) {
39370
- const index = join35(this.d.paths.archiveDir, `${wt.id}.index`);
40357
+ const index = join36(this.d.paths.archiveDir, `${wt.id}.index`);
39371
40358
  const kept = await keepState(repo.path, wt.path, archiveRef(wt.id), index) ?? await this.keepUnlinked(repo, wt);
39372
40359
  if (!kept)
39373
40360
  log.warn(wt.id, "could not keep its git state");
39374
40361
  else if (kept.lost)
39375
- log.warn(wt.id, "could not keep its uncommitted changes: only its commits are archived");
40362
+ log.warn(wt.id, "could not keep its uncommitted changes");
39376
40363
  return kept;
39377
40364
  }
39378
40365
  async keepUnlinked(repo, wt) {
@@ -39404,10 +40391,10 @@ class WorktreeService {
39404
40391
  log.warn(wt.id, `its directory could not be deleted and stays at ${wt.path}`);
39405
40392
  }
39406
40393
  plansScratch(worktreeId) {
39407
- return join35(this.d.paths.archiveDir, `${worktreeId}.plans`);
40394
+ return join36(this.d.paths.archiveDir, `${worktreeId}.plans`);
39408
40395
  }
39409
40396
  keepPlans(wt) {
39410
- const from = join35(wt.path, PLANS_DIR);
40397
+ const from = join36(wt.path, PLANS_DIR);
39411
40398
  if (!existsSync38(from))
39412
40399
  return;
39413
40400
  try {
@@ -39555,7 +40542,7 @@ class WorktreeService {
39555
40542
  }
39556
40543
  archivedAttachment(archiveId, file) {
39557
40544
  const files = this.archive.chatFiles(archiveId);
39558
- return files && isAttachmentFile(file) ? join35(files.attachments, file) : null;
40545
+ return files && isAttachmentFile(file) ? join36(files.attachments, file) : null;
39559
40546
  }
39560
40547
  archivedGit(archiveId) {
39561
40548
  const rec = this.archive.get(archiveId);
@@ -39616,7 +40603,7 @@ class WorktreeService {
39616
40603
  throw new UserError(`${old.title} was archived without its commits, so there is nothing to restore`);
39617
40604
  if (rec.auto)
39618
40605
  log.info(old.id, `restoring a worktree that archived itself (${rec.auto})`);
39619
- const path = existsSync38(old.path) ? join35(dirname15(old.path), freeSlot(dirname15(old.path)).dir) : old.path;
40606
+ const path = existsSync38(old.path) ? join36(dirname15(old.path), freeSlot(dirname15(old.path)).dir) : old.path;
39620
40607
  const wt = {
39621
40608
  ...old,
39622
40609
  repoId: repo.id,
@@ -39656,7 +40643,7 @@ class WorktreeService {
39656
40643
  run.finish(0);
39657
40644
  this.arriving.delete(wt.id);
39658
40645
  wt.branch = branch;
39659
- move(chat.plans, join35(path, PLANS_DIR));
40646
+ move(chat.plans, join36(path, PLANS_DIR));
39660
40647
  const dropped = await git(repo.path, "update-ref", "-d", archiveRef(old.id));
39661
40648
  if (!dropped.ok)
39662
40649
  log.warn(old.id, `could not drop its archive ref: ${dropped.err}`);
@@ -39795,6 +40782,9 @@ class WorktreeService {
39795
40782
  throw new UserError("worktrees must belong to one repo");
39796
40783
  if (isBusy(this.d.runtime.agentFor(w.id)?.status))
39797
40784
  throw new UserError(`${w.title}'s agent is mid-turn; stop it first`);
40785
+ const out = this.ship.shippingOf(w.id);
40786
+ if (out)
40787
+ throw new UserError(`${shipNoun(out.op)} is still running in ${w.title}; try again once it ends`);
39798
40788
  }
39799
40789
  for (const w of all) {
39800
40790
  if ((await statusFiles(w.path)).length > 0)
@@ -39817,7 +40807,7 @@ class WorktreeService {
39817
40807
  }
39818
40808
  for (const w of sources)
39819
40809
  await this.discardWorktree(w.id);
39820
- await this.refreshLanded(target);
40810
+ await this.ship.refreshLanded(target);
39821
40811
  await this.headMoved(target.id);
39822
40812
  return { target, grafted: sources.map((w) => w.title) };
39823
40813
  }
@@ -39857,7 +40847,7 @@ class WorktreeService {
39857
40847
  }
39858
40848
  }
39859
40849
  async mirrorBaseIgnore(repo) {
39860
- const src = join35(repo.path, ".gitignore");
40850
+ const src = join36(repo.path, ".gitignore");
39861
40851
  const tracked = existsSync38(src) && (await git(repo.path, "ls-files", "--", ".gitignore")).out !== "";
39862
40852
  const mirror = existsSync38(src) && !tracked;
39863
40853
  const lines = mirror ? readFileSync22(src, "utf8").split(`
@@ -39875,8 +40865,8 @@ class WorktreeService {
39875
40865
  await this.d.cache.restore(wt, repo, (line) => this.d.hub.emit("log", wt.id, "setup", line));
39876
40866
  run.finish(0);
39877
40867
  }
39878
- const srcNm = join35(depsSource, "node_modules");
39879
- const dstNm = join35(wt.path, "node_modules");
40868
+ const srcNm = join36(depsSource, "node_modules");
40869
+ const dstNm = join36(wt.path, "node_modules");
39880
40870
  if (existsSync38(srcNm) && !existsSync38(dstNm)) {
39881
40871
  const run = this.runs.begin(wt.id, "setup", { timeoutMs: ceiling, stage: "copying dependencies" });
39882
40872
  const r = await cloneTree(srcNm, dstNm);
@@ -39887,10 +40877,10 @@ class WorktreeService {
39887
40877
  log.warn(wt.id, "could not copy node_modules from the base checkout", r.error);
39888
40878
  }
39889
40879
  for (const f of LOCAL_CONFIG_FILES) {
39890
- const src = join35(depsSource, f);
39891
- if (!existsSync38(src) || existsSync38(join35(wt.path, f)))
40880
+ const src = join36(depsSource, f);
40881
+ if (!existsSync38(src) || existsSync38(join36(wt.path, f)))
39892
40882
  continue;
39893
- const r = await run("cp", [src, join35(wt.path, f)], wt.path);
40883
+ const r = await run("cp", [src, join36(wt.path, f)], wt.path);
39894
40884
  if (r.ok)
39895
40885
  this.d.hub.emit("log", wt.id, "setup", `copied ${f}`);
39896
40886
  else
@@ -39913,188 +40903,31 @@ class WorktreeService {
39913
40903
  await this.d.runtime.start(wt, repo);
39914
40904
  }
39915
40905
  setPr(worktreeId, pr) {
39916
- const wt = this.d.state.worktree(worktreeId);
39917
- if (!wt || wt.pr === undefined && pr === undefined)
39918
- return;
39919
- if (pr)
39920
- wt.pr = pr;
39921
- else
39922
- delete wt.pr;
39923
- this.d.state.save();
39924
- this.d.hub.emit("worktreesChanged");
39925
- }
39926
- async refreshLanded(wt, facts) {
39927
- if (wt.kind === "main")
39928
- return;
39929
- const f = facts ?? await this.landedFacts(wt);
39930
- if (f)
39931
- this.setLanded(wt, landedNow(wt, f));
39932
- }
39933
- async settleLanded(wt) {
39934
- const repo = this.d.state.repo(wt.repoId);
39935
- const f = wt.kind === "main" || !repo ? null : await this.landedFacts(wt);
39936
- delete wt.landing;
39937
- let held;
39938
- if (f && repo) {
39939
- held = notLandedWhy(wt, f, baseOf(repo));
39940
- if (held) {
39941
- delete wt.landed;
39942
- log.warn(wt.id, `landed, but not marked landed: ${held}`);
39943
- } else
39944
- wt.landed = true;
39945
- }
39946
- this.d.state.save();
39947
- this.d.hub.emit("worktreesChanged");
39948
- return held;
39949
- }
39950
- async landedFacts(wt) {
39951
- const repo = this.d.state.repo(wt.repoId);
39952
- if (!repo)
39953
- return null;
39954
- try {
39955
- const [head, ab] = await Promise.all([git(wt.path, "rev-parse", "HEAD"), aheadBehind(wt.path, baseOf(repo))]);
39956
- if (!head.ok)
39957
- return null;
39958
- return { head: head.out, ahead: ab.ahead };
39959
- } catch (e) {
39960
- log.warn(wt.id, "could not read the tree for the landed mark", e);
39961
- return null;
39962
- }
39963
- }
39964
- setLanded(wt, landed) {
39965
- if (!!wt.landed === landed)
39966
- return;
39967
- if (landed)
39968
- wt.landed = true;
39969
- else
39970
- delete wt.landed;
39971
- this.d.state.save();
39972
- this.d.hub.emit("worktreesChanged");
39973
- }
39974
- fingerprint(path) {
39975
- return (this.d.fingerprint ?? treeFingerprint)(path);
40906
+ this.ship.setPr(worktreeId, pr);
39976
40907
  }
39977
40908
  setLanding(worktreeId, landing) {
39978
- const wt = this.d.state.worktree(worktreeId);
39979
- if (!wt || wt.landing === undefined && landing === undefined)
39980
- return;
39981
- if (landing)
39982
- wt.landing = landing;
39983
- else
39984
- delete wt.landing;
39985
- this.d.state.save();
39986
- this.d.hub.emit("worktreesChanged");
40909
+ this.ship.setLanding(worktreeId, landing);
39987
40910
  }
39988
- landable(worktreeId, verb) {
39989
- const pair = this.d.state.requireWorktreeWithRepo(worktreeId);
39990
- if (isMain(pair.wt))
39991
- throw new UserError(`${verb} from a worktree, not main`);
39992
- if (!canLand(pair.wt))
39993
- throw new UserError(`${pair.wt.title} is a PR under review; it lands upstream, not here`);
39994
- return pair;
40911
+ land(worktreeId, message) {
40912
+ return this.ship.land(worktreeId, message);
39995
40913
  }
39996
- commitMessage(wt, typed) {
39997
- const m = typed?.trim();
39998
- if (m)
39999
- return m;
40000
- const s = wt.landing?.subject;
40001
- if (s)
40002
- return wt.landing?.body ? `${s}
40003
-
40004
- ${wt.landing.body}` : s;
40005
- throw new UserError("no commit message yet: write one in the changes panel");
40914
+ prMerged(worktreeId) {
40915
+ return this.ship.prMerged(worktreeId);
40006
40916
  }
40007
- async commitIfDirty(wt, typed) {
40008
- if ((await statusFiles(wt.path)).length === 0)
40009
- return null;
40010
- return this.commitRecorded(wt, this.commitMessage(wt, typed));
40011
- }
40012
- async commitRecorded(wt, message) {
40013
- const result = await commitWorktree(wt.path, message, this.landWatch(wt));
40014
- if (result.ok) {
40015
- await this.verdictSurvives(wt, { dropMessage: true });
40016
- await this.headMoved(wt.id);
40017
- }
40018
- return result;
40917
+ sync(worktreeId, by = "press") {
40918
+ return this.ship.sync(worktreeId, by);
40019
40919
  }
40020
- landWatch(wt) {
40021
- const repo = this.d.state.requireRepo(wt.repoId);
40022
- return {
40023
- step: (name) => this.step(wt.id, name),
40024
- git: async (cwd, args) => {
40025
- const where = cwd === wt.path ? "git" : `git -C ${cwd}`;
40026
- const commit = args[0] === "commit";
40027
- const ceiling = commit ? timeoutFor(repo.config, "commit") : STEP_TIMEOUT_MS;
40028
- const handle = commit ? this.runs.begin(wt.id, "commit", { timeoutMs: ceiling }) : null;
40029
- const run = (onText, signal) => stepRun(cwd, args, onText, signal, {
40030
- timeoutMs: ceiling,
40031
- ...handle ? { onSpawn: handle.spawned } : {},
40032
- onHook: (hook) => handle?.stage(hook ? `${hook} hook` : undefined)
40033
- });
40034
- const watched = async () => {
40035
- if (!this.d.watch)
40036
- return run(() => {});
40037
- const command = `${where} ${commandLine(args)}`;
40038
- try {
40039
- return { ...await this.d.watch(wt.id, command, run), command };
40040
- } catch (e) {
40041
- log.warn(wt.id, `landing step runs unwatched: ${e instanceof Error ? e.message : e}`);
40042
- return run(() => {});
40043
- }
40044
- };
40045
- const r = await watched();
40046
- handle?.finish(r.exit);
40047
- return r;
40048
- }
40049
- };
40920
+ unqueueSync(worktreeId) {
40921
+ this.ship.unqueueSync(worktreeId);
40050
40922
  }
40051
- async verdictSurvives(wt, opts) {
40052
- const l = wt.landing;
40053
- if (!l || l.check === "pending")
40054
- return;
40055
- const { subject, body, unanswered, ...kept } = l;
40056
- const message = opts.dropMessage ? {} : { ...subject ? { subject } : {}, ...body ? { body } : {}, ...unanswered ? { unanswered } : {} };
40057
- this.setLanding(wt.id, { ...kept, ...message, fingerprint: await this.fingerprint(wt.path) });
40923
+ pull(worktreeId) {
40924
+ return this.ship.pull(worktreeId);
40058
40925
  }
40059
- async landedByHand(wt, mark) {
40060
- const repo = this.d.state.requireRepo(wt.repoId);
40061
- log.info(wt.id, `landed on ${baseOf(repo)} outside toyon: ${mark.base.slice(0, 7)}..${mark.tip.slice(0, 7)}`);
40062
- await this.noteLand(repo, wt, mark);
40063
- await this.restartFromMain(wt, baseOf(repo));
40064
- await this.settleLanded(wt);
40065
- this.d.hub.emit("landed", wt.id);
40926
+ commit(worktreeId, message) {
40927
+ return this.ship.commit(worktreeId, message);
40066
40928
  }
40067
- async land(worktreeId, message) {
40068
- const { wt, repo } = this.landable(worktreeId, "land");
40069
- const out = await this.ship(wt.id, "land", async () => {
40070
- await this.followBranch(wt);
40071
- const moved = await this.movedMidTurn(wt);
40072
- if (moved)
40073
- return { result: moved };
40074
- return this.landOp(wt, repo, message);
40075
- });
40076
- if (out.result.ok && out.landed) {
40077
- this.noteLanded(wt, out.result.message, out);
40078
- this.markPrompted(wt.id);
40079
- }
40080
- return { result: this.fixShip(wt, out.result, baseOf(repo)) };
40081
- }
40082
- fixShip(wt, result, base) {
40083
- if (result.ok)
40084
- return result;
40085
- if (result.hook?.name === "commit-msg") {
40086
- this.d.hub.emit("messageRefused", wt.id, result.hook.text.trim().slice(-REFUSAL_CHARS));
40087
- return { ...result, message: "the commit-msg hook refused the message: another is being written" };
40088
- }
40089
- const { hook } = result;
40090
- const failure = hook ? { kind: "hook", hook: hook.name, toolId: hook.toolId, command: hook.command, text: hook.text } : result.conflict ? {
40091
- kind: "conflict",
40092
- base,
40093
- how: result.conflict,
40094
- ...result.step ? { step: result.step } : {},
40095
- ...result.carried ? { carried: result.carried } : {}
40096
- } : null;
40097
- return failure && this.d.fix.report(wt.id, failure) ? { ...result, asked: true } : result;
40929
+ shippingOf(id) {
40930
+ return this.ship.shippingOf(id);
40098
40931
  }
40099
40932
  async fixPreview(worktreeId, clientId) {
40100
40933
  const wt = this.d.state.requireWorktree(worktreeId);
@@ -40113,439 +40946,6 @@ ${wt.landing.body}` : s;
40113
40946
  ...isProvisional(wt) ? { worktreeId } : {}
40114
40947
  });
40115
40948
  }
40116
- async movedMidTurn(wt) {
40117
- if (!isBusy(this.d.runtime.agentFor(wt.id)?.status))
40118
- return null;
40119
- const l = wt.landing;
40120
- if (!l || l.fingerprint === await this.fingerprint(wt.path))
40121
- return null;
40122
- return { ok: false, message: "the agent has changed files since the check: land once its turn ends" };
40123
- }
40124
- noteLanded(wt, message, o) {
40125
- this.d.runtime.ensureAgent(wt).agent.note({
40126
- type: "landed",
40127
- message,
40128
- ...o.mark ? { mark: o.mark } : {},
40129
- ...o.url ? { url: o.url } : {},
40130
- ...o.held ? { held: o.held } : {},
40131
- ts: Date.now()
40132
- });
40133
- this.d.hub.emit("landed", wt.id);
40134
- }
40135
- async followBranch(wt, current) {
40136
- if (wt.kind === "main")
40137
- return;
40138
- const branch = current ?? (await git(wt.path, "branch", "--show-current")).out;
40139
- if (!branch || branch === wt.branch)
40140
- return;
40141
- log.info(wt.id, `on ${branch} now, not ${wt.branch}: the row follows the checkout`);
40142
- wt.branch = branch;
40143
- this.d.state.save();
40144
- this.d.hub.emit("worktreesChanged");
40145
- }
40146
- shippingOf(id) {
40147
- return this.ops.get(id);
40148
- }
40149
- async ship(id, op, run) {
40150
- const out = this.ops.get(id);
40151
- if (out)
40152
- throw new UserError(`${shipNoun(out.op)} is already running here`);
40153
- const release = this.d.runtime.agentFor(id)?.hold();
40154
- this.ops.set(id, { op });
40155
- this.d.hub.emit("worktreesChanged");
40156
- const started = Date.now();
40157
- const watchdog = setInterval(() => {
40158
- const step = this.ops.get(id)?.step;
40159
- const at = step ? ` at "${step}"` : "";
40160
- log.warn(id, `${shipNoun(op)} still running after ${describeDuration(Date.now() - started)}${at}`);
40161
- }, RUN_CEILING_MS);
40162
- watchdog.unref?.();
40163
- try {
40164
- return await run();
40165
- } finally {
40166
- clearInterval(watchdog);
40167
- this.ops.delete(id);
40168
- release?.();
40169
- this.d.hub.emit("worktreesChanged");
40170
- if (!isBusy(this.d.runtime.agentFor(id)?.status))
40171
- this.syncAfterTurn(id);
40172
- }
40173
- }
40174
- step(id, name) {
40175
- const out = this.ops.get(id);
40176
- if (!out || out.step === name)
40177
- return;
40178
- out.step = name;
40179
- this.d.hub.emit("worktreesChanged");
40180
- }
40181
- async landOp(wt, repo, message) {
40182
- const policy = landPolicy(repo.config);
40183
- const own = hasOwnBranch(wt);
40184
- const w = this.landWatch(wt);
40185
- const typed = message?.trim();
40186
- const suggested = typed || (wt.landing?.subject ? wt.landing.body ? `${wt.landing.subject}
40187
-
40188
- ${wt.landing.body}` : wt.landing.subject : undefined);
40189
- const base = baseOf(repo);
40190
- if (policy.land === "pr") {
40191
- if (wt.pr?.state === "merged" && !prTaken(wt)) {
40192
- const { mark, held, ...result } = await this.prMergedOp(wt.id, undefined, w);
40193
- return result.ok ? { result, landed: true, mark, held, url: wt.pr.url } : { result };
40194
- }
40195
- const fetched = await this.fetchBase(wt, repo, w);
40196
- if (!fetched.ok)
40197
- return { result: fetched };
40198
- if (wt.pr?.state === "open") {
40199
- const dirty = (await statusFiles(wt.path)).length > 0;
40200
- const missing = dirty || (await aheadUpstream(wt.path, wt.branch) ?? 0) > 0;
40201
- if (!missing) {
40202
- const result = await mergePr(wt.path, wt.pr.number, policy.merge, w);
40203
- if (result.ok && result.queued)
40204
- this.setPr(wt.id, { ...wt.pr, queue: true, queued: "waiting", at: Date.now() });
40205
- return { result };
40206
- }
40207
- const committed = await this.commitIfDirty(wt, message);
40208
- if (committed && !committed.ok)
40209
- return { result: committed };
40210
- this.tree.invalidate(wt.id);
40211
- const taken = await takeMainIn(wt.path, base, own, w);
40212
- if (!taken.ok)
40213
- return { result: taken };
40214
- await this.headMoved(wt.id);
40215
- this.tree.invalidate(wt.id);
40216
- const pushed = await pushBranch(wt.path, wt.branch, w);
40217
- if (!pushed.ok)
40218
- return { result: pushed };
40219
- await this.headMoved(wt.id);
40220
- this.setLanding(wt.id, undefined);
40221
- this.markPrompted(wt.id);
40222
- const did = committed ? "committed and pushed" : "pushed";
40223
- if (wt.pr.queued) {
40224
- const { queued: _, ...left } = wt.pr;
40225
- this.setPr(wt.id, { ...left, at: Date.now() });
40226
- return {
40227
- result: {
40228
- ok: true,
40229
- url: wt.pr.url,
40230
- message: `${did}; PR #${wt.pr.number} has the new commits and left the merge queue: land it again once it is ready`
40231
- }
40232
- };
40233
- }
40234
- return { result: { ok: true, url: wt.pr.url, message: `${did}; PR #${wt.pr.number} has the new commits` } };
40235
- }
40236
- const committed = await this.commitIfDirty(wt, message);
40237
- if (committed && !committed.ok)
40238
- return { result: committed };
40239
- const mark = await landingMark(wt.path, base);
40240
- this.tree.invalidate(wt.id);
40241
- const taken = await takeMainIn(wt.path, base, own, w);
40242
- if (!taken.ok)
40243
- return { result: taken };
40244
- await this.headMoved(wt.id);
40245
- if ((await aheadBehind(wt.path, base)).ahead === 0) {
40246
- const { mark: noted, held, ...r } = await this.prMergedOp(wt.id, mark, w);
40247
- const on = `origin's ${repo.defaultBranch} has this work already`;
40248
- const result = { ...r, message: r.ok ? `${on}; ${r.message}` : `${on}, but ${r.message}` };
40249
- return r.ok ? { result, landed: true, mark: noted, held, url: wt.pr?.url } : { result };
40250
- }
40251
- const named = !suggested ? undefined : splitMessage(policy.merge === "squash" ? await squashMessage(wt.path, base, suggested, !!typed) : suggested);
40252
- const result = await openPr({
40253
- worktreePath: wt.path,
40254
- branch: wt.branch,
40255
- defaultBr: repo.defaultBranch,
40256
- base,
40257
- subject: named?.subject,
40258
- body: named?.body,
40259
- automerge: policy.automerge,
40260
- method: policy.merge
40261
- }, w);
40262
- if (result.ok) {
40263
- this.setLanding(wt.id, undefined);
40264
- if (result.pr)
40265
- this.setPr(wt.id, { ...result.pr, at: Date.now() });
40266
- this.markPrompted(wt.id);
40267
- }
40268
- return { result };
40269
- }
40270
- if (policy.land === "push")
40271
- return this.landPush(wt, repo, policy, message, suggested, w);
40272
- let mergedHere = false;
40273
- let committedHere = false;
40274
- let held;
40275
- let noted;
40276
- const result = await withRepoLock(repo.path, async () => {
40277
- const committed = await this.commitIfDirty(wt, message);
40278
- if (committed && !committed.ok)
40279
- return committed;
40280
- committedHere = committed !== null;
40281
- const method = policy.merge ?? DEFAULT_MERGE_METHOD;
40282
- const merged = own && method === "rebase" ? await requireLinear(wt.path, base) : null;
40283
- if (merged)
40284
- return merged;
40285
- const taken = await takeMainIn(wt.path, base, own, w);
40286
- if (!taken.ok)
40287
- return taken;
40288
- const mark = await landingMark(wt.path, base);
40289
- const squash = method === "squash" ? await squashMessage(wt.path, base, suggested, !!message?.trim()) : "";
40290
- const landed = await landLocally(wt.path, wt.branch, repo.path, repo.defaultBranch, method, squash, w);
40291
- if (!landed.ok)
40292
- return landed;
40293
- mergedHere = true;
40294
- noted = await this.noteLand(repo, wt, mark, { squash });
40295
- await this.restartFromMain(wt, base);
40296
- return landed;
40297
- });
40298
- if (mergedHere) {
40299
- this.invalidateCounts();
40300
- held = await this.settleLanded(wt);
40301
- }
40302
- if (!result.ok)
40303
- return { result };
40304
- const did = committedHere ? "committed and merged" : "merged";
40305
- return {
40306
- result: { ...result, message: `${did} into ${repo.defaultBranch}` },
40307
- landed: true,
40308
- mark: noted,
40309
- held
40310
- };
40311
- }
40312
- async landPush(wt, repo, policy, message, suggested, w) {
40313
- const base = baseOf(repo);
40314
- const own = hasOwnBranch(wt);
40315
- const method = policy.merge ?? DEFAULT_MERGE_METHOD;
40316
- const committed = await this.commitIfDirty(wt, message);
40317
- if (committed && !committed.ok)
40318
- return { result: committed };
40319
- const read = await landingMark(wt.path, base);
40320
- if (!read)
40321
- return { result: { ok: false, message: `nothing to land: no commits ahead of ${base}` } };
40322
- let sha = "";
40323
- let already = false;
40324
- let squash = "";
40325
- const merged = own && method === "rebase" ? await requireLinear(wt.path, base) : null;
40326
- if (merged)
40327
- return { result: merged };
40328
- for (let attempt = 0;; attempt++) {
40329
- const fetched = await this.fetchBase(wt, repo, w);
40330
- if (!fetched.ok)
40331
- return { result: fetched };
40332
- this.tree.invalidate(wt.id);
40333
- const taken = await takeMainIn(wt.path, base, own, w);
40334
- if (!taken.ok)
40335
- return { result: taken };
40336
- await this.headMoved(wt.id);
40337
- if ((await aheadBehind(wt.path, base)).ahead === 0) {
40338
- already = true;
40339
- sha = await commitOf(wt.path, base) ?? "";
40340
- break;
40341
- }
40342
- squash = method === "squash" ? await squashMessage(wt.path, base, suggested, !!message?.trim()) : "";
40343
- this.tree.invalidate(wt.id);
40344
- const built = await landingCommit(wt.path, wt.branch, base, method, squash, own, w);
40345
- await this.headMoved(wt.id);
40346
- if (!built.ok || !built.sha)
40347
- return { result: built };
40348
- sha = built.sha;
40349
- const pushed = await pushLanding(wt.path, sha, repo.defaultBranch, w);
40350
- if (pushed.ok)
40351
- break;
40352
- if (!pushed.moved)
40353
- return { result: pushed };
40354
- if (attempt > 0) {
40355
- return { result: { ok: false, message: `${base} moved twice while this landed; land again` } };
40356
- }
40357
- }
40358
- if (await commitOf(wt.path, base) !== sha)
40359
- await this.fetchBase(wt, repo, w);
40360
- let noted;
40361
- await withRepoLock(repo.path, async () => {
40362
- noted = await this.noteLand(repo, wt, read, { squash });
40363
- await this.restartFromMain(wt, base);
40364
- });
40365
- this.invalidateCounts();
40366
- const held = await this.settleLanded(wt);
40367
- const followed = await this.trunk.catchUp(repo.id);
40368
- const here = followed.ok ? `${repo.defaultBranch} here ${followed.moved ? "pulled it" : "has it"}` : `${repo.defaultBranch} here was left where it is: ${followed.message}`;
40369
- const how = { merge: "merged into", squash: "squashed onto", rebase: "rebased onto" }[method];
40370
- const did = already ? `origin's ${repo.defaultBranch} has this work already` : `${committed ? `committed and ${how}` : how} ${repo.defaultBranch} and pushed`;
40371
- return { result: { ok: true, message: `${did}; ${here}` }, landed: true, mark: noted, held };
40372
- }
40373
- async noteLand(repo, wt, mark, opts = {}) {
40374
- if (!mark)
40375
- return;
40376
- const n = wt.lands?.length ?? 0;
40377
- const pinned = await git(repo.path, "update-ref", landRef(wt.id, n), mark.tip);
40378
- if (!pinned.ok) {
40379
- log.warn(wt.id, `could not keep the commits it landed: ${pinned.err}`);
40380
- return;
40381
- }
40382
- const noted = {
40383
- ...mark,
40384
- at: Date.now(),
40385
- ...opts.pr ? { pr: opts.pr.number } : {},
40386
- subjects: await this.landedWhat(repo, wt, mark, opts)
40387
- };
40388
- wt.lands = [...wt.lands ?? [], noted];
40389
- this.d.state.save();
40390
- return noted;
40391
- }
40392
- async landedWhat(repo, wt, mark, opts) {
40393
- if (opts.pr?.title)
40394
- return [opts.pr.title];
40395
- const subject = opts.squash ? splitMessage(opts.squash).subject : "";
40396
- if (subject)
40397
- return [subject];
40398
- const read = await rangeSubjects(repo.path, mark.base, mark.tip);
40399
- if (!read)
40400
- log.warn(wt.id, `could not read what it landed: ${mark.base.slice(0, 7)}..${mark.tip.slice(0, 7)}`);
40401
- return read ?? [];
40402
- }
40403
- async tipMark(wt) {
40404
- const head = await git(wt.path, "rev-parse", "HEAD");
40405
- return head.ok ? { base: head.out, tip: head.out } : null;
40406
- }
40407
- async restartFromMain(wt, base) {
40408
- if (!hasOwnBranch(wt))
40409
- return;
40410
- this.tree.invalidate(wt.id);
40411
- const r = await git(wt.path, "reset", "--keep", base);
40412
- if (!r.ok)
40413
- log.warn(wt.id, `could not restart ${wt.branch} from ${base}: ${r.err}`);
40414
- await this.tree.refresh(wt.id);
40415
- }
40416
- async fetchBase(wt, repo, w) {
40417
- if (!baseIsRemote(repo))
40418
- return { ok: true, message: `${baseOf(repo)} here is the base` };
40419
- const r = await withFetchLock(repo.path, () => fetchBase(wt.path, repo, w));
40420
- this.trunk.noteFetch(repo.id, { ok: r.ok, err: r.message });
40421
- return r;
40422
- }
40423
- async prMerged(worktreeId) {
40424
- const wt = this.d.state.worktree(worktreeId);
40425
- const pr = wt?.pr ? `PR #${wt.pr.number}` : "the PR";
40426
- const out = this.ops.get(worktreeId);
40427
- if (out) {
40428
- const message = `${shipNoun(out.op)} is already running here`;
40429
- log.info(worktreeId, `${pr} merged on GitHub; ${message}, landing waits`);
40430
- return { ok: false, message };
40431
- }
40432
- if (wt && prTaken(wt))
40433
- return { ok: true, message: `${pr} merged; landed already` };
40434
- log.info(worktreeId, `${pr} merged on GitHub; landing it`);
40435
- const { mark, held, ...result } = await this.ship(worktreeId, "land", () => this.prMergedOp(worktreeId));
40436
- if (result.ok)
40437
- log.info(worktreeId, `landed: ${result.message}`);
40438
- else
40439
- log.warn(worktreeId, `landing failed: ${result.message}`);
40440
- if (result.ok && wt)
40441
- this.noteLanded(wt, result.message, { mark, url: wt.pr?.url, held });
40442
- return result;
40443
- }
40444
- async prMergedOp(worktreeId, carried, w = UNWATCHED) {
40445
- const { wt, repo } = this.d.state.requireWorktreeWithRepo(worktreeId);
40446
- const br = repo.defaultBranch;
40447
- const base = baseOf(repo);
40448
- const read = carried === undefined ? await landingMark(wt.path, base) : carried;
40449
- const pulled = await this.trunk.pull(repo.id, w);
40450
- let noted;
40451
- await withRepoLock(repo.path, async () => {
40452
- if (!prTaken(wt)) {
40453
- const own = read ?? await handLanding(wt.path, wt.branch, base, wt.lands?.at(-1)?.tip);
40454
- const mark = own ?? (wt.pr ? await this.tipMark(wt) : null);
40455
- noted = await this.noteLand(repo, wt, mark, { pr: wt.pr });
40456
- }
40457
- await this.restartFromMain(wt, base);
40458
- });
40459
- this.invalidateCounts();
40460
- const held = await this.settleLanded(wt);
40461
- const took = pulled.ok ? `${br} here ${pulled.moved ? "pulled it" : "has it"}` : `${br} here was left where it is: ${pulled.message}`;
40462
- return { ok: true, message: wt.pr ? `PR #${wt.pr.number} merged; ${took}` : took, mark: noted, held };
40463
- }
40464
- async sync(worktreeId) {
40465
- const r = this.readable(worktreeId);
40466
- if (!r)
40467
- throw new UserError("that worktree is gone");
40468
- if (r.wt && isMain(r.wt))
40469
- throw new UserError("sync from a worktree, not main");
40470
- if (r.wt && isProvisional(r.wt))
40471
- throw new UserError("the new worktree already sits on main; it follows it on its own");
40472
- if (!r.branch)
40473
- throw new UserError(`${r.name} is detached: check out a branch in it first`);
40474
- if (r.locked)
40475
- throw new UserError(`${r.name} is held by another tool`);
40476
- const repo = this.d.state.requireRepo(r.repoId);
40477
- const own = r.wt ? hasOwnBranch(r.wt) : false;
40478
- const carry = !!r.wt;
40479
- const busy = isBusy(this.d.runtime.agentFor(worktreeId)?.status);
40480
- if (carry && busy && (await statusFiles(r.path)).length > 0) {
40481
- this.syncQueued.add(worktreeId);
40482
- this.d.hub.emit("worktreesChanged");
40483
- return { result: { ok: true, message: `${r.name} syncs from ${r.base} once the turn ends` }, base: r.base };
40484
- }
40485
- const w = r.wt ? this.landWatch(r.wt) : UNWATCHED;
40486
- const result = await this.ship(worktreeId, "sync-main", async () => {
40487
- if (r.wt) {
40488
- const fetched = await this.fetchBase(r.wt, repo, w);
40489
- if (!fetched.ok)
40490
- return fetched;
40491
- }
40492
- this.tree.invalidate(worktreeId);
40493
- const taken = await withRepoLock(repo.path, () => takeMainIn(r.path, r.base, own, w, carry));
40494
- if (taken.ok) {
40495
- await this.headMoved(worktreeId);
40496
- if (r.wt)
40497
- await this.verdictSurvives(r.wt, { dropMessage: false });
40498
- }
40499
- return taken;
40500
- });
40501
- return { result: r.wt ? this.fixShip(r.wt, result, r.base) : result, base: r.base };
40502
- }
40503
- unqueueSync(worktreeId) {
40504
- if (!this.syncQueued.delete(worktreeId))
40505
- return;
40506
- this.d.hub.emit("worktreesChanged");
40507
- }
40508
- syncAfterTurn(worktreeId) {
40509
- if (!this.syncQueued.has(worktreeId))
40510
- return;
40511
- if (this.ops.has(worktreeId))
40512
- return;
40513
- this.syncQueued.delete(worktreeId);
40514
- const release = this.d.runtime.agentFor(worktreeId)?.hold();
40515
- this.d.hub.emit("worktreesChanged");
40516
- fireAndForget(worktreeId, (async () => {
40517
- try {
40518
- const { result } = await this.sync(worktreeId);
40519
- if (!result.ok && !result.asked)
40520
- this.d.hub.emit("failed", worktreeId, result.message);
40521
- } catch (e) {
40522
- this.d.hub.emit("failed", worktreeId, e instanceof Error ? e.message : String(e));
40523
- } finally {
40524
- release?.();
40525
- }
40526
- })(), "sync after the turn");
40527
- }
40528
- async pull(worktreeId) {
40529
- const wt = this.d.state.requireWorktree(worktreeId);
40530
- if (!isMain(wt))
40531
- throw new UserError("pull on main; a worktree syncs from main instead");
40532
- const repo = this.d.state.requireRepo(wt.repoId);
40533
- const w = { step: (name) => this.step(worktreeId, name), git: UNWATCHED.git };
40534
- return this.ship(worktreeId, "pull-main", async () => {
40535
- const result = await this.trunk.pull(repo.id, w);
40536
- if (result.ok && result.moved)
40537
- await this.recountAll();
40538
- return result;
40539
- });
40540
- }
40541
- async commit(worktreeId, message) {
40542
- const wt = this.d.state.requireWorktree(worktreeId);
40543
- const m = message.trim();
40544
- if (!m)
40545
- throw new UserError("commit message required");
40546
- const base = baseOf(this.d.state.requireRepo(wt.repoId));
40547
- return this.fixShip(wt, await this.ship(wt.id, "commit", () => this.commitRecorded(wt, m)), base);
40548
- }
40549
40949
  setEmpty(wt, empty) {
40550
40950
  if (wt.empty === empty)
40551
40951
  return;
@@ -40559,7 +40959,7 @@ ${wt.landing.body}` : wt.landing.subject : undefined);
40559
40959
  trunksNow() {
40560
40960
  const all = this.trunk.all();
40561
40961
  for (const t of Object.values(all)) {
40562
- const shipping = this.ops.get(t.id);
40962
+ const shipping = this.ship.shippingOf(t.id);
40563
40963
  if (shipping)
40564
40964
  t.shipping = shipping;
40565
40965
  }
@@ -40736,37 +41136,12 @@ ${wt.landing.body}` : wt.landing.subject : undefined);
40736
41136
  const wt = r.wt;
40737
41137
  if (!wt)
40738
41138
  return false;
40739
- if (this.ops.has(wt.id))
41139
+ if (this.ship.shippingOf(wt.id))
40740
41140
  return false;
40741
- const isMain = wt.kind === "main";
40742
- if (core.branch)
40743
- await this.followBranch(wt, core.branch);
40744
- const { files, head } = core;
40745
- const ahead = core.ahead ?? 0;
40746
- if (!isMain && hasOwnBranch(wt) && files.length === 0 && ahead === 0 && head && this.handChecked.get(wt.id) !== head) {
40747
- this.handChecked.set(wt.id, head);
40748
- const mark = await handLanding(r.path, wt.branch, r.base, wt.lands?.at(-1)?.tip);
40749
- if (mark) {
40750
- await this.landedByHand(wt, mark);
40751
- return true;
40752
- }
40753
- }
40754
- if (!isMain && head) {
40755
- const facts = { head, ahead };
40756
- if (prTaken(wt) && movedPastLand(wt, facts))
40757
- this.setPr(wt.id, undefined);
40758
- await this.refreshLanded(wt, facts);
40759
- }
40760
- const landing = wt.landing;
40761
- if (landing && landing.check !== "pending") {
40762
- const moved = landing.fingerprint !== await this.fingerprint(r.path);
40763
- if (wt.landing === landing && moved !== !!landing.stale) {
40764
- const { stale: _was, ...rest } = landing;
40765
- this.setLanding(wt.id, moved ? { ...rest, stale: true } : rest);
40766
- }
40767
- }
40768
- if (isMain)
40769
- this.setEmpty(wt, files.length === 0 ? await treeEmpty(r.path) : false);
41141
+ if (await this.ship.afterRead(r, wt, core))
41142
+ return true;
41143
+ if (wt.kind === "main")
41144
+ this.setEmpty(wt, core.files.length === 0 ? await treeEmpty(r.path) : false);
40770
41145
  return false;
40771
41146
  }
40772
41147
  async gitStatus(worktreeId) {
@@ -40836,12 +41211,16 @@ ${wt.landing.body}` : wt.landing.subject : undefined);
40836
41211
  this.markPrompted(worktreeId);
40837
41212
  }
40838
41213
  markPrompted(worktreeId) {
41214
+ if (this.stampPrompted(worktreeId))
41215
+ this.d.hub.emit("worktreesChanged");
41216
+ }
41217
+ stampPrompted(worktreeId) {
40839
41218
  const wt = this.d.state.worktree(worktreeId);
40840
41219
  if (!wt)
40841
- return;
41220
+ return false;
40842
41221
  wt.promptedAt = Date.now();
40843
41222
  this.d.state.save();
40844
- this.d.hub.emit("worktreesChanged");
41223
+ return true;
40845
41224
  }
40846
41225
  snapshot() {
40847
41226
  const rows = [...this.ownedRows(), ...this.foundRows()];
@@ -40929,8 +41308,8 @@ ${wt.landing.body}` : wt.landing.subject : undefined);
40929
41308
  usage: this.usageFor(wt.id),
40930
41309
  transcript: this.writtenTranscript(wt.id),
40931
41310
  sessionId: this.d.state.session(wt.id),
40932
- shipping: this.ops.get(wt.id),
40933
- syncQueued: this.syncQueued.has(wt.id) || undefined
41311
+ shipping: this.ship.shippingOf(wt.id),
41312
+ syncQueued: this.ship.syncWaiting(wt.id) || undefined
40934
41313
  };
40935
41314
  });
40936
41315
  }
@@ -40945,7 +41324,7 @@ ${wt.landing.body}` : wt.landing.subject : undefined);
40945
41324
  ahead,
40946
41325
  behind,
40947
41326
  dirty,
40948
- shipping: this.ops.get(f.id)
41327
+ shipping: this.ship.shippingOf(f.id)
40949
41328
  };
40950
41329
  });
40951
41330
  }
@@ -41056,17 +41435,17 @@ if (pruned > 0)
41056
41435
  var managed = await loadManaged();
41057
41436
  var paths = makePaths();
41058
41437
  ensureDirs(paths);
41059
- forwardTranscripts([paths.transcriptsDir, paths.archiveDir], join36(paths.home, "transcripts-forwarded"));
41438
+ forwardTranscripts([paths.transcriptsDir, paths.archiveDir], join37(paths.home, "transcripts-forwarded"));
41060
41439
  var token = loadOrCreateToken(paths);
41061
41440
  var port = Number(process.env.TOYON_PORT ?? DAEMON_DEFAULT_PORT);
41062
- var helperApp = join36(paths.home, DAEMON_FILES.helper);
41441
+ var helperApp = join37(paths.home, DAEMON_FILES.helper);
41063
41442
  var helper = helperWanted({ platform: process.platform, cloud: cloud.enabled, port, home: process.env.TOYON_HOME }) ? new Helper({
41064
41443
  dir: helperApp,
41065
41444
  stub: HELPER_STUB,
41066
41445
  bun: process.execPath,
41067
41446
  cli: CLI_ENTRY,
41068
- log: join36(paths.home, DAEMON_FILES.launcherLog),
41069
- icon: join36(SHELL_DIST, "icon.svg"),
41447
+ log: join37(paths.home, DAEMON_FILES.launcherLog),
41448
+ icon: join37(SHELL_DIST, "icon.svg"),
41070
41449
  version: package_default.version
41071
41450
  }) : null;
41072
41451
  if (helper) {
@@ -41091,7 +41470,7 @@ var tailscale = tailscaleCli();
41091
41470
  var tailnet = new Tailnet({ ts: findTailscale() ? tailscale : null, hub });
41092
41471
  var remoteSwitch = new RemoteSwitch({ setting, ts: tailscale, port, file: paths.remoteFile, policy: managed.policy });
41093
41472
  var knocks = new Knocks(hub);
41094
- var origins = new Origins({ setting, port, branded: () => branded });
41473
+ var origins = new Origins({ setting, port, branded: () => branded, appPorts: () => runtime.appPorts() });
41095
41474
  var linuxSandbox = new LinuxSandbox;
41096
41475
  await linuxSandbox.refresh();
41097
41476
  var agents = loadAgentRegistry(paths.agentsFile, paths.agentsDir, linuxSandbox, managed.policy, paths.agentsFrom);
@@ -41167,7 +41546,8 @@ var worktrees = new WorktreeService({
41167
41546
  fix,
41168
41547
  pageErrors: (id) => pageErrors.ambient(id),
41169
41548
  interested: (id) => interest.isSubscribed(id),
41170
- shown: (id) => idle.isShown(id)
41549
+ shown: (id) => idle.isShown(id),
41550
+ message: (wt) => landing.message(wt.id)
41171
41551
  });
41172
41552
  interest.onFirst((id) => worktrees.tree.watch(id));
41173
41553
  interest.onLast((id) => worktrees.tree.unwatch(id));