@sema-agent/client-core 0.84.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +27 -13
  3. package/dist/abortableSleep.d.ts +10 -0
  4. package/dist/abortableSleep.js +37 -0
  5. package/dist/adapt/arms.js +1 -1
  6. package/dist/adapt.d.ts +1 -1
  7. package/dist/adapt.js +1 -1
  8. package/dist/adapter/downstream/terminalToSdkResult.d.ts +1 -0
  9. package/dist/adapter/downstream/terminalToSdkResult.js +19 -10
  10. package/dist/adapter/runStream.js +6 -2
  11. package/dist/agentSession/engineSessionBackgroundStop.d.ts +50 -0
  12. package/dist/agentSession/engineSessionBackgroundStop.js +291 -0
  13. package/dist/agentsWireCaps.d.ts +2 -1
  14. package/dist/agentsWireCaps.js +8 -0
  15. package/dist/cloudConfigWireCaps.js +2 -1
  16. package/dist/corruptSession.d.ts +3 -0
  17. package/dist/corruptSession.js +29 -0
  18. package/dist/corruptSessionCopy.d.ts +1 -0
  19. package/dist/corruptSessionCopy.js +4 -0
  20. package/dist/decideFailureNote.d.ts +2 -1
  21. package/dist/decideFailureNote.js +8 -3
  22. package/dist/decideReceipt.d.ts +6 -0
  23. package/dist/decideReceipt.js +30 -1
  24. package/dist/detachWire.d.ts +1 -0
  25. package/dist/detachWire.js +13 -5
  26. package/dist/displayUntrusted.js +114 -18
  27. package/dist/effortWire.d.ts +2 -1
  28. package/dist/effortWire.js +2 -4
  29. package/dist/engineErrorCodes.d.ts +11 -0
  30. package/dist/engineErrorCodes.js +32 -0
  31. package/dist/engineNoticeCodes.d.ts +39 -4
  32. package/dist/engineNoticeCodes.js +117 -161
  33. package/dist/engineWireFor.d.ts +43 -0
  34. package/dist/engineWireFor.js +399 -0
  35. package/dist/engineWireSdk.d.ts +6 -0
  36. package/dist/fileHistoryCaptureCapability.d.ts +4 -1
  37. package/dist/fileHistoryCaptureCapability.js +43 -9
  38. package/dist/gateVocabulary.d.ts +1 -2
  39. package/dist/gateVocabulary.js +1 -22
  40. package/dist/generated/engineNoticeTables.d.ts +3 -0
  41. package/dist/generated/engineNoticeTables.js +156 -0
  42. package/dist/generated/toolNameTables.d.ts +2 -0
  43. package/dist/generated/toolNameTables.js +43 -0
  44. package/dist/headlessPermissionModeWire.js +2 -4
  45. package/dist/hitl/askGateWire.d.ts +1 -1
  46. package/dist/hitl/askGateWire.js +1 -1
  47. package/dist/hitl/frameRouter.js +4 -2
  48. package/dist/hitl/hitlHostSurface.js +1 -1
  49. package/dist/hitl/parkResolver.d.ts +1 -0
  50. package/dist/hitl/parkResolver.js +11 -3
  51. package/dist/hitl/planReviewWire.d.ts +41 -3
  52. package/dist/hitl/planReviewWire.js +264 -49
  53. package/dist/hitl/rosterMountedNames.d.ts +2 -0
  54. package/dist/hitl/rosterMountedNames.js +38 -0
  55. package/dist/hitl/sessionPolicyDeliverable.d.ts +8 -2
  56. package/dist/hitl/sessionPolicyDeliverable.js +39 -48
  57. package/dist/hitl/sessionPolicyWire.d.ts +40 -0
  58. package/dist/hitl/sessionPolicyWire.js +173 -0
  59. package/dist/hitl/toolApprovalWire.d.ts +4 -2
  60. package/dist/hitl/toolApprovalWire.js +6 -4
  61. package/dist/index.d.ts +10 -3
  62. package/dist/index.js +7 -2
  63. package/dist/liveInitToolFace.js +28 -10
  64. package/dist/permissionWireCaps.d.ts +2 -1
  65. package/dist/permissionWireCaps.js +2 -7
  66. package/dist/request/taskRequest.d.ts +2 -0
  67. package/dist/request/taskRequest.js +18 -1
  68. package/dist/resumeRefusalCopy.d.ts +11 -0
  69. package/dist/resumeRefusalCopy.js +42 -2
  70. package/dist/runTerminal.js +4 -0
  71. package/dist/sandboxWire.js +1 -1
  72. package/dist/sealedKeyCapability.d.ts +17 -0
  73. package/dist/sealedKeyCapability.js +42 -0
  74. package/dist/seatContract.d.ts +2 -1
  75. package/dist/seatContract.js +5 -21
  76. package/dist/subagent/engineCompactWire.d.ts +13 -2
  77. package/dist/subagent/engineCompactWire.js +48 -37
  78. package/dist/subagent/engineDelegatedPrompt.d.ts +9 -1
  79. package/dist/subagent/engineDelegatedPrompt.js +16 -16
  80. package/dist/subagent/engineRowStopGate.d.ts +2 -2
  81. package/dist/subagent/engineRowStopGate.js +4 -4
  82. package/dist/subagent/engineSubagentOutput.d.ts +8 -0
  83. package/dist/subagent/engineSubagentOutput.js +37 -18
  84. package/dist/subagent/engineSubagentResume.d.ts +11 -2
  85. package/dist/subagent/engineSubagentResume.js +28 -14
  86. package/dist/subagent/engineSubagentSteer.d.ts +10 -1
  87. package/dist/subagent/engineSubagentSteer.js +16 -11
  88. package/dist/subagent/engineSubagentTail.d.ts +10 -1
  89. package/dist/subagent/engineSubagentTail.js +35 -17
  90. package/dist/subagent/engineTaskHandleWire.d.ts +13 -0
  91. package/dist/subagent/engineTaskHandleWire.js +41 -20
  92. package/dist/systemReminderTag.d.ts +5 -0
  93. package/dist/systemReminderTag.js +20 -9
  94. package/dist/taskRequestWords.d.ts +5 -0
  95. package/dist/taskRequestWords.js +4 -0
  96. package/dist/wireErrorTriage.d.ts +1 -0
  97. package/dist/wireErrorTriage.js +3 -1
  98. package/dist/wireRefusalCopy.d.ts +10 -0
  99. package/dist/wireRefusalCopy.js +62 -2
  100. package/docs/INTEGRATION-CLIENTS.md +1128 -22
  101. package/package.json +1 -1
