@anhlp/alp-paseo 0.0.1-beta.5 → 0.0.1-beta.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anhlp/alp-paseo",
3
- "version": "0.0.1-beta.5",
3
+ "version": "0.0.1-beta.6",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "paseo-plugin.json",
@@ -17,11 +17,11 @@
17
17
  "@anthropic-ai/sdk": "0.131.0",
18
18
  "@modelcontextprotocol/sdk": "1.32.1",
19
19
  "zod": "4.4.3",
20
- "@anhlp/alp-protocol": "0.0.1-beta.5",
21
- "@anhlp/alp-core": "0.0.1-beta.5",
22
- "@anhlp/alp-runtime": "0.0.1-beta.5",
23
- "@anhlp/alp-daemon": "0.0.1-beta.5",
24
- "@anhlp/alp-client": "0.0.1-beta.5",
20
+ "@anhlp/alp-protocol": "0.0.1-beta.6",
21
+ "@anhlp/alp-core": "0.0.1-beta.6",
22
+ "@anhlp/alp-runtime": "0.0.1-beta.6",
23
+ "@anhlp/alp-daemon": "0.0.1-beta.6",
24
+ "@anhlp/alp-client": "0.0.1-beta.6",
25
25
  "ws": "^8.22.0"
26
26
  },
27
27
  "peerDependencies": {
@@ -15264,10 +15264,15 @@ function addGate(projectRoot, id, { kind, note, until, ref, options: options2 }
15264
15264
  var GATE_OPTIONS = 10;
15265
15265
  var GATE_OPTION_CHARS = 200;
15266
15266
  var GATE_CHANNELS = ["paseo", "web", "cli"];
15267
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
15268
+ var labelKey = (text3) => text3.normalize("NFC").trim().toLowerCase();
15269
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
15267
15270
  function gateOptions(value) {
15268
15271
  if (!Array.isArray(value) || value.length > GATE_OPTIONS || !value.every((item) => typeof item === "string" && !!item.trim() && item.trim().length <= GATE_OPTION_CHARS)) {
15269
15272
  fail2("INVALID_TASK", `options must list at most ${GATE_OPTIONS} nonempty answers of at most ${GATE_OPTION_CHARS} characters`);
15270
15273
  }
15274
+ const reserved = value.find((item) => REJECT_KEYS.has(labelKey(item)));
15275
+ if (reserved !== void 0) fail2("INVALID_TASK", `"${reserved.trim()}" is the Reject answer every gate card already offers; name the options for approving only`);
15271
15276
  return [...new Set(value.map((item) => item.trim()))];
15272
15277
  }
15273
15278
  var checkChannel = (channel) => {
@@ -15327,13 +15332,13 @@ function gateDecisions(tasks) {
15327
15332
  }
15328
15333
  return found;
15329
15334
  }
15330
- function markGateNotified(projectRoot, id, gateId, decision) {
15335
+ function markGateNotified(projectRoot, id, gateId, decision, at2) {
15331
15336
  return locked(projectRoot, async () => {
15332
15337
  const { tasks } = await loadTasks(projectRoot);
15333
15338
  const task = tasks.find((entry) => entry.id === id);
15334
15339
  const gate = task?.gates?.find((entry) => entry.id === gateId);
15335
15340
  const record3 = decision === "approved" ? gate?.resolved : gate?.rejected;
15336
- if (!task || !record3?.channel || record3.notified) return false;
15341
+ if (!task || !record3?.channel || record3.notified || at2 !== void 0 && record3.at !== at2) return false;
15337
15342
  record3.notified = now();
15338
15343
  await save(projectRoot, task);
15339
15344
  return true;
@@ -15556,8 +15561,8 @@ async function validWorktree(directory) {
15556
15561
  async function reattachWorktree(worktree) {
15557
15562
  const exists5 = !!await stat3(worktree.path).catch(() => void 0);
15558
15563
  if (exists5 && await validWorktree(worktree.path)) {
15559
- const gitdir = await git(worktree.path, ["rev-parse", "--absolute-git-dir"]);
15560
- if (gitdir.code === 0) await rm3(path13.join(gitdir.stdout.trim(), "index.lock"), { force: true });
15564
+ const dirs = await gitDirs(worktree.path);
15565
+ if (dirs) await clearStaleLocks(dirs, worktree.branch);
15561
15566
  return worktree;
15562
15567
  }
15563
15568
  if (!await branchExists(worktree.checkout, worktree.branch)) throw new Error(`Branch ${worktree.branch} of the worktree no longer exists`);
@@ -15635,29 +15640,92 @@ async function removeWorktree(worktree, { deleteBranch = false } = {}) {
15635
15640
  await git(worktree.checkout, ["worktree", "prune"]);
15636
15641
  if (deleteBranch) await git(worktree.checkout, ["branch", "-D", worktree.branch]);
15637
15642
  }
15643
+ async function gitDirs(target) {
15644
+ const result = await git(target, ["rev-parse", "--path-format=absolute", "--absolute-git-dir", "--git-common-dir"]);
15645
+ if (result.code !== 0) return void 0;
15646
+ const [own2, common] = result.stdout.trim().split("\n").map((line) => path13.resolve(line.trim()));
15647
+ return own2 && common ? { own: own2, common } : void 0;
15648
+ }
15649
+ var SHARED_LOCK_STALE_MS = 1e4;
15650
+ async function clearStaleLocks(dirs, branch) {
15651
+ if (dirs.own === dirs.common) return;
15652
+ const own2 = (await readdir6(dirs.own).catch(() => [])).filter((name2) => name2.endsWith(".lock"));
15653
+ const refs = (await readdir6(path13.join(dirs.own, "refs"), { recursive: true }).catch(() => [])).filter((name2) => name2.endsWith(".lock")).map((name2) => path13.join("refs", name2));
15654
+ for (const name2 of [...own2, ...refs]) await unlink3(path13.join(dirs.own, name2)).catch(() => {
15655
+ });
15656
+ const shared = `${path13.join(dirs.common, "refs", "heads", ...branch.split("/"))}.lock`;
15657
+ const info = await lstat4(shared).catch(() => void 0);
15658
+ if (info?.isFile() && Date.now() - info.mtimeMs >= SHARED_LOCK_STALE_MS) await unlink3(shared).catch(() => {
15659
+ });
15660
+ }
15661
+ var KEEP_IGNORED = /^\.env/;
15662
+ async function ignoredToKeep(target) {
15663
+ const result = await git(target, ["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--directory"]);
15664
+ if (result.code !== 0) return void 0;
15665
+ return result.stdout.split("\0").filter(Boolean).map((entry) => entry.replace(/\/$/, "")).filter((entry) => KEEP_IGNORED.test(path13.posix.basename(entry))).sort();
15666
+ }
15667
+ var firstLine = (result) => (result.stderr || result.stdout).trim().split("\n")[0] ?? "";
15638
15668
  async function reclaimWorktrees(root) {
15639
15669
  const kept = [];
15670
+ const left = [];
15640
15671
  const entries3 = await readdir6(root).catch(() => []);
15641
15672
  for (const name2 of entries3) {
15642
15673
  const target = path13.join(root, name2);
15674
+ const leave = (reason, extra = {}) => {
15675
+ left.push({ name: name2, path: target, reason, ...extra });
15676
+ };
15643
15677
  if (!(await stat3(target).catch(() => void 0))?.isDirectory()) continue;
15644
- if (!await validWorktree(target)) continue;
15645
- const common = await git(target, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
15646
- if (common.code !== 0) continue;
15647
- const checkout = path13.dirname(common.stdout.trim());
15678
+ if (!await validWorktree(target)) {
15679
+ leave("invalid");
15680
+ continue;
15681
+ }
15682
+ const dirs = await gitDirs(target);
15683
+ if (!dirs) {
15684
+ leave("invalid");
15685
+ continue;
15686
+ }
15687
+ if (dirs.own === dirs.common) {
15688
+ leave("not-linked");
15689
+ continue;
15690
+ }
15691
+ const checkout = path13.dirname(dirs.common);
15648
15692
  const ref = await git(target, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
15649
15693
  const branch = ref.stdout.trim();
15650
- if (ref.code !== 0 || branch !== `alp/${name2}`) continue;
15651
- if ((await git(target, ["add", "-A"])).code !== 0) continue;
15694
+ if (ref.code !== 0 || branch !== `alp/${name2}`) {
15695
+ leave("other-branch", ref.code === 0 && branch ? { branch } : {});
15696
+ continue;
15697
+ }
15698
+ await clearStaleLocks(dirs, branch);
15699
+ const added = await git(target, ["add", "-A"]);
15700
+ if (added.code !== 0) {
15701
+ leave("commit-failed", { branch, detail: firstLine(added) });
15702
+ continue;
15703
+ }
15652
15704
  if ((await git(target, ["diff", "--cached", "--quiet"])).code === 1) {
15653
- if ((await git(target, [...IDENTITY, "commit", "--quiet", "--no-verify", "-m", "alp: work left by an interrupted assignment"])).code !== 0) continue;
15705
+ const committed = await git(target, [...IDENTITY, "commit", "--quiet", "--no-verify", "-m", "alp: work left by an interrupted assignment"]);
15706
+ if (committed.code !== 0) {
15707
+ leave("commit-failed", { branch, detail: firstLine(committed) });
15708
+ continue;
15709
+ }
15654
15710
  }
15655
15711
  const status = await git(target, ["status", "--porcelain", "--ignored=no"]);
15656
- if (status.code !== 0 || status.stdout.trim()) continue;
15712
+ if (status.code !== 0 || status.stdout.trim()) {
15713
+ leave("uncommitted", { branch });
15714
+ continue;
15715
+ }
15716
+ const ignored = await ignoredToKeep(target);
15717
+ if (!ignored) {
15718
+ leave("ignored-files", { branch, detail: "git could not list its ignored files" });
15719
+ continue;
15720
+ }
15721
+ if (ignored.length) {
15722
+ leave("ignored-files", { branch, files: ignored });
15723
+ continue;
15724
+ }
15657
15725
  await removeWorktree({ checkout, path: target, workdir: target, branch, base: "" });
15658
15726
  kept.push(branch);
15659
15727
  }
15660
- return kept;
15728
+ return { kept, left };
15661
15729
  }
15662
15730
  async function createCopy(workdir, root, id) {
15663
15731
  const checkout = await checkoutOf(workdir);
@@ -31125,6 +31193,7 @@ function commandDecision(profile, command) {
31125
31193
 
31126
31194
  // packages/runtime/src/claude-transport.ts
31127
31195
  var CLAUDE_SDK = ["@anthropic-ai", "claude-agent-sdk"].join("/");
31196
+ var claudeSdk = () => packagedInfo()?.modules?.[CLAUDE_SDK]?.() ?? import(CLAUDE_SDK);
31128
31197
  var bounded = (schema, limits) => external_exports.preprocess((value, ctx) => {
31129
31198
  const error51 = inputLimitError(value, limits);
31130
31199
  if (error51) ctx.addIssue({ code: "custom", message: error51 });
@@ -31405,7 +31474,7 @@ function claudePermissions(sandbox, currentSandbox, rules, ask) {
31405
31474
  var claudeLimited = (info) => info?.status === "rejected" && info.overageStatus !== "allowed" && info.overageStatus !== "allowed_warning";
31406
31475
  var BOOT_MS = 15e3;
31407
31476
  async function claudeChain(sessionId, dir) {
31408
- const { getSessionMessages } = await import(CLAUDE_SDK);
31477
+ const { getSessionMessages } = await claudeSdk();
31409
31478
  let messages = dir ? await getSessionMessages(sessionId, { dir }) : [];
31410
31479
  if (!messages.length) messages = await getSessionMessages(sessionId);
31411
31480
  return messages.filter((message) => (message.type === "user" || message.type === "assistant") && typeof message.uuid === "string").map((message) => {
@@ -31491,7 +31560,7 @@ var ClaudeTransport = class {
31491
31560
  }
31492
31561
  /** The models Claude offers now, from a Claude process that sends no prompt and is closed after (t-e5ee). */
31493
31562
  async probeModels(signal) {
31494
- const { query } = await import(CLAUDE_SDK);
31563
+ const { query } = await claudeSdk();
31495
31564
  if (signal?.aborted) throw new Error("aborted");
31496
31565
  const probe = query({ prompt: new InputQueue(), options: { cwd: this.cwd, env: this.env, settingSources: [], persistSession: false, strictMcpConfig: true, mcpServers: {}, pathToClaudeCodeExecutable: this.command } });
31497
31566
  const stop = () => probe.close();
@@ -31507,7 +31576,7 @@ var ClaudeTransport = class {
31507
31576
  /** The plan's usage windows before a session starts, from a Claude process that sends no prompt and is closed after. */
31508
31577
  async probeUsage() {
31509
31578
  if (this.closed) return void 0;
31510
- const { query } = await import(CLAUDE_SDK);
31579
+ const { query } = await claudeSdk();
31511
31580
  const probe = query({ prompt: new InputQueue(), options: { cwd: this.cwd, env: this.env, settingSources: [], persistSession: false, strictMcpConfig: true, mcpServers: {}, pathToClaudeCodeExecutable: this.command } });
31512
31581
  try {
31513
31582
  return await probe.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true });
@@ -31536,7 +31605,7 @@ var ClaudeTransport = class {
31536
31605
  }
31537
31606
  if (method === "thread/chain") return { entries: await claudeChain(params.threadId, params.cwd ?? this.cwd) };
31538
31607
  if (method === "thread/delete") {
31539
- const { deleteSession } = await import(CLAUDE_SDK);
31608
+ const { deleteSession } = await claudeSdk();
31540
31609
  await deleteSession(params.threadId, params.cwd ? { dir: params.cwd } : {});
31541
31610
  return {};
31542
31611
  }
@@ -31632,7 +31701,7 @@ var ClaudeTransport = class {
31632
31701
  if (this.bootError) throw this.bootError;
31633
31702
  }
31634
31703
  async start(from) {
31635
- const { createSdkMcpServer, query, tool } = await import(CLAUDE_SDK);
31704
+ const { createSdkMcpServer, query, tool } = await claudeSdk();
31636
31705
  const config2 = this.config;
31637
31706
  const mcpServers = this.convertMcp(config2.mcpServers);
31638
31707
  if (config2.dynamicTools.length) {
@@ -40882,7 +40951,7 @@ function nativeSessionConfig(runtimeKind, mapping, targets, parentAgent, role, s
40882
40951
 
40883
40952
  // packages/runtime/src/runtime.ts
40884
40953
  import { createHash as createHash12, randomUUID as randomUUID13 } from "node:crypto";
40885
- import { readFileSync as readFileSync5, realpathSync as realpathSync9 } from "node:fs";
40954
+ import { readFileSync as readFileSync5, realpathSync as realpathSync9, watch as watchFiles } from "node:fs";
40886
40955
  import { appendFile as appendFile2, mkdir as mkdir16, open as open5, readFile as readFile21, realpath as realpath5, rm as rm5, stat as stat6, writeFile as writeFile16 } from "node:fs/promises";
40887
40956
  import os11 from "node:os";
40888
40957
  import path37 from "node:path";
@@ -42548,7 +42617,9 @@ function createAlpRuntime(options2 = {}) {
42548
42617
  }
42549
42618
  cancelQuestions(sessionId, true);
42550
42619
  if (session.parent) sessions.get(session.parent)?.assignments.get(sessionId)?.ask?.resolve(toolResult(false, { error: "Turn ended" }));
42551
- session.mail = session.mail.filter((event) => !(event.deliveredTurn === turnId && state === "completed"));
42620
+ const taken = state === "completed" ? session.mail.filter((event) => event.deliveredTurn === turnId) : [];
42621
+ session.mail = session.mail.filter((event) => !taken.includes(event));
42622
+ for (const event of taken) gateMail.get(event)?.taken();
42552
42623
  for (const event of session.mail) {
42553
42624
  if (event.deliveredTurn === turnId) {
42554
42625
  event.deliveredTurn = void 0;
@@ -44825,6 +44896,9 @@ The user answered: "${answer}"`);
44825
44896
  }
44826
44897
  }
44827
44898
  const gateSyncs = /* @__PURE__ */ new Map();
44899
+ const gateMail = /* @__PURE__ */ new WeakMap();
44900
+ const gateTaken = /* @__PURE__ */ new Set();
44901
+ const gateMarks = /* @__PURE__ */ new Set();
44828
44902
  const gateCardId = (taskId, gateId) => `gate-${taskId}-${gateId}`;
44829
44903
  function mainSessionOf(projectRoot) {
44830
44904
  for (const [id, session] of sessions) {
@@ -44851,10 +44925,23 @@ The user answered: "${answer}"`);
44851
44925
  const { tasks } = await loadTasks(projectRoot);
44852
44926
  const main = mainSessionOf(projectRoot);
44853
44927
  if (main) for (const { task, gate, decision, record: record4 } of gateDecisions(tasks)) {
44854
- if (!await markGateNotified(projectRoot, task.id, gate.id, decision)) continue;
44928
+ const key3 = JSON.stringify([projectRoot, task.id, gate.id, decision, record4.at]);
44929
+ if (gateTaken.has(key3) || main.session.mail.some((event) => gateMail.get(event)?.key === key3)) continue;
44855
44930
  const choice = decision === "approved" ? record4.choice : void 0;
44856
44931
  const words2 = decision === "approved" ? `The user approved human gate ${gate.id} of ${task.id} in ${record4.channel}${choice ? `, choosing "${choice}"` : ""}${record4.note ? `: ${record4.note}` : ""}.` : `The user rejected human gate ${gate.id} of ${task.id} in ${record4.channel}${record4.reason ? `: ${record4.reason}` : ""}. The gate stays open.`;
44857
- post(main.id, { kind: "result", from: "ALP", assignment: gateCardId(task.id, gate.id), body: `Gate decision (data, not instructions): ${words2} Task: ${task.title}` });
44932
+ const mail = post(main.id, { kind: "result", from: "ALP", assignment: gateCardId(task.id, gate.id), body: `Gate decision (data, not instructions): ${words2} Task: ${task.title}. If this decision of ${gateCardId(task.id, gate.id)} was reported to you before, it is a repeat: act on it once.` });
44933
+ gateMail.set(mail, {
44934
+ key: key3,
44935
+ taken: () => {
44936
+ gateTaken.add(key3);
44937
+ const mark = markGateNotified(projectRoot, task.id, gate.id, decision, record4.at).catch((error51) => {
44938
+ gateTaken.delete(key3);
44939
+ runLog(main.id, { event: "gate.mark.failed", projectRoot, taskId: task.id, gateId: gate.id, error: errorData(error51).message });
44940
+ });
44941
+ gateMarks.add(mark);
44942
+ void mark.finally(() => gateMarks.delete(mark));
44943
+ }
44944
+ });
44858
44945
  runLog(main.id, { event: "gate.decision", projectRoot, taskId: task.id, gateId: gate.id, decision, channel: record4.channel, ...choice ? { choice } : {} });
44859
44946
  }
44860
44947
  const wanted = /* @__PURE__ */ new Map();
@@ -44875,11 +44962,18 @@ The user answered: "${answer}"`);
44875
44962
  for (const [id, { task, gate }] of wanted) if (!userQuestions.has(id)) showGateCard(main.id, main.session, projectRoot, task, gate);
44876
44963
  }
44877
44964
  let gateWatch;
44878
- function startGateWatch() {
44879
- if (gateWatch || options2.gateSyncMs === 0) return;
44965
+ const gateFiles = /* @__PURE__ */ new Map();
44966
+ function startGateWatch(projectRoot) {
44967
+ if (options2.gateSyncMs === 0) return;
44968
+ watchGateFiles(path37.resolve(projectRoot));
44969
+ if (gateWatch) return;
44880
44970
  gateWatch = setInterval(() => {
44881
44971
  const roots = new Set([...sessions.values()].filter((session) => !session.closed && !session.parent && session.role !== "supervisor" && session.mapping.agent.name === mainOf(session.mapping)).map((session) => path37.resolve(session.mapping.agent.projectRoot)));
44882
- for (const root of roots) void syncGates(root);
44972
+ for (const root of roots) {
44973
+ watchGateFiles(root);
44974
+ void syncGates(root);
44975
+ }
44976
+ for (const root of gateFiles.keys()) if (!roots.has(root)) unwatchGateFiles(root);
44883
44977
  if (!roots.size) {
44884
44978
  clearInterval(gateWatch);
44885
44979
  gateWatch = void 0;
@@ -44887,6 +44981,33 @@ The user answered: "${answer}"`);
44887
44981
  }, options2.gateSyncMs ?? 5e3);
44888
44982
  gateWatch.unref?.();
44889
44983
  }
44984
+ function watchGateFiles(root) {
44985
+ if (closed || gateFiles.has(root)) return;
44986
+ try {
44987
+ const entry = {
44988
+ // A burst of writes (a save, its ledger, a mark) syncs once.
44989
+ watcher: watchFiles(path37.join(root, TASKS_DIR), () => {
44990
+ if (entry.timer || closed) return;
44991
+ entry.timer = setTimeout(() => {
44992
+ entry.timer = void 0;
44993
+ void syncGates(root);
44994
+ }, 50);
44995
+ entry.timer.unref?.();
44996
+ })
44997
+ };
44998
+ entry.watcher.on("error", () => unwatchGateFiles(root));
44999
+ entry.watcher.unref?.();
45000
+ gateFiles.set(root, entry);
45001
+ } catch {
45002
+ }
45003
+ }
45004
+ function unwatchGateFiles(root) {
45005
+ const entry = gateFiles.get(root);
45006
+ if (!entry) return;
45007
+ gateFiles.delete(root);
45008
+ clearTimeout(entry.timer);
45009
+ entry.watcher.close();
45010
+ }
44890
45011
  function showGateCard(sessionId, session, projectRoot, task, gate) {
44891
45012
  const say = wordsFor(session);
44892
45013
  const vietnamese = say.approve === words("vi").approve;
@@ -44914,9 +45035,9 @@ ${gate.note ?? ""}`,
44914
45035
  }
44915
45036
  async function decideGate(projectRoot, question, card, reply, choices) {
44916
45037
  const defaults = !choices;
44917
- if (card.deciding) return;
44918
- card.deciding = true;
44919
45038
  const { taskId, gateId } = question.gate;
45039
+ if (card.deciding) throw gateRefusal("GATE_BUSY", `An answer to gate ${gateId} of ${taskId} is already being recorded`);
45040
+ card.deciding = true;
44920
45041
  const channel = reply.channel ?? "web";
44921
45042
  const text4 = reply.text?.trim();
44922
45043
  const approve2 = !reply.dismiss && !!text4 && (defaults ? APPROVALS.includes(text4.toLowerCase()) : choices.includes(text4));
@@ -44929,16 +45050,21 @@ ${gate.note ?? ""}`,
44929
45050
  if (error51?.code !== "TASK_CHANGED") throw error51;
44930
45051
  const current = (await loadTasks(projectRoot)).tasks.find((entry) => entry.id === taskId);
44931
45052
  const gate = current?.gates.find((entry) => entry.id === gateId);
44932
- if (!current || current.status === "closed" || !gate || gate.resolved || gate.rejected) return;
45053
+ if (!current || current.status === "closed" || !gate) throw gateRefusal("GATE_DECIDED", `Gate ${gateId} of ${taskId} is no longer open`);
45054
+ if (gate.resolved || gate.rejected) throw gateRefusal("GATE_DECIDED", `Gate ${gateId} of ${taskId} was already ${gate.resolved ? "approved" : "rejected"}${(gate.resolved ?? gate.rejected)?.channel ? ` in ${(gate.resolved ?? gate.rejected).channel}` : ""}`);
44933
45055
  await write(current.rev);
44934
45056
  }
44935
45057
  } catch (error51) {
44936
45058
  runLog(question.rootId, { event: "gate.answer.failed", questionId: question.id, error: errorData(error51).message });
45059
+ throw error51;
44937
45060
  } finally {
44938
45061
  card.deciding = false;
44939
45062
  await syncGates(projectRoot);
44940
45063
  }
44941
45064
  }
45065
+ function gateRefusal(code, message) {
45066
+ return Object.assign(new Error(message), { code });
45067
+ }
44942
45068
  function askTool(sessionId, session, args) {
44943
45069
  const parent = session.parent ? sessions.get(session.parent) : void 0;
44944
45070
  const assignment = parent?.assignments.get(sessionId);
@@ -45468,7 +45594,7 @@ ${digest3}` : "")
45468
45594
  });
45469
45595
  if (!session.parent && session.role !== "supervisor" && mapping.agent.name === mainOf(mapping)) {
45470
45596
  void syncGates(mapping.agent.projectRoot);
45471
- startGateWatch();
45597
+ startGateWatch(mapping.agent.projectRoot);
45472
45598
  }
45473
45599
  if (history === "replay") {
45474
45600
  for (const turn of result.thread.turns ?? []) {
@@ -46779,8 +46905,7 @@ ${interrupted}`), text_elements: [] }] : []
46779
46905
  if (!pending) throw new Error(`No question ${questionId} waits for an answer`);
46780
46906
  if (pending.gate) {
46781
46907
  if (!reply.dismiss && (typeof reply.text !== "string" || !reply.text.trim() || reply.text.length > MAIL_BODY_CHARS)) throw new Error("An answer needs text");
46782
- void pending.gate.decide(reply);
46783
- return;
46908
+ return pending.gate.decide(reply);
46784
46909
  }
46785
46910
  if (reply.dismiss) {
46786
46911
  pending.settle("dismissed", void 0, reply.reason);
@@ -46817,7 +46942,9 @@ ${interrupted}`), text_elements: [] }] : []
46817
46942
  );
46818
46943
  clearInterval(watchdog);
46819
46944
  if (gateWatch) clearInterval(gateWatch);
46945
+ for (const root of [...gateFiles.keys()]) unwatchGateFiles(root);
46820
46946
  await Promise.all(gateSyncs.values());
46947
+ await Promise.all(gateMarks);
46821
46948
  await Promise.all(boards.values());
46822
46949
  await boardDomain.flush();
46823
46950
  await runLogWrites;
@@ -49450,7 +49577,7 @@ function createOperations({ home: home2, templates, knownProjects = () => [], on
49450
49577
  return { id: task.id, status: task.status, rev: task.rev, ...landed ? { landed } : {} };
49451
49578
  }),
49452
49579
  // Approves a gate, or with reject records the user's rejection and leaves it open. Either way
49453
- // the runtime is told, so main gets the decision by mail once and the gate's card settles.
49580
+ // the runtime is told, so main gets the decision by mail (at least once) and the gate's card settles.
49454
49581
  "task.gate.resolve": guard(async ({ projectRoot, id, gate, note, ifRev, choice, reject, channel = "web" }, caller) => {
49455
49582
  const root = await project(projectRoot);
49456
49583
  if (caller?.agent) {
@@ -55858,14 +55985,18 @@ function createDaemonServer({ runtime, socketPath, version: version2, onShutdown
55858
55985
  throw new RpcError(ERROR.failed, error51?.message ?? String(error51));
55859
55986
  }
55860
55987
  },
55861
- "question.answer"(connection, { questionId, text: text3, dismiss = false, reason }) {
55988
+ async "question.answer"(connection, { questionId, text: text3, dismiss = false, reason }) {
55862
55989
  if (connection.agent) throw new RpcError(ERROR.forbidden, "Only the user answers a question; this connection was opened by an ALP agent", { reason: "user_only" });
55863
55990
  if (typeof questionId !== "string") throw new RpcError(-32602, "questionId is required");
55864
55991
  if (dismiss !== true && typeof text3 !== "string") throw new RpcError(-32602, "text or dismiss is required");
55865
55992
  const matches = runtime.questions().filter((question) => question.id === questionId || question.id.startsWith(questionId));
55866
55993
  if (matches.length !== 1) throw new RpcError(ERROR.notFound, matches.length ? `Question id ${questionId} is ambiguous` : `No question ${questionId} waits for an answer`);
55867
55994
  const channel = connection.client?.startsWith("alp-paseo") ? "paseo" : "web";
55868
- runtime.answer(matches[0].id, dismiss === true ? { dismiss: true, reason, channel } : { text: text3, channel });
55995
+ const decided = runtime.answer(matches[0].id, dismiss === true ? { dismiss: true, reason, channel } : { text: text3, channel });
55996
+ if (decided) await decided.catch((error51) => {
55997
+ if (error51?.code === "GATE_BUSY" || error51?.code === "GATE_DECIDED") throw new RpcError(ERROR.conflict, error51.message, { reason: "task_state" });
55998
+ throw fromCore(error51);
55999
+ });
55869
56000
  return { questionId: matches[0].id };
55870
56001
  },
55871
56002
  "daemon.shutdown": () => {
@@ -57260,10 +57391,18 @@ function guardOutput(onError, fs) {
57260
57391
  }
57261
57392
 
57262
57393
  // packages/daemon/src/main.ts
57263
- var VERSION = true ? "0.0.1-beta.5" : "0.0.0-dev";
57394
+ var VERSION = true ? "0.0.1-beta.6" : "0.0.0-dev";
57264
57395
  var LOG_BYTES = 10 * 1024 * 1024;
57265
57396
  var LOG_FILES = 3;
57266
57397
  var log6 = createLogger({ component: "alpd" });
57398
+ var LEFT_REASONS = {
57399
+ invalid: () => "it is not a working git worktree (its git metadata is missing or broken)",
57400
+ "not-linked": () => "it is a git repository of its own, not a worktree of one",
57401
+ "other-branch": (entry) => entry.branch ? `it is on branch ${entry.branch}, not its alp branch` : "its HEAD is detached, so no branch holds its work",
57402
+ "commit-failed": (entry) => `its work could not be committed${entry.detail ? ` (${entry.detail})` : ""}`,
57403
+ uncommitted: () => "it still has changes that are not committed",
57404
+ "ignored-files": (entry) => entry.files?.length ? `its work is committed to ${entry.branch}, but it holds ignored files that exist nowhere else: ${entry.files.join(", ")}` : `its ignored files could not be checked${entry.detail ? ` (${entry.detail})` : ""}`
57405
+ };
57267
57406
  async function rotate(log7) {
57268
57407
  const size = await stat18(log7).then((info) => info.size, () => 0);
57269
57408
  if (size < LOG_BYTES) return;
@@ -57421,7 +57560,18 @@ async function run2(home2) {
57421
57560
  await writeFile24(marker, JSON.stringify({ pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString(), groupLeader: leadsGroup() }), { mode: 384 });
57422
57561
  const runLogDir = process.env.ALP_RUN_LOG_DIR || path59.join(home2, "runs");
57423
57562
  const worktreeDir = path59.join(home2, "worktrees");
57424
- for (const branch of await reclaimWorktrees(worktreeDir).catch(() => [])) log6.info(`kept interrupted work on branch ${branch}`, { event: "worktree.kept", branch });
57563
+ const reclaimed = await reclaimWorktrees(worktreeDir).catch(() => ({ kept: [], left: [] }));
57564
+ for (const branch of reclaimed.kept) log6.info(`kept interrupted work on branch ${branch}`, { event: "worktree.kept", branch });
57565
+ for (const entry of reclaimed.left) {
57566
+ log6.warn(`left worktree ${entry.path} in place: ${LEFT_REASONS[entry.reason](entry)}`, {
57567
+ event: "worktree.left",
57568
+ path: entry.path,
57569
+ reason: entry.reason,
57570
+ ...entry.branch ? { branch: entry.branch } : {},
57571
+ ...entry.detail ? { detail: entry.detail } : {},
57572
+ ...entry.files ? { files: entry.files } : {}
57573
+ });
57574
+ }
57425
57575
  const copyDir = path59.join(home2, "copies");
57426
57576
  await reclaimCopies(copyDir).catch(() => 0);
57427
57577
  const settings = await userSettings(home2);
@@ -21010,10 +21010,15 @@ function addGate(projectRoot, id2, { kind, note, until, ref, options: options2 }
21010
21010
  var GATE_OPTIONS = 10;
21011
21011
  var GATE_OPTION_CHARS = 200;
21012
21012
  var GATE_CHANNELS = ["paseo", "web", "cli"];
21013
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
21014
+ var labelKey = (text3) => text3.normalize("NFC").trim().toLowerCase();
21015
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
21013
21016
  function gateOptions(value) {
21014
21017
  if (!Array.isArray(value) || value.length > GATE_OPTIONS || !value.every((item) => typeof item === "string" && !!item.trim() && item.trim().length <= GATE_OPTION_CHARS)) {
21015
21018
  fail2("INVALID_TASK", `options must list at most ${GATE_OPTIONS} nonempty answers of at most ${GATE_OPTION_CHARS} characters`);
21016
21019
  }
21020
+ const reserved = value.find((item) => REJECT_KEYS.has(labelKey(item)));
21021
+ if (reserved !== void 0) fail2("INVALID_TASK", `"${reserved.trim()}" is the Reject answer every gate card already offers; name the options for approving only`);
21017
21022
  return [...new Set(value.map((item) => item.trim()))];
21018
21023
  }
21019
21024
  var checkChannel = (channel) => {
@@ -21073,13 +21078,13 @@ function gateDecisions(tasks) {
21073
21078
  }
21074
21079
  return found;
21075
21080
  }
21076
- function markGateNotified(projectRoot, id2, gateId, decision) {
21081
+ function markGateNotified(projectRoot, id2, gateId, decision, at2) {
21077
21082
  return locked(projectRoot, async () => {
21078
21083
  const { tasks } = await loadTasks(projectRoot);
21079
21084
  const task = tasks.find((entry) => entry.id === id2);
21080
21085
  const gate = task?.gates?.find((entry) => entry.id === gateId);
21081
21086
  const record3 = decision === "approved" ? gate?.resolved : gate?.rejected;
21082
- if (!task || !record3?.channel || record3.notified) return false;
21087
+ if (!task || !record3?.channel || record3.notified || at2 !== void 0 && record3.at !== at2) return false;
21083
21088
  record3.notified = now();
21084
21089
  await save(projectRoot, task);
21085
21090
  return true;
@@ -21302,8 +21307,8 @@ async function validWorktree(directory) {
21302
21307
  async function reattachWorktree(worktree) {
21303
21308
  const exists3 = !!await stat2(worktree.path).catch(() => void 0);
21304
21309
  if (exists3 && await validWorktree(worktree.path)) {
21305
- const gitdir = await git(worktree.path, ["rev-parse", "--absolute-git-dir"]);
21306
- if (gitdir.code === 0) await rm3(path12.join(gitdir.stdout.trim(), "index.lock"), { force: true });
21310
+ const dirs = await gitDirs(worktree.path);
21311
+ if (dirs) await clearStaleLocks(dirs, worktree.branch);
21307
21312
  return worktree;
21308
21313
  }
21309
21314
  if (!await branchExists(worktree.checkout, worktree.branch)) throw new Error(`Branch ${worktree.branch} of the worktree no longer exists`);
@@ -21381,6 +21386,24 @@ async function removeWorktree(worktree, { deleteBranch = false } = {}) {
21381
21386
  await git(worktree.checkout, ["worktree", "prune"]);
21382
21387
  if (deleteBranch) await git(worktree.checkout, ["branch", "-D", worktree.branch]);
21383
21388
  }
21389
+ async function gitDirs(target) {
21390
+ const result = await git(target, ["rev-parse", "--path-format=absolute", "--absolute-git-dir", "--git-common-dir"]);
21391
+ if (result.code !== 0) return void 0;
21392
+ const [own3, common] = result.stdout.trim().split("\n").map((line) => path12.resolve(line.trim()));
21393
+ return own3 && common ? { own: own3, common } : void 0;
21394
+ }
21395
+ var SHARED_LOCK_STALE_MS = 1e4;
21396
+ async function clearStaleLocks(dirs, branch) {
21397
+ if (dirs.own === dirs.common) return;
21398
+ const own3 = (await readdir5(dirs.own).catch(() => [])).filter((name2) => name2.endsWith(".lock"));
21399
+ const refs = (await readdir5(path12.join(dirs.own, "refs"), { recursive: true }).catch(() => [])).filter((name2) => name2.endsWith(".lock")).map((name2) => path12.join("refs", name2));
21400
+ for (const name2 of [...own3, ...refs]) await unlink2(path12.join(dirs.own, name2)).catch(() => {
21401
+ });
21402
+ const shared = `${path12.join(dirs.common, "refs", "heads", ...branch.split("/"))}.lock`;
21403
+ const info = await lstat3(shared).catch(() => void 0);
21404
+ if (info?.isFile() && Date.now() - info.mtimeMs >= SHARED_LOCK_STALE_MS) await unlink2(shared).catch(() => {
21405
+ });
21406
+ }
21384
21407
  async function createCopy(workdir, root, id2) {
21385
21408
  const checkout = await checkoutOf(workdir);
21386
21409
  if (!checkout) throw new Error("A review copy needs a git repository");
@@ -36843,6 +36866,7 @@ function commandDecision(profile, command) {
36843
36866
 
36844
36867
  // packages/runtime/src/claude-transport.ts
36845
36868
  var CLAUDE_SDK = ["@anthropic-ai", "claude-agent-sdk"].join("/");
36869
+ var claudeSdk = () => packagedInfo()?.modules?.[CLAUDE_SDK]?.() ?? import(CLAUDE_SDK);
36846
36870
  var bounded = (schema, limits) => external_exports.preprocess((value, ctx) => {
36847
36871
  const error51 = inputLimitError(value, limits);
36848
36872
  if (error51) ctx.addIssue({ code: "custom", message: error51 });
@@ -37123,7 +37147,7 @@ function claudePermissions(sandbox, currentSandbox, rules, ask) {
37123
37147
  var claudeLimited = (info) => info?.status === "rejected" && info.overageStatus !== "allowed" && info.overageStatus !== "allowed_warning";
37124
37148
  var BOOT_MS = 15e3;
37125
37149
  async function claudeChain(sessionId, dir) {
37126
- const { getSessionMessages } = await import(CLAUDE_SDK);
37150
+ const { getSessionMessages } = await claudeSdk();
37127
37151
  let messages = dir ? await getSessionMessages(sessionId, { dir }) : [];
37128
37152
  if (!messages.length) messages = await getSessionMessages(sessionId);
37129
37153
  return messages.filter((message) => (message.type === "user" || message.type === "assistant") && typeof message.uuid === "string").map((message) => {
@@ -37209,7 +37233,7 @@ var ClaudeTransport = class {
37209
37233
  }
37210
37234
  /** The models Claude offers now, from a Claude process that sends no prompt and is closed after (t-e5ee). */
37211
37235
  async probeModels(signal) {
37212
- const { query } = await import(CLAUDE_SDK);
37236
+ const { query } = await claudeSdk();
37213
37237
  if (signal?.aborted) throw new Error("aborted");
37214
37238
  const probe = query({ prompt: new InputQueue(), options: { cwd: this.cwd, env: this.env, settingSources: [], persistSession: false, strictMcpConfig: true, mcpServers: {}, pathToClaudeCodeExecutable: this.command } });
37215
37239
  const stop = () => probe.close();
@@ -37225,7 +37249,7 @@ var ClaudeTransport = class {
37225
37249
  /** The plan's usage windows before a session starts, from a Claude process that sends no prompt and is closed after. */
37226
37250
  async probeUsage() {
37227
37251
  if (this.closed) return void 0;
37228
- const { query } = await import(CLAUDE_SDK);
37252
+ const { query } = await claudeSdk();
37229
37253
  const probe = query({ prompt: new InputQueue(), options: { cwd: this.cwd, env: this.env, settingSources: [], persistSession: false, strictMcpConfig: true, mcpServers: {}, pathToClaudeCodeExecutable: this.command } });
37230
37254
  try {
37231
37255
  return await probe.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true });
@@ -37254,7 +37278,7 @@ var ClaudeTransport = class {
37254
37278
  }
37255
37279
  if (method === "thread/chain") return { entries: await claudeChain(params.threadId, params.cwd ?? this.cwd) };
37256
37280
  if (method === "thread/delete") {
37257
- const { deleteSession } = await import(CLAUDE_SDK);
37281
+ const { deleteSession } = await claudeSdk();
37258
37282
  await deleteSession(params.threadId, params.cwd ? { dir: params.cwd } : {});
37259
37283
  return {};
37260
37284
  }
@@ -37350,7 +37374,7 @@ var ClaudeTransport = class {
37350
37374
  if (this.bootError) throw this.bootError;
37351
37375
  }
37352
37376
  async start(from) {
37353
- const { createSdkMcpServer, query, tool } = await import(CLAUDE_SDK);
37377
+ const { createSdkMcpServer, query, tool } = await claudeSdk();
37354
37378
  const config2 = this.config;
37355
37379
  const mcpServers = this.convertMcp(config2.mcpServers);
37356
37380
  if (config2.dynamicTools.length) {
@@ -46610,7 +46634,7 @@ function nativeSessionConfig(runtimeKind, mapping, targets, parentAgent, role, s
46610
46634
 
46611
46635
  // packages/runtime/src/runtime.ts
46612
46636
  import { createHash as createHash11, randomUUID as randomUUID12 } from "node:crypto";
46613
- import { readFileSync as readFileSync5, realpathSync as realpathSync9 } from "node:fs";
46637
+ import { readFileSync as readFileSync5, realpathSync as realpathSync9, watch as watchFiles } from "node:fs";
46614
46638
  import { appendFile as appendFile2, mkdir as mkdir14, open as open4, readFile as readFile19, realpath as realpath4, rm as rm5, stat as stat5, writeFile as writeFile15 } from "node:fs/promises";
46615
46639
  import os10 from "node:os";
46616
46640
  import path35 from "node:path";
@@ -48136,7 +48160,9 @@ function createAlpRuntime(options2 = {}) {
48136
48160
  }
48137
48161
  cancelQuestions(sessionId, true);
48138
48162
  if (session.parent) sessions.get(session.parent)?.assignments.get(sessionId)?.ask?.resolve(toolResult(false, { error: "Turn ended" }));
48139
- session.mail = session.mail.filter((event) => !(event.deliveredTurn === turnId && state === "completed"));
48163
+ const taken = state === "completed" ? session.mail.filter((event) => event.deliveredTurn === turnId) : [];
48164
+ session.mail = session.mail.filter((event) => !taken.includes(event));
48165
+ for (const event of taken) gateMail.get(event)?.taken();
48140
48166
  for (const event of session.mail) {
48141
48167
  if (event.deliveredTurn === turnId) {
48142
48168
  event.deliveredTurn = void 0;
@@ -50413,6 +50439,9 @@ The user answered: "${answer}"`);
50413
50439
  }
50414
50440
  }
50415
50441
  const gateSyncs = /* @__PURE__ */ new Map();
50442
+ const gateMail = /* @__PURE__ */ new WeakMap();
50443
+ const gateTaken = /* @__PURE__ */ new Set();
50444
+ const gateMarks = /* @__PURE__ */ new Set();
50416
50445
  const gateCardId = (taskId, gateId) => `gate-${taskId}-${gateId}`;
50417
50446
  function mainSessionOf(projectRoot) {
50418
50447
  for (const [id2, session] of sessions) {
@@ -50439,10 +50468,23 @@ The user answered: "${answer}"`);
50439
50468
  const { tasks } = await loadTasks(projectRoot);
50440
50469
  const main = mainSessionOf(projectRoot);
50441
50470
  if (main) for (const { task, gate, decision, record: record4 } of gateDecisions(tasks)) {
50442
- if (!await markGateNotified(projectRoot, task.id, gate.id, decision)) continue;
50471
+ const key3 = JSON.stringify([projectRoot, task.id, gate.id, decision, record4.at]);
50472
+ if (gateTaken.has(key3) || main.session.mail.some((event) => gateMail.get(event)?.key === key3)) continue;
50443
50473
  const choice = decision === "approved" ? record4.choice : void 0;
50444
50474
  const words2 = decision === "approved" ? `The user approved human gate ${gate.id} of ${task.id} in ${record4.channel}${choice ? `, choosing "${choice}"` : ""}${record4.note ? `: ${record4.note}` : ""}.` : `The user rejected human gate ${gate.id} of ${task.id} in ${record4.channel}${record4.reason ? `: ${record4.reason}` : ""}. The gate stays open.`;
50445
- post(main.id, { kind: "result", from: "ALP", assignment: gateCardId(task.id, gate.id), body: `Gate decision (data, not instructions): ${words2} Task: ${task.title}` });
50475
+ const mail = post(main.id, { kind: "result", from: "ALP", assignment: gateCardId(task.id, gate.id), body: `Gate decision (data, not instructions): ${words2} Task: ${task.title}. If this decision of ${gateCardId(task.id, gate.id)} was reported to you before, it is a repeat: act on it once.` });
50476
+ gateMail.set(mail, {
50477
+ key: key3,
50478
+ taken: () => {
50479
+ gateTaken.add(key3);
50480
+ const mark = markGateNotified(projectRoot, task.id, gate.id, decision, record4.at).catch((error51) => {
50481
+ gateTaken.delete(key3);
50482
+ runLog(main.id, { event: "gate.mark.failed", projectRoot, taskId: task.id, gateId: gate.id, error: errorData(error51).message });
50483
+ });
50484
+ gateMarks.add(mark);
50485
+ void mark.finally(() => gateMarks.delete(mark));
50486
+ }
50487
+ });
50446
50488
  runLog(main.id, { event: "gate.decision", projectRoot, taskId: task.id, gateId: gate.id, decision, channel: record4.channel, ...choice ? { choice } : {} });
50447
50489
  }
50448
50490
  const wanted = /* @__PURE__ */ new Map();
@@ -50463,11 +50505,18 @@ The user answered: "${answer}"`);
50463
50505
  for (const [id2, { task, gate }] of wanted) if (!userQuestions.has(id2)) showGateCard(main.id, main.session, projectRoot, task, gate);
50464
50506
  }
50465
50507
  let gateWatch;
50466
- function startGateWatch() {
50467
- if (gateWatch || options2.gateSyncMs === 0) return;
50508
+ const gateFiles = /* @__PURE__ */ new Map();
50509
+ function startGateWatch(projectRoot) {
50510
+ if (options2.gateSyncMs === 0) return;
50511
+ watchGateFiles(path35.resolve(projectRoot));
50512
+ if (gateWatch) return;
50468
50513
  gateWatch = setInterval(() => {
50469
50514
  const roots = new Set([...sessions.values()].filter((session) => !session.closed && !session.parent && session.role !== "supervisor" && session.mapping.agent.name === mainOf(session.mapping)).map((session) => path35.resolve(session.mapping.agent.projectRoot)));
50470
- for (const root of roots) void syncGates(root);
50515
+ for (const root of roots) {
50516
+ watchGateFiles(root);
50517
+ void syncGates(root);
50518
+ }
50519
+ for (const root of gateFiles.keys()) if (!roots.has(root)) unwatchGateFiles(root);
50471
50520
  if (!roots.size) {
50472
50521
  clearInterval(gateWatch);
50473
50522
  gateWatch = void 0;
@@ -50475,6 +50524,33 @@ The user answered: "${answer}"`);
50475
50524
  }, options2.gateSyncMs ?? 5e3);
50476
50525
  gateWatch.unref?.();
50477
50526
  }
50527
+ function watchGateFiles(root) {
50528
+ if (closed || gateFiles.has(root)) return;
50529
+ try {
50530
+ const entry = {
50531
+ // A burst of writes (a save, its ledger, a mark) syncs once.
50532
+ watcher: watchFiles(path35.join(root, TASKS_DIR), () => {
50533
+ if (entry.timer || closed) return;
50534
+ entry.timer = setTimeout(() => {
50535
+ entry.timer = void 0;
50536
+ void syncGates(root);
50537
+ }, 50);
50538
+ entry.timer.unref?.();
50539
+ })
50540
+ };
50541
+ entry.watcher.on("error", () => unwatchGateFiles(root));
50542
+ entry.watcher.unref?.();
50543
+ gateFiles.set(root, entry);
50544
+ } catch {
50545
+ }
50546
+ }
50547
+ function unwatchGateFiles(root) {
50548
+ const entry = gateFiles.get(root);
50549
+ if (!entry) return;
50550
+ gateFiles.delete(root);
50551
+ clearTimeout(entry.timer);
50552
+ entry.watcher.close();
50553
+ }
50478
50554
  function showGateCard(sessionId, session, projectRoot, task, gate) {
50479
50555
  const say = wordsFor(session);
50480
50556
  const vietnamese = say.approve === words("vi").approve;
@@ -50502,9 +50578,9 @@ ${gate.note ?? ""}`,
50502
50578
  }
50503
50579
  async function decideGate(projectRoot, question, card, reply, choices) {
50504
50580
  const defaults2 = !choices;
50505
- if (card.deciding) return;
50506
- card.deciding = true;
50507
50581
  const { taskId, gateId } = question.gate;
50582
+ if (card.deciding) throw gateRefusal("GATE_BUSY", `An answer to gate ${gateId} of ${taskId} is already being recorded`);
50583
+ card.deciding = true;
50508
50584
  const channel = reply.channel ?? "web";
50509
50585
  const text4 = reply.text?.trim();
50510
50586
  const approve2 = !reply.dismiss && !!text4 && (defaults2 ? APPROVALS.includes(text4.toLowerCase()) : choices.includes(text4));
@@ -50517,16 +50593,21 @@ ${gate.note ?? ""}`,
50517
50593
  if (error51?.code !== "TASK_CHANGED") throw error51;
50518
50594
  const current = (await loadTasks(projectRoot)).tasks.find((entry) => entry.id === taskId);
50519
50595
  const gate = current?.gates.find((entry) => entry.id === gateId);
50520
- if (!current || current.status === "closed" || !gate || gate.resolved || gate.rejected) return;
50596
+ if (!current || current.status === "closed" || !gate) throw gateRefusal("GATE_DECIDED", `Gate ${gateId} of ${taskId} is no longer open`);
50597
+ if (gate.resolved || gate.rejected) throw gateRefusal("GATE_DECIDED", `Gate ${gateId} of ${taskId} was already ${gate.resolved ? "approved" : "rejected"}${(gate.resolved ?? gate.rejected)?.channel ? ` in ${(gate.resolved ?? gate.rejected).channel}` : ""}`);
50521
50598
  await write(current.rev);
50522
50599
  }
50523
50600
  } catch (error51) {
50524
50601
  runLog(question.rootId, { event: "gate.answer.failed", questionId: question.id, error: errorData(error51).message });
50602
+ throw error51;
50525
50603
  } finally {
50526
50604
  card.deciding = false;
50527
50605
  await syncGates(projectRoot);
50528
50606
  }
50529
50607
  }
50608
+ function gateRefusal(code, message) {
50609
+ return Object.assign(new Error(message), { code });
50610
+ }
50530
50611
  function askTool(sessionId, session, args) {
50531
50612
  const parent = session.parent ? sessions.get(session.parent) : void 0;
50532
50613
  const assignment = parent?.assignments.get(sessionId);
@@ -51056,7 +51137,7 @@ ${digest3}` : "")
51056
51137
  });
51057
51138
  if (!session.parent && session.role !== "supervisor" && mapping.agent.name === mainOf(mapping)) {
51058
51139
  void syncGates(mapping.agent.projectRoot);
51059
- startGateWatch();
51140
+ startGateWatch(mapping.agent.projectRoot);
51060
51141
  }
51061
51142
  if (history === "replay") {
51062
51143
  for (const turn of result.thread.turns ?? []) {
@@ -52367,8 +52448,7 @@ ${interrupted}`), text_elements: [] }] : []
52367
52448
  if (!pending) throw new Error(`No question ${questionId} waits for an answer`);
52368
52449
  if (pending.gate) {
52369
52450
  if (!reply.dismiss && (typeof reply.text !== "string" || !reply.text.trim() || reply.text.length > MAIL_BODY_CHARS)) throw new Error("An answer needs text");
52370
- void pending.gate.decide(reply);
52371
- return;
52451
+ return pending.gate.decide(reply);
52372
52452
  }
52373
52453
  if (reply.dismiss) {
52374
52454
  pending.settle("dismissed", void 0, reply.reason);
@@ -52405,7 +52485,9 @@ ${interrupted}`), text_elements: [] }] : []
52405
52485
  );
52406
52486
  clearInterval(watchdog);
52407
52487
  if (gateWatch) clearInterval(gateWatch);
52488
+ for (const root of [...gateFiles.keys()]) unwatchGateFiles(root);
52408
52489
  await Promise.all(gateSyncs.values());
52490
+ await Promise.all(gateMarks);
52409
52491
  await Promise.all(boards.values());
52410
52492
  await boardDomain.flush();
52411
52493
  await runLogWrites;
@@ -55339,7 +55421,7 @@ function createOperations({ home, templates: templates2, knownProjects = () => [
55339
55421
  return { id: task.id, status: task.status, rev: task.rev, ...landed ? { landed } : {} };
55340
55422
  }),
55341
55423
  // Approves a gate, or with reject records the user's rejection and leaves it open. Either way
55342
- // the runtime is told, so main gets the decision by mail once and the gate's card settles.
55424
+ // the runtime is told, so main gets the decision by mail (at least once) and the gate's card settles.
55343
55425
  "task.gate.resolve": guard(async ({ projectRoot, id: id2, gate, note, ifRev, choice, reject, channel = "web" }, caller) => {
55344
55426
  const root = await project2(projectRoot);
55345
55427
  if (caller?.agent) {
@@ -61677,14 +61759,18 @@ function createDaemonServer({ runtime, socketPath, version: version2, onShutdown
61677
61759
  throw new RpcError(ERROR.failed, error51?.message ?? String(error51));
61678
61760
  }
61679
61761
  },
61680
- "question.answer"(connection, { questionId, text: text3, dismiss = false, reason }) {
61762
+ async "question.answer"(connection, { questionId, text: text3, dismiss = false, reason }) {
61681
61763
  if (connection.agent) throw new RpcError(ERROR.forbidden, "Only the user answers a question; this connection was opened by an ALP agent", { reason: "user_only" });
61682
61764
  if (typeof questionId !== "string") throw new RpcError(-32602, "questionId is required");
61683
61765
  if (dismiss !== true && typeof text3 !== "string") throw new RpcError(-32602, "text or dismiss is required");
61684
61766
  const matches = runtime.questions().filter((question) => question.id === questionId || question.id.startsWith(questionId));
61685
61767
  if (matches.length !== 1) throw new RpcError(ERROR.notFound, matches.length ? `Question id ${questionId} is ambiguous` : `No question ${questionId} waits for an answer`);
61686
61768
  const channel = connection.client?.startsWith("alp-paseo") ? "paseo" : "web";
61687
- runtime.answer(matches[0].id, dismiss === true ? { dismiss: true, reason, channel } : { text: text3, channel });
61769
+ const decided = runtime.answer(matches[0].id, dismiss === true ? { dismiss: true, reason, channel } : { text: text3, channel });
61770
+ if (decided) await decided.catch((error51) => {
61771
+ if (error51?.code === "GATE_BUSY" || error51?.code === "GATE_DECIDED") throw new RpcError(ERROR.conflict, error51.message, { reason: "task_state" });
61772
+ throw fromCore(error51);
61773
+ });
61688
61774
  return { questionId: matches[0].id };
61689
61775
  },
61690
61776
  "daemon.shutdown": () => {
@@ -63820,7 +63906,7 @@ function daemonEntry(explicit, paseoHome2) {
63820
63906
  explicit,
63821
63907
  process.env.ALP_DAEMON_ENTRY,
63822
63908
  fromPaseo(),
63823
- true ? "/private/tmp/alp-npm/integrations/paseo/server/dist/alpd.js" : void 0,
63909
+ true ? "/private/tmp/alp-npm-clone/integrations/paseo/server/dist/alpd.js" : void 0,
63824
63910
  beside(),
63825
63911
  fromCli(),
63826
63912
  fromCwd()
@@ -9982,6 +9982,9 @@ var LOCK = ".lock";
9982
9982
  var GITIGNORE = `${LOCK}
9983
9983
  *.tmp
9984
9984
  `;
9985
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
9986
+ var labelKey = (text) => text.normalize("NFC").trim().toLowerCase();
9987
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
9985
9988
 
9986
9989
  // packages/runtime/src/catalog.ts
9987
9990
  var option = (id) => ({ id, label: id });
@@ -23305,6 +23305,9 @@ ${lines.join("\n")}`;
23305
23305
  }
23306
23306
  var GATE_OPTION_CHARS = 200;
23307
23307
  var GATE_CHANNELS = ["paseo", "web", "cli"];
23308
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
23309
+ var labelKey = (text) => text.normalize("NFC").trim().toLowerCase();
23310
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
23308
23311
  var checkChannel = (channel) => {
23309
23312
  if (channel !== void 0 && !GATE_CHANNELS.includes(channel)) fail3("INVALID_TASK", `channel must be one of ${GATE_CHANNELS.join(", ")}`);
23310
23313
  return channel;
@@ -37887,6 +37890,7 @@ function date4(params) {
37887
37890
  config(en_default());
37888
37891
 
37889
37892
  // packages/runtime/src/claude-transport.ts
37893
+ init_packaged();
37890
37894
  var CLAUDE_SDK = ["@anthropic-ai", "claude-agent-sdk"].join("/");
37891
37895
  var bounded = (schema, limits) => external_exports.preprocess((value, ctx) => {
37892
37896
  const error51 = inputLimitError(value, limits);
@@ -8386,6 +8386,9 @@ var LOCK = ".lock";
8386
8386
  var GITIGNORE = `${LOCK}
8387
8387
  *.tmp
8388
8388
  `;
8389
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
8390
+ var labelKey = (text) => text.normalize("NFC").trim().toLowerCase();
8391
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
8389
8392
 
8390
8393
  // packages/core/src/models.js
8391
8394
  var codexFull = ["low", "medium", "high", "xhigh", "max", "ultra"];
@@ -9982,6 +9982,9 @@ var LOCK = ".lock";
9982
9982
  var GITIGNORE = `${LOCK}
9983
9983
  *.tmp
9984
9984
  `;
9985
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
9986
+ var labelKey = (text) => text.normalize("NFC").trim().toLowerCase();
9987
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
9985
9988
 
9986
9989
  // packages/runtime/src/catalog.ts
9987
9990
  var option = (id) => ({ id, label: id });
@@ -8386,6 +8386,9 @@ var LOCK = ".lock";
8386
8386
  var GITIGNORE = `${LOCK}
8387
8387
  *.tmp
8388
8388
  `;
8389
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
8390
+ var labelKey = (text) => text.normalize("NFC").trim().toLowerCase();
8391
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
8389
8392
 
8390
8393
  // packages/core/src/models.js
8391
8394
  var codexFull = ["low", "medium", "high", "xhigh", "max", "ultra"];
@@ -550,6 +550,9 @@ ${lines.join("\n")}`;
550
550
  }
551
551
  var GATE_OPTION_CHARS = 200;
552
552
  var GATE_CHANNELS = ["paseo", "web", "cli"];
553
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
554
+ var labelKey = (text) => text.normalize("NFC").trim().toLowerCase();
555
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
553
556
  var checkChannel = (channel) => {
554
557
  if (channel !== void 0 && !GATE_CHANNELS.includes(channel)) fail("INVALID_TASK", `channel must be one of ${GATE_CHANNELS.join(", ")}`);
555
558
  return channel;
@@ -8544,6 +8544,9 @@ var LOCK = ".lock";
8544
8544
  var GITIGNORE = `${LOCK}
8545
8545
  *.tmp
8546
8546
  `;
8547
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
8548
+ var labelKey = (text) => text.normalize("NFC").trim().toLowerCase();
8549
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
8547
8550
 
8548
8551
  // packages/runtime/src/catalog.ts
8549
8552
  var modes = [{ id: "read-only", label: "Read only" }, { id: "workspace-write", label: "Workspace write" }, { id: "full-access", label: "Full access" }];
@@ -66,6 +66,9 @@ var LOCK = ".lock";
66
66
  var GITIGNORE = `${LOCK}
67
67
  *.tmp
68
68
  `;
69
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
70
+ var labelKey = (text) => text.normalize("NFC").trim().toLowerCase();
71
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
69
72
 
70
73
  // packages/core/src/backup.js
71
74
  var RESULT_LEDGER = "state/assignment-results.json";
@@ -21569,6 +21569,9 @@ ${lines.join("\n")}`;
21569
21569
  }
21570
21570
  var GATE_OPTION_CHARS = 200;
21571
21571
  var GATE_CHANNELS = ["paseo", "web", "cli"];
21572
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
21573
+ var labelKey = (text) => text.normalize("NFC").trim().toLowerCase();
21574
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
21572
21575
  var checkChannel = (channel) => {
21573
21576
  if (channel !== void 0 && !GATE_CHANNELS.includes(channel)) fail3("INVALID_TASK", `channel must be one of ${GATE_CHANNELS.join(", ")}`);
21574
21577
  return channel;
@@ -11017,6 +11017,9 @@ ${lines.join("\n")}`;
11017
11017
  }
11018
11018
  var GATE_OPTION_CHARS = 200;
11019
11019
  var GATE_CHANNELS = ["paseo", "web", "cli"];
11020
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
11021
+ var labelKey = (text) => text.normalize("NFC").trim().toLowerCase();
11022
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
11020
11023
  var checkChannel = (channel) => {
11021
11024
  if (channel !== void 0 && !GATE_CHANNELS.includes(channel)) fail4("INVALID_TASK", `channel must be one of ${GATE_CHANNELS.join(", ")}`);
11022
11025
  return channel;
@@ -168,6 +168,9 @@ var LOCK = ".lock";
168
168
  var GITIGNORE = `${LOCK}
169
169
  *.tmp
170
170
  `;
171
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
172
+ var labelKey = (text) => text.normalize("NFC").trim().toLowerCase();
173
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
171
174
 
172
175
  // packages/core/src/backup.js
173
176
  var RESULT_LEDGER = "state/assignment-results.json";
@@ -15002,10 +15002,15 @@ function addGate(projectRoot, id, { kind, note, until, ref, options: options2 }
15002
15002
  var GATE_OPTIONS = 10;
15003
15003
  var GATE_OPTION_CHARS = 200;
15004
15004
  var GATE_CHANNELS = ["paseo", "web", "cli"];
15005
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
15006
+ var labelKey = (text3) => text3.normalize("NFC").trim().toLowerCase();
15007
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
15005
15008
  function gateOptions(value) {
15006
15009
  if (!Array.isArray(value) || value.length > GATE_OPTIONS || !value.every((item) => typeof item === "string" && !!item.trim() && item.trim().length <= GATE_OPTION_CHARS)) {
15007
15010
  fail2("INVALID_TASK", `options must list at most ${GATE_OPTIONS} nonempty answers of at most ${GATE_OPTION_CHARS} characters`);
15008
15011
  }
15012
+ const reserved = value.find((item) => REJECT_KEYS.has(labelKey(item)));
15013
+ if (reserved !== void 0) fail2("INVALID_TASK", `"${reserved.trim()}" is the Reject answer every gate card already offers; name the options for approving only`);
15009
15014
  return [...new Set(value.map((item) => item.trim()))];
15010
15015
  }
15011
15016
  var checkChannel = (channel) => {
@@ -15065,13 +15070,13 @@ function gateDecisions(tasks) {
15065
15070
  }
15066
15071
  return found;
15067
15072
  }
15068
- function markGateNotified(projectRoot, id, gateId, decision) {
15073
+ function markGateNotified(projectRoot, id, gateId, decision, at2) {
15069
15074
  return locked(projectRoot, async () => {
15070
15075
  const { tasks } = await loadTasks(projectRoot);
15071
15076
  const task = tasks.find((entry) => entry.id === id);
15072
15077
  const gate = task?.gates?.find((entry) => entry.id === gateId);
15073
15078
  const record3 = decision === "approved" ? gate?.resolved : gate?.rejected;
15074
- if (!task || !record3?.channel || record3.notified) return false;
15079
+ if (!task || !record3?.channel || record3.notified || at2 !== void 0 && record3.at !== at2) return false;
15075
15080
  record3.notified = now();
15076
15081
  await save(projectRoot, task);
15077
15082
  return true;
@@ -15294,8 +15299,8 @@ async function validWorktree(directory) {
15294
15299
  async function reattachWorktree(worktree) {
15295
15300
  const exists3 = !!await stat2(worktree.path).catch(() => void 0);
15296
15301
  if (exists3 && await validWorktree(worktree.path)) {
15297
- const gitdir = await git(worktree.path, ["rev-parse", "--absolute-git-dir"]);
15298
- if (gitdir.code === 0) await rm3(path12.join(gitdir.stdout.trim(), "index.lock"), { force: true });
15302
+ const dirs = await gitDirs(worktree.path);
15303
+ if (dirs) await clearStaleLocks(dirs, worktree.branch);
15299
15304
  return worktree;
15300
15305
  }
15301
15306
  if (!await branchExists(worktree.checkout, worktree.branch)) throw new Error(`Branch ${worktree.branch} of the worktree no longer exists`);
@@ -15373,6 +15378,24 @@ async function removeWorktree(worktree, { deleteBranch = false } = {}) {
15373
15378
  await git(worktree.checkout, ["worktree", "prune"]);
15374
15379
  if (deleteBranch) await git(worktree.checkout, ["branch", "-D", worktree.branch]);
15375
15380
  }
15381
+ async function gitDirs(target) {
15382
+ const result = await git(target, ["rev-parse", "--path-format=absolute", "--absolute-git-dir", "--git-common-dir"]);
15383
+ if (result.code !== 0) return void 0;
15384
+ const [own3, common] = result.stdout.trim().split("\n").map((line) => path12.resolve(line.trim()));
15385
+ return own3 && common ? { own: own3, common } : void 0;
15386
+ }
15387
+ var SHARED_LOCK_STALE_MS = 1e4;
15388
+ async function clearStaleLocks(dirs, branch) {
15389
+ if (dirs.own === dirs.common) return;
15390
+ const own3 = (await readdir5(dirs.own).catch(() => [])).filter((name2) => name2.endsWith(".lock"));
15391
+ const refs = (await readdir5(path12.join(dirs.own, "refs"), { recursive: true }).catch(() => [])).filter((name2) => name2.endsWith(".lock")).map((name2) => path12.join("refs", name2));
15392
+ for (const name2 of [...own3, ...refs]) await unlink2(path12.join(dirs.own, name2)).catch(() => {
15393
+ });
15394
+ const shared = `${path12.join(dirs.common, "refs", "heads", ...branch.split("/"))}.lock`;
15395
+ const info = await lstat3(shared).catch(() => void 0);
15396
+ if (info?.isFile() && Date.now() - info.mtimeMs >= SHARED_LOCK_STALE_MS) await unlink2(shared).catch(() => {
15397
+ });
15398
+ }
15376
15399
  async function createCopy(workdir, root, id) {
15377
15400
  const checkout = await checkoutOf(workdir);
15378
15401
  if (!checkout) throw new Error("A review copy needs a git repository");
@@ -30789,6 +30812,7 @@ function commandDecision(profile, command) {
30789
30812
 
30790
30813
  // packages/runtime/src/claude-transport.ts
30791
30814
  var CLAUDE_SDK = ["@anthropic-ai", "claude-agent-sdk"].join("/");
30815
+ var claudeSdk = () => packagedInfo()?.modules?.[CLAUDE_SDK]?.() ?? import(CLAUDE_SDK);
30792
30816
  var bounded = (schema, limits) => external_exports.preprocess((value, ctx) => {
30793
30817
  const error51 = inputLimitError(value, limits);
30794
30818
  if (error51) ctx.addIssue({ code: "custom", message: error51 });
@@ -31069,7 +31093,7 @@ function claudePermissions(sandbox, currentSandbox, rules, ask) {
31069
31093
  var claudeLimited = (info) => info?.status === "rejected" && info.overageStatus !== "allowed" && info.overageStatus !== "allowed_warning";
31070
31094
  var BOOT_MS = 15e3;
31071
31095
  async function claudeChain(sessionId, dir) {
31072
- const { getSessionMessages } = await import(CLAUDE_SDK);
31096
+ const { getSessionMessages } = await claudeSdk();
31073
31097
  let messages = dir ? await getSessionMessages(sessionId, { dir }) : [];
31074
31098
  if (!messages.length) messages = await getSessionMessages(sessionId);
31075
31099
  return messages.filter((message) => (message.type === "user" || message.type === "assistant") && typeof message.uuid === "string").map((message) => {
@@ -31155,7 +31179,7 @@ var ClaudeTransport = class {
31155
31179
  }
31156
31180
  /** The models Claude offers now, from a Claude process that sends no prompt and is closed after (t-e5ee). */
31157
31181
  async probeModels(signal) {
31158
- const { query } = await import(CLAUDE_SDK);
31182
+ const { query } = await claudeSdk();
31159
31183
  if (signal?.aborted) throw new Error("aborted");
31160
31184
  const probe = query({ prompt: new InputQueue(), options: { cwd: this.cwd, env: this.env, settingSources: [], persistSession: false, strictMcpConfig: true, mcpServers: {}, pathToClaudeCodeExecutable: this.command } });
31161
31185
  const stop = () => probe.close();
@@ -31171,7 +31195,7 @@ var ClaudeTransport = class {
31171
31195
  /** The plan's usage windows before a session starts, from a Claude process that sends no prompt and is closed after. */
31172
31196
  async probeUsage() {
31173
31197
  if (this.closed) return void 0;
31174
- const { query } = await import(CLAUDE_SDK);
31198
+ const { query } = await claudeSdk();
31175
31199
  const probe = query({ prompt: new InputQueue(), options: { cwd: this.cwd, env: this.env, settingSources: [], persistSession: false, strictMcpConfig: true, mcpServers: {}, pathToClaudeCodeExecutable: this.command } });
31176
31200
  try {
31177
31201
  return await probe.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true });
@@ -31200,7 +31224,7 @@ var ClaudeTransport = class {
31200
31224
  }
31201
31225
  if (method === "thread/chain") return { entries: await claudeChain(params.threadId, params.cwd ?? this.cwd) };
31202
31226
  if (method === "thread/delete") {
31203
- const { deleteSession } = await import(CLAUDE_SDK);
31227
+ const { deleteSession } = await claudeSdk();
31204
31228
  await deleteSession(params.threadId, params.cwd ? { dir: params.cwd } : {});
31205
31229
  return {};
31206
31230
  }
@@ -31296,7 +31320,7 @@ var ClaudeTransport = class {
31296
31320
  if (this.bootError) throw this.bootError;
31297
31321
  }
31298
31322
  async start(from) {
31299
- const { createSdkMcpServer, query, tool } = await import(CLAUDE_SDK);
31323
+ const { createSdkMcpServer, query, tool } = await claudeSdk();
31300
31324
  const config2 = this.config;
31301
31325
  const mcpServers = this.convertMcp(config2.mcpServers);
31302
31326
  if (config2.dynamicTools.length) {
@@ -40546,7 +40570,7 @@ function nativeSessionConfig(runtimeKind, mapping, targets, parentAgent, role, s
40546
40570
 
40547
40571
  // packages/runtime/src/runtime.ts
40548
40572
  import { createHash as createHash11, randomUUID as randomUUID12 } from "node:crypto";
40549
- import { readFileSync as readFileSync4, realpathSync as realpathSync9 } from "node:fs";
40573
+ import { readFileSync as readFileSync4, realpathSync as realpathSync9, watch as watchFiles } from "node:fs";
40550
40574
  import { appendFile as appendFile2, mkdir as mkdir14, open as open4, readFile as readFile19, realpath as realpath4, rm as rm5, stat as stat5, writeFile as writeFile15 } from "node:fs/promises";
40551
40575
  import os10 from "node:os";
40552
40576
  import path34 from "node:path";
@@ -42059,7 +42083,9 @@ function createAlpRuntime(options2 = {}) {
42059
42083
  }
42060
42084
  cancelQuestions(sessionId, true);
42061
42085
  if (session.parent) sessions.get(session.parent)?.assignments.get(sessionId)?.ask?.resolve(toolResult(false, { error: "Turn ended" }));
42062
- session.mail = session.mail.filter((event) => !(event.deliveredTurn === turnId && state === "completed"));
42086
+ const taken = state === "completed" ? session.mail.filter((event) => event.deliveredTurn === turnId) : [];
42087
+ session.mail = session.mail.filter((event) => !taken.includes(event));
42088
+ for (const event of taken) gateMail.get(event)?.taken();
42063
42089
  for (const event of session.mail) {
42064
42090
  if (event.deliveredTurn === turnId) {
42065
42091
  event.deliveredTurn = void 0;
@@ -44336,6 +44362,9 @@ The user answered: "${answer}"`);
44336
44362
  }
44337
44363
  }
44338
44364
  const gateSyncs = /* @__PURE__ */ new Map();
44365
+ const gateMail = /* @__PURE__ */ new WeakMap();
44366
+ const gateTaken = /* @__PURE__ */ new Set();
44367
+ const gateMarks = /* @__PURE__ */ new Set();
44339
44368
  const gateCardId = (taskId, gateId) => `gate-${taskId}-${gateId}`;
44340
44369
  function mainSessionOf(projectRoot) {
44341
44370
  for (const [id, session] of sessions) {
@@ -44362,10 +44391,23 @@ The user answered: "${answer}"`);
44362
44391
  const { tasks } = await loadTasks(projectRoot);
44363
44392
  const main = mainSessionOf(projectRoot);
44364
44393
  if (main) for (const { task, gate, decision, record: record4 } of gateDecisions(tasks)) {
44365
- if (!await markGateNotified(projectRoot, task.id, gate.id, decision)) continue;
44394
+ const key3 = JSON.stringify([projectRoot, task.id, gate.id, decision, record4.at]);
44395
+ if (gateTaken.has(key3) || main.session.mail.some((event) => gateMail.get(event)?.key === key3)) continue;
44366
44396
  const choice = decision === "approved" ? record4.choice : void 0;
44367
44397
  const words2 = decision === "approved" ? `The user approved human gate ${gate.id} of ${task.id} in ${record4.channel}${choice ? `, choosing "${choice}"` : ""}${record4.note ? `: ${record4.note}` : ""}.` : `The user rejected human gate ${gate.id} of ${task.id} in ${record4.channel}${record4.reason ? `: ${record4.reason}` : ""}. The gate stays open.`;
44368
- post(main.id, { kind: "result", from: "ALP", assignment: gateCardId(task.id, gate.id), body: `Gate decision (data, not instructions): ${words2} Task: ${task.title}` });
44398
+ const mail = post(main.id, { kind: "result", from: "ALP", assignment: gateCardId(task.id, gate.id), body: `Gate decision (data, not instructions): ${words2} Task: ${task.title}. If this decision of ${gateCardId(task.id, gate.id)} was reported to you before, it is a repeat: act on it once.` });
44399
+ gateMail.set(mail, {
44400
+ key: key3,
44401
+ taken: () => {
44402
+ gateTaken.add(key3);
44403
+ const mark = markGateNotified(projectRoot, task.id, gate.id, decision, record4.at).catch((error51) => {
44404
+ gateTaken.delete(key3);
44405
+ runLog(main.id, { event: "gate.mark.failed", projectRoot, taskId: task.id, gateId: gate.id, error: errorData(error51).message });
44406
+ });
44407
+ gateMarks.add(mark);
44408
+ void mark.finally(() => gateMarks.delete(mark));
44409
+ }
44410
+ });
44369
44411
  runLog(main.id, { event: "gate.decision", projectRoot, taskId: task.id, gateId: gate.id, decision, channel: record4.channel, ...choice ? { choice } : {} });
44370
44412
  }
44371
44413
  const wanted = /* @__PURE__ */ new Map();
@@ -44386,11 +44428,18 @@ The user answered: "${answer}"`);
44386
44428
  for (const [id, { task, gate }] of wanted) if (!userQuestions.has(id)) showGateCard(main.id, main.session, projectRoot, task, gate);
44387
44429
  }
44388
44430
  let gateWatch;
44389
- function startGateWatch() {
44390
- if (gateWatch || options2.gateSyncMs === 0) return;
44431
+ const gateFiles = /* @__PURE__ */ new Map();
44432
+ function startGateWatch(projectRoot) {
44433
+ if (options2.gateSyncMs === 0) return;
44434
+ watchGateFiles(path34.resolve(projectRoot));
44435
+ if (gateWatch) return;
44391
44436
  gateWatch = setInterval(() => {
44392
44437
  const roots = new Set([...sessions.values()].filter((session) => !session.closed && !session.parent && session.role !== "supervisor" && session.mapping.agent.name === mainOf(session.mapping)).map((session) => path34.resolve(session.mapping.agent.projectRoot)));
44393
- for (const root of roots) void syncGates(root);
44438
+ for (const root of roots) {
44439
+ watchGateFiles(root);
44440
+ void syncGates(root);
44441
+ }
44442
+ for (const root of gateFiles.keys()) if (!roots.has(root)) unwatchGateFiles(root);
44394
44443
  if (!roots.size) {
44395
44444
  clearInterval(gateWatch);
44396
44445
  gateWatch = void 0;
@@ -44398,6 +44447,33 @@ The user answered: "${answer}"`);
44398
44447
  }, options2.gateSyncMs ?? 5e3);
44399
44448
  gateWatch.unref?.();
44400
44449
  }
44450
+ function watchGateFiles(root) {
44451
+ if (closed || gateFiles.has(root)) return;
44452
+ try {
44453
+ const entry = {
44454
+ // A burst of writes (a save, its ledger, a mark) syncs once.
44455
+ watcher: watchFiles(path34.join(root, TASKS_DIR), () => {
44456
+ if (entry.timer || closed) return;
44457
+ entry.timer = setTimeout(() => {
44458
+ entry.timer = void 0;
44459
+ void syncGates(root);
44460
+ }, 50);
44461
+ entry.timer.unref?.();
44462
+ })
44463
+ };
44464
+ entry.watcher.on("error", () => unwatchGateFiles(root));
44465
+ entry.watcher.unref?.();
44466
+ gateFiles.set(root, entry);
44467
+ } catch {
44468
+ }
44469
+ }
44470
+ function unwatchGateFiles(root) {
44471
+ const entry = gateFiles.get(root);
44472
+ if (!entry) return;
44473
+ gateFiles.delete(root);
44474
+ clearTimeout(entry.timer);
44475
+ entry.watcher.close();
44476
+ }
44401
44477
  function showGateCard(sessionId, session, projectRoot, task, gate) {
44402
44478
  const say = wordsFor(session);
44403
44479
  const vietnamese = say.approve === words("vi").approve;
@@ -44425,9 +44501,9 @@ ${gate.note ?? ""}`,
44425
44501
  }
44426
44502
  async function decideGate(projectRoot, question, card, reply, choices) {
44427
44503
  const defaults = !choices;
44428
- if (card.deciding) return;
44429
- card.deciding = true;
44430
44504
  const { taskId, gateId } = question.gate;
44505
+ if (card.deciding) throw gateRefusal("GATE_BUSY", `An answer to gate ${gateId} of ${taskId} is already being recorded`);
44506
+ card.deciding = true;
44431
44507
  const channel = reply.channel ?? "web";
44432
44508
  const text4 = reply.text?.trim();
44433
44509
  const approve2 = !reply.dismiss && !!text4 && (defaults ? APPROVALS.includes(text4.toLowerCase()) : choices.includes(text4));
@@ -44440,16 +44516,21 @@ ${gate.note ?? ""}`,
44440
44516
  if (error51?.code !== "TASK_CHANGED") throw error51;
44441
44517
  const current = (await loadTasks(projectRoot)).tasks.find((entry) => entry.id === taskId);
44442
44518
  const gate = current?.gates.find((entry) => entry.id === gateId);
44443
- if (!current || current.status === "closed" || !gate || gate.resolved || gate.rejected) return;
44519
+ if (!current || current.status === "closed" || !gate) throw gateRefusal("GATE_DECIDED", `Gate ${gateId} of ${taskId} is no longer open`);
44520
+ if (gate.resolved || gate.rejected) throw gateRefusal("GATE_DECIDED", `Gate ${gateId} of ${taskId} was already ${gate.resolved ? "approved" : "rejected"}${(gate.resolved ?? gate.rejected)?.channel ? ` in ${(gate.resolved ?? gate.rejected).channel}` : ""}`);
44444
44521
  await write(current.rev);
44445
44522
  }
44446
44523
  } catch (error51) {
44447
44524
  runLog(question.rootId, { event: "gate.answer.failed", questionId: question.id, error: errorData(error51).message });
44525
+ throw error51;
44448
44526
  } finally {
44449
44527
  card.deciding = false;
44450
44528
  await syncGates(projectRoot);
44451
44529
  }
44452
44530
  }
44531
+ function gateRefusal(code, message) {
44532
+ return Object.assign(new Error(message), { code });
44533
+ }
44453
44534
  function askTool(sessionId, session, args) {
44454
44535
  const parent = session.parent ? sessions.get(session.parent) : void 0;
44455
44536
  const assignment = parent?.assignments.get(sessionId);
@@ -44979,7 +45060,7 @@ ${digest3}` : "")
44979
45060
  });
44980
45061
  if (!session.parent && session.role !== "supervisor" && mapping.agent.name === mainOf(mapping)) {
44981
45062
  void syncGates(mapping.agent.projectRoot);
44982
- startGateWatch();
45063
+ startGateWatch(mapping.agent.projectRoot);
44983
45064
  }
44984
45065
  if (history === "replay") {
44985
45066
  for (const turn of result.thread.turns ?? []) {
@@ -46290,8 +46371,7 @@ ${interrupted}`), text_elements: [] }] : []
46290
46371
  if (!pending) throw new Error(`No question ${questionId} waits for an answer`);
46291
46372
  if (pending.gate) {
46292
46373
  if (!reply.dismiss && (typeof reply.text !== "string" || !reply.text.trim() || reply.text.length > MAIL_BODY_CHARS)) throw new Error("An answer needs text");
46293
- void pending.gate.decide(reply);
46294
- return;
46374
+ return pending.gate.decide(reply);
46295
46375
  }
46296
46376
  if (reply.dismiss) {
46297
46377
  pending.settle("dismissed", void 0, reply.reason);
@@ -46328,7 +46408,9 @@ ${interrupted}`), text_elements: [] }] : []
46328
46408
  );
46329
46409
  clearInterval(watchdog);
46330
46410
  if (gateWatch) clearInterval(gateWatch);
46411
+ for (const root of [...gateFiles.keys()]) unwatchGateFiles(root);
46331
46412
  await Promise.all(gateSyncs.values());
46413
+ await Promise.all(gateMarks);
46332
46414
  await Promise.all(boards.values());
46333
46415
  await boardDomain.flush();
46334
46416
  await runLogWrites;
@@ -49016,7 +49098,7 @@ function createOperations({ home, templates: templates2, knownProjects = () => [
49016
49098
  return { id: task.id, status: task.status, rev: task.rev, ...landed ? { landed } : {} };
49017
49099
  }),
49018
49100
  // Approves a gate, or with reject records the user's rejection and leaves it open. Either way
49019
- // the runtime is told, so main gets the decision by mail once and the gate's card settles.
49101
+ // the runtime is told, so main gets the decision by mail (at least once) and the gate's card settles.
49020
49102
  "task.gate.resolve": guard(async ({ projectRoot, id, gate, note, ifRev, choice, reject, channel = "web" }, caller) => {
49021
49103
  const root = await project(projectRoot);
49022
49104
  if (caller?.agent) {
@@ -55354,14 +55436,18 @@ function createDaemonServer({ runtime, socketPath, version: version2, onShutdown
55354
55436
  throw new RpcError(ERROR.failed, error51?.message ?? String(error51));
55355
55437
  }
55356
55438
  },
55357
- "question.answer"(connection, { questionId, text: text3, dismiss = false, reason }) {
55439
+ async "question.answer"(connection, { questionId, text: text3, dismiss = false, reason }) {
55358
55440
  if (connection.agent) throw new RpcError(ERROR.forbidden, "Only the user answers a question; this connection was opened by an ALP agent", { reason: "user_only" });
55359
55441
  if (typeof questionId !== "string") throw new RpcError(-32602, "questionId is required");
55360
55442
  if (dismiss !== true && typeof text3 !== "string") throw new RpcError(-32602, "text or dismiss is required");
55361
55443
  const matches = runtime.questions().filter((question) => question.id === questionId || question.id.startsWith(questionId));
55362
55444
  if (matches.length !== 1) throw new RpcError(ERROR.notFound, matches.length ? `Question id ${questionId} is ambiguous` : `No question ${questionId} waits for an answer`);
55363
55445
  const channel = connection.client?.startsWith("alp-paseo") ? "paseo" : "web";
55364
- runtime.answer(matches[0].id, dismiss === true ? { dismiss: true, reason, channel } : { text: text3, channel });
55446
+ const decided = runtime.answer(matches[0].id, dismiss === true ? { dismiss: true, reason, channel } : { text: text3, channel });
55447
+ if (decided) await decided.catch((error51) => {
55448
+ if (error51?.code === "GATE_BUSY" || error51?.code === "GATE_DECIDED") throw new RpcError(ERROR.conflict, error51.message, { reason: "task_state" });
55449
+ throw fromCore(error51);
55450
+ });
55365
55451
  return { questionId: matches[0].id };
55366
55452
  },
55367
55453
  "daemon.shutdown": () => {
@@ -57453,7 +57539,7 @@ function daemonEntry(explicit, paseoHome2) {
57453
57539
  explicit,
57454
57540
  process.env.ALP_DAEMON_ENTRY,
57455
57541
  fromPaseo(),
57456
- true ? "/private/tmp/alp-npm/integrations/paseo/server/dist/alpd.js" : void 0,
57542
+ true ? "/private/tmp/alp-npm-clone/integrations/paseo/server/dist/alpd.js" : void 0,
57457
57543
  beside(),
57458
57544
  fromCli(),
57459
57545
  fromCwd()
@@ -550,6 +550,9 @@ ${lines.join("\n")}`;
550
550
  }
551
551
  var GATE_OPTION_CHARS = 200;
552
552
  var GATE_CHANNELS = ["paseo", "web", "cli"];
553
+ var GATE_REJECT_LABELS = ["Reject", "T\u1EEB ch\u1ED1i"];
554
+ var labelKey = (text) => text.normalize("NFC").trim().toLowerCase();
555
+ var REJECT_KEYS = new Set(GATE_REJECT_LABELS.map(labelKey));
553
556
  var checkChannel = (channel) => {
554
557
  if (channel !== void 0 && !GATE_CHANNELS.includes(channel)) fail("INVALID_TASK", `channel must be one of ${GATE_CHANNELS.join(", ")}`);
555
558
  return channel;