sortie-dogs 0.3.21 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ Requirements: Node.js 22.6 or newer, npm, and OpenCode.
20
20
 
21
21
  Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
22
22
 
23
- Release: [v0.3.21](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.3.21)
23
+ Release: [v0.4.1](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.4.1)
24
24
 
25
25
  ## Quick start
26
26
 
@@ -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.6-card43";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.7-fast-lane-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.6-card43";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.7-fast-lane-v1";
@@ -0,0 +1,21 @@
1
+ export declare const BACKLOG_DRAIN_CAPABILITY = "sortie_enable_backlog_drain";
2
+ export type FastLaneDenialCode = "TURN_STATE_REQUIRED" | "ROLE_FORBIDDEN" | "WORKER_LIMIT" | "SCOUT_GAP_REQUIRED" | "SCOUT_LIMIT" | "SCOUT_TOO_LATE" | "REVIEW_EVIDENCE_REQUIRED" | "REVIEW_PHASE_INVALID" | "REVIEW_LIMIT" | "ADVISOR_TRIGGER_REQUIRED" | "ADVISOR_LIMIT" | "CONSULTATION_RETRY_INVALID" | "CONSULTATION_RETRY_UNAUTHORIZED" | "CONSULTATION_RETRY_MISMATCH" | "BACKLOG_DRAIN_INVALID" | "BACKLOG_DRAIN_TOO_LATE" | "MANUAL_COMPACTION_FORBIDDEN";
3
+ export declare class FastLaneDeniedError extends Error {
4
+ readonly code: FastLaneDenialCode;
5
+ constructor(code: FastLaneDenialCode);
6
+ }
7
+ export interface FastLaneToolOptions {
8
+ readonly consultationFallbackAuthorized?: boolean;
9
+ }
10
+ export declare class FastLaneController {
11
+ private readonly sessions;
12
+ private setSession;
13
+ beginTurn(sessionID: string, synthetic: boolean): void;
14
+ forget(sessionID: string): void;
15
+ manualCompactionForbidden(sessionID: string): boolean;
16
+ backlogDrainEnabled(sessionID: string): boolean;
17
+ backlogContinuationAllowed(sessionID: string): boolean;
18
+ enableBacklogDrain(sessionID: string, maxUnits: number): void;
19
+ continuationQueued(sessionID: string): void;
20
+ beforeTool(sessionID: string, tool: string, args: unknown, options?: FastLaneToolOptions): void;
21
+ }
@@ -0,0 +1,230 @@
1
+ import { isSourceReviewRiskTag, STRATEGY_TRIGGERS } from "../core/consultation.js";
2
+ const MAX_SESSIONS = 256;
3
+ const GAP_CODES = new Set(["manifest", "validation", "owner-risk"]);
4
+ const STRATEGY_TRIGGER_SET = new Set(STRATEGY_TRIGGERS);
5
+ const MANUAL_COMPACTION_TOOLS = new Set(["compact_and_continue", "sortie_compact_and_continue"]);
6
+ export const BACKLOG_DRAIN_CAPABILITY = "sortie_enable_backlog_drain";
7
+ export class FastLaneDeniedError extends Error {
8
+ code;
9
+ constructor(code) {
10
+ super(`SORTIE_FAST_LANE_DENIED: ${code}`);
11
+ this.name = "FastLaneDeniedError";
12
+ this.code = code;
13
+ }
14
+ }
15
+ function taskArgument(args, key) {
16
+ if (args === null || typeof args !== "object" || Array.isArray(args))
17
+ return undefined;
18
+ const value = args[key];
19
+ return typeof value === "string" ? value : undefined;
20
+ }
21
+ function lineValue(prompt, key) {
22
+ const values = [...prompt.matchAll(new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, "gmu"))];
23
+ return values.length === 1 ? values[0][1] : undefined;
24
+ }
25
+ function hasReviewEvidence(prompt) {
26
+ if (lineValue(prompt, "canonical_validation_exit") !== "0")
27
+ return false;
28
+ const rawTags = lineValue(prompt, "risk_tags");
29
+ if (rawTags === undefined || !/^\[[^\[\]]+\]$/u.test(rawTags))
30
+ return false;
31
+ const tags = rawTags.slice(1, -1).split(",").map((tag) => tag.trim());
32
+ return tags.length > 0 && tags.every((tag) => tag.length > 0) &&
33
+ new Set(tags).size === tags.length && tags.every(isSourceReviewRiskTag);
34
+ }
35
+ function freshState() {
36
+ return {
37
+ advisorDispatches: 0,
38
+ backlogDrain: false,
39
+ continuationPending: false,
40
+ initialReviewDispatches: 0,
41
+ verificationReviewDispatches: 0,
42
+ scoutDispatches: 0,
43
+ totalWorkerDispatches: 0,
44
+ workerLimit: 1,
45
+ workerDispatches: 0,
46
+ };
47
+ }
48
+ function lockedState() {
49
+ return {
50
+ advisorDispatches: 2,
51
+ backlogDrain: false,
52
+ continuationPending: false,
53
+ initialReviewDispatches: 2,
54
+ verificationReviewDispatches: 2,
55
+ scoutDispatches: 1,
56
+ totalWorkerDispatches: 1,
57
+ workerLimit: 1,
58
+ workerDispatches: 1,
59
+ };
60
+ }
61
+ function fallbackBasis(prompt) {
62
+ return prompt.replace(/\r\n?/gu, "\n")
63
+ .split("\n")
64
+ .filter((line) => !/^\s*fallback_retry\s*:\s*true\s*$/u.test(line))
65
+ .join("\n")
66
+ .trimEnd();
67
+ }
68
+ export class FastLaneController {
69
+ sessions = new Map();
70
+ setSession(sessionID, state) {
71
+ this.sessions.delete(sessionID);
72
+ this.sessions.set(sessionID, state);
73
+ while (this.sessions.size > MAX_SESSIONS)
74
+ this.sessions.delete(this.sessions.keys().next().value);
75
+ }
76
+ beginTurn(sessionID, synthetic) {
77
+ if (synthetic) {
78
+ const state = this.sessions.get(sessionID);
79
+ if (state === undefined) {
80
+ this.setSession(sessionID, lockedState());
81
+ }
82
+ else if (state.backlogDrain && state.continuationPending) {
83
+ state.advisorDispatches = 0;
84
+ delete state.advisorPrompt;
85
+ state.continuationPending = false;
86
+ state.initialReviewDispatches = 0;
87
+ delete state.initialReviewPrompt;
88
+ state.verificationReviewDispatches = 0;
89
+ delete state.verificationReviewPrompt;
90
+ state.scoutDispatches = 0;
91
+ state.workerDispatches = 0;
92
+ }
93
+ return;
94
+ }
95
+ this.setSession(sessionID, freshState());
96
+ }
97
+ forget(sessionID) {
98
+ this.sessions.delete(sessionID);
99
+ }
100
+ manualCompactionForbidden(sessionID) {
101
+ const state = this.sessions.get(sessionID);
102
+ return state !== undefined && state.workerDispatches > 0 &&
103
+ (!state.backlogDrain || state.totalWorkerDispatches >= state.workerLimit);
104
+ }
105
+ backlogDrainEnabled(sessionID) {
106
+ return this.sessions.get(sessionID)?.backlogDrain === true;
107
+ }
108
+ backlogContinuationAllowed(sessionID) {
109
+ const state = this.sessions.get(sessionID);
110
+ return state?.backlogDrain === true && state.workerDispatches === 1 &&
111
+ state.totalWorkerDispatches < state.workerLimit && !state.continuationPending;
112
+ }
113
+ enableBacklogDrain(sessionID, maxUnits) {
114
+ const state = this.sessions.get(sessionID);
115
+ if (state === undefined)
116
+ throw new FastLaneDeniedError("TURN_STATE_REQUIRED");
117
+ if (!Number.isInteger(maxUnits) || maxUnits < 4 || maxUnits > 11) {
118
+ throw new FastLaneDeniedError("BACKLOG_DRAIN_INVALID");
119
+ }
120
+ if (state.backlogDrain || state.totalWorkerDispatches > 0) {
121
+ throw new FastLaneDeniedError("BACKLOG_DRAIN_TOO_LATE");
122
+ }
123
+ state.backlogDrain = true;
124
+ state.workerLimit = maxUnits;
125
+ }
126
+ continuationQueued(sessionID) {
127
+ const state = this.sessions.get(sessionID);
128
+ if (this.backlogContinuationAllowed(sessionID) && state !== undefined) {
129
+ state.continuationPending = true;
130
+ }
131
+ }
132
+ beforeTool(sessionID, tool, args, options = {}) {
133
+ const state = this.sessions.get(sessionID);
134
+ if (state === undefined) {
135
+ if (tool === "task" || MANUAL_COMPACTION_TOOLS.has(tool)) {
136
+ throw new FastLaneDeniedError("TURN_STATE_REQUIRED");
137
+ }
138
+ return;
139
+ }
140
+ if (MANUAL_COMPACTION_TOOLS.has(tool)) {
141
+ if (!this.backlogContinuationAllowed(sessionID)) {
142
+ throw new FastLaneDeniedError("MANUAL_COMPACTION_FORBIDDEN");
143
+ }
144
+ return;
145
+ }
146
+ if (tool !== "task")
147
+ return;
148
+ const role = taskArgument(args, "subagent_type");
149
+ const prompt = taskArgument(args, "prompt") ?? "";
150
+ if (role === "dog-worker") {
151
+ if (state.workerDispatches >= 1 || state.totalWorkerDispatches >= state.workerLimit) {
152
+ throw new FastLaneDeniedError("WORKER_LIMIT");
153
+ }
154
+ state.workerDispatches += 1;
155
+ state.totalWorkerDispatches += 1;
156
+ return;
157
+ }
158
+ if (role === "dog-scout") {
159
+ if (state.workerDispatches > 0)
160
+ throw new FastLaneDeniedError("SCOUT_TOO_LATE");
161
+ const gap = lineValue(prompt, "missing_evidence_code");
162
+ if (gap === undefined || !GAP_CODES.has(gap))
163
+ throw new FastLaneDeniedError("SCOUT_GAP_REQUIRED");
164
+ if (state.scoutDispatches >= 1)
165
+ throw new FastLaneDeniedError("SCOUT_LIMIT");
166
+ state.scoutDispatches += 1;
167
+ return;
168
+ }
169
+ if (role === "dog-reviewer") {
170
+ if (!hasReviewEvidence(prompt))
171
+ throw new FastLaneDeniedError("REVIEW_EVIDENCE_REQUIRED");
172
+ const phase = lineValue(prompt, "review_phase");
173
+ if (phase !== "initial" && phase !== "verification") {
174
+ throw new FastLaneDeniedError("REVIEW_PHASE_INVALID");
175
+ }
176
+ const retry = lineValue(prompt, "fallback_retry");
177
+ const count = phase === "initial" ? state.initialReviewDispatches : state.verificationReviewDispatches;
178
+ const previousPrompt = phase === "initial" ? state.initialReviewPrompt : state.verificationReviewPrompt;
179
+ if (phase === "initial" && state.verificationReviewDispatches > 0) {
180
+ throw new FastLaneDeniedError("REVIEW_PHASE_INVALID");
181
+ }
182
+ if (phase === "verification" && state.initialReviewDispatches === 0) {
183
+ throw new FastLaneDeniedError("REVIEW_PHASE_INVALID");
184
+ }
185
+ if ((count === 0 && retry !== undefined) || (count === 1 && retry !== "true")) {
186
+ throw new FastLaneDeniedError("CONSULTATION_RETRY_INVALID");
187
+ }
188
+ if (count === 1 && options.consultationFallbackAuthorized !== true) {
189
+ throw new FastLaneDeniedError("CONSULTATION_RETRY_UNAUTHORIZED");
190
+ }
191
+ if (count === 1 && previousPrompt !== fallbackBasis(prompt)) {
192
+ throw new FastLaneDeniedError("CONSULTATION_RETRY_MISMATCH");
193
+ }
194
+ if (count >= 2)
195
+ throw new FastLaneDeniedError("REVIEW_LIMIT");
196
+ if (phase === "initial") {
197
+ state.initialReviewPrompt ??= fallbackBasis(prompt);
198
+ state.initialReviewDispatches += 1;
199
+ }
200
+ else {
201
+ state.verificationReviewPrompt ??= fallbackBasis(prompt);
202
+ state.verificationReviewDispatches += 1;
203
+ }
204
+ return;
205
+ }
206
+ if (role === "dog-advisor") {
207
+ const trigger = lineValue(prompt, "strategy_trigger");
208
+ if (trigger === undefined || !STRATEGY_TRIGGER_SET.has(trigger)) {
209
+ throw new FastLaneDeniedError("ADVISOR_TRIGGER_REQUIRED");
210
+ }
211
+ const retry = lineValue(prompt, "fallback_retry");
212
+ if ((state.advisorDispatches === 0 && retry !== undefined) ||
213
+ (state.advisorDispatches === 1 && retry !== "true")) {
214
+ throw new FastLaneDeniedError("CONSULTATION_RETRY_INVALID");
215
+ }
216
+ if (state.advisorDispatches === 1 && options.consultationFallbackAuthorized !== true) {
217
+ throw new FastLaneDeniedError("CONSULTATION_RETRY_UNAUTHORIZED");
218
+ }
219
+ if (state.advisorDispatches === 1 && state.advisorPrompt !== fallbackBasis(prompt)) {
220
+ throw new FastLaneDeniedError("CONSULTATION_RETRY_MISMATCH");
221
+ }
222
+ if (state.advisorDispatches >= 2)
223
+ throw new FastLaneDeniedError("ADVISOR_LIMIT");
224
+ state.advisorPrompt ??= fallbackBasis(prompt);
225
+ state.advisorDispatches += 1;
226
+ return;
227
+ }
228
+ throw new FastLaneDeniedError("ROLE_FORBIDDEN");
229
+ }
230
+ }
@@ -6,10 +6,11 @@ import { normalizeRelativePath, RelativePathError } from "../core/path.js";
6
6
  import { validateManifest } from "../core/validate-manifest.js";
