@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/src/runtime.ts CHANGED
@@ -56,6 +56,8 @@ const USER_REPLY_MS = 60_000;
56
56
 
57
57
  /** What a waiting session hears when the user wrote to it. */
58
58
  const 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.';
59
+ /** A wait cut short by the end of its turn: what it waited for goes on. */
60
+ const TURN_ENDED = 'Turn ended before the result arrived. Running assignments keep running; ALP wakes you with their results as mail.';
59
61
  /** What a waiting session hears with a check-in, or mail from its requester or the user. */
60
62
  const 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.';
61
63
  /** A session's native process is restarted at most RESTART_LIMIT times within RESTART_WINDOW_MS unless it makes progress meanwhile. */
@@ -206,11 +208,12 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
206
208
  ...(session.parent ? { parentId: session.parent } : {}),
207
209
  ...(session.toolCallId ? { toolCallId: session.toolCallId } : {}),
208
210
  ...(session.active ? { activeTurnId: session.active } : {}),
209
- ...(session.parked ? { parked: session.parked.reason } : {}),
211
+ // turn.ended listeners persist the intent before terminal() finishes parking it.
212
+ ...((session.parked?.reason ?? session.parkReason) ? { parked: session.parked?.reason ?? session.parkReason } : {}),
210
213
  ...(session.instructionsSha ? { instructionsSha: session.instructionsSha } : {}),
211
214
  ...(takesImages(session) ? { promptImages: true } : {}),
212
215
  ...(session.usage ? { usage: session.usage } : {}),
213
- busy: !!(session.active || session.pending || session.children.size || session.assignments.size || hasActiveMail(session) || reviewing(session)),
216
+ busy: !!(session.active || session.pending || session.parked || session.parkReason || session.children.size || session.assignments.size || hasActiveMail(session) || reviewing(session)),
214
217
  };
215
218
  }
216
219
 
@@ -238,7 +241,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
238
241
  clearTimeout(waiter.timer);
239
242
  waiter.resolve(null);
240
243
  }
241
- cancelQuestions(sessionId);
244
+ cancelQuestions(sessionId, true);
242
245
  if (session.parent) sessions.get(session.parent)?.assignments.get(sessionId)?.ask?.resolve(toolResult(false, { error: 'Turn ended' }));
243
246
 
244
247
  // Mail is acknowledged only by a turn that completed; otherwise it is delivered again.
@@ -465,7 +468,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
465
468
 
466
469
  // A usage limit pauses its runtime; an assignment it stopped is parked to continue later.
467
470
  if (state === 'failed' && LIMIT_ERRORS.includes(params.turn.error?.codexErrorInfo)) {
468
- if (session.parent && session.role !== 'supervisor') session.parkReason = `the ${label(session.runtimeKind)} usage limit was reached`;
471
+ if (session.role !== 'supervisor') session.parkReason = `the ${label(session.runtimeKind)} usage limit was reached`;
469
472
  void limitReached(session, params.turn.error);
470
473
  }
471
474
 
@@ -484,6 +487,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
484
487
 
485
488
  session.closed = true;
486
489
  cancelQuestions(sessionId);
490
+ approvalLedger.delete(sessionId);
487
491
  releaseClaims(sessionId, session.mapping.agent.projectRoot);
488
492
 
489
493
  await Promise.all(
@@ -624,7 +628,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
624
628
  const boards = new Map<string, Promise<Pin[]>>();
625
629
  /** The same boards once loaded, for synchronous status. */
626
630
  const loadedBoards = new Map<string, Pin[]>();
627
- const userQuestions = new Map<string, { question: UserQuestion; settle: (outcome: 'answered' | 'dismissed' | 'timeout' | 'canceled', answer?: string, reason?: string, result?: unknown) => void }>();
631
+ const userQuestions = new Map<string, { question: UserQuestion; persistent?: boolean; /** From alp_ask: the user's words in the chat end it (#116). */ asked?: boolean; settle: (outcome: 'answered' | 'dismissed' | 'timeout' | 'canceled', answer?: string, reason?: string, result?: unknown) => void }>();
628
632
  let mailSequence = 0;
629
633
  let watchdog: NodeJS.Timeout | undefined;
630
634
 
@@ -793,6 +797,8 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
793
797
  : 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)}` : ''}`
794
798
  : 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`
795
799
  : entry.event === 'recall' ? `${entry.agent} recalled ${entry.recalled} assignment ${entry.assignmentId}: ${clip(entry.question)}`
800
+ : entry.event === 'skill.approval' && !entry.saved ? `skill approval ${entry.approvalId} for ${entry.name}: not saved (${entry.error ?? entry.outcome})`
801
+ : entry.event === 'issue.approval' ? `issue approval ${entry.approvalId} in ${entry.repo}: ${entry.posted ? entry.url : entry.error ?? entry.outcome}`
796
802
  : 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}`
797
803
  : entry.event?.startsWith('worktree.') ? `${entry.event.slice('worktree.'.length)} worktree of ${entry.assignmentId} (${entry.branch})${entry.status ? `: ${entry.status}` : ''}`
798
804
  : undefined;
@@ -1253,20 +1259,36 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
1253
1259
  }
1254
1260
 
