@anhlp/alp-runtime 0.0.0-beta.2 → 0.0.1-beta.3

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/dist/runtime.js CHANGED
@@ -47,6 +47,7 @@ import { addGate, checkGates, resolveGate, blockersOf, childrenOf, closeTask, ep
47
47
  var MAX_WAKES = 8;
48
48
  var USER_REPLY_MS = 6e4;
49
49
  var USER_WROTE = "The user just wrote to you; their message follows. Answer them first in a short message they can read (tool calls alone show them nothing), and steer an assignment with alp_send when their words change its brief. Your assignments keep running: then alp_wait again, or end your turn and ALP wakes you with their results.";
50
+ var TURN_ENDED = "Turn ended before the result arrived. Running assignments keep running; ALP wakes you with their results as mail.";
50
51
  var CHECKED_IN = "Not a result: the assignment keeps running. Act on this mail first (a steer from your requester or the user's words may change what your assignments should do: pass that on with alp_send), then alp_wait again or end your turn.";
51
52
  var RESTART_LIMIT = 3;
52
53
  var RESTART_WINDOW_MS = 10 * 6e4;
@@ -139,11 +140,12 @@ function createAlpRuntime(options = {}) {
139
140
  ...session.parent ? { parentId: session.parent } : {},
140
141
  ...session.toolCallId ? { toolCallId: session.toolCallId } : {},
141
142
  ...session.active ? { activeTurnId: session.active } : {},
142
- ...session.parked ? { parked: session.parked.reason } : {},
143
+ // turn.ended listeners persist the intent before terminal() finishes parking it.
144
+ ...session.parked?.reason ?? session.parkReason ? { parked: session.parked?.reason ?? session.parkReason } : {},
143
145
  ...session.instructionsSha ? { instructionsSha: session.instructionsSha } : {},
144
146
  ...takesImages(session) ? { promptImages: true } : {},
145
147
  ...session.usage ? { usage: session.usage } : {},
146
- busy: !!(session.active || session.pending || session.children.size || session.assignments.size || hasActiveMail(session) || reviewing(session))
148
+ busy: !!(session.active || session.pending || session.parked || session.parkReason || session.children.size || session.assignments.size || hasActiveMail(session) || reviewing(session))
147
149
  };
148
150
  }