@@ -1,12 +1,12 @@
1
1
  import { publishQuestionFrame, publishQuestionFrameFor, registerLocalQuestionResponder, unregisterLocalQuestionResponder, hasLocalQuestionResponder, hasQuestionOverlayFor, } from '../liveQuestionStore.js';
2
2
  import { hostLog } from '../host.js';
3
- import { abortableSleep } from '../abortableSleep.js';
4
- import { makeEngineWireClient } from '../engineWireSdk.js';
3
+ import { abortableSleep, settleWithinDeadline } from '../abortableSleep.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';
8
8
  import { decideFailureNote } from '../decideFailureNote.js';
9
- import { isPlanReviewModeAfter } from './hitlBridge.js';
9
+ import { denyReasonForWire, isPlanReviewModeAfter } from './hitlBridge.js';
10
10
  import { engineCapString } from '../engineCapsCache.js';
11
11
  import { planReviewQuestionId, REOPEN_ID_TAIL, taskIdFromPlanReviewQuestionId } from './gateIdentity.js';
12
12
  import { notePlanReviewAnswered, notePlanReviewAnsweredFor, planReviewArmedKeyFor, registerArmedGateFor, waitForGateArmedFor, wasGateArmedFor, } from './armedGateRegistry.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() {
@@ -225,9 +237,21 @@ export function armPlanReviewApproval(result, sessionKey, opts) {
225
237
  '此处拒绝而不是回落默认槽 —— 回落会把这条会话的决断带着别的槽的 baseUrl/token/principal 发出去。');
226
238
  return false;
227
239
  }
228
- if (!engineWireTarget())
240
+ const wire = opts?.wire;
241
+ if (wire !== undefined) {
242
+ if (!usablePlanReviewWireClient(wire.client)) {
243
+ hostLog('error', 'planReviewWire: plan_review card NOT armed — the engine client supplied by the host is not usable (needs assistant.planReview and runs.get)');
244
+ return false;
245
+ }
246
+ }
247
+ else if (!engineWireTarget()) {
229
248
  return false;
249
+ }
250
+ const delivery = wire !== undefined || opts?.onOutcome !== undefined
251
+ ? { ...(wire !== undefined ? { wire } : {}), ...(opts?.onOutcome !== undefined ? { onOutcome: opts.onOutcome } : {}) }
252
+ : undefined;
230
253
  const { taskId } = result;