1255
1261
  function continueParked(sessionId: string, session: Session) {
1256
- const { reason, prompt } = session.parked!;
1257
- session.parked = undefined;
1258
- session.wakeHeld = false;
1259
- session.lastActivity = Date.now();
1260
- emit(sessionId, { type: 'session.updated', session: snapshot(sessionId, session) });
1261
- const parent = session.parent ? sessions.get(session.parent) : undefined;
1262
- const assignment = parent?.assignments.get(sessionId);
1263
- runLog(rootOf(sessionId), { event: 'assignment.resumed', assignmentId: sessionId, agent: session.mapping.agent.name });
1264
- if (assignment) emit(session.parent!, { type: 'assignment', assignment: assignmentSnapshot(assignment, 'running') });
1265
- void startPrompt(sessionId, {
1266
- clientMessageId: `alp-resume-${randomUUID()}`,
1267
- delivery: 'auto',
1268
- 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.` }],
1269
- }, session.parent ? 'assignment' : 'wake').catch(error => session.settle?.('failed', error));
1262
+ void enqueue(async () => {
1263
+ if (session.closed || session.active || session.pending || !session.parked || pauseOf(session.runtimeKind)) return;
1264
+ const parked = session.parked;
1265
+ const { reason, prompt } = parked;
1266
+ session.parked = undefined;
1267
+ session.wakeHeld = false;
1268
+ session.lastActivity = Date.now();
1269
+ const parent = session.parent ? sessions.get(session.parent) : undefined;
1270
+ const assignment = parent?.assignments.get(sessionId);
1271
+ runLog(rootOf(sessionId), { event: 'assignment.resumed', assignmentId: sessionId, agent: session.mapping.agent.name });
1272
+ if (assignment) emit(session.parent!, { type: 'assignment', assignment: assignmentSnapshot(assignment, 'running') });
1273
+ try {
1274
+ await startPrompt(sessionId, {
1275
+ clientMessageId: `alp-resume-${randomUUID()}`,
1276
+ delivery: 'auto',
1277
+ content: [{ type: 'text', text: prompt ?? (session.parent
1278
+ ? `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.`
1279
+ : `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.`) }],
1280
+ }, session.parent ? 'assignment' : 'wake');
1281
+ session.resumedTurn = session.active;
1282
+ emit(sessionId, { type: 'session.updated', session: snapshot(sessionId, session) });
1283
+ } catch (error) {
1284
+ if (!session.closed && !session.active) {
1285
+ session.parked = parked;
1286
+ emit(sessionId, { type: 'session.updated', session: snapshot(sessionId, session) });
1287
+ if (assignment) emit(session.parent!, { type: 'assignment', assignment: assignmentSnapshot(assignment, 'parked') });
1288
+ }
1289
+ notice('warning', `Could not resume ${session.mapping.agent.name}: ${errorData(error).message}`);
1290
+ }
1291
+ });
1270
1292
  }
1271
1293
 
1272
1294
  /** When a limit resets, from what the runtime last reported: the latest reset of a window that is used up. */
@@ -1287,8 +1309,10 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
1287
1309
  /** A turn failed on a usage limit: pause that runtime for delegation and wakes, and tell the user. */
1288
1310
  async function limitReached(session: Session, error: any) {
1289
1311
  const kind = session.runtimeKind;
1290
- if (paused.runtimes[kind]?.by === 'alpd') return;
1312
+ if (pauseOf(kind)) return;
1291
1313
  const resetsAt = await limitReset(session, error);
1314
+ // A user's pause made while the provider lookup awaited remains theirs to lift.
1315
+ if (closed || session.closed || pauseOf(kind)) return;
1292
1316
  const pause: Pause = { since: new Date().toISOString(), by: 'alpd', reason: `${label(kind)} usage limit reached`, ...(resetsAt ? { resetsAt } : {}) };
1293
1317
  pauseRuntime(kind, pause);
1294
1318
  const other = RUNTIMES.find(candidate => candidate !== kind && !pauseOf(candidate));
@@ -1478,10 +1502,10 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
1478
1502
  * Asks the user to approve a proposal and waits for the answer. Only an approving
1479
1503
  * answer approves; a dismissal, a timeout or another answer does not.
1480
1504
  */
1481
- async function confirm(sessionId: string, session: Session, body: string): Promise<{ approved: boolean; feedback?: string; outcome?: string }> {
1505
+ async function confirm(sessionId: string, session: Session, body: string, approval?: { id: string }): Promise<{ approved: boolean; feedback?: string; outcome?: string }> {
1482
1506
  if ([...userQuestions.values()].some(pending => pending.question.sessionId === sessionId)) return { approved: false, outcome: 'busy', feedback: 'A question is already waiting for the user' };
1483
1507
  const say = wordsFor(session);
1484
- const result = await askUser(sessionId, session, undefined, body, [say.approve, say.reject]) as { contentItems: Array<{ text: string }> };
1508
+ const result = await askUser(sessionId, session, undefined, body, [say.approve, say.reject], approval) as { contentItems: Array<{ text: string }> };
1485
1509
  let value: any;
1486
1510
  try { value = JSON.parse(result.contentItems[0].text); } catch { value = {}; }
1487
1511
  if (value.status !== 'answered') return { approved: false, outcome: value.status ?? 'canceled', ...(value.reason ? { feedback: value.reason } : {}) };
@@ -1489,18 +1513,25 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
1489
1513
  return APPROVALS.includes(answer.toLowerCase()) ? { approved: true } : { approved: false, outcome: 'answered', feedback: answer };
1490
1514
  }
1491
1515
 
1492
- const declined = (decision: { feedback?: string; outcome?: string }) => toolResult(true, {
1493
- approved: false,
1494
- outcome: decision.outcome,
1495
- ...(decision.feedback ? { feedback: decision.feedback } : {}),
1496
- next: 'Nothing was saved or posted. Revise from the feedback and propose again, or drop it.',
1497
- });
1498
-
1499
1516
  const text = (value: unknown, limit: number) => typeof value === 'string' && !!value.trim() && value.length <= limit;
1500
1517
 
1518
+ // Approvals of alp_issue and alp_skill (#114, #116): the tool returns a pending id at once and the
1519
+ // decision comes as result mail. Draft identities stay until session close, so a replayed tool
1520
+ // call or turn never applies an approved draft twice. Interrupting a session cancels its approvals.
1521
+ const approvalCancellation = new WeakMap<Session, number>();
1522
+ const approvalLedger = new Map<string, Map<string, { approvalId: string; draftKey: string; result?: Record<string, unknown> }>>();
1523
+ const approvalsOf = (sessionId: string) => {
1524
+ let approvals = approvalLedger.get(sessionId);
1525
+ if (!approvals) approvalLedger.set(sessionId, approvals = new Map());
1526
+ return approvals;
1527
+ };
1528
+ const approvalCanceled = (session: Session, cancellation: number) => closed || session.closed || cancellation !== (approvalCancellation.get(session) ?? 0);
1529
+
1501
1530
  async function skillTool(sessionId: string, session: Session, args: unknown) {
1531
+ const cancellation = approvalCancellation.get(session) ?? 0;
1532
+ args = structuredClone(args);
1502
1533
  if (
1503
- !plainObject(args, ['name', 'description', 'body', 'roles', 'lessons', 'replace']) ||
1534
+ !plainObject(args, ['name', 'description', 'body', 'roles', 'lessons', 'replace', 'requestId']) ||
1504
1535
  typeof args.name !== 'string' || !/^[a-z0-9][a-z0-9-]{1,62}$/.test(args.name) ||
1505
1536
  !text(args.description, 300) || !text(args.body, SKILL_BODY_CHARS) ||
1506
1537
  !Array.isArray(args.roles) || !args.roles.length || args.roles.length > 10 || !args.roles.every((role: unknown) => typeof role === 'string' && /^[\w.-]+$/.test(role) && role !== '.' && role !== '..') ||
@@ -1509,14 +1540,25 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
1509
1540
  ) {
1510
1541
  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` });
1511
1542
  }
