@sema-agent/client-core 0.84.1 → 0.85.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +20 -13
  3. package/dist/adapt/arms.js +1 -1
  4. package/dist/adapt.d.ts +1 -1
  5. package/dist/adapt.js +1 -1
  6. package/dist/adapter/activeRunSelfHeal.d.ts +3 -0
  7. package/dist/adapter/activeRunSelfHeal.js +149 -17
  8. package/dist/adapter/downstream/terminalToSdkResult.js +9 -5
  9. package/dist/adapter/runStream.js +6 -2
  10. package/dist/agentSession/engineSessionBackgroundStop.d.ts +50 -0
  11. package/dist/agentSession/engineSessionBackgroundStop.js +291 -0
  12. package/dist/agentsWireCaps.d.ts +2 -1
  13. package/dist/cloudConfigWireCaps.js +2 -1
  14. package/dist/corruptSession.d.ts +3 -0
  15. package/dist/corruptSession.js +29 -0
  16. package/dist/corruptSessionCopy.d.ts +1 -0
  17. package/dist/corruptSessionCopy.js +4 -0
  18. package/dist/decideFailureNote.js +4 -1
  19. package/dist/decideReceipt.d.ts +6 -0
  20. package/dist/decideReceipt.js +30 -1
  21. package/dist/effortWire.d.ts +2 -1
  22. package/dist/effortWire.js +2 -4
  23. package/dist/engineErrorCodes.d.ts +9 -0
  24. package/dist/engineErrorCodes.js +29 -0
  25. package/dist/engineNoticeCodes.d.ts +9 -0
  26. package/dist/engineNoticeCodes.js +44 -156
  27. package/dist/engineWireFor.d.ts +43 -0
  28. package/dist/engineWireFor.js +399 -0
  29. package/dist/engineWireSdk.d.ts +4 -0
  30. package/dist/fileHistoryCaptureCapability.d.ts +4 -1
  31. package/dist/fileHistoryCaptureCapability.js +43 -9
  32. package/dist/gateVocabulary.d.ts +1 -2
  33. package/dist/gateVocabulary.js +1 -22
  34. package/dist/generated/engineNoticeTables.d.ts +3 -0
  35. package/dist/generated/engineNoticeTables.js +156 -0
  36. package/dist/generated/toolNameTables.d.ts +2 -0
  37. package/dist/generated/toolNameTables.js +43 -0
  38. package/dist/headlessPermissionModeWire.js +2 -4
  39. package/dist/hitl/armedGateRegistry.d.ts +1 -0
  40. package/dist/hitl/armedGateRegistry.js +1 -1
  41. package/dist/hitl/frameRouter.js +4 -2
  42. package/dist/hitl/parkResolver.js +3 -1
  43. package/dist/hitl/planReviewWire.d.ts +11 -0
  44. package/dist/hitl/planReviewWire.js +191 -72
  45. package/dist/hitl/rosterMountedNames.d.ts +2 -0
  46. package/dist/hitl/rosterMountedNames.js +38 -0
  47. package/dist/hitl/sessionPolicyDeliverable.d.ts +6 -2
  48. package/dist/hitl/sessionPolicyDeliverable.js +25 -45
  49. package/dist/hitl/sessionPolicyWire.js +1 -38
  50. package/dist/hitl/toolApprovalWire.d.ts +4 -2
  51. package/dist/hitl/toolApprovalWire.js +4 -2
  52. package/dist/host.d.ts +1 -1
  53. package/dist/host.js +11 -2
  54. package/dist/index.d.ts +10 -4
  55. package/dist/index.js +7 -3
  56. package/dist/permissionWireCaps.d.ts +2 -1
  57. package/dist/permissionWireCaps.js +2 -7
  58. package/dist/request/taskRequest.d.ts +4 -0
  59. package/dist/request/taskRequest.js +30 -1
  60. package/dist/resumeRefusalCopy.js +4 -2
  61. package/dist/runTerminal.js +4 -0
  62. package/dist/sandboxWire.js +1 -1
  63. package/dist/sealedKeyCapability.d.ts +17 -0
  64. package/dist/sealedKeyCapability.js +42 -0
  65. package/dist/seatContract.d.ts +2 -1
  66. package/dist/seatContract.js +5 -21
  67. package/dist/subagent/engineCompactWire.d.ts +13 -2
  68. package/dist/subagent/engineCompactWire.js +48 -37
  69. package/dist/subagent/engineDelegatedPrompt.d.ts +9 -1
  70. package/dist/subagent/engineDelegatedPrompt.js +16 -16
  71. package/dist/subagent/engineRowStopGate.d.ts +2 -2
  72. package/dist/subagent/engineRowStopGate.js +4 -4
  73. package/dist/subagent/engineSubagentOutput.d.ts +8 -0
  74. package/dist/subagent/engineSubagentOutput.js +37 -18
  75. package/dist/subagent/engineSubagentResume.d.ts +11 -2
  76. package/dist/subagent/engineSubagentResume.js +28 -14
  77. package/dist/subagent/engineSubagentSteer.d.ts +10 -1
  78. package/dist/subagent/engineSubagentSteer.js +16 -11
  79. package/dist/subagent/engineSubagentTail.d.ts +10 -1
  80. package/dist/subagent/engineSubagentTail.js +35 -17
  81. package/dist/subagent/engineTaskHandleWire.d.ts +13 -0
  82. package/dist/subagent/engineTaskHandleWire.js +41 -20
  83. package/dist/subagent/subagentOwnerAbsence.js +2 -5
  84. package/dist/taskRequestWords.d.ts +5 -0
  85. package/dist/taskRequestWords.js +4 -0
  86. package/dist/wireErrorTriage.d.ts +1 -0
  87. package/dist/wireErrorTriage.js +3 -1
  88. package/dist/wireRefusalCopy.d.ts +10 -0
  89. package/dist/wireRefusalCopy.js +62 -2
  90. package/docs/INTEGRATION-CLIENTS.md +867 -22
  91. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { publishQuestionFrame, publishQuestionFrameFor, registerLocalQuestionResponder, unregisterLocalQuestionResponder, hasLocalQuestionResponder, hasQuestionOverlayFor, } from '../liveQuestionStore.js';