254
+ const gateBefore = pausedCheckpointIdOf(result);
231
255
  retireActiveReopen(DEFAULT_SESSION_KEY, planReviewQuestionId(taskId));
232
256
  if (armedPlanReviewTaskIds.has(taskId)) {
233
257
  if (hasLocalQuestionResponder(planReviewQuestionId(taskId))) {
@@ -240,8 +264,9 @@ export function armPlanReviewApproval(result, sessionKey, opts) {
240
264
  }
241
265
  armedPlanReviewTaskIds.add(taskId);
242
266
  armedTaskId = taskId;
267
+ const capsBaseUrl = wire !== undefined ? wire.capsBaseUrl : engineWireTarget()?.baseUrl;
243
268
  const offerModeAfter = opts?.submittedInPlanMode === true &&
244
- versionSupportsPlanReviewModeAfter(engineCapString(engineWireTarget()?.baseUrl, 'version')) === 'supported';
269
+ versionSupportsPlanReviewModeAfter(engineCapString(capsBaseUrl, 'version')) === 'supported';
245
270
  if (offerModeAfter)
246
271
  offerModeAfterByTask.set(taskId, true);
247
272
  else
@@ -262,7 +287,7 @@ export function armPlanReviewApproval(result, sessionKey, opts) {
262
287
  offerModeAfterByTask.delete(taskId);
263
288
  notePlanReviewAnswered(questionId);
264
289
  hostLog('debug', `planReviewWire: arm responder delivering ${decided} for task ${taskId} (path=arm)`);
265
- void decidePlanReview(taskId, decided, modeAfter);
290
+ void decidePlanReviewFor(taskId, decided, modeAfter, delivery, gateBefore);
266
291
  return { ok: true };
267
292
  });
268
293
  publishPlanReviewCard(taskId);
@@ -296,73 +321,211 @@ export function planReviewOutcomeTail(taskId, decision, effect) {
296
321
  return `Do not state the result as fact: tell the user the ${decision} was submitted but its effect could not be confirmed; TaskOutput("${taskId}") shows where the task stands.`;
297
322
  }
298
323
  }
299
- const decideInFlight = new Map();
324
+ function planReviewDecisionTimedOutOutcome(decision) {
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 — ` +
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'}).`);
327
+ }
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.`);
335
+ }
336
+ function usablePlanReviewWireClient(client) {
337
+ if (client === null || typeof client !== 'object')
338
+ return false;
339
+ return clientHasVerbs(client, [['assistant', 'planReview'], ['runs', 'get']]);
340
+ }
341
+ const PLAN_REVIEW_INJECTED_REPULL_TIMEOUT_MS = 15_000;
342
+ const planReviewDecisionOps = [];
343
+ const MAX_PLAN_REVIEW_DECISION_OPS = 32;
344
+ let planReviewLedgerClock = 0;
300
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
+ }
301
426
  export function __resetPlanReviewDecisionLatchForTests() {
302
- decideInFlight.clear();
427
+ planReviewDecisionOps.length = 0;
303
428
  }
304
- export async function decidePlanReview(taskId, decision, permissionModeAfter) {
305
- if (decideInFlight.has(taskId)) {
429
+ export async function decidePlanReview(taskId, decision, permissionModeAfter, opts) {
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')) {
306
434
  hostLog('error', `planReviewWire: decidePlanReview(${decision}) for task ${taskId} REFUSED — a decision for this task is already in flight (duplicate delivery latched)`);
307
435
  return;
308
436
  }
309
- const token = {};
310
- 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;
311
447
  const releaseLatch = () => {
312
- if (decideInFlight.get(taskId) === token)
313
- 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);
314
455
  };
315
456
  const dispatchNo = ++decideDispatchSeq;
316
457
  hostLog('debug', `planReviewWire: decidePlanReview dispatch #${dispatchNo} task=${taskId} decision=${decision} mode=${permissionModeAfter ?? '-'}`);
317
458
  try {
318
- await decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch);
459
+ await decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch, settle, opts, gateBefore);
319
460
  }
320
461
  finally {
321
462
  releaseLatch();
463
+ settlePlanReviewDecisionOp(mine);
464
+ mine.ownCallOpen = false;
322
465
  }