1512
- if (!options.libraryDir) return toolResult(false, { error: 'This host keeps no user skill library' });
1513
- const known = new Set([...STARTER_ROLES, ...await discoverAgents(session.mapping.agent.projectRoot, { library: options.libraryDir, templates: options.templates })]);
1543
+ if (args.requestId !== undefined && !text(args.requestId, 100)) return toolResult(false, { error: 'requestId must be a nonempty string of at most 100 characters' });
1544
+ const library = options.libraryDir;
1545
+ if (!library) return toolResult(false, { error: 'This host keeps no user skill library' });
1546
+ const known = new Set([...STARTER_ROLES, ...await discoverAgents(session.mapping.agent.projectRoot, { library, templates: options.templates })]);
1514
1547
  const unknown = args.roles.filter((role: string) => !known.has(role));
1515
1548
  if (unknown.length) return toolResult(false, { error: `Unknown roles: ${unknown.join(', ')}. Roles are ${[...known].join(', ')}` });
1516
- const file = path.join(options.libraryDir, 'skills', args.name, 'SKILL.md');
1549
+ const file = path.join(library, 'skills', args.name, 'SKILL.md');
1550
+ const content = `---\nname: ${args.name}\ndescription: ${JSON.stringify(args.description.replace(/\s+/g, ' ').trim())}\n---\n\n${args.body.trim()}\n`;
1551
+ const lessons: string[] = (args.lessons ?? []).map((lesson: string) => lesson.replace(/\s+/g, ' ').trim());
1552
+ // A replay of a proposed draft returns its approval, before anything it changed is checked again.
1553
+ const draftKey = JSON.stringify(['skill', content, args.roles, lessons, !!args.replace]);
1554
+ const key = `skill:${args.requestId ?? draftKey}`;
1555
+ const approvals = approvalsOf(sessionId);
1556
+ const previous = approvals.get(key);
1557
+ if (previous && previous.draftKey !== draftKey) return toolResult(false, { error: 'requestId was already used for a different draft' });
1558
+ if (previous) return toolResult(true, previous.result ?? { status: 'pending_approval', approvalId: previous.approvalId, file });
1517
1559
  const existing = await readFile(file, 'utf8').catch(() => undefined);
