sortie-dogs 0.12.12 → 0.12.14

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.
@@ -3,5 +3,5 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export declare const V010_RUNTIME_ASSET_VERSION = "0.12.12-mission-lineage-external-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.12.14-worker-context-progress-v1";
7
7
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION;
@@ -3,4 +3,4 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export const V010_RUNTIME_ASSET_VERSION = "0.12.12-mission-lineage-external-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.12.14-worker-context-progress-v1";
@@ -167,6 +167,15 @@ export type GoalFlightEvent = (GoalEventBase & {
167
167
  readonly evidence: readonly GoalEvidence[];
168
168
  readonly elapsed_ms: number | null;
169
169
  readonly cost_usd: number | null;
170
+ readonly native_session_id?: string;
171
+ readonly native_started_at?: string;
172
+ }) | (GoalEventBase & {
173
+ readonly kind: "unit.usage-reconciled";
174
+ readonly goal_id: string;
175
+ readonly reservation_id: string;
176
+ readonly native_session_id: string;
177
+ readonly cost_usd: number;
178
+ readonly source: "native-usage-price-table";
170
179
  }) | (GoalEventBase & {
171
180
  readonly kind: "unit.evidence-reconciled";
172
181
  readonly goal_id: string;
@@ -115,6 +115,7 @@ export function reduceGoalFlight(records) {
115
115
  let legacyResetRevision = false;
116
116
  let previous = null;
117
117
  const processDefects = new Map();
118
+ const unitCosts = new Map();
118
119
  const validationAdmissions = new Map();
119
120
  const validationSettlements = new Map();
120
121
  const reopenedValidationEvidence = new Set();
@@ -128,6 +129,7 @@ export function reduceGoalFlight(records) {
128
129
  continue;
129
130
  requireState(instant(event.at), "invalid", "Goal event timestamp is invalid.");
130
131
  if (event.kind === "goal.accepted") {
132
+ unitCosts.clear();
131
133
  requireState(state.goal_id === null || state.phase === "terminal" || state.phase === "stopped", "transition", "An active goal already owns this root.");
132
134
  requireState(text(event.goal_id) && HASH.test(event.acceptance_fingerprint) && text(event.origin_user_message_id) &&
133
135
  text(event.origin_session_id) && text(event.selected_agent) && event.budget.max_units > 0 &&
@@ -222,6 +224,7 @@ export function reduceGoalFlight(records) {
222
224
  : state.no_progress_results;
223
225
  const elapsed = state.consumed_time_ms === null || event.elapsed_ms === null ? null : state.consumed_time_ms + event.elapsed_ms;
224
226
  const cost = state.consumed_cost_usd === null || event.cost_usd === null ? null : state.consumed_cost_usd + event.cost_usd;
227
+ unitCosts.set(event.reservation_id, { cost: event.cost_usd, session: event.native_session_id });
225
228
  state = { ...state, consumed_units: state.consumed_units + 1, consumed_time_ms: elapsed, consumed_cost_usd: cost,
226
229
  no_progress_results: nextNoProgress, replan_required: nextNoProgress >= 2,
227
230
  outstanding_reservations: state.outstanding_reservations.filter((entry) => entry.reservation_id !== event.reservation_id),
@@ -232,6 +235,17 @@ export function reduceGoalFlight(records) {
232
235
  processDefects.set(event.receipt_id, { unitID: event.unit_id, revision: state.revision, epoch: state.scope_epoch });
233
236
  }
234
237
  }
238
+ else if (event.kind === "unit.usage-reconciled") {
239
+ const prior = unitCosts.get(event.reservation_id);
240
+ requireState(event.goal_id === state.goal_id && prior !== undefined && prior.session === event.native_session_id &&
241
+ event.source === "native-usage-price-table" &&
242
+ Number.isFinite(event.cost_usd) && event.cost_usd >= 0 &&
243
+ (prior.cost === null || prior.cost === event.cost_usd), "transition", "Usage reconciliation must identify one settled native unit.");
244
+ unitCosts.set(event.reservation_id, { cost: event.cost_usd, session: event.native_session_id });
245
+ const costs = [...unitCosts.values()];
246
+ state = { ...state, consumed_cost_usd: costs.some(item => item.cost === null) ? null
247
+ : costs.reduce((sum, item) => sum + item.cost, 0) };
248
+ }
235
249
  else if (event.kind === "unit.evidence-reconciled") {
236
250
  const old = processDefects.get(event.previous_receipt_id);
237
251
  requireState(state.phase === "active" && state.receipt === null && old?.unitID === event.unit_id &&
@@ -17,6 +17,8 @@ export interface OperatorMission {
17
17
  id: string;
18
18
  text: string;
19
19
  }[];
20
+ /** The current user intentionally replaced the predecessor's requirements. */
21
+ requirementsReplaced?: boolean;
20
22
  phase: "open" | "running" | "submitted" | "completed" | "cancelled";
21
23
  coordinator: string | null;
22
24
  callID: string | null;
@@ -74,7 +76,7 @@ export declare class OperatorMissionRuntime {
74
76
  read(root: string): Promise<OperatorMission | undefined>;
75
77
  required(root: string): Promise<OperatorMission>;
76
78
  capture(root: string, request: MissionRequest): Promise<void>;
77
- start(root: string, requirements: unknown): Promise<OperatorMission>;
79
+ start(root: string, requirements: unknown, replaceRequirements?: boolean): Promise<OperatorMission>;
78
80
  update(root: string, change: (state: OperatorMission) => void): Promise<OperatorMission>;
79
81
  /** Keep host-accepted criteria ahead of new text, including an exactly linked settled predecessor. */
80
82
  carryForward(root: string, missionID: string, acceptance: readonly string[], supersededRunID?: string): Promise<OperatorMission>;
@@ -160,7 +160,7 @@ export class OperatorMissionRuntime {
160
160
  }
161
161
  });
162
162
  }
163
- start(root, requirements) {
163
+ start(root, requirements, replaceRequirements = false) {
164
164
  return this.serial(root, async () => {
165
165
  if (!Array.isArray(requirements) || requirements.length === 0 || requirements.length > 64 ||
166
166
  !requirements.every(item => typeof item === "string" && item.trim() && !/[\r\n]/u.test(item))) {
@@ -182,6 +182,7 @@ export class OperatorMissionRuntime {
182
182
  await this.save(this.file(root, `.${previous.id}`), previous);
183
183
  const state = { version: "0.12", id: `mission-${randomUUID()}`, root, requests: [request],
184
184
  requirements: requirements.map((text, index) => ({ id: `R${index + 1}`, text })), phase: "open",
185
+ ...(replaceRequirements ? { requirementsReplaced: true } : {}),
185
186
  coordinator: null, callID: null, dispatchOpen: false, runID: null, plans: 0, progress: [], submission: null,
186
187
  ...(previous?.phase === "cancelled" &&
187
188
  (previous.runID === null || request.id !== previous.requests[0]?.id) &&
@@ -331,9 +332,15 @@ export function missionPlan(mission, raw) {
331
332
  units }, "execution");
332
333
  }
333
334
  export function missionPacket(mission, run) {
335
+ const predecessor = run && mission.runID !== run.runID && (mission.supersededRunID === run.runID || run.phase === "cancelled") ? run : undefined;
336
+ if (predecessor)
337
+ run = undefined;
334
338
  const currentReview = mission.review !== undefined && mission.review.runID === (run?.runID ?? mission.runID);
335
339
  const reviewAccepted = currentReview && missionReviewAccepted(mission.review);
336
340
  return { mission_id: mission.id, phase: mission.phase, coordinator_session_id: mission.coordinator,
341
+ ...(predecessor ? { predecessor: { run_id: predecessor.runID, status: predecessor.phase,
342
+ completed_units: predecessor.units.filter(unit => unit.status === "succeeded").length,
343
+ note: "Historical results and spend are retained; they do not complete the current requirements." } } : {}),
337
344
  requirements: mission.requirements, original_request_refs: mission.requests.map(item => `user:${item.id}`),
338
345
  submission: mission.submission, progress: mission.progress,
339
346
  execution_summary: { completed_units: run?.units.filter(unit => unit.status === "succeeded").length ?? 0,
@@ -351,6 +358,8 @@ export function missionPacket(mission, run) {
351
358
  ...(run ? { run_id: run.runID, status: run.phase, decision: run.decision,
352
359
  units: run.units.map(unit => ({ id: unit.unit.id, title: unit.unit.title, status: unit.status,
353
360
  child_session_id: unit.childSessionID, result_class: unit.resultClass, evidence: unit.evidence,
361
+ handoff_path: unit.handoffPath, operation_manifest_path: unit.manifestPath,
362
+ scope_read: unit.unit.read, scope_write: unit.unit.write, validation: unit.unit.validation,
354
363
  ...(unit.dispatchDenial ? { dispatch_denial: unit.dispatchDenial } : {}) })) } : {}),
355
364
  next_action: mission.phase === "completed" ? "Mission completed. Report the accepted result and retained review gaps; no further dispatch or completion call is needed."
356
365
  : mission.phase === "submitted" && mission.submission?.status === "ready"
@@ -204,6 +204,8 @@ export interface OperatorState {
204
204
  operatorCallID: string | null;
205
205
  operatorSessionID: string | null;
206
206
  dispatched: number;
207
+ /** Native interrupt acknowledgements for this cancelled run, persisted before settlement. */
208
+ stoppedChildren?: string[];
207
209
  units: UnitState[];
208
210
  decision: string | null;
209
211
  receipt: GoalTerminalReceipt | null;
@@ -248,7 +250,7 @@ export declare class OperatorRuntime {
248
250
  prepareMission(root: string, raw: unknown, dispatcher?: {
249
251
  sessionID: string;
250
252
  callID: string;
251
- }, supersededRunID?: string, terminalChildren?: readonly string[]): Promise<OperatorState>;
253
+ }, supersededRunID?: string, terminalChildren?: readonly string[], replaceRequirements?: boolean): Promise<OperatorState>;
252
254
  /** Stage a settled mission's replacement; rejected preparation never cancels the usable run. */
253
255
  replanMission(root: string, runID: string, raw: unknown, dispatcher?: {
254
256
  sessionID: string;
@@ -323,6 +325,7 @@ export declare class OperatorRuntime {
323
325
  processReplacementRequired(root: string, runID: string): Promise<OperatorState>;
324
326
  private operatorReturnedOnce;
325
327
  interrupted(root: string, reason: string): Promise<readonly string[]>;
328
+ recordStoppedChild(root: string, runID: string, child: string): Promise<void>;
326
329
  private interruptedOnce;
327
330
  terminal(root: string, receipt: GoalTerminalReceipt): Promise<void>;
328
331
  private terminalOnce;
@@ -337,6 +340,8 @@ export declare class OperatorRuntime {
337
340
  completionGoalFingerprint(state: OperatorState): Promise<string>;
338
341
  verifyContinuityControls(state: OperatorState): Promise<void>;
339
342
  packet(state: OperatorState): unknown;
343
+ /** Reconstruct the admitted child's instructions without trusting a lossy conversation summary. */
344
+ workerContext(root: string, child: string): Promise<string | undefined>;
340
345
  continuationCheckpoint(root: string): Promise<string | undefined>;
341
346
  private verifyControls;
342
347
  /** Follow only the host's authorized parent-link repair; keep the durable control pin in sync. */
@@ -686,8 +686,8 @@ export class OperatorRuntime {
686
686
  }
687
687
  }
688
688
  /** Mission authority is supplied only by the owning profile, never by a model-authored plan. */
689
- prepareMission(root, raw, dispatcher, supersededRunID, terminalChildren = []) {
690
- return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID, terminalChildren }));
689
+ prepareMission(root, raw, dispatcher, supersededRunID, terminalChildren = [], replaceRequirements = false) {
690
+ return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID, terminalChildren, replaceRequirements }));
691
691
  }
692
692
  /** Stage a settled mission's replacement; rejected preparation never cancels the usable run. */
693
693
  replanMission(root, runID, raw, dispatcher) {
@@ -758,12 +758,12 @@ export class OperatorRuntime {
758
758
  return previous;
759
759
  throw new Error("operator-active-contract-immutable");
760
760
  }
761
- // Cancellation stops execution, not the accepted user order. A replacement
762
- // plan must carry the original ordered criteria; only a completed root clears them.
761
+ // Same-request continuation retains acceptance. Explicit user replacement keeps the
762
+ // predecessor's history/spend but uses the current requirements instead of an old prefix.
763
763
  // Treat the predecessor as retired only in this staged replacement. Its durable state remains
764
764
  // intact through schema checks, control writes and archive creation, until save atomically switches it.
765
765
  const parent = replanning ? { ...previous, phase: "cancelled", decision: "mission-replan" }
766
- : (!superseding || retainAcceptance) && previous?.phase === "cancelled" ? previous : undefined;
766
+ : (!superseding || (retainAcceptance && !mission?.replaceRequirements)) && previous?.phase === "cancelled" ? previous : undefined;
767
767
  if (parent && ["explicit-cancellation", "agent-changed"].includes(parent.decision ?? "")) {
768
768
  const cancelled = parent.units.flatMap(unit => (parent.decision === "agent-changed" || unit.status === "cancelled") && unit.childSessionID !== null
769
769
  ? [unit.childSessionID] : []);
@@ -1579,6 +1579,15 @@ export class OperatorRuntime {
1579
1579
  interrupted(root, reason) {
1580
1580
  return this.serial(root, () => this.interruptedOnce(root, reason));
1581
1581
  }
1582
+ recordStoppedChild(root, runID, child) {
1583
+ return this.serial(root, async () => {
1584
+ const state = await this.required(root);
1585
+ if (state.runID !== runID || state.phase !== "cancelled")
1586
+ return;
1587
+ state.stoppedChildren = [...new Set([...(state.stoppedChildren ?? []), child])];
1588
+ await this.save(state);
1589
+ });
1590
+ }
1582
1591
  async interruptedOnce(root, reason) {
1583
1592
  const state = await this.read(root);
1584
1593
  if (!state || state.phase === "completed")
@@ -1910,6 +1919,26 @@ export class OperatorRuntime {
1910
1919
  throw new Error("operator-packet-too-large-no-evidence-truncated");
1911
1920
  return packet;
1912
1921
  }
1922
+ /** Reconstruct the admitted child's instructions without trusting a lossy conversation summary. */
1923
+ async workerContext(root, child) {
1924
+ const state = await this.read(root);
1925
+ if (!state || state.phase !== "running")
1926
+ return undefined;
1927
+ const unit = state.units.find(item => item.childSessionID === child && item.status === "running");
1928
+ if (!unit)
1929
+ return undefined;
1930
+ // Keep this prefix stable across reads/edits: changing counters belong in status/tool results.
1931
+ return `SORTIE_WORKER_CONTEXT\n${JSON.stringify({ root_session_id: root, run_id: state.runID,
1932
+ unit_id: unit.unit.id, task_id: /^task_id: (.+)$/m.exec(unit.task.prompt)?.[1],
1933
+ project_root: this.projectRoot, handoff_path: unit.handoffPath, operation_manifest: unit.manifestPath,
1934
+ objective: unit.unit.objective, read: unit.unit.read, write: unit.unit.write,
1935
+ validation: unit.unit.validation, acceptance: state.acceptance,
1936
+ ...(unit.repairValidation === null ? {} : { validation_only: true }),
1937
+ })}\nThis is the durable assignment for this same Worker, including after compaction or reload. ` +
1938
+ "A missing path in a summary does not mean the handoff is missing. Read the retained handoff/manifest and inspect the actual diff and outputs before claiming no edits or replaying work. " +
1939
+ `Use ${this.profile.toolPrefix}operator_status for observed validation and remaining work; do not repeat successful checks for an unchanged candidate. ` +
1940
+ "This context is an assignment, not proof of execution or acceptance.";
1941
+ }
1913
1942
  async continuationCheckpoint(root) {
1914
1943
  const state = await this.read(root);
1915
1944
  const cancelledRemediation = state?.phase === "cancelled" &&
@@ -918,6 +918,10 @@ export class RunFlightLedger {
918
918
  const records = await this.#readGoalRecords();
919
919
  if (guard !== undefined)
920
920
  guard(reduceGoalFlight(records));
921
+ if (event.kind === "unit.usage-reconciled" && records.some(({ event: stored }) => stored.kind === "unit.usage-reconciled" && stored.goal_id === event.goal_id &&
922
+ stored.reservation_id === event.reservation_id && stored.native_session_id === event.native_session_id &&
923
+ stored.cost_usd === event.cost_usd))
924
+ return reduceGoalFlight(records);
921
925
  if (event.kind === "goal.accepted") {
922
926
  const prior = records.find(({ event: stored }) => stored.kind === "goal.accepted" &&
923
927
  stored.origin_user_message_id === event.origin_user_message_id);
@@ -1240,21 +1240,16 @@ export async function createWriteGate(project, value, toolDirectory = project.ro
1240
1240
  : undefined;
1241
1241
  if (command !== undefined && declaredValidation.has(command))
1242
1242
  return;
1243
+ // Shell syntax is not an execution permission boundary. Mission workers already use
1244
+ // native host permissions; only declared checks produce formal validation evidence.
1245
+ if (options?.investigativeShell && ["bash", "shell"].includes(_input.tool))
1246
+ return;
1243
1247
  const extracted = extractWritePaths(_input.tool, output.args);
1244
1248
  if (!extracted.applies)
1245
1249
  return;
1246
- // Mission workers may investigate with repository-specific executables without registering
1247
- // every diagnostic as a formal check. Known write destinations/Git mutations still use the
1248
- // same scope gate; this option never makes diagnostic output acceptance evidence.
1249
- if (options?.investigativeShell && ["bash", "shell"].includes(_input.tool) &&
1250
- extracted.issue?.cause === "executable-not-allowlisted" && extracted.paths.length === 0 &&
1251
- !extracted.gitMutation && !extracted.remoteMutation)
1252
- return;
1253
1250
  if (extracted.ambiguous || (extracted.paths.length === 0 && !extracted.gitCommit)) {
1254
1251
  if (extracted.issue !== undefined) {
1255
- const formatCorrection = options?.investigativeShell === true &&
1256
- ["unsupported-curl-form", "unsupported-git-archive-form", "unsupported-webrequest-form"].includes(extracted.issue.cause);
1257
- throw new WriteDeniedError("unclassified-command", issuePath(extracted.issue, formatCorrection));
1252
+ throw new WriteDeniedError("unclassified-command", issuePath(extracted.issue));
1258
1253
  }
1259
1254
  throw new WriteDeniedError("path-required", "<missing-path>");
1260
1255
  }
@@ -52,6 +52,7 @@ import { STABLE_RUNTIME_PROFILE } from "../core/runtime-profile.js";
52
52
  import { evidenceFromObservedExecution } from "../core/observed-goal-evidence.js";
53
53
  import { receiptBoundTerminalText } from "./receipt-presentation.js";
54
54
  import { protectedSnapshot, refreshProtectedSnapshot } from "./protected-snapshot.js";
55
+ import { settledUnitUsage } from "./unit-usage.js";
55
56
  import { goalCompletionReadiness } from "./goal-completion.js";
56
57
  const INPUT_LIMITS = { config: 64 * 1024, manifest: 512 * 1024, handoff: 2 * 1024 * 1024, parallel: 512 * 1024 };
57
58
  const INSPECTION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
@@ -1520,6 +1521,32 @@ export const SortieDogsPlugin = async (input, options) => {
1520
1521
  startedAt: observedStartedAt, endedAt, exitCode: exitCode ?? null,
1521
1522
  ...(outcome === undefined ? {} : { outcome }), ...(immutableRef === undefined ? {} : { immutableRef }), fresh });
1522
1523
  }
1524
+ async function nativeUnitCost(child, startedAt) {
1525
+ if (!child || !input.client?.session?.messages)
1526
+ return null;
1527
+ try {
1528
+ const response = await input.client.session.messages({ path: { id: child }, query: { directory: input.directory } });
1529
+ return isRecord(response) && Array.isArray(response.data) ? settledUnitUsage(response.data, startedAt) : null;
1530
+ }
1531
+ catch {
1532
+ return null;
1533
+ }
1534
+ }
1535
+ async function reconcileUnitUsage(root) {
1536
+ const ledger = await goalLedger(root), snapshot = await ledger.readGoal();
1537
+ const reconciled = new Set(snapshot.records.flatMap(({ event }) => event.kind === "unit.usage-reconciled" ? [event.reservation_id] : []));
1538
+ for (const { event } of snapshot.records) {
1539
+ if (event.kind !== "unit.settled" || event.goal_id !== snapshot.state.goal_id || event.cost_usd !== null ||
1540
+ !event.native_session_id || reconciled.has(event.reservation_id))
1541
+ continue;
1542
+ const cost = await nativeUnitCost(event.native_session_id, event.native_started_at);
1543
+ if (cost === null)
1544
+ continue;
1545
+ await ledger.appendGoal({ kind: "unit.usage-reconciled", at: new Date().toISOString(), goal_id: event.goal_id,
1546
+ reservation_id: event.reservation_id, native_session_id: event.native_session_id,
1547
+ cost_usd: cost, source: "native-usage-price-table" });
1548
+ }
1549
+ }
1523
1550
  async function recoverCompletedGoalReservations(sessionID) {
1524
1551
  const root = goalRoot(sessionID);
1525
1552
  const active = goalReservationRecoveries.get(root);
@@ -1531,7 +1558,31 @@ export const SortieDogsPlugin = async (input, options) => {
1531
1558
  // A terminal host Task is authoritative even when a missed after hook left process-local
1532
1559
  // dispatch accounting behind. Treating that memory as live made the reservation permanent.
1533
1560
  const pending = state.outstanding_reservations;
1534
- if (pending.length === 0 || state.goal_id === null || input.client?.session?.messages === undefined)
1561
+ for (const reservation of pending) {
1562
+ const dispatch = await input.runtimeBridge?.recoverMissionDispatch?.(root, reservation.unit_id);
1563
+ if (!dispatch || !state.goal_id || goalFingerprint({ goal_id: state.goal_id, unit_id: reservation.unit_id,
1564
+ call_id: dispatch.callID }) !== reservation.reservation_id)
1565
+ continue;
1566
+ const startedAt = (await ledger.readGoal()).records.find(({ event }) => event.kind === "dispatch.reserved" &&
1567
+ event.reservation_id === reservation.reservation_id)?.event.at;
1568
+ await ledger.appendGoal({ kind: "unit.settled", at: new Date().toISOString(),
1569
+ reservation_id: reservation.reservation_id,
1570
+ receipt_id: goalFingerprint({ recovered_host_task: dispatch.callID, reservation: reservation.reservation_id }),
1571
+ goal_id: state.goal_id, unit_id: reservation.unit_id,
1572
+ disposition: dispatch.cancelled ? "cancelled" : "failed", result_class: dispatch.cancelled ? "interrupted" : "process-defect",
1573
+ progress_fingerprint: null, evidence: [], elapsed_ms: null,
1574
+ cost_usd: await nativeUnitCost(dispatch.childSessionID, startedAt),
1575
+ ...(dispatch.childSessionID ? { native_session_id: dispatch.childSessionID, native_started_at: startedAt } : {}) });
1576
+ // A missed after hook closes execution, but cannot turn unobserved validation into acceptance.
1577
+ await input.runtimeBridge?.onSerialSettlement?.({ rootSessionID: root, callID: dispatch.callID,
1578
+ unitID: reservation.unit_id, ...(dispatch.childSessionID ? { childSessionID: dispatch.childSessionID } : {}),
1579
+ disposition: dispatch.cancelled ? "cancelled" : "failed", resultClass: dispatch.cancelled ? "interrupted" : "process-defect", evidence: [] });
1580
+ goalReservations.delete(dispatch.callID);
1581
+ if (finishCoordinatorTask(root, dispatch.callID))
1582
+ fastLane.workerCompleted(root);
1583
+ }
1584
+ const remaining = (await ledger.readGoal()).state.outstanding_reservations;
1585
+ if (remaining.length === 0 || state.goal_id === null || input.client?.session?.messages === undefined)
1535
1586
  return;
1536
1587
  const messages = input.client.session.messages;
1537
1588
  const response = await messages.call(input.client.session, { path: { id: root },
@@ -1615,7 +1666,7 @@ export const SortieDogsPlugin = async (input, options) => {
1615
1666
  return undefined;
1616
1667
  return candidates[0].callID;
1617
1668
  }
1618
- for (const reservation of pending) {
1669
+ for (const reservation of remaining) {
1619
1670
  const matches = [];
1620
1671
  for (const message of data) {
1621
1672
  if (!isRecord(message) || !isRecord(message.info) || message.info.role !== "assistant" ||
@@ -2240,11 +2291,12 @@ export const SortieDogsPlugin = async (input, options) => {
2240
2291
  const reservation = goalReservations.get(callID);
2241
2292
  if (reservation === undefined)
2242
2293
  return;
2243
- goalReservations.delete(callID);
2244
2294
  const ledger = await goalLedger(reservation.root);
2245
2295
  const state = (await ledger.readGoal()).state;
2246
- if (state.goal_id === null)
2296
+ if (state.goal_id === null || !state.outstanding_reservations.some(item => item.reservation_id === reservation.reservationID)) {
2297
+ goalReservations.delete(callID);
2247
2298
  return;
2299
+ }
2248
2300
  const outputText = typeof output.output === "string" ? output.output : "";
2249
2301
  // Task text and Task result metadata are model/child-controlled. Only an exact declared command
2250
2302
  // observed in the child tool hooks with a native host exit may produce goal evidence.
@@ -2288,7 +2340,10 @@ export const SortieDogsPlugin = async (input, options) => {
2288
2340
  goal_id: state.goal_id, unit_id: reservation.unitID,
2289
2341
  disposition: validated ? "succeeded" : interrupted ? "cancelled" : "failed", result_class: resultClass,
2290
2342
  progress_fingerprint: progress ? goalFingerprint(acceptedEvidence) : null,
2291
- evidence: acceptedEvidence, elapsed_ms: Math.max(0, Date.now() - reservation.started), cost_usd: null });
2343
+ evidence: acceptedEvidence, elapsed_ms: Math.max(0, Date.now() - reservation.started),
2344
+ cost_usd: await nativeUnitCost(childSessionID, new Date(reservation.started).toISOString()),
2345
+ ...(childSessionID ? { native_session_id: childSessionID, native_started_at: new Date(reservation.started).toISOString() } : {}) });
2346
+ goalReservations.delete(callID);
2292
2347
  await input.runtimeBridge?.onSerialSettlement?.({
2293
2348
  rootSessionID: reservation.root, callID, unitID: reservation.unitID,
2294
2349
  ...(childSessionID === undefined ? {} : { childSessionID }),
@@ -7761,6 +7816,7 @@ export const SortieDogsPlugin = async (input, options) => {
7761
7816
  // Planning must account for terminal native Tasks before reserving new units.
7762
7817
  // Otherwise a cancelled predecessor keeps a stale unit reservation across missions.
7763
7818
  await recoverCompletedGoalReservations(root);
7819
+ await reconcileUnitUsage(root);
7764
7820
  const state = await currentGoal(root);
7765
7821
  if (state.goal_id === null || state.budget === null)
7766
7822
  return null;
@@ -7769,7 +7825,8 @@ export const SortieDogsPlugin = async (input, options) => {
7769
7825
  reserved_units: reserved, remaining_units: Math.max(0, state.budget.max_units - state.consumed_units - reserved),
7770
7826
  settled_cost_usd: state.consumed_cost_usd, cost_limit_usd: state.budget.cost_usd,
7771
7827
  cost_status: state.consumed_cost_usd === null ? "unknown-usage" : reserved > 0 ? "in-flight-not-final" : "settled",
7772
- cost_note: "Goal Worker ledger only; excludes unfinalized native requests, orchestration/review and external campaign spend. Zero settled cost does not mean free execution." };
7828
+ cost_source: "native-usage-price-table",
7829
+ cost_note: "Worker token-price estimates from complete native usage; missing requests remain unknown and are reconciled on later status. Excludes orchestration/review and external campaign spend. Zero settled cost does not mean free execution." };
7773
7830
  },
7774
7831
  registerGoalDeclaration: async (root, prompt, missionRevision) => {
7775
7832
  if (!isCoordinatorSession(root) && !await recoverCoordinatorRoot(root))
@@ -7949,6 +8006,7 @@ export const SortieDogsPlugin = async (input, options) => {
7949
8006
  fastLane.grantSerialUnits(sessionID, maximum);
7950
8007
  },
7951
8008
  cancelChildren: async (sessionID) => {
8009
+ await recoverCompletedGoalReservations(sessionID);
7952
8010
  for (const [callID, reservation] of [...goalReservations]) {
7953
8011
  if (reservation.root !== sessionID)
7954
8012
  continue;
@@ -1,5 +1,6 @@
1
- /** Display-only native Task progress. Never prompts a model or changes execution ownership. */
2
1
  type Sink = (value: Record<string, unknown>) => Promise<void>;
3
2
  export declare function bindMissionProgress(root: string, sink: Sink): () => void;
4
3
  export declare function publishMissionProgress(root: string, value: Record<string, unknown>): Promise<void>;
4
+ /** Read durable transitions even when settlement ran in a different/reloaded plugin instance. */
5
+ export declare function missionProgressReader(directory: string, root: string, callID: string): () => Promise<Record<string, unknown> | undefined>;
5
6
  export {};
@@ -1,3 +1,7 @@
1
+ /** Display-only native Task progress. Never prompts a model or changes execution ownership. */
2
+ import { OperatorMissionRuntime } from "../core/operator-mission.js";
3
+ import { OperatorRuntime } from "../core/operator-runtime.js";
4
+ import { V010_RUNTIME_PROFILE } from "../core/runtime-profile.js";
1
5
  const sinks = new Map();
2
6
  export function bindMissionProgress(root, sink) {
3
7
  sinks.set(root, sink);
@@ -7,3 +11,27 @@ export function bindMissionProgress(root, sink) {
7
11
  export async function publishMissionProgress(root, value) {
8
12
  await sinks.get(root)?.(value).catch(() => undefined);
9
13
  }
14
+ /** Read durable transitions even when settlement ran in a different/reloaded plugin instance. */
15
+ export function missionProgressReader(directory, root, callID) {
16
+ const missions = new OperatorMissionRuntime(directory, V010_RUNTIME_PROFILE);
17
+ return async () => {
18
+ const mission = await missions.read(root);
19
+ if (!mission || mission.callID !== callID)
20
+ return undefined;
21
+ // OperatorRuntime caches its own writes; a display observer must read a fresh disk snapshot.
22
+ const run = await new OperatorRuntime(directory, V010_RUNTIME_PROFILE).read(root);
23
+ const units = run?.runID === mission.runID ? run.units : [];
24
+ const running = units.find(unit => unit.status === "running");
25
+ const last = mission.progress.at(-1);
26
+ const status = running ? "running" : last?.status ?? mission.phase;
27
+ const title = running?.unit.title ?? last?.title ?? mission.requirements[0]?.text ?? "Coordinator";
28
+ return { description: `🐾 ${status}: ${title}`, sortie_progress: {
29
+ mission_id: mission.id, phase: mission.phase, unit: running ? `${run.runID}/${running.unit.id}` : last?.unit ?? null,
30
+ title, status, child_session_id: running?.childSessionID ?? null,
31
+ completed_units: units.filter(unit => unit.status === "succeeded").length,
32
+ failed_units: units.filter(unit => unit.status === "failed").length,
33
+ historical_failed_attempts: mission.progress.filter(unit => unit.status === "failed").length,
34
+ accepted: mission.phase === "completed",
35
+ } };
36
+ };
37
+ }
@@ -10,7 +10,7 @@ import { BUILT_IN_MODEL_CATALOG } from "./model-routing.js";
10
10
  import { goalFingerprint } from "../core/goal-bound.js";
11
11
  import { decoratePreviewHeadings, returnReportPanel } from "./receipt-presentation.js";
12
12
  import { sanitizeTerminalReport, terminalRunOutcome } from "./run-metrics.js";
13
- import { extractWritePaths, normalizeCommand } from "./gate.js";
13
+ import { normalizeCommand } from "./gate.js";
14
14
  import { normalizeRelativePath } from "../core/path.js";
15
15
  import { MISSION_EVIDENCE_GAP_REVIEW_LIMIT, OperatorMissionRuntime, missionPacket, missionPlan, missionReviewAccepted, missionReviewTask, missionReviewTraces, missionReviewVerdict } from "../core/operator-mission.js";
16
16
  import { publishMissionProgress } from "./mission-progress.js";
@@ -362,6 +362,36 @@ export function createProfiledPlugin(profile, assetVersion) {
362
362
  state.units.some(unit => unit.status === "running" && unit.callID === callID &&
363
363
  /^task_id: (.+)$/m.exec(unit.task.prompt)?.[1] === taskID);
364
364
  },
365
+ recoverMissionDispatch: async (root, taskID) => {
366
+ let run = await operators.read(root);
367
+ const unit = run?.units.find(item => /^task_id: (.+)$/m.exec(item.task.prompt)?.[1] === taskID);
368
+ if (!run || !unit?.callID)
369
+ return undefined;
370
+ if (run.phase !== "cancelled") {
371
+ const history = await messages(run.operatorSessionID ?? root);
372
+ const terminal = history.some(message => Array.isArray(message.parts) && message.parts.some(part => record(part) && part.type === "tool" && part.callID === unit.callID && record(part.state) &&
373
+ ["completed", "error"].includes(String(part.state.status))));
374
+ if (!terminal)
375
+ return undefined;
376
+ if (unit.childSessionID) {
377
+ const child = payload(await session("get", { path: { id: unit.childSessionID }, query: { directory: input.directory } }));
378
+ if (!record(child) || child.parentID !== (run.operatorSessionID ?? root) ||
379
+ !["succeeded", "failed", "interrupted"].includes(String(child.outcome)))
380
+ return undefined;
381
+ }
382
+ return { callID: unit.callID, ...(unit.childSessionID ? { childSessionID: unit.childSessionID } : {}), cancelled: false };
383
+ }
384
+ await stopCancelledChildren(root, run);
385
+ run = await operators.required(root);
386
+ // A Task rejected before creating a child still has a native terminal record.
387
+ if (!unit.childSessionID) {
388
+ const history = await messages(run.operatorSessionID ?? root);
389
+ if (!history.some(message => Array.isArray(message.parts) && message.parts.some(part => record(part) && part.type === "tool" && part.callID === unit.callID && record(part.state) &&
390
+ ["completed", "error"].includes(String(part.state.status)))))
391
+ return undefined;
392
+ }
393
+ return { callID: unit.callID, ...(unit.childSessionID ? { childSessionID: unit.childSessionID } : {}), cancelled: true };
394
+ },
365
395
  allowsInvestigativeShell: async (child) => {
366
396
  const root = await rootFor(child);
367
397
  if (!root)
@@ -487,7 +517,8 @@ export function createProfiledPlugin(profile, assetVersion) {
487
517
  if (previous?.phase !== "cancelled" || mission.runID !== null)
488
518
  return mission;
489
519
  if (mission.supersededRunID !== undefined) {
490
- if (mission.supersededRunID !== previous.runID || !cancelledMissionRetainsAcceptance(previous))
520
+ // The predecessor remains archived for costs/results, not as obligations for a new request.
521
+ if (mission.requirementsReplaced || mission.supersededRunID !== previous.runID || !cancelledMissionRetainsAcceptance(previous))
491
522
  return mission;
492
523
  return missions.carryForward(root, mission.id, previous.acceptance, previous.runID);
493
524
  }
@@ -638,6 +669,37 @@ export function createProfiledPlugin(profile, assetVersion) {
638
669
  const reopenProposalScope = profileTool(profile, "sortie_reopen_operator_proposal_scope");
639
670
  const reconcileOrphan = profileTool(profile, "sortie_reconcile_orphaned_operator_dispatch");
640
671
  const reviseApprovedIntent = profileTool(profile, "sortie_revise_approved_operator_intent");
672
+ async function stopCancelledChildren(root, run) {
673
+ if (run.phase !== "cancelled")
674
+ return;
675
+ const children = new Set(run.units.flatMap(unit => unit.childSessionID ? [unit.childSessionID] : []));
676
+ if (run.operatorSessionID) {
677
+ // Older V2 private servers expose get/interrupt but no session.list. The durable
678
+ // dispatch IDs suffice to cancel our work; a missing listing must not veto that operation.
679
+ const nested = payload(await session("children", { path: { id: run.operatorSessionID }, query: { directory: input.directory } }).catch(() => undefined));
680
+ if (Array.isArray(nested))
681
+ for (const child of nested)
682
+ if (record(child) && typeof child.id === "string" &&
683
+ child.parentID === run.operatorSessionID)
684
+ children.add(child.id);
685
+ children.add(run.operatorSessionID);
686
+ }
687
+ for (const child of children) {
688
+ if (run.stoppedChildren?.includes(child))
689
+ continue;
690
+ const who = payload(await session("get", { path: { id: child }, query: { directory: input.directory } }));
691
+ if (!record(who) || who.parentID !== (child === run.operatorSessionID ? root : run.operatorSessionID ?? root)) {
692
+ throw new Error(`mission-cancellation-lineage-unavailable: ${child}`);
693
+ }
694
+ if (!["succeeded", "failed", "interrupted"].includes(String(who.outcome))) {
695
+ const result = await session("abort", { path: { id: child }, query: { directory: input.directory } });
696
+ if (result === undefined || result === false || (record(result) && result.data === false)) {
697
+ throw new Error(`mission-cancellation-stop-unconfirmed: ${child}`);
698
+ }
699
+ }
700
+ await operators.recordStoppedChild(root, run.runID, child);
701
+ }
702
+ }
641
703
  async function stop(root, reason, retireRoot = true) {
642
704
  if (retireRoot)
643
705
  retired.add(root);
@@ -664,6 +726,9 @@ export function createProfiledPlugin(profile, assetVersion) {
664
726
  const result = await session("abort", { path: { id: child }, query: { directory: input.directory } });
665
727
  if (result === undefined || result === false || (record(result) && result.data === false))
666
728
  throw new Error("operator-child-stop-unconfirmed");
729
+ const run = await operators.read(root);
730
+ if (run)
731
+ await operators.recordStoppedChild(root, run.runID, child);
667
732
  }
668
733
  await control?.cancelChildren(root);
669
734
  if (retireRoot)
@@ -733,6 +798,11 @@ export function createProfiledPlugin(profile, assetVersion) {
733
798
  if (!root)
734
799
  throw new Error("operator-grant-invalid");
735
800
  const activeMission = await reconcileMissionDispatch(root);
801
+ if (activeMission && !["cancelled", "completed"].includes(activeMission.phase) && activeMission.runID === null && context.sessionID !== root) {
802
+ const budget = await control.currentBudget(root);
803
+ return JSON.stringify({ ...missionDispatchPacket(activeMission, await operators.read(root)), budget,
804
+ next_action: "This mission has no unit plan yet. Call plan_units with the next useful work under the current requirements; prior run results are historical." });
805
+ }
736
806
  if (activeMission && context.sessionID === root && !activeMission.dispatchOpen &&
737
807
  ["open", "running"].includes(activeMission.phase)) {
738
808
  return JSON.stringify({ ...missionDispatchPacket(activeMission, await operators.read(root)), budget: await control.currentBudget(root) });
@@ -786,9 +856,10 @@ export function createProfiledPlugin(profile, assetVersion) {
786
856
  const mission = root && context.sessionID === root ? await reconcileMissionDispatch(root) : root ? await missions.read(root) : undefined;
787
857
  // Observation is available to every owned role. Only the root reconciles dispatch above.
788
858
  if (mission) {
859
+ const budget = await control.currentBudget(root);
789
860
  const run = await operators.read(root);
790
861
  const completion = run?.phase === "awaiting-acceptance" ? await control.completionReadiness(root) : undefined;
791
- return JSON.stringify({ ...missionDispatchPacket(mission, run), budget: await control.currentBudget(root),
862
+ return JSON.stringify({ ...missionDispatchPacket(mission, run), budget,
792
863
  ...(completion ? { completion, ...(!completion.ready ? { next_action: completion.blockers.map(item => item.next_action).join("\n") } : {}) } : {}) });
793
864
  }
794
865
  if (root && context.sessionID !== root) {
@@ -1226,20 +1297,23 @@ export function createProfiledPlugin(profile, assetVersion) {
1226
1297
  } };
1227
1298
  const startMission = `${profile.toolPrefix}start_mission`, planUnits = `${profile.toolPrefix}plan_units`, reviewMission = `${profile.toolPrefix}review_mission`, submitMission = `${profile.toolPrefix}submit_mission`, completeMission = `${profile.toolPrefix}complete_mission`, expandUnit = `${profile.toolPrefix}expand_unit`;
1228
1299
  const stringList = { type: "array", items: { type: "string" } };
1229
- tools[startMission] = { description: "Operator: save a few one-line requirements/negative constraints. Original user messages, IDs and ownership are captured automatically. An unfinished mission in another location returns its continuation location. For intentionally separate work in this location, intent=new creates a separate mission without cancelling other missions or resetting cumulative spend. Dispatch the returned Coordinator task immediately, or use plan_units for simple single-unit work.",
1300
+ tools[startMission] = { description: "Operator: save the current user requirements. Use intent=replace when the user changes an existing request (version, parallelism, target): host cancels the previous run and archives its requirements/results while retaining spend. Supply the complete current requirements, retaining constraints the user has not changed. For separate work in a different location use intent=new. Dispatch the Coordinator immediately, or plan_units for a known single-unit procedure; item count and runtime alone do not require a Coordinator.",
1230
1301
  args: { requirements: { ...stringList, minItems: 1, maxItems: 64 },
1231
- intent: { type: "string", enum: ["", "continue", "new"], "x-sortie-optional": true } }, execute: async (args, context) => {
1302
+ intent: { type: "string", enum: ["", "continue", "new", "replace"], "x-sortie-optional": true } }, execute: async (args, context) => {
1232
1303
  await requireRoot(context.sessionID);
1233
- if (args.intent !== undefined && !["", "continue", "new"].includes(args.intent))
1304
+ if (args.intent !== undefined && !["", "continue", "new", "replace"].includes(args.intent))
1234
1305
  throw new Error("mission-intent-invalid");
1235
1306
  return serializeDispatchTransition(context.sessionID, async () => {
1236
1307
  const existing = await reconcileMissionDispatch(context.sessionID);
1308
+ if (args.intent === "replace" && existing && !["completed", "cancelled"].includes(existing.phase)) {
1309
+ await stop(context.sessionID, "explicit-cancellation", false);
1310
+ }
1237
1311
  if (args.intent !== "new" && (!existing || ["completed", "cancelled"].includes(existing.phase))) {
1238
1312
  const relocated = await relocatedMission(context.sessionID);
1239
1313
  if (relocated)
1240
1314
  return JSON.stringify({ ...relocated, budget: await control.currentBudget(context.sessionID) });
1241
1315
  }
1242
- const mission = await retainCancelledMissionAcceptance(context.sessionID, await missions.start(context.sessionID, args.requirements));
1316
+ const mission = await retainCancelledMissionAcceptance(context.sessionID, await missions.start(context.sessionID, args.requirements, args.intent === "replace" || args.intent === "new"));
1243
1317
  return JSON.stringify({ ...locationObservation(context.sessionID), ...(mission.dispatchOpen
1244
1318
  ? missionDispatchPacket(mission, await operators.read(context.sessionID))
1245
1319
  : { mission_id: mission.id, requirements: mission.requirements, task: missions.task(mission),
@@ -1270,12 +1344,25 @@ export function createProfiledPlugin(profile, assetVersion) {
1270
1344
  previous.units.some(unit => (previous.decision === "agent-changed" || unit.status === "cancelled") && unit.childSessionID !== null)));
1271
1345
  const terminalChildren = cancelledPredecessor
1272
1346
  ? await terminalCancelledMissionChildren(profile, root, previous, budget, {
1273
- get: async (id) => payload(await session("get", { path: { id }, query: { directory: input.directory } })),
1274
- children: async (id) => payload(await session("children", { path: { id }, query: { directory: input.directory } })),
1347
+ get: async (id) => {
1348
+ const value = payload(await session("get", { path: { id }, query: { directory: input.directory } }));
1349
+ const stopped = (await operators.required(root)).stoppedChildren?.includes(id);
1350
+ return record(value) && stopped ? { ...value, outcome: "interrupted" } : value;
1351
+ },
1352
+ children: async (id) => {
1353
+ try {
1354
+ return payload(await session("children", { path: { id }, query: { directory: input.directory } }));
1355
+ }
1356
+ catch {
1357
+ const run = await operators.required(root);
1358
+ // The cancellation acknowledgements are scoped to this exact durable run.
1359
+ return id === run.operatorSessionID ? run.units.flatMap(unit => unit.childSessionID ? [{ id: unit.childSessionID }] : []) : [];
1360
+ }
1361
+ },
1275
1362
  }) : [];
1276
1363
  const dispatcher = actor === root ? undefined : { sessionID: actor, callID: mission.callID };
1277
1364
  const state = replanning ? await operators.replanMission(root, previous.runID, plan, dispatcher)
1278
- : await operators.prepareMission(root, plan, dispatcher, mission.supersededRunID, terminalChildren);
1365
+ : await operators.prepareMission(root, plan, dispatcher, mission.supersededRunID, terminalChildren, mission.requirementsReplaced);
1279
1366
  await control.registerGoalDeclaration(root, state.units[0].task.prompt, true);
1280
1367
  control.enableUnits(root, state.units.filter(unit => unit.status === "pending").length);
1281
1368
  await missions.update(root, item => {
@@ -1290,7 +1377,8 @@ export function createProfiledPlugin(profile, assetVersion) {
1290
1377
  }
1291
1378
  tools[planUnits] = { description: "Coordinator (or single-unit Fast-lane Operator): declare useful units, then dispatch the returned Worker immediately. Host generates IDs, handoff, manifest and proof mapping. Keep every original requirement covered. Use write: [] for read-only verification; use dir/** for directory outputs including not-yet-created trees. Native absolute paths support global installs and external outputs under host permissions; include their actual paths in read/write for evidence. Final validation command in each unit proves that unit; read-only diagnostic commands need no registration. Recalling with reason replaces settled work within unchanged requirements and cumulative budget; include required scope extensions here. Rejected budget, contract or control-storage preparation preserves the existing run so you can correct the plan directly.",
1292
1379
  args: { units: { type: "array", minItems: 1, maxItems: 32, items: { type: "object", additionalProperties: false,
1293
- properties: { title: { type: "string" }, objective: { type: "string" }, read: stringList, write: stringList,
1380
+ properties: { title: { type: "string" }, objective: { type: "string" },
1381
+ read: { ...stringList, description: "Inputs that affect validation, not an allowlist for observation. Do not include whole live session/database/log trees just to inspect them." }, write: stringList,
1294
1382
  validation: stringList, requirement_ids: stringList }, required: ["title", "objective", "write", "validation"] } },
1295
1383
  reason: { type: "string", "x-sortie-optional": true } }, execute: async (args, context) => {
1296
1384
  const { root, mission } = await missionAuthority(context.sessionID);
@@ -1669,9 +1757,8 @@ export function createProfiledPlugin(profile, assetVersion) {
1669
1757
  if (["read", "glob", "grep", "list", "execute", "todowrite", "todoread", "webfetch", "websearch", "skill"].includes(request.tool))
1670
1758
  return;
1671
1759
  if (["bash", "shell"].includes(request.tool)) {
1672
- const writes = extractWritePaths(request.tool, args);
1673
- if (writes.paths.length || writes.gitMutation || writes.remoteMutation)
1674
- throw new Error("mission-coordinator-shell-readonly: delegate source changes to Worker");
1760
+ // Native shell permissions own execution. Parsing shell syntax cannot enforce read-only
1761
+ // behavior (interpreter bodies were already opaque); the role still delegates source edits.
1675
1762
  return;
1676
1763
  }
1677
1764
  throw new Error("mission-coordinator-no-edit-tool");
@@ -2025,6 +2112,11 @@ export function createProfiledPlugin(profile, assetVersion) {
2025
2112
  (output.system ??= []).push(`SORTIE_RUNTIME_PROFILE ${profile.id}; marker ${assetVersion}. ` +
2026
2113
  `Shared MkII protocol role names are logical: ${protocolMap}. Use only ${profile.toolPrefix} tools for this profile. ` +
2027
2114
  "Never rewrite user acceptance or evidence to rename protocol roles. Final acceptance belongs only to the root coordinator.");
2115
+ if ((await identity(request.sessionID)).role === "dog-worker") {
2116
+ const context = await operators.workerContext(root, request.sessionID);
2117
+ if (context)
2118
+ (output.system ??= []).push(context);
2119
+ }
2028
2120
  const proposal = await proposals.read(root);
2029
2121
  if (proposal?.phase !== "approved" && proposal?.proposal_session_id === request.sessionID) {
2030
2122
  // Only immutable identity and the frozen budget caps belong here. Consumed counters move with
@@ -2079,6 +2171,12 @@ export function createProfiledPlugin(profile, assetVersion) {
2079
2171
  const root = await rootFor(request.sessionID);
2080
2172
  if (!root)
2081
2173
  return;
2174
+ if ((await identity(request.sessionID)).role === "dog-worker") {
2175
+ const context = await operators.workerContext(root, request.sessionID);
2176
+ if (context)
2177
+ (output.context ??= []).push(context +
2178
+ " Preserve the actual edits, tool outcomes and next action in the summary; do not replace them with an empty-work claim.");
2179
+ }
2082
2180
  if ((await identity(request.sessionID)).role === "dog-operator") {
2083
2181
  const mission = await missions.read(root);
2084
2182
  if (mission?.coordinator === request.sessionID) {
@@ -31,6 +31,12 @@ export interface RuntimeBridge {
31
31
  ownsCanonicalValidation?(rootSessionID: string, unitID: string, childSessionID: string, command: string): Promise<boolean>;
32
32
  /** A mission's hash-pinned Task has already passed durable run/acceptance admission. */
33
33
  ownsMissionDispatch?(rootSessionID: string, callID: string, taskID: string): Promise<boolean>;
34
+ /** Durable operator dispatch identity survives adapter reload and missed after hooks. */
35
+ recoverMissionDispatch?(rootSessionID: string, taskID: string): Promise<{
36
+ callID: string;
37
+ childSessionID?: string;
38
+ cancelled: boolean;
39
+ } | undefined>;
34
40
  onHostHandoffRepaired?(rootSessionID: string, taskID: string, handoffPath: string, original: string, repaired: string): Promise<void>;
35
41
  allowsInvestigativeShell?(sessionID: string): Promise<boolean>;
36
42
  onSerialSettlement?(settlement: SerialDispatchSettlement): Promise<void>;
@@ -0,0 +1,2 @@
1
+ /** Price complete native requests; absent/interrupted usage is unknown, never a fabricated zero. */
2
+ export declare function settledUnitUsage(messages: readonly unknown[], startedAt?: string): number | null;
@@ -0,0 +1,44 @@
1
+ import { estimateModelUsageCost } from "./model-cost.js";
2
+ const record = (value) => !!value && typeof value === "object" && !Array.isArray(value);
3
+ /** Price complete native requests; absent/interrupted usage is unknown, never a fabricated zero. */
4
+ export function settledUnitUsage(messages, startedAt) {
5
+ let cost = 0, requests = 0;
6
+ const seen = new Set();
7
+ for (const message of messages) {
8
+ if (!record(message) || !record(message.info) || message.info.role !== "assistant")
9
+ continue;
10
+ const info = message.info;
11
+ if (typeof info.id !== "string" || seen.has(info.id))
12
+ continue;
13
+ seen.add(info.id);
14
+ const time = record(info.time) ? info.time : undefined;
15
+ if (startedAt) {
16
+ if (typeof time?.created !== "number")
17
+ return null;
18
+ if (time.created < Date.parse(startedAt))
19
+ continue;
20
+ }
21
+ if (typeof time?.completed !== "number")
22
+ return null;
23
+ const tokens = record(info.tokens) ? info.tokens : undefined;
24
+ const cache = record(tokens?.cache) ? tokens.cache : undefined;
25
+ if (info.error && [tokens?.input, tokens?.output, tokens?.reasoning, cache?.read, cache?.write]
26
+ .every(value => value === 0 || value === undefined))
27
+ return null;
28
+ const estimate = estimateModelUsageCost({
29
+ providerID: typeof info.providerID === "string" ? info.providerID : undefined,
30
+ modelID: typeof info.modelID === "string" ? info.modelID : undefined,
31
+ uncachedInputTokens: typeof tokens?.input === "number" ? tokens.input : undefined,
32
+ outputTokens: typeof tokens?.output === "number" ? tokens.output : undefined,
33
+ reasoningTokens: typeof tokens?.reasoning === "number" ? tokens.reasoning : undefined,
34
+ cacheReadTokens: typeof cache?.read === "number" ? cache.read : undefined,
35
+ cacheWriteTokens: typeof cache?.write === "number" ? cache.write : undefined,
36
+ serviceTier: typeof info.serviceTier === "string" ? info.serviceTier : undefined,
37
+ });
38
+ if (estimate.status !== "priced")
39
+ return null;
40
+ requests++;
41
+ cost += estimate.usd;
42
+ }
43
+ return requests ? cost : null;
44
+ }
package/dist/plugin/v2.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SortieDogsV010Plugin } from "./profiled.js";
2
- import { bindMissionProgress } from "./mission-progress.js";
2
+ import { bindMissionProgress, missionProgressReader } from "./mission-progress.js";
3
3
  import { owningServiceSessionList } from "./v2-session-history.js";
4
4
  import { createHash } from "node:crypto";
5
5
  import { readFileSync } from "node:fs";
@@ -332,18 +332,50 @@ async function registerV2Hooks(context, hooks) {
332
332
  return execute(value, execution);
333
333
  const report = execution.progress;
334
334
  let metadata = {};
335
- const unbind = bindMissionProgress(String(execution.sessionID), async (progress) => {
336
- metadata = { ...metadata, ...progress };
337
- await report(metadata);
338
- });
335
+ let pending = Promise.resolve();
336
+ const update = (update) => {
337
+ metadata = { ...metadata, ...update };
338
+ const snapshot = { ...metadata };
339
+ pending = pending.catch(() => undefined).then(() => report(snapshot));
340
+ return pending;
341
+ };
342
+ const unbind = bindMissionProgress(String(execution.sessionID), update);
343
+ const readProgress = missionProgressReader(context.location.directory, String(execution.sessionID), String(execution.id));
344
+ let previous = "", reading;
345
+ const refresh = () => {
346
+ if (reading)
347
+ return reading;
348
+ reading = (async () => {
349
+ try {
350
+ const progress = await readProgress(), key = JSON.stringify(progress);
351
+ if (progress && key !== previous) {
352
+ await update(progress);
353
+ previous = key;
354
+ }
355
+ }
356
+ catch (error) {
357
+ console.warn("[sortie-dogs-v010] mission progress unavailable", error instanceof Error ? error.message : "unknown");
358
+ }
359
+ })().finally(() => { reading = undefined; });
360
+ return reading;
361
+ };
362
+ // The native executor is a stable snapshot. Poll only local durable state, not models or
363
+ // session status APIs; this also recovers progress after another plugin instance settles work.
364
+ const timer = setInterval(() => { void refresh(); }, 1000);
365
+ timer.unref();
339
366
  try {
340
- return await execute(value, { ...execution, progress: async (update) => {
341
- metadata = { ...metadata, ...update };
342
- await report(metadata);
343
- } });
367
+ await refresh();
368
+ const result = await execute(value, { ...execution, progress: update });
369
+ await refresh();
370
+ await pending.catch(() => undefined);
371
+ // Native completion replaces metadata, so retain our display fields alongside its own.
372
+ return { ...result, metadata: { ...metadata, ...(record(result.metadata) ? result.metadata : {}) } };
344
373
  }
345
374
  finally {
375
+ clearInterval(timer);
346
376
  unbind();
377
+ await reading;
378
+ await pending.catch(() => undefined);
347
379
  }
348
380
  };
349
381
  });
@@ -39,7 +39,7 @@ ${controls(profile, ["start_mission", "plan_units", "operator_next", "operator_s
39
39
  ---
40
40
  # ${profileAgent(profile, "dog-coordinator")}
41
41
 
42
- You are Operator, the user-facing strategic authority. Preserve every original requirement, prohibition,
42
+ You are Operator, the user-facing strategic authority. Preserve every current requirement, prohibition,
43
43
  quality threshold and explicit model/budget choice. Follow AGENTS.md and use the user's language.
44
44
 
45
45
  1. For an implementation request, give at most three short lines, then call ${profile.toolPrefix}start_mission
@@ -49,7 +49,10 @@ quality threshold and explicit model/budget choice. Follow AGENTS.md and use the
49
49
  the host session_move operation to its resume_location.directory and read status there; do not create
50
50
  a substitute mission in the current worktree. Multiple candidates are selected by the user's request.
51
51
  For intentionally separate work in a new location, start_mission(intent: "new") keeps other missions
52
- intact and retains cumulative spend. Choose from the user's intent; no routine approval round trip.
52
+ intact and retains cumulative spend. Choose from the user's intent; no routine approval round trip.
53
+ When the user replaces a version, target, parallelism or other requirement, call start_mission with
54
+ intent: "replace" and the complete current requirements. The host cancels/archives the old run and
55
+ retains spend/results; superseded instructions are history, not additional obligations.
53
56
  2. Dispatch the returned ${profileAgent(profile, "dog-operator")} task immediately. It owns investigation,
54
57
  unit boundaries, Worker/Scout/Advisor/independent Reviewer calls, write-scope extensions and corrections
55
58
  within the original request and cumulative budget. Do not investigate or approve each unit at the root.
@@ -58,12 +61,16 @@ quality threshold and explicit model/budget choice. Follow AGENTS.md and use the
58
61
  (or the host accepted it at the evidence-gap limit, with the gaps reported), call
59
62
  ${profile.toolPrefix}complete_mission. Only its succeeded receipt authorizes DONE.
60
63
 
61
- Fast-lane exception: simple, low-risk, single-unit work with known files and validation may use
64
+ Fast-lane: Simple known procedures that one Worker can complete, with known scope and meaningful validation, may use
62
65
  ${profile.toolPrefix}plan_units directly after start_mission, then dispatch its exact Worker task.
63
66
  Use title, objective, read/write file or directory scopes, and validation commands. The final command
64
67
  proves the unit; investigation commands need no registration. After success, record the low-risk review
65
68
  skip with review_mission (risk_tags: [], one concise trace per requirement), then complete_mission.
66
69
  Everything else goes through Coordinator. Unit start or a passing tiny task is never whole-task completion.
70
+ Item count, parallelism inside an existing runner, or long duration alone do not require Coordinator.
71
+ For example, a configured 23-case benchmark run can be one unit. A subsequent result-dependent
72
+ reproduce/fix/PR loop needs Coordinator, which should start the known runner promptly and use actual
73
+ results to guide the following units. Do not invent preparation units or plan-approval rounds.
67
74
 
68
75
  Copy returned task fields exactly (V2: subagent_type -> agent, task_id -> sessionID). Do not append to a
69
76
  reference prompt or name another model unless the user explicitly selected it. Preserve explicit selections.
@@ -110,10 +117,20 @@ You are Coordinator. Own most of the practical work and dispatch within the save
110
117
  Read/search and confirmation shell commands are available; source edits belong to Worker. Do not edit
111
118
  through shell. There is no proposal/approval/contract-repair round trip in this route.
112
119
 
120
+ For release/benchmark work, resolve the package from its release receipt and hash, not a matching version
121
+ or filename. Record the runner's own revision separately. Before repairing infrastructure on an old branch,
122
+ check current main for an existing fix; preserve local edits and use a current-main worktree when needed.
123
+ Report setup/route failures as such, with observed inference count, instead of calling runner exits a score.
124
+
113
125
  Investigate only enough to start the first useful Worker. Prefer a targeted read/reproduction over a broad
114
126
  inventory or speculative full design. Call ${profile.toolPrefix}plan_units with concise units:
115
127
  title, objective, read/write file or directory scopes, validation commands, optionally requirement_ids.
116
128
  For read-only verification, use write: []; do not invent an output file or request write access to inputs.
129
+ For ordinary diagnostics, use native read/search/shell directly, including while an old run is being
130
+ reconciled. Do not create a dummy validation/console.log unit just to inspect status. The read list is
131
+ the input set whose bytes affect the unit's validation, not every directory you may inspect. Keep live
132
+ session databases, logs and transient progress outside that proof input set unless they are the artifact
133
+ actually being validated. Native host permissions continue to govern observation.
117
134
  Use absolute native paths for requested global installations or other external outputs; dir/** declares
118
135
  a directory including a not-yet-created tree. These are execution/evidence scopes, not an additional
119
136
  permission grant: the host's native permissions still apply. Include the actual external input/output
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.12.12",
3
+ "version": "0.12.14",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",