sortie-dogs 0.5.15 → 0.5.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ Requirements: Node.js 22.6 or newer, npm, and OpenCode.
20
20
 
21
21
  Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
22
22
 
23
- Release: [v0.5.15](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.5.15)
23
+ Release: [v0.5.17](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.5.17)
24
24
 
25
25
  ## Quick start
26
26
 
@@ -13,6 +13,7 @@ const MAX_OUTPUT = 1024 * 1024;
13
13
  const MAX_TIMEOUT = 10 * 60_000;
14
14
  const GIT_TIMEOUT = 30_000;
15
15
  const EXIT_GRACE = 500;
16
+ let systemdUnitSequence = 0;
16
17
  const KILL_WAIT = 2_000;
17
18
  const WINDOWS_WRAPPER_GRACE = 15_000;
18
19
  const LINUX_WRAPPER_GRACE = 10_000;
@@ -483,9 +484,163 @@ async function terminateTree(child, closed) {
483
484
  return false;
484
485
  };
485
486
  if (!(await groupGone())) {
486
- throw new WorktreeCommitArtifactError("validation-failed", "Process-tree termination could not be confirmed.");
487
+ try {
488
+ process.kill(-child.pid, "SIGKILL");
489
+ }
490
+ catch { /* Already closed. */ }
491
+ if (!(await groupGone())) {
492
+ throw new WorktreeCommitArtifactError("validation-failed", "Process-tree termination could not be confirmed.");
493
+ }
494
+ }
495
+ }
496
+ }
497
+ async function linuxSystemdRun() {
498
+ for (const candidate of ["/usr/bin/systemd-run", "/bin/systemd-run"]) {
499
+ const canonical = await realpath(candidate).catch(() => undefined);
500
+ if (canonical === undefined || !canonical.startsWith("/usr/bin/") && !canonical.startsWith("/bin/"))
501
+ continue;
502
+ const info = await lstat(canonical).catch(() => undefined);
503
+ if (info !== undefined && info.isFile() && !info.isSymbolicLink() && info.uid === 0 && (info.mode & 0o111) !== 0)
504
+ return canonical;
505
+ }
506
+ throw new WorktreeCommitArtifactError("validation-failed", "Validation containment setup failed.");
507
+ }
508
+ async function linuxSystemctl() {
509
+ for (const candidate of ["/usr/bin/systemctl", "/bin/systemctl"]) {
510
+ const canonical = await realpath(candidate).catch(() => undefined);
511
+ if (canonical === undefined || !canonical.startsWith("/usr/bin/") && !canonical.startsWith("/bin/"))
512
+ continue;
513
+ const info = await lstat(canonical).catch(() => undefined);
514
+ if (info !== undefined && info.isFile() && !info.isSymbolicLink() && info.uid === 0 && (info.mode & 0o111) !== 0)
515
+ return canonical;
516
+ }
517
+ throw new WorktreeCommitArtifactError("validation-failed", "Validation containment setup failed.");
518
+ }
519
+ async function linuxEnvironmentExecutable() {
520
+ for (const candidate of ["/usr/bin/env", "/bin/env"]) {
521
+ const canonical = await realpath(candidate).catch(() => undefined);
522
+ if (canonical === undefined || !canonical.startsWith("/usr/bin/") && !canonical.startsWith("/bin/"))
523
+ continue;
524
+ const info = await lstat(canonical).catch(() => undefined);
525
+ if (info !== undefined && info.isFile() && !info.isSymbolicLink() && info.uid === 0 && (info.mode & 0o111) !== 0)
526
+ return canonical;
527
+ }
528
+ throw new WorktreeCommitArtifactError("validation-failed", "Validation containment setup failed.");
529
+ }
530
+ async function linuxShell() {
531
+ for (const candidate of ["/bin/sh", "/usr/bin/sh"]) {
532
+ const canonical = await realpath(candidate).catch(() => undefined);
533
+ if (canonical === undefined || !canonical.startsWith("/usr/bin/") && !canonical.startsWith("/bin/"))
534
+ continue;
535
+ const info = await lstat(canonical).catch(() => undefined);
536
+ if (info !== undefined && info.isFile() && !info.isSymbolicLink() && info.uid === 0 && (info.mode & 0o111) !== 0)
537
+ return canonical;
538
+ }
539
+ throw new WorktreeCommitArtifactError("validation-failed", "Validation containment setup failed.");
540
+ }
541
+ async function stopSystemdUnit(unit, environment) {
542
+ const systemctl = await linuxSystemctl();
543
+ const invoke = async (args) => {
544
+ const child = spawn(systemctl, args, { env: environment, shell: false, windowsHide: true, stdio: "ignore" });
545
+ const closed = new Promise((done, reject) => {
546
+ child.once("error", reject);
547
+ child.once("close", (code) => done({ code }));
548
+ });
549
+ if (!(await waitForClose(closed, KILL_WAIT + 1_500))) {
550
+ child.kill("SIGKILL");
551
+ if (!(await waitForClose(closed, KILL_WAIT))) {
552
+ throw new WorktreeCommitArtifactError("validation-failed", "Validation containment shutdown failed.");
553
+ }
487
554
  }
555
+ return (await closed).code;
556
+ };
557
+ await invoke(["--user", "stop", unit]).catch(() => undefined);
558
+ const activeState = async () => {
559
+ const code = await invoke(["--user", "is-active", "--quiet", unit]);
560
+ if (code === 0)
561
+ return true;
562
+ if (code === 3 || code === 4)
563
+ return false;
564
+ throw new WorktreeCommitArtifactError("validation-failed", "Validation containment shutdown failed.");
565
+ };
566
+ const deadline = Date.now() + KILL_WAIT;
567
+ while (Date.now() < deadline) {
568
+ if (await activeState()) {
569
+ await invoke(["--user", "stop", unit]).catch(() => undefined);
570
+ }
571
+ await new Promise((done) => setTimeout(done, 25));
572
+ }
573
+ if (await activeState()) {
574
+ throw new WorktreeCommitArtifactError("validation-failed", "Validation containment shutdown failed.");
575
+ }
576
+ }
577
+ async function runLinuxSystemd(executable, args, cwd, timeout) {
578
+ const systemdRun = await linuxSystemdRun();
579
+ const [environmentExecutable, shell] = await Promise.all([linuxEnvironmentExecutable(), linuxShell()]);
580
+ const uid = process.getuid?.();
581
+ if (uid === undefined)
582
+ throw new WorktreeCommitArtifactError("validation-failed", "Validation containment setup failed.");
583
+ const runtimeDirectory = `/run/user/${uid}`;
584
+ const environment = {
585
+ ...cleanEnvironment(),
586
+ XDG_RUNTIME_DIR: runtimeDirectory,
587
+ DBUS_SESSION_BUS_ADDRESS: `unix:path=${runtimeDirectory}/bus`,
588
+ };
589
+ systemdUnitSequence = (systemdUnitSequence + 1) % Number.MAX_SAFE_INTEGER;
590
+ const unit = `sortie-dogs-${process.pid}-${Date.now()}-${systemdUnitSequence}`;
591
+ const validationEnvironment = Object.entries(cleanEnvironment()).map(([key, value]) => `${key}=${value}`);
592
+ const child = spawn(systemdRun, [
593
+ "--user", "--wait", "--collect", "--quiet", `--unit=${unit}`,
594
+ "--property=KillMode=control-group", `--property=RuntimeMaxSec=${timeout}ms`,
595
+ "--property=TimeoutStopSec=1s", "--working-directory", cwd, "--",
596
+ environmentExecutable, "-i", ...validationEnvironment, shell, "-c",
597
+ '"$@"; code=$?; if [ "$code" -eq 0 ]; then exit 0; else exit 239; fi',
598
+ "sortie-validation", executable, ...args,
599
+ ], {
600
+ cwd, env: environment, shell: false, windowsHide: true, detached: true, stdio: ["ignore", "pipe", "pipe"],
601
+ });
602
+ const chunks = [];
603
+ let outputBytes = 0;
604
+ let overflow = false;
605
+ const collect = (chunk) => {
606
+ outputBytes += chunk.byteLength;
607
+ if (outputBytes <= MAX_OUTPUT)
608
+ chunks.push(chunk);
609
+ else
610
+ overflow = true;
611
+ };
612
+ child.stdout.on("data", collect);
613
+ child.stderr.on("data", (chunk) => {
614
+ outputBytes += chunk.byteLength;
615
+ if (outputBytes > MAX_OUTPUT)
616
+ overflow = true;
617
+ });
618
+ const closed = new Promise((done, reject) => {
619
+ child.once("error", reject);
620
+ child.once("close", (code) => done({ code }));
621
+ });
622
+ let timer;
623
+ const bounded = await Promise.race([
624
+ closed.then((result) => ({ kind: "closed", result })),
625
+ new Promise((done) => {
626
+ timer = setTimeout(() => done({ kind: "timeout" }), timeout + LINUX_WRAPPER_GRACE);
627
+ }),
628
+ ]).catch(async () => {
629
+ await terminateTree(child, closed).catch(() => undefined);
630
+ await stopSystemdUnit(unit, environment);
631
+ throw new WorktreeCommitArtifactError("validation-failed", "Validation executable failed.");
632
+ });
633
+ if (timer !== undefined)
634
+ clearTimeout(timer);
635
+ if (bounded.kind === "timeout" || overflow) {
636
+ if (child.exitCode === null && child.signalCode === null)
637
+ await terminateTree(child, closed);
638
+ await stopSystemdUnit(unit, environment);
639
+ throw new WorktreeCommitArtifactError("validation-failed", "Validation exceeded its resource bound.");
488
640
  }
641
+ await stopSystemdUnit(unit, environment);
642
+ const code = bounded.result.code === 0 ? 0 : bounded.result.code === 1 ? 238 : 239;
643
+ return { code, stdout: Buffer.concat(chunks) };
489
644
  }