1518
1560
  if (existing !== undefined && !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` });
1519
- const rolesFile = path.join(options.libraryDir, 'role-skills.json');
1561
+ const rolesFile = path.join(library, 'role-skills.json');
1520
1562
  const readRoles = async () => {
1521
1563
  const raw = await readFile(rolesFile, 'utf8').catch(() => '{}');
1522
1564
  const roles = JSON.parse(raw);
@@ -1524,49 +1566,74 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
1524
1566
  return roles as Record<string, unknown>;
1525
1567
  };
1526
1568
  try { await readRoles(); } catch (error) { return toolResult(false, { error: errorData(error).message }); }
1527
- const content = `---\nname: ${args.name}\ndescription: ${JSON.stringify(args.description.replace(/\s+/g, ' ').trim())}\n---\n\n${args.body.trim()}\n`;
1528
- const lessons: string[] = (args.lessons ?? []).map((lesson: string) => lesson.replace(/\s+/g, ' ').trim());
1569
+ if (approvalCanceled(session, cancellation)) return toolResult(false, { error: 'Skill proposal canceled or session closed; no approval was created' });
1570
+ 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' });
1571
+ const approvalId = `q-${randomUUID()}`;
1572
+ const record: { approvalId: string; draftKey: string; result?: Record<string, unknown> } = { approvalId, draftKey };
1573
+ approvals.set(key, record);
1529
1574
  const say = wordsFor(session);
1530
- const decision = await confirm(sessionId, session,
1575
+ // The user sees the whole skill, as it will be saved.
1576
+ const decision = confirm(sessionId, session,
1531
1577
  say.skill(session.mapping.agent.name, existing !== undefined, file, args.roles.join(', '), lessons) +
1532
- `\n${say.answerLine(session.mapping.agent.name)}\n\n${content}`);
1533
- if (!decision.approved) return declined(decision);
1534
- try {
1535
- await mkdir(path.dirname(file), { recursive: true });
1536
- await writeFile(file, content);
1537
- const roles = await readRoles();
1538
- for (const role of args.roles) {
1539
- const list = Array.isArray(roles[role]) ? roles[role] as string[] : [];
1540
- if (!list.includes(args.name)) list.push(args.name);
1541
- roles[role] = list;
1542
- }
1543
- await writeFile(rolesFile, JSON.stringify(roles, null, 2) + '\n');
1544
- } catch (error) {
1545
- return toolResult(false, { error: errorData(error).message });
1546
- }
1547
- // The lessons the skill replaces leave both files; a lesson matches only by its exact text.
1548
- const removed = new Set<string>();
1549
- for (const lessonFile of lessonFiles(session.mapping)) {
1550
- const current = await readFile(lessonFile, 'utf8').catch(() => undefined);
1551
- if (current === undefined) continue;
1552
- const kept = current.split('\n').filter(line => {
1553
- const match = /^- \d{4}-\d\d-\d\d: (.*)$/.exec(line);
1554
- if (!match || !lessons.includes(match[1].trim())) return true;
1555
- removed.add(match[1].trim());
1556
- return false;
1557
- });
1558
- if (kept.length !== current.split('\n').length) await writeFile(lessonFile, kept.join('\n')).catch(() => {});
1559
- }
1560
- runLog(rootOf(sessionId), { event: 'skill', agent: session.mapping.agent.name, name: args.name, roles: args.roles, replaced: existing !== undefined, lessons: [...removed] });
1561
- return toolResult(true, {
1562
- approved: true, saved: file, roles: args.roles, lessonsRemoved: [...removed],
1563
- ...(lessons.length > removed.size ? { lessonsNotFound: lessons.filter(lesson => !removed.has(lesson)) } : {}),
1564
- next: 'New sessions of those roles list the skill; read the file now if you need it in this one.',
1565
- });
1578
+ `\n${say.answerLine(session.mapping.agent.name)}\n\n${content}`, { id: approvalId });
1579
+ const save = async (): Promise<Record<string, unknown>> => {
1580
+ // A skill of this name saved meanwhile is not overwritten by a proposal that was not a replacement.
1581
+ if (existing === undefined && !args.replace && await readFile(file, 'utf8').then(() => true, () => false)) {
1582
+ 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.' };
1583
+ }
1584
+ try {
1585
+ await mkdir(path.dirname(file), { recursive: true });
1586
+ await writeFile(file, content);
1587
+ const roles = await readRoles();
1588
+ for (const role of args.roles) {
1589
+ const list = Array.isArray(roles[role]) ? roles[role] as string[] : [];
1590
+ if (!list.includes(args.name)) list.push(args.name);
1591
+ roles[role] = list;
1592
+ }
1593
+ await writeFile(rolesFile, JSON.stringify(roles, null, 2) + '\n');
1594
+ } catch (error) {
1595
+ return { approved: true, saved: false, error: errorData(error).message, next: 'Tell the user; nothing is retried automatically.' };
1596
+ }
1597
+ // The lessons the skill replaces leave both files; a lesson matches only by its exact text.
1598
+ const removed = new Set<string>();
1599
+ for (const lessonFile of lessonFiles(session.mapping)) {
1600
+ const current = await readFile(lessonFile, 'utf8').catch(() => undefined);
1601
+ if (current === undefined) continue;
1602
+ const kept = current.split('\n').filter(line => {
1603
+ const match = /^- \d{4}-\d\d-\d\d: (.*)$/.exec(line);
1604
+ if (!match || !lessons.includes(match[1].trim())) return true;
1605
+ removed.add(match[1].trim());
1606
+ return false;
1607
+ });
1608
+ if (kept.length !== current.split('\n').length) await writeFile(lessonFile, kept.join('\n')).catch(() => {});
1609
+ }
1610
+ runLog(rootOf(sessionId), { event: 'skill', agent: session.mapping.agent.name, name: args.name, roles: args.roles, replaced: existing !== undefined, lessons: [...removed] });
1611
+ return {
1612
+ approved: true, saved: file, roles: args.roles, lessonsRemoved: [...removed],
1613
+ ...(lessons.length > removed.size ? { lessonsNotFound: lessons.filter(lesson => !removed.has(lesson)) } : {}),
1614
+ next: 'New sessions of those roles list the skill; read the file now if you need it in this one.',
1615
+ };
1616
+ };
1617
+ void (async () => {
1618
+ const answer = await decision;
1619
+ const canceled = approvalCanceled(session, cancellation);
1620
+ const result = !answer.approved || canceled
1621
+ ? { 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.' }
1622
+ : await save();
1623
+ record.result = { status: 'resolved', approvalId, name: args.name, ...result };
1624
+ runLog(rootOf(sessionId), { event: 'skill.approval', agent: session.mapping.agent.name, ...record.result });
1625
+ if (!closed && !session.closed && sessions.get(sessionId) === session) {
1626
+ // Result mail renews the bounded wake allowance and reaches this main only.
1627
+ post(sessionId, { kind: 'result', from: 'ALP', assignment: approvalId, body: `Skill approval result: ${JSON.stringify(record.result)}` });
1628
+ }
1629
+ })();
1630
+ 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.' });
1566
1631
  }
1567
1632
 
1568
1633
  async function issueTool(sessionId: string, session: Session, args: unknown) {
1569
- if (!plainObject(args, ['action', 'target', 'query', 'title', 'body', 'issue', 'labels']) || !['search', 'create', 'comment'].includes(args.action) || !['project', 'alp'].includes(args.target)) {
1634
+ const cancellation = approvalCancellation.get(session) ?? 0;
1635
+ args = structuredClone(args);
1636
+ if (!plainObject(args, ['action', 'target', 'query', 'title', 'body', 'issue', 'labels', 'requestId']) || !['search', 'create', 'comment'].includes(args.action) || !['project', 'alp'].includes(args.target)) {
1570
1637
  return toolResult(false, { error: 'An issue call needs action (search, create or comment) and target (project or alp)' });
1571
1638
  }
1572
1639
  const project = session.mapping.agent.projectRoot;
@@ -1589,6 +1656,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
1589
1656
  if (args.labels !== undefined && (args.action !== 'create' || !Array.isArray(args.labels) || args.labels.length > 10 || !args.labels.every((label: unknown) => text(label, 50)))) {
1590
1657
  return toolResult(false, { error: 'labels are up to 10 existing label names, for create' });
1591
1658
  }
1659
+ if (args.requestId !== undefined && !text(args.requestId, 100)) return toolResult(false, { error: 'requestId must be a nonempty string of at most 100 characters' });
1592
1660
  // Resolve labels before approval, so a draft with an unknown label never
1593
1661
  // consumes the user's approval and then fails during issue creation.
1594
1662
  if (args.action === 'create' && args.labels?.length) {
@@ -1601,24 +1669,48 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
1601
1669
  return toolResult(false, { repo, error: `Could not validate issue labels: ${errorData(error).message}` });
1602
1670
  }
1603
1671
  }
1672
+ if (approvalCanceled(session, cancellation)) return toolResult(false, { error: 'Issue proposal canceled or session closed; no approval was created' });
1673
+ const draftKey = JSON.stringify([repo, args.action, args.title ?? null, args.issue ?? null, args.body, [...(args.labels ?? [])].sort()]);
1674
+ const key = `issue:${args.requestId ?? draftKey}`;
1675
+ const approvals = approvalsOf(sessionId);
1676
+ const previous = approvals.get(key);
1677
+ if (previous && previous.draftKey !== draftKey) return toolResult(false, { error: 'requestId was already used for a different draft' });
1678
+ if (previous) return toolResult(true, previous.result ?? { status: 'pending_approval', approvalId: previous.approvalId, repo });
1679
+ 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' });
1680
+ const approvalId = `q-${randomUUID()}`;
1681
+ const record: { approvalId: string; draftKey: string; result?: Record<string, unknown> } = { approvalId, draftKey };
1682
+ approvals.set(key, record);
1604
1683
  const where = `${repo}${args.target === 'alp' ? ' (ALP itself)' : ''}`;
1605
1684
  const say = wordsFor(session);
1606
- const decision = await confirm(sessionId, session,
1685
+ const decision = confirm(sessionId, session,
1607
1686
  say.issue(session.mapping.agent.name, args.action === 'create', args.issue, where) + '\n' +
1608
1687
  `${say.answerLine(session.mapping.agent.name)}\n\n` +
1609
- (args.action === 'create' ? `${say.issueTitle}: ${args.title}\n${args.labels?.length ? `${say.issueLabels}: ${args.labels.join(', ')}\n` : ''}\n` : '') + args.body);
1610
- if (!decision.approved) return declined(decision);
1611
- const body = args.body.trim() + ISSUE_FOOTER;
1612
- try {
1613
- const output = args.action === 'create'
1614
- ? await github(['issue', 'create', '-R', repo, '--title', args.title, '--body-file', '-', ...(args.labels ?? []).flatMap((label: string) => ['--label', label])], { cwd: project, input: body })
1615
- : await github(['issue', 'comment', String(args.issue), '-R', repo, '--body-file', '-'], { cwd: project, input: body });
1616
- const url = output.trim().split('\n').at(-1) ?? '';
1617
- runLog(rootOf(sessionId), { event: 'issue', action: args.action, repo, ...(args.title ? { title: args.title } : {}), ...(args.issue ? { issue: args.issue } : {}), url });
1618
- return toolResult(true, { approved: true, posted: true, repo, url });
1619
- } catch (error) {
1620
- return toolResult(false, { approved: true, posted: false, repo, error: errorData(error).message, next: 'Tell the user; the GitHub CLI may need `gh auth login`.' });
1621
- }
1688
+ (args.action === 'create' ? `${say.issueTitle}: ${args.title}\n${args.labels?.length ? `${say.issueLabels}: ${args.labels.join(', ')}\n` : ''}\n` : '') + args.body.trim() + ISSUE_FOOTER, { id: approvalId });
1689
+ void (async () => {
1690
+ const answer = await decision;
1691
+ let result: Record<string, unknown>;
1692
+ const canceled = approvalCanceled(session, cancellation);
1693
+ if (!answer.approved || canceled) {
1694
+ result = { approved: false, posted: false, outcome: canceled ? 'canceled' : answer.outcome, ...(answer.feedback ? { feedback: answer.feedback } : {}) };
1695
+ } else {
1696
+ try {
1697
+ const body = args.body.trim() + ISSUE_FOOTER;
1698
+ const output = args.action === 'create'
1699
+ ? await github(['issue', 'create', '-R', repo, '--title', args.title, '--body-file', '-', ...(args.labels ?? []).flatMap((label: string) => ['--label', label])], { cwd: project, input: body })
1700
+ : await github(['issue', 'comment', String(args.issue), '-R', repo, '--body-file', '-'], { cwd: project, input: body });
1701
+ result = { approved: true, posted: true, url: output.trim().split('\n').at(-1) ?? '' };
1702
+ } catch (error) {
1703
+ 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.' };
1704
+ }
1705
+ }
1706
+ record.result = { status: 'resolved', approvalId, repo, ...result };
1707
+ runLog(rootOf(sessionId), { event: 'issue.approval', action: args.action, ...(args.title ? { title: args.title } : {}), ...(args.issue ? { issue: args.issue } : {}), ...record.result });
1708
+ if (!closed && !session.closed && sessions.get(sessionId) === session) {
1709
+ // Result mail renews the bounded wake allowance and reaches this main only.
1710
+ post(sessionId, { kind: 'result', from: 'ALP', assignment: approvalId, body: `Issue approval result: ${JSON.stringify(record.result)}` });
1711
+ }
1712
+ })();
1713
+ 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.' });
1622
1714
  }
1623
1715
 
1624
1716
  /** Activity anywhere in a subtree keeps its ancestors' assignments alive. */
@@ -1645,6 +1737,10 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
1645
1737
  if (session && !session.closed) {
1646
1738
  emit(sessionId, { type: 'mail', mail: publicEvent(mail) });
1647
1739
  session.mail.push(mail);
1740
+ // New assignment progress deserves a fresh delivery allowance. Reset here,
1741
+ // never on redelivery, so a failing turn cannot retry the same mail forever.
1742
+ if (mail.kind === 'result' || mail.kind === 'question') session.wakes = 0;
1743
+ if (mail.kind === 'result' || mail.kind === 'question' || mail.kind === 'checkin') session.wakeFailures = 0;
1648
1744
  deliver(sessionId, session);
1649
1745
  }
1650
1746
  return mail;
@@ -1673,7 +1769,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
1673
1769
  if (session.mail.some(event => !event.deliveredTurn && !event.passive && !event.defer)) void steerMail(sessionId, session);
1674
1770
  } else if (!waking) {
1675
1771
  return;
1676
- } else if (!session.wakeBlocked && session.wakes < MAX_WAKES) {
1772
+ } else if (!session.wakeBlocked && session.wakeFailures < MAX_WAKES && (session.wakes < MAX_WAKES || session.mail.some(event => !event.deliveredTurn && event.kind === 'checkin'))) {
1677
1773
  autoWake(sessionId, session);
1678
1774
  } else if (session.parent && !session.wakeBlocked && !held(session)) {
1679
1775
  // A child never gets the user prompt that resets its wakes; report instead of waiting out the watchdog.
@@ -1711,6 +1807,9 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
1711
1807
  function autoWake(sessionId: string, session: Session) {
1712
1808
  // Its runtime is paused or it is parked: the mail waits for resume.
1713
1809
  if (held(session)) { session.wakeHeld = true; return; }
1810
+ // A check-in authorizes delivery of older mail too: the capped first batch
1811
+ // may not reach the check-in itself. Keep that authorization through queuing.
1812
+ const checkIn = session.mail.find(event => !event.deliveredTurn && event.kind === 'checkin');
1714
1813
  const batch = takeBatch(session.mail, () => true);
1715
1814
  if (!batch.length) return;
1716
1815
  for (const event of batch) event.deliveredTurn = STARTING;
@@ -1724,11 +1823,15 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
1724
1823
  release();
1725
1824
  return deliver(sessionId, session);
1726
1825
  }
1826
+ // Earlier queued attempts may have consumed the allowance since delivery
1827
+ // reserved this batch. Keep retries bounded at the actual start boundary.
1828
+ if (session.wakeFailures >= MAX_WAKES || (session.wakes >= MAX_WAKES && (!checkIn || !session.mail.includes(checkIn)))) return release();
1727
1829
  if (!batch.every(event => event.kind === 'checkin')) session.wakes++;
1728
1830
  const id = `alp-wake-${randomUUID()}`;
1729
1831
  try {
1730
1832
  await startPrompt(sessionId, { clientMessageId: id, delivery: 'auto', content: [{ type: 'text', text: renderMail(batch, session.parentAgent) }] }, 'wake', batch);
1731
1833
  } catch {
1834
+ session.wakeFailures++;
1732
1835
  release();
1733
1836
  deliver(sessionId, session);
1734
1837
  }
@@ -1842,6 +1945,21 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
1842
1945
  }
1843
1946
  }
1844
1947
 
1948
+ /**
1949
+ * The user wrote in the chat while main's own alp_ask question to them waits (#116): the question
1950
+ * ends now, so the message reaches main. Approvals, permission prompts and the hook-trust
1951
+ * question keep waiting for the user's decision.
1952
+ */
1953
+ function releaseQuestions(sessionId: string) {
1954
+ for (const { question, settle, asked } of [...userQuestions.values()]) {
1955
+ if (question.sessionId !== sessionId || !asked) continue;
1956
+ settle('canceled', undefined, 'the user wrote instead of answering', toolResult(true, {
1957
+ status: 'user_wrote', userMessage: true, question: question.id,
1958
+ next: `${USER_WROTE} Their message may be the answer to your question; if it is not, ask again later.`,
1959
+ }));
1960
+ }
1961
+ }
1962
+
1845
1963
  const assignmentSnapshot = (assignment: Assignment, status: string): AssignmentSnapshot => ({
1846
1964
  id: assignment.id,
1847
1965
  agent: assignment.agent,
@@ -2107,7 +2225,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
2107
2225
  const pending = [...session.assignments.keys()].some(id => !ids || ids.has(id)) || takeBatch(session.mail, event => event.kind !== 'board' && !event.defer && accept(event)).length > 0;
2108
2226
  const events = pending ? await waitFor(sessionId, session, accept, Math.min(args.timeoutMs ?? 300_000, 900_000)) : [];
2109
2227
  if (events === 'user') return toolResult(true, { events: [], userMessage: true, running: running(session), next: USER_WROTE });
2110
- if (!events) return toolResult(false, { error: 'Turn ended' });
2228
+ if (!events) return toolResult(false, session.closed || session.wakeBlocked ? { error: 'Turn ended' } : { error: TURN_ENDED, running: running(session) });
2111
2229
  return toolResult(true, { events: events.map(publicEvent), running: running(session) });
2112
2230
  }
2113
2231
 
@@ -2149,10 +2267,10 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
2149
2267
  }
2150
2268
 
2151
2269
  /** Asks the user; viewers show the question, and answer() or a timeout resolves it. */
2152
- function askUser(sessionId: string, session: Session, assignment: Assignment | undefined, body: string, options?: string[]) {
2270
+ function askUser(sessionId: string, session: Session, assignment: Assignment | undefined, body: string, options?: string[], approval?: { id: string }, asked = false) {
2153
2271
  const rootId = rootOf(sessionId);
2154
2272
  const question: UserQuestion = {
2155
- id: `q-${randomUUID().slice(0, 8)}`,
2273
+ id: approval?.id ?? `q-${randomUUID().slice(0, 8)}`,
2156
2274
  sessionId,
2157
2275
  rootId,
2158
2276
  agent: session.mapping.agent.name,
@@ -2180,7 +2298,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
2180
2298
  next: 'Decide, and record the assumption in your handoff or final message; or report that you are blocked.',
2181
2299
  })));
2182
2300
  };
2183
- userQuestions.set(question.id, { question, settle });
2301
+ userQuestions.set(question.id, { question, settle, persistent: !!approval, asked });
2184
2302
  // The watchdog does not count time spent waiting for an answer.
2185
2303
  if (assignment) assignment.ask = { id: question.id, resolve: result => settle('canceled', undefined, undefined, result) };
2186
2304
  emit(sessionId, { type: 'question', question });
@@ -2198,10 +2316,10 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
2198
2316
  post(session.parent, { kind: 'note', from: session.mapping.agent.name, assignment: sessionId, body: `[ALP, on behalf of ${session.mapping.agent.name}] ${body}` });
2199
2317
  }
2200
2318
 
2201
- /** Questions to the user end with the turn or session that asked them. */
2202
- function cancelQuestions(sessionId: string) {
2203
- for (const { question, settle } of [...userQuestions.values()]) {
2204
- if (question.sessionId === sessionId) settle('canceled', undefined, undefined, toolResult(false, { error: 'Turn ended' }));
2319
+ /** Ordinary questions end with their turn; issue approvals survive until decision or session cancellation. */
2320
+ function cancelQuestions(sessionId: string, turnOnly = false) {
2321
+ for (const { question, settle, persistent } of [...userQuestions.values()]) {
2322
+ if (question.sessionId === sessionId && (!turnOnly || !persistent)) settle('canceled', undefined, undefined, toolResult(false, { error: 'Turn ended' }));
2205
2323
  }
2206
2324
  }
2207
2325
 
@@ -2223,7 +2341,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
2223
2341
  if (to === 'user' && session.parent && !session.userOpened) {
2224
2342
  return Promise.resolve(toolResult(false, { error: `Only main talks to the user; ask ${session.parentAgent ?? 'your requester'} with alp_ask instead` }));
2225
2343
  }
2226
- if (to === 'user') return askUser(sessionId, session, assignment, args.question, args.options);
2344
+ if (to === 'user') return askUser(sessionId, session, assignment, args.question, args.options, undefined, true);
2227
2345
  if (!session.parent || !parent || !assignment) return Promise.resolve(toolResult(false, { error: 'This session has no requester; ask the user with to: "user"' }));
2228
2346
  const parentId = session.parent;
2229
2347
  return new Promise<unknown>(resolve => {
@@ -2415,9 +2533,11 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
2415
2533
  const rootId = rootOf(sessionId);
2416
2534
  const root = sessions.get(rootId)!;
2417
2535
 
2418
- if (root.calls >= 16) {
2536
+ const advisor = ['advisor', 'reviewer'].includes(roleOf(session.mapping, args.agent) ?? '') && childMode === 'read-only';
2537
+ const budget = advisor ? 'advisorCalls' : 'calls';
2538
+ if (root[budget] >= 16) {
2419
2539
  return toolResult(false, {
2420
- error: 'Delegation limit reached for this turn',
2540
+ 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.`,
2421
2541
  });
