sortie-dogs 0.13.19 → 0.13.21

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.
@@ -11,12 +11,12 @@ import { BUILT_IN_MODEL_CATALOG } from "./model-routing.js";
11
11
  import { goalFingerprint } from "../core/goal-bound.js";
12
12
  import { decoratePreviewHeadings, returnReportPanel } from "./receipt-presentation.js";
13
13
  import { sanitizeTerminalReport, terminalRunOutcome } from "./run-metrics.js";
14
- import { normalizeCommand, canonicalObservedValidationMembers, extractWritePaths } from "./gate.js";
14
+ import { normalizeCommand, canonicalObservedValidationMembers, extractWritePaths, isShellTool } from "./gate.js";
15
15
  import { normalizeExecutionScope, normalizeManifestScope, normalizeRelativePath } from "../core/path.js";
16
- import { OperatorMissionRuntime, missionAcceptanceSummary, missionPacket, missionPlan, missionReviewAccepted, missionReviewIndependent, missionReviewScope, missionReviewTask, missionCommandOutcome, missionConversationContext, missionExecutionComplete, missionExecutionStatus, missionOperationSummary, missionValidationCommand, missionReviewTraces, missionReviewVerdict, missionSelfRecheckReport } from "../core/operator-mission.js";
16
+ import { OperatorMissionRuntime, missionAcceptanceSummary, missionPacket, missionPlan, missionReviewAccepted, missionReviewIndependent, missionReviewScope, missionReviewTask, missionCommandOutcome, missionConversationContext, missionExecutionComplete, missionExecutionRecoveryAction, missionExecutionStatus, missionOperationCommandMatch, missionOperationCommandDecorated, missionOperationDirectoryMatch, missionOperationSummary, missionValidationCommand, missionReviewTraces, missionReviewVerdict, missionSelfRecheckReport } from "../core/operator-mission.js";
17
17
  import { publishMissionProgress } from "./mission-progress.js";
18
18
  import { formatWorkQuantity, planWorkQuantity } from "../core/work-quantity.js";
19
- import { cachedWorkProgress, observeActorWork, workRootForActor } from "../core/work-quantity-observations.js";
19
+ import { cachedWorkProgress, forgetWorkSession, observeActorWork, workRootForActor } from "../core/work-quantity-observations.js";
20
20
  import { serializeOperatorStatus } from "./operator-status-output.js";
21
21
  import { completedMissionReviewPrompts, initialMissionReviewPrompt, missionDeliveryObservation, missionReviewBaseline, missionReviewSource, observedMissionValidation, observedMissionValidationSummary, missionReviewValidation, reviewerCorrectionValidation, reviewerCorrectionValidationFresh, validationDirectoryMatches } from "./mission-review.js";
22
22
  import { missionLocations, missionLocationPacket } from "./mission-location.js";
@@ -242,8 +242,14 @@ export function createProfiledPlugin(profile, assetVersion) {
242
242
  return observedMissionValidationSummary(validation, child, typeof nativeSession?.messages === "function"
243
243
  ? () => session("messages", { path: { id: child }, query: { directory: input.directory } }) : undefined, notBefore, notBefore === undefined ? undefined : input.directory, direct?.unit.directExecution?.validationCwd ?? direct?.unit.unit.validation_cwd);
244
244
  }