490
645
  async function runBounded(executable, args, cwd, timeout, kind) {
491
646
  const windowsWrapper = kind === "validation" && process.platform === "win32";
@@ -557,6 +712,8 @@ async function runBounded(executable, args, cwd, timeout, kind) {
557
712
  }
558
713
  const code = linuxWrapper && result.code !== 0 && ![238, 239, 240, 241].includes(result.code ?? -1)
559
714
  ? 240 : result.code ?? -1;
715
+ if (linuxWrapper && code === 240)
716
+ return await runLinuxSystemd(executable, args, cwd, timeout);
560
717
  return { code, stdout: Buffer.concat(chunks) };
561
718
  }
562
719
  catch (error) {
@@ -144,6 +144,7 @@ export interface ContinuationHooks {
144
144
  textComplete(input: {
145
145
  sessionID: string;
146
146
  allowCheckpointContinuation?: boolean;
147
+ allowStepRecoveryFallback?: boolean;
147
148
  }, output: {
148
149
  text: string;
149
150
  }): Promise<void>;
@@ -865,7 +865,9 @@ export function createContinuationHooks(client, directory, policySource, timings
865
865
  }
866
866
  else if (nonTerminalProgress(state.latestCoordinatorReport) ||
867
867
  (state.stepRecoveryActive && !terminalCheckpoint(state.latestCoordinatorReport))) {
868
- scheduleStepRecovery(input.sessionID, state, state.latestCoordinatorReport);
868
+ if (input.allowStepRecoveryFallback !== false) {
869
+ scheduleStepRecovery(input.sessionID, state, state.latestCoordinatorReport);
870
+ }
869
871
  }
870
872
  // The resumed coordinator completed a turn, so no late event from its prior compaction can
871
873
  // compete with the next host-managed compaction.
@@ -919,6 +919,7 @@ export const SortieDogsPlugin = async (input, options) => {
919
919
  return loaded?.gate === undefined && manifestAbsent;
920
920
  }
921
921
  const inspected = new Map();
922
+ const inspectionOperations = new Map();
922
923
  const sessionAuthorizations = new Map();
923
924
  const bindingPins = new Map();
924
925
  const bindingOperations = new Set();
@@ -1383,8 +1384,8 @@ export const SortieDogsPlugin = async (input, options) => {
1383
1384
  if (JSON.stringify(running.artifact.validation.command) !== JSON.stringify(requestedCommand)) {
1384
1385
  return deny("artifact-replay");
1385
1386
  }
1386
- await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, running.artifact);
1387
1387
  await removeParallelControlFiles(binding.descriptor);
1388
+ await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, running.artifact);
1388
1389
  parallelArtifacts.set(sessionID, { requestFingerprint: request.fingerprint, artifact: running.artifact });
1389
1390
  pruneParallelChildMap(parallelArtifacts);
1390
1391
  return JSON.stringify({ status: "created", replay: true, artifact: boundedParallelArtifact(running.artifact) });
@@ -1397,8 +1398,8 @@ export const SortieDogsPlugin = async (input, options) => {
1397
1398
  };
1398
1399
  const recovered = await recoverWorktreeCommitArtifact(produceRequest);
1399
1400
  const artifact = recovered ?? await produceWorktreeCommitArtifact(produceRequest);
1400
- await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, artifact);
1401
1401
  await removeParallelControlFiles(binding.descriptor);