2422
2542
  }
2423
2543
 
@@ -2432,7 +2552,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
2432
2552
  const continued = args.continueFrom !== undefined ? session.worktrees.get(args.continueFrom) : undefined;
2433
2553
  if (args.continueFrom !== undefined && !continued && !sharedContinuation) return toolResult(false, { error: `${args.continueFrom} was merged or discarded meanwhile` });
2434
2554
  if (continued) session.worktrees.delete(args.continueFrom);
2435
- root.calls++;
2555
+ root[budget]++;
2436
2556
  if (peer) root.peerCount++;
2437
2557
 
2438
2558
  const childId = `alp-child-${randomUUID()}`;
@@ -2598,12 +2718,16 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
2598
2718
  if (events === 'user') return toolResult(true, { assignmentId: childId, agent: args.agent, status: 'running', userMessage: true, next: USER_WROTE });
2599
2719
 
2600
2720
  if (!events) {
2601
- return toolResult(false, {
2602
- agent: args.agent,
2603
- assignmentId: childId,
2604
- status: 'canceled',
2605
- error: 'Parent assignment stopped',
2606
- });
2721
+ // Only the user's stop or a closing session ends the assignment with the wait (#116).
2722
+ if (session.closed || session.wakeBlocked || !session.assignments.has(childId)) {
2723
+ return toolResult(false, {
2724
+ agent: args.agent,
2725
+ assignmentId: childId,
2726
+ status: 'canceled',
2727
+ error: 'Parent assignment stopped',
2728
+ });
2729
+ }
2730
+ return toolResult(false, { agent: args.agent, assignmentId: childId, status: 'running', error: TURN_ENDED });
2607
2731
  }