245
- async function reviewMessages(id, notBefore) {
246
- const result = payload(await session(typeof nativeSession?.reviewMessages === "function" ? "reviewMessages" : "messages", { path: { id }, query: { directory: input.directory, ...(notBefore ? { notBefore } : {}) } }));
245
+ async function reviewMessages(id, notBefore, requireAvailable = false) {
246
+ const method = typeof nativeSession?.reviewMessages === "function" ? "reviewMessages" : "messages";
247
+ if (requireAvailable && typeof nativeSession?.[method] !== "function")
248
+ throw new Error("native-operation-history-api-unavailable");
249
+ const response = await session(method, { path: { id }, query: { directory: input.directory, ...(notBefore ? { notBefore } : {}) } });
250
+ const result = payload(response);
251
+ if (requireAvailable && (record(response) && response.error || !Array.isArray(result)))
252
+ throw new Error("native-operation-history-response-unavailable");
247
253
  return Array.isArray(result) ? result.filter(record) : [];
248
254
  }
249
255
  async function observedSessionModel(id) {
@@ -551,7 +557,7 @@ export function createProfiledPlugin(profile, assetVersion) {
551
557
  // not by resetting the goal budget. This checkpoint grants no dispatch itself.
552
558
  if (mission && (!["completed", "cancelled"].includes(mission.phase) ||
553
559
  (mission.phase === "cancelled" && run))) {
554
- return JSON.stringify(missionPacket(mission, run));
560
+ return JSON.stringify(missionPacket(mission, run, false));
555
561
  }
556
562
  return await operators.continuationCheckpoint(root) ?? await proposals.continuationCheckpoint(root);
557
563
  },
@@ -1617,6 +1623,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1617
1623
  if (mission) {
1618
1624
  const budget = await control.currentBudget(root);
1619
1625
  const run = await operators.read(root);
1626
+ mission = await reconcileMissionExecution(root, mission, run);
1620
1627
  const completion = run?.phase === "awaiting-acceptance" ? await control.completionReadiness(root) : undefined;
1621
1628
  const acceptanceSummary = await missionAcceptanceSummary(mission, run, operators, acceptanceValidationObservation);
1622
1629
  const packet = { acceptance_summary: acceptanceSummary, ...missionDispatchPacket(mission, run), budget,
@@ -1635,6 +1642,9 @@ export function createProfiledPlugin(profile, assetVersion) {
1635
1642
  delete packet.task;
1636
1643
  delete packet.coordinator_dispatch;
1637
1644
  }
1645
+ const recoveryAction = missionExecutionRecoveryAction(mission);
1646
+ if (recoveryAction)
1647
+ packet.next_action = recoveryAction;
1638
1648
  if (view === "progress")
1639
1649
  return serializeOperatorStatus(missionProgress(mission, run, budget, packet));
1640
1650
  return serializeOperatorStatus(view === "full" ? packet : compactMissionEvidence(packet, run, root));
@@ -1766,10 +1776,12 @@ export function createProfiledPlugin(profile, assetVersion) {
1766
1776
  await requireRoot(context.sessionID);
1767
1777
  const state = await operators.required(context.sessionID);
1768
1778
  let mission = await missions.read(context.sessionID);
1779
+ if (mission)
1780
+ mission = await reconcileMissionExecution(context.sessionID, mission, state);
1769
1781
  if (mission?.kind === "operation" && !missionExecutionComplete(mission))
1770
1782
  return JSON.stringify({
1771
1783
  status: "not-ready", operation_status: missionExecutionStatus(mission),
1772
- next_action: "The requested operation has not completed. Preserve its real outcome; auxiliary checks and a bounded review do not complete it. Continue the declared operation or report its blocker.",
1784
+ next_action: missionExecutionRecoveryAction(mission) ?? "The requested operation has not completed. Preserve its real outcome; auxiliary checks and a bounded review do not complete it. Continue the declared operation or report its blocker.",
1773
1785
  packet: missionPacket(mission, state)
1774
1786
  });
1775
1787
  if (mission && !["completed", "cancelled"].includes(mission.phase))
@@ -2518,6 +2530,225 @@ export function createProfiledPlugin(profile, assetVersion) {
2518
2530
  const commands = [...new Set(execution.commands.map(missionValidationCommand).map(normalizeCommand))];
2519
2531
  return { commands, directory: resolve(input.directory, execution.directory), observations: operation?.observations ?? [] };
2520
2532
  }
2533
+ async function reconcileMissionExecution(root, mission, run) {
2534
+ const execution = mission.execution;
2535
+ if (mission.kind !== "operation" || !execution || !run || run.runID !== mission.runID ||
2536
+ ["completed", "cancelled"].includes(mission.phase))
2537
+ return mission;
2538
+ const owners = new Map();
2539
+ const recoveryErrors = [];
2540
+ const addOwner = (actor, window) => owners.set(actor, [...(owners.get(actor) ?? []), window]);
2541
+ for (const unit of run.units) {
2542
+ if (unit.directExecution?.actor) {
2543
+ addOwner(unit.directExecution.actor, { notBefore: unit.directExecution.startedAt,
2544
+ ...(unit.directExecution.finishedAt ? { notAfter: unit.directExecution.finishedAt } : {}) });
2545
+ continue;
2546
+ }
2547
+ const child = unit.childSessionID;
2548
+ if (!child || !unit.callID)
2549
+ continue;
2550
+ const attempt = mission.attempts?.find(item => item.runID === run.runID && item.unitID === unit.unit.id &&
2551
+ item.callID === unit.callID && item.childSessionID === child);
2552
+ if (!attempt || attempt.status === "pending")
2553
+ continue;
2554
+ let info;
2555
+ try {
2556
+ const response = await session("get", { path: { id: child }, query: { directory: input.directory } });
2557
+ const value = payload(response);
2558
+ if (!record(value) || record(response) && response.error)
2559
+ throw new Error("native-operation-session-unavailable");
2560
+ info = value;
2561
+ }
2562
+ catch (error) {
2563
+ recoveryErrors.push({ sessionID: child, reason: error instanceof Error ? error.message : String(error) });
2564
+ continue;
2565
+ }
2566
+ if (info.id !== undefined && info.id !== child || info.parentID !== (run.operatorSessionID ?? root))
2567
+ continue;
2568
+ const created = record(info.time) ? info.time.created : undefined;
2569
+ const createdAt = typeof created === "number" && Number.isFinite(created) ? created
2570
+ : typeof created === "string" ? Date.parse(created) : NaN;
2571
+ // A reusable correction author belongs to this admission, not every earlier turn
2572
+ // since the conversation was created. Ordinary Workers have a new native session.
2573
+ const admittedAt = Date.parse(unit.reviewerCorrection?.admittedAt ?? run.createdAt);
2574
+ const settled = unit.reviewerCorrection && ["succeeded", "failed", "cancelled"].includes(unit.status)
2575
+ ? [...mission.progress].reverse().find(item => item.unit === `${run.runID}/${unit.unit.id}` &&
2576
+ ["succeeded", "failed", "cancelled"].includes(item.status) && Number.isFinite(Date.parse(item.at)))?.at
2577
+ : undefined;
2578
+ // The correction session is reusable after settlement; its later turns are
2579
+ // not this unit's operation. Reuse the existing recorded settlement boundary.
2580
+ if (unit.reviewerCorrection && unit.status !== "running" && !settled)
2581
+ continue;
2582
+ if (Number.isFinite(createdAt) && Number.isFinite(admittedAt)) {
2583
+ addOwner(child, { notBefore: new Date(Math.max(createdAt, admittedAt)).toISOString(),
2584
+ ...(settled ? { notAfter: settled } : {}) });
2585
+ }
2586
+ }
2587
+ if (owners.size === 0 && recoveryErrors.length === 0)
2588
+ return mission;
2589
+ const recovered = [];
2590
+ const observedInputs = [];
2591
+ const conflictingCalls = [];
2592
+ const laterStart = (saved, observed) => !saved || Date.parse(observed) > Date.parse(saved) ? observed : saved;
2593
+ for (const [sessionID, windows] of owners) {
2594
+ // A direct session has its own native admission time; a delegated session is bounded
2595
+ // by its native creation time when exposed by the existing session API.
2596
+ const notBefore = windows.map(window => window.notBefore).sort()[0];
2597
+ if (!notBefore)
2598
+ continue;
2599
+ let history;
2600
+ try {
2601
+ history = await reviewMessages(sessionID, notBefore, true);
2602
+ }
2603
+ catch (error) {
2604
+ recoveryErrors.push({ sessionID, reason: error instanceof Error ? error.message : String(error) });
2605
+ continue;
2606
+ }
2607
+ const calls = new Map();
2608
+ const conflicts = new Set();
2609
+ const nativeStarts = new Map();
2610
+ const nativeInputs = new Map();
2611
+ for (const message of history) {
2612
+ const info = record(message.info) ? message.info : message;
2613
+ if (info.role !== "assistant" || info.sessionID !== sessionID || !Array.isArray(message.parts))
2614
+ continue;
2615
+ for (const part of message.parts) {
2616
+ if (!record(part) || part.type !== "tool" || !isShellTool(String(part.tool)) ||
2617
+ (part.sessionID !== undefined && part.sessionID !== sessionID) || typeof part.callID !== "string" ||
2618
+ !record(part.state) || !record(part.state.input))
2619
+ continue;
2620
+ const state = part.state;
2621
+ const nativeInput = state.input;
2622
+ if (typeof nativeInput.command !== "string")
2623
+ continue;
2624
+ const time = record(state.time) ? state.time : record(part.time) ? part.time : {};
2625
+ const date = (value) => typeof value === "number" && Number.isFinite(value) ? new Date(value).toISOString()
2626
+ : typeof value === "string" && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : undefined;
2627
+ const startedAt = date(time.start ?? time.ran);
2628
+ if (!startedAt)
2629
+ continue;
2630
+ // A boundary page may include tools from a later reusable-session turn.
2631
+ // Ignore them before comparing call input or terminal state as well.
2632
+ if (!windows.some(window => Date.parse(startedAt) >= Date.parse(window.notBefore) &&
2633
+ (!window.notAfter || Date.parse(startedAt) <= Date.parse(window.notAfter))))
2634
+ continue;
2635
+ const nativeDirectory = resolve(input.directory, typeof nativeInput.workdir === "string" ? nativeInput.workdir : ".");
2636
+ const directory = missionOperationDirectoryMatch(nativeDirectory, execution.directory) ? execution.directory : nativeDirectory;
2637
+ const key = part.callID;
2638
+ nativeStarts.set(key, laterStart(nativeStarts.get(key), startedAt));
2639
+ const nativeInputIdentity = { command: nativeInput.command, directory };
2640
+ const priorInput = nativeInputs.get(key);
2641
+ if (priorInput && (priorInput.command !== nativeInput.command || priorInput.directory !== directory)) {
2642
+ conflicts.add(key);
2643
+ }
2644
+ nativeInputs.set(key, nativeInputIdentity);
2645
+ const command = missionOperationCommandMatch(nativeInput.command, execution.commands, execution.directory);
2646
+ // Compare admitted inputs with persisted same-call observations even when this
2647
+ // record is no longer a matching command/cwd and will not yield a new result.
2648
+ observedInputs.push({ sessionID, callID: key, command, directory, startedAt });
2649
+ if (directory !== execution.directory)
2650
+ continue;
2651
+ if (!command)
2652
+ continue;
2653
+ const metadata = record(state.metadata) ? state.metadata : {};
2654
+ const nativeStatus = String(state.status ?? "").toLowerCase();
2655
+ const resultStatus = String(metadata.status ?? "").toLowerCase();
2656
+ if ((nativeStatus === "error" && resultStatus === "running") || (nativeStatus === "running" && resultStatus === "error")) {
2657
+ conflicts.add(key);
2658
+ }
2659
+ const running = nativeStatus === "running" || resultStatus === "running";
2660
+ const failed = nativeStatus === "error" || resultStatus === "error";
2661
+ if (!["completed", "error", "running"].includes(nativeStatus))
2662
+ continue;
2663
+ if (!running && !failed && nativeStatus !== "completed")
2664
+ continue;
2665
+ const completedAt = running ? undefined : date(time.end ?? time.completed);
2666
+ const exit = Number.isSafeInteger(metadata.exit) ? metadata.exit : undefined;
2667
+ const output = typeof state.output === "string" ? state.output : typeof part.output === "string" ? part.output : "";
2668
+ const status = failed ? "error" : running ? "running" : "completed";
2669
+ const outcome = conflicts.has(key) || running || !completedAt || exit === undefined ? undefined : missionCommandOutcome(output, exit, status);
2670
+ // A completed tool envelope without an exit/result does not prove a successful
2671
+ // process. Keep it unclassified instead of turning missing metadata into success.
2672
+ if (nativeInput.background === true && !running)
2673
+ continue;
2674
+ const observation = { command, directory: execution.directory, callID: key, sessionID, startedAt, nativeStartedAt: startedAt,
2675
+ ...(completedAt ? { completedAt } : {}), ...(exit === undefined ? {} : { exit }), status,
2676
+ ...(typeof metadata.shellID === "string" ? { shellID: metadata.shellID } : {}), ...(outcome ?? {}) };
2677
+ const prior = calls.get(key);
2678
+ if (prior && (prior.command !== command || prior.directory !== directory || prior.observation.startedAt !== startedAt ||
2679
+ prior.observation.completedAt && observation.completedAt && (prior.observation.status !== status ||
2680
+ prior.observation.completedAt !== observation.completedAt ||
2681
+ prior.observation.exit !== undefined && exit !== undefined && prior.observation.exit !== exit ||
2682
+ prior.observation.outcome !== undefined && observation.outcome !== undefined && JSON.stringify(prior.observation) !== JSON.stringify(observation)))) {
2683
+ conflicts.add(key);
2684
+ }
2685
+ else if (!prior || !conflicts.has(key) && (prior.observation.outcome === undefined || observation.outcome !== undefined)) {
2686
+ calls.set(key, { command, directory, observation });
2687
+ }
2688
+ }
2689
+ }
2690
+ for (const [callID, item] of calls)
2691
+ recovered.push({ ...item.observation, ...(conflicts.has(callID)
2692
+ ? { conflicting: true, latestConflictingStartedAt: nativeStarts.get(callID) } : {}) });
2693
+ for (const callID of conflicts)
2694
+ conflictingCalls.push({ sessionID, callID, latestConflictingStartedAt: nativeStarts.get(callID) });
2695
+ }
2696
+ if (recovered.length === 0 && conflictingCalls.length === 0 && observedInputs.length === 0 && recoveryErrors.length === 0 && !execution.recovery)
2697
+ return mission;
2698
+ return missions.update(root, current => {
2699
+ if (current.runID !== mission.runID || current.kind !== "operation" || !current.execution ||
2700
+ ["completed", "cancelled"].includes(current.phase) ||
2701
+ current.execution.directory !== execution.directory || JSON.stringify(current.execution.commands) !== JSON.stringify(execution.commands))
2702
+ return;
2703
+ if (recoveryErrors.length)
2704
+ current.execution.recovery = { status: "unavailable", errors: recoveryErrors };
2705
+ else
2706
+ delete current.execution.recovery;
2707
+ for (const nativeInput of observedInputs) {
2708
+ const prior = current.execution.observations.find(item => item.callID === nativeInput.callID && item.sessionID === nativeInput.sessionID);
2709
+ if (prior && (prior.command !== nativeInput.command || prior.directory !== nativeInput.directory)) {
2710
+ prior.conflicting = true;
2711
+ prior.latestConflictingStartedAt = laterStart(prior.latestConflictingStartedAt ?? prior.nativeStartedAt, nativeInput.startedAt);
2712
+ }
2713
+ }
2714
+ for (const observation of recovered) {
2715
+ const prior = current.execution.observations.find(item => item.callID === observation.callID && item.sessionID === observation.sessionID);
2716
+ if (!prior) {
2717
+ current.execution.observations.push(observation);
2718
+ continue;
2719
+ }
2720
+ if (prior.command !== observation.command || prior.directory !== observation.directory)
2721
+ continue;
2722
+ if (observation.conflicting || prior.nativeStartedAt !== undefined && prior.nativeStartedAt !== observation.nativeStartedAt ||
2723
+ prior.completedAt && observation.completedAt && (prior.status !== observation.status ||
2724
+ prior.exit !== undefined && observation.exit !== undefined && prior.exit !== observation.exit ||
2725
+ prior.outcome !== undefined && observation.outcome !== undefined && prior.outcome !== observation.outcome)) {
2726
+ prior.conflicting = true;
2727
+ }
2728
+ if (prior.conflicting)
2729
+ prior.latestConflictingStartedAt = laterStart(prior.latestConflictingStartedAt ?? prior.nativeStartedAt, observation.latestConflictingStartedAt ?? observation.nativeStartedAt);
2730
+ prior.nativeStartedAt ??= observation.nativeStartedAt;
2731
+ if (prior.outcome !== undefined)
2732
+ continue;
2733
+ const latestConflictingStartedAt = prior.latestConflictingStartedAt;
2734
+ const nativeStartedAt = prior.nativeStartedAt;
2735
+ delete prior.completedAt;
2736
+ delete prior.exit;
2737
+ delete prior.outcome;
2738
+ delete prior.result;
2739
+ Object.assign(prior, observation, { startedAt: prior.startedAt,
2740
+ ...(nativeStartedAt ? { nativeStartedAt } : {}),
2741
+ ...(latestConflictingStartedAt ? { latestConflictingStartedAt } : {}) });
2742
+ }
2743
+ for (const conflict of conflictingCalls) {
2744
+ const prior = current.execution.observations.find(item => item.callID === conflict.callID && item.sessionID === conflict.sessionID);
2745
+ if (prior) {
2746
+ prior.conflicting = true;
2747
+ prior.latestConflictingStartedAt = laterStart(prior.latestConflictingStartedAt ?? prior.nativeStartedAt, conflict.latestConflictingStartedAt);
2748
+ }
2749
+ }
2750
+ });
2751
+ }
2521
2752
  async function declareMissionUnits(root, actor, mission, raw, reason, execution, planningCallID, handoffNotes, workQuantities) {
2522
2753
  await control.currentBudget(root);
2523
2754
  mission = await missions.required(root);
@@ -3186,13 +3417,14 @@ export function createProfiledPlugin(profile, assetVersion) {
3186
3417
  args: { status: { type: "string", enum: ["ready", "needs-decision", "blocked"] }, summary: stringSchema }, execute: async (args, context) => {
3187
3418
  const { root } = await missionAuthority(context.sessionID);
3188
3419
  return serializeDispatchTransition(root, async () => {
3189
- const mission = await missions.required(root);
3420
+ let mission = await missions.required(root);
3421
+ mission = await reconcileMissionExecution(root, mission, await operators.read(root));
3190
3422
  if (args.status === "ready") {
3191
3423
  if (mission.kind === "operation" && !missionExecutionComplete(mission)) {
3192
3424
  return JSON.stringify({ ...missionPacket(mission, await operators.read(root)), status: "operation-incomplete",
3193
- next_action: missionExecutionStatus(mission) === "running"
3425
+ next_action: missionExecutionRecoveryAction(mission) ?? (missionExecutionStatus(mission) === "running"
3194
3426
  ? "The declared operation is already running. Inspect its native shell/progress; do not start another Worker or run. Wait for a terminal result, or report the existing run as blocked if its completion cannot be observed."
3195
- : "Continue the requested operation or submit its actual blocker. A successful setup/NO_START check does not authorize ready." });
3427
+ : "Continue the requested operation or submit its actual blocker. A successful setup/NO_START check does not authorize ready.") });
3196
3428
  }
3197
3429
  const run = await operators.required(root);
3198
3430
  if (run.phase !== "awaiting-acceptance")
@@ -3215,7 +3447,8 @@ export function createProfiledPlugin(profile, assetVersion) {
3215
3447
  ...compactMissionEvidence(missionPacket(updated, submittedRun), submittedRun, root),
3216
3448
  next_action: args.status === "ready"
3217
3449
  ? "Return this result and acceptance_summary to Operator now. Compare the original requests with the candidate and actual evidence; inspect concrete gaps only, then complete_mission if satisfied. No routine archive search or full source reread. Completion still requires its final comparison and complete_mission receipt."
3218
- : "Return the saved blocker or user-only decision to Operator now. This is incomplete delivery, not acceptance; retain the candidate, failed checks and cumulative spend. Do not repeat unchanged work or dispatch a replacement merely to finalize this result." });
3450
+ : ["Return the saved blocker or user-only decision to Operator now. This is incomplete delivery, not acceptance; retain the candidate, failed checks and cumulative spend. Do not repeat unchanged work or dispatch a replacement merely to finalize this result.",
3451
+ missionExecutionRecoveryAction(updated)].filter(Boolean).join(" ") });
3219
3452
  });
3220
3453
  } };
3221
3454
  tools[completeMission] = { description: "Operator only: after comparing the original request, source, actual checks and current review disposition (explicit native author self-recheck is not independent PASS), explicitly accept the whole mission. A collected terminal failure can satisfy a run-once/report request, not a request for successful execution. Returns operation outcome separately from the measured 🐾 Mission receipt; never treat Worker start, CORRECTION_READY or one passing check as completion.",
@@ -3872,17 +4105,16 @@ export function createProfiledPlugin(profile, assetVersion) {
3872
4105
  const operationArgs = record(mapped.args) ? mapped.args : {};
3873
4106
  // A decorated launch would spend on the real operation but leave the native mission observation empty.
3874
4107
  // Ask the same Worker to correct its shell input before that expensive side effect.
3875
- if (writer && ["bash", "shell"].includes(request.tool.toLowerCase()) && mission?.execution &&
4108
+ if (writer && isShellTool(request.tool) && mission?.execution &&
3876
4109
  typeof operationArgs.command === "string" &&
3877
- resolve(input.directory, typeof operationArgs.workdir === "string" ? operationArgs.workdir : ".") === mission.execution.directory) {
3878
- const actual = normalizeCommand(operationArgs.command);
3879
- if (operationArgs.background === true && mission.execution.commands.includes(actual)) {
4110
+ missionOperationDirectoryMatch(resolve(input.directory, typeof operationArgs.workdir === "string" ? operationArgs.workdir : "."), mission.execution.directory)) {
4111
+ const declaredOperation = missionOperationCommandMatch(operationArgs.command, mission.execution.commands, mission.execution.directory);
4112
+ if (operationArgs.background === true && declaredOperation !== undefined) {
3880
4113
  throw new Error("mission-operation-background: run the declared command in foreground with a suitable timeout. " +
3881
4114
  "A background shell reports only launch, not process exit; no run was started by this refused call. " +
3882
4115
  "Correct this same Worker command without a new plan or approval.");
3883
4116
  }
3884
- const decorated = mission.execution.commands.some(declared => actual.startsWith(declared) &&
3885
- /^\s*(?:\||\d?>|&&?|;)/u.test(actual.slice(declared.length)));
4117
+ const decorated = missionOperationCommandDecorated(operationArgs.command, mission.execution.commands, mission.execution.directory);
3886
4118
  if (decorated)
3887
4119
  throw new Error("mission-operation-command-not-observed: run the declared operation command exactly; " +
3888
4120
  "do not append a pipe, tee, redirection or chained command. Capture its tool output or write a separate result file afterward. " +
@@ -3926,16 +4158,16 @@ export function createProfiledPlugin(profile, assetVersion) {
3926
4158
  }
3927
4159
  else
3928
4160
  Object.assign(output, outward);
3929
- if (writer && ["bash", "shell"].includes(request.tool) && mission?.execution &&
3930
- typeof args.command === "string" && mission.execution.commands.includes(normalizeCommand(args.command)) &&
3931
- resolve(input.directory, typeof args.workdir === "string" ? args.workdir : ".") === mission.execution.directory) {
3932
- const command = normalizeCommand(args.command);
3933
- await missions.update(root, item => {
3934
- if (!item.execution.observations.some(observation => observation.callID === request.callID)) {
3935
- item.execution.observations.push({ command, directory: item.execution.directory,
3936
- callID: request.callID, sessionID: request.sessionID, startedAt: new Date().toISOString() });
3937
- }
3938
- });
4161
+ if (writer && isShellTool(request.tool) && mission?.execution &&
4162
+ typeof args.command === "string" && missionOperationDirectoryMatch(resolve(input.directory, typeof args.workdir === "string" ? args.workdir : "."), mission.execution.directory)) {
4163
+ const command = missionOperationCommandMatch(args.command, mission.execution.commands, mission.execution.directory);
4164
+ if (command !== undefined)
4165
+ await missions.update(root, item => {
4166
+ if (!item.execution.observations.some(observation => observation.callID === request.callID)) {
4167
+ item.execution.observations.push({ command, directory: item.execution.directory,
4168
+ callID: request.callID, sessionID: request.sessionID, startedAt: new Date().toISOString() });
4169
+ }
4170
+ });
3939
4171
  }
3940
4172
  }
3941
4173
  catch (error) {
@@ -4192,7 +4424,7 @@ export function createProfiledPlugin(profile, assetVersion) {
4192
4424
  await core["tool.execute.after"]?.({ ...translate(request, false),
4193
4425
  ...(ownership ? { sessionID: ownership.root } : {}) }, mapped);
4194
4426
  Object.assign(output, translate(mapped, true));
4195
- if (!ownership && ["bash", "shell"].includes(request.tool.toLowerCase())) {
4427
+ if (!ownership && isShellTool(request.tool)) {
4196
4428
  const root = await rootFor(id);
4197
4429
  const mission = root ? await missions.read(root) : undefined;
4198
4430
  if (root && mission?.execution?.observations.some(item => item.callID === request.callID && item.sessionID === id)) {
@@ -4211,7 +4443,9 @@ export function createProfiledPlugin(profile, assetVersion) {
4211
4443
  observation.status = output.status === "error" || metadata.status === "error" ? "error" : "completed";
4212
4444
  if (Number.isSafeInteger(metadata.exit))
4213
4445
  observation.exit = metadata.exit;
4214
- Object.assign(observation, missionCommandOutcome(returnedOutput, metadata.exit, observation.status));
4446
+ if (Number.isSafeInteger(metadata.exit)) {
4447
+ Object.assign(observation, missionCommandOutcome(returnedOutput, metadata.exit, observation.status));
4448
+ }
4215
4449
  });
4216
4450
  }
4217
4451
  }
@@ -4489,42 +4723,48 @@ export function createProfiledPlugin(profile, assetVersion) {
4489
4723
  : event.type.startsWith("session.") && typeof info?.id === "string" ? info.id : undefined;
4490
4724
  if (!id)
4491
4725
  return;
4492
- if (event.type === "session.deleted" && canonicalAgent(profile, selected.get(id)) === "dog-coordinator") {
4493
- await stop(id, "session-deleted");
4494
- return;
4495
- }
4496
- if (!await rootFor(id))
4497
- return;
4498
- const mappedEvent = translate({ event }, false);
4499
- const mappedPart = record(mappedEvent.event.properties?.part) ? mappedEvent.event.properties.part : undefined;
4500
- if ((await identity(id)).role === "dog-coordinator" && mappedPart?.type === "text" && typeof mappedPart.text === "string") {
4501
- mappedPart.text = forwardTerminalText(mappedPart.text);
4502
- }
4503
- await core.event?.(mappedEvent);
4504
- if (event.type === "message.updated" && info)
4505
- await renderCompletedReply(id, info);
4506
- if (event.type === "message.part.updated" && part?.type === "tool" && part.tool === "task" &&
4507
- typeof part.callID === "string" && record(part.state) && part.state.status === "error") {
4508
- const ownership = taskOwners.get(part.callID);
4509
- if (ownership !== undefined && ownership.actor === id) {
4510
- if (ownership.mission) {
4511
- await missions.update(ownership.root, state => { state.dispatchOpen = false; });
4512
- taskOwners.delete(part.callID);
4513
- return;
4514
- }
4515
- if (ownership.consultation) {
4516
- taskOwners.delete(part.callID);
4517
- return;
4726
+ try {
4727
+ if (event.type === "session.deleted" && canonicalAgent(profile, selected.get(id)) === "dog-coordinator") {
4728
+ await stop(id, "session-deleted");
4729
+ return;
4730
+ }
4731
+ if (!await rootFor(id))
4732
+ return;
4733
+ const mappedEvent = translate({ event }, false);
4734
+ const mappedPart = record(mappedEvent.event.properties?.part) ? mappedEvent.event.properties.part : undefined;
4735
+ if ((await identity(id)).role === "dog-coordinator" && mappedPart?.type === "text" && typeof mappedPart.text === "string") {
4736
+ mappedPart.text = forwardTerminalText(mappedPart.text);
4737
+ }
4738
+ await core.event?.(mappedEvent);
4739
+ if (event.type === "message.updated" && info)
4740
+ await renderCompletedReply(id, info);
4741
+ if (event.type === "message.part.updated" && part?.type === "tool" && part.tool === "task" &&
4742
+ typeof part.callID === "string" && record(part.state) && part.state.status === "error") {
4743
+ const ownership = taskOwners.get(part.callID);
4744
+ if (ownership !== undefined && ownership.actor === id) {
4745
+ if (ownership.mission) {
4746
+ await missions.update(ownership.root, state => { state.dispatchOpen = false; });
4747
+ taskOwners.delete(part.callID);
4748
+ return;
4749
+ }
4750
+ if (ownership.consultation) {
4751
+ taskOwners.delete(part.callID);
4752
+ return;
4753
+ }
4754
+ const settled = ownership.proposal
4755
+ ? (await control.settleProposalBudget(ownership.root, (await proposals.required(ownership.root)).intent_id, part.callID, "failed"), true)
4756
+ : ownership.operator
4757
+ ? (await operators.operatorRejected(ownership.root), true)
4758
+ : await control?.settleRejectedDispatch(ownership.root, part.callID) ?? false;
4759
+ if (settled)
4760
+ taskOwners.delete(part.callID);
4518
4761
  }
4519
- const settled = ownership.proposal
4520
- ? (await control.settleProposalBudget(ownership.root, (await proposals.required(ownership.root)).intent_id, part.callID, "failed"), true)
4521
- : ownership.operator
4522
- ? (await operators.operatorRejected(ownership.root), true)
4523
- : await control?.settleRejectedDispatch(ownership.root, part.callID) ?? false;
4524
- if (settled)
4525
- taskOwners.delete(part.callID);
4526
4762
  }
4527
4763
  }
4764
+ finally {
4765
+ if (event.type === "session.deleted")
4766
+ forgetWorkSession(input.directory, id);
4767
+ }
4528
4768
  },
4529
4769
  };
4530
4770
  const before = hooks["tool.execute.before"];