1402
+ await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, artifact);
1402
1403
  parallelArtifacts.set(sessionID, { requestFingerprint: request.fingerprint, artifact });
1403
1404
  pruneParallelChildMap(parallelArtifacts);
1404
1405
  return JSON.stringify({ status: "created", ...(recovered === undefined ? {} : { replay: true }),
@@ -1564,13 +1565,14 @@ export const SortieDogsPlugin = async (input, options) => {
1564
1565
  authorization.lease = undefined;
1565
1566
  }
1566
1567
  }
1567
- async function completeContinuationText(sessionID, text) {
1568
+ async function completeContinuationText(sessionID, text, allowStepRecoveryFallback = true) {
1568
1569
  const continuationText = fastLane.manualCompactionForbidden(sessionID)
1569
1570
  ? text.replaceAll(ROLLOVER_MARKER, "").replaceAll(CONTINUATION_MARKER, "").trimEnd()
1570
1571
  : text;
1571
1572
  await continuation.textComplete({
1572
1573
  sessionID,
1573
1574
  allowCheckpointContinuation: fastLane.backlogContinuationAllowed(sessionID),
1575
+ allowStepRecoveryFallback,
1574
1576
  }, { text: continuationText });
1575
1577
  if (fastLane.backlogContinuationAllowed(sessionID) && continuation.blocksTool(sessionID)) {
1576
1578
  fastLane.continuationQueued(sessionID);
@@ -1952,6 +1954,11 @@ export const SortieDogsPlugin = async (input, options) => {
1952
1954
  return deny("binding-replay");
1953
1955
  }
1954
1956
  const existingAuthorization = sessionAuthorizations.get(sessionID);
1957
+ const pendingInspections = [...inspectionOperations.entries()]
1958
+ .filter(([key]) => key.startsWith(`${sessionID}\u0000`))
1959
+ .map(([, operation]) => operation);
1960
+ if (pendingInspections.length > 0)
1961
+ await Promise.allSettled(pendingInspections);
1955
1962
  pruneInspections(now);
1956
1963
  const inspectedEntry = [...inspected.entries()].find(([key, entry]) => key.startsWith(`${sessionID}\u0000`) &&
1957
1964
  entry.ownerSessionID === sessionID &&
@@ -2400,7 +2407,17 @@ export const SortieDogsPlugin = async (input, options) => {
2400
2407
  const path = input.args.filePath;
2401
2408
  if (typeof path !== "string" || path.length === 0)
2402
2409
  return;
2403
- await inspect(path, input.sessionID);
2410
+ const absolutePath = resolve(path);
2411
+ const key = `${input.sessionID}\u0000${absolutePath}`;
2412
+ const operation = inspect(path, input.sessionID).then(() => undefined);
2413
+ inspectionOperations.set(key, operation);
2414
+ try {
2415
+ await operation;
2416
+ }
2417
+ finally {
2418
+ if (inspectionOperations.get(key) === operation)
2419
+ inspectionOperations.delete(key);
2420
+ }
2404
2421
  }
2405
2422
  async function invalidateEditedHandoff(path) {
2406
2423
  await ensureLoaded();
@@ -2481,7 +2498,7 @@ export const SortieDogsPlugin = async (input, options) => {
2481
2498
  return undefined;
2482
2499
  }
2483
2500
  }
2484
- async function hostSessionUserTurn(sessionID) {
2501
+ async function hostSessionRecoveryHistory(sessionID) {
2485
2502
  const messages = input.client?.session?.messages;
2486
2503
  if (messages === undefined)
2487
2504
  return undefined;
@@ -2493,55 +2510,40 @@ export const SortieDogsPlugin = async (input, options) => {
2493
2510
  const payload = isRecord(response) && "data" in response ? response.data : response;
2494
2511
  if (!Array.isArray(payload))
2495
2512
  return undefined;
2513
+ let persistedTurn;
2514
+ let hasForeignUserTurn = false;
2496
2515
  for (let index = payload.length - 1; index >= 0; index -= 1) {
2497
2516
  const message = payload[index];
2498
- if ((message.info?.role ?? message.role) !== "user")
2499
- continue;
2500
- const agent = message.info?.agent ?? message.agent;
2501
- if (typeof agent !== "string")
2517
+ if (!isRecord(message))
2502
2518
  return undefined;
2503
- return {
2504
- agent,
2505
- synthetic: (message.parts ?? []).some((part) => part.synthetic === true),
2506
- };
2507
- }
2508
- return undefined;
2509
- }
2510
- catch {
2511
- return undefined;
2512
- }
2513
- }
2514
- async function hostSessionForeignUserAgent(sessionID) {
2515
- const messages = input.client?.session?.messages;
2516
- if (messages === undefined)
2517
- return undefined;
2518
- try {
2519
- const response = await messages.call(input.client.session, {
2520
- path: { id: sessionID },
2521
- query: { directory: input.directory },
2522
- });
2523
- const payload = isRecord(response) && "data" in response ? response.data : response;
2524
- if (!Array.isArray(payload))
2525
- return undefined;
2526
- for (const message of payload) {
2527
- if ((message.info?.role ?? message.role) !== "user")
2519
+ if (message.info !== undefined && !isRecord(message.info))
2520
+ return undefined;
2521
+ const info = isRecord(message.info) ? message.info : undefined;
2522
+ const role = info?.role ?? message.role;
2523
+ if (role !== "user" && role !== "assistant")
2524
+ return undefined;
2525
+ if (role !== "user")
2528
2526
  continue;
2529
- const agent = message.info?.agent ?? message.agent;
2527
+ const agent = info?.agent ?? message.agent;
2530
2528
  if (typeof agent !== "string")
2531
2529
  return undefined;
2532
2530
  if (agent !== COORDINATOR_AGENT)
2533
- return agent;
2531
+ hasForeignUserTurn = true;
2532
+ if (persistedTurn === undefined) {
2533
+ if (message.parts !== undefined && !Array.isArray(message.parts))
2534
+ return undefined;
2535
+ persistedTurn = {
2536
+ agent,
2537
+ synthetic: Array.isArray(message.parts) && message.parts.some((part) => isRecord(part) && part.synthetic === true),
2538
+ };
2539
+ }
2534
2540
  }
2535
- return false;
2541
+ return { hasForeignUserTurn, persistedTurn };
2536
2542
  }
2537
2543
  catch {
2538
2544
  return undefined;
2539
2545
  }
2540
2546
  }
2541
- async function hostSessionHasForeignUserTurn(sessionID) {
2542
- const agent = await hostSessionForeignUserAgent(sessionID);
2543
- return typeof agent === "string" ? true : agent;
2544
- }
2545
2547
  async function assistantMessageText(sessionID, messageID, expectedAgent, partID) {
2546
2548
  const messages = input.client?.session?.messages;
2547
2549
  if (messages === undefined)
@@ -2599,10 +2601,13 @@ export const SortieDogsPlugin = async (input, options) => {
2599
2601
  return false;
2600
2602
  if (identity.agent !== undefined && identity.agent !== COORDINATOR_AGENT)
2601
2603
  return false;
2602
- if (await hostSessionHasForeignUserTurn(sessionID) !== false)
2604
+ const history = await hostSessionRecoveryHistory(sessionID);
2605
+ if (history === undefined || history.hasForeignUserTurn)
2606
+ return false;
2607
+ const persistedTurn = history.persistedTurn;
2608
+ if (persistedTurn === undefined && identity.agent !== COORDINATOR_AGENT)
2603
2609
  return false;
2604
- const persistedTurn = await hostSessionUserTurn(sessionID);
2605
- if (persistedTurn?.agent !== COORDINATOR_AGENT)
2610
+ if (persistedTurn !== undefined && persistedTurn.agent !== COORDINATOR_AGENT)
2606
2611
  return false;
2607
2612
  await rememberCoordinatorRoot(sessionID);
2608
2613
  releaseSessionEnforcement(sessionID);
@@ -2898,7 +2903,7 @@ export const SortieDogsPlugin = async (input, options) => {
2898
2903
  .replaceAll(CONTINUATION_MARKER, "")
2899
2904
  .trimEnd();
2900
2905
  }
2901
- await completeContinuationText(textInput.sessionID, textOutput.text);
2906
+ await completeContinuationText(textInput.sessionID, textOutput.text, false);
2902
2907
  },
2903
2908
  "experimental.session.compacting": async (compactInput, compactOutput) => {
2904
2909
  await continuation.sessionCompacting(compactInput, compactOutput);
@@ -3101,6 +3106,7 @@ export const SortieDogsPlugin = async (input, options) => {
3101
3106
  */
3102
3107
  "tool.execute.after": async (toolInput, output) => {
3103
3108
  const completedChildSessionID = toolInput.tool === "task" ? taskChildSessionID(output) : undefined;
3109
+ const handoffInspection = inspectSuccessfulRead(toolInput);
3104
3110
  try {
3105
3111
  if (bootstrapRequired && toolInput.tool === "sortie_check_contract" && toolInput.sessionID !== undefined &&
3106
3112
  isCoordinatorSession(toolInput.sessionID) && successfulBootstrapContractCheck(output)) {
@@ -3120,7 +3126,7 @@ export const SortieDogsPlugin = async (input, options) => {
3120
3126
  }
3121
3127
  }
3122
3128
  }
3123
- await inspectSuccessfulRead(toolInput);
3129
+ await handoffInspection;
3124
3130
  let parallel = parallelCalls.get(toolInput.callID ?? "");
3125
3131
  if (parallel === undefined && toolInput.tool === "task" && toolInput.sessionID !== undefined &&
3126
3132
  toolInput.callID !== undefined &&
@@ -3517,14 +3523,13 @@ export const SortieDogsPlugin = async (input, options) => {
3517
3523
  completedCoordinatorParts.add(eventPart.id);
3518
3524
  const text = await eventAssistantMessageText(eventSessionID, eventPart.messageID, COORDINATOR_AGENT, eventPart.id);
3519
3525
  if (text !== undefined && text === eventPart.text.trim()) {
3520
- completedCoordinatorMessages.add(eventPart.messageID);
3521
3526
  while (completedCoordinatorParts.size > ACTIVE_SESSION_CACHE.maximum) {
3522
3527
  completedCoordinatorParts.delete(completedCoordinatorParts.values().next().value);
3523
3528
  }
3524
3529
  while (completedCoordinatorMessages.size > ACTIVE_SESSION_CACHE.maximum) {
3525
3530
  completedCoordinatorMessages.delete(completedCoordinatorMessages.values().next().value);
3526
3531
  }
3527
- await completeContinuationText(eventSessionID, text);
3532
+ await completeContinuationText(eventSessionID, text, false);
3528
3533
  return;
3529
3534
  }
3530
3535
  completedCoordinatorParts.delete(eventPart.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
4
4
  "description": "Bounded, validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",