2608
2732
 
2609
2733
  // A result that came with a check-in makes the check-in moot.
@@ -2702,10 +2826,12 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
2702
2826
  peerCount: 0,
2703
2827
  assignments: new Map(),
2704
2828
  calls: 0,
2829
+ advisorCalls: 0,
2705
2830
  mail: [],
2706
2831
  waiters: [],
2707
2832
  lastActivity: Date.now(),
2708
2833
  wakes: 0,
2834
+ wakeFailures: 0,
2709
2835
  wakeBlocked: false,
2710
2836
 
2711
2837
  toolCalls: new Map(),
@@ -3054,7 +3180,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
3054
3180
  * root first, since each assignment needs its requester open. Each one continues
3055
3181
  * its turn, or waits parked when its runtime is paused or the user resumes recovery.
3056
3182
  */
3057
- async function recoverTree(rootId: string, continueRoot: boolean): Promise<RecoveryOutcome[]> {
3183
+ async function recoverTree(rootId: string, continueRoot: boolean, parkedRoot?: string): Promise<RecoveryOutcome[]> {
3058
3184
  const root = sessions.get(rootId);
3059
3185
  if (!root || root.closed) throw new Error(`Session ${rootId} is not open`);
3060
3186
  const entries = inherited.filter(entry => entry.rootId === rootId).sort((a, b) => a.ancestry.length - b.ancestry.length);
@@ -3094,7 +3220,11 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
3094
3220
  runLog(rootId, { event: 'assignment.recovered', assignmentId: entry.assignmentId, agent: entry.agent, ...(entry.taskId ? { taskId: entry.taskId } : {}) });
3095
3221
  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))}`);
3096
3222
  }
3097
- 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))}`);
3223
+ if (parkedRoot) {
3224
+ root.parked = { reason: parkedRoot, since: Date.now() };
3225
+ emit(rootId, { type: 'session.updated', session: snapshot(rootId, root) });
3226
+ if (options.autoResume !== false && options.recoveryResume !== false && !pauseOf(root.runtimeKind)) continueParked(rootId, root);
3227
+ } 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))}`);
3098
3228
  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.` });