7
7
  import { safeSchemaPointer, validateHandoffSchema, validateOperationManifestSchema, } from "../core/validate-schema.js";
8
8
  import { DEFAULT_PLUGIN_OPTIONS, resolvePluginConfiguration, resolvePluginConfigurationSourcesWithGlobal, } from "./config.js";
9
- import { CONTINUATION_CAPABILITY, createContinuationHooks, } from "./continuation.js";
9
+ import { CONTINUATION_CAPABILITY, CONTINUATION_MARKER, ROLLOVER_MARKER, createContinuationHooks, } from "./continuation.js";
10
10
  import { WriteDeniedError, canonicalManifestReadScopes, canonicalManifestWriteScopes, createProjectPaths, createWriteGate, describeUnclassifiedCommand, isGitMutation, isKnownReadOnlyTool, isRemoteMutation, normalizeCommand, resolveProjectRoot, safePath, writeScopesOverlap, } from "./gate.js";
11
+ import { BACKLOG_DRAIN_CAPABILITY, FastLaneController } from "./fast-lane.js";
11
12
  import { createModelRoutingHook, } from "./model-routing-hook.js";
12
- import { createTaskResultRepairHook, markConsultationFallbackRetry, } from "./task-result-repair.js";
13
+ import { createTaskResultRepairHook, markConsultationFallbackRetry, taskChildSessionID, } from "./task-result-repair.js";
13
14
  import { configRoot, nearestPackageVersion, reflectionEnabled, ReflectionError, ReflectionStore } from "../reflection/index.js";