2
2
  import { hostLog } from '../host.js';
3
3
  import { abortableSleep, settleWithinDeadline } from '../abortableSleep.js';
4
- import { makeEngineWireClient } from '../engineWireSdk.js';
4
+ import { clientHasVerbs, engineWireFor, noAnswerKindOf, sendOnce } from '../engineWireFor.js';
5
5
  import { engineWireTarget } from '../engineWireTarget.js';
6
6
  import { enqueuePlanReviewOutcome } from '../notifications.js';
7
7
  import { corruptStoredRowContent, corruptStoredRowWhere } from '../wireErrorTriage.js';
@@ -102,6 +102,18 @@ export function isPlanReviewPark(result) {
102
102
  const taskId = result?.taskId;
103
103
  return typeof taskId === 'string' && taskId.length > 0;
104
104
  }
105
+ function pausedCheckpointIdOf(carrier) {
106
+ try {
107
+ const read = readRunTerminal(carrier);
108
+ if (read === null || read.kind !== 'paused')
109
+ return undefined;
110
+ const id = read.checkpointId;
111
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ }
105
117
  const armedPlanReviewTaskIds = new Set();
106
118
  const offerModeAfterByTask = new Map();
107
119
  export function _resetArmedPlanReviewsForTest() {
@@ -239,6 +251,7 @@ export function armPlanReviewApproval(result, sessionKey, opts) {
239
251
  ? { ...(wire !== undefined ? { wire } : {}), ...(opts?.onOutcome !== undefined ? { onOutcome: opts.onOutcome } : {}) }
240
252
  : undefined;
241
253
  const { taskId } = result;
254
+ const gateBefore = pausedCheckpointIdOf(result);
242
255
  retireActiveReopen(DEFAULT_SESSION_KEY, planReviewQuestionId(taskId));
243
256
  if (armedPlanReviewTaskIds.has(taskId)) {
244
257
  if (hasLocalQuestionResponder(planReviewQuestionId(taskId))) {
@@ -274,7 +287,7 @@ export function armPlanReviewApproval(result, sessionKey, opts) {
274
287
  offerModeAfterByTask.delete(taskId);
275
288
  notePlanReviewAnswered(questionId);
276
289
  hostLog('debug', `planReviewWire: arm responder delivering ${decided} for task ${taskId} (path=arm)`);
277
- void (delivery === undefined ? decidePlanReview(taskId, decided, modeAfter) : decidePlanReview(taskId, decided, modeAfter, delivery));
290
+ void decidePlanReviewFor(taskId, decided, modeAfter, delivery, gateBefore);
278
291
  return { ok: true };
279
292
  });
280
293
  publishPlanReviewCard(taskId);
@@ -312,108 +325,186 @@ function planReviewDecisionTimedOutOutcome(decision) {
312
325
  return (`The plan_review ${decision} was sent, but no answer came back from the engine within the time limit this host allows for it — ` +
313
326
  `it may have taken effect (${decision === 'approve' ? 'an approval keeps the engine busy until the resumed work finishes' : 'the engine may still be applying it'}).`);
314
327
  }
315
- function isRequestTimeout(e) {
316
- try {
317
- return typeof e === 'object' && e !== null && e.name === 'TimeoutError';
318
- }
319
- catch {
320
- return false;
321
- }
328
+ function planReviewDecisionConnectionLostOutcome(decision) {
329
+ return (`The plan_review ${decision} got no answer from the engine: the connection failed before an answer came back, and the request may already have reached the engine — ` +
330
+ `it may have taken effect (${decision === 'approve' ? 'an approval keeps the engine busy until the resumed work finishes' : 'the engine may still be applying it'}).`);
331
+ }
332
+ function planReviewAdvancedOutcome(decision, postStatus) {
333
+ return (`The plan was ${decision === 'approve' ? 'approved' : 'rejected'} and the task advanced to a NEW approval gate ` +
334
+ `(post-decide status: ${postStatus}) — the next approval card will surface it; this is not a completion yet.`);
322
335
  }
323
336
  function usablePlanReviewWireClient(client) {
324
337
  if (client === null || typeof client !== 'object')
325
338
  return false;
326
- const c = client;
327
- return typeof c.assistant?.planReview === 'function' && typeof c.runs?.get === 'function';
339
+ return clientHasVerbs(client, [['assistant', 'planReview'], ['runs', 'get']]);
328
340
  }
329
341
  const PLAN_REVIEW_INJECTED_REPULL_TIMEOUT_MS = 15_000;
330
- const decideInFlight = new Map();
342
+ const planReviewDecisionOps = [];
343
+ const MAX_PLAN_REVIEW_DECISION_OPS = 32;
344
+ let planReviewLedgerClock = 0;
331
345
  let decideDispatchSeq = 0;
346
+ function planReviewOpsOf(taskId) {
347
+ return planReviewDecisionOps.filter((op) => op.taskId === taskId);
348
+ }
349
+ function settlePlanReviewDecisionOp(op) {
350
+ if (op.settledAt === null)
351
+ op.settledAt = ++planReviewLedgerClock;
352
+ }
353
+ function pushPlanReviewDecisionOp(op) {
354
+ planReviewDecisionOps.push(op);
355
+ while (planReviewDecisionOps.length > MAX_PLAN_REVIEW_DECISION_OPS) {
356
+ let victim = planReviewDecisionOps.findIndex((o) => o.settledAt !== null && !o.ownCallOpen);
357
+ if (victim < 0)
358
+ victim = planReviewDecisionOps.findIndex((o) => o !== op && o.phase === 'handed');
359
+ if (victim < 0)
360
+ break;
361
+ planReviewDecisionOps.splice(victim, 1);
362
+ }
363
+ }
364
+ function dropPlanReviewDecisionOp(op) {
365
+ const i = planReviewDecisionOps.indexOf(op);
366
+ if (i >= 0)
367
+ planReviewDecisionOps.splice(i, 1);
368
+ }
369
+ export function notePlanReviewDecisionHandedOver(taskId) {
370
+ if (typeof taskId !== 'string' || taskId === '')
371
+ return null;
372
+ if (planReviewDecisionOps.some((op) => op.taskId === taskId && op.settledAt === null))
373
+ return null;
374
+ const op = { taskId, handedAt: ++planReviewLedgerClock, settledAt: null, phase: 'handed', notApplied: false, ownCallOpen: false };
375
+ pushPlanReviewDecisionOp(op);
376
+ return {
377
+ settle: () => {
378
+ if (op.phase !== 'handed')
379
+ return;
380
+ settlePlanReviewDecisionOp(op);
381
+ },
382
+ revoke: () => {
383
+ if (op.phase !== 'handed' || op.settledAt !== null)
384
+ return;
385
+ dropPlanReviewDecisionOp(op);
386
+ },
387
+ };
388
+ }
389
+ export function planReviewPendingReadMark() {
390
+ return ++planReviewLedgerClock;
391
+ }
392
+ export function planReviewDecisionInFlight(taskId, pendingReadStartedAt) {
393
+ if (typeof taskId !== 'string' || taskId === '')
394
+ return false;
395
+ const mark = typeof pendingReadStartedAt === 'number' && Number.isFinite(pendingReadStartedAt) ? pendingReadStartedAt : undefined;
396
+ return planReviewDecisionOps.some((op) => op.taskId === taskId && (op.settledAt === null || (mark !== undefined && op.settledAt > mark && !op.notApplied)));
397
+ }
398
+ export function notePlanReviewDecisionNotApplied(taskId) {
399
+ if (typeof taskId !== 'string' || taskId === '')
400
+ return;
401
+ const ops = planReviewOpsOf(taskId);
402
+ if (ops.some((op) => op.settledAt === null))
403
+ return;
404
+ let latest;
405
+ for (const op of ops)
406
+ if (latest === undefined || op.handedAt > latest.handedAt)
407
+ latest = op;
408
+ if (latest !== undefined)
409
+ latest.notApplied = true;
410
+ }
411
+ export function notePlanReviewGateLeft(taskId) {
412
+ if (typeof taskId !== 'string' || taskId === '')
413
+ return;
414
+ for (const op of planReviewOpsOf(taskId))
415
+ settlePlanReviewDecisionOp(op);
416
+ }
417
+ export function latestPlanReviewDecisionMark(taskId) {
418
+ if (typeof taskId !== 'string' || taskId === '')
419
+ return null;
420
+ let mark = null;
421
+ for (const op of planReviewOpsOf(taskId))
422
+ if (mark === null || op.handedAt > mark)
423
+ mark = op.handedAt;
424
+ return mark;
425
+ }
332
426
  export function __resetPlanReviewDecisionLatchForTests() {
333
- decideInFlight.clear();
427
+ planReviewDecisionOps.length = 0;
334
428
  }
335
429
  export async function decidePlanReview(taskId, decision, permissionModeAfter, opts) {
336
- if (decideInFlight.has(taskId)) {
430
+ return decidePlanReviewFor(taskId, decision, permissionModeAfter, opts, undefined);
431
+ }
432
+ async function decidePlanReviewFor(taskId, decision, permissionModeAfter, opts, gateBefore) {
433
+ if (typeof taskId === 'string' && planReviewDecisionOps.some((op) => op.taskId === taskId && op.phase === 'posting')) {
337
434
  hostLog('error', `planReviewWire: decidePlanReview(${decision}) for task ${taskId} REFUSED — a decision for this task is already in flight (duplicate delivery latched)`);
338
435
  return;
339
436
  }
340
- const token = {};
341
- decideInFlight.set(taskId, token);
437
+ let op = planReviewDecisionOps.find((o) => o.taskId === taskId && o.phase === 'handed' && o.settledAt === null);
438
+ if (op === undefined) {
439
+ op = { taskId: typeof taskId === 'string' ? taskId : String(taskId), handedAt: ++planReviewLedgerClock, settledAt: null, phase: 'posting', notApplied: false, ownCallOpen: true };
440
+ pushPlanReviewDecisionOp(op);
441
+ }
442
+ else {
443
+ op.phase = 'posting';
444
+ op.ownCallOpen = true;
445
+ }
446
+ const mine = op;
342
447
  const releaseLatch = () => {
343
- if (decideInFlight.get(taskId) === token)
344
- decideInFlight.delete(taskId);
448
+ if (mine.phase === 'posting')
449
+ mine.phase = 'landed';
450
+ };
451
+ const settle = (notApplied) => {
452
+ if (notApplied)
453
+ mine.notApplied = true;
454
+ settlePlanReviewDecisionOp(mine);
345
455
  };
346
456
  const dispatchNo = ++decideDispatchSeq;
347
457
  hostLog('debug', `planReviewWire: decidePlanReview dispatch #${dispatchNo} task=${taskId} decision=${decision} mode=${permissionModeAfter ?? '-'}`);
348
458
  try {
349
- await decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch, opts);
459
+ await decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch, settle, opts, gateBefore);
350
460
  }
351
461
  finally {
352
462
  releaseLatch();
463
+ settlePlanReviewDecisionOp(mine);
464
+ mine.ownCallOpen = false;
353
465
  }
354
466
  }
355
- function planReviewClientsFor(wire, cfg) {
356
- if (wire !== undefined) {
357
- let injected;
358
- try {
359
- injected = typeof wire === 'object' && wire !== null ? wire.client : undefined;
360
- }
361
- catch {
362
- injected = undefined;
363
- }
364
- if (!usablePlanReviewWireClient(injected))
365
- return null;
366
- const client = injected;
467
+ function planReviewClientsFor(resolved) {
468
+ if (resolved.kind === 'injected') {
469
+ const client = resolved.client;
367
470
  return { decide: client, repull: () => client };
368
471
  }
369
- if (!cfg)
370
- return null;
371
- const decide = makeEngineWireClient({
372
- baseUrl: cfg.baseUrl,
373
- ...(cfg.token ? { token: cfg.token } : {}),
374
- principal: cfg.principal,
375
- timeoutMs: PLAN_REVIEW_RESUME_TIMEOUT_MS,
376
- });
472
+ const decide = resolved.make({ timeoutMs: PLAN_REVIEW_RESUME_TIMEOUT_MS });
377
473
  if (!decide)
378
474
  return null;
379
475
  return {
380
476
  decide,
381
- repull: () => makeEngineWireClient({
382
- baseUrl: cfg.baseUrl,
383
- ...(cfg.token ? { token: cfg.token } : {}),
384
- principal: cfg.principal,
385
- timeoutMs: 15_000,
386
- }),
477
+ repull: () => resolved.make({ timeoutMs: 15_000 }),
387
478
  };
388
479
  }
389
- async function decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch, opts) {
480
+ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch, settle, opts, gateBefore) {
390
481
  let outcome;
391
482
  let effect = 'unconfirmed';
483
+ let stillParkedProven = false;
392
484
  let statusSeen;
393
485
  const wire = opts?.wire;
394
- const cfg = wire === undefined ? engineWireTarget() : null;
486
+ const resolved = engineWireFor(wire, DEFAULT_SESSION_KEY, usablePlanReviewWireClient);
395
487
  const badMode = permissionModeAfter !== undefined && (decision !== 'approve' || !isPlanReviewModeAfter(permissionModeAfter));
396
488
  if (badMode) {
397
489
  hostLog('error', `planReviewWire: ${decision} NOT sent — permissionModeAfter is only valid with approve and must be "default" or "acceptEdits"`);
398
490
  effect = 'not_sent';
399
491
  outcome = `The plan_review ${decision} could NOT be sent: the post-approval permission mode given with it is not valid for this decision (it only applies to an approval, and must be "default" or "acceptEdits"). Tell the user plainly that the decision did not go through; the plan is still waiting.`;
400
492
  }
401
- else if (wire === undefined && !cfg) {
493
+ else if (resolved.kind === 'no-target') {
402
494
  hostLog('error', `planReviewWire: ${decision} NOT sent — engineWireTarget() unavailable`);
403
495
  effect = 'not_sent';
404
496
  outcome = `The plan_review ${decision} could NOT be sent: no engine connection is configured on this host. Tell the user plainly that the decision did not go through.`;
405
497
  }
498
+ else if (resolved.kind === 'injected-unusable') {
499
+ hostLog('error', `planReviewWire: ${decision} NOT sent — the engine client supplied by the host is not usable (needs assistant.planReview and runs.get)`);
500
+ effect = 'not_sent';
501
+ outcome = `The plan_review ${decision} could NOT be sent: the engine client supplied by the host cannot send it (it lacks the plan-review verb). Tell the user plainly that the decision did not go through.`;
502
+ }
406
503
  else {
407
- const clients = planReviewClientsFor(wire, cfg);
504
+ const clients = planReviewClientsFor(resolved);
408
505
  if (!clients) {
409
- if (wire !== undefined) {
410
- hostLog('error', `planReviewWire: ${decision} NOT sent — the engine client supplied by the host is not usable (needs assistant.planReview and runs.get)`);
411
- outcome = `The plan_review ${decision} could NOT be sent: the engine client supplied by the host cannot send it (it lacks the plan-review verb). Tell the user plainly that the decision did not go through.`;
412
- }
413
- else {
414
- hostLog('error', `planReviewWire: ${decision} NOT sent — makeEngineWireClient() returned null`);
415
- outcome = `The plan_review ${decision} could NOT be sent: the engine client could not be constructed. Tell the user plainly that the decision did not go through.`;
416
- }
506
+ hostLog('error', `planReviewWire: ${decision} NOT sent — makeEngineWireClient() returned null`);
507
+ outcome = `The plan_review ${decision} could NOT be sent: the engine client could not be constructed. Tell the user plainly that the decision did not go through.`;
417
508
  effect = 'not_sent';
418
509
  }
419
510
  else {
@@ -428,13 +519,13 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
428
519
  reqBody.permissionModeAfter = mode;
429
520
  let body;
430
521
  try {
431
- body = await client.assistant.planReview(taskId, reqBody);
522
+ body = await sendOnce(undefined, () => client.assistant.planReview(taskId, reqBody));
432
523
  }
433
524
  catch (e1) {
434
525
  const conflict = e1;
435
526
  if (mode === 'default' && conflict?.status === 400 && conflict?.errorCode === 'request.field_conflict') {
436
527
  hostLog('debug', 'planReviewWire: permissionModeAfter "default" refused as not applicable — re-sending the approval without the key (same meaning)');
437
- body = await client.assistant.planReview(taskId, reason !== undefined ? { decision, reason } : { decision });
528
+ body = await sendOnce(undefined, () => client.assistant.planReview(taskId, reason !== undefined ? { decision, reason } : { decision }));
438
529
  }
439
530
  else {
440
531
  throw e1;
@@ -445,6 +536,7 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
445
536
  }
446
537
  hostLog('debug', `planReviewWire: ${decision} → ok (dispatch #${dispatchNo}) ${JSON.stringify(body).slice(0, 200)}`);
447
538
  let postStatus;
539
+ let gateAfter;
448
540
  try {
449
541
  const probe = clients.repull();
450
542
  if (probe) {
@@ -461,6 +553,7 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
461
553
  post = (await probe.runs.get(taskId));
462
554
  }
463
555
  postStatus = typeof post?.status === 'string' ? post.status : undefined;
556
+ gateAfter = pausedCheckpointIdOf(post?.result);
464
557
  }
465
558
  }
466
559
  catch (e) {
@@ -468,8 +561,14 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
468
561
  }
469
562
  const effective = postStatus ?? (typeof body?.status === 'string' ? body.status : undefined);
470
563
  statusSeen = effective;
471
- if (effective === 'needs_review') {
564
+ const gateMoved = postStatus === 'needs_review' && gateBefore !== undefined && gateAfter !== undefined && gateAfter !== gateBefore;
565
+ if (effective === 'needs_review' && gateMoved) {
566
+ effect = 'advanced';
567
+ outcome = planReviewAdvancedOutcome(decision, 'needs_review');
568
+ }
569
+ else if (effective === 'needs_review') {
472
570
  effect = 'still_parked';
571
+ stillParkedProven = postStatus === 'needs_review' && gateBefore !== undefined && gateAfter !== undefined && gateAfter === gateBefore;
473
572
  outcome =
474
573
  `The plan_review ${decision} returned HTTP 200 but the session is STILL locked on the same review gate ` +
475
574
  `(post-decide status: needs_review). The decision did NOT take effect (known engine issue, RB-471 family). ` +
@@ -477,9 +576,7 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
477
576
  }
478
577
  else if (effective === 'suspended') {
479
578
  effect = 'advanced';
480
- outcome =
481
- `The plan was ${decision === 'approve' ? 'approved' : 'rejected'} and the task advanced to a NEW approval gate ` +
482
- `(post-decide status: suspended) — the next approval card will surface it; this is not a completion yet.`;
579
+ outcome = planReviewAdvancedOutcome(decision, 'suspended');
483
580
  }
484
581
  else if (effective !== undefined) {
485
582
  effect = 'took_effect';
@@ -492,9 +589,11 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
492
589
  catch (e) {
493
590
  const status = e?.status;
494
591
  const refusedCode = e?.errorCode;
592
+ const noAnswer = typeof status === 'number' || typeof e !== 'object' ? null : noAnswerKindOf(e);
495
593
  if (status === 400 && refusedCode === 'request.field_conflict' && permissionModeAfter === 'acceptEdits') {
496
594
  hostLog('debug', 'planReviewWire: approve + acceptEdits refused (request.field_conflict) — NOT re-sent with a different mode');
497
595
  effect = 'still_parked';
596
+ stillParkedProven = true;
498
597
  outcome =
499
598
  'The plan approval was NOT applied: the engine refused "auto-accept edits" for this task (it was not started read-only in plan mode, so that option does not apply). ' +
500
599
  'Nothing changed engine-side — the plan is still waiting for review. Tell the user plainly, and that approving with "manually approve edits" will go through.';
@@ -518,10 +617,14 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
518
617
  effect = status >= 400 && status < 500 ? 'not_applied' : 'unconfirmed';
519
618
  outcome = `${`The plan_review decision failed: HTTP ${status} ${msg}`.trim()}${decideFailureNote(e)}`;
520
619
  }
521
- else if (isRequestTimeout(e)) {
620
+ else if (noAnswer === 'time-limit' || noAnswer === 'deadline') {
522
621
  hostLog('debug', `planReviewWire: ${decision} sent but no answer within the request time limit (task ${taskId})`);
523
622
  outcome = planReviewDecisionTimedOutOutcome(decision);
524
623
  }
624
+ else if (noAnswer === 'connection-lost') {
625
+ hostLog('debug', `planReviewWire: ${decision} got no answer — the connection failed and it cannot be told whether the request reached the engine (task ${taskId})`);
626
+ outcome = planReviewDecisionConnectionLostOutcome(decision);
627
+ }
525
628
  else {
526
629
  outcome = `The plan_review decision could not reach the engine: ${String(e)}`;
527
630
  }
@@ -531,6 +634,7 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
531
634
  const tail = planReviewOutcomeTail(taskId, decision, effect);
532
635
  const prompt = `<plan-review-outcome>\n${outcome}\n${tail}\n</plan-review-outcome>`;
533
636
  const onOutcome = opts?.onOutcome;
637
+ settle(effect === 'not_sent' || (effect === 'still_parked' && stillParkedProven));
534
638
  try {
535
639
  const delivered = enqueuePlanReviewOutcome(prompt, { taskId, dispatchNo, decision, effect });
536
640
  if (!delivered) {
@@ -723,7 +827,13 @@ function mintPlanReviewReopenCard(taskId, opts, sessionKey, receiptMs) {
723
827
  revokePlanReviewUserDismissal(sessionKey, taskId, markBefore);
724
828
  return { reopened: true, firstSight };
725
829
  }
726
- async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark) {
830
+ function noPresentationSurfaceVerdict(taskId, sessionKey) {
831
+ const verdict = { reopened: false, _sema_noPresentationSurface: true };
832
+ hostLog('debug', `planReviewWire: reopen for task ${taskId} refused — nothing is subscribed to question frames for session ` +
833
+ `${JSON.stringify(sessionKey)} (onQuestionFrameFor), so the card has nowhere to render`);
834
+ return verdict;
835
+ }
836
+ async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark, recheckReadStart) {
727
837
  const refusedByUser = { reopened: false, dismissedByUser: true };
728
838
  try {
729
839
  let fresh = false;
@@ -740,7 +850,8 @@ async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, ma
740
850
  }
741
851
  }
742
852
  let handedOver = false;
743
- if (!decideInFlight.has(taskId)) {
853
+ const ledgerSaysInFlight = planReviewDecisionInFlight(taskId, recheckReadStart);
854
+ if (!ledgerSaysInFlight) {
744
855
  try {
745
856
  handedOver = opts.decisionHandedOver?.(taskId) === true;
746
857
  }
@@ -749,7 +860,7 @@ async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, ma
749
860
  return { reopened: false };
750
861
  }
751
862
  }
752
- if (decideInFlight.has(taskId) || handedOver) {
863
+ if (ledgerSaysInFlight || handedOver) {
753
864
  hostLog('debug', `planReviewWire: reopen for task ${taskId} refused after the dismissal check — a decision for this task went out while the check was reading`);
754
865
  return { reopened: false, _sema_decisionInFlight: true };
755
866
  }
@@ -758,6 +869,11 @@ async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, ma
758
869
  hostLog('debug', `planReviewWire: reopen for task ${taskId} not minted — the dismissal record changed while it was being checked (stale evaluation)`);
759
870
  return await staleDismissalVerdict(current, sessionKey, taskId, receiptMs);
760
871
  }
872
+ if (!hasQuestionOverlayFor(sessionKey)) {
873
+ if (fresh)
874
+ dropDismissalEntry(sessionKey, taskId, mark);
875
+ return noPresentationSurfaceVerdict(taskId, sessionKey);
876
+ }
761
877
  if (!fresh) {
762
878
  hostLog('debug', `planReviewWire: automatic reopen for task ${taskId} refused — the user closed this gate's card (no newer gate instance proven)`);
763
879
  return refusedByUser;
@@ -773,8 +889,7 @@ async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, ma
773
889
  putDismissalEntry(sessionKey, taskId, reopening);
774
890
  let verdict = { reopened: false };
775
891
  try {
776
- if (hasQuestionOverlayFor(sessionKey))
777
- verdict = await mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs);
892
+ verdict = await mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs);
778
893
  }
779
894
  finally {
780
895
  const presented = verdict.reopened === true;
@@ -822,14 +937,18 @@ export function reopenPlanReviewCard(taskId, opts) {
822
937
  if (typeof taskId !== 'string' || taskId.length === 0) {
823
938
  return receiptMs === undefined ? { reopened: false } : Promise.resolve({ reopened: false });
824
939
  }
825
- if (decideInFlight.has(taskId)) {
940
+ const readStart = typeof opts?.pendingReadStartedAt === 'number' && Number.isFinite(opts.pendingReadStartedAt) ? opts.pendingReadStartedAt : undefined;
941
+ if (planReviewDecisionInFlight(taskId, readStart)) {
826
942
  hostLog('debug', `planReviewWire: reopen for task ${taskId} refused — a decision for this task is in flight (its answer is still being delivered)`);
827
943
  const refused = { reopened: false, _sema_decisionInFlight: true };
828
944
  return receiptMs === undefined ? refused : Promise.resolve(refused);
829
945
  }
946
+ const guardStartedAt = planReviewPendingReadMark();
947
+ const recheckReadStart = readStart === undefined ? guardStartedAt : Math.min(readStart, guardStartedAt);
830
948
  const sessionKey = opts?.sessionKey ?? DEFAULT_SESSION_KEY;
831
949
  if (!hasQuestionOverlayFor(sessionKey)) {
832
- return receiptMs === undefined ? { reopened: false } : Promise.resolve({ reopened: false });
950
+ const noSurface = noPresentationSurfaceVerdict(taskId, sessionKey);
951
+ return receiptMs === undefined ? noSurface : Promise.resolve(noSurface);
833
952
  }
834
953
  if (sessionKey !== DEFAULT_SESSION_KEY && opts?.deliverDecision === undefined && opts?.wire === undefined) {
835
954
  hostLog('error', `planReviewWire: plan_review 卡未重开 —— sessionKey=${JSON.stringify(sessionKey)} 是非默认槽,` +
@@ -846,7 +965,7 @@ export function reopenPlanReviewCard(taskId, opts) {
846
965
  hostLog('debug', `planReviewWire: automatic reopen for task ${taskId} refused — the user closed this gate's card`);
847
966
  return { reopened: false, dismissedByUser: true };
848
967
  }
849
- return reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark);
968
+ return reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark, recheckReadStart);
850
969
  }
851
970
  }
852
971
  return mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs);
@@ -0,0 +1,2 @@
1
+ import type { ToolRosterView } from '../toolRoster.js';
2
+ export declare function rosterMountedNames(roster: ToolRosterView | null | undefined): ReadonlySet<string> | 'absent' | 'unreadable';
@@ -0,0 +1,38 @@
1
+ export function rosterMountedNames(roster) {
2
+ if (roster === undefined || roster === null)
3
+ return 'absent';
4
+ try {
5
+ if (typeof roster !== 'object' || Array.isArray(roster))
6
+ return 'unreadable';
7
+ const entries = roster.entries;
8
+ if (!Array.isArray(entries))
9
+ return 'unreadable';
10
+ const names = new Set();
11
+ const n = entries.length;
12
+ for (let i = 0; i < n; i++) {
13
+ if (!Object.hasOwn(entries, i))
14
+ return 'unreadable';
15
+ const row = entries[i];
16
+ if (typeof row !== 'object' || row === null)
17
+ return 'unreadable';
18
+ const name = row.name;
19
+ if (typeof name !== 'string' || name.length === 0)
20
+ return 'unreadable';
21
+ names.add(name);
22
+ const aliases = row.aliases;
23
+ if (aliases === undefined)
24
+ continue;
25
+ if (!Array.isArray(aliases))
26
+ return 'unreadable';
27
+ for (const a of aliases) {
28
+ if (typeof a !== 'string')
29
+ return 'unreadable';
30
+ names.add(a);
31
+ }
32
+ }
33
+ return names;
34
+ }
35
+ catch {
36
+ return 'unreadable';
37
+ }
38
+ }
@@ -1,5 +1,6 @@
1
1
  import type { SessionPermissionRules } from '@sema-agent/sdk';
2
- export type SessionPolicyWithheldWhy = 'ask_no_bucket' | 'qualified_deny' | 'peer_wide' | 'wildcard' | 'not_a_tool_name' | 'legacy_tool_name';
2
+ import type { ToolRosterView } from '../toolRoster.js';
3
+ export type SessionPolicyWithheldWhy = 'ask_no_bucket' | 'qualified_deny' | 'peer_wide' | 'wildcard' | 'not_a_tool_name' | 'legacy_tool_name' | 'not_in_roster';
3
4
  export declare const SESSION_POLICY_WITHHELD_WHY: readonly SessionPolicyWithheldWhy[];
4
5
  export interface SessionPolicyWithheldRule {
5
6
  readonly rule: string;
@@ -11,7 +12,10 @@ export interface SessionPolicyDeliverability {
11
12
  readonly withheld: readonly SessionPolicyWithheldRule[];
12
13
  readonly unreadable?: true;
13
14
  }
15
+ export interface SessionPolicyDeliverableOptions {
16
+ readonly roster?: ToolRosterView;
17
+ }
14
18
  export declare function isEngineRefusedToolName(name: string): boolean;
15
- export declare function sessionPolicyDeliverable(behavior: 'deny' | 'ask', rules: readonly string[]): SessionPolicyDeliverability;
19
+ export declare function sessionPolicyDeliverable(behavior: 'deny' | 'ask', rules: readonly string[], opts?: SessionPolicyDeliverableOptions): SessionPolicyDeliverability;
16
20
  export declare function sessionPolicyWithheldNotice(why: SessionPolicyWithheldWhy): string;
17
21
  export declare function legacyToolNameFailureNoteOf(code: string | undefined): string | undefined;
@@ -1,47 +1,8 @@
1
1
  import { CONFIG_LEGACY_TOOL_NAME } from '../engineErrorCodes.js';
2
- const COVERING_NAMESPACE_PREFIXES = ['mcp__', 'a2a__'];
3
- const ENGINE_RETIRED_TOOL_NAMES = new Map([
4
- ['read_file', 'Read'],
5
- ['edit_file', 'Edit'],
6
- ['write_file', 'Write'],
7
- ['bash', 'Bash'],
8
- ['bash_readonly', null],
9
- ['glob', 'Glob'],
10
- ['grep', 'Grep'],
11
- ['web_fetch', 'WebFetch'],
12
- ['tool_search', 'ToolSearch'],
13
- ['skill', 'Skill'],
14
- ['present_plan', 'ExitPlanMode'],
15
- ['enter_plan_mode', 'EnterPlanMode'],
16
- ['spawn_subagent', 'Agent'],
17
- ['CronCancel', 'CronDelete'],
18
- ['lsp', 'LSP'],
19
- ['submit_output', 'StructuredOutput'],
20
- ['repo_map', 'RepoMap'],
21
- ['read_tool_result', 'ReadToolResult'],
22
- ['report_blocked', 'ReportBlocked'],
23
- ['declare_done', 'DeclareDone'],
24
- ['run_workflow', 'Workflow'],
25
- ['RunWorkflow', 'Workflow'],
26
- ['Task', 'Agent'],
27
- ['BashOutput', 'TaskOutput'],
28
- ['AgentOutputTool', 'TaskOutput'],
29
- ['BashOutputTool', 'TaskOutput'],
30
- ['AgentOutput', 'TaskOutput'],
31
- ['WorkflowStatus', null],
32
- ['KillShell', 'TaskStop'],
33
- ['KillBash', 'TaskStop'],
34
- ['ListMcpResources', 'ListMcpResourcesTool'],
35
- ['ReadMcpResource', 'ReadMcpResourceTool'],
36
- ['ReadMcpResourceDir', 'ReadMcpResourceDirTool'],
37
- ['MultiEdit', null],
38
- ['Fork', null],
39
- ['Sleep', null],
40
- ['Remember', null],
41
- ['remember', null],
42
- ['Recall', null],
43
- ['recall', null],
44
- ]);
2
+ import { rosterMountedNames } from './rosterMountedNames.js';
3
+ import { CORE_PROTOCOL_PREFIXES, CORE_RETIRED_TOOL_CURRENT_NAMES } from '../generated/toolNameTables.js';
4
+ const COVERING_NAMESPACE_PREFIXES = CORE_PROTOCOL_PREFIXES;
5
+ const ENGINE_RETIRED_TOOL_NAMES = CORE_RETIRED_TOOL_CURRENT_NAMES;
45
6
  const PREFIXES_IN_PROSE = COVERING_NAMESPACE_PREFIXES.map((prefix) => `"${prefix}"`).join(' or ');
46
7
  const WITHHELD_NOTICE = {
47
8
  ask_no_bucket: 'Not written to this session’s rule record: that record only holds rules that block outright, and this rule asks before running instead — recording it as a block would change what it means.',
@@ -50,6 +11,7 @@ const WITHHELD_NOTICE = {
50
11
  wildcard: 'Not written to this session’s rule record: this rule uses a wildcard (*), and an engine that matches that record by exact tool name would block none of the tools it covers.',
51
12
  not_a_tool_name: 'Not written to this session’s rule record: this entry is not exactly one tool name — it is empty, not text, or has spaces around the name.',
52
13
  legacy_tool_name: `Not written to this session’s rule record: unless a tool by exactly this name is available, the engine refuses it — it is a retired tool name, or it contains "__" but does not start with ${PREFIXES_IN_PROSE} — and with it in that record every later run of this session would fail at startup until that entry is removed from the record. If the tool still exists, use its current name; a server or agent-peer tool name starts with ${PREFIXES_IN_PROSE}.`,
14
+ not_in_roster: 'Not written to this session’s rule record: the tool roster supplied for this check has no tool by exactly this name, as a name or an alias, with letter case counted — it may be misspelled, or name a tool this deployment does not provide — so recorded as it is it would block nothing here.',
53
15
  };
54
16
  export const SESSION_POLICY_WITHHELD_WHY = Object.freeze(Object.keys(WITHHELD_NOTICE));
55
17
  const MAX_RULES = 10_000;
@@ -83,7 +45,21 @@ export function isEngineRefusedToolName(name) {
83
45
  return true;
84
46
  return name.includes('__') && !COVERING_NAMESPACE_PREFIXES.some((prefix) => name.startsWith(prefix));
85
47
  }
86
- export function sessionPolicyDeliverable(behavior, rules) {
48
+ function suppliedRosterNames(opts) {
49
+ if (opts === undefined || opts === null)
50
+ return 'absent';
51
+ let roster;
52
+ try {
53
+ if (typeof opts !== 'object')
54
+ return 'unreadable';
55
+ roster = opts.roster;
56
+ }
57
+ catch {
58
+ return 'unreadable';
59
+ }
60
+ return rosterMountedNames(roster);
61
+ }
62
+ export function sessionPolicyDeliverable(behavior, rules, opts) {
87
63
  const entries = [];
88
64
  try {
89
65
  if (behavior !== 'deny' && behavior !== 'ask')
@@ -105,6 +81,10 @@ export function sessionPolicyDeliverable(behavior, rules) {
105
81
  catch {
106
82
  return unreadable();
107
83
  }
84
+ const known = suppliedRosterNames(opts);
85
+ if (known === 'unreadable')
86
+ return unreadable();
87
+ const mounted = known === 'absent' ? undefined : known;
108
88
  const withheld = [];
109
89
  if (behavior === 'ask') {
110
90
  for (const r of entries)
@@ -117,7 +97,7 @@ export function sessionPolicyDeliverable(behavior, rules) {
117
97
  withheld.push({ rule: '', why: 'not_a_tool_name' });
118
98
  continue;
119
99
  }
120
- const why = withheldWhyOf(r);
100
+ const why = withheldWhyOf(r) ?? (mounted !== undefined && !mounted.has(r) ? 'not_in_roster' : undefined);
121
101
  if (why === undefined)
122
102
  exact.push(r);
123
103
  else {