3099
3229
  const failed = outcomes.filter(outcome => outcome.outcome === 'failed');
3100
3230
  if (entries.length || continueRoot) {
@@ -3154,6 +3284,11 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
3154
3284
 
3155
3285
  session.wakeBlocked = true;
3156
3286
  session.userStopped = true;
3287
+ session.parked = undefined;
3288
+ session.parkReason = undefined;
3289
+ emit(sessionId, { type: 'session.updated', session: snapshot(sessionId, session) });
3290
+ approvalCancellation.set(session, (approvalCancellation.get(session) ?? 0) + 1);
3291
+ cancelQuestions(sessionId);
3157
3292
  // Codex keeps no output of an interrupted turn in its thread: remember what the user saw, by
3158
3293
  // thread, since a client that leaves closes the session and the next prompt opens it again.
3159
3294
  if (activeTurn && session.runtimeKind === 'codex' && session.threadId) {
@@ -3264,12 +3399,9 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
3264
3399
  if (!wake) {
3265
3400
  session.handoff = undefined;
3266
3401
  session.wakes = 0;
3402
+ session.wakeFailures = 0;
3267
3403
  session.wakeBlocked = false;
3268
3404
  session.userStopped = false;
3269
-
3270
- if (!session.parent) {
3271
- session.calls = 0;
3272
- }
3273
3405
  }
3274
3406
  }
3275
3407
 
@@ -3335,6 +3467,12 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
3335
3467
 
3336
3468
  for (const event of mail) event.deliveredTurn = turnId;
3337
3469
 
3470
+ // Only transport-accepted user input renews the root-tree budgets.
3471
+ if (!session.parent && origin === 'user') {
3472
+ session.calls = 0;
3473
+ session.advisorCalls = 0;
3474
+ }
3475
+
3338
3476
  session.seen.add(
3339
3477
  prompt.clientMessageId,
3340
3478
  );
@@ -3352,7 +3490,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
3352
3490
  if (origin === 'user' && !session.parent) session.userWaiting ??= Date.now();
3353
3491
  if (session.supervisor && origin === 'user') session.journal.push(stamp(`user ${prompt.delivery === 'steer' ? 'steered' : 'asked'} ${session.mapping.agent.name}: ${clip(text, 1500)}`));
3354
3492
  // The steer reaches the model once its waiting tool returns; return it now, not when the work ends.
3355
- if (prompt.delivery === 'steer' && origin === 'user' && !session.parent) releaseWaiters(session);
3493
+ if (prompt.delivery === 'steer' && origin === 'user' && !session.parent) { releaseWaiters(session); releaseQuestions(sessionId); }
3356
3494
 
3357
3495
  emit(sessionId, {
3358
3496
  type: 'prompt.accepted',
@@ -3411,8 +3549,16 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
3411
3549
  }),
3412
3550
 
3413
3551
  prompt: (sessionId, input) => enqueue(async () => {
3552
+ const session = sessions.get(sessionId);
3553
+ const parked = session?.parked;
3414
3554
  try {
3415
- await startPrompt(sessionId, input, 'user');
3555
+ const prompt = session?.active && session.resumedTurn === session.active && input.delivery === 'auto'
3556
+ ? { ...input, delivery: 'steer' as const } : input;
3557
+ await startPrompt(sessionId, prompt, 'user');
3558
+ if (session && parked && session.parked === parked) {
3559
+ session.parked = undefined;
3560
+ emit(sessionId, { type: 'session.updated', session: snapshot(sessionId, session) });
3561
+ }
3416
3562
  } catch (error) {
3417
3563
  const session = sessions.get(sessionId);
3418
3564
  if (session?.seen.has(input.clientMessageId)) return;
@@ -3578,8 +3724,8 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
3578
3724
  return [...inherited];
3579
3725
  },
3580
3726
 
3581
- recover(rootId, { continueRoot = false } = {}) {
3582
- return enqueue(() => recoverTree(rootId, continueRoot));
3727
+ recover(rootId, { continueRoot = false, parkedRoot } = {}) {
3728
+ return enqueue(() => recoverTree(rootId, continueRoot, parkedRoot));
3583
3729
  },
3584
3730
 
3585
3731
  async abandon(rootId, reason) {