14
15
  const INPUT_LIMITS = { config: 64 * 1024, manifest: 512 * 1024, handoff: 2 * 1024 * 1024 };
15
16
  const INSPECTION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
@@ -592,6 +593,7 @@ export const SortieDogsPlugin = async (input, options) => {
592
593
  const bindingOperations = new Set();
593
594
  const activeSessions = new Map();
594
595
  const coordinatorRoots = new Map();
596
+ const coordinatorTaskCalls = new Map();
595
597
  const reflectionOwnedRoots = new Set();
596
598
  const reflectionClosingRoots = new Set();
597
599
  const reflectionInFlight = new Map();
@@ -602,6 +604,34 @@ export const SortieDogsPlugin = async (input, options) => {
602
604
  const sessionRoots = new Map();
603
605
  const consultationRetries = new Map();
604
606
  const taskResultRepair = createTaskResultRepairHook(input.client);
607
+ const fastLane = new FastLaneController();
608
+ function beginCoordinatorTask(sessionID, callID) {
609
+ const calls = coordinatorTaskCalls.get(sessionID) ?? new Set();
610
+ calls.add(callID);
611
+ coordinatorTaskCalls.set(sessionID, calls);
612
+ }
613
+ function finishCoordinatorTask(sessionID, callID) {
614
+ if (sessionID === undefined || callID === undefined)
615
+ return;
616
+ const calls = coordinatorTaskCalls.get(sessionID);
617
+ if (calls === undefined)
618
+ return;
619
+ calls.delete(callID);
620
+ if (calls.size === 0)
621
+ coordinatorTaskCalls.delete(sessionID);
622
+ }
623
+ function childHasInFlightParentTask(sessionID) {
624
+ const parentID = sessionParents.get(sessionID);
625
+ return parentID !== undefined && (coordinatorTaskCalls.get(parentID)?.size ?? 0) > 0;
626
+ }
627
+ function abortCoordinatorTasks(sessionID) {
628
+ if (!coordinatorTaskCalls.delete(sessionID))
629
+ return;
630
+ for (const [childID, parentID] of [...sessionParents]) {
631
+ if (parentID === sessionID)
632
+ evictSession(childID);
633
+ }
634
+ }
605
635
  function hasSessionEnforcementState(sessionID) {
606
636
  return sessionAuthorizations.has(sessionID) || bindingPins.has(sessionID);
607
637
  }
@@ -1325,6 +1355,47 @@ export const SortieDogsPlugin = async (input, options) => {
1325
1355
  return undefined;
1326
1356
  }
1327
1357
  }
1358
+ async function hostSessionUserTurn(sessionID) {
1359
+ const messages = input.client?.session?.messages;
1360
+ if (messages === undefined)
1361
+ return undefined;
1362
+ try {
1363
+ const response = await messages.call(input.client.session, { path: { id: sessionID } });
1364
+ const payload = isRecord(response) && "data" in response ? response.data : response;
1365
+ if (!Array.isArray(payload))
1366
+ return undefined;
1367
+ for (let index = payload.length - 1; index >= 0; index -= 1) {
1368
+ const message = payload[index];
1369
+ if ((message.info?.role ?? message.role) !== "user")
1370
+ continue;
1371
+ const agent = message.info?.agent ?? message.agent;
1372
+ if (typeof agent !== "string")
1373
+ return undefined;
1374
+ return {
1375
+ agent,
1376
+ synthetic: (message.parts ?? []).some((part) => part.synthetic === true),
1377
+ };
1378
+ }
1379
+ return undefined;
1380
+ }
1381
+ catch {
1382
+ return undefined;
1383
+ }
1384
+ }
1385
+ async function recoverCoordinatorRoot(sessionID) {
1386
+ if (isCoordinatorSession(sessionID))
1387
+ return true;
1388
+ const identity = await hostSessionIdentity(sessionID);
1389
+ if (identity === undefined || identity.parentPresent)
1390
+ return false;
1391
+ const persistedTurn = await hostSessionUserTurn(sessionID);
1392
+ if (persistedTurn?.agent !== COORDINATOR_AGENT)
1393
+ return false;
1394
+ await rememberCoordinatorRoot(sessionID);
1395
+ releaseSessionEnforcement(sessionID);
1396
+ fastLane.beginTurn(sessionID, persistedTurn?.synthetic ?? false);
1397
+ return true;
1398
+ }
1328
1399
  function consultationRetryKey(parentID, role) {
1329
1400
  return `${parentID}\u0000${role}`;
1330
1401
  }
@@ -1357,10 +1428,8 @@ export const SortieDogsPlugin = async (input, options) => {
1357
1428
  const child = await hostSessionIdentity(sessionID);
1358
1429
  if (child?.parentID === undefined)
1359
1430
  return undefined;
1360
- const parent = await hostSessionIdentity(child.parentID);
1361
- if (parent?.agent !== COORDINATOR_AGENT || parent.parentPresent)
1431
+ if (!await recoverCoordinatorRoot(child.parentID))
1362
1432
  return undefined;
1363
- await rememberCoordinatorRoot(child.parentID);
1364
1433
  rememberParent(sessionID, child.parentID);
1365
1434
  return child.parentID;
1366
1435
  }
@@ -1465,7 +1534,20 @@ export const SortieDogsPlugin = async (input, options) => {
1465
1534
  async execute(_args, context) {
1466
1535
  // A configuration failure must not remove the loop; the shipped default still resolves.
1467
1536
  await ensureLoaded().catch(() => undefined);
1468
- return await continuation.tool.execute({}, context);
1537
+ const result = await continuation.tool.execute({}, context);
1538
+ if (result === "SORTIE_COMPACT_AND_CONTINUE_QUEUED") {
1539
+ fastLane.continuationQueued(context.sessionID);
1540
+ }
1541
+ return result;
1542
+ },
1543
+ }),
1544
+ [BACKLOG_DRAIN_CAPABILITY]: defineTool({
1545
+ description: "Enable one explicit bounded backlog drain before its first worker dispatch.",
1546
+ args: { max_units: defineTool.schema.string() },
1547
+ async execute(args, context) {
1548
+ const maxUnits = Number(args.max_units);
1549
+ fastLane.enableBacklogDrain(context.sessionID, maxUnits);
1550
+ return JSON.stringify({ status: "enabled", max_units: maxUnits });
1469
1551
  },
1470
1552
  }),