149
151
  function terminal(sessionId, session, state, error) {
@@ -161,7 +163,7 @@ function createAlpRuntime(options = {}) {
161
163
  clearTimeout(waiter.timer);
162
164
  waiter.resolve(null);
163
165
  }
164
- cancelQuestions(sessionId);
166
+ cancelQuestions(sessionId, true);
165
167
  if (session.parent) sessions.get(session.parent)?.assignments.get(sessionId)?.ask?.resolve(toolResult(false, { error: "Turn ended" }));
166
168
  session.mail = session.mail.filter((event) => !(event.deliveredTurn === turnId && state === "completed"));
167
169
  for (const event of session.mail) {
@@ -319,7 +321,7 @@ function createAlpRuntime(options = {}) {
319
321
  if (method === "turn/completed" && params.turn.id === session.active) {
320
322
  const state = params.turn.status === "completed" ? "completed" : params.turn.status === "interrupted" ? "canceled" : "failed";
321
323
  if (state === "failed" && LIMIT_ERRORS.includes(params.turn.error?.codexErrorInfo)) {
322
- if (session.parent && session.role !== "supervisor") session.parkReason = `the ${label(session.runtimeKind)} usage limit was reached`;
324
+ if (session.role !== "supervisor") session.parkReason = `the ${label(session.runtimeKind)} usage limit was reached`;
323
325
  void limitReached(session, params.turn.error);
324
326
  }
325
327
  terminal(
@@ -335,6 +337,7 @@ function createAlpRuntime(options = {}) {
335
337
  if (!session || session.closed) return;
336
338
  session.closed = true;
337
339
  cancelQuestions(sessionId);
340
+ approvalLedger.delete(sessionId);
338
341
  releaseClaims(sessionId, session.mapping.agent.projectRoot);
339
342
  await Promise.all(
340
343
  [...session.children, ...session.supervisor ? [session.supervisor] : []].map(closeSession)
@@ -601,7 +604,7 @@ ${output.slice(0, 4e3)}` : ""}`;
601
604
  const root = sessions.get(rootId);
602
605
  const watcher = root?.mapping.team?.supervisor ? root.mapping.team.supervisor.agent : "supervisor";
603
606
  if (!root?.supervisor || entry.from === "supervisor" || entry.agent === watcher) return;
604
- const line = entry.event === "assignment.started" ? `${entry.parentAgent} \u2192 ${entry.agent} assignment ${entry.assignmentId}${entry.taskId ? ` for task ${entry.taskId}` : ""} (${entry.model}, ${entry.thinking ?? "default effort"}, ${entry.mode}, ${entry.isolation}${entry.wait ? "" : ", async"}): ${clip(entry.task)}` : entry.event === "assignment.finished" ? `${entry.agent} assignment ${entry.assignmentId} ${entry.status}${entry.handoff ? `, handoff ${entry.handoff.outcome}${entry.handoff.verdict ? `, ${verdictLine(entry.handoff.verdict)}` : ""}: ${clip(entry.handoff.summary)}` : ", no handoff"}${entry.error ? ` (${clip(entry.error, 200)})` : ""}${entry.handoff?.discovered?.length ? `; discovered: ${clip(entry.handoff.discovered.join("; "))}` : ""}` : entry.event === "mail" && entry.kind !== "result" && entry.kind !== "board" ? `mail ${entry.kind} from ${entry.from}${entry.replyTo ? ` (reply to ${entry.replyTo})` : ""}: ${clip(entry.body ?? "")}` : entry.event === "human.question" ? `${entry.agent} asked the user: ${clip(entry.body)}` : entry.event === "human.answer" ? `user ${entry.outcome} ${entry.questionId}${entry.answer ? `: ${clip(entry.answer)}` : ""}` : entry.event === "board.pin" ? `${entry.agent} pinned ${entry.kind}${entry.paths ? ` ${entry.paths.join(", ")}` : ""}: ${clip(entry.body)}` : entry.event === "lesson" ? `${entry.agent} recorded a ${entry.scope} lesson: ${clip(entry.lesson)}` : entry.event === "skill" && Array.isArray(entry.roles) ? `${entry.agent} saved skill ${entry.name} for ${entry.roles.join(", ")} with the user's approval` : entry.event === "task" ? `${entry.agent} ${TASK_PAST[entry.action] ?? entry.action} task ${entry.id} "${clip(entry.title, 120)}"${entry.status ? ` (now ${entry.status})` : ""}${entry.detail ? `: ${clip(entry.detail)}` : ""}` : entry.event === "epic.landed" ? `${entry.agent} closed ${entry.id} "${clip(entry.title, 120)}": ${entry.tasks} tasks, ${entry.reworked} reworked, ${entry.unverified?.length ?? 0} closed unverified` : entry.event === "recall" ? `${entry.agent} recalled ${entry.recalled} assignment ${entry.assignmentId}: ${clip(entry.question)}` : entry.event === "issue" ? `${entry.action === "create" ? `opened issue "${clip(entry.title, 200)}"` : `commented on issue #${entry.issue}`} in ${entry.repo} with the user's approval: ${entry.url}` : entry.event?.startsWith("worktree.") ? `${entry.event.slice("worktree.".length)} worktree of ${entry.assignmentId} (${entry.branch})${entry.status ? `: ${entry.status}` : ""}` : void 0;
607
+ const line = entry.event === "assignment.started" ? `${entry.parentAgent} \u2192 ${entry.agent} assignment ${entry.assignmentId}${entry.taskId ? ` for task ${entry.taskId}` : ""} (${entry.model}, ${entry.thinking ?? "default effort"}, ${entry.mode}, ${entry.isolation}${entry.wait ? "" : ", async"}): ${clip(entry.task)}` : entry.event === "assignment.finished" ? `${entry.agent} assignment ${entry.assignmentId} ${entry.status}${entry.handoff ? `, handoff ${entry.handoff.outcome}${entry.handoff.verdict ? `, ${verdictLine(entry.handoff.verdict)}` : ""}: ${clip(entry.handoff.summary)}` : ", no handoff"}${entry.error ? ` (${clip(entry.error, 200)})` : ""}${entry.handoff?.discovered?.length ? `; discovered: ${clip(entry.handoff.discovered.join("; "))}` : ""}` : entry.event === "mail" && entry.kind !== "result" && entry.kind !== "board" ? `mail ${entry.kind} from ${entry.from}${entry.replyTo ? ` (reply to ${entry.replyTo})` : ""}: ${clip(entry.body ?? "")}` : entry.event === "human.question" ? `${entry.agent} asked the user: ${clip(entry.body)}` : entry.event === "human.answer" ? `user ${entry.outcome} ${entry.questionId}${entry.answer ? `: ${clip(entry.answer)}` : ""}` : entry.event === "board.pin" ? `${entry.agent} pinned ${entry.kind}${entry.paths ? ` ${entry.paths.join(", ")}` : ""}: ${clip(entry.body)}` : entry.event === "lesson" ? `${entry.agent} recorded a ${entry.scope} lesson: ${clip(entry.lesson)}` : entry.event === "skill" && Array.isArray(entry.roles) ? `${entry.agent} saved skill ${entry.name} for ${entry.roles.join(", ")} with the user's approval` : entry.event === "task" ? `${entry.agent} ${TASK_PAST[entry.action] ?? entry.action} task ${entry.id} "${clip(entry.title, 120)}"${entry.status ? ` (now ${entry.status})` : ""}${entry.detail ? `: ${clip(entry.detail)}` : ""}` : entry.event === "epic.landed" ? `${entry.agent} closed ${entry.id} "${clip(entry.title, 120)}": ${entry.tasks} tasks, ${entry.reworked} reworked, ${entry.unverified?.length ?? 0} closed unverified` : entry.event === "recall" ? `${entry.agent} recalled ${entry.recalled} assignment ${entry.assignmentId}: ${clip(entry.question)}` : entry.event === "skill.approval" && !entry.saved ? `skill approval ${entry.approvalId} for ${entry.name}: not saved (${entry.error ?? entry.outcome})` : entry.event === "issue.approval" ? `issue approval ${entry.approvalId} in ${entry.repo}: ${entry.posted ? entry.url : entry.error ?? entry.outcome}` : entry.event === "issue" ? `${entry.action === "create" ? `opened issue "${clip(entry.title, 200)}"` : `commented on issue #${entry.issue}`} in ${entry.repo} with the user's approval: ${entry.url}` : entry.event?.startsWith("worktree.") ? `${entry.event.slice("worktree.".length)} worktree of ${entry.assignmentId} (${entry.branch})${entry.status ? `: ${entry.status}` : ""}` : void 0;
605
608
  if (line) root.journal.push(stamp(line));
606
609
  }
607
610
  const eventful = (journal) => journal.some((line) => !unstamped(line).startsWith("turn ended:"));
@@ -1036,20 +1039,34 @@ Review the process against ALP.md, ${agent}'s AGENT.md and the recorded lessons.
1036
1039
  else post(session.parent, { kind: "stalled", from: assignment.agent, assignment: sessionId, body: `Parked: ${session.parked.reason}. ALP continues this assignment where it stopped when ${label(session.runtimeKind)} is resumed (${options.autoResume !== false && (pauseOf(session.runtimeKind)?.by ?? "alpd") === "alpd" ? `ALP resumes it as soon as ${label(session.runtimeKind)} says the limit lifted` : "the user runs alp resume"}). Wait for it, start other work, or stop it with alp_cancel and give the rest to an agent on another runtime (pass model claude:\u2026 or codex:\u2026); what it changed stays in your checkout or its worktree.` });
1037
1040
  }
1038
1041
  function continueParked(sessionId, session) {
1039
- const { reason, prompt } = session.parked;
1040
- session.parked = void 0;
1041
- session.wakeHeld = false;
1042
- session.lastActivity = Date.now();
1043
- emit(sessionId, { type: "session.updated", session: snapshot(sessionId, session) });
1044
- const parent = session.parent ? sessions.get(session.parent) : void 0;
1045
- const assignment = parent?.assignments.get(sessionId);
1046
- runLog(rootOf(sessionId), { event: "assignment.resumed", assignmentId: sessionId, agent: session.mapping.agent.name });
1047
- if (assignment) emit(session.parent, { type: "assignment", assignment: assignmentSnapshot(assignment, "running") });
1048
- void startPrompt(sessionId, {
1049
- clientMessageId: `alp-resume-${randomUUID()}`,
1050
- delivery: "auto",
1051
- content: [{ type: "text", text: prompt ?? `ALP resumed this assignment; it had stopped because ${reason}. Continue where you left off: your earlier work in this session and its files are intact. Finish with alp_handoff as before.` }]
1052
- }, session.parent ? "assignment" : "wake").catch((error) => session.settle?.("failed", error));
1042
+ void enqueue(async () => {
1043
+ if (session.closed || session.active || session.pending || !session.parked || pauseOf(session.runtimeKind)) return;
1044
+ const parked = session.parked;
1045
+ const { reason, prompt } = parked;
1046
+ session.parked = void 0;
1047
+ session.wakeHeld = false;
1048
+ session.lastActivity = Date.now();
1049
+ const parent = session.parent ? sessions.get(session.parent) : void 0;
1050
+ const assignment = parent?.assignments.get(sessionId);
1051
+ runLog(rootOf(sessionId), { event: "assignment.resumed", assignmentId: sessionId, agent: session.mapping.agent.name });
1052
+ if (assignment) emit(session.parent, { type: "assignment", assignment: assignmentSnapshot(assignment, "running") });
1053
+ try {
1054
+ await startPrompt(sessionId, {
1055
+ clientMessageId: `alp-resume-${randomUUID()}`,
1056
+ delivery: "auto",
1057
+ content: [{ type: "text", text: prompt ?? (session.parent ? `ALP resumed this assignment; it had stopped because ${reason}. Continue where you left off: your earlier work in this session and its files are intact. Finish with alp_handoff as before.` : `ALP resumed this session; it had stopped because ${reason}. Continue the user's request where you left off: your earlier work in this thread and its files are intact.`) }]
1058
+ }, session.parent ? "assignment" : "wake");
1059
+ session.resumedTurn = session.active;
1060
+ emit(sessionId, { type: "session.updated", session: snapshot(sessionId, session) });
1061
+ } catch (error) {
1062
+ if (!session.closed && !session.active) {
1063
+ session.parked = parked;
1064
+ emit(sessionId, { type: "session.updated", session: snapshot(sessionId, session) });
1065
+ if (assignment) emit(session.parent, { type: "assignment", assignment: assignmentSnapshot(assignment, "parked") });
1066
+ }
1067
+ notice("warning", `Could not resume ${session.mapping.agent.name}: ${errorData(error).message}`);
1068
+ }
1069
+ });
1053
1070
  }
1054
1071
  async function limitReset(session, error) {
1055
1072
  const at = (seconds) => typeof seconds === "number" && seconds > 0 ? new Date(seconds * 1e3).toISOString() : void 0;
@@ -1066,8 +1083,9 @@ Review the process against ALP.md, ${agent}'s AGENT.md and the recorded lessons.
1066
1083
  }
1067
1084
  async function limitReached(session, error) {
1068
1085
  const kind = session.runtimeKind;
1069
- if (paused.runtimes[kind]?.by === "alpd") return;
1086
+ if (pauseOf(kind)) return;
1070
1087
  const resetsAt = await limitReset(session, error);
1088
+ if (closed || session.closed || pauseOf(kind)) return;
1071
1089
  const pause = { since: (/* @__PURE__ */ new Date()).toISOString(), by: "alpd", reason: `${label(kind)} usage limit reached`, ...resetsAt ? { resetsAt } : {} };
1072
1090
  pauseRuntime(kind, pause);
1073
1091
  const other = RUNTIMES.find((candidate) => candidate !== kind && !pauseOf(candidate));
@@ -1208,10 +1226,10 @@ ${cut(session.brief, BRIEF_CHARS)}`);
1208
1226
  function lessonFiles(mapping) {
1209
1227
  return [path.join(mapping.agent.projectRoot, ".alp", LESSONS_FILE), ...options.libraryDir ? [path.join(options.libraryDir, LESSONS_FILE)] : []];
1210
1228
  }
1211
- async function confirm(sessionId, session, body) {
1229
+ async function confirm(sessionId, session, body, approval) {
1212
1230
  if ([...userQuestions.values()].some((pending) => pending.question.sessionId === sessionId)) return { approved: false, outcome: "busy", feedback: "A question is already waiting for the user" };
1213
1231
  const say = wordsFor(session);
1214
- const result = await askUser(sessionId, session, void 0, body, [say.approve, say.reject]);
1232
+ const result = await askUser(sessionId, session, void 0, body, [say.approve, say.reject], approval);
1215
1233
  let value;
1216
1234
  try {
1217
1235
  value = JSON.parse(result.contentItems[0].text);
@@ -1222,25 +1240,45 @@ ${cut(session.brief, BRIEF_CHARS)}`);
1222
1240
  const answer = String(value.answer).trim();
1223
1241
  return APPROVALS.includes(answer.toLowerCase()) ? { approved: true } : { approved: false, outcome: "answered", feedback: answer };
1224
1242
  }
1225
- const declined = (decision) => toolResult(true, {
1226
- approved: false,
1227
- outcome: decision.outcome,
1228
- ...decision.feedback ? { feedback: decision.feedback } : {},
1229
- next: "Nothing was saved or posted. Revise from the feedback and propose again, or drop it."
1230
- });
1231
1243
  const text = (value, limit) => typeof value === "string" && !!value.trim() && value.length <= limit;
1244
+ const approvalCancellation = /* @__PURE__ */ new WeakMap();
1245
+ const approvalLedger = /* @__PURE__ */ new Map();
1246
+ const approvalsOf = (sessionId) => {
1247
+ let approvals = approvalLedger.get(sessionId);
1248
+ if (!approvals) approvalLedger.set(sessionId, approvals = /* @__PURE__ */ new Map());
1249
+ return approvals;
1250
+ };
1251
+ const approvalCanceled = (session, cancellation) => closed || session.closed || cancellation !== (approvalCancellation.get(session) ?? 0);
1232
1252
  async function skillTool(sessionId, session, args) {
1233
- if (!plainObject(args, ["name", "description", "body", "roles", "lessons", "replace"]) || typeof args.name !== "string" || !/^[a-z0-9][a-z0-9-]{1,62}$/.test(args.name) || !text(args.description, 300) || !text(args.body, SKILL_BODY_CHARS) || !Array.isArray(args.roles) || !args.roles.length || args.roles.length > 10 || !args.roles.every((role) => typeof role === "string" && /^[\w.-]+$/.test(role) && role !== "." && role !== "..") || args.lessons !== void 0 && (!Array.isArray(args.lessons) || args.lessons.length > 50 || !args.lessons.every((lesson) => text(lesson, LESSON_CHARS))) || args.replace !== void 0 && typeof args.replace !== "boolean") {
1253
+ const cancellation = approvalCancellation.get(session) ?? 0;
1254
+ args = structuredClone(args);
1255
+ if (!plainObject(args, ["name", "description", "body", "roles", "lessons", "replace", "requestId"]) || typeof args.name !== "string" || !/^[a-z0-9][a-z0-9-]{1,62}$/.test(args.name) || !text(args.description, 300) || !text(args.body, SKILL_BODY_CHARS) || !Array.isArray(args.roles) || !args.roles.length || args.roles.length > 10 || !args.roles.every((role) => typeof role === "string" && /^[\w.-]+$/.test(role) && role !== "." && role !== "..") || args.lessons !== void 0 && (!Array.isArray(args.lessons) || args.lessons.length > 50 || !args.lessons.every((lesson) => text(lesson, LESSON_CHARS))) || args.replace !== void 0 && typeof args.replace !== "boolean") {
1234
1256
  return toolResult(false, { error: `A skill needs a name (lowercase letters, digits, hyphens), a one-line description, a body of at most ${SKILL_BODY_CHARS} characters, and the roles that get it` });
1235
1257
  }
1236
- if (!options.libraryDir) return toolResult(false, { error: "This host keeps no user skill library" });
1237
- const known = /* @__PURE__ */ new Set([...STARTER_ROLES, ...await discoverAgents(session.mapping.agent.projectRoot, { library: options.libraryDir, templates: options.templates })]);
1258
+ if (args.requestId !== void 0 && !text(args.requestId, 100)) return toolResult(false, { error: "requestId must be a nonempty string of at most 100 characters" });
1259
+ const library = options.libraryDir;
1260
+ if (!library) return toolResult(false, { error: "This host keeps no user skill library" });
1261
+ const known = /* @__PURE__ */ new Set([...STARTER_ROLES, ...await discoverAgents(session.mapping.agent.projectRoot, { library, templates: options.templates })]);
1238
1262
  const unknown = args.roles.filter((role) => !known.has(role));
1239
1263
  if (unknown.length) return toolResult(false, { error: `Unknown roles: ${unknown.join(", ")}. Roles are ${[...known].join(", ")}` });
1240
- const file = path.join(options.libraryDir, "skills", args.name, "SKILL.md");
1264
+ const file = path.join(library, "skills", args.name, "SKILL.md");
1265
+ const content = `---
1266
+ name: ${args.name}
1267
+ description: ${JSON.stringify(args.description.replace(/\s+/g, " ").trim())}
1268
+ ---
1269
+
1270
+ ${args.body.trim()}
1271
+ `;
1272
+ const lessons = (args.lessons ?? []).map((lesson) => lesson.replace(/\s+/g, " ").trim());
1273
+ const draftKey = JSON.stringify(["skill", content, args.roles, lessons, !!args.replace]);
1274
+ const key = `skill:${args.requestId ?? draftKey}`;
1275
+ const approvals = approvalsOf(sessionId);
1276
+ const previous = approvals.get(key);
1277
+ if (previous && previous.draftKey !== draftKey) return toolResult(false, { error: "requestId was already used for a different draft" });
1278
+ if (previous) return toolResult(true, previous.result ?? { status: "pending_approval", approvalId: previous.approvalId, file });
1241
1279
  const existing = await readFile(file, "utf8").catch(() => void 0);
1242
1280
  if (existing !== void 0 && !args.replace) return toolResult(false, { error: `The library already has a skill named ${args.name} (${file}); read it, then pass replace: true to propose a new version, or choose another name` });
1243
- const rolesFile = path.join(options.libraryDir, "role-skills.json");
1281
+ const rolesFile = path.join(library, "role-skills.json");
1244
1282
  const readRoles = async () => {
1245
1283
  const raw = await readFile(rolesFile, "utf8").catch(() => "{}");
1246
1284
  const roles = JSON.parse(raw);
@@ -1252,62 +1290,77 @@ ${cut(session.brief, BRIEF_CHARS)}`);
1252
1290
  } catch (error) {
1253
1291
  return toolResult(false, { error: errorData(error).message });
1254
1292
  }
1255
- const content = `---
1256
- name: ${args.name}
1257
- description: ${JSON.stringify(args.description.replace(/\s+/g, " ").trim())}
1258
- ---
1259
-
1260
- ${args.body.trim()}
1261
- `;
1262
- const lessons = (args.lessons ?? []).map((lesson) => lesson.replace(/\s+/g, " ").trim());
1293
+ if (approvalCanceled(session, cancellation)) return toolResult(false, { error: "Skill proposal canceled or session closed; no approval was created" });
1294
+ if ([...userQuestions.values()].some((pending) => pending.question.sessionId === sessionId)) return toolResult(false, { error: "A question is already waiting for the user; finish it before proposing a skill" });
1295
+ const approvalId = `q-${randomUUID()}`;
1296
+ const record2 = { approvalId, draftKey };
1297
+ approvals.set(key, record2);
1263
1298
  const say = wordsFor(session);
1264
- const decision = await confirm(
1299
+ const decision = confirm(
1265
1300
  sessionId,
1266
1301
  session,
1267
1302
  say.skill(session.mapping.agent.name, existing !== void 0, file, args.roles.join(", "), lessons) + `
1268
1303
  ${say.answerLine(session.mapping.agent.name)}
1269
1304
 
1270
- ${content}`
1305
+ ${content}`,
1306
+ { id: approvalId }
1271
1307
  );
1272
- if (!decision.approved) return declined(decision);
1273
- try {
1274
- await mkdir(path.dirname(file), { recursive: true });
1275
- await writeFile(file, content);
1276
- const roles = await readRoles();
1277
- for (const role of args.roles) {
1278
- const list = Array.isArray(roles[role]) ? roles[role] : [];
1279
- if (!list.includes(args.name)) list.push(args.name);
1280
- roles[role] = list;
1281
- }
1282
- await writeFile(rolesFile, JSON.stringify(roles, null, 2) + "\n");
1283
- } catch (error) {
1284
- return toolResult(false, { error: errorData(error).message });
1285
- }
1286
- const removed = /* @__PURE__ */ new Set();
1287
- for (const lessonFile of lessonFiles(session.mapping)) {
1288
- const current = await readFile(lessonFile, "utf8").catch(() => void 0);
1289
- if (current === void 0) continue;
1290
- const kept = current.split("\n").filter((line) => {
1291
- const match = /^- \d{4}-\d\d-\d\d: (.*)$/.exec(line);
1292
- if (!match || !lessons.includes(match[1].trim())) return true;
1293
- removed.add(match[1].trim());
1294
- return false;
1295
- });
1296
- if (kept.length !== current.split("\n").length) await writeFile(lessonFile, kept.join("\n")).catch(() => {
1297
- });
1298
- }
1299
- runLog(rootOf(sessionId), { event: "skill", agent: session.mapping.agent.name, name: args.name, roles: args.roles, replaced: existing !== void 0, lessons: [...removed] });
1300
- return toolResult(true, {
1301
- approved: true,
1302
- saved: file,
1303
- roles: args.roles,
1304
- lessonsRemoved: [...removed],
1305
- ...lessons.length > removed.size ? { lessonsNotFound: lessons.filter((lesson) => !removed.has(lesson)) } : {},
1306
- next: "New sessions of those roles list the skill; read the file now if you need it in this one."
1307
- });
1308
+ const save = async () => {
1309
+ if (existing === void 0 && !args.replace && await readFile(file, "utf8").then(() => true, () => false)) {
1310
+ return { approved: true, saved: false, error: `A skill named ${args.name} was saved meanwhile (${file}); nothing was overwritten`, next: "Read it, then propose again with replace: true if yours should replace it." };
1311
+ }
1312
+ try {
1313
+ await mkdir(path.dirname(file), { recursive: true });
1314
+ await writeFile(file, content);
1315
+ const roles = await readRoles();
1316
+ for (const role of args.roles) {
1317
+ const list = Array.isArray(roles[role]) ? roles[role] : [];
1318
+ if (!list.includes(args.name)) list.push(args.name);
1319
+ roles[role] = list;
1320
+ }
1321
+ await writeFile(rolesFile, JSON.stringify(roles, null, 2) + "\n");
1322
+ } catch (error) {
1323
+ return { approved: true, saved: false, error: errorData(error).message, next: "Tell the user; nothing is retried automatically." };
1324
+ }
1325
+ const removed = /* @__PURE__ */ new Set();
1326
+ for (const lessonFile of lessonFiles(session.mapping)) {
1327
+ const current = await readFile(lessonFile, "utf8").catch(() => void 0);
1328
+ if (current === void 0) continue;
1329
+ const kept = current.split("\n").filter((line) => {
1330
+ const match = /^- \d{4}-\d\d-\d\d: (.*)$/.exec(line);
1331
+ if (!match || !lessons.includes(match[1].trim())) return true;
1332
+ removed.add(match[1].trim());
1333
+ return false;
1334
+ });
1335
+ if (kept.length !== current.split("\n").length) await writeFile(lessonFile, kept.join("\n")).catch(() => {
1336
+ });
1337
+ }
1338
+ runLog(rootOf(sessionId), { event: "skill", agent: session.mapping.agent.name, name: args.name, roles: args.roles, replaced: existing !== void 0, lessons: [...removed] });
1339
+ return {
1340
+ approved: true,
1341
+ saved: file,
1342
+ roles: args.roles,
1343
+ lessonsRemoved: [...removed],
1344
+ ...lessons.length > removed.size ? { lessonsNotFound: lessons.filter((lesson) => !removed.has(lesson)) } : {},
1345
+ next: "New sessions of those roles list the skill; read the file now if you need it in this one."
1346
+ };
1347
+ };
1348
+ void (async () => {
1349
+ const answer = await decision;
1350
+ const canceled = approvalCanceled(session, cancellation);
1351
+ const result = !answer.approved || canceled ? { approved: false, saved: false, outcome: canceled ? "canceled" : answer.outcome, ...answer.feedback ? { feedback: answer.feedback } : {}, next: "Nothing was saved. Revise from the feedback and propose again with a fresh requestId, or drop it." } : await save();
1352
+ record2.result = { status: "resolved", approvalId, name: args.name, ...result };
1353
+ runLog(rootOf(sessionId), { event: "skill.approval", agent: session.mapping.agent.name, ...record2.result });
1354
+ if (!closed && !session.closed && sessions.get(sessionId) === session) {
1355
+ post(sessionId, { kind: "result", from: "ALP", assignment: approvalId, body: `Skill approval result: ${JSON.stringify(record2.result)}` });
1356
+ }
1357
+ })();
1358
+ return toolResult(true, { status: "pending_approval", approvalId, file, next: "Continue other work or end your turn. ALP sends the user's decision, and whether the skill was saved, by mail; do not resubmit this draft." });
1308
1359
  }
1309
1360
  async function issueTool(sessionId, session, args) {
1310
- if (!plainObject(args, ["action", "target", "query", "title", "body", "issue", "labels"]) || !["search", "create", "comment"].includes(args.action) || !["project", "alp"].includes(args.target)) {
1361
+ const cancellation = approvalCancellation.get(session) ?? 0;
1362
+ args = structuredClone(args);
1363
+ if (!plainObject(args, ["action", "target", "query", "title", "body", "issue", "labels", "requestId"]) || !["search", "create", "comment"].includes(args.action) || !["project", "alp"].includes(args.target)) {
1311
1364
  return toolResult(false, { error: "An issue call needs action (search, create or comment) and target (project or alp)" });
1312
1365
  }
1313
1366
  const project = session.mapping.agent.projectRoot;
@@ -1333,6 +1386,7 @@ ${content}`
1333
1386
  if (args.labels !== void 0 && (args.action !== "create" || !Array.isArray(args.labels) || args.labels.length > 10 || !args.labels.every((label2) => text(label2, 50)))) {
1334
1387
  return toolResult(false, { error: "labels are up to 10 existing label names, for create" });
1335
1388
  }
1389
+ if (args.requestId !== void 0 && !text(args.requestId, 100)) return toolResult(false, { error: "requestId must be a nonempty string of at most 100 characters" });
1336
1390
  if (args.action === "create" && args.labels?.length) {
1337
1391
  try {
1338
1392
  const labels = JSON.parse(await github(["label", "list", "-R", repo, "--limit", "1000", "--json", "name"], { cwd: project }));
@@ -1343,9 +1397,20 @@ ${content}`
1343
1397
  return toolResult(false, { repo, error: `Could not validate issue labels: ${errorData(error).message}` });
1344
1398
  }
1345
1399
  }
1400
+ if (approvalCanceled(session, cancellation)) return toolResult(false, { error: "Issue proposal canceled or session closed; no approval was created" });
1401
+ const draftKey = JSON.stringify([repo, args.action, args.title ?? null, args.issue ?? null, args.body, [...args.labels ?? []].sort()]);
1402
+ const key = `issue:${args.requestId ?? draftKey}`;
1403
+ const approvals = approvalsOf(sessionId);
1404
+ const previous = approvals.get(key);
1405
+ if (previous && previous.draftKey !== draftKey) return toolResult(false, { error: "requestId was already used for a different draft" });
1406
+ if (previous) return toolResult(true, previous.result ?? { status: "pending_approval", approvalId: previous.approvalId, repo });
1407
+ if ([...userQuestions.values()].some((pending) => pending.question.sessionId === sessionId)) return toolResult(false, { error: "A question is already waiting for the user; finish it before proposing another issue draft" });
1408
+ const approvalId = `q-${randomUUID()}`;
1409
+ const record2 = { approvalId, draftKey };
1410
+ approvals.set(key, record2);
1346
1411
  const where = `${repo}${args.target === "alp" ? " (ALP itself)" : ""}`;
1347
1412
  const say = wordsFor(session);
1348
- const decision = await confirm(
1413
+ const decision = confirm(
1349
1414
  sessionId,
1350
1415
  session,
1351
1416
  say.issue(session.mapping.agent.name, args.action === "create", args.issue, where) + `
@@ -1354,18 +1419,31 @@ ${say.answerLine(session.mapping.agent.name)}
1354
1419
  ` + (args.action === "create" ? `${say.issueTitle}: ${args.title}
1355
1420
  ${args.labels?.length ? `${say.issueLabels}: ${args.labels.join(", ")}
1356
1421
  ` : ""}
1357
- ` : "") + args.body
1422
+ ` : "") + args.body.trim() + ISSUE_FOOTER,
1423
+ { id: approvalId }
1358
1424
  );
1359
- if (!decision.approved) return declined(decision);
1360
- const body = args.body.trim() + ISSUE_FOOTER;
1361
- try {
1362
- const output = args.action === "create" ? await github(["issue", "create", "-R", repo, "--title", args.title, "--body-file", "-", ...(args.labels ?? []).flatMap((label2) => ["--label", label2])], { cwd: project, input: body }) : await github(["issue", "comment", String(args.issue), "-R", repo, "--body-file", "-"], { cwd: project, input: body });
1363
- const url = output.trim().split("\n").at(-1) ?? "";
1364
- runLog(rootOf(sessionId), { event: "issue", action: args.action, repo, ...args.title ? { title: args.title } : {}, ...args.issue ? { issue: args.issue } : {}, url });
1365
- return toolResult(true, { approved: true, posted: true, repo, url });
1366
- } catch (error) {
1367
- return toolResult(false, { approved: true, posted: false, repo, error: errorData(error).message, next: "Tell the user; the GitHub CLI may need `gh auth login`." });
1368
- }
1425
+ void (async () => {
1426
+ const answer = await decision;
1427
+ let result;
1428
+ const canceled = approvalCanceled(session, cancellation);
1429
+ if (!answer.approved || canceled) {
1430
+ result = { approved: false, posted: false, outcome: canceled ? "canceled" : answer.outcome, ...answer.feedback ? { feedback: answer.feedback } : {} };
1431
+ } else {
1432
+ try {
1433
+ const body = args.body.trim() + ISSUE_FOOTER;
1434
+ const output = args.action === "create" ? await github(["issue", "create", "-R", repo, "--title", args.title, "--body-file", "-", ...(args.labels ?? []).flatMap((label2) => ["--label", label2])], { cwd: project, input: body }) : await github(["issue", "comment", String(args.issue), "-R", repo, "--body-file", "-"], { cwd: project, input: body });
1435
+ result = { approved: true, posted: true, url: output.trim().split("\n").at(-1) ?? "" };
1436
+ } catch (error) {
1437
+ result = { approved: true, posted: false, error: errorData(error).message, next: "Tell the user; do not retry this draft automatically: GitHub may have accepted it before reporting an error." };
1438
+ }
1439
+ }
1440
+ record2.result = { status: "resolved", approvalId, repo, ...result };
1441
+ runLog(rootOf(sessionId), { event: "issue.approval", action: args.action, ...args.title ? { title: args.title } : {}, ...args.issue ? { issue: args.issue } : {}, ...record2.result });
1442
+ if (!closed && !session.closed && sessions.get(sessionId) === session) {
1443
+ post(sessionId, { kind: "result", from: "ALP", assignment: approvalId, body: `Issue approval result: ${JSON.stringify(record2.result)}` });
1444
+ }
1445
+ })();
1446
+ return toolResult(true, { status: "pending_approval", approvalId, repo, next: "Continue other work. ALP sends the decision and posting result by mail; do not resubmit this draft." });
1369
1447
  }
1370
1448
  function touch(sessionId, session) {
1371
1449
  const now = Date.now();
@@ -1389,6 +1467,8 @@ ${args.labels?.length ? `${say.issueLabels}: ${args.labels.join(", ")}
1389
1467
  if (session && !session.closed) {
1390
1468
  emit(sessionId, { type: "mail", mail: publicEvent(mail) });
1391
1469
  session.mail.push(mail);
1470
+ if (mail.kind === "result" || mail.kind === "question") session.wakes = 0;
1471
+ if (mail.kind === "result" || mail.kind === "question" || mail.kind === "checkin") session.wakeFailures = 0;
1392
1472
  deliver(sessionId, session);
1393
1473
  }
1394
1474
  return mail;
@@ -1411,7 +1491,7 @@ ${args.labels?.length ? `${say.issueLabels}: ${args.labels.join(", ")}
1411
1491
  if (session.mail.some((event) => !event.deliveredTurn && !event.passive && !event.defer)) void steerMail(sessionId, session);
1412
1492
  } else if (!waking) {
1413
1493
  return;
1414
- } else if (!session.wakeBlocked && session.wakes < MAX_WAKES) {
1494
+ } else if (!session.wakeBlocked && session.wakeFailures < MAX_WAKES && (session.wakes < MAX_WAKES || session.mail.some((event) => !event.deliveredTurn && event.kind === "checkin"))) {
1415
1495
  autoWake(sessionId, session);
1416
1496
  } else if (session.parent && !session.wakeBlocked && !held(session)) {
1417
1497
  session.settle?.("failed", `Wake limit (${MAX_WAKES}) reached with mail outstanding`);
@@ -1445,6 +1525,7 @@ ${args.labels?.length ? `${say.issueLabels}: ${args.labels.join(", ")}
1445
1525
  session.wakeHeld = true;
1446
1526
  return;
1447
1527
  }
1528
+ const checkIn2 = session.mail.find((event) => !event.deliveredTurn && event.kind === "checkin");
1448
1529
  const batch = takeBatch(session.mail, () => true);
1449
1530
  if (!batch.length) return;
1450
1531
  for (const event of batch) event.deliveredTurn = STARTING;
@@ -1457,11 +1538,13 @@ ${args.labels?.length ? `${say.issueLabels}: ${args.labels.join(", ")}
1457
1538
  release();
1458
1539
  return deliver(sessionId, session);
1459
1540
  }
1541
+ if (session.wakeFailures >= MAX_WAKES || session.wakes >= MAX_WAKES && (!checkIn2 || !session.mail.includes(checkIn2))) return release();
1460
1542
  if (!batch.every((event) => event.kind === "checkin")) session.wakes++;
1461
1543
  const id = `alp-wake-${randomUUID()}`;
1462
1544
  try {
1463
1545
  await startPrompt(sessionId, { clientMessageId: id, delivery: "auto", content: [{ type: "text", text: renderMail(batch, session.parentAgent) }] }, "wake", batch);
1464
1546
  } catch {
1547
+ session.wakeFailures++;
1465
1548
  release();
1466
1549
  deliver(sessionId, session);
1467
1550
  }
@@ -1561,6 +1644,17 @@ ${args.labels?.length ? `${say.issueLabels}: ${args.labels.join(", ")}
1561
1644
  waiter.resolve("user");
1562
1645
  }
1563
1646
  }
1647
+ function releaseQuestions(sessionId) {
1648
+ for (const { question, settle, asked } of [...userQuestions.values()]) {
1649
+ if (question.sessionId !== sessionId || !asked) continue;
1650
+ settle("canceled", void 0, "the user wrote instead of answering", toolResult(true, {
1651
+ status: "user_wrote",
1652
+ userMessage: true,
1653
+ question: question.id,
1654
+ next: `${USER_WROTE} Their message may be the answer to your question; if it is not, ask again later.`
1655
+ }));
1656
+ }
1657
+ }
1564
1658
  const assignmentSnapshot = (assignment, status) => ({
1565
1659
  id: assignment.id,
1566
1660
  agent: assignment.agent,
@@ -1770,7 +1864,7 @@ ${refused}`, next: "Fix what it reports, then call alp_handoff again." });
1770
1864
  const pending = [...session.assignments.keys()].some((id) => !ids || ids.has(id)) || takeBatch(session.mail, (event) => event.kind !== "board" && !event.defer && accept(event)).length > 0;
1771
1865
  const events = pending ? await waitFor(sessionId, session, accept, Math.min(args.timeoutMs ?? 3e5, 9e5)) : [];
1772
1866
  if (events === "user") return toolResult(true, { events: [], userMessage: true, running: running(session), next: USER_WROTE });
1773
- if (!events) return toolResult(false, { error: "Turn ended" });
1867
+ if (!events) return toolResult(false, session.closed || session.wakeBlocked ? { error: "Turn ended" } : { error: TURN_ENDED, running: running(session) });
1774
1868
  return toolResult(true, { events: events.map(publicEvent), running: running(session) });
1775
1869
  }
1776
1870
  function sendTool(sessionId, session, args) {
@@ -1800,10 +1894,10 @@ ${refused}`, next: "Fix what it reports, then call alp_handoff again." });
1800
1894
  }
1801
1895
  return toolResult(true, { sent: post(assignment.id, { kind: args.kind, from, assignment: assignment.id, body: args.body, ...args.replyTo ? { replyTo: args.replyTo } : {} }).id });
1802
1896
  }
1803
- function askUser(sessionId, session, assignment, body, options2) {
1897
+ function askUser(sessionId, session, assignment, body, options2, approval, asked = false) {
1804
1898
  const rootId = rootOf(sessionId);
1805
1899
  const question = {
1806
- id: `q-${randomUUID().slice(0, 8)}`,
1900
+ id: approval?.id ?? `q-${randomUUID().slice(0, 8)}`,
1807
1901
  sessionId,
1808
1902
  rootId,
1809
1903
  agent: session.mapping.agent.name,
@@ -1829,7 +1923,7 @@ The user answered: "${answer}"`);
1829
1923
  next: "Decide, and record the assumption in your handoff or final message; or report that you are blocked."
1830
1924
  })));
1831
1925
  };
1832
- userQuestions.set(question.id, { question, settle });
1926
+ userQuestions.set(question.id, { question, settle, persistent: !!approval, asked });
1833
1927
  if (assignment) assignment.ask = { id: question.id, resolve: (result) => settle("canceled", void 0, void 0, result) };
1834
1928
  emit(sessionId, { type: "question", question });
1835
1929
  runLog(rootId, { event: "human.question", questionId: question.id, sessionId, agent: question.agent, body, ...options2?.length ? { options: options2 } : {} });
@@ -1842,9 +1936,9 @@ The user answered: "${answer}"`);
1842
1936
  if (!session.parent || !sessions.has(session.parent)) return;
1843
1937
  post(session.parent, { kind: "note", from: session.mapping.agent.name, assignment: sessionId, body: `[ALP, on behalf of ${session.mapping.agent.name}] ${body}` });
1844
1938
  }
1845
- function cancelQuestions(sessionId) {
1846
- for (const { question, settle } of [...userQuestions.values()]) {
1847
- if (question.sessionId === sessionId) settle("canceled", void 0, void 0, toolResult(false, { error: "Turn ended" }));
1939
+ function cancelQuestions(sessionId, turnOnly = false) {
1940
+ for (const { question, settle, persistent } of [...userQuestions.values()]) {
1941
+ if (question.sessionId === sessionId && (!turnOnly || !persistent)) settle("canceled", void 0, void 0, toolResult(false, { error: "Turn ended" }));
1848
1942
  }
1849
1943
  }
1850
1944
  function askTool(sessionId, session, args) {
@@ -1864,7 +1958,7 @@ The user answered: "${answer}"`);
1864
1958
  if (to === "user" && session.parent && !session.userOpened) {
1865
1959
  return Promise.resolve(toolResult(false, { error: `Only main talks to the user; ask ${session.parentAgent ?? "your requester"} with alp_ask instead` }));
1866
1960
  }
1867
- if (to === "user") return askUser(sessionId, session, assignment, args.question, args.options);
1961
+ if (to === "user") return askUser(sessionId, session, assignment, args.question, args.options, void 0, true);
1868
1962
  if (!session.parent || !parent || !assignment) return Promise.resolve(toolResult(false, { error: 'This session has no requester; ask the user with to: "user"' }));
1869
1963
  const parentId = session.parent;
1870
1964
  return new Promise((resolve) => {
@@ -2011,9 +2105,11 @@ The user answered: "${answer}"`);
2011
2105
  }
2012
2106
  const rootId = rootOf(sessionId);
2013
2107
  const root = sessions.get(rootId);
2014
- if (root.calls >= 16) {
2108
+ const advisor = ["advisor", "reviewer"].includes(roleOf(session.mapping, args.agent) ?? "") && childMode === "read-only";
2109
+ const budget = advisor ? "advisorCalls" : "calls";
2110
+ if (root[budget] >= 16) {
2015
2111
  return toolResult(false, {
2016
- error: "Delegation limit reached for this turn"
2112
+ error: `${advisor ? "Advisor" : "Implementation"} delegation budget exhausted (16 starts across the root session tree since its last new user turn). Mail wakes and child turns do not reset it; a new accepted user prompt in root (including steering) resets both budgets.`
2017
2113
  });
2018
2114
  }
2019
2115
  const peer = roleOf(session.mapping, args.agent) === "peer";
@@ -2026,7 +2122,7 @@ The user answered: "${answer}"`);
2026
2122
  const continued = args.continueFrom !== void 0 ? session.worktrees.get(args.continueFrom) : void 0;
2027
2123
  if (args.continueFrom !== void 0 && !continued && !sharedContinuation) return toolResult(false, { error: `${args.continueFrom} was merged or discarded meanwhile` });
2028
2124
  if (continued) session.worktrees.delete(args.continueFrom);
2029
- root.calls++;
2125
+ root[budget]++;
2030
2126
  if (peer) root.peerCount++;
2031
2127
  const childId = `alp-child-${randomUUID()}`;
2032
2128
  const startedAt = Date.now();
@@ -2186,12 +2282,15 @@ ${digest}` : "")
2186
2282
  const events = await waitFor(sessionId, session, (event2) => event2.assignment === childId && (event2.kind === "result" || event2.kind === "question"), void 0, true);
2187
2283
  if (events === "user") return toolResult(true, { assignmentId: childId, agent: args.agent, status: "running", userMessage: true, next: USER_WROTE });
2188
2284
  if (!events) {
2189
- return toolResult(false, {
2190
- agent: args.agent,
2191
- assignmentId: childId,
2192
- status: "canceled",
2193
- error: "Parent assignment stopped"
2194
- });
2285
+ if (session.closed || session.wakeBlocked || !session.assignments.has(childId)) {
2286
+ return toolResult(false, {
2287
+ agent: args.agent,
2288
+ assignmentId: childId,
2289
+ status: "canceled",
2290
+ error: "Parent assignment stopped"
2291
+ });
2292
+ }
2293
+ return toolResult(false, { agent: args.agent, assignmentId: childId, status: "running", error: TURN_ENDED });
2195
2294
  }
2196
2295
  const event = events.find((candidate) => candidate.kind === "result") ?? events.find((candidate) => candidate.kind === "question");
2197
2296
  if (!event) return toolResult(true, { assignmentId: childId, agent: args.agent, status: "running", events: events.map(publicEvent), next: CHECKED_IN });
@@ -2257,10 +2356,12 @@ ${digest}` : "")
2257
2356
  peerCount: 0,
2258
2357
  assignments: /* @__PURE__ */ new Map(),
2259
2358
  calls: 0,
2359
+ advisorCalls: 0,
2260
2360
  mail: [],
2261
2361
  waiters: [],
2262
2362
  lastActivity: Date.now(),
2263
2363
  wakes: 0,
2364
+ wakeFailures: 0,
2264
2365
  wakeBlocked: false,
2265
2366
  toolCalls: /* @__PURE__ */ new Map(),
2266
2367
  acknowledged: Promise.resolve(),
@@ -2565,7 +2666,7 @@ Write scope: change files only under ${session.spec.writeRoot}. Your working dir
2565
2666
  runLog(entry.rootId, { event: "assignment.finished", assignmentId: entry.assignmentId, reconciled: true, ...result });
2566
2667
  if (parent && !parent.closed) post(entry.parentId, { kind: "result", from: entry.agent, assignment: entry.assignmentId, result });
2567
2668
  }
2568
- async function recoverTree(rootId, continueRoot) {
2669
+ async function recoverTree(rootId, continueRoot, parkedRoot) {
2569
2670
  const root = sessions.get(rootId);
2570
2671
  if (!root || root.closed) throw new Error(`Session ${rootId} is not open`);
2571
2672
  const entries = inherited.filter((entry) => entry.rootId === rootId).sort((a, b) => a.ancestry.length - b.ancestry.length);
@@ -2602,7 +2703,11 @@ Write scope: change files only under ${session.spec.writeRoot}. Your working dir
2602
2703
  runLog(rootId, { event: "assignment.recovered", assignmentId: entry.assignmentId, agent: entry.agent, ...entry.taskId ? { taskId: entry.taskId } : {} });
2603
2704
  proceed(entry.assignmentId, child, `ALP: ${cause} while you worked on this assignment, and reopened this session. Your earlier work in it${entry.worktree ? " and your worktree" : ""} is intact; check where you stopped (for example with git status), then continue. Finish with alp_handoff as before.${listed(resumed.get(entry.assignmentId))}`);
2604
2705
  }
2605
- if (continueRoot) proceed(rootId, root, `ALP: ${cause} during your turn, and reopened this session. Continue the user's request where you stopped.${listed(resumed.get(rootId))}`);
2706
+ if (parkedRoot) {
2707
+ root.parked = { reason: parkedRoot, since: Date.now() };
2708
+ emit(rootId, { type: "session.updated", session: snapshot(rootId, root) });
2709
+ if (options.autoResume !== false && options.recoveryResume !== false && !pauseOf(root.runtimeKind)) continueParked(rootId, root);
2710
+ } else if (continueRoot) proceed(rootId, root, `ALP: ${cause} during your turn, and reopened this session. Continue the user's request where you stopped.${listed(resumed.get(rootId))}`);
2606
2711
  else for (const assignment of resumed.get(rootId) ?? []) post(rootId, { kind: "note", from: assignment.agent, assignment: assignment.id, passive: true, body: `${cause}; ALP reopened this assignment and it continues. It reports when done.` });
2607
2712
  const failed = outcomes.filter((outcome) => outcome.outcome === "failed");
2608
2713
  if (entries.length || continueRoot) {
@@ -2649,6 +2754,11 @@ Write scope: change files only under ${session.spec.writeRoot}. Your working dir
2649
2754
  const activeTurn = session.active;
2650
2755
  session.wakeBlocked = true;
2651
2756
  session.userStopped = true;
2757
+ session.parked = void 0;
2758
+ session.parkReason = void 0;
2759
+ emit(sessionId, { type: "session.updated", session: snapshot(sessionId, session) });
2760
+ approvalCancellation.set(session, (approvalCancellation.get(session) ?? 0) + 1);
2761
+ cancelQuestions(sessionId);
2652
2762
  if (activeTurn && session.runtimeKind === "codex" && session.threadId) {
2653
2763
  const partial = [...session.text.values()].join("\n").trim();
2654
2764
  if (partial) interruptedThreads.set(session.threadId, partial.slice(-4e3));
@@ -2721,11 +2831,9 @@ Write scope: change files only under ${session.spec.writeRoot}. Your working dir
2721
2831
  if (!wake) {
2722
2832
  session.handoff = void 0;
2723
2833
  session.wakes = 0;
2834
+ session.wakeFailures = 0;
2724
2835
  session.wakeBlocked = false;
2725
2836
  session.userStopped = false;
2726
- if (!session.parent) {
2727
- session.calls = 0;
2728
- }
2729
2837
  }
2730
2838
  }
2731
2839
  try {
@@ -2767,6 +2875,10 @@ ${interrupted}`), text_elements: [] }] : [],
2767
2875
  );
2768
2876
  const turnId = prompt.delivery === "steer" ? session.active : result.turn.id;
2769
2877
  for (const event of mail) event.deliveredTurn = turnId;
2878
+ if (!session.parent && origin === "user") {
2879
+ session.calls = 0;
2880
+ session.advisorCalls = 0;
2881
+ }
2770
2882
  session.seen.add(
2771
2883
  prompt.clientMessageId
2772
2884
  );
@@ -2782,7 +2894,10 @@ ${interrupted}`), text_elements: [] }] : [],
2782
2894
  });
2783
2895
  if (origin === "user" && !session.parent) session.userWaiting ??= Date.now();
2784
2896
  if (session.supervisor && origin === "user") session.journal.push(stamp(`user ${prompt.delivery === "steer" ? "steered" : "asked"} ${session.mapping.agent.name}: ${clip(text2, 1500)}`));
2785
- if (prompt.delivery === "steer" && origin === "user" && !session.parent) releaseWaiters(session);
2897
+ if (prompt.delivery === "steer" && origin === "user" && !session.parent) {
2898
+ releaseWaiters(session);
2899
+ releaseQuestions(sessionId);
2900
+ }
2786
2901
  emit(sessionId, {
2787
2902
  type: "prompt.accepted",
2788
2903
  clientMessageId: prompt.clientMessageId,
@@ -2823,12 +2938,19 @@ ${interrupted}`), text_elements: [] }] : [],
2823
2938
  return opened;
2824
2939
  }),
2825
2940
  prompt: (sessionId, input) => enqueue(async () => {
2941
+ const session = sessions.get(sessionId);
2942
+ const parked = session?.parked;
2826
2943
  try {
2827
- await startPrompt(sessionId, input, "user");
2944
+ const prompt = session?.active && session.resumedTurn === session.active && input.delivery === "auto" ? { ...input, delivery: "steer" } : input;
2945
+ await startPrompt(sessionId, prompt, "user");
2946
+ if (session && parked && session.parked === parked) {
2947
+ session.parked = void 0;
2948
+ emit(sessionId, { type: "session.updated", session: snapshot(sessionId, session) });
2949
+ }
2828
2950
  } catch (error) {
2829
- const session = sessions.get(sessionId);
2830
- if (session?.seen.has(input.clientMessageId)) return;
2831
- session?.seen.add(input.clientMessageId);
2951
+ const session2 = sessions.get(sessionId);
2952
+ if (session2?.seen.has(input.clientMessageId)) return;
2953
+ session2?.seen.add(input.clientMessageId);
2832
2954
  emit(sessionId, { type: "prompt.failed", clientMessageId: input.clientMessageId, error: errorData(error) });
2833
2955
  }
2834
2956
  }),
@@ -2972,8 +3094,8 @@ ${interrupted}`), text_elements: [] }] : [],
2972
3094
  recoverable() {
2973
3095
  return [...inherited];
2974
3096
  },
2975
- recover(rootId, { continueRoot = false } = {}) {
2976
- return enqueue(() => recoverTree(rootId, continueRoot));
3097
+ recover(rootId, { continueRoot = false, parkedRoot } = {}) {
3098
+ return enqueue(() => recoverTree(rootId, continueRoot, parkedRoot));
2977
3099
  },
2978
3100
  async abandon(rootId, reason) {
2979
3101
  const entries = inherited.filter((entry) => entry.rootId === rootId);