@try-works/dsh-recursive-mode 0.2.3 → 0.3.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.
@@ -532,6 +532,316 @@ const BOARD_CSS = `
532
532
  color: var(--board-muted-fg);
533
533
  }
534
534
 
535
+ /* ===== Per-phase doc viewer (0.2.4, inside the inspector detail body) ===== */
536
+
537
+ /* View phase ghost button on each present phase row. */
538
+ .rec-phase-view {
539
+ flex: none;
540
+ padding: 3px 10px;
541
+ font-size: var(--board-text-xs);
542
+ color: var(--board-info);
543
+ background: transparent;
544
+ border: 1px solid var(--board-border);
545
+ border-radius: 999px;
546
+ cursor: pointer;
547
+ white-space: nowrap;
548
+ }
549
+
550
+ .rec-phase-view:hover {
551
+ background: var(--board-accent);
552
+ }
553
+
554
+ /* Viewer shell: fills the inspector detail body (the panel owns data-theme). */
555
+ .rec-doc {
556
+ display: flex;
557
+ flex-direction: column;
558
+ min-height: 0;
559
+ gap: var(--board-space-12);
560
+ color: var(--board-fg);
561
+ font-family: var(--board-font-sans);
562
+ background: var(--board-bg);
563
+ border: 1px solid var(--board-border);
564
+ border-radius: var(--board-radius-xl);
565
+ overflow: hidden;
566
+ }
567
+
568
+ .rec-doc-header {
569
+ display: flex;
570
+ align-items: center;
571
+ gap: var(--board-space-12);
572
+ padding: var(--board-space-12) var(--board-space-16);
573
+ border-bottom: 1px solid var(--board-border);
574
+ flex: none;
575
+ }
576
+
577
+ .rec-doc-heading {
578
+ display: flex;
579
+ align-items: baseline;
580
+ gap: var(--board-space-12);
581
+ flex: 1;
582
+ min-width: 0;
583
+ }
584
+
585
+ .rec-doc-badge {
586
+ flex: none;
587
+ padding: 2px 10px;
588
+ font-size: 11px;
589
+ font-weight: var(--board-fw-semibold);
590
+ letter-spacing: 0.05em;
591
+ text-transform: uppercase;
592
+ border-radius: 999px;
593
+ background: var(--board-accent);
594
+ color: var(--board-muted-fg);
595
+ }
596
+
597
+ .rec-doc-title {
598
+ margin: 0;
599
+ font-size: var(--board-text-sm);
600
+ font-weight: var(--board-fw-semibold);
601
+ letter-spacing: var(--board-tracking-tight);
602
+ overflow: hidden;
603
+ text-overflow: ellipsis;
604
+ white-space: nowrap;
605
+ }
606
+
607
+ .rec-doc-run {
608
+ flex: none;
609
+ font-family: var(--board-font-mono);
610
+ font-size: var(--board-text-xs);
611
+ color: var(--board-muted-fg);
612
+ }
613
+
614
+ .rec-doc-btn {
615
+ flex: none;
616
+ padding: 5px 12px;
617
+ font-size: var(--board-text-xs);
618
+ color: var(--board-fg);
619
+ background: transparent;
620
+ border: 1px solid var(--board-border);
621
+ border-radius: 999px;
622
+ cursor: pointer;
623
+ white-space: nowrap;
624
+ }
625
+
626
+ .rec-doc-btn:hover {
627
+ background: var(--board-accent);
628
+ }
629
+
630
+ /* Search bar (/ to open). */
631
+ .rec-doc-search {
632
+ display: flex;
633
+ align-items: center;
634
+ gap: var(--board-space-12);
635
+ padding: var(--board-space-8) var(--board-space-16);
636
+ border-bottom: 1px solid var(--board-border);
637
+ flex: none;
638
+ }
639
+
640
+ .rec-doc-search-input {
641
+ flex: 1 1 auto;
642
+ background: var(--board-muted);
643
+ color: var(--board-fg);
644
+ border: 1px solid var(--board-border);
645
+ border-radius: var(--board-radius-md);
646
+ padding: 5px 10px;
647
+ font-size: var(--board-text-xs);
648
+ font-family: inherit;
649
+ min-width: 0;
650
+ }
651
+
652
+ .rec-doc-search-input::placeholder {
653
+ color: var(--board-muted-fg);
654
+ opacity: 1;
655
+ }
656
+
657
+ .rec-doc-search-count {
658
+ font-size: var(--board-text-xs);
659
+ color: var(--board-muted-fg);
660
+ white-space: nowrap;
661
+ }
662
+
663
+ /* Body: scrollable line list. */
664
+ .rec-doc-body {
665
+ flex: 1 1 auto;
666
+ min-height: 0;
667
+ overflow-y: auto;
668
+ padding: var(--board-space-12) var(--board-space-16) var(--board-space-16);
669
+ }
670
+
671
+ .rec-doc-line {
672
+ font-size: var(--board-text-sm);
673
+ line-height: 1.7;
674
+ padding: 0 6px;
675
+ border-left: 2px solid transparent;
676
+ }
677
+
678
+ .rec-doc-line-current {
679
+ background: var(--board-muted);
680
+ border-left-color: var(--board-info);
681
+ }
682
+
683
+ .rec-doc-blank {
684
+ height: 10px;
685
+ }
686
+
687
+ .rec-doc-plain {
688
+ white-space: pre-wrap;
689
+ }
690
+
691
+ .rec-doc-h1 {
692
+ font-size: 22px;
693
+ font-weight: 700;
694
+ margin: 8px 0 6px;
695
+ line-height: 1.3;
696
+ }
697
+
698
+ .rec-doc-h2 {
699
+ font-size: 18px;
700
+ font-weight: 600;
701
+ margin: 12px 0 4px;
702
+ line-height: 1.3;
703
+ }
704
+
705
+ .rec-doc-h3 {
706
+ font-size: 15px;
707
+ font-weight: 600;
708
+ margin: 10px 0 4px;
709
+ line-height: 1.3;
710
+ }
711
+
712
+ .rec-doc-h4 {
713
+ font-size: 14px;
714
+ font-weight: 600;
715
+ margin: 8px 0 4px;
716
+ line-height: 1.3;
717
+ }
718
+
719
+ .rec-doc-li {
720
+ display: flex;
721
+ align-items: baseline;
722
+ gap: 8px;
723
+ font-size: var(--board-text-sm);
724
+ line-height: 1.65;
725
+ margin: 0 0 4px;
726
+ }
727
+
728
+ .rec-doc-bullet {
729
+ flex: none;
730
+ color: var(--board-muted-fg);
731
+ width: 14px;
732
+ text-align: center;
733
+ }
734
+
735
+ .rec-doc-li-text {
736
+ flex: 1;
737
+ min-width: 0;
738
+ }
739
+
740
+ .rec-doc-pre {
741
+ margin: 6px 0;
742
+ padding: var(--board-space-12);
743
+ background: var(--board-muted);
744
+ border: 1px solid var(--board-border);
745
+ border-radius: var(--board-radius-md);
746
+ overflow-x: auto;
747
+ }
748
+
749
+ .rec-doc-code,
750
+ .rec-doc-inline-code {
751
+ font-family: var(--board-font-mono);
752
+ font-size: 12.5px;
753
+ letter-spacing: var(--board-tracking-mono);
754
+ }
755
+
756
+ .rec-doc-inline-code {
757
+ background: var(--board-muted);
758
+ border: 1px solid var(--board-border);
759
+ border-radius: 4px;
760
+ padding: 0 4px;
761
+ }
762
+
763
+ .rec-doc-inline-link {
764
+ color: var(--board-info);
765
+ text-decoration: underline;
766
+ cursor: pointer;
767
+ }
768
+
769
+ .rec-doc-table {
770
+ width: 100%;
771
+ border-collapse: collapse;
772
+ margin: 6px 0;
773
+ font-size: var(--board-text-xs);
774
+ }
775
+
776
+ .rec-doc-table th,
777
+ .rec-doc-table td {
778
+ border: 1px solid var(--board-border);
779
+ padding: 4px 8px;
780
+ text-align: left;
781
+ }
782
+
783
+ .rec-doc-table th {
784
+ background: var(--board-muted);
785
+ color: var(--board-muted-fg);
786
+ font-weight: var(--board-fw-semibold);
787
+ }
788
+
789
+ /* Footer: status + key hints. */
790
+ .rec-doc-footer {
791
+ flex: none;
792
+ display: flex;
793
+ align-items: center;
794
+ gap: var(--board-space-16);
795
+ padding: var(--board-space-8) var(--board-space-16);
796
+ border-top: 1px solid var(--board-border);
797
+ flex-wrap: wrap;
798
+ }
799
+
800
+ .rec-doc-status {
801
+ font-size: var(--board-text-xs);
802
+ color: var(--board-muted-fg);
803
+ min-width: 120px;
804
+ }
805
+
806
+ .rec-doc-status-error {
807
+ color: var(--board-error);
808
+ }
809
+
810
+ .rec-doc-status-ok {
811
+ color: var(--board-success);
812
+ }
813
+
814
+ .rec-doc-hints {
815
+ display: flex;
816
+ align-items: center;
817
+ gap: 2px;
818
+ flex-wrap: wrap;
819
+ font-size: var(--board-text-xs);
820
+ color: var(--board-muted-fg);
821
+ }
822
+
823
+ .rec-doc-hints kbd {
824
+ background: var(--board-muted);
825
+ border: 1px solid var(--board-border);
826
+ border-radius: 4px;
827
+ padding: 1px 6px;
828
+ font-family: inherit;
829
+ font-size: 11px;
830
+ font-weight: 600;
831
+ color: var(--board-fg);
832
+ margin: 0 3px 0 8px;
833
+ }
834
+
835
+ .rec-doc-text {
836
+ margin: 0;
837
+ font-size: var(--board-text-sm);
838
+ color: var(--board-muted-fg);
839
+ }
840
+
841
+ .rec-doc-error {
842
+ color: var(--board-error);
843
+ }
844
+
535
845
  /* ===== Strip (session dock) — keeps the shell --dsw-* theme ===== */
536
846
  .rec-badge {
537
847
  flex: none;
package/src/delegation.ts CHANGED
@@ -11,11 +11,53 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
11
11
  import { join, resolve, sep } from 'node:path'
12
12
  import { contentSha256 } from './review.ts'
13
13
 
14
+ /**
15
+ * Opaque handle to the live direct-parent Agent. The live continuable service
16
+ * authorizes by EXACT live object identity — `ctx.agents.get(parent.id) ===
17
+ * parent` (authorizeLineage), `ancestry.has(parent)` (interrupt/drain), and a
18
+ * `WeakSet` of live ancestry — so this must be the real live `Agent`, never a
19
+ * structural `{ id }` copy. The seam only ever reads `id`/`session.header.cwd`
20
+ * for attribution, and never serializes or inspects the live object.
21
+ */
22
+ export interface SubagentParentHandle {
23
+ readonly id?: string
24
+ readonly session?: { readonly header?: { readonly cwd?: string } }
25
+ }
26
+
27
+ /** Durable identity of one continuable child session (string-branded in the host). */
28
+ export type ContinuableChildId = string
29
+
30
+ /** Durable identity of one accepted inbox message (string-branded in the host). */
31
+ export type ContinuableMessageId = string
32
+
33
+ /**
34
+ * Attribution for a model coordinator's follow-up to one of its children (the
35
+ * live `CoordinatorMessageSource` subset — see subagent/src/continuation.ts).
36
+ */
37
+ export interface CoordinatorSourceLike {
38
+ readonly kind: 'coordinator'
39
+ readonly form: 'relay'
40
+ readonly senderSessionId: string
41
+ }
42
+
43
+ /** Uniform outcome for the interrupt/drain kill-switch helpers. */
44
+ export interface ContinuableOpResult {
45
+ ok: boolean
46
+ reason?: string
47
+ }
48
+
14
49
  /** Minimal host-realm contract for ctx.subagents (the seam we call). */
15
50
  export interface SubagentsRuntimeLike {
16
51
  start(name: string, request: SubagentStartRequestLike): Promise<SubagentResultLike>
17
52
  getProvider?(name: string): unknown
18
53
  list?(): unknown
54
+ /** T4: continuable child lifecycle (startContinuable / followup / interrupt / drain). */
55
+ startContinuable?(spec: ContinuableStartSpecLike): Promise<ContinuableStartLike>
56
+ /** The parent MUST be the exact live Agent (object-identity authority), never a `{ id }` copy. */
57
+ followup?(parent: SubagentParentHandle, childId: ContinuableChildId, content: readonly { type: 'text'; text: string }[], options: SubagentFollowupOptionsLike): Promise<ContinuableMessageId>
58
+ interrupt?(targetSessionId: ContinuableChildId, authority: SubagentInterruptAuthorityLike): void
59
+ drainContinuableChildren?(parent: SubagentParentHandle, childIds: readonly ContinuableChildId[]): Promise<void>
60
+ drainContinuableDescendants?(parents: readonly SubagentParentHandle[]): Promise<void>
19
61
  }
20
62
 
21
63
  export interface SubagentStartRequestLike {
@@ -116,11 +158,305 @@ export async function delegate(input: {
116
158
  }
117
159
  }
118
160
 
161
+ /**
162
+ * T4: continuable-child delegation — ONE durable child receives the initial
163
+ * prompt (startContinuable), each REVISE is delivered as a followup to the SAME
164
+ * child (FIFO, working set retained), and the parent observes each round's
165
+ * settlement through the injected `awaitRoundResult` seam (in live usage the
166
+ * child's settlement lands in the parent's inbox — `reportFrom` is the
167
+ * CHILD-side API, so the parent-side loop collects via settlement, not by
168
+ * calling it). A hung reviewer is cancelled with `interruptContinuable`
169
+ * (keepInbox: the child's pending inbox survives). Falls back to one-shot
170
+ * `delegate` when the seam has no continuable methods.
171
+ */
172
+
173
+ /** What the caller asks for when starting a continuable background child (structural subset). */
174
+ export interface ContinuableStartSpecLike {
175
+ /** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */
176
+ readonly provider: string
177
+ /** The initial delegation's short `description`, persisted as the child's creation label. */
178
+ readonly label: string
179
+ /** Optional caller-reserved child identity. */
180
+ childId?: ContinuableChildId
181
+ /** The delegation request (prompt + parent + toolFilter + maxDepth; no label/signal/outputSchema). */
182
+ readonly request: Omit<SubagentStartRequestLike, 'label' | 'signal' | 'outputSchema'>
183
+ /** Caller cancellation, owning the operation only until inbox acceptance. */
184
+ readonly signal?: AbortSignalLike
185
+ }
186
+
187
+ /** Minimal cancellation shape (a live AbortSignal satisfies it). */
188
+ export interface AbortSignalLike {
189
+ readonly throwIfAborted: () => void
190
+ }
191
+
192
+ /** Identities returned once a continuable child accepted its initial prompt. */
193
+ export interface ContinuableStartLike {
194
+ /** The durable child session id, stable across activations. */
195
+ readonly childId: ContinuableChildId
196
+ /** The accepted initial prompt's inbox message id. */
197
+ readonly messageId: ContinuableMessageId
198
+ }
199
+
200
+ /** Options for following up with one continuable child (structural subset). */
201
+ export interface SubagentFollowupOptionsLike {
202
+ /** Durable attribution retained on the delivered message. */
203
+ readonly source: CoordinatorSourceLike
204
+ /** Caller cancellation, owning the operation only until inbox acceptance. */
205
+ readonly signal?: AbortSignalLike
206
+ }
207
+
208
+ /** Authority under which one interrupt request is admitted. */
209
+ export type SubagentInterruptAuthorityLike =
210
+ | { readonly kind: 'user'; readonly parentSessionId: string }
211
+ | { readonly kind: 'ancestor'; readonly agent: SubagentParentHandle }
212
+
213
+ /** One round of a continuable child: the delivered text plus the observed outcome. */
214
+ export interface ContinuableRoundLike {
215
+ /** The message text delivered as this round's user prompt. */
216
+ text: string
217
+ /** The child's observed outcome for this round. */
218
+ result?: SubagentResultLike
219
+ /** True when this round's verdict was REVISE (a repair followup followed). */
220
+ revise?: boolean
221
+ /** The repair instruction delivered in the followup (only when revise). */
222
+ repair?: string
223
+ }
224
+
225
+ /** The full T4 delegation outcome. */
226
+ export interface ContinuableDelegationLike {
227
+ ok: boolean
228
+ reason?: string
229
+ /** The durable child session id (stable across rounds). */
230
+ childId?: ContinuableChildId
231
+ /** Inbox message ids: [initial acceptance, ...followups]. */
232
+ messageIds?: ContinuableMessageId[]
233
+ rounds: ContinuableRoundLike[]
234
+ /** Final outcome accepted (last verdict APPROVE + result accepted). */
235
+ accepted: boolean
236
+ /** True when the fallback one-shot `delegate()` was used (no continuable seam). */
237
+ fellBackToOneShot?: boolean
238
+ }
239
+
240
+ /** Verdict vocabulary shared by T3/T4 (matches the delegated review schema). */
241
+ export type DelegationVerdict = 'APPROVE' | 'REVISE' | 'REJECT'
242
+
243
+ /** Read the verdict from a review-schema structured result (pure). */
244
+ export function readVerdictFromStructured(result: SubagentResultLike): DelegationVerdict {
245
+ // SAFETY: reviewOutputSchema() defines verdict as a string enum; the cast reads
246
+ // one leaf field only, never mutates, and falls back on a non-matching value.
247
+ const verdict = (result.structured as { verdict?: unknown } | undefined)?.verdict
248
+ if (verdict === 'APPROVE' || verdict === 'REVISE' || verdict === 'REJECT') return verdict
249
+ // No structured verdict: a completed run with text output is a provisional APPROVE
250
+ // candidate, but delegation acceptance stays strict (caller evaluates).
251
+ return 'APPROVE'
252
+ }
253
+
254
+ /** Read the repair instruction from a review-schema structured result (pure). */
255
+ export function readRepairFromStructured(result: SubagentResultLike): string {
256
+ // SAFETY: reviewOutputSchema() defines findings as an array of {severity,title,
257
+ // detail}; the cast reads leaf fields only (no live data, no mutation). The
258
+ // repair instruction is ALWAYS synthesized from the findings — a child cannot
259
+ // inject arbitrary instruction text (prompt-injection hygiene).
260
+ const findings = (result.structured as { findings?: Array<{ title?: string }> } | undefined)?.findings
261
+ const titles = Array.isArray(findings) ? findings.map(f => f.title ?? '').filter(Boolean) : []
262
+ if (titles.length > 0) return 'Address the findings: ' + titles.join('; ')
263
+ return 'REVISE: address the review findings and re-submit.'
264
+ }
265
+
266
+ /**
267
+ * Run a multi-round delegated task on ONE durable continuable child:
268
+ * 1. `startContinuable` (initial prompt) — `start()` is never called.
269
+ * 2. `awaitRoundResult` observes the child's settlement for that round.
270
+ * 3. On REVISE: `followup` delivers the repair instruction to the SAME child.
271
+ * 4. On APPROVE/REJECT: finish (accepted only when the verdict is APPROVE and
272
+ * the result evaluates as accepted).
273
+ *
274
+ * `awaitRoundResult(childId, messageId)` is the ONLY parent-side observation
275
+ * seam: in live usage it waits for the child's settlement notice (the child's
276
+ * `reportFrom` lands in the parent's inbox); in tests it is a fake queue.
277
+ */
278
+ export async function delegateContinuable(input: {
279
+ subagents: SubagentsRuntimeLike
280
+ provider: string
281
+ label: string
282
+ prompt: string
283
+ parent?: SubagentParentHandle
284
+ toolFilter?: unknown
285
+ maxDepth?: number
286
+ childId?: ContinuableChildId
287
+ maxRounds?: number
288
+ readVerdict?: (result: SubagentResultLike) => DelegationVerdict
289
+ readRepair?: (result: SubagentResultLike) => string | undefined
290
+ awaitRoundResult?: (childId: ContinuableChildId, messageId: ContinuableMessageId) => Promise<SubagentResultLike | null>
291
+ }): Promise<ContinuableDelegationLike> {
292
+ const { subagents, provider, label, prompt, parent, toolFilter, maxDepth } = input
293
+ const maxRounds = input.maxRounds ?? 3
294
+ const readVerdict = input.readVerdict ?? readVerdictFromStructured
295
+ const readRepair = input.readRepair ?? readRepairFromStructured
296
+
297
+ const startContinuable = subagents?.startContinuable
298
+ const followup = subagents?.followup
299
+ // A continuable loop MUST observe the child's real settlement AND hold the
300
+ // exact live parent Agent (the live service authorizes followup by object
301
+ // identity). When either is missing, fall back to one-shot (which returns the
302
+ // actual result) rather than fabricating authority and silently APPROVE-ing.
303
+ const hasContinuableSeam = startContinuable !== undefined && followup !== undefined && input.awaitRoundResult !== undefined
304
+ if (!hasContinuableSeam || subagents === undefined || parent === undefined) {
305
+ // Fall back to one-shot delegation (self-audit-safe): never silently drop.
306
+ try {
307
+ const oneShot = await delegate({
308
+ subagents,
309
+ provider,
310
+ request: {
311
+ prompt: [{ type: 'text', text: prompt }],
312
+ label,
313
+ toolFilter,
314
+ maxDepth,
315
+ parent,
316
+ },
317
+ })
318
+ const verdict = readVerdict(oneShot)
319
+ return {
320
+ ok: true,
321
+ rounds: [{ text: prompt, result: oneShot }],
322
+ accepted: verdict === 'APPROVE' && evaluateDelegationResult(oneShot).accepted,
323
+ fellBackToOneShot: true,
324
+ }
325
+ } catch (err) {
326
+ return { ok: false, reason: err instanceof Error ? err.message : String(err), rounds: [], accepted: false, fellBackToOneShot: true }
327
+ }
328
+ }
329
+
330
+ const messageIds: ContinuableMessageId[] = []
331
+ const rounds: ContinuableRoundLike[] = []
332
+ let childId: ContinuableChildId | undefined
333
+
334
+ const request: ContinuableStartSpecLike['request'] = {
335
+ prompt: [{ type: 'text', text: prompt }],
336
+ parent,
337
+ }
338
+ if (toolFilter !== undefined) request.toolFilter = toolFilter
339
+ if (maxDepth !== undefined) request.maxDepth = maxDepth
340
+
341
+ const spec: ContinuableStartSpecLike = {
342
+ provider,
343
+ label,
344
+ request,
345
+ }
346
+ if (input.childId !== undefined) spec.childId = input.childId
347
+ try {
348
+ const started = await startContinuable(spec)
349
+ childId = started.childId
350
+ messageIds.push(started.messageId)
351
+ rounds.push({ text: prompt })
352
+
353
+ for (let round = 0; round < maxRounds; round += 1) {
354
+ const current = rounds[round]
355
+ const observed = await input.awaitRoundResult!(childId, messageIds[messageIds.length - 1])
356
+ if (observed === null) {
357
+ return { ok: false, reason: 'continuable child produced no settlement for round ' + (round + 1), childId, messageIds, rounds, accepted: false }
358
+ }
359
+ current.result = observed
360
+ const verdict = readVerdict(observed)
361
+ if (verdict !== 'REVISE') {
362
+ const accepted = verdict === 'APPROVE' && evaluateDelegationResult(observed).accepted
363
+ return {
364
+ ok: accepted,
365
+ reason: accepted ? 'delegation completed' : 'delegation stopped with verdict ' + verdict,
366
+ childId,
367
+ messageIds,
368
+ rounds,
369
+ accepted,
370
+ }
371
+ }
372
+ // REVISE: send the repair instruction to the SAME child (FIFO, context retained).
373
+ const repair = readRepair(observed)
374
+ if (!repair) {
375
+ return { ok: false, reason: 'REVISE verdict without a repair instruction', childId, messageIds, rounds, accepted: false }
376
+ }
377
+ const followupId = await followup(
378
+ parent,
379
+ childId,
380
+ [{ type: 'text', text: repair }],
381
+ { source: { kind: 'coordinator', form: 'relay', senderSessionId: parent.id ?? '' } },
382
+ )
383
+ messageIds.push(followupId)
384
+ current.revise = true
385
+ current.repair = repair
386
+ rounds.push({ text: repair })
387
+ }
388
+ return { ok: false, reason: 'max rounds reached without an APPROVE', childId, messageIds, rounds, accepted: false }
389
+ } catch (err) {
390
+ const message = err instanceof Error ? err.message : String(err)
391
+ // Failure preserves the child (a later followup may still resume it); the
392
+ // kill switch is explicit (interruptContinuable), never implicit.
393
+ return { ok: false, reason: message, childId, messageIds, rounds, accepted: false }
394
+ }
395
+ }
396
+
397
+ /**
398
+ * T4 kill switch: interrupt one live continuable child's current turn. Admission
399
+ * is synchronous, the effect asynchronous, and the child's pending inbox is
400
+ * preserved (keepInbox semantics) — a followup later resumes the parked queue.
401
+ */
402
+ export function interruptContinuable(
403
+ subagents: SubagentsRuntimeLike,
404
+ childId: ContinuableChildId,
405
+ parentSessionId: string,
406
+ ): ContinuableOpResult {
407
+ const interrupt = subagents?.interrupt
408
+ if (interrupt === undefined) {
409
+ return { ok: false, reason: 'no continuable interrupt seam' }
410
+ }
411
+ try {
412
+ interrupt(childId, { kind: 'user', parentSessionId })
413
+ return { ok: true }
414
+ } catch (err) {
415
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
416
+ }
417
+ }
418
+
419
+ /**
420
+ * T4 closeout: release one continuable child (host drains its Activation and
421
+ * disposes its handle). No-op when the seam lacks the method (one-shot hosts).
422
+ */
423
+ export async function drainContinuableChildren(
424
+ subagents: SubagentsRuntimeLike,
425
+ parent: SubagentParentHandle,
426
+ childIds: readonly ContinuableChildId[],
427
+ ): Promise<ContinuableOpResult> {
428
+ if (subagents?.drainContinuableChildren === undefined || childIds.length === 0) return { ok: true }
429
+ try {
430
+ await subagents.drainContinuableChildren(parent, childIds)
431
+ return { ok: true }
432
+ } catch (err) {
433
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
434
+ }
435
+ }
436
+
437
+ /**
438
+ * T4 closeout (host teardown path): release every continuable descendant below
439
+ * the given live parents (mirrors the live `drainContinuableDescendants`).
440
+ * No-op when the seam lacks the method; the host owns this at session teardown.
441
+ */
442
+ export async function drainContinuableDescendants(
443
+ subagents: SubagentsRuntimeLike | null,
444
+ parents: readonly SubagentParentHandle[],
445
+ ): Promise<ContinuableOpResult> {
446
+ const drain = subagents?.drainContinuableDescendants
447
+ if (drain === undefined || parents.length === 0) return { ok: true }
448
+ try {
449
+ await drain(parents)
450
+ return { ok: true }
451
+ } catch (err) {
452
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
453
+ }
454
+ }
455
+
119
456
  export interface Reference {
120
457
  path: string
121
458
  lineRange?: string
122
459
  }
123
-
124
460
  export interface ReferenceCheck {
125
461
  ok: boolean
126
462
  failures: string[]
@@ -46,7 +46,7 @@ const LOCK_TOOL_NAMES = new Set(['recursive_lock', 'recursive_lock_phase'])
46
46
  * Pure predicate: inspects the pending tool execution (name + args) against
47
47
  * the run tree under the given worktree root.
48
48
  */
49
- export type ToolGuardDecision = { kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string }
49
+ export type ToolGuardDecision = { kind: 'allow'; warn?: string } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string }
50
50
 
51
51
  export interface ToolExecLike {
52
52
  name: string
@@ -108,6 +108,21 @@ export function evaluateToolGuard(
108
108
  return { kind: 'allow' }
109
109
  }
110
110
 
111
+ /**
112
+ * T6 (approval ask→policy bridge): an `ask` decision must never be a silent
113
+ * allow. Under `strict` it coerces to `deny`; under `advisory` it stays `allow`
114
+ * but flags a `warn` so the caller never lets it through unlogged. Non-ask
115
+ * decisions pass through unchanged.
116
+ */
117
+ export function coerceAskToDecision(decision: ToolGuardDecision, mode: EnforcementMode = 'advisory'): ToolGuardDecision {
118
+ if (decision.kind !== 'ask') return decision
119
+ if (mode === 'strict') {
120
+ return { kind: 'deny', reason: decision.reason ?? 'ask under strict enforcement denies' }
121
+ }
122
+ // advisory: allow, but carry the warning so the caller logs (never silent).
123
+ return { kind: 'allow', warn: decision.reason ?? 'ask under advisory enforcement allows' }
124
+ }
125
+
111
126
  /** Resolve a tool-target path to an absolute path under the worktree root. */
112
127
  function resolveTargetPath(target: string, worktreeRoot: string): string | null {
113
128
  const normalized = target.replace(/\\/g, '/')