@sema-agent/client-core 0.83.2 → 0.83.4

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.
@@ -16,6 +16,7 @@ export interface RulesFacade {
16
16
  signal?: AbortSignal;
17
17
  }): Promise<CcImportRedeemResult>;
18
18
  }
19
+ export type RulesListFacade = Pick<RulesFacade, 'list'>;
19
20
  export declare function persistedRulesLaneAvailable(baseUrl: string | undefined): boolean;
20
21
  export declare function persistedRulesGovernanceAvailable(baseUrl: string | undefined): boolean;
21
22
  export declare function persistedRulesWriteAvailable(baseUrl: string | undefined): boolean;
@@ -90,7 +91,7 @@ export type ListAllPersistedRulesOutcome = {
90
91
  ok: false;
91
92
  failure: RulesFailure;
92
93
  };
93
- export declare function listAllPersistedRules(facade: RulesFacade, params?: Omit<RuleListParams, 'cursor'>, opts?: {
94
+ export declare function listAllPersistedRules(facade: RulesListFacade & Partial<Omit<RulesFacade, 'list'>>, params?: Omit<RuleListParams, 'cursor'>, opts?: {
94
95
  signal?: AbortSignal;
95
96
  }): Promise<ListAllPersistedRulesOutcome>;
96
97
  declare const DIRECT_WRITE_EXCLUDED_BEHAVIOR = "allow";
@@ -25,8 +25,12 @@ export declare function isPlanReviewPark(result: unknown): result is {
25
25
  sessionId?: string;
26
26
  };
27
27
  export declare function _resetArmedPlanReviewsForTest(): void;
28
+ export type PlanReviewReopenTrigger = 'user' | 'automatic';
29
+ export type PlanReviewGateInstanceReader = (taskId: string) => Promise<ReadonlySet<string> | null>;
30
+ export declare function clearPlanReviewUserDismissals(sessionKey?: string): void;
28
31
  export declare function armPlanReviewApproval(result: unknown, sessionKey?: string, opts?: {
29
32
  submittedInPlanMode?: boolean;
33
+ readGateInstanceKeys?: PlanReviewGateInstanceReader;
30
34
  }): boolean;
31
35
  export type PlanReviewDecisionEffect = 'took_effect' | 'advanced' | 'still_parked' | 'not_sent' | 'not_applied' | 'unconfirmed';
32
36
  export interface PlanReviewOutcomeMeta {
@@ -43,6 +47,9 @@ export interface ReopenPlanReviewOpts {
43
47
  deliverDecision?: (taskId: string, decision: 'approve' | 'reject') => void | Promise<void>;
44
48
  sessionKey?: string;
45
49
  presentationReceiptMs?: number;
50
+ trigger?: PlanReviewReopenTrigger;
51
+ readGateInstanceKeys?: PlanReviewGateInstanceReader;
52
+ decisionHandedOver?: (taskId: string) => boolean;
46
53
  }
47
54
  export declare function _resetActiveReopenRespondersForTest(): void;
48
55
  export declare function reopenPlanReviewCard(taskId: string, opts: ReopenPlanReviewOpts & {
@@ -1,5 +1,6 @@
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';
3
4
  import { makeEngineWireClient } from '../engineWireSdk.js';
4
5
  import { engineWireTarget } from '../engineWireTarget.js';
5
6
  import { enqueuePlanReviewOutcome } from '../notifications.js';
@@ -7,9 +8,9 @@ import { corruptStoredRowContent, corruptStoredRowWhere } from '../wireErrorTria
7
8
  import { decideFailureNote } from '../decideFailureNote.js';
8
9
  import { isPlanReviewModeAfter } from './hitlBridge.js';
9
10
  import { engineCapString } from '../engineCapsCache.js';
10
- import { planReviewQuestionId, REOPEN_ID_TAIL } from './gateIdentity.js';
11
+ import { planReviewQuestionId, REOPEN_ID_TAIL, taskIdFromPlanReviewQuestionId } from './gateIdentity.js';
11
12
  import { notePlanReviewAnswered, notePlanReviewAnsweredFor, planReviewArmedKeyFor, registerArmedGateFor, waitForGateArmedFor, wasGateArmedFor, } from './armedGateRegistry.js';
12
- import { DEFAULT_SESSION_KEY } from '../sessionSlot.js';
13
+ import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
13
14
  import { PLAN_REVIEW_GATE_KIND } from '../adapter/activeRunSelfHeal.js';
14
15
  import { readRunTerminal, runTerminalGateKind } from '../runTerminal.js';
15
16
  const PLAN_REVIEW_RESUME_TIMEOUT_MS = 6 * 60 * 60_000;
@@ -84,6 +85,13 @@ function notePlanReviewAnsweredIfDecisiveFor(sessionKey, questionId, answer) {
84
85
  if (planReviewDecisionFromAnswer(answer ?? { answers: [] }, ALL_KNOWN_LABELS) === 'dismissed')
85
86
  return;
86
87
  notePlanReviewAnsweredFor(sessionKey, questionId);
88
+ const taskId = typeof questionId === 'string' ? taskIdFromPlanReviewQuestionId(questionId) : undefined;
89
+ if (taskId === undefined)
90
+ return;
91
+ const current = dismissalEntryFor(sessionKey, taskId);
92
+ if (current?.kind === 'dismissed' && current.questionId === questionId)
93
+ return;
94
+ notePlanReviewDecisiveAnswerFor(sessionKey, taskId);
87
95
  }
88
96
  export function isPlanReviewPark(result) {
89
97
  const read = readRunTerminal(result);
@@ -99,6 +107,93 @@ const offerModeAfterByTask = new Map();
99
107
  export function _resetArmedPlanReviewsForTest() {
100
108
  armedPlanReviewTaskIds.clear();
101
109
  offerModeAfterByTask.clear();
110
+ planReviewDismissalsByKey.clear();
111
+ }
112
+ const ON_SCREEN = Promise.resolve(true);
113
+ const planReviewDismissalsByKey = createSessionSlot();
114
+ const MAX_DISMISSAL_ENTRIES_PER_SESSION = 32;
115
+ const GATE_INSTANCE_READ_WAIT_MS = 1_500;
116
+ function dismissalEntryFor(sessionKey, taskId) {
117
+ return planReviewDismissalsByKey.get(sessionKey)?.get(taskId);
118
+ }
119
+ function putDismissalEntry(sessionKey, taskId, entry) {
120
+ let ledger = planReviewDismissalsByKey.get(sessionKey);
121
+ if (ledger === undefined) {
122
+ ledger = new Map();
123
+ planReviewDismissalsByKey.set(sessionKey, ledger);
124
+ }
125
+ ledger.delete(taskId);
126
+ ledger.set(taskId, entry);
127
+ while (ledger.size > MAX_DISMISSAL_ENTRIES_PER_SESSION) {
128
+ const oldest = ledger.keys().next();
129
+ if (oldest.done === true)
130
+ break;
131
+ ledger.delete(oldest.value);
132
+ }
133
+ }
134
+ function planReviewUserDismissalMarkFor(sessionKey, taskId) {
135
+ const entry = dismissalEntryFor(sessionKey, taskId);
136
+ return entry?.kind === 'dismissed' ? entry : undefined;
137
+ }
138
+ function startGateInstanceRead(reader, taskId) {
139
+ if (typeof reader !== 'function')
140
+ return null;
141
+ let raw;
142
+ try {
143
+ raw = reader(taskId);
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ return Promise.resolve(raw).then((v) => {
149
+ try {
150
+ return v instanceof Set ? new Set(v) : null;
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }, () => null);
156
+ }
157
+ async function boundedGateInstanceRead(p) {
158
+ if (p === null)
159
+ return null;
160
+ const ac = new AbortController();
161
+ try {
162
+ return await Promise.race([
163
+ p.then((v) => v, () => null),
164
+ abortableSleep(GATE_INSTANCE_READ_WAIT_MS, ac.signal).then(() => null),
165
+ ]);
166
+ }
167
+ finally {
168
+ ac.abort();
169
+ }
170
+ }
171
+ function notePlanReviewUserDismissalFor(sessionKey, taskId, reader, questionId) {
172
+ try {
173
+ putDismissalEntry(sessionKey, taskId, { kind: 'dismissed', closedGates: startGateInstanceRead(reader, taskId), questionId });
174
+ }
175
+ catch (err) {
176
+ hostLog('debug', `planReviewWire: could not record that the user closed the plan card for task ${taskId}: ${String(err)}`);
177
+ }
178
+ }
179
+ function notePlanReviewDecisiveAnswerFor(sessionKey, taskId) {
180
+ if (dismissalEntryFor(sessionKey, taskId) === undefined)
181
+ return;
182
+ putDismissalEntry(sessionKey, taskId, { kind: 'decided', closedGates: null });
183
+ }
184
+ function revokePlanReviewUserDismissal(sessionKey, taskId, mark) {
185
+ if (mark === undefined || mark.kind !== 'dismissed')
186
+ return;
187
+ if (dismissalEntryFor(sessionKey, taskId) === mark)
188
+ putDismissalEntry(sessionKey, taskId, { kind: 'reopened', closedGates: null, onScreen: ON_SCREEN });
189
+ }
190
+ function dropDismissalEntry(sessionKey, taskId, entry) {
191
+ const ledger = planReviewDismissalsByKey.get(sessionKey);
192
+ if (ledger?.get(taskId) === entry)
193
+ ledger.delete(taskId);
194
+ }
195
+ export function clearPlanReviewUserDismissals(sessionKey = DEFAULT_SESSION_KEY) {
196
+ planReviewDismissalsByKey.set(sessionKey, undefined);
102
197
  }
103
198
  const ARM_QUESTION = 'The plan is ready for review. Approve it and start the implementation?';
104
199
  const REOPEN_QUESTION = 'This plan is still waiting for your review — it is what is holding this session. Approve it and start the implementation?';
@@ -158,9 +253,11 @@ export function armPlanReviewApproval(result, sessionKey, opts) {
158
253
  const shown = planReviewCardOptions(offerModeAfterByTask.get(taskId) === true).map(o => o.label);
159
254
  const choice = planReviewChoiceFromAnswer(answer, shown);
160
255
  if (choice === 'dismissed') {
256
+ notePlanReviewUserDismissalFor(DEFAULT_SESSION_KEY, taskId, opts?.readGateInstanceKeys, questionId);
161
257
  return { ok: true };
162
258
  }
163
259
  const decided = choice.decision;
260
+ notePlanReviewDecisiveAnswerFor(DEFAULT_SESSION_KEY, taskId);
164
261
  const modeAfter = choice.decision === 'approve' ? choice.permissionModeAfter : undefined;
165
262
  offerModeAfterByTask.delete(taskId);
166
263
  notePlanReviewAnswered(questionId);
@@ -375,10 +472,245 @@ function retireActiveReopen(sessionKey, canonicalId) {
375
472
  }
376
473
  export function _resetActiveReopenRespondersForTest() {
377
474
  activeReopenResponders.clear();
475
+ reopenFlights.clear();
476
+ }
477
+ const reopenFlights = new Map();
478
+ function joinReopenFlight(flight, taskId, receiptMs) {
479
+ hostLog('debug', `planReviewWire: reopen for task ${taskId} joined the card already on its way (no second card, its answer path kept)`);
480
+ if (receiptMs === undefined)
481
+ return { reopened: true, firstSight: false };
482
+ const ac = new AbortController();
483
+ return Promise.race([
484
+ flight.presented.then((v) => v === true, () => false),
485
+ abortableSleep(Math.max(0, receiptMs), ac.signal).then(() => false),
486
+ ]).then((presented) => {
487
+ ac.abort();
488
+ return presented ? { reopened: true, firstSight: false } : { reopened: false };
489
+ }, () => {
490
+ ac.abort();
491
+ return { reopened: false };
492
+ });
493
+ }
494
+ function mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs) {
495
+ const flightKey = `${sessionKey}\u0000${taskId}`;
496
+ const inFlight = reopenFlights.get(flightKey);
497
+ if (inFlight !== undefined)
498
+ return joinReopenFlight(inFlight, taskId, receiptMs);
499
+ let land = () => { };
500
+ const flight = {
501
+ presented: new Promise((resolve) => {
502
+ land = resolve;
503
+ }),
504
+ };
505
+ reopenFlights.set(flightKey, flight);
506
+ const settle = (presented) => {
507
+ land(presented);
508
+ if (reopenFlights.get(flightKey) === flight)
509
+ reopenFlights.delete(flightKey);
510
+ };
511
+ let verdict;
512
+ try {
513
+ verdict = mintPlanReviewReopenCard(taskId, opts, sessionKey, receiptMs);
514
+ }
515
+ catch (e) {
516
+ settle(false);
517
+ throw e;
518
+ }
519
+ if (verdict instanceof Promise) {
520
+ return verdict.then((v) => {
521
+ settle(v.reopened === true);
522
+ return v;
523
+ }, (e) => {
524
+ settle(false);
525
+ throw e;
526
+ });
527
+ }
528
+ settle(verdict.reopened === true);
529
+ return verdict;
530
+ }
531
+ function mintPlanReviewReopenCard(taskId, opts, sessionKey, receiptMs) {
532
+ const markBefore = dismissalEntryFor(sessionKey, taskId);
533
+ const settleOnReceipt = (receipt, firstSight) => receipt.then((presented) => {
534
+ if (!presented)
535
+ return { reopened: false };
536
+ revokePlanReviewUserDismissal(sessionKey, taskId, markBefore);
537
+ return { reopened: true, firstSight };
538
+ }, () => ({ reopened: false }));
539
+ const canonicalId = planReviewQuestionId(taskId);
540
+ const armedKey = planReviewArmedKeyFor(sessionKey, taskId);
541
+ const firstSight = !wasGateArmedFor(sessionKey, armedKey);
542
+ const mintFresh = opts?.mintFreshQuestionId !== false;
543
+ if (!mintFresh && hasLocalQuestionResponder(canonicalId)) {
544
+ hostLog('debug', `planReviewWire: reopen for task ${taskId} reusing the canonical responder (path=reopen-canonical)`);
545
+ if (opts?.deliverDecision !== undefined) {
546
+ hostLog('debug', `planReviewWire: reopen canonical-reuse branch for task ${taskId} ignores opts.deliverDecision (answers route through the existing arm responder — documented exception)`);
547
+ }
548
+ retireActiveReopen(sessionKey, canonicalId);
549
+ if (receiptMs !== undefined) {
550
+ const receipt = waitForGateArmedFor(sessionKey, [canonicalId], receiptMs);
551
+ publishQuestionFrameFor(sessionKey, {
552
+ type: 'question',
553
+ questionId: canonicalId,
554
+ questions: planReviewQuestions(ARM_QUESTION, offerModeAfterByTask.get(taskId) === true),
555
+ });
556
+ return settleOnReceipt(receipt, firstSight);
557
+ }
558
+ publishQuestionFrameFor(sessionKey, {
559
+ type: 'question',
560
+ questionId: canonicalId,
561
+ questions: planReviewQuestions(ARM_QUESTION, offerModeAfterByTask.get(taskId) === true),
562
+ });
563
+ registerArmedGateFor(sessionKey, armedKey);
564
+ revokePlanReviewUserDismissal(sessionKey, taskId, markBefore);
565
+ return { reopened: true, firstSight };
566
+ }
567
+ retireActiveReopen(sessionKey, canonicalId);
568
+ if (unregisterLocalQuestionResponder(canonicalId)) {
569
+ hostLog('debug', `planReviewWire: reopen retired the canonical responder for task ${taskId} (single-active discipline)`);
570
+ }
571
+ publishQuestionFrameFor(sessionKey, { type: 'question_complete', questionId: canonicalId });
572
+ const questionId = `${canonicalId}${REOPEN_ID_TAIL}${reopenIdSuffix()}`;
573
+ hostLog('debug', `planReviewWire: reopen for task ${taskId} minting a fresh card ${questionId} (path=reopen-fresh)`);
574
+ const deliver = opts?.deliverDecision ??
575
+ ((tid, decision) => {
576
+ hostLog('debug', `planReviewWire: reopen responder delivering ${decision} for task ${tid} (path=reopen-default)`);
577
+ void decidePlanReview(tid, decision);
578
+ });
579
+ const activeKey = `${sessionKey}\u0000${canonicalId}`;
580
+ const unregister = registerLocalQuestionResponder(questionId, async (_id, answer) => {
581
+ unregister();
582
+ if (activeReopenResponders.get(activeKey)?.questionId === questionId) {
583
+ activeReopenResponders.delete(activeKey);
584
+ }
585
+ const decided = planReviewDecisionFromAnswer(answer, LEGACY_TWO_CHOICE_LABELS);
586
+ if (decided === 'dismissed') {
587
+ notePlanReviewUserDismissalFor(sessionKey, taskId, opts?.readGateInstanceKeys, questionId);
588
+ return { ok: true };
589
+ }
590
+ notePlanReviewDecisiveAnswerFor(sessionKey, taskId);
591
+ notePlanReviewAnsweredFor(sessionKey, questionId);
592
+ try {
593
+ await deliver(taskId, decided);
594
+ }
595
+ catch (e) {
596
+ hostLog('error', `planReviewWire: reopen decision delivery threw for task ${taskId}: ${String(e)}`);
597
+ }
598
+ return { ok: true };
599
+ });
600
+ activeReopenResponders.set(activeKey, { questionId, unregister });
601
+ if (receiptMs !== undefined) {
602
+ const receipt = waitForGateArmedFor(sessionKey, [questionId], receiptMs);
603
+ publishQuestionFrameFor(sessionKey, {
604
+ type: 'question',
605
+ questionId,
606
+ questions: planReviewQuestions(REOPEN_QUESTION),
607
+ });
608
+ return settleOnReceipt(receipt, firstSight);
609
+ }
610
+ registerArmedGateFor(sessionKey, armedKey);
611
+ publishQuestionFrameFor(sessionKey, {
612
+ type: 'question',
613
+ questionId,
614
+ questions: planReviewQuestions(REOPEN_QUESTION),
615
+ });
616
+ revokePlanReviewUserDismissal(sessionKey, taskId, markBefore);
617
+ return { reopened: true, firstSight };
618
+ }
619
+ async function reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark) {
620
+ const refusedByUser = { reopened: false, dismissedByUser: true };
621
+ try {
622
+ let fresh = false;
623
+ const closed = await boundedGateInstanceRead(mark.closedGates);
624
+ if (closed !== null && closed.size > 0) {
625
+ const now = await boundedGateInstanceRead(startGateInstanceRead(opts.readGateInstanceKeys, taskId));
626
+ if (now !== null) {
627
+ for (const k of now) {
628
+ if (!closed.has(k)) {
629
+ fresh = true;
630
+ break;
631
+ }
632
+ }
633
+ }
634
+ }
635
+ let handedOver = false;
636
+ if (!decideInFlight.has(taskId)) {
637
+ try {
638
+ handedOver = opts.decisionHandedOver?.(taskId) === true;
639
+ }
640
+ catch (err) {
641
+ hostLog('debug', `planReviewWire: reopen for task ${taskId} refused — the host's decision hand-over check threw: ${String(err)}`);
642
+ return { reopened: false };
643
+ }
644
+ }
645
+ if (decideInFlight.has(taskId) || handedOver) {
646
+ 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
+ return { reopened: false, _sema_decisionInFlight: true };
648
+ }
649
+ const current = dismissalEntryFor(sessionKey, taskId);
650
+ if (current !== mark) {
651
+ hostLog('debug', `planReviewWire: reopen for task ${taskId} not minted — the dismissal record changed while it was being checked (stale evaluation)`);
652
+ return await staleDismissalVerdict(current, sessionKey, taskId, receiptMs);
653
+ }
654
+ if (!fresh) {
655
+ hostLog('debug', `planReviewWire: automatic reopen for task ${taskId} refused — the user closed this gate's card (no newer gate instance proven)`);
656
+ return refusedByUser;
657
+ }
658
+ let settle = () => { };
659
+ const reopening = {
660
+ kind: 'reopened',
661
+ closedGates: null,
662
+ onScreen: new Promise((resolve) => {
663
+ settle = resolve;
664
+ }),
665
+ };
666
+ putDismissalEntry(sessionKey, taskId, reopening);
667
+ let verdict = { reopened: false };
668
+ try {
669
+ if (hasQuestionOverlayFor(sessionKey))
670
+ verdict = await mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs);
671
+ }
672
+ finally {
673
+ const presented = verdict.reopened === true;
674
+ settle(presented);
675
+ if (!presented)
676
+ dropDismissalEntry(sessionKey, taskId, reopening);
677
+ }
678
+ return verdict;
679
+ }
680
+ catch {
681
+ return refusedByUser;
682
+ }
683
+ }
684
+ async function staleDismissalVerdict(current, sessionKey, taskId, receiptMs) {
685
+ const ac = new AbortController();
686
+ const windowEnd = abortableSleep(Math.max(0, receiptMs), ac.signal).then(() => false);
687
+ try {
688
+ let entry = current;
689
+ for (let round = 0; round < 8; round++) {
690
+ if (entry?.kind === 'decided')
691
+ return { reopened: false, _sema_decisionInFlight: true };
692
+ if (entry?.kind === 'dismissed')
693
+ return { reopened: false, dismissedByUser: true };
694
+ if (entry?.kind !== 'reopened' || entry.onScreen === undefined)
695
+ return { reopened: false };
696
+ const onScreen = await Promise.race([
697
+ entry.onScreen.then((v) => v === true, () => false),
698
+ windowEnd,
699
+ ]);
700
+ const now = dismissalEntryFor(sessionKey, taskId);
701
+ if (now === entry) {
702
+ return onScreen ? { reopened: true, firstSight: !wasGateArmedFor(sessionKey, planReviewArmedKeyFor(sessionKey, taskId)) } : { reopened: false };
703
+ }
704
+ entry = now;
705
+ }
706
+ return { reopened: false };
707
+ }
708
+ finally {
709
+ ac.abort();
710
+ }
378
711
  }
379
712
  export function reopenPlanReviewCard(taskId, opts) {
380
713
  const receiptMs = opts?.presentationReceiptMs;
381
- const settleOnReceipt = (receipt, firstSight) => receipt.then((presented) => (presented ? { reopened: true, firstSight } : { reopened: false }), () => ({ reopened: false }));
382
714
  try {
383
715
  if (typeof taskId !== 'string' || taskId.length === 0) {
384
716
  return receiptMs === undefined ? { reopened: false } : Promise.resolve({ reopened: false });
@@ -399,81 +731,18 @@ export function reopenPlanReviewCard(taskId, opts) {
399
731
  '此处拒绝而不是照发 —— 照发会把这条会话的决断发到另一个槽上。');
400
732
  return receiptMs === undefined ? { reopened: false } : Promise.resolve({ reopened: false });
401
733
  }
402
- const canonicalId = planReviewQuestionId(taskId);
403
- const armedKey = planReviewArmedKeyFor(sessionKey, taskId);
404
- const firstSight = !wasGateArmedFor(sessionKey, armedKey);
405
- const mintFresh = opts?.mintFreshQuestionId !== false;
406
- if (!mintFresh && hasLocalQuestionResponder(canonicalId)) {
407
- hostLog('debug', `planReviewWire: reopen for task ${taskId} reusing the canonical responder (path=reopen-canonical)`);
408
- if (opts?.deliverDecision !== undefined) {
409
- hostLog('debug', `planReviewWire: reopen canonical-reuse branch for task ${taskId} ignores opts.deliverDecision (answers route through the existing arm responder — documented exception)`);
410
- }
411
- retireActiveReopen(sessionKey, canonicalId);
412
- if (receiptMs !== undefined) {
413
- const receipt = waitForGateArmedFor(sessionKey, [canonicalId], receiptMs);
414
- publishQuestionFrameFor(sessionKey, {
415
- type: 'question',
416
- questionId: canonicalId,
417
- questions: planReviewQuestions(ARM_QUESTION, offerModeAfterByTask.get(taskId) === true),
418
- });
419
- return settleOnReceipt(receipt, firstSight);
420
- }
421
- publishQuestionFrameFor(sessionKey, {
422
- type: 'question',
423
- questionId: canonicalId,
424
- questions: planReviewQuestions(ARM_QUESTION, offerModeAfterByTask.get(taskId) === true),
425
- });
426
- registerArmedGateFor(sessionKey, armedKey);
427
- return { reopened: true, firstSight };
428
- }
429
- retireActiveReopen(sessionKey, canonicalId);
430
- if (unregisterLocalQuestionResponder(canonicalId)) {
431
- hostLog('debug', `planReviewWire: reopen retired the canonical responder for task ${taskId} (single-active discipline)`);
432
- }
433
- publishQuestionFrameFor(sessionKey, { type: 'question_complete', questionId: canonicalId });
434
- const questionId = `${canonicalId}${REOPEN_ID_TAIL}${reopenIdSuffix()}`;
435
- hostLog('debug', `planReviewWire: reopen for task ${taskId} minting a fresh card ${questionId} (path=reopen-fresh)`);
436
- const deliver = opts?.deliverDecision ??
437
- ((tid, decision) => {
438
- hostLog('debug', `planReviewWire: reopen responder delivering ${decision} for task ${tid} (path=reopen-default)`);
439
- void decidePlanReview(tid, decision);
440
- });
441
- const activeKey = `${sessionKey}\u0000${canonicalId}`;
442
- const unregister = registerLocalQuestionResponder(questionId, async (_id, answer) => {
443
- unregister();
444
- if (activeReopenResponders.get(activeKey)?.questionId === questionId) {
445
- activeReopenResponders.delete(activeKey);
446
- }
447
- const decided = planReviewDecisionFromAnswer(answer, LEGACY_TWO_CHOICE_LABELS);
448
- if (decided === 'dismissed') {
449
- return { ok: true };
450
- }
451
- notePlanReviewAnsweredFor(sessionKey, questionId);
452
- try {
453
- await deliver(taskId, decided);
454
- }
455
- catch (e) {
456
- hostLog('error', `planReviewWire: reopen decision delivery threw for task ${taskId}: ${String(e)}`);
734
+ const trigger = opts?.trigger === undefined ? undefined : opts.trigger === 'user' ? 'user' : 'automatic';
735
+ if (trigger === 'automatic') {
736
+ const mark = planReviewUserDismissalMarkFor(sessionKey, taskId);
737
+ if (mark !== undefined) {
738
+ if (receiptMs === undefined || opts === undefined) {
739
+ hostLog('debug', `planReviewWire: automatic reopen for task ${taskId} refused — the user closed this gate's card`);
740
+ return { reopened: false, dismissedByUser: true };
741
+ }
742
+ return reopenAfterDismissalCheck(taskId, opts, sessionKey, receiptMs, mark);
457
743
  }
458
- return { ok: true };
459
- });
460
- activeReopenResponders.set(activeKey, { questionId, unregister });
461
- if (receiptMs !== undefined) {
462
- const receipt = waitForGateArmedFor(sessionKey, [questionId], receiptMs);
463
- publishQuestionFrameFor(sessionKey, {
464
- type: 'question',
465
- questionId,
466
- questions: planReviewQuestions(REOPEN_QUESTION),
467
- });
468
- return settleOnReceipt(receipt, firstSight);
469
744
  }
470
- registerArmedGateFor(sessionKey, armedKey);
471
- publishQuestionFrameFor(sessionKey, {
472
- type: 'question',
473
- questionId,
474
- questions: planReviewQuestions(REOPEN_QUESTION),
475
- });
476
- return { reopened: true, firstSight };
745
+ return mintPlanReviewReopen(taskId, opts, sessionKey, receiptMs);
477
746
  }
478
747
  catch {
479
748
  return receiptMs === undefined ? { reopened: false } : Promise.resolve({ reopened: false });
@@ -1,6 +1,7 @@
1
1
  import { type GateCurrentPending, type HitlFailureStage, type HitlSafetyCode, type HitlClientLike, type DecideOperationObserver } from './hitlBridge.js';
2
2
  import { type RuleStoreUnreadableKind } from '../gateVocabulary.js';
3
3
  import { type ApprovalMandateWord } from '../gateVocabulary.js';
4
+ import type { ToolPermissionRequest } from '../seatContract.js';
4
5
  import { type DenyAttributionRefusalDetail } from '../wireRefusalCopy.js';
5
6
  import { type DecideReceiptView } from '../decideReceipt.js';
6
7
  import { RULE_OFFER_MATCHES, RULE_OFFER_BATCH_MEMBER_KINDS, RULE_OFFER_UNCOVERED_REASONS, RULE_OFFERS_ABSENCE_REASONS, DENIAL_LIMIT_KINDS } from '@sema-agent/sdk';
@@ -207,6 +208,7 @@ export declare function approvalIsMandated(v: ApprovalMandatedFacts | undefined
207
208
  export declare function readApprovalMandate(v: {
208
209
  readonly mandate?: string;
209
210
  } | undefined | null): ApprovalMandateWord | undefined;
211
+ export declare function toolPermissionRequestAskBits(card: object | null | undefined): Pick<ToolPermissionRequest, 'mandated' | 'mandate' | 'origin' | 'ruleOffersAbsence'>;
210
212
  export declare function isFromSubagent(frame: ToolApprovalFrame): boolean;
211
213
  export type ToolApprovalRespondDecision = 'allow' | 'allow_session' | 'deny';
212
214
  export interface RespondToolApprovalOpts {
@@ -206,7 +206,7 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
206
206
  : {}),
207
207
  ...(pending.governanceForced === true ? { governanceForced: true } : {}),
208
208
  ...(pending.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
209
- ...(pending.mandated === true ? { mandated: true } : {}),
209
+ ...(hasOwnStrictTrue(pending, 'mandated') ? { mandated: true } : {}),
210
210
  ...((w) => (w !== undefined ? { mandate: w } : {}))(readApprovalMandate(pending)),
211
211
  ...((o) => (o !== undefined ? { origin: o } : {}))(readOwnNonEmptyString(pending, 'origin')),
212
212
  ...(pending.gateKind === 'irreversible_ask' ? { irreversibleParkGate: true } : {}),
@@ -403,9 +403,11 @@ const MANDATED_ABSENCE_WORD = 'mandated';
403
403
  export function approvalIsMandated(v) {
404
404
  if (v === null || v === undefined)
405
405
  return false;
406
- if (v.mandated === true)
406
+ if (typeof v !== 'object')
407
+ return false;
408
+ if (hasOwnStrictTrue(v, 'mandated'))
407
409
  return true;
408
- if (v.ruleOffersAbsence === MANDATED_ABSENCE_WORD)
410
+ if (readOwnNonEmptyString(v, 'ruleOffersAbsence') === MANDATED_ABSENCE_WORD)
409
411
  return true;
410
412
  return readApprovalMandate(v) !== undefined;
411
413
  }
@@ -415,6 +417,23 @@ export function readApprovalMandate(v) {
415
417
  const raw = v.mandate;
416
418
  return isApprovalMandateWord(raw) ? raw : undefined;
417
419
  }
420
+ export function toolPermissionRequestAskBits(card) {
421
+ if (typeof card !== 'object' || card === null)
422
+ return {};
423
+ const mandated = hasOwnStrictTrue(card, 'mandated');
424
+ const mandate = readApprovalMandate(card);
425
+ const origin = readOwnNonEmptyString(card, 'origin');
426
+ const ruleOffersAbsence = readOwnNonEmptyString(card, 'ruleOffersAbsence');
427
+ return {
428
+ ...(mandated ? { mandated: true } : {}),
429
+ ...(mandate !== undefined ? { mandate } : {}),
430
+ ...(origin !== undefined ? { origin } : {}),
431
+ ...(ruleOffersAbsence !== undefined ? { ruleOffersAbsence } : {}),
432
+ };
433
+ }
434
+ function hasOwnStrictTrue(v, key) {
435
+ return Object.hasOwn(v, key) && v[key] === true;
436
+ }
418
437
  function isToolApprovalDelegation(v) {
419
438
  if (v === null || typeof v !== 'object')
420
439
  return false;
@@ -840,7 +859,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
840
859
  ...(isWireRecordCarrier(frame.probeCause) ? { probeCause: frame.probeCause } : {}),
841
860
  ...(isWireRecordCarrier(frame.ruleEvidence) ? { ruleEvidence: frame.ruleEvidence } : {}),
842
861
  ...(frame.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
843
- ...(frame.mandated === true ? { mandated: true } : {}),
862
+ ...(hasOwnStrictTrue(frame, 'mandated') ? { mandated: true } : {}),
844
863
  ...((w) => (w !== undefined ? { mandate: w } : {}))(readApprovalMandate(frame)),
845
864
  ...(() => { const rc = readReadRootCandidate(frame.readRootCandidate); return rc !== undefined ? { readRootCandidate: rc } : {}; })(),
846
865
  ...(frame.inputHasBidi === true ? { inputHasBidi: true } : {}),
@@ -7,7 +7,7 @@ import { MAX_HOOK_NOTICE_TEXT_CHARS } from './notifications.js';
7
7
  import { goalStopHookMatcher, __resetGoalStopHookForTests } from './goalStopHook.js';
8
8
  export { GOAL_STOP_HOOK_WIRE_ENV, CC_STOP_SEMANTICS_MIN_SERVER, ccStopSemanticsFromVersion, engineCcStopSemantics, isGoalStopHookWireArmed, setWireSessionStopHook, getWireSessionStopHook, getWireSessionStopHookCcSemantics, buildGoalStopHookPrompt, } from './goalStopHook.js';
9
9
  const EDITABLE_HOOK_SOURCES = ['userSettings', 'projectSettings', 'localSettings'];
10
- const ALL_HOOK_SOURCES = ['policySettings', ...EDITABLE_HOOK_SOURCES];
10
+ const ALL_HOOK_SOURCES = ['policySettings', ...EDITABLE_HOOK_SOURCES, 'flagSettings'];
11
11
  const _hookSourceListsPin = [true, true];
12
12
  void _hookSourceListsPin;
13
13
  function hooksLockedToPluginOnly(policyValue) {
package/dist/index.d.ts CHANGED
@@ -66,6 +66,7 @@ export * from './controlRouter.js';
66
66
  export * from './sseIdleTriage.js';
67
67
  export * from './engineWireSdk.js';
68
68
  export * from './classifierVerdictWire.js';
69
+ export { stripSystemReminderBlocks, unwrapSystemReminder } from './systemReminderTag.js';
69
70
  export * from './engineErrorCodes.js';
70
71
  export * from './agentsWireCaps.js';
71
72
  export * from './attachmentsWireCaps.js';
@@ -154,7 +155,7 @@ export * from './hitl/editedRuleTextPrecheck.js';
154
155
  export * from './coreValuePorts.js';
155
156
  export * from './hitl/askGateWire.js';
156
157
  export * from './hitl/planReviewWire.js';
157
- export * from './hitl/gateIdentity.js';
158
+ export { PLAN_REVIEW_QUESTION_ID_PREFIX, REOPEN_ID_TAIL, approvalCallKey, askGateQuestionId, planReviewQuestionId, liveFrameCallKey, armedKeyFromQuestionId, } from './hitl/gateIdentity.js';
158
159
  export * from './hitl/armedGateRegistry.js';
159
160
  export * from './hitl/parkOwnership.js';
160
161
  export * from './hitl/parkRowBirthWait.js';
package/dist/index.js CHANGED
@@ -64,6 +64,7 @@ export * from './controlRouter.js';
64
64
  export * from './sseIdleTriage.js';
65
65
  export * from './engineWireSdk.js';
66
66
  export * from './classifierVerdictWire.js';
67
+ export { stripSystemReminderBlocks, unwrapSystemReminder } from './systemReminderTag.js';
67
68
  export * from './engineErrorCodes.js';
68
69
  export * from './agentsWireCaps.js';
69
70
  export * from './attachmentsWireCaps.js';
@@ -151,7 +152,7 @@ export * from './hitl/editedRuleTextPrecheck.js';
151
152
  export * from './coreValuePorts.js';
152
153
  export * from './hitl/askGateWire.js';
153
154
  export * from './hitl/planReviewWire.js';
154
- export * from './hitl/gateIdentity.js';
155
+ export { PLAN_REVIEW_QUESTION_ID_PREFIX, REOPEN_ID_TAIL, approvalCallKey, askGateQuestionId, planReviewQuestionId, liveFrameCallKey, armedKeyFromQuestionId, } from './hitl/gateIdentity.js';
155
156
  export * from './hitl/armedGateRegistry.js';
156
157
  export * from './hitl/parkOwnership.js';
157
158
  export * from './hitl/parkRowBirthWait.js';
@@ -8,13 +8,14 @@ type ModelIdentityLaneTuple = readonly [
8
8
  export type ModelIdentityLane = ModelIdentityLaneTuple[number];
9
9
  export type ModelIdentityLaneReading = 'complete' | 'partial' | 'absent' | 'unreadable';
10
10
  export type ModelIdentityLanes = {
11
- [L in ModelIdentityLane]: L extends 'modelCatalogFile' ? Exclude<ModelIdentityLaneReading, 'complete'> : ModelIdentityLaneReading;
11
+ [L in ModelIdentityLane]: ModelIdentityLaneReading;
12
12
  };
13
13
  export type EngineAnswerObservation = 'answered' | 'not_observed' | 'unreachable';
14
14
  export interface LocalEngineModelIdentityReading {
15
15
  engine: 'local';
16
16
  lanes: ModelIdentityLanes;
17
17
  engineAnswer?: EngineAnswerObservation;
18
+ engineAcceptsCatalogDefault?: boolean;
18
19
  }
19
20
  export interface RemoteEngineModelIdentityReading {
20
21
  engine: 'remote';