1471
1553
  ...(reflectionStartup ? {
@@ -1506,7 +1588,19 @@ export const SortieDogsPlugin = async (input, options) => {
1506
1588
  } : {}),
1507
1589
  },
1508
1590
  "experimental.text.complete": async (textInput, textOutput) => {
1591
+ const backlogMarker = fastLane.backlogContinuationAllowed(textInput.sessionID) &&
1592
+ textOutput.text.includes(CONTINUATION_MARKER);
1593
+ if (fastLane.manualCompactionForbidden(textInput.sessionID)) {
1594
+ textOutput.text = textOutput.text
1595
+ .replaceAll(ROLLOVER_MARKER, "")
1596
+ .replaceAll(CONTINUATION_MARKER, "")
1597
+ .trimEnd();
1598
+ return;
1599
+ }
1509
1600
  await continuation.textComplete(textInput, textOutput);
1601
+ if (backlogMarker && continuation.blocksTool(textInput.sessionID)) {
1602
+ fastLane.continuationQueued(textInput.sessionID);
1603
+ }
1510
1604
  },
1511
1605
  "experimental.session.compacting": async (compactInput, compactOutput) => {
1512
1606
  await continuation.sessionCompacting(compactInput, compactOutput);
@@ -1516,10 +1610,12 @@ export const SortieDogsPlugin = async (input, options) => {
1516
1610
  },
1517
1611
  "chat.message": async (chatInput, output) => {
1518
1612
  const parentID = chatParentID(chatInput);
1613
+ const synthetic = output.parts.some((part) => isRecord(part) && part.synthetic === true);
1519
1614
  if (parentID !== undefined)
1520
1615
  rememberParent(chatInput.sessionID, parentID);
1521
1616
  const coordinatorOrigin = chatInput.agent === COORDINATOR_AGENT || output.message.agent === COORDINATOR_AGENT;
1522
1617
  if (coordinatorOrigin) {
1618
+ fastLane.beginTurn(chatInput.sessionID, synthetic);
1523
1619
  releaseSessionEnforcement(chatInput.sessionID);
1524
1620
  await rememberCoordinatorRoot(chatInput.sessionID);
1525
1621
  }
@@ -1566,31 +1662,36 @@ export const SortieDogsPlugin = async (input, options) => {
1566
1662
  }
1567
1663
  }
1568
1664
  if (coordinatorOrigin) {
1569
- const synthetic = output.parts.some((part) => isRecord(part) && part.synthetic === true);
1570
1665
  continuation.observeModel(chatInput.sessionID, output.message.model, synthetic);
1571
1666
  }
1572
1667
  },