323
466
  }
324
- async function decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch) {
467
+ function planReviewClientsFor(resolved) {
468
+ if (resolved.kind === 'injected') {
469
+ const client = resolved.client;
470
+ return { decide: client, repull: () => client };
471
+ }
472
+ const decide = resolved.make({ timeoutMs: PLAN_REVIEW_RESUME_TIMEOUT_MS });
473
+ if (!decide)
474
+ return null;
475
+ return {
476
+ decide,
477
+ repull: () => resolved.make({ timeoutMs: 15_000 }),
478
+ };
479
+ }
480
+ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, dispatchNo, releaseLatch, settle, opts, gateBefore) {
325
481
  let outcome;
326
482
  let effect = 'unconfirmed';
327
- const cfg = engineWireTarget();
483
+ let stillParkedProven = false;
484
+ let statusSeen;
485
+ const wire = opts?.wire;
486
+ const resolved = engineWireFor(wire, DEFAULT_SESSION_KEY, usablePlanReviewWireClient);
328
487
  const badMode = permissionModeAfter !== undefined && (decision !== 'approve' || !isPlanReviewModeAfter(permissionModeAfter));
329
488
  if (badMode) {
330
489
  hostLog('error', `planReviewWire: ${decision} NOT sent — permissionModeAfter is only valid with approve and must be "default" or "acceptEdits"`);
331
490
  effect = 'not_sent';
332
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.`;
333
492
  }
334
- else if (!cfg) {
493
+ else if (resolved.kind === 'no-target') {
335
494
  hostLog('error', `planReviewWire: ${decision} NOT sent — engineWireTarget() unavailable`);
336
495
  effect = 'not_sent';
337
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.`;
338
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
+ }
339
503
  else {
340
- const client = makeEngineWireClient({
341
- baseUrl: cfg.baseUrl,
342
- ...(cfg.token ? { token: cfg.token } : {}),
343
- principal: cfg.principal,
344
- timeoutMs: PLAN_REVIEW_RESUME_TIMEOUT_MS,
345
- });
346
- if (!client) {
504
+ const clients = planReviewClientsFor(resolved);
505
+ if (!clients) {
347
506
  hostLog('error', `planReviewWire: ${decision} NOT sent — makeEngineWireClient() returned null`);
348
- effect = 'not_sent';
349
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.`;
508
+ effect = 'not_sent';
350
509
  }
351
510
  else {
511
+ const client = clients.decide;
352
512
  try {
353
513
  const mode = isPlanReviewModeAfter(permissionModeAfter) ? permissionModeAfter : undefined;
514
+ const reason = denyReasonForWire(opts?.reason, `plan-review ${taskId}`);
354
515
  const reqBody = { decision };
516
+ if (reason !== undefined)
517
+ reqBody.reason = reason;
355
518
  if (mode !== undefined)
356
519
  reqBody.permissionModeAfter = mode;
357
520
  let body;
358
521
  try {
359
- body = await client.assistant.planReview(taskId, reqBody);
522
+ body = await sendOnce(undefined, () => client.assistant.planReview(taskId, reqBody));
360
523
  }
361
524
  catch (e1) {
362
525
  const conflict = e1;
363
526
  if (mode === 'default' && conflict?.status === 400 && conflict?.errorCode === 'request.field_conflict') {
364
527
  hostLog('debug', 'planReviewWire: permissionModeAfter "default" refused as not applicable — re-sending the approval without the key (same meaning)');
365
- body = await client.assistant.planReview(taskId, { decision });
528
+ body = await sendOnce(undefined, () => client.assistant.planReview(taskId, reason !== undefined ? { decision, reason } : { decision }));
366
529
  }
367
530
  else {
368
531
  throw e1;
@@ -373,22 +536,39 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
373
536
  }
374
537
  hostLog('debug', `planReviewWire: ${decision} → ok (dispatch #${dispatchNo}) ${JSON.stringify(body).slice(0, 200)}`);
375
538
  let postStatus;
539
+ let gateAfter;
376
540
  try {
377
- const probe = makeEngineWireClient({
378
- baseUrl: cfg.baseUrl,
379
- ...(cfg.token ? { token: cfg.token } : {}),
380
- principal: cfg.principal,
381
- timeoutMs: 15_000,
382
- });
383
- const post = probe ? (await probe.runs.get(taskId)) : null;
384
- postStatus = typeof post?.status === 'string' ? post.status : undefined;
541
+ const probe = clients.repull();
542
+ if (probe) {
543
+ let post;
544
+ if (wire !== undefined) {
545
+ const got = await settleWithinDeadline(PLAN_REVIEW_INJECTED_REPULL_TIMEOUT_MS, (signal) => probe.runs.get(taskId, { signal }));
546
+ if (got.kind === 'error')
547
+ throw new Error(got.detail);
548
+ if (got.kind === 'deadline')
549
+ throw new Error(`post-decide status re-pull passed its ${PLAN_REVIEW_INJECTED_REPULL_TIMEOUT_MS} ms deadline`);
550
+ post = got.value;
551
+ }
552
+ else {
553
+ post = (await probe.runs.get(taskId));
554
+ }
555
+ postStatus = typeof post?.status === 'string' ? post.status : undefined;
556
+ gateAfter = pausedCheckpointIdOf(post?.result);
557
+ }
385
558
  }
386
559
  catch (e) {
387
560
  hostLog('debug', `planReviewWire: post-decide status re-pull failed (fail-soft): ${String(e)}`);
388
561
  }
389
562
  const effective = postStatus ?? (typeof body?.status === 'string' ? body.status : undefined);
390
- if (effective === 'needs_review') {
563
+ statusSeen = effective;
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') {
391
570
  effect = 'still_parked';
571
+ stillParkedProven = postStatus === 'needs_review' && gateBefore !== undefined && gateAfter !== undefined && gateAfter === gateBefore;
392
572
  outcome =
393
573
  `The plan_review ${decision} returned HTTP 200 but the session is STILL locked on the same review gate ` +
394
574
  `(post-decide status: needs_review). The decision did NOT take effect (known engine issue, RB-471 family). ` +
@@ -396,9 +576,7 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
396
576
  }
397
577
  else if (effective === 'suspended') {
398
578
  effect = 'advanced';
399
- outcome =
400
- `The plan was ${decision === 'approve' ? 'approved' : 'rejected'} and the task advanced to a NEW approval gate ` +
401
- `(post-decide status: suspended) — the next approval card will surface it; this is not a completion yet.`;
579
+ outcome = planReviewAdvancedOutcome(decision, 'suspended');
402
580
  }
403
581
  else if (effective !== undefined) {
404
582
  effect = 'took_effect';
@@ -411,9 +589,11 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
411
589
  catch (e) {
412
590
  const status = e?.status;
413
591
  const refusedCode = e?.errorCode;
592
+ const noAnswer = typeof status === 'number' || typeof e !== 'object' ? null : noAnswerKindOf(e);
414
593
  if (status === 400 && refusedCode === 'request.field_conflict' && permissionModeAfter === 'acceptEdits') {
415
594
  hostLog('debug', 'planReviewWire: approve + acceptEdits refused (request.field_conflict) — NOT re-sent with a different mode');
416
595
  effect = 'still_parked';
596
+ stillParkedProven = true;
417
597
  outcome =
418
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). ' +
419
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.';
@@ -437,21 +617,46 @@ async function decidePlanReviewInner(taskId, decision, permissionModeAfter, disp
437
617
  effect = status >= 400 && status < 500 ? 'not_applied' : 'unconfirmed';
438
618
  outcome = `${`The plan_review decision failed: HTTP ${status} ${msg}`.trim()}${decideFailureNote(e)}`;
439
619
  }
620
+ else if (noAnswer === 'time-limit' || noAnswer === 'deadline') {
621
+ hostLog('debug', `planReviewWire: ${decision} sent but no answer within the request time limit (task ${taskId})`);
622
+ outcome = planReviewDecisionTimedOutOutcome(decision);
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
+ }
440
628
  else {
441
629
  outcome = `The plan_review decision could not reach the engine: ${String(e)}`;
442
630
  }
443
631
  }
444
632
  }
445
633
  }
634
+ const tail = planReviewOutcomeTail(taskId, decision, effect);
635
+ const prompt = `<plan-review-outcome>\n${outcome}\n${tail}\n</plan-review-outcome>`;
636
+ const onOutcome = opts?.onOutcome;
637
+ settle(effect === 'not_sent' || (effect === 'still_parked' && stillParkedProven));
446
638
  try {
447
- const delivered = enqueuePlanReviewOutcome(`<plan-review-outcome>\n${outcome}\n${planReviewOutcomeTail(taskId, decision, effect)}\n</plan-review-outcome>`, { taskId, dispatchNo, decision, effect });
639
+ const delivered = enqueuePlanReviewOutcome(prompt, { taskId, dispatchNo, decision, effect });
448
640
  if (!delivered) {
449
- hostLog('error', `planReviewWire: outcome enqueue MISSED (queue port not installed or lacks enqueueMetaPrompt) for task ${taskId} — model will not learn the ${decision} result`);
641
+ if (onOutcome !== undefined) {
642
+ hostLog('debug', `planReviewWire: outcome queue port not installed for task ${taskId} — outcome handed to the host's onOutcome callback only`);
643
+ }
644
+ else {
645
+ hostLog('error', `planReviewWire: outcome enqueue MISSED (queue port not installed or lacks enqueueMetaPrompt) for task ${taskId} — model will not learn the ${decision} result`);
646
+ }
450
647
  }
451
648
  }
452
649
  catch (e) {
453
650
  hostLog('debug', `planReviewWire: outcome enqueue threw: ${String(e)}`);
454
651
  }
652
+ if (onOutcome !== undefined) {
653
+ try {
654
+ onOutcome({ taskId, dispatchNo, decision, effect, detail: outcome, tail, prompt, ...(statusSeen !== undefined ? { status: statusSeen } : {}) });
655
+ }
656
+ catch (e) {
657
+ hostLog('debug', `planReviewWire: onOutcome callback threw for task ${taskId} (ignored): ${String(e)}`);
658
+ }
659
+ }
455
660
  }
456
661
  function reopenIdSuffix() {
457
662
  return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
@@ -545,6 +750,9 @@ function mintPlanReviewReopenCard(taskId, opts, sessionKey, receiptMs) {
545
750
  if (opts?.deliverDecision !== undefined) {
546
751
  hostLog('debug', `planReviewWire: reopen canonical-reuse branch for task ${taskId} ignores opts.deliverDecision (answers route through the existing arm responder — documented exception)`);
547
752
  }
753
+ if (opts?.wire !== undefined || opts?.onOutcome !== undefined) {
754
+ hostLog('debug', `planReviewWire: reopen canonical-reuse branch for task ${taskId} ignores opts.wire / opts.onOutcome (answers route through the existing arm responder and the wire it was armed with — documented exception)`);
755
+ }
548
756
  retireActiveReopen(sessionKey, canonicalId);
549
757
  if (receiptMs !== undefined) {
550
758
  const receipt = waitForGateArmedFor(sessionKey, [canonicalId], receiptMs);
@@ -571,10 +779,13 @@ function mintPlanReviewReopenCard(taskId, opts, sessionKey, receiptMs) {
571
779
  publishQuestionFrameFor(sessionKey, { type: 'question_complete', questionId: canonicalId });
572
780
  const questionId = `${canonicalId}${REOPEN_ID_TAIL}${reopenIdSuffix()}`;
573
781
  hostLog('debug', `planReviewWire: reopen for task ${taskId} minting a fresh card ${questionId} (path=reopen-fresh)`);
782
+ const delivery = opts?.wire !== undefined || opts?.onOutcome !== undefined
783
+ ? { ...(opts.wire !== undefined ? { wire: opts.wire } : {}), ...(opts.onOutcome !== undefined ? { onOutcome: opts.onOutcome } : {}) }
784
+ : undefined;
574
785
  const deliver = opts?.deliverDecision ??
575
786
  ((tid, decision) => {
576
787
  hostLog('debug', `planReviewWire: reopen responder delivering ${decision} for task ${tid} (path=reopen-default)`);
577
- void decidePlanReview(tid, decision);
788
+ void (delivery === undefined ? decidePlanReview(tid, decision) : decidePlanReview(tid, decision, undefined, delivery));
578
789
  });
579
790
  const activeKey = `${sessionKey}\u0000${canonicalId}`;
580
791
  const unregister = registerLocalQuestionResponder(questionId, async (_id, answer) => {
@@ -616,7 +827,7 @@ function mintPlanReviewReopenCard(taskId, opts, sessionKey, receiptMs) {
616
827
  revokePlanReviewUserDismissal(sessionKey, taskId, markBefore);
617
828
  return { reopened: true, firstSight };
618
829
  }
619
- async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark) {
830
+ async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark, recheckReadStart) {
620
831
  const refusedByUser = { reopened: false, dismissedByUser: true };
621
832
  try {
622
833
  let fresh = false;
@@ -633,7 +844,8 @@ async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, ma
633
844
  }
634
845
  }
635
846
  let handedOver = false;
636
- if (!decideInFlight.has(taskId)) {
847
+ const ledgerSaysInFlight = planReviewDecisionInFlight(taskId, recheckReadStart);
848
+ if (!ledgerSaysInFlight) {
637
849
  try {
638
850
  handedOver = opts.decisionHandedOver?.(taskId) === true;
639
851
  }
@@ -642,7 +854,7 @@ async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, ma
642
854
  return { reopened: false };
643
855
  }
644
856
  }
645
- if (decideInFlight.has(taskId) || handedOver) {
857
+ if (ledgerSaysInFlight || handedOver) {
646
858
  hostLog('debug', `planReviewWire: reopen for task ${taskId} refused after the dismissal check — a decision for this task went out while the check was reading`);
647
859
  return { reopened: false, _sema_decisionInFlight: true };
648
860
  }
@@ -715,16 +927,19 @@ export function reopenPlanReviewCard(taskId, opts) {
715
927
  if (typeof taskId !== 'string' || taskId.length === 0) {
716
928
  return receiptMs === undefined ? { reopened: false } : Promise.resolve({ reopened: false });
717
929
  }
718
- if (decideInFlight.has(taskId)) {
930
+ const readStart = typeof opts?.pendingReadStartedAt === 'number' && Number.isFinite(opts.pendingReadStartedAt) ? opts.pendingReadStartedAt : undefined;
931
+ if (planReviewDecisionInFlight(taskId, readStart)) {
719
932
  hostLog('debug', `planReviewWire: reopen for task ${taskId} refused — a decision for this task is in flight (its answer is still being delivered)`);
720
933
  const refused = { reopened: false, _sema_decisionInFlight: true };
721
934
  return receiptMs === undefined ? refused : Promise.resolve(refused);
722
935
  }
936
+ const guardStartedAt = planReviewPendingReadMark();
937
+ const recheckReadStart = readStart === undefined ? guardStartedAt : Math.min(readStart, guardStartedAt);
723
938
  const sessionKey = opts?.sessionKey ?? DEFAULT_SESSION_KEY;
724
939
  if (!hasQuestionOverlayFor(sessionKey)) {
725
940
  return receiptMs === undefined ? { reopened: false } : Promise.resolve({ reopened: false });
726
941
  }
727
- if (sessionKey !== DEFAULT_SESSION_KEY && opts?.deliverDecision === undefined) {
942
+ if (sessionKey !== DEFAULT_SESSION_KEY && opts?.deliverDecision === undefined && opts?.wire === undefined) {
728
943
  hostLog('error', `planReviewWire: plan_review 卡未重开 —— sessionKey=${JSON.stringify(sessionKey)} 是非默认槽,` +
729
944
  '而缺省决断投递口(decidePlanReview)今天固定打默认槽的 baseUrl/token/principal。' +
730
945
  '整条链转 keyed 之前,非默认槽要重开请自带 deliverDecision(按本槽投递);' +
@@ -739,7 +954,7 @@ export function reopenPlanReviewCard(taskId, opts) {
739
954
  hostLog('debug', `planReviewWire: automatic reopen for task ${taskId} refused — the user closed this gate's card`);
740
955
  return { reopened: false, dismissedByUser: true };
741
956
  }
742
- return reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark);
957
+ return reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark, recheckReadStart);
743
958
  }
744
959
  }
745
960
  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,5 +12,10 @@ export interface SessionPolicyDeliverability {
11
12
  readonly withheld: readonly SessionPolicyWithheldRule[];
12
13
  readonly unreadable?: true;
13
14
  }
14
- export declare function sessionPolicyDeliverable(behavior: 'deny' | 'ask', rules: readonly string[]): SessionPolicyDeliverability;
15
+ export interface SessionPolicyDeliverableOptions {
16
+ readonly roster?: ToolRosterView;
17
+ }
18
+ export declare function isEngineRefusedToolName(name: string): boolean;
19
+ export declare function sessionPolicyDeliverable(behavior: 'deny' | 'ask', rules: readonly string[], opts?: SessionPolicyDeliverableOptions): SessionPolicyDeliverability;
15
20
  export declare function sessionPolicyWithheldNotice(why: SessionPolicyWithheldWhy): string;
21
+ export declare function legacyToolNameFailureNoteOf(code: string | undefined): string | undefined;