sortie-dogs 0.6.0 → 0.7.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.
package/README.md CHANGED
@@ -1,9 +1,13 @@
1
1
  # Sortie-dogs
2
2
 
3
- **Give OpenCode a task; get a bounded, validated implementation loop instead of an open-ended agent run.**
4
-
5
- > **Project status: Experimental / unsupported.** No stability, compatibility,
6
- > or support guarantees are provided. Mk2A2 remains the canonical internal workflow.
3
+ **Give OpenCode a task; get a bounded, validated implementation loop instead of an open-ended agent run.**
4
+
5
+ Sortie-dogs is an execution harness for OpenCode, applying harness-engineering
6
+ principles through mechanical write boundaries, validation, review, and bounded
7
+ orchestration.
8
+
9
+ > **Project status: Beta.** Stable in regular use. As a pre-1.0 release,
10
+ > configuration and runtime assets may still change between releases.
7
11
 
8
12
  [![npm](https://img.shields.io/npm/v/sortie-dogs)](https://www.npmjs.com/package/sortie-dogs)
9
13
  [![license](https://img.shields.io/npm/l/sortie-dogs)](LICENSE)
@@ -20,7 +24,7 @@ Requirements: Node.js 22.6 or newer, npm, and OpenCode.
20
24
 
21
25
  Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
22
26
 
23
- Release: [v0.6.0](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.6.0)
27
+ Release: [v0.7.0](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.7.0)
24
28
 
25
29
  ## Quick start
26
30
 
@@ -151,7 +155,8 @@ Optional settings in `.opencode/sortie-dogs.json`:
151
155
  "continuation": { "enabled": true, "maxAutoContinues": 10 },
152
156
  "reflection": {
153
157
  "enabled": false,
154
- "layers": { "run": true, "project": true, "global": false }
158
+ "layers": { "run": true, "project": true, "global": false },
159
+ "maxInjectedTokens": 500
155
160
  }
156
161
  }
157
162
  ```
@@ -201,6 +206,10 @@ global file for durable global settings.
201
206
  to enabled after opt-in; the cross-project global storage layer remains
202
207
  disabled unless explicitly enabled. Child and non-coordinator sessions fail
203
208
  closed, and `SORTIE_REFLECTION=0` is an immediate kill switch. The coordinator
209
+ injects the governing `REFLECTION_POLICY` only while reflection is enabled.
210
+ `maxInjectedTokens` budgets the dynamic `SORTIE_PROCESS_REFLECTIONS` heading
211
+ and persisted entry lines; the policy is outside that entry budget.
212
+ The coordinator
204
213
  evaluates it only after a resolved blocker/review defect and at a terminal
205
214
  unit, with a maximum of three records per run; routine bugs and external
206
215
  failures are never journaled.
@@ -2,5 +2,5 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export declare const RUNTIME_ASSET_VERSION = "0.3.49-run-metrics-v1";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.51-state-observability-v1";
6
6
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION;
@@ -2,4 +2,4 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export const RUNTIME_ASSET_VERSION = "0.3.49-run-metrics-v1";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.51-state-observability-v1";
@@ -0,0 +1,54 @@
1
+ export declare const RETAINED_STATE_EXTENSION: "sortie-dogs/retained-state";
2
+ export declare const RETAINED_STATE_SCHEMA_VERSION: "0.1";
3
+ export declare const RETAINED_STATE_AUTHORITY: "shadow";
4
+ export declare const MAX_RETAINED_STATE_BYTES: number;
5
+ export declare const MAX_RETAINED_STATE_ITEMS = 12;
6
+ export declare const MAX_RETAINED_STATE_WARNINGS = 8;
7
+ export interface RetainedValidationAttempt {
8
+ readonly command: string;
9
+ readonly exit: number | null;
10
+ readonly fingerprint: string;
11
+ }
12
+ export interface NextEvidenceDecision {
13
+ readonly schema_version: "0.1";
14
+ readonly authority: "shadow";
15
+ readonly gap_id: string;
16
+ readonly blocked_acceptance: string;
17
+ readonly question: string;
18
+ readonly expected_discrimination: string;
19
+ readonly action: string;
20
+ readonly stop_condition: string;
21
+ }
22
+ export interface AdmissionReceipt {
23
+ readonly evidence_id: string;
24
+ readonly source_agent: string;
25
+ readonly source_revision: string;
26
+ readonly evidence_fingerprint: string;
27
+ readonly supports: readonly string[];
28
+ readonly contradicts: readonly string[];
29
+ readonly freshness_basis: string;
30
+ readonly status: "recorded" | "recorded_with_warnings";
31
+ readonly warnings: readonly string[];
32
+ }
33
+ export interface RetainedStateCapsule {
34
+ readonly schema_version: "0.1";
35
+ readonly authority: "shadow";
36
+ readonly task_id: string;
37
+ readonly acceptance_fingerprint: string;
38
+ readonly source_manifest: "none" | readonly string[];
39
+ readonly operation_manifest: "none" | string;
40
+ readonly validation_history: readonly RetainedValidationAttempt[];
41
+ readonly blockers: readonly string[];
42
+ readonly next_action: string;
43
+ readonly next_evidence_decision?: NextEvidenceDecision;
44
+ readonly admissions?: readonly AdmissionReceipt[];
45
+ }
46
+ export interface RetainedStateWarning {
47
+ readonly code: "malformed" | "oversize" | "optional_invalid";
48
+ readonly message: string;
49
+ }
50
+ export interface RetainedStateInspection {
51
+ readonly capsule: RetainedStateCapsule | undefined;
52
+ readonly warnings: readonly RetainedStateWarning[];
53
+ }
54
+ export declare function inspectRetainedStateCapsule(handoff: unknown): RetainedStateInspection;
@@ -0,0 +1,81 @@
1
+ export const RETAINED_STATE_EXTENSION = "sortie-dogs/retained-state";
2
+ export const RETAINED_STATE_SCHEMA_VERSION = "0.1";
3
+ export const RETAINED_STATE_AUTHORITY = "shadow";
4
+ export const MAX_RETAINED_STATE_BYTES = 16 * 1024;
5
+ export const MAX_RETAINED_STATE_ITEMS = 12;
6
+ export const MAX_RETAINED_STATE_WARNINGS = 8;
7
+ const MAX_SHORT = 256;
8
+ const MAX_TEXT = 1000;
9
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10
+ const isText = (value, max = MAX_TEXT) => typeof value === "string" && value.length > 0 && value.length <= max;
11
+ const isBoundedList = (value, max = MAX_RETAINED_STATE_ITEMS) => Array.isArray(value) && value.length <= max;
12
+ const isSourceManifest = (value) => value === "none" || (isBoundedList(value) && value.length === new Set(value).size && value.every((item) => isText(item, 512)));
13
+ function validAttempt(value) {
14
+ const exit = isObject(value) ? value.exit : undefined;
15
+ return isObject(value) && isText(value.command, 1000) && (exit === null || (typeof exit === "number" && Number.isInteger(exit) && exit >= -1 && exit <= 255)) && isText(value.fingerprint, MAX_SHORT);
16
+ }
17
+ function validDecision(value) {
18
+ return isObject(value) && value.schema_version === "0.1" && value.authority === "shadow" &&
19
+ isText(value.gap_id, MAX_SHORT) && isText(value.blocked_acceptance) && isText(value.question) &&
20
+ isText(value.expected_discrimination) && isText(value.action) && isText(value.stop_condition);
21
+ }
22
+ function validAdmission(value) {
23
+ return isObject(value) && isText(value.evidence_id, MAX_SHORT) && isText(value.source_agent, MAX_SHORT) &&
24
+ isText(value.source_revision, MAX_SHORT) && isText(value.evidence_fingerprint, MAX_SHORT) &&
25
+ isBoundedList(value.supports) && value.supports.every((item) => isText(item, MAX_SHORT)) &&
26
+ isBoundedList(value.contradicts) && value.contradicts.every((item) => isText(item, MAX_SHORT)) &&
27
+ isText(value.freshness_basis) && (value.status === "recorded" || value.status === "recorded_with_warnings") &&
28
+ isBoundedList(value.warnings, MAX_RETAINED_STATE_WARNINGS) && value.warnings.every((item) => isText(item, MAX_SHORT));
29
+ }
30
+ export function inspectRetainedStateCapsule(handoff) {
31
+ if (!isObject(handoff) || !isObject(handoff.ext) || !(RETAINED_STATE_EXTENSION in handoff.ext)) {
32
+ return { capsule: undefined, warnings: [] };
33
+ }
34
+ const raw = handoff.ext[RETAINED_STATE_EXTENSION];
35
+ let bytes;
36
+ try {
37
+ bytes = new TextEncoder().encode(JSON.stringify(raw)).byteLength;
38
+ }
39
+ catch {
40
+ return { capsule: undefined, warnings: [{ code: "malformed", message: "Retained state was ignored because it is malformed." }] };
41
+ }
42
+ if (bytes > MAX_RETAINED_STATE_BYTES)
43
+ return { capsule: undefined, warnings: [{ code: "oversize", message: "Retained state was ignored because it exceeds its bound." }] };
44
+ if (!isObject(raw) || raw.schema_version !== "0.1" || raw.authority !== "shadow" || !isText(raw.task_id, MAX_SHORT) ||
45
+ !isText(raw.acceptance_fingerprint, MAX_SHORT) || !isSourceManifest(raw.source_manifest) ||
46
+ !(raw.operation_manifest === "none" || isText(raw.operation_manifest, 512)) ||
47
+ !isBoundedList(raw.validation_history) || !raw.validation_history.every(validAttempt) ||
48
+ !isBoundedList(raw.blockers) || !raw.blockers.every((item) => isText(item)) || !isText(raw.next_action)) {
49
+ return { capsule: undefined, warnings: [{ code: "malformed", message: "Retained state was ignored because it is malformed." }] };
50
+ }
51
+ const capsule = {
52
+ schema_version: "0.1",
53
+ authority: "shadow",
54
+ task_id: raw.task_id,
55
+ acceptance_fingerprint: raw.acceptance_fingerprint,
56
+ source_manifest: structuredClone(raw.source_manifest),
57
+ operation_manifest: raw.operation_manifest,
58
+ validation_history: structuredClone(raw.validation_history),
59
+ blockers: structuredClone(raw.blockers),
60
+ next_action: raw.next_action,
61
+ };
62
+ const warnings = [];
63
+ if ("next_evidence_decision" in raw) {
64
+ if (validDecision(raw.next_evidence_decision))
65
+ capsule.next_evidence_decision = structuredClone(raw.next_evidence_decision);
66
+ else
67
+ warnings.push({ code: "optional_invalid", message: "An optional retained-state decision was ignored." });
68
+ }
69
+ if ("admissions" in raw) {
70
+ if (isBoundedList(raw.admissions)) {
71
+ const validAdmissions = raw.admissions.filter(validAdmission);
72
+ if (validAdmissions.length > 0 || raw.admissions.length === 0)
73
+ capsule.admissions = structuredClone(validAdmissions);
74
+ if (validAdmissions.length !== raw.admissions.length)
75
+ warnings.push({ code: "optional_invalid", message: "Invalid retained-state admission receipts were ignored." });
76
+ }
77
+ else
78
+ warnings.push({ code: "optional_invalid", message: "Optional retained-state admission receipts were ignored." });
79
+ }
80
+ return { capsule, warnings };
81
+ }
package/dist/index.d.ts CHANGED
@@ -19,3 +19,5 @@ export type { HandoffDenialReason, OpenCodeEvent, OpenCodeHooks, OpenCodePlugin,
19
19
  export { CONSULTATION_CAPABILITIES, CONSULTATION_ROLE_POLICY, MAX_REVIEW_ARTIFACT_BYTES, SOURCE_REVIEW_RISK_TAGS, STRATEGY_TRIGGERS, evaluateReviewAvailability, evaluateReviewGate, evaluateSourceReviewRequirement, isSourceReviewRiskTag, requiresSourceReview, shouldConsultStrategy, validateReviewArtifact, validateReviewVerdict, } from "./core/consultation.js";
20
20
  export type { ConsultationAdapter, ConsultationCapability, ConsultationRequest, ConsultationResult, ReviewArtifact, ReviewAvailability, ReviewFinding, ReviewFindingSeverity, ReviewGateInput, ReviewGateResult, ReviewVerdict, ReviewVerdictKind, SourceReviewConsultationRequest, SourceReviewConsultationResult, SourceReviewRequirement, SourceReviewRequirementInput, SourceReviewRiskTag, StrategyConsultationRequest, StrategyConsultationResult, StrategyTrigger, StrategyTriggerInput, UnavailableConsultationResult, ValidationResult, } from "./core/consultation.js";
21
21
  export type * from "./core/types.js";
22
+ export { inspectRetainedStateCapsule, MAX_RETAINED_STATE_BYTES, MAX_RETAINED_STATE_ITEMS, MAX_RETAINED_STATE_WARNINGS, RETAINED_STATE_AUTHORITY, RETAINED_STATE_EXTENSION, RETAINED_STATE_SCHEMA_VERSION, } from "./core/retained-state.js";
23
+ export type { AdmissionReceipt, NextEvidenceDecision, RetainedStateCapsule, RetainedStateInspection, RetainedStateWarning, RetainedValidationAttempt, } from "./core/retained-state.js";
package/dist/index.js CHANGED
@@ -15,3 +15,4 @@ export { SortieDogsPlugin } from "./plugin/index.js";
15
15
  */
16
16
  export { HandoffDeniedError, InvalidModelTargetError, ModelRoutingDeniedError, isExplicitTaskHandoff, } from "./plugin/index.js";
17
17
  export { CONSULTATION_CAPABILITIES, CONSULTATION_ROLE_POLICY, MAX_REVIEW_ARTIFACT_BYTES, SOURCE_REVIEW_RISK_TAGS, STRATEGY_TRIGGERS, evaluateReviewAvailability, evaluateReviewGate, evaluateSourceReviewRequirement, isSourceReviewRiskTag, requiresSourceReview, shouldConsultStrategy, validateReviewArtifact, validateReviewVerdict, } from "./core/consultation.js";
18
+ export { inspectRetainedStateCapsule, MAX_RETAINED_STATE_BYTES, MAX_RETAINED_STATE_ITEMS, MAX_RETAINED_STATE_WARNINGS, RETAINED_STATE_AUTHORITY, RETAINED_STATE_EXTENSION, RETAINED_STATE_SCHEMA_VERSION, } from "./core/retained-state.js";
@@ -7,6 +7,7 @@ export interface ReflectionConfiguration {
7
7
  readonly global: boolean;
8
8
  };
9
9
  readonly maxInjectedEntries: number;
10
+ /** Token budget for the dynamic SORTIE_PROCESS_REFLECTIONS heading and persisted entry lines; excludes the governing REFLECTION_POLICY. */
10
11
  readonly maxInjectedTokens: number;
11
12
  }
12
13
  export type ReflectionPolicyInput = Partial<Omit<ReflectionConfiguration, "layers">> & {
@@ -170,10 +170,21 @@ export interface ContinuationHooks {
170
170
  sessionIdle(sessionID: string): Promise<void>;
171
171
  forgetSession(sessionID: string): void;
172
172
  }
173
+ export type ContinuationTransitionType = "continuation.queued" | "continuation.compacted" | "continuation.resumed" | "continuation.not_required";
174
+ export type ContinuationTransitionReason = "continuation-requested" | "compaction-only" | "summarize-accepted" | "prompt-accepted" | "terminal-checkpoint";
175
+ export interface ContinuationTransition {
176
+ readonly type: ContinuationTransitionType;
177
+ readonly sessionID: string;
178
+ readonly epoch: number;
179
+ readonly reason: ContinuationTransitionReason;
180
+ readonly attempts: number;
181
+ readonly resumeAttempts: number;
182
+ }
183
+ export type ContinuationTransitionObserver = (transition: ContinuationTransition) => void;
173
184
  /**
174
185
  * Build the continuation runtime. Every hook fails closed: an unreadable session, an absent client,
175
186
  * or a rejected identity leaves the coordinator exactly where it was, with manual continuation
176
187
  * still available to the user.
177
188
  */
178
- export declare function createContinuationHooks(client: ContinuationClient | undefined, directory: string, policySource: ContinuationPolicySource, timings?: ContinuationTimings, localIdentity?: LocalIdentitySource): ContinuationHooks;
189
+ export declare function createContinuationHooks(client: ContinuationClient | undefined, directory: string, policySource: ContinuationPolicySource, timings?: ContinuationTimings, localIdentity?: LocalIdentitySource, transitionObserver?: ContinuationTransitionObserver): ContinuationHooks;
179
190
  export {};
@@ -214,9 +214,38 @@ function sessionInfo(response) {
214
214
  * or a rejected identity leaves the coordinator exactly where it was, with manual continuation
215
215
  * still available to the user.
216
216
  */
217
- export function createContinuationHooks(client, directory, policySource, timings = DEFAULT_TIMINGS, localIdentity) {
217
+ export function createContinuationHooks(client, directory, policySource, timings = DEFAULT_TIMINGS, localIdentity, transitionObserver) {
218
218
  const sessions = new Map();
219
219
  const warned = new Set();
220
+ function observeTransition(type, sessionID, state, reason) {
221
+ try {
222
+ transitionObserver?.({
223
+ type,
224
+ sessionID,
225
+ epoch: state.rolloverEpoch,
226
+ reason,
227
+ attempts: state.attempts,
228
+ resumeAttempts: state.resumeAttempts,
229
+ });
230
+ }
231
+ catch {
232
+ // Lifecycle telemetry is best effort and must never affect continuation.
233
+ }
234
+ }
235
+ function observeResumed(sessionID, state, epoch) {
236
+ if (state.compactedTransitionEpoch !== epoch || state.resumedTransitionEpoch === epoch)
237
+ return;
238
+ state.resumedTransitionEpoch = epoch;
239
+ observeTransition("continuation.resumed", sessionID, state, "prompt-accepted");
240
+ }
241
+ function observeCompacted(sessionID, state, epoch) {
242
+ if (state.compactedTransitionEpoch === epoch)
243
+ return;
244
+ state.compactedTransitionEpoch = epoch;
245
+ observeTransition("continuation.compacted", sessionID, state, "summarize-accepted");
246
+ if (state.resumeIssuedEpoch === epoch)
247
+ observeResumed(sessionID, state, epoch);
248
+ }
220
249
  /**
221
250
  * A rollover that cannot start is otherwise indistinguishable from a coordinator that never asked
222
251
  * for one, so every abort names its own reason exactly once per session.
@@ -374,7 +403,12 @@ export function createContinuationHooks(client, directory, policySource, timings
374
403
  const entries = [];
375
404
  for (const { index, line } of topLevelProtocolLines(text)) {
376
405
  const explicit = /^status:\s*(DONE|BLOCKED|NEED_DECISION)\b/iu.exec(line)?.[1]?.toUpperCase();
377
- const status = explicit ??
406
+ const aliasMatch = /^([✅⛔❓])[ \t]+conclusion:\s*status:\s*(DONE|BLOCKED|NEED_DECISION)\b/iu.exec(line);
407
+ const aliasStatus = aliasMatch?.[2]?.toUpperCase();
408
+ const alias = aliasStatus === "DONE" && aliasMatch?.[1] === "✅" ? "DONE" :
409
+ aliasStatus === "BLOCKED" && aliasMatch?.[1] === "⛔" ? "BLOCKED" :
410
+ aliasStatus === "NEED_DECISION" && aliasMatch?.[1] === "❓" ? "NEED_DECISION" : undefined;
411
+ const status = explicit ?? alias ??
378
412
  (/^✅[ \t]+\*\*DONE\*\*/u.test(line) ? "DONE" :
379
413
  /^⛔[ \t]+\*\*BLOCKED\*\*/u.test(line) ? "BLOCKED" :
380
414
  /^❓[ \t]+\*\*NEED_DECISION\*\*/u.test(line) ? "NEED_DECISION" : undefined);
@@ -384,15 +418,21 @@ export function createContinuationHooks(client, directory, policySource, timings
384
418
  }
385
419
  return entries;
386
420
  }
421
+ function firstCheckpoint(text) {
422
+ const firstLine = topLevelProtocolLines(text).find(({ line }) => line.trim().length > 0);
423
+ return firstLine === undefined
424
+ ? undefined
425
+ : checkpointEntries(text).find(({ index }) => index === firstLine.index);
426
+ }
387
427
  function checkpointStatus(text) {
388
- return checkpointEntries(text).at(-1)?.status;
428
+ return firstCheckpoint(text)?.status;
389
429
  }
390
430
  function terminalCheckpoint(text) {
391
431
  const status = checkpointStatus(text);
392
432
  return status === "DONE" || status === "NEED_DECISION" || (status === "BLOCKED" && trueBlockerReport(text));
393
433
  }
394
434
  function trueBlockerReport(text) {
395
- const checkpoint = checkpointEntries(text).at(-1);
435
+ const checkpoint = firstCheckpoint(text);
396
436
  if (checkpoint?.status !== "BLOCKED")
397
437
  return false;
398
438
  return topLevelProtocolLines(text).some(({ line, index }) => index > checkpoint.index &&
@@ -443,7 +483,7 @@ export function createContinuationHooks(client, directory, policySource, timings
443
483
  function observeRecoveryReport(state, report) {
444
484
  if (state.recoveryObservedRevision === state.turnRevision)
445
485
  return state.recoveryRepeatCount;
446
- const checkpoint = checkpointEntries(report).at(-1);
486
+ const checkpoint = firstCheckpoint(report);
447
487
  const checkpointLine = checkpoint === undefined
448
488
  ? undefined
449
489
  : topLevelProtocolLines(report).find(({ index }) => index === checkpoint.index)?.line;
@@ -532,6 +572,7 @@ export function createContinuationHooks(client, directory, policySource, timings
532
572
  state.recoverySummaryValidated = false;
533
573
  // The accepted prompt now belongs to the host loop, not this rollover request.
534
574
  state.active = false;
575
+ observeResumed(sessionID, state, epoch);
535
576
  return true;
536
577
  }
537
578
  catch (error) {
@@ -608,6 +649,7 @@ export function createContinuationHooks(client, directory, policySource, timings
608
649
  state.promptPending = false;
609
650
  state.compactedRollover = true;
610
651
  state.lastRollover = Date.now();
652
+ observeCompacted(sessionID, state, operationEpoch);
611
653
  }
612
654
  if (state.resumeIssuingEpoch === operationEpoch ||
613
655
  state.resumeIssuedEpoch === operationEpoch) {
@@ -694,6 +736,7 @@ export function createContinuationHooks(client, directory, policySource, timings
694
736
  if (resume && countAttempt)
695
737
  state.attempts += 1;
696
738
  const epoch = state.rolloverEpoch;
739
+ observeTransition("continuation.queued", sessionID, state, resume ? "continuation-requested" : "compaction-only");
697
740
  // session.idle can be lost when a one-shot CLI host exits. Keep this zero-delay timer referenced
698
741
  // so the rollover reaches the host after the current plugin hook returns but before process exit.
699
742
  setTimeout(async () => {
@@ -835,8 +878,11 @@ export function createContinuationHooks(client, directory, policySource, timings
835
878
  * One-shot CLI hosts can exit as soon as the compaction assistant finishes, before the
836
879
  * compacted event or summarize response. Its text-complete hook is the last awaited boundary
837
880
  * where the summary message already exists and a resume prompt can still join the same loop.
838
- */
881
+ */
839
882
  if (ownedCompactionSummary || trimmed.startsWith(ROLLOVER_TOKEN)) {
883
+ if (ownedCompactionSummary) {
884
+ observeCompacted(input.sessionID, state, state.rolloverEpoch);
885
+ }
840
886
  await arbitrateResume(input.sessionID, state);
841
887
  if (state.resumeIssuedEpoch === state.rolloverEpoch)
842
888
  return;
@@ -850,6 +896,12 @@ export function createContinuationHooks(client, directory, policySource, timings
850
896
  clearTimer(state.stepRecoveryTimer);
851
897
  state.stepRecoveryTimer = undefined;
852
898
  resetRecoveryStall(state);
899
+ if (state.notRequiredRevision !== state.turnRevision &&
900
+ !output.text.includes(ROLLOVER_MARKER) &&
901
+ !output.text.includes(CONTINUATION_MARKER)) {
902
+ state.notRequiredRevision = state.turnRevision;
903
+ observeTransition("continuation.not_required", input.sessionID, state, "terminal-checkpoint");
904
+ }
853
905
  }
854
906
  if (batchCheckpointNeedsContinuation(state.latestCoordinatorReport)) {
855
907
  if (input.allowCheckpointContinuation === false) {
@@ -951,8 +1003,11 @@ export function createContinuationHooks(client, directory, policySource, timings
951
1003
  },
952
1004
  async sessionCompacted(sessionID) {
953
1005
  const state = sessions.get(sessionID);
954
- if (state !== undefined)
1006
+ if (state?.pendingRollover === true &&
1007
+ (state.active || state.promptPending || state.compactingEpoch === state.rolloverEpoch)) {
1008
+ observeCompacted(sessionID, state, state.rolloverEpoch);
955
1009
  await arbitrateResume(sessionID, state);
1010
+ }
956
1011
  },
957
1012
  async compactionAutoContinue(input, output) {
958
1013
  const state = sessions.get(input.sessionID);
@@ -10,6 +10,19 @@ export interface OpenCodePluginInput {
10
10
  worktree?: string;
11
11
  /** The host SDK client. Absent in hosts that construct the plugin without one. */
12
12
  client?: SessionMessageReader & RunMetricsClient & ContinuationClient & OpenCodeModelAvailabilityClient & {
13
+ app?: {
14
+ log?: (request: {
15
+ body: {
16
+ service: string;
17
+ level: "debug" | "info" | "error" | "warn";
18
+ message: string;
19
+ extra?: Record<string, unknown>;
20
+ };
21
+ query?: {
22
+ directory?: string;
23
+ };
24
+ }) => unknown;
25
+ };
13
26
  tui?: {
14
27
  showToast?: (request: {
15
28
  body: {