1573
- ...(reflectionStartup ? { "experimental.chat.system.transform": async (transformInput, transformOutput) => {
1574
- if (!(await beginReflection(transformInput.sessionID)))
1668
+ "experimental.chat.system.transform": async (transformInput, transformOutput) => {
1669
+ if (fastLane.manualCompactionForbidden(transformInput.sessionID)) {
1670
+ transformOutput.system = [...(transformOutput.system ?? []),
1671
+ "SORTIE_FAST_LANE_TERMINAL\nA worker was already dispatched in this normal lane. " +
1672
+ "Do not call a compaction capability or emit a continuation marker. " +
1673
+ "After any required risk-based review or coordinator-owned finalization, return the terminal report and stop."];
1674
+ }
1675
+ if (!reflectionStartup || !(await beginReflection(transformInput.sessionID)))
1676
+ return;
1677
+ const config = reflectionConfiguration;
1678
+ try {
1679
+ if (!config)
1575
1680
  return;
1576
- const config = reflectionConfiguration;
1577
- try {
1578
- if (!config)
1579
- return;
1580
- const heading = "SORTIE_PROCESS_REFLECTIONS";
1581
- const buckets = ["run", "project", "global"]
1582
- .filter((layer) => config.layers[layer])
1583
- .map((layer) => ({ layer, ...(layer === "global" ? {} : { run: transformInput.sessionID }) }));
1584
- const budget = Math.max(0, config.maxInjectedTokens - Buffer.byteLength(`${heading}\n`, "utf8"));
1585
- const text = await reflectionStore.injectBuckets(buckets, config.maxInjectedEntries, budget, reflectionVersion);
1586
- if (text)
1587
- transformOutput.system = [...(transformOutput.system ?? []), `${heading}\n${text}`];
1588
- }
1589
- catch { /* reflection is strictly non-invasive */ }
1590
- finally {
1591
- endReflection(transformInput.sessionID);
1592
- }
1593
- } } : {}),
1681
+ const heading = "SORTIE_PROCESS_REFLECTIONS";
1682
+ const buckets = ["run", "project", "global"]
1683
+ .filter((layer) => config.layers[layer])
1684
+ .map((layer) => ({ layer, ...(layer === "global" ? {} : { run: transformInput.sessionID }) }));
1685
+ const budget = Math.max(0, config.maxInjectedTokens - Buffer.byteLength(`${heading}\n`, "utf8"));
1686
+ const text = await reflectionStore.injectBuckets(buckets, config.maxInjectedEntries, budget, reflectionVersion);
1687
+ if (text)
1688
+ transformOutput.system = [...(transformOutput.system ?? []), `${heading}\n${text}`];
1689
+ }
1690
+ catch { /* reflection is strictly non-invasive */ }
1691
+ finally {
1692
+ endReflection(transformInput.sessionID);
1693
+ }
1694
+ },
1594
1695
  "permission.ask": async (permission) => {
1595
1696
  if (permission.permission !== "edit")
1596
1697
  return;
@@ -1626,6 +1727,7 @@ export const SortieDogsPlugin = async (input, options) => {
1626
1727
  * erases an answer the worker already produced and the coordinator re-dispatches the same work.
1627
1728
  */
1628
1729
  "tool.execute.after": async (toolInput, output) => {
1730
+ const completedChildSessionID = toolInput.tool === "task" ? taskChildSessionID(output) : undefined;
1629
1731
  try {
1630
1732
  const repair = await taskResultRepair(toolInput, output);
1631
1733
  if (repair.kind === "unrecoverable-empty" && toolInput.sessionID !== undefined) {
@@ -1643,13 +1745,32 @@ export const SortieDogsPlugin = async (input, options) => {
1643
1745
  }
1644
1746
  finally {
1645
1747
  activeSessions.get(toolInput.sessionID ?? "")?.inFlightCalls.delete(toolInput.callID ?? "");
1748
+ if (toolInput.tool === "task")
1749
+ finishCoordinatorTask(toolInput.sessionID, toolInput.callID);
1750
+ if (completedChildSessionID !== undefined)
1751
+ evictSession(completedChildSessionID);
1646
1752
  }
1647
1753
  },
1648
1754
  "tool.execute.before": async (toolInput, output) => {
1649
- if (isCoordinatorSession(toolInput.sessionID)) {
1755
+ const coordinatorCapability = toolInput.tool === "task" ||
1756
+ toolInput.tool === CONTINUATION_CAPABILITY || toolInput.tool === BACKLOG_DRAIN_CAPABILITY;
1757
+ if (isCoordinatorSession(toolInput.sessionID) ||
1758
+ (coordinatorCapability && await recoverCoordinatorRoot(toolInput.sessionID))) {
1650
1759
  if (continuation.blocksTool(toolInput.sessionID)) {
1651
1760
  throw new Error("SORTIE_ROLLOVER_PENDING: stop this turn and wait for compaction");
1652
1761
  }
1762
+ const taskRole = isRecord(output.args) && typeof output.args.subagent_type === "string"
1763
+ ? output.args.subagent_type
1764
+ : undefined;
1765
+ const role = consultationAgent(taskRole);
1766
+ const consultationFallbackAuthorized = role !== undefined &&
1767
+ consultationRetries.get(consultationRetryKey(toolInput.sessionID, role))?.phase === "pending";
1768
+ fastLane.beforeTool(toolInput.sessionID, toolInput.tool, output.args, {
1769
+ consultationFallbackAuthorized,
1770
+ });
1771
+ if (toolInput.tool === "task" && taskRole === "dog-worker") {
1772
+ beginCoordinatorTask(toolInput.sessionID, toolInput.callID);
1773
+ }
1653
1774
  return;
1654
1775
  }
1655
1776
  const status = activeSessionStatus(toolInput.sessionID);
@@ -1747,6 +1868,8 @@ export const SortieDogsPlugin = async (input, options) => {
1747
1868
  return;
1748
1869
  }
1749
1870
  if (event.type === "session.deleted") {
1871
+ fastLane.forget(eventSessionID);
1872
+ abortCoordinatorTasks(eventSessionID);
1750
1873
  if (reflectionStore !== undefined && reflectionConfiguration?.layers.run && reflectionOwnedRoots.has(eventSessionID)) {
1751
1874
  reflectionClosingRoots.add(eventSessionID);
1752
1875
  await waitForReflections(eventSessionID);
@@ -1785,18 +1908,24 @@ export const SortieDogsPlugin = async (input, options) => {
1785
1908
  await continuation.sessionCompacted(eventSessionID);
1786
1909
  if (event.type === "session.idle")
1787
1910
  await continuation.sessionIdle(eventSessionID);
1911
+ if (event.type === "session.idle" && isCoordinatorSession(eventSessionID)) {
1912
+ abortCoordinatorTasks(eventSessionID);
1913
+ }
1788
1914
  if (!isActiveSession(eventSessionID))
1789
1915
  return;
1790
1916
  if (event.type !== "session.idle")
1791
1917
  touchActiveSession(eventSessionID);
1792
1918
  if (event.type === "session.idle" && eventSessionID !== undefined) {
1793
1919
  activeSessions.get(eventSessionID)?.inFlightCalls.clear();
1920
+ if (childHasInFlightParentTask(eventSessionID)) {
1921
+ touchActiveSession(eventSessionID);
1922
+ return;
1923
+ }
1794
1924
  const authorization = sessionAuthorizations.get(eventSessionID);
1795
1925
  if (authorization === undefined)
1796
1926
  return;
1797
1927
  try {
1798
- // Idle revalidates the pinned handoff but always releases write ownership. A resumed
1799
- // worker must bind again, so a completed serial worker cannot block later work.
1928
+ // Idle is the abnormal-exit fallback when the parent Task completion hook never arrives.
1800
1929
  await inspect(authorization.handoffPath, eventSessionID);
1801
1930
  }
1802
1931
  catch {
@@ -27,8 +27,10 @@ export interface SessionMessagePart {
27
27
  export interface SessionMessage {
28
28
  readonly info?: {
29
29
  readonly role?: unknown;
30
+ readonly agent?: unknown;
30
31
  } | undefined;
31
32
  readonly role?: unknown;
33
+ readonly agent?: unknown;
32
34
  readonly parts?: readonly SessionMessagePart[] | undefined;
33
35
  }
34
36
  /** The subset of the OpenCode SDK client this repair depends on. */
@@ -56,6 +58,7 @@ export type TaskResultRepairOutcome = {
56
58
  export declare const CONSULTATION_FALLBACK_RETRY_MARKER = "SORTIE_CONSULTATION_FALLBACK_RETRY";
57
59
  /** Assistant text the child actually produced, ignoring the empty tail that caused the defect. */
58
60
  export declare function lastAssistantText(messages: readonly SessionMessage[]): string | undefined;
61
+ export declare function taskChildSessionID(output: TaskToolExecuteOutput): string | undefined;
59
62
  /**
60
63
  * Repair only the exact defect: a completed `task` call whose result body is empty while the child
61
64
  * session holds real assistant text. Everything else is left byte-identical.
@@ -36,7 +36,7 @@ export function lastAssistantText(messages) {
36
36
  }
37
37
  return undefined;
38
38
  }
39
- function childSessionID(output) {
39
+ export function taskChildSessionID(output) {
40
40
  const metadata = output.metadata;
41
41
  if (metadata === null || typeof metadata !== "object")
42
42
  return undefined;
@@ -72,7 +72,7 @@ export function createTaskResultRepairHook(client) {
72
72
  const match = emptyResultMatch(output);
73
73
  if (match === undefined)
74
74
  return unchanged;
75
- const sessionID = childSessionID(output);
75
+ const sessionID = taskChildSessionID(output);
76
76
  if (sessionID === undefined)
77
77
  return unchanged;
78
78
  let recovered;