oh-my-opencode-slim 2.0.0-beta.12 → 2.0.0-beta.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.
package/dist/index.js CHANGED
@@ -18196,6 +18196,13 @@ function getCustomOpenCodeConfigDir() {
18196
18196
  const configDir = process.env.OPENCODE_CONFIG_DIR?.trim();
18197
18197
  return configDir || undefined;
18198
18198
  }
18199
+ function getConfigDir() {
18200
+ const customConfigDir = getCustomOpenCodeConfigDir();
18201
+ if (customConfigDir) {
18202
+ return customConfigDir;
18203
+ }
18204
+ return getDefaultOpenCodeConfigDir();
18205
+ }
18199
18206
  function getConfigSearchDirs() {
18200
18207
  const dirs = [getCustomOpenCodeConfigDir(), getDefaultOpenCodeConfigDir()];
18201
18208
  return dirs.filter((dir, index) => {
@@ -18203,7 +18210,7 @@ function getConfigSearchDirs() {
18203
18210
  });
18204
18211
  }
18205
18212
  function getOpenCodeConfigPaths() {
18206
- const configDir = getDefaultOpenCodeConfigDir();
18213
+ const configDir = getConfigDir();
18207
18214
  return [join(configDir, "opencode.json"), join(configDir, "opencode.jsonc")];
18208
18215
  }
18209
18216
 
@@ -18232,6 +18239,12 @@ var CUSTOM_SKILLS = [
18232
18239
  description: "Heavy/complex coding sessions and large modifications workflow",
18233
18240
  allowedAgents: ["orchestrator"],
18234
18241
  sourcePath: "src/skills/deepwork"
18242
+ },
18243
+ {
18244
+ name: "oh-my-opencode-slim",
18245
+ description: "Configure, customize, and safely improve oh-my-opencode-slim setups",
18246
+ allowedAgents: ["orchestrator"],
18247
+ sourcePath: "src/skills/oh-my-opencode-slim"
18235
18248
  }
18236
18249
  ];
18237
18250
 
@@ -18597,12 +18610,6 @@ var DivoomConfigSchema = z2.object({
18597
18610
  posterizeBits: z2.number().int().min(1).max(8).default(3),
18598
18611
  gifs: z2.record(z2.string(), z2.string().min(1)).optional()
18599
18612
  });
18600
- var TodoContinuationConfigSchema = z2.object({
18601
- maxContinuations: z2.number().int().min(1).max(50).default(5).describe("Maximum consecutive auto-continuations before stopping to ask user"),
18602
- cooldownMs: z2.number().int().min(0).max(30000).default(3000).describe("Delay in ms before auto-continuing (gives user time to abort)"),
18603
- autoEnable: z2.boolean().default(false).describe("Automatically enable auto-continue when the orchestrator session has enough todos"),
18604
- autoEnableThreshold: z2.number().int().min(1).max(50).default(4).describe("Number of todos that triggers auto-enable (only used when autoEnable is true)")
18605
- });
18606
18613
  var FailoverConfigSchema = z2.object({
18607
18614
  enabled: z2.boolean().default(true),
18608
18615
  timeoutMs: z2.number().min(0).default(15000),
@@ -18649,7 +18656,6 @@ var PluginConfigSchema = z2.object({
18649
18656
  interview: InterviewConfigSchema.optional(),
18650
18657
  backgroundJobs: BackgroundJobsConfigSchema.optional(),
18651
18658
  divoom: DivoomConfigSchema.optional(),
18652
- todoContinuation: TodoContinuationConfigSchema.optional(),
18653
18659
  fallback: FailoverConfigSchema.optional(),
18654
18660
  council: CouncilConfigSchema.optional()
18655
18661
  }).superRefine((value, ctx) => {
@@ -18903,34 +18909,32 @@ function parseModelReference(model) {
18903
18909
  async function promptWithTimeout(client, args, timeoutMs, signal) {
18904
18910
  if (signal?.aborted)
18905
18911
  throw new Error("Prompt cancelled");
18906
- if (timeoutMs <= 0) {
18907
- await client.session.prompt(args);
18908
- return;
18909
- }
18910
18912
  const sessionId = args.path.id;
18913
+ const hasTimeout = timeoutMs > 0;
18911
18914
  let timer;
18912
18915
  let onAbort;
18913
18916
  try {
18914
18917
  const promptPromise = client.session.prompt(args);
18915
18918
  promptPromise.catch(() => {});
18916
- await Promise.race([
18917
- promptPromise,
18918
- new Promise((_, reject) => {
18919
+ const racers = [promptPromise];
18920
+ if (hasTimeout) {
18921
+ racers.push(new Promise((_, reject) => {
18919
18922
  timer = setTimeout(() => {
18920
18923
  reject(new OperationTimeoutError(`Prompt timed out after ${timeoutMs}ms`));
18921
18924
  }, timeoutMs);
18922
- }),
18923
- new Promise((_, reject) => {
18924
- if (!signal)
18925
- return;
18925
+ }));
18926
+ }
18927
+ if (signal) {
18928
+ racers.push(new Promise((_, reject) => {
18926
18929
  if (signal.aborted) {
18927
18930
  reject(new Error("Prompt cancelled"));
18928
18931
  return;
18929
18932
  }
18930
18933
  onAbort = () => reject(new Error("Prompt cancelled"));
18931
18934
  signal.addEventListener("abort", onAbort, { once: true });
18932
- })
18933
- ]);
18935
+ }));
18936
+ }
18937
+ await Promise.race(racers);
18934
18938
  } catch (error) {
18935
18939
  if (error instanceof OperationTimeoutError) {
18936
18940
  try {
@@ -19161,13 +19165,6 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
19161
19165
  - If multiple remembered sessions fit, prefer the most recently used matching session.
19162
19166
  - Prefer re-uses over creating new sessions all the time
19163
19167
 
19164
- ### Auto-Continue
19165
- When working through multi-step tasks, consider enabling auto-continue to avoid stopping between batches:
19166
- - **Enable when:** User requests autonomous/batch work, or you create 4+ todos in a session
19167
- - **Don't enable when:** User is in an interactive/conversational flow, or each step needs explicit review
19168
- - Use the \`auto_continue\` tool with \`enabled: true\` to activate. The system will automatically resume you when incomplete todos remain after you stop.
19169
- - The user can toggle this anytime via the \`/auto-continue\` command.
19170
-
19171
19168
  ### Validation routing
19172
19169
  - Validation is a workflow stage owned by the Orchestrator, not a separate specialist
19173
19170
  ${enabledValidationRouting}
@@ -20039,10 +20036,6 @@ function setActiveRuntimePresetWithPrevious(name) {
20039
20036
  previousRuntimePreset = activeRuntimePreset;
20040
20037
  activeRuntimePreset = name;
20041
20038
  }
20042
- function rollbackRuntimePreset(previous) {
20043
- activeRuntimePreset = previous;
20044
- previousRuntimePreset = null;
20045
- }
20046
20039
 
20047
20040
  // src/utils/logger.ts
20048
20041
  import * as fs2 from "node:fs";
@@ -20055,7 +20048,7 @@ var RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
20055
20048
  var logFile = null;
20056
20049
  var writeChain = Promise.resolve();
20057
20050
  function getLogDir() {
20058
- return process.env.OPENCODE_LOG_DIR ?? path2.join(os.homedir(), ".local/share/opencode");
20051
+ return process.env.OPENCODE_LOG_DIR ?? path2.join(os.homedir(), ".local/share/opencode/log");
20059
20052
  }
20060
20053
  function cleanupOldLogs(logDir) {
20061
20054
  try {
@@ -22642,6 +22635,10 @@ function createDisplayNameMentionRewriter(config) {
22642
22635
  };
22643
22636
  }
22644
22637
  // src/utils/task.ts
22638
+ var TRANSIENT_PROCESS_ERROR_TEXT = new Set([
22639
+ "Task is not running in this process and has no final output.",
22640
+ "Task is not running in this process and has not produced output."
22641
+ ]);
22645
22642
  function parseTaskIdFromTaskOutput(output) {
22646
22643
  const lines = output.split(/\r?\n/);
22647
22644
  for (const line of lines) {
@@ -22677,6 +22674,19 @@ function parseTaskStatusOutput(output) {
22677
22674
  result: parseTaskResultFromOutput(output)
22678
22675
  };
22679
22676
  }
22677
+ function classifyTaskStatusOutput(status) {
22678
+ if (status.timedOut)
22679
+ return "timeout";
22680
+ if (status.state === "running")
22681
+ return "running";
22682
+ if (status.state === "completed" || status.state === "cancelled") {
22683
+ return "terminal";
22684
+ }
22685
+ if (TRANSIENT_PROCESS_ERROR_TEXT.has(status.result ?? "")) {
22686
+ return "transient_process_error";
22687
+ }
22688
+ return "unknown_error";
22689
+ }
22680
22690
  function parseTaskStateFromOutput(output) {
22681
22691
  for (const line of getTaskHeader(output).split(/\r?\n/)) {
22682
22692
  const match = /^state:\s*(running|completed|error|cancelled)\s*$/i.exec(line.trim());
@@ -22735,11 +22745,15 @@ class BackgroundJobBoard {
22735
22745
  objective: input.objective ?? existing.objective,
22736
22746
  state: "running",
22737
22747
  timedOut: false,
22748
+ statusUncertain: false,
22749
+ cancellationRequested: false,
22738
22750
  terminalUnreconciled: false,
22739
22751
  completedAt: undefined,
22740
22752
  resultSummary: undefined,
22753
+ lastStatusError: undefined,
22741
22754
  terminalState: undefined,
22742
22755
  lastLaunchedAt: now,
22756
+ lastLiveBusyAt: now,
22743
22757
  lastUsedAt: now,
22744
22758
  updatedAt: now
22745
22759
  };
@@ -22754,9 +22768,12 @@ class BackgroundJobBoard {
22754
22768
  objective: input.objective,
22755
22769
  state: "running",
22756
22770
  timedOut: false,
22771
+ statusUncertain: false,
22772
+ cancellationRequested: false,
22757
22773
  terminalUnreconciled: false,
22758
22774
  launchedAt: now,
22759
22775
  lastLaunchedAt: now,
22776
+ lastLiveBusyAt: now,
22760
22777
  lastUsedAt: now,
22761
22778
  updatedAt: now,
22762
22779
  alias: this.nextAlias(input.parentSessionID, input.agent),
@@ -22778,11 +22795,13 @@ class BackgroundJobBoard {
22778
22795
  ...existing,
22779
22796
  state: input.state,
22780
22797
  timedOut: input.timedOut ?? false,
22798
+ statusUncertain: input.statusUncertain ?? false,
22781
22799
  terminalUnreconciled: terminal ? true : existing.terminalUnreconciled,
22782
22800
  updatedAt: now,
22783
22801
  completedAt: terminal ? existing.completedAt ?? now : existing.completedAt,
22784
22802
  terminalState: terminal ? input.state : existing.terminalState,
22785
- resultSummary: input.resultSummary ?? existing.resultSummary
22803
+ resultSummary: input.resultSummary ?? existing.resultSummary,
22804
+ lastStatusError: input.lastStatusError
22786
22805
  };
22787
22806
  this.jobs.set(input.taskID, updated);
22788
22807
  this.trimReusable(input.taskID);
@@ -22803,18 +22822,28 @@ class BackgroundJobBoard {
22803
22822
  const existing = this.jobs.get(taskID);
22804
22823
  if (!existing)
22805
22824
  return;
22806
- const isStaleCancellation = existing.state === "cancelled" || existing.state === "reconciled" && existing.terminalState === "cancelled";
22807
- if (!isStaleCancellation)
22808
- return existing;
22825
+ const isStaleTerminal = TERMINAL_STATES.has(existing.state) || existing.state === "reconciled";
22826
+ if (!isStaleTerminal || existing.cancellationRequested) {
22827
+ const updated2 = {
22828
+ ...existing,
22829
+ lastLiveBusyAt: now
22830
+ };
22831
+ this.jobs.set(taskID, updated2);
22832
+ return updated2;
22833
+ }
22809
22834
  const updated = {
22810
22835
  ...existing,
22811
22836
  state: "running",
22812
22837
  timedOut: false,
22838
+ statusUncertain: false,
22839
+ cancellationRequested: false,
22813
22840
  terminalUnreconciled: false,
22814
22841
  updatedAt: now,
22842
+ lastLiveBusyAt: now,
22815
22843
  completedAt: undefined,
22816
22844
  terminalState: undefined,
22817
- resultSummary: undefined
22845
+ resultSummary: undefined,
22846
+ lastStatusError: undefined
22818
22847
  };
22819
22848
  this.jobs.set(taskID, updated);
22820
22849
  return updated;
@@ -22830,6 +22859,7 @@ class BackgroundJobBoard {
22830
22859
  ...existing,
22831
22860
  state: "reconciled",
22832
22861
  terminalUnreconciled: false,
22862
+ statusUncertain: false,
22833
22863
  updatedAt: now,
22834
22864
  lastUsedAt: now,
22835
22865
  terminalState: existing.terminalState ?? terminalStateOf(existing.state)
@@ -22838,24 +22868,29 @@ class BackgroundJobBoard {
22838
22868
  this.trimReusable(taskID);
22839
22869
  return updated;
22840
22870
  }
22841
- markCancelled(taskID, reason, now = Date.now()) {
22871
+ markCancelled(taskID, reason, now = Date.now(), options = {}) {
22842
22872
  const existing = this.jobs.get(taskID);
22843
22873
  if (!existing)
22844
22874
  return;
22845
- if (existing.state === "reconciled")
22846
- return existing;
22847
- if (TERMINAL_STATES.has(existing.state))
22848
- return existing;
22875
+ if (!options.force) {
22876
+ if (existing.state === "reconciled")
22877
+ return existing;
22878
+ if (TERMINAL_STATES.has(existing.state))
22879
+ return existing;
22880
+ }
22849
22881
  const summary = normalizeCancelReason(reason);
22850
22882
  const updated = {
22851
22883
  ...existing,
22852
22884
  state: "cancelled",
22853
22885
  timedOut: false,
22886
+ statusUncertain: false,
22887
+ cancellationRequested: true,
22854
22888
  terminalUnreconciled: true,
22855
22889
  updatedAt: now,
22856
22890
  completedAt: existing.completedAt ?? now,
22857
22891
  terminalState: "cancelled",
22858
- resultSummary: summary
22892
+ resultSummary: summary,
22893
+ lastStatusError: undefined
22859
22894
  };
22860
22895
  this.jobs.set(taskID, updated);
22861
22896
  return updated;
@@ -22925,7 +22960,7 @@ class BackgroundJobBoard {
22925
22960
  return [
22926
22961
  "### Background Job Board",
22927
22962
  "SENTINEL: background-job-board-v2",
22928
- "Use task_status for running jobs. Reconcile terminal jobs before final response. Reuse any non-running session for the same specialist/context.",
22963
+ "Use task_status for running jobs. Reconcile terminal jobs before final response. Reuse only completed sessions for the same specialist/context; never reuse cancelled or errored sessions.",
22929
22964
  "",
22930
22965
  "#### Active / Unreconciled",
22931
22966
  ...active.length > 0 ? active.map((job) => formatJob(job, now)) : ["- none"],
@@ -22981,7 +23016,8 @@ function deriveTaskSessionLabel(input) {
22981
23016
  return firstPromptLine ? firstPromptLine.slice(0, 48) : `recent ${input.agentType} task`;
22982
23017
  }
22983
23018
  function isReusable(job) {
22984
- return job.state !== "running";
23019
+ const terminal = job.terminalState ?? terminalStateOf(job.state);
23020
+ return terminal === "completed" && !job.terminalUnreconciled;
22985
23021
  }
22986
23022
  function terminalStateOf(state) {
22987
23023
  return state === "completed" || state === "error" || state === "cancelled" ? state : undefined;
@@ -23001,13 +23037,15 @@ function formatJob(job, now = Date.now()) {
23001
23037
  const ageMs = now - job.lastLaunchedAt;
23002
23038
  const isResume = job.lastLaunchedAt !== job.launchedAt;
23003
23039
  const ageLabel = job.state === "running" && ageMs < 30000 ? ` [${isResume ? "resumed" : "just launched"}, ${Math.floor(ageMs / 1000)}s ago]` : "";
23004
- const status = job.terminalUnreconciled ? `${job.state}, unreconciled` : job.timedOut ? `${job.state}, timed out` : `${job.state}${ageLabel}`;
23040
+ const status = job.terminalUnreconciled ? `${job.state}, unreconciled` : job.statusUncertain ? `${job.state}, status uncertain` : job.timedOut ? `${job.state}, timed out` : `${job.state}${ageLabel}`;
23005
23041
  const lines = [
23006
23042
  `- ${job.alias} / ${job.taskID} / ${job.agent} / ${status}`,
23007
23043
  ` Objective: ${job.objective || job.description}`
23008
23044
  ];
23009
23045
  if (job.resultSummary && job.terminalUnreconciled) {
23010
23046
  lines.push(` Result: ${singleLine(job.resultSummary)}`);
23047
+ } else if (job.lastStatusError && job.statusUncertain) {
23048
+ lines.push(` Status: ${singleLine(job.lastStatusError)}`);
23011
23049
  }
23012
23050
  return lines.join(`
23013
23051
  `);
@@ -24094,6 +24132,17 @@ function createTaskSessionManagerHook(_ctx, options) {
24094
24132
  timedOut: status.timedOut,
24095
24133
  hasResult: Boolean(status.result)
24096
24134
  });
24135
+ const existing = backgroundJobBoard.get(status.taskID);
24136
+ if (isLateCancelledTaskError(existing, status.state)) {
24137
+ log("[task-session-manager] suppressed late cancelled task error", {
24138
+ taskID: status.taskID,
24139
+ alias: existing?.alias,
24140
+ state: existing?.state,
24141
+ terminalState: existing?.terminalState,
24142
+ result: status.result
24143
+ });
24144
+ return existing;
24145
+ }
24097
24146
  const updated = backgroundJobBoard.updateStatus({
24098
24147
  taskID: status.taskID,
24099
24148
  state: status.state,
@@ -24122,6 +24171,61 @@ function createTaskSessionManagerHook(_ctx, options) {
24122
24171
  }
24123
24172
  return updated;
24124
24173
  }
24174
+ async function handleTransientTaskStatusOutput(output) {
24175
+ if (typeof output.output !== "string")
24176
+ return false;
24177
+ const status = parseTaskStatusOutput(output.output);
24178
+ if (!status)
24179
+ return false;
24180
+ if (classifyTaskStatusOutput(status) !== "transient_process_error") {
24181
+ return false;
24182
+ }
24183
+ const existing = backgroundJobBoard.get(status.taskID);
24184
+ const liveStatus = existing && existing.state === "running" ? undefined : await getLiveSessionStatus(status.taskID);
24185
+ const recentLiveBusy = !!existing?.lastLiveBusyAt && (!existing.completedAt || existing.lastLiveBusyAt >= existing.completedAt);
24186
+ const isStillRunning = existing?.state === "running" || recentLiveBusy || liveStatus === "busy" || liveStatus === "retry";
24187
+ if (!isStillRunning)
24188
+ return false;
24189
+ const updated = existing?.state === "running" ? backgroundJobBoard.updateStatus({
24190
+ taskID: status.taskID,
24191
+ state: "running",
24192
+ statusUncertain: true,
24193
+ lastStatusError: status.result
24194
+ }) : undefined;
24195
+ log("[task-session-manager] classified transient task_status error", {
24196
+ taskID: status.taskID,
24197
+ alias: existing?.alias,
24198
+ parentSessionID: existing?.parentSessionID,
24199
+ previousState: existing?.state,
24200
+ updatedState: updated?.state,
24201
+ liveStatus,
24202
+ recentLiveBusy
24203
+ });
24204
+ return true;
24205
+ }
24206
+ async function getLiveSessionStatus(sessionID) {
24207
+ try {
24208
+ const response = await _ctx.client.session.status();
24209
+ const data = response.data;
24210
+ if (!isObjectRecord(data))
24211
+ return;
24212
+ const item = data[sessionID];
24213
+ if (item === undefined)
24214
+ return "idle";
24215
+ if (isObjectRecord(item) && typeof item.type === "string") {
24216
+ return item.type;
24217
+ }
24218
+ const directType = data.type;
24219
+ if (typeof directType === "string")
24220
+ return directType;
24221
+ const nestedStatus = data.status;
24222
+ if (!isObjectRecord(nestedStatus))
24223
+ return;
24224
+ return typeof nestedStatus.type === "string" ? nestedStatus.type : undefined;
24225
+ } catch {
24226
+ return;
24227
+ }
24228
+ }
24125
24229
  function updateFromInjectedCompletion(part, message, _messageIndex, partIndex) {
24126
24230
  if (part.type !== "text" || typeof part.text !== "string") {
24127
24231
  return;
@@ -24134,11 +24238,24 @@ function createTaskSessionManagerHook(_ctx, options) {
24134
24238
  const status = parseTaskStatusOutput(part.text);
24135
24239
  if (!status)
24136
24240
  return;
24241
+ const occurrenceId = createOccurrenceId(part, message, partIndex);
24242
+ const existing = backgroundJobBoard.get(status.taskID);
24243
+ if (isFailed && isLateCancelledTaskError(existing, status.state)) {
24244
+ part.text = formatCancelledTaskStatusOutput(status.taskID, existing?.resultSummary);
24245
+ log("[task-session-manager] normalized late cancelled injected failure", {
24246
+ taskID: status.taskID,
24247
+ alias: existing?.alias,
24248
+ state: existing?.state,
24249
+ terminalState: existing?.terminalState,
24250
+ result: status.result
24251
+ });
24252
+ rememberProcessedInjectedCompletion(occurrenceId);
24253
+ return existing;
24254
+ }
24137
24255
  if (isCompleted && status.state !== "completed")
24138
24256
  return;
24139
24257
  if (isFailed && status.state !== "error")
24140
24258
  return;
24141
- const occurrenceId = createOccurrenceId(part, message, partIndex);
24142
24259
  if (processedInjectedCompletions.has(occurrenceId))
24143
24260
  return;
24144
24261
  const updated = updateBackgroundJobFromOutput(part.text);
@@ -24298,6 +24415,10 @@ function createTaskSessionManagerHook(_ctx, options) {
24298
24415
  if (!input.sessionID || !options.shouldManageSession(input.sessionID)) {
24299
24416
  return;
24300
24417
  }
24418
+ normalizeLateCancelledToolStatus(output);
24419
+ if (await handleTransientTaskStatusOutput(output)) {
24420
+ return;
24421
+ }
24301
24422
  updateBackgroundJobFromOutput(output.output);
24302
24423
  return;
24303
24424
  }
@@ -24421,12 +24542,27 @@ function createTaskSessionManagerHook(_ctx, options) {
24421
24542
  const sessionId2 = input.event.properties?.info?.id ?? input.event.properties?.sessionID;
24422
24543
  const before = sessionId2 ? backgroundJobBoard.get(sessionId2) : undefined;
24423
24544
  const updated = sessionId2 ? backgroundJobBoard.markRunningFromLiveSession(sessionId2) : undefined;
24545
+ if (before?.cancellationRequested) {
24546
+ log("[task-session-manager] busy observed after cancel request", {
24547
+ sessionID: sessionId2,
24548
+ previousState: before.state,
24549
+ previousTerminalState: before.terminalState,
24550
+ terminalUnreconciled: before.terminalUnreconciled,
24551
+ resultSummary: before.resultSummary,
24552
+ updatedState: updated?.state,
24553
+ updatedCancellationRequested: updated?.cancellationRequested
24554
+ });
24555
+ }
24424
24556
  log("[task-session-manager] busy/status busy observed", {
24425
24557
  sessionID: sessionId2,
24426
24558
  managesSession: sessionId2 ? options.shouldManageSession(sessionId2) : false,
24427
24559
  previousState: before?.state,
24428
24560
  previousTerminalState: before?.terminalState,
24429
- updatedState: updated?.state
24561
+ previousCancellationRequested: before?.cancellationRequested,
24562
+ previousLastLiveBusyAt: before?.lastLiveBusyAt,
24563
+ updatedState: updated?.state,
24564
+ updatedCancellationRequested: updated?.cancellationRequested,
24565
+ updatedLastLiveBusyAt: updated?.lastLiveBusyAt
24430
24566
  });
24431
24567
  return;
24432
24568
  }
@@ -24459,715 +24595,45 @@ function createTaskSessionManagerHook(_ctx, options) {
24459
24595
  }
24460
24596
  }
24461
24597
  };
24462
- }
24463
- // src/hooks/todo-continuation/index.ts
24464
- import { tool } from "@opencode-ai/plugin";
24465
-
24466
- // src/hooks/todo-continuation/todo-hygiene.ts
24467
- var TODO_HYGIENE_REMINDER = "If the active task changed or finished, update the todo list to match the current work state.";
24468
- var TODO_FINAL_ACTIVE_REMINDER = "If you are finishing now, do not leave the active todo in_progress. Mark it completed, or move unfinished work back to pending.";
24469
- var RESET = new Set(["todowrite"]);
24470
- var IGNORE = new Set(["auto_continue"]);
24471
- function createTodoHygiene(options) {
24472
- const pending = new Map;
24473
- const active = new Set;
24474
- function clearCycle(sessionID) {
24475
- pending.delete(sessionID);
24476
- }
24477
- function clear(sessionID) {
24478
- clearCycle(sessionID);
24479
- active.delete(sessionID);
24480
- }
24481
- function isFinalActive(state) {
24482
- return state.inProgressCount === 1 && state.pendingCount === 0 && state.openCount === 1;
24483
- }
24484
- function mark(sessionID, reason) {
24485
- const reasons = pending.get(sessionID) ?? new Set;
24486
- reasons.add(reason);
24487
- pending.set(sessionID, reasons);
24488
- }
24489
- function pick(reasons) {
24490
- if (reasons.has("final_active")) {
24491
- return TODO_FINAL_ACTIVE_REMINDER;
24492
- }
24493
- return TODO_HYGIENE_REMINDER;
24494
- }
24495
- return {
24496
- handleRequestStart(input) {
24497
- clear(input.sessionID);
24498
- },
24499
- async handleToolExecuteAfter(input, _output) {
24500
- if (!input.sessionID) {
24501
- return;
24502
- }
24503
- const tool = input.tool.toLowerCase();
24504
- if (IGNORE.has(tool)) {
24505
- return;
24506
- }
24507
- try {
24508
- if (RESET.has(tool)) {
24509
- if (options.shouldInject && !options.shouldInject(input.sessionID)) {
24510
- clear(input.sessionID);
24511
- return;
24512
- }
24513
- active.add(input.sessionID);
24514
- clearCycle(input.sessionID);
24515
- const state2 = await options.getTodoState(input.sessionID);
24516
- if (!state2.hasOpenTodos) {
24517
- active.delete(input.sessionID);
24518
- options.log?.("Cleared todo hygiene cycle", {
24519
- sessionID: input.sessionID,
24520
- tool
24521
- });
24522
- return;
24523
- }
24524
- if (!isFinalActive(state2)) {
24525
- options.log?.("Reset todo hygiene cycle", {
24526
- sessionID: input.sessionID,
24527
- tool
24528
- });
24529
- return;
24530
- }
24531
- mark(input.sessionID, "final_active");
24532
- options.log?.("Armed final-active todo hygiene reminder", {
24533
- sessionID: input.sessionID,
24534
- tool
24535
- });
24536
- return;
24537
- }
24538
- if (!active.has(input.sessionID)) {
24539
- return;
24540
- }
24541
- if (pending.get(input.sessionID)?.has("final_active")) {
24542
- return;
24543
- }
24544
- if (options.shouldInject && !options.shouldInject(input.sessionID)) {
24545
- clear(input.sessionID);
24546
- return;
24547
- }
24548
- const state = await options.getTodoState(input.sessionID);
24549
- if (!state.hasOpenTodos) {
24550
- clear(input.sessionID);
24551
- return;
24552
- }
24553
- if (isFinalActive(state)) {
24554
- mark(input.sessionID, "final_active");
24555
- } else {
24556
- mark(input.sessionID, "general");
24557
- }
24558
- options.log?.("Armed todo hygiene reminder", {
24559
- sessionID: input.sessionID,
24560
- tool,
24561
- reasons: Array.from(pending.get(input.sessionID) ?? [])
24562
- });
24563
- } catch (error) {
24564
- options.log?.("Skipped todo hygiene reminder: failed to inspect todos", {
24565
- sessionID: input.sessionID,
24566
- tool,
24567
- error: error instanceof Error ? error.message : String(error)
24568
- });
24569
- }
24570
- },
24571
- getPendingReminder(sessionID) {
24572
- const reasons = pending.get(sessionID);
24573
- if (!reasons || reasons.size === 0) {
24574
- return null;
24575
- }
24576
- if (options.shouldInject && !options.shouldInject(sessionID)) {
24577
- clear(sessionID);
24578
- return null;
24579
- }
24580
- const reminder = pick(reasons);
24581
- options.log?.("Read todo hygiene reminder", {
24582
- sessionID,
24583
- reminder,
24584
- reasons: Array.from(reasons)
24585
- });
24586
- return reminder;
24587
- },
24588
- handleEvent(event) {
24589
- if (event.type !== "session.deleted") {
24590
- return;
24591
- }
24592
- const sessionID = event.properties?.sessionID ?? event.properties?.info?.id;
24593
- if (!sessionID) {
24594
- return;
24595
- }
24596
- clear(sessionID);
24597
- }
24598
- };
24599
- }
24600
-
24601
- // src/hooks/todo-continuation/index.ts
24602
- var HOOK_NAME = "todo-continuation";
24603
- var COMMAND_NAME2 = "auto-continue";
24604
- var TODO_STATE_TIMEOUT_MS = 500;
24605
- var CONTINUATION_PROMPT = "[Auto-continue: enabled - there are incomplete todos remaining. Continue with the next uncompleted item. Press Esc to cancel. If you need user input or review for the next item, ask instead of proceeding.]";
24606
- var TODO_HYGIENE_INSTRUCTION_OPEN = '<instruction name="todo_hygiene">';
24607
- var TODO_HYGIENE_INSTRUCTION_CLOSE = "</instruction>";
24608
- var SUPPRESS_AFTER_ABORT_MS = 5000;
24609
- var NOTIFICATION_BUSY_GRACE_MS = 250;
24610
- var QUESTION_PHRASES = [
24611
- "would you like",
24612
- "should i",
24613
- "do you want",
24614
- "please review",
24615
- "let me know",
24616
- "what do you think",
24617
- "can you confirm",
24618
- "would you prefer",
24619
- "shall i",
24620
- "any thoughts"
24621
- ];
24622
- var TERMINAL_TODO_STATUSES = ["completed", "cancelled"];
24623
- function isQuestion(text) {
24624
- const lowerText = text.toLowerCase().trim();
24625
- if (/\?\s*$/.test(lowerText)) {
24626
- return true;
24627
- }
24628
- return QUESTION_PHRASES.some((phrase) => lowerText.includes(phrase));
24629
- }
24630
- function cancelPendingTimer(state) {
24631
- if (state.pendingTimer) {
24632
- clearTimeout(state.pendingTimer);
24633
- state.pendingTimer = null;
24634
- }
24635
- state.pendingTimerSessionId = null;
24636
- }
24637
- function resetState(state) {
24638
- cancelPendingTimer(state);
24639
- state.consecutiveContinuations = 0;
24640
- state.suppressUntil = 0;
24641
- state.isAutoInjecting = false;
24642
- state.notifyingSessionIds.clear();
24643
- state.notificationBusyUntilBySession.clear();
24644
- }
24645
- function stripTodoHygieneInstruction(text) {
24646
- const trimmed = text.trimEnd();
24647
- if (!trimmed.endsWith(TODO_HYGIENE_INSTRUCTION_CLOSE)) {
24648
- return trimmed;
24649
- }
24650
- const start = trimmed.lastIndexOf(TODO_HYGIENE_INSTRUCTION_OPEN);
24651
- if (start === -1) {
24652
- return trimmed;
24653
- }
24654
- return trimmed.slice(0, start).trimEnd();
24655
- }
24656
- function appendTodoHygieneInstruction(message, reminder) {
24657
- const textPart = [...message.parts].reverse().find((part) => part.type === "text" && typeof part.text === "string");
24658
- if (!textPart)
24659
- return;
24660
- const baseText = stripTodoHygieneInstruction(textPart.text ?? "");
24661
- const instruction = `${TODO_HYGIENE_INSTRUCTION_OPEN}
24662
- ${reminder}
24663
- ${TODO_HYGIENE_INSTRUCTION_CLOSE}`;
24664
- textPart.text = baseText ? `${baseText}
24665
-
24666
- ${instruction}` : instruction;
24667
- }
24668
- function stripTodoHygieneInstructionFromMessage(message) {
24669
- const textPart = [...message.parts].reverse().find((part) => part.type === "text" && typeof part.text === "string");
24670
- if (!textPart)
24671
- return;
24672
- textPart.text = stripTodoHygieneInstruction(textPart.text ?? "");
24673
- }
24674
- function createTodoContinuationHook(ctx, config) {
24675
- const maxContinuations = config?.maxContinuations ?? 5;
24676
- const cooldownMs = config?.cooldownMs ?? 3000;
24677
- const autoEnable = config?.autoEnable ?? false;
24678
- const autoEnableThreshold = config?.autoEnableThreshold ?? 4;
24679
- const backgroundJobBoard = config?.backgroundJobBoard;
24680
- const requestSignatureBySession = new Map;
24681
- const state = {
24682
- enabled: false,
24683
- consecutiveContinuations: 0,
24684
- pendingTimer: null,
24685
- pendingTimerSessionId: null,
24686
- suppressUntil: 0,
24687
- orchestratorSessionIds: new Set,
24688
- sawChatMessage: false,
24689
- isAutoInjecting: false,
24690
- notifyingSessionIds: new Set,
24691
- notificationBusyUntilBySession: new Map
24692
- };
24693
- async function fetchTodos(sessionID) {
24694
- const result = await withTimeout(ctx.client.session.todo({
24695
- path: { id: sessionID }
24696
- }), TODO_STATE_TIMEOUT_MS, `Todo state lookup timed out after ${TODO_STATE_TIMEOUT_MS}ms`);
24697
- return result.data;
24698
- }
24699
- const hygiene = createTodoHygiene({
24700
- getTodoState: async (sessionID) => {
24701
- const todos = await fetchTodos(sessionID);
24702
- const openTodos = todos.filter((todo) => !TERMINAL_TODO_STATUSES.includes(todo.status));
24703
- return {
24704
- hasOpenTodos: openTodos.length > 0,
24705
- openCount: openTodos.length,
24706
- inProgressCount: openTodos.filter((todo) => todo.status === "in_progress").length,
24707
- pendingCount: openTodos.filter((todo) => todo.status === "pending").length
24708
- };
24709
- },
24710
- shouldInject: (sessionID) => isOrchestratorSession(sessionID),
24711
- log: (message, meta) => log(`[${HOOK_NAME}] ${message}`, meta)
24712
- });
24713
- function inferSessionID(messages, index) {
24714
- const direct = messages[index]?.info.sessionID;
24715
- if (direct) {
24716
- return direct;
24717
- }
24718
- for (let i = index - 1;i >= 0; i--) {
24719
- const sessionID = messages[i]?.info.sessionID;
24720
- if (sessionID) {
24721
- return sessionID;
24722
- }
24723
- }
24724
- for (let i = index + 1;i < messages.length; i++) {
24725
- const sessionID = messages[i]?.info.sessionID;
24726
- if (sessionID) {
24727
- return sessionID;
24728
- }
24729
- }
24730
- if (state.orchestratorSessionIds.size === 1) {
24731
- return Array.from(state.orchestratorSessionIds)[0];
24732
- }
24733
- return;
24734
- }
24735
- function isExternalUserMessage(message) {
24736
- if (message.info.role !== "user") {
24737
- return false;
24738
- }
24739
- const visibleText = message.parts.filter((part) => part.type === "text" && typeof part.text === "string" && !part.text.includes(SLIM_INTERNAL_INITIATOR_MARKER)).map((part) => part.text?.trim() ?? "").filter(Boolean).join(`
24740
- `);
24741
- const hasNonTextPart = message.parts.some((part) => part.type !== "text");
24742
- return !(!visibleText && !hasNonTextPart && message.parts.some((part) => part.type === "text" && typeof part.text === "string" && part.text.includes(SLIM_INTERNAL_INITIATOR_MARKER)));
24743
- }
24744
- function getLastExternalUserMessage(messages) {
24745
- for (let i = messages.length - 1;i >= 0; i--) {
24746
- const message = messages[i];
24747
- if (!isExternalUserMessage(message)) {
24748
- continue;
24749
- }
24750
- const sessionID = inferSessionID(messages, i);
24751
- const partSignature = message.parts.map((part) => {
24752
- if (part.type === "text" && typeof part.text === "string") {
24753
- const text = stripTodoHygieneInstruction(part.text);
24754
- return `${part.type}:${text.includes(SLIM_INTERNAL_INITIATOR_MARKER) ? "<internal>" : text.trim()}`;
24755
- }
24756
- return part.type ?? "unknown";
24757
- }).join("|");
24758
- const ordinal = messages.slice(0, i + 1).filter((item) => isExternalUserMessage(item)).length;
24759
- return {
24760
- sessionID,
24761
- agent: message.info.agent,
24762
- message,
24763
- signature: message.info.id ? `${message.info.id}:${partSignature}` : `${ordinal}:${partSignature}`
24764
- };
24765
- }
24766
- return null;
24767
- }
24768
- async function handleMessagesTransform(output) {
24769
- const lastUserMessage = getLastExternalUserMessage(output.messages);
24770
- if (!lastUserMessage) {
24771
- return;
24772
- }
24773
- if (lastUserMessage.agent && lastUserMessage.agent !== "orchestrator") {
24774
- return;
24775
- }
24776
- if (!lastUserMessage.sessionID) {
24777
- for (const sessionID of state.orchestratorSessionIds) {
24778
- requestSignatureBySession.delete(sessionID);
24779
- hygiene.handleRequestStart({ sessionID });
24780
- }
24781
- return;
24782
- }
24783
- const knownOrchestrator = isOrchestratorSession(lastUserMessage.sessionID);
24784
- if (lastUserMessage.agent === "orchestrator") {
24785
- registerOrchestratorSession(lastUserMessage.sessionID);
24786
- } else if (!knownOrchestrator) {
24787
- return;
24788
- }
24789
- if (requestSignatureBySession.get(lastUserMessage.sessionID) === lastUserMessage.signature) {
24790
- const reminder = hygiene.getPendingReminder(lastUserMessage.sessionID);
24791
- const guardrail = backgroundGuardrail(lastUserMessage.sessionID);
24792
- const combinedReminder = [reminder, guardrail].filter((item) => Boolean(item)).join(" ");
24793
- if (combinedReminder) {
24794
- appendTodoHygieneInstruction(lastUserMessage.message, combinedReminder);
24795
- } else {
24796
- stripTodoHygieneInstructionFromMessage(lastUserMessage.message);
24797
- }
24798
- return;
24799
- }
24800
- requestSignatureBySession.set(lastUserMessage.sessionID, lastUserMessage.signature);
24801
- stripTodoHygieneInstructionFromMessage(lastUserMessage.message);
24802
- hygiene.handleRequestStart({ sessionID: lastUserMessage.sessionID });
24803
- }
24804
- function markNotificationStarted(sessionID) {
24805
- state.notifyingSessionIds.add(sessionID);
24806
- }
24807
- function markNotificationFinished(sessionID) {
24808
- state.notifyingSessionIds.delete(sessionID);
24809
- state.notificationBusyUntilBySession.set(sessionID, Date.now() + NOTIFICATION_BUSY_GRACE_MS);
24810
- }
24811
- function clearNotificationState(sessionID) {
24812
- state.notifyingSessionIds.delete(sessionID);
24813
- state.notificationBusyUntilBySession.delete(sessionID);
24814
- }
24815
- function isNotificationBusy(sessionID) {
24816
- if (state.notifyingSessionIds.has(sessionID)) {
24817
- return true;
24818
- }
24819
- const until = state.notificationBusyUntilBySession.get(sessionID) ?? 0;
24820
- if (until <= Date.now()) {
24821
- state.notificationBusyUntilBySession.delete(sessionID);
24822
- return false;
24823
- }
24824
- return true;
24825
- }
24826
- function isOrchestratorSession(sessionID) {
24827
- return state.orchestratorSessionIds.has(sessionID);
24828
- }
24829
- function registerOrchestratorSession(sessionID) {
24830
- state.orchestratorSessionIds.add(sessionID);
24831
- }
24832
- function backgroundGuardrail(sessionID) {
24833
- if (!backgroundJobBoard)
24834
- return;
24835
- const hasRunning = backgroundJobBoard.hasRunning(sessionID);
24836
- const hasTerminal = backgroundJobBoard.hasTerminalUnreconciled(sessionID);
24837
- if (hasRunning && hasTerminal) {
24838
- return "Background jobs are still unresolved: call task_status for running jobs and reconcile terminal Background Job Board results before dependent work or finalizing.";
24839
- }
24840
- if (hasTerminal) {
24841
- return "Background jobs have terminal results: reconcile the Background Job Board results before finalizing.";
24842
- }
24843
- if (hasRunning) {
24844
- return "Background jobs are still running: call task_status before dependent work or finalizing.";
24845
- }
24846
- return;
24847
- }
24848
- function continuationPrompt(sessionID) {
24849
- const guardrail = backgroundGuardrail(sessionID);
24850
- if (!guardrail)
24851
- return CONTINUATION_PROMPT;
24852
- return `${CONTINUATION_PROMPT} ${guardrail}`;
24853
- }
24854
- function handleChatMessage(input) {
24855
- if (!input.agent) {
24598
+ function normalizeLateCancelledToolStatus(output) {
24599
+ if (typeof output.output !== "string")
24856
24600
  return;
24857
- }
24858
- state.sawChatMessage = true;
24859
- if (input.agent === "orchestrator") {
24860
- registerOrchestratorSession(input.sessionID);
24861
- }
24862
- }
24863
- const autoContinue = tool({
24864
- description: "Toggle auto-continuation for incomplete todos. When enabled, the orchestrator will automatically continue working through its todo list when it stops with incomplete items.",
24865
- args: { enabled: tool.schema.boolean() },
24866
- execute: async (args) => {
24867
- const enabled = args.enabled;
24868
- state.enabled = enabled;
24869
- state.consecutiveContinuations = 0;
24870
- if (enabled) {
24871
- state.suppressUntil = 0;
24872
- log(`[${HOOK_NAME}] Auto-continue enabled`, { maxContinuations });
24873
- return `Auto-continue enabled. Will auto-continue for up to ${maxContinuations} consecutive injections.`;
24874
- }
24875
- cancelPendingTimer(state);
24876
- log(`[${HOOK_NAME}] Auto-continue disabled`);
24877
- return "Auto-continue disabled.";
24878
- }
24879
- });
24880
- async function handleEvent(input) {
24881
- const { event } = input;
24882
- const properties = event.properties ?? {};
24883
- hygiene.handleEvent({
24884
- type: event.type,
24885
- properties: {
24886
- info: properties.info,
24887
- sessionID: properties.sessionID
24888
- }
24889
- });
24890
- if (event.type === "session.idle" || event.type === "session.status" && properties.status?.type === "idle") {
24891
- const sessionID = properties.sessionID;
24892
- if (!sessionID) {
24893
- return;
24894
- }
24895
- log(`[${HOOK_NAME}] Session idle`, { sessionID });
24896
- if (!state.sawChatMessage && state.orchestratorSessionIds.size === 0) {
24897
- registerOrchestratorSession(sessionID);
24898
- log(`[${HOOK_NAME}] Tracked orchestrator session`, {
24899
- sessionID
24900
- });
24901
- }
24902
- if (!isOrchestratorSession(sessionID)) {
24903
- log(`[${HOOK_NAME}] Skipped: not orchestrator session`, {
24904
- sessionID
24905
- });
24906
- return;
24907
- }
24908
- if (autoEnable && !state.enabled) {
24909
- try {
24910
- const todos = await fetchTodos(sessionID);
24911
- const incompleteCount2 = todos.filter((t) => !TERMINAL_TODO_STATUSES.includes(t.status)).length;
24912
- if (incompleteCount2 >= autoEnableThreshold) {
24913
- state.enabled = true;
24914
- state.consecutiveContinuations = 0;
24915
- state.suppressUntil = 0;
24916
- log(`[${HOOK_NAME}] Auto-enabled: ${incompleteCount2} incomplete todos >= threshold ${autoEnableThreshold}`, { sessionID });
24917
- } else {
24918
- log(`[${HOOK_NAME}] Auto-enable skipped: ${incompleteCount2} incomplete todos < threshold ${autoEnableThreshold}`, { sessionID });
24919
- }
24920
- } catch (error) {
24921
- log(`[${HOOK_NAME}] Warning: failed to fetch todos for auto-enable check`, {
24922
- sessionID,
24923
- error: error instanceof Error ? error.message : String(error)
24924
- });
24925
- }
24926
- }
24927
- if (!state.enabled) {
24928
- log(`[${HOOK_NAME}] Skipped: auto-continue not enabled`, {
24929
- sessionID
24930
- });
24931
- return;
24932
- }
24933
- let hasIncompleteTodos = false;
24934
- let incompleteCount = 0;
24935
- try {
24936
- const todos = await fetchTodos(sessionID);
24937
- incompleteCount = todos.filter((t) => !TERMINAL_TODO_STATUSES.includes(t.status)).length;
24938
- hasIncompleteTodos = incompleteCount > 0;
24939
- log(`[${HOOK_NAME}] Fetched todos`, {
24940
- sessionID,
24941
- hasIncompleteTodos,
24942
- total: todos.length
24943
- });
24944
- } catch (error) {
24945
- log(`[${HOOK_NAME}] Warning: failed to fetch todos`, {
24946
- sessionID,
24947
- error: error instanceof Error ? error.message : String(error)
24948
- });
24949
- return;
24950
- }
24951
- if (!hasIncompleteTodos) {
24952
- log(`[${HOOK_NAME}] Skipped: no incomplete todos`, { sessionID });
24953
- return;
24954
- }
24955
- let lastAssistantIsQuestion = false;
24956
- try {
24957
- const messagesResult = await ctx.client.session.messages({
24958
- path: { id: sessionID }
24959
- });
24960
- const messages = messagesResult.data;
24961
- const lastAssistantMessage = messages.slice().reverse().find((m) => m.info?.role === "assistant");
24962
- if (lastAssistantMessage?.parts) {
24963
- const lastText = lastAssistantMessage.parts.map((p) => p.text ?? "").join(" ");
24964
- lastAssistantIsQuestion = isQuestion(lastText);
24965
- }
24966
- log(`[${HOOK_NAME}] Fetched messages`, {
24967
- sessionID,
24968
- lastAssistantIsQuestion
24969
- });
24970
- } catch (error) {
24971
- log(`[${HOOK_NAME}] Warning: failed to fetch messages`, {
24972
- sessionID,
24973
- error: error instanceof Error ? error.message : String(error)
24974
- });
24975
- return;
24976
- }
24977
- if (lastAssistantIsQuestion) {
24978
- log(`[${HOOK_NAME}] Skipped: last message is question`, {
24979
- sessionID
24980
- });
24981
- return;
24982
- }
24983
- if (state.consecutiveContinuations >= maxContinuations) {
24984
- log(`[${HOOK_NAME}] Skipped: max continuations reached`, {
24985
- sessionID,
24986
- consecutive: state.consecutiveContinuations,
24987
- max: maxContinuations
24988
- });
24989
- return;
24990
- }
24991
- const now = Date.now();
24992
- if (now < state.suppressUntil) {
24993
- log(`[${HOOK_NAME}] Skipped: in suppress window`, {
24994
- sessionID,
24995
- suppressUntil: state.suppressUntil
24996
- });
24997
- return;
24998
- }
24999
- if (state.pendingTimer !== null || state.isAutoInjecting) {
25000
- log(`[${HOOK_NAME}] Skipped: timer pending or injection in flight`, {
25001
- sessionID
25002
- });
25003
- return;
25004
- }
25005
- log(`[${HOOK_NAME}] Scheduling continuation`, {
25006
- sessionID,
25007
- delayMs: cooldownMs
25008
- });
25009
- markNotificationStarted(sessionID);
25010
- ctx.client.session.prompt({
25011
- path: { id: sessionID },
25012
- body: {
25013
- noReply: true,
25014
- parts: [
25015
- {
25016
- type: "text",
25017
- text: [
25018
- `⎔ Auto-continue: ${incompleteCount} incomplete todos remaining — resuming in ${cooldownMs / 1000}s — Esc×2 to cancel`,
25019
- "",
25020
- "[system status: continue without acknowledging this notification]"
25021
- ].join(`
25022
- `)
25023
- }
25024
- ]
25025
- }
25026
- }).catch(() => {}).finally(() => {
25027
- markNotificationFinished(sessionID);
25028
- });
25029
- state.pendingTimerSessionId = sessionID;
25030
- state.pendingTimer = setTimeout(async () => {
25031
- state.pendingTimer = null;
25032
- state.pendingTimerSessionId = null;
25033
- clearNotificationState(sessionID);
25034
- if (!state.enabled) {
25035
- log(`[${HOOK_NAME}] Cancelled: disabled during cooldown`, {
25036
- sessionID
25037
- });
25038
- return;
25039
- }
25040
- state.isAutoInjecting = true;
25041
- try {
25042
- await ctx.client.session.prompt({
25043
- path: { id: sessionID },
25044
- body: {
25045
- parts: [
25046
- createInternalAgentTextPart(continuationPrompt(sessionID))
25047
- ]
25048
- }
25049
- });
25050
- state.consecutiveContinuations++;
25051
- log(`[${HOOK_NAME}] Continuation injected`, {
25052
- sessionID,
25053
- consecutive: state.consecutiveContinuations
25054
- });
25055
- } catch (error) {
25056
- log(`[${HOOK_NAME}] Error: failed to inject continuation`, {
25057
- sessionID,
25058
- error: error instanceof Error ? error.message : String(error)
25059
- });
25060
- } finally {
25061
- state.isAutoInjecting = false;
25062
- }
25063
- }, cooldownMs);
25064
- } else if (event.type === "session.status") {
25065
- const status = properties.status;
25066
- const sessionID = properties.sessionID;
25067
- if (status?.type === "busy") {
25068
- const isOrchestrator = isOrchestratorSession(sessionID);
25069
- const isNotification = isNotificationBusy(sessionID);
25070
- if (isOrchestrator && !isNotification && state.pendingTimerSessionId === sessionID) {
25071
- cancelPendingTimer(state);
25072
- }
25073
- if (!state.isAutoInjecting && !isNotification && isOrchestrator && state.consecutiveContinuations > 0) {
25074
- state.consecutiveContinuations = 0;
25075
- log(`[${HOOK_NAME}] Reset consecutive count on user activity`, {
25076
- sessionID
25077
- });
25078
- }
25079
- }
25080
- } else if (event.type === "session.error") {
25081
- const error = properties.error;
25082
- const sessionID = properties.sessionID;
25083
- const errorName = error?.name;
25084
- const isOrchestrator = isOrchestratorSession(sessionID);
25085
- if (isOrchestrator && (errorName === "MessageAbortedError" || errorName === "AbortError")) {
25086
- state.suppressUntil = Date.now() + SUPPRESS_AFTER_ABORT_MS;
25087
- log(`[${HOOK_NAME}] Suppressed continuation after abort`, {
25088
- sessionID,
25089
- errorName
25090
- });
25091
- }
25092
- if (isOrchestrator) {
25093
- cancelPendingTimer(state);
25094
- log(`[${HOOK_NAME}] Cancelled pending timer on error`, {
25095
- sessionID
25096
- });
25097
- }
25098
- } else if (event.type === "session.deleted") {
25099
- const deletedSessionId = properties.info?.id ?? properties.sessionID;
25100
- if (deletedSessionId && isOrchestratorSession(deletedSessionId)) {
25101
- requestSignatureBySession.delete(deletedSessionId);
25102
- if (state.pendingTimerSessionId === deletedSessionId) {
25103
- cancelPendingTimer(state);
25104
- log(`[${HOOK_NAME}] Cancelled pending timer on orchestrator delete`, {
25105
- sessionID: deletedSessionId
25106
- });
25107
- }
25108
- state.orchestratorSessionIds.delete(deletedSessionId);
25109
- clearNotificationState(deletedSessionId);
25110
- if (state.orchestratorSessionIds.size === 0) {
25111
- resetState(state);
25112
- state.sawChatMessage = false;
25113
- }
25114
- log(`[${HOOK_NAME}] Reset orchestrator session on delete`, {
25115
- sessionID: deletedSessionId
25116
- });
25117
- }
25118
- }
25119
- }
25120
- async function handleCommandExecuteBefore(input, output) {
25121
- if (input.command !== COMMAND_NAME2) {
24601
+ const status = parseTaskStatusOutput(output.output);
24602
+ if (!status)
25122
24603
  return;
25123
- }
25124
- registerOrchestratorSession(input.sessionID);
25125
- output.parts.length = 0;
25126
- const arg = input.arguments.trim().toLowerCase();
25127
- let newEnabled;
25128
- if (arg === "on") {
25129
- newEnabled = true;
25130
- } else if (arg === "off") {
25131
- newEnabled = false;
25132
- } else {
25133
- newEnabled = !state.enabled;
25134
- }
25135
- state.enabled = newEnabled;
25136
- state.consecutiveContinuations = 0;
25137
- if (!newEnabled) {
25138
- cancelPendingTimer(state);
25139
- output.parts.push(createInternalAgentTextPart("[Auto-continue: disabled by user command.]"));
25140
- log(`[${HOOK_NAME}] Disabled via /${COMMAND_NAME2} command`);
24604
+ const existing = backgroundJobBoard.get(status.taskID);
24605
+ if (!isLateCancelledTaskError(existing, status.state))
25141
24606
  return;
25142
- }
25143
- state.suppressUntil = 0;
25144
- log(`[${HOOK_NAME}] Enabled via /${COMMAND_NAME2} command`, {
25145
- maxContinuations
24607
+ log("[task-session-manager] normalized late cancelled task_status output", {
24608
+ taskID: status.taskID,
24609
+ alias: existing?.alias,
24610
+ state: existing?.state,
24611
+ terminalState: existing?.terminalState,
24612
+ result: status.result
25146
24613
  });
25147
- let hasIncompleteTodos = false;
25148
- try {
25149
- const todos = await fetchTodos(input.sessionID);
25150
- hasIncompleteTodos = todos.some((t) => !TERMINAL_TODO_STATUSES.includes(t.status));
25151
- } catch (error) {
25152
- log(`[${HOOK_NAME}] Warning: failed to fetch todos in command hook`, {
25153
- sessionID: input.sessionID,
25154
- error: error instanceof Error ? error.message : String(error)
25155
- });
25156
- }
25157
- if (hasIncompleteTodos) {
25158
- output.parts.push(createInternalAgentTextPart(`${continuationPrompt(input.sessionID)} [Auto-continue enabled: up to ${maxContinuations} continuations.]`));
25159
- } else {
25160
- output.parts.push(createInternalAgentTextPart(`[Auto-continue: enabled for up to ${maxContinuations} continuations. No incomplete todos right now.]`));
24614
+ output.output = formatCancelledTaskStatusOutput(status.taskID, existing?.resultSummary);
24615
+ if (isObjectRecord(output) && isObjectRecord(output.metadata)) {
24616
+ output.metadata.state = "cancelled";
25161
24617
  }
25162
24618
  }
25163
- return {
25164
- tool: { auto_continue: autoContinue },
25165
- handleToolExecuteAfter: hygiene.handleToolExecuteAfter,
25166
- handleMessagesTransform,
25167
- handleEvent,
25168
- handleChatMessage,
25169
- handleCommandExecuteBefore
25170
- };
24619
+ }
24620
+ function isLateCancelledTaskError(job, state) {
24621
+ if (state !== "error")
24622
+ return false;
24623
+ if (!job?.cancellationRequested)
24624
+ return false;
24625
+ return job.state === "cancelled" || job.terminalState === "cancelled";
24626
+ }
24627
+ function formatCancelledTaskStatusOutput(taskID, summary = "cancelled") {
24628
+ return [
24629
+ `task_id: ${taskID}`,
24630
+ "state: cancelled",
24631
+ "",
24632
+ "<task_error>",
24633
+ summary,
24634
+ "</task_error>"
24635
+ ].join(`
24636
+ `);
25171
24637
  }
25172
24638
  // src/interview/manager.ts
25173
24639
  import path13 from "node:path";
@@ -28156,7 +27622,7 @@ function buildAnswerPrompt(answers, questions, maxQuestions) {
28156
27622
  }
28157
27623
 
28158
27624
  // src/interview/service.ts
28159
- var COMMAND_NAME3 = "interview";
27625
+ var COMMAND_NAME2 = "interview";
28160
27626
  var DEFAULT_MAX_QUESTIONS = 2;
28161
27627
  function isTruthyEnvFlag(value) {
28162
27628
  if (!value) {
@@ -28403,11 +27869,11 @@ function createInterviewService(ctx, config, deps) {
28403
27869
  }
28404
27870
  function registerCommand(opencodeConfig) {
28405
27871
  const configCommand = opencodeConfig.command;
28406
- if (!configCommand?.[COMMAND_NAME3]) {
27872
+ if (!configCommand?.[COMMAND_NAME2]) {
28407
27873
  if (!opencodeConfig.command) {
28408
27874
  opencodeConfig.command = {};
28409
27875
  }
28410
- opencodeConfig.command[COMMAND_NAME3] = {
27876
+ opencodeConfig.command[COMMAND_NAME2] = {
28411
27877
  template: "Start an interview and write a live markdown spec",
28412
27878
  description: "Open a localhost interview UI linked to the current OpenCode session"
28413
27879
  };
@@ -28481,7 +27947,7 @@ function createInterviewService(ctx, config, deps) {
28481
27947
  }
28482
27948
  }
28483
27949
  async function handleCommandExecuteBefore(input, output) {
28484
- if (input.command !== COMMAND_NAME3) {
27950
+ if (input.command !== COMMAND_NAME2) {
28485
27951
  return;
28486
27952
  }
28487
27953
  const idea = input.arguments.trim();
@@ -29951,7 +29417,7 @@ class MultiplexerSessionManager {
29951
29417
  });
29952
29418
  return;
29953
29419
  }
29954
- if (tracked.ownerInstanceId !== this.instanceId) {
29420
+ if (reason !== "deleted" && tracked.ownerInstanceId !== this.instanceId) {
29955
29421
  log("[multiplexer-session-manager] close skipped; non-owner instance", {
29956
29422
  instanceId: this.instanceId,
29957
29423
  ownerInstanceId: tracked.ownerInstanceId,
@@ -29961,6 +29427,15 @@ class MultiplexerSessionManager {
29961
29427
  });
29962
29428
  return;
29963
29429
  }
29430
+ if (reason === "deleted" && tracked.ownerInstanceId !== this.instanceId) {
29431
+ log("[multiplexer-session-manager] closing deleted pane as non-owner", {
29432
+ instanceId: this.instanceId,
29433
+ ownerInstanceId: tracked.ownerInstanceId,
29434
+ sessionId,
29435
+ paneId: tracked.paneId,
29436
+ reason
29437
+ });
29438
+ }
29964
29439
  if (reason === "idle" && this.isRunningBackgroundJob(sessionId)) {
29965
29440
  log("[multiplexer-session-manager] close skipped; background job running", {
29966
29441
  instanceId: this.instanceId,
@@ -30129,7 +29604,7 @@ async function isServerRunning(serverUrl, timeoutMs = 3000, maxAttempts = 2) {
30129
29604
  return false;
30130
29605
  }
30131
29606
  // src/tools/ast-grep/tools.ts
30132
- import { tool as tool2 } from "@opencode-ai/plugin";
29607
+ import { tool } from "@opencode-ai/plugin";
30133
29608
 
30134
29609
  // src/tools/ast-grep/cli.ts
30135
29610
  import { existsSync as existsSync9 } from "node:fs";
@@ -30608,14 +30083,14 @@ function showOutputToUser(context, output) {
30608
30083
  const ctx = context;
30609
30084
  ctx.metadata?.({ metadata: { output } });
30610
30085
  }
30611
- var ast_grep_search = tool2({
30086
+ var ast_grep_search = tool({
30612
30087
  description: "Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " + "Use meta-variables: $VAR (single node), $$$ (multiple nodes). " + "IMPORTANT: Patterns must be complete AST nodes (valid code). " + "For functions, include params and body: 'export async function $NAME($$$) { $$$ }' not 'export async function $NAME'. " + "Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'",
30613
30088
  args: {
30614
- pattern: tool2.schema.string().describe("AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."),
30615
- lang: tool2.schema.enum(CLI_LANGUAGES).describe("Target language"),
30616
- paths: tool2.schema.array(tool2.schema.string()).optional().describe("Paths to search (default: ['.'])"),
30617
- globs: tool2.schema.array(tool2.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"),
30618
- context: tool2.schema.number().optional().describe("Context lines around match")
30089
+ pattern: tool.schema.string().describe("AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."),
30090
+ lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
30091
+ paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"),
30092
+ globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"),
30093
+ context: tool.schema.number().optional().describe("Context lines around match")
30619
30094
  },
30620
30095
  execute: async (args, context) => {
30621
30096
  try {
@@ -30644,15 +30119,15 @@ ${hint}`;
30644
30119
  }
30645
30120
  }
30646
30121
  });
30647
- var ast_grep_replace = tool2({
30122
+ var ast_grep_replace = tool({
30648
30123
  description: "Replace code patterns across filesystem with AST-aware rewriting. " + "Dry-run by default. Use meta-variables in rewrite to preserve matched content. " + "Example: pattern='console.log($MSG)' rewrite='logger.info($MSG)'",
30649
30124
  args: {
30650
- pattern: tool2.schema.string().describe("AST pattern to match"),
30651
- rewrite: tool2.schema.string().describe("Replacement pattern (can use $VAR from pattern)"),
30652
- lang: tool2.schema.enum(CLI_LANGUAGES).describe("Target language"),
30653
- paths: tool2.schema.array(tool2.schema.string()).optional().describe("Paths to search"),
30654
- globs: tool2.schema.array(tool2.schema.string()).optional().describe("Include/exclude globs"),
30655
- dryRun: tool2.schema.boolean().optional().describe("Preview changes without applying (default: true)")
30125
+ pattern: tool.schema.string().describe("AST pattern to match"),
30126
+ rewrite: tool.schema.string().describe("Replacement pattern (can use $VAR from pattern)"),
30127
+ lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
30128
+ paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search"),
30129
+ globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs"),
30130
+ dryRun: tool.schema.boolean().optional().describe("Preview changes without applying (default: true)")
30656
30131
  },
30657
30132
  execute: async (args, context) => {
30658
30133
  try {
@@ -30676,11 +30151,14 @@ var ast_grep_replace = tool2({
30676
30151
  });
30677
30152
  // src/tools/cancel-task.ts
30678
30153
  import {
30679
- tool as tool3
30154
+ tool as tool2
30680
30155
  } from "@opencode-ai/plugin";
30681
- var z4 = tool3.schema;
30156
+ var z4 = tool2.schema;
30157
+
30158
+ class SessionStillRunningError extends Error {
30159
+ }
30682
30160
  function createCancelTaskTool(options) {
30683
- const cancel_task = tool3({
30161
+ const cancel_task = tool2({
30684
30162
  description: `Cancel a tracked background specialist task.
30685
30163
 
30686
30164
  Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accepts either the native task_id/session ID or the parent-scoped alias shown in the Background Job Board. Cancellation is not rollback: if cancelling a writer, inspect and reconcile partial file changes before replacing the lane.`,
@@ -30702,43 +30180,68 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
30702
30180
  if (!requested)
30703
30181
  throw new Error("cancel_task requires task_id");
30704
30182
  const job = options.backgroundJobBoard.resolve(parentSessionID, requested);
30183
+ log("[cancel-task] request received", {
30184
+ parentSessionID,
30185
+ requested,
30186
+ resolvedTaskID: job?.taskID,
30187
+ alias: job?.alias,
30188
+ state: job?.state,
30189
+ terminalState: job?.terminalState,
30190
+ cancellationRequested: job?.cancellationRequested
30191
+ });
30705
30192
  if (!job) {
30706
- return [
30707
- `task_id: ${requested}`,
30708
- "state: unknown",
30709
- "",
30710
- "<task_error>",
30711
- "unknown or unowned background task",
30712
- "</task_error>"
30713
- ].join(`
30714
- `);
30715
- }
30716
- const shouldAbort = job.state === "running" || job.state === "cancelled" || job.state === "reconciled" && job.terminalState === "cancelled";
30717
- if (!shouldAbort) {
30718
- return [
30719
- `task_id: ${job.taskID}`,
30720
- `state: ${job.state}`,
30721
- "",
30722
- "<task_result>",
30723
- `not cancelled: task is already ${job.state}`,
30724
- "</task_result>"
30725
- ].join(`
30726
- `);
30193
+ if (isSessionID(requested)) {
30194
+ if (requested === parentSessionID) {
30195
+ log("[cancel-task] rejected parent session cancellation", {
30196
+ parentSessionID,
30197
+ taskID: requested
30198
+ });
30199
+ return unknownTaskOutput(requested, "cannot cancel parent session");
30200
+ }
30201
+ const knownJob = options.backgroundJobBoard.get(requested);
30202
+ if (knownJob && knownJob.parentSessionID !== parentSessionID) {
30203
+ log("[cancel-task] rejected unowned tracked raw session", {
30204
+ parentSessionID,
30205
+ taskID: requested,
30206
+ ownerParentSessionID: knownJob.parentSessionID
30207
+ });
30208
+ return unknownTaskOutput(requested, "unknown or unowned background task");
30209
+ }
30210
+ const parentID = await getSessionParentID(options.client, requested);
30211
+ if (parentID !== parentSessionID) {
30212
+ log("[cancel-task] rejected raw session without parent ownership", {
30213
+ parentSessionID,
30214
+ taskID: requested,
30215
+ actualParentID: parentID
30216
+ });
30217
+ return unknownTaskOutput(requested, "unknown or unowned background task");
30218
+ }
30219
+ log("[cancel-task] falling back to owned raw session abort", {
30220
+ parentSessionID,
30221
+ taskID: requested
30222
+ });
30223
+ return cancelSessionByID(options, requested, args.reason);
30224
+ }
30225
+ return unknownTaskOutput(requested, "unknown or unowned background task");
30727
30226
  }
30728
30227
  try {
30729
- await abortSessionWithTimeout(options.client, job.taskID, options.abortTimeoutMs ?? 1e4);
30228
+ await abortAndVerifySession(options, job.taskID);
30730
30229
  } catch (error) {
30731
- const timedOut = error instanceof OperationTimeoutError;
30732
- if (timedOut) {
30733
- options.backgroundJobBoard.updateStatus({
30734
- taskID: job.taskID,
30735
- state: "error",
30736
- resultSummary: error instanceof Error ? error.message : "cancel request timed out"
30737
- });
30738
- }
30230
+ const stillRunning = error instanceof SessionStillRunningError;
30231
+ log("[cancel-task] abort failed", {
30232
+ taskID: job.taskID,
30233
+ stillRunning,
30234
+ error: error instanceof Error ? error.message : String(error)
30235
+ });
30236
+ options.backgroundJobBoard.updateStatus({
30237
+ taskID: job.taskID,
30238
+ state: "running",
30239
+ statusUncertain: true,
30240
+ lastStatusError: error instanceof Error ? error.message : String(error)
30241
+ });
30739
30242
  return [
30740
30243
  `task_id: ${job.taskID}`,
30741
- `state: ${timedOut ? "error" : "cancel_error"}`,
30244
+ "state: running",
30742
30245
  "",
30743
30246
  "<task_error>",
30744
30247
  error instanceof Error ? error.message : String(error),
@@ -30746,7 +30249,14 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
30746
30249
  ].join(`
30747
30250
  `);
30748
30251
  }
30749
- const cancelled = options.backgroundJobBoard.markCancelled(job.taskID, args.reason);
30252
+ const cancelled = options.backgroundJobBoard.markCancelled(job.taskID, args.reason, Date.now(), { force: true });
30253
+ log("[cancel-task] marked job cancelled after verified abort", {
30254
+ taskID: job.taskID,
30255
+ alias: job.alias,
30256
+ previousState: job.state,
30257
+ state: cancelled?.state,
30258
+ cancellationRequested: cancelled?.cancellationRequested
30259
+ });
30750
30260
  return [
30751
30261
  `task_id: ${job.taskID}`,
30752
30262
  `state: ${cancelled?.state ?? "cancelled"}`,
@@ -30760,11 +30270,287 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
30760
30270
  });
30761
30271
  return { cancel_task };
30762
30272
  }
30273
+ async function cancelSessionByID(options, taskID, reason) {
30274
+ try {
30275
+ await abortAndVerifySession(options, taskID);
30276
+ } catch (error) {
30277
+ const stillRunning = error instanceof SessionStillRunningError;
30278
+ log("[cancel-task] raw session abort failed", {
30279
+ taskID,
30280
+ stillRunning,
30281
+ error: error instanceof Error ? error.message : String(error)
30282
+ });
30283
+ return [
30284
+ `task_id: ${taskID}`,
30285
+ `state: ${stillRunning ? "running" : "error"}`,
30286
+ "",
30287
+ "<task_error>",
30288
+ error instanceof Error ? error.message : String(error),
30289
+ "</task_error>"
30290
+ ].join(`
30291
+ `);
30292
+ }
30293
+ return [
30294
+ `task_id: ${taskID}`,
30295
+ "state: cancelled",
30296
+ "",
30297
+ "<task_error>",
30298
+ normalizeCancelReason2(reason),
30299
+ "</task_error>"
30300
+ ].join(`
30301
+ `);
30302
+ }
30303
+ async function abortAndVerifySession(options, taskID) {
30304
+ log("[cancel-task] abort attempt starting", { taskID });
30305
+ const abortStartedAt = Date.now();
30306
+ try {
30307
+ await abortSessionWithTimeout(options.client, taskID, options.abortTimeoutMs ?? 1e4);
30308
+ log("[cancel-task] abort call returned", { taskID });
30309
+ } catch (error) {
30310
+ log("[cancel-task] abort call failed", {
30311
+ taskID,
30312
+ error: error instanceof Error ? error.message : String(error),
30313
+ canDelete: canDeleteSession(options.client)
30314
+ });
30315
+ if (!canDeleteSession(options.client))
30316
+ throw error;
30317
+ }
30318
+ if (canDeleteSession(options.client)) {
30319
+ await deleteAndVerifySession(options, taskID, "cancel-task-after-abort");
30320
+ return;
30321
+ }
30322
+ const verifyAbortMs = options.verifyAbortMs ?? 8000;
30323
+ const stableStoppedMs = options.stableStoppedMs ?? 3000;
30324
+ const retryIntervalMs = options.abortRetryIntervalMs ?? 150;
30325
+ const deadline = Date.now() + verifyAbortMs;
30326
+ log("[cancel-task] abort verification starting", {
30327
+ taskID,
30328
+ verifyAbortMs,
30329
+ stableStoppedMs,
30330
+ retryIntervalMs
30331
+ });
30332
+ let attempts = 0;
30333
+ let stableStoppedSince;
30334
+ let lastStatus;
30335
+ while (Date.now() <= deadline) {
30336
+ attempts += 1;
30337
+ const statusSnapshot = await getSessionStatus(options.client, taskID);
30338
+ lastStatus = statusSnapshot.status;
30339
+ log("[cancel-task] abort verification status", {
30340
+ taskID,
30341
+ attempts,
30342
+ status: statusSnapshot.status,
30343
+ statusSource: statusSnapshot.source,
30344
+ statusKeys: statusSnapshot.keys,
30345
+ stableStoppedSince,
30346
+ stableStoppedForMs: stableStoppedSince ? Date.now() - stableStoppedSince : 0,
30347
+ boardState: options.backgroundJobBoard.get(taskID)?.state,
30348
+ boardLastLiveBusyAt: options.backgroundJobBoard.get(taskID)?.lastLiveBusyAt
30349
+ });
30350
+ const boardLastLiveBusyAt = options.backgroundJobBoard.get(taskID)?.lastLiveBusyAt;
30351
+ if (boardLastLiveBusyAt && boardLastLiveBusyAt >= abortStartedAt) {
30352
+ log("[cancel-task] abort verification saw board busy after abort", {
30353
+ taskID,
30354
+ attempts,
30355
+ abortStartedAt,
30356
+ boardLastLiveBusyAt,
30357
+ status: statusSnapshot.status,
30358
+ statusSource: statusSnapshot.source
30359
+ });
30360
+ await deleteAndVerifySession(options, taskID, "board-busy-after-abort");
30361
+ return;
30362
+ }
30363
+ if (statusSnapshot.status === "busy" || statusSnapshot.status === "retry") {
30364
+ if (stableStoppedSince !== undefined) {
30365
+ log("[cancel-task] abort verification saw busy after idle", {
30366
+ taskID,
30367
+ attempts,
30368
+ stableStoppedForMs: Date.now() - stableStoppedSince
30369
+ });
30370
+ await deleteAndVerifySession(options, taskID, "busy-after-idle");
30371
+ return;
30372
+ }
30373
+ stableStoppedSince = undefined;
30374
+ await abortSessionWithTimeout(options.client, taskID, options.abortTimeoutMs ?? 1e4);
30375
+ log("[cancel-task] abort retry returned", {
30376
+ taskID,
30377
+ attempts,
30378
+ status: statusSnapshot.status
30379
+ });
30380
+ await delay(retryIntervalMs);
30381
+ continue;
30382
+ }
30383
+ stableStoppedSince ??= Date.now();
30384
+ if (Date.now() - stableStoppedSince >= stableStoppedMs) {
30385
+ log("[cancel-task] abort verified stopped", {
30386
+ taskID,
30387
+ attempts,
30388
+ status: statusSnapshot.status,
30389
+ stableStoppedMs
30390
+ });
30391
+ return;
30392
+ }
30393
+ await delay(retryIntervalMs);
30394
+ }
30395
+ log("[cancel-task] abort verification timed out", {
30396
+ taskID,
30397
+ attempts,
30398
+ lastStatus,
30399
+ stableStoppedSince
30400
+ });
30401
+ if (lastStatus === "busy" || lastStatus === "retry") {
30402
+ await deleteAndVerifySession(options, taskID, "still-busy-after-abort");
30403
+ return;
30404
+ }
30405
+ throw new SessionStillRunningError(`Session abort returned but task did not stay stopped: ${taskID}`);
30406
+ }
30407
+ async function deleteAndVerifySession(options, taskID, reason) {
30408
+ const session2 = options.client.session;
30409
+ if (!session2.delete) {
30410
+ log("[cancel-task] session delete unavailable", { taskID, reason });
30411
+ throw new SessionStillRunningError(`Session resumed after abort and delete is unavailable: ${taskID}`);
30412
+ }
30413
+ log("[cancel-task] deleting session after unstable abort", {
30414
+ taskID,
30415
+ reason
30416
+ });
30417
+ try {
30418
+ await withTimeout(session2.delete({ path: { id: taskID } }), options.deleteTimeoutMs ?? 1e4, `Session delete timed out after ${options.deleteTimeoutMs ?? 1e4}ms`);
30419
+ log("[cancel-task] session delete returned", { taskID, reason });
30420
+ } catch (error) {
30421
+ log("[cancel-task] session delete failed; verifying live state", {
30422
+ taskID,
30423
+ reason,
30424
+ error: error instanceof Error ? error.message : String(error)
30425
+ });
30426
+ const status = await getSessionStatus(options.client, taskID);
30427
+ log("[cancel-task] delete failure verification status", {
30428
+ taskID,
30429
+ reason,
30430
+ status: status.status,
30431
+ statusSource: status.source,
30432
+ statusKeys: status.keys
30433
+ });
30434
+ if (status.status === "busy" || status.status === "retry") {
30435
+ throw new SessionStillRunningError(`Session delete failed and task is still busy: ${taskID}`);
30436
+ }
30437
+ if (status.status !== "idle")
30438
+ throw error;
30439
+ }
30440
+ const deadline = Date.now() + (options.deleteVerifyMs ?? 1500);
30441
+ const stableStoppedMs = options.deleteStableStoppedMs ?? 300;
30442
+ const retryIntervalMs = options.abortRetryIntervalMs ?? 150;
30443
+ let stableStoppedSince;
30444
+ let attempts = 0;
30445
+ let lastStatus;
30446
+ while (Date.now() <= deadline) {
30447
+ attempts += 1;
30448
+ const status = await getSessionStatus(options.client, taskID);
30449
+ lastStatus = status.status;
30450
+ log("[cancel-task] delete verification status", {
30451
+ taskID,
30452
+ reason,
30453
+ attempts,
30454
+ status: status.status,
30455
+ statusSource: status.source,
30456
+ statusKeys: status.keys,
30457
+ stableStoppedSince
30458
+ });
30459
+ if (status.status === "busy" || status.status === "retry") {
30460
+ stableStoppedSince = undefined;
30461
+ await delay(retryIntervalMs);
30462
+ continue;
30463
+ }
30464
+ stableStoppedSince ??= Date.now();
30465
+ if (Date.now() - stableStoppedSince >= stableStoppedMs)
30466
+ return;
30467
+ await delay(retryIntervalMs);
30468
+ }
30469
+ throw new SessionStillRunningError(`Session delete returned but task did not stay stopped: ${taskID} (${lastStatus ?? "unknown"})`);
30470
+ }
30471
+ function canDeleteSession(client) {
30472
+ const session2 = client.session;
30473
+ return typeof session2.delete === "function";
30474
+ }
30475
+ async function getSessionStatus(client, taskID) {
30476
+ try {
30477
+ const result = await client.session.status();
30478
+ const data = result.data;
30479
+ if (!isObjectRecord2(data)) {
30480
+ return { status: undefined, source: "invalid-data", keys: [] };
30481
+ }
30482
+ const keys = Object.keys(data).slice(0, 20);
30483
+ const item = data[taskID];
30484
+ if (item === undefined) {
30485
+ return { status: "idle", source: "missing-from-map", keys };
30486
+ }
30487
+ if (isObjectRecord2(item) && typeof item.type === "string") {
30488
+ return { status: item.type, source: "task-map-entry", keys };
30489
+ }
30490
+ if (typeof data.type === "string") {
30491
+ return { status: data.type, source: "legacy-data-type", keys };
30492
+ }
30493
+ const nested = data.status;
30494
+ if (isObjectRecord2(nested) && typeof nested.type === "string") {
30495
+ return { status: nested.type, source: "legacy-data-status", keys };
30496
+ }
30497
+ return { status: undefined, source: "unknown-shape", keys };
30498
+ } catch (error) {
30499
+ log("[cancel-task] session status lookup failed", {
30500
+ taskID,
30501
+ error: error instanceof Error ? error.message : String(error)
30502
+ });
30503
+ return { status: undefined, source: "lookup-error", keys: [] };
30504
+ }
30505
+ }
30506
+ function delay(ms) {
30507
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
30508
+ }
30509
+ function isObjectRecord2(value) {
30510
+ return typeof value === "object" && value !== null;
30511
+ }
30512
+ function isSessionID(value) {
30513
+ return /^ses_[\w-]+$/.test(value);
30514
+ }
30515
+ function normalizeCancelReason2(reason) {
30516
+ const normalized = reason?.replace(/\s+/g, " ").trim();
30517
+ return normalized ? `cancelled: ${normalized}` : "cancelled";
30518
+ }
30519
+ async function getSessionParentID(client, taskID) {
30520
+ const session2 = client.session;
30521
+ if (!session2.get)
30522
+ return;
30523
+ try {
30524
+ const response = await session2.get({ path: { id: taskID } });
30525
+ const data = response.data;
30526
+ if (!isObjectRecord2(data))
30527
+ return;
30528
+ const parentID = data.parentID;
30529
+ return typeof parentID === "string" ? parentID : undefined;
30530
+ } catch (error) {
30531
+ log("[cancel-task] session metadata lookup failed", {
30532
+ taskID,
30533
+ error: error instanceof Error ? error.message : String(error)
30534
+ });
30535
+ return;
30536
+ }
30537
+ }
30538
+ function unknownTaskOutput(taskID, message) {
30539
+ return [
30540
+ `task_id: ${taskID}`,
30541
+ "state: unknown",
30542
+ "",
30543
+ "<task_error>",
30544
+ message,
30545
+ "</task_error>"
30546
+ ].join(`
30547
+ `);
30548
+ }
30763
30549
  // src/tools/council.ts
30764
30550
  import {
30765
- tool as tool4
30551
+ tool as tool3
30766
30552
  } from "@opencode-ai/plugin";
30767
- var z5 = tool4.schema;
30553
+ var z5 = tool3.schema;
30768
30554
  function formatModelComposition(councillorResults) {
30769
30555
  return councillorResults.map((cr) => {
30770
30556
  const shortModel = shortModelLabel(cr.model);
@@ -30772,7 +30558,7 @@ function formatModelComposition(councillorResults) {
30772
30558
  }).join(", ");
30773
30559
  }
30774
30560
  function createCouncilTool(_ctx, councilManager) {
30775
- const council_session = tool4({
30561
+ const council_session = tool3({
30776
30562
  description: `Launch a multi-LLM council session for consensus-based analysis.
30777
30563
 
30778
30564
  Sends the prompt to multiple models (councillors) in parallel and returns their formatted responses for you to synthesize.
@@ -30826,6 +30612,9 @@ Returns the councillor responses with a summary footer.`,
30826
30612
  });
30827
30613
  return { council_session };
30828
30614
  }
30615
+ // src/tools/preset-manager.ts
30616
+ import * as fs10 from "node:fs";
30617
+
30829
30618
  // src/tui-state.ts
30830
30619
  import * as fs9 from "node:fs";
30831
30620
  import * as os5 from "node:os";
@@ -30895,11 +30684,11 @@ function recordTuiAgentModel(input) {
30895
30684
  }
30896
30685
 
30897
30686
  // src/tools/preset-manager.ts
30898
- var COMMAND_NAME4 = "preset";
30687
+ var COMMAND_NAME3 = "preset";
30899
30688
  function createPresetManager(ctx, config) {
30900
30689
  let activePreset = getActiveRuntimePreset() ?? config.preset ?? null;
30901
30690
  async function handleCommandExecuteBefore(input, output) {
30902
- if (input.command !== COMMAND_NAME4) {
30691
+ if (input.command !== COMMAND_NAME3) {
30903
30692
  return;
30904
30693
  }
30905
30694
  output.parts.length = 0;
@@ -30918,11 +30707,11 @@ function createPresetManager(ctx, config) {
30918
30707
  }
30919
30708
  function registerCommand(opencodeConfig) {
30920
30709
  const configCommand = opencodeConfig.command;
30921
- if (!configCommand?.[COMMAND_NAME4]) {
30710
+ if (!configCommand?.[COMMAND_NAME3]) {
30922
30711
  if (!opencodeConfig.command) {
30923
30712
  opencodeConfig.command = {};
30924
30713
  }
30925
- opencodeConfig.command[COMMAND_NAME4] = {
30714
+ opencodeConfig.command[COMMAND_NAME3] = {
30926
30715
  template: "List available presets and switch between them",
30927
30716
  description: "Switch agent presets at runtime (e.g., /preset cheap, /preset powerful)"
30928
30717
  };
@@ -30944,64 +30733,47 @@ function createPresetManager(ctx, config) {
30944
30733
  agentUpdates[resolvedName] = agentConfig;
30945
30734
  }
30946
30735
  }
30947
- const currentRuntimePreset = getActiveRuntimePreset();
30948
- const resetUpdates = {};
30949
- if (currentRuntimePreset && config.presets?.[currentRuntimePreset]) {
30950
- const oldPreset = config.presets[currentRuntimePreset];
30951
- for (const rawName of Object.keys(oldPreset)) {
30952
- const resolvedOld = AGENT_ALIASES[rawName] ?? rawName;
30953
- if (resolvedOld in agentUpdates)
30954
- continue;
30955
- const baseline = config.agents?.[resolvedOld];
30956
- if (baseline) {
30957
- resetUpdates[resolvedOld] = mapOverrideToAgentConfig(baseline);
30958
- }
30959
- }
30960
- }
30961
30736
  const hasAgentUpdates = Object.keys(agentUpdates).length > 0;
30962
- const allUpdates = { ...resetUpdates, ...agentUpdates };
30963
30737
  if (!hasAgentUpdates) {
30964
30738
  output.parts.push(createInternalAgentTextPart(`Preset "${presetName}" is empty (no agent overrides defined).`));
30965
30739
  return;
30966
30740
  }
30967
- const previousPreset = activePreset;
30968
30741
  setActiveRuntimePresetWithPrevious(presetName);
30969
30742
  try {
30970
- await ctx.client.config.update({
30971
- body: { agent: allUpdates }
30972
- });
30973
- const snapshot = readTuiSnapshot();
30974
- const agentModels = { ...snapshot.agentModels };
30975
- for (const [agentName, agentConfig] of Object.entries(allUpdates)) {
30976
- if (typeof agentConfig.model === "string") {
30977
- agentModels[agentName] = agentConfig.model;
30978
- }
30979
- }
30980
- recordTuiAgentModels({ agentModels });
30981
- activePreset = presetName;
30982
- const summaryParts = [];
30983
- for (const [name, cfg] of Object.entries(agentUpdates)) {
30984
- const parts = [name];
30985
- if (cfg.model)
30986
- parts.push(`model: ${cfg.model}`);
30987
- if (cfg.variant)
30988
- parts.push(`variant: ${cfg.variant}`);
30989
- if (cfg.temperature !== undefined)
30990
- parts.push(`temp: ${cfg.temperature}`);
30991
- if (cfg.options)
30992
- parts.push("options: yes");
30993
- summaryParts.push(parts.join(" → "));
30994
- }
30995
- if (Object.keys(resetUpdates).length > 0) {
30996
- summaryParts.push(`Reset to baseline: ${Object.keys(resetUpdates).join(", ")}`);
30997
- }
30998
- output.parts.push(createInternalAgentTextPart(`Switched to preset "${presetName}":
30743
+ const { userConfigPath } = findPluginConfigPaths(ctx.directory);
30744
+ if (userConfigPath) {
30745
+ const raw = fs10.readFileSync(userConfigPath, "utf-8");
30746
+ const persisted = JSON.parse(stripJsonComments(raw));
30747
+ persisted.preset = presetName;
30748
+ fs10.writeFileSync(userConfigPath, `${JSON.stringify(persisted, null, 2)}
30749
+ `);
30750
+ }
30751
+ } catch {}
30752
+ const snapshot = readTuiSnapshot();
30753
+ const agentModels = { ...snapshot.agentModels };
30754
+ for (const [agentName, agentConfig] of Object.entries(agentUpdates)) {
30755
+ if (typeof agentConfig.model === "string") {
30756
+ agentModels[agentName] = agentConfig.model;
30757
+ }
30758
+ }
30759
+ recordTuiAgentModels({ agentModels });
30760
+ activePreset = presetName;
30761
+ const summaryParts = [];
30762
+ for (const [name, cfg] of Object.entries(agentUpdates)) {
30763
+ const parts = [name];
30764
+ if (cfg.model)
30765
+ parts.push(`model: ${cfg.model}`);
30766
+ if (cfg.variant)
30767
+ parts.push(`variant: ${cfg.variant}`);
30768
+ if (cfg.temperature !== undefined)
30769
+ parts.push(`temp: ${cfg.temperature}`);
30770
+ if (cfg.options)
30771
+ parts.push("options: yes");
30772
+ summaryParts.push(parts.join(" → "));
30773
+ }
30774
+ output.parts.push(createInternalAgentTextPart(`Saved preset "${presetName}". Restart or reload OpenCode to apply it to agent configuration. The current session was not reloaded to avoid interrupting the active conversation.
30999
30775
  ${summaryParts.join(`
31000
30776
  `)}`));
31001
- } catch (err) {
31002
- rollbackRuntimePreset(previousPreset);
31003
- output.parts.push(createInternalAgentTextPart(`Failed to switch preset "${presetName}": ${String(err)}`));
31004
- }
31005
30777
  }
31006
30778
  function mapOverrideToAgentConfig(override) {
31007
30779
  const agentConfig = {};
@@ -31091,7 +30863,7 @@ var WEBFETCH_DESCRIPTION = "Fetch a URL with better extraction for static/docs p
31091
30863
  import os6 from "node:os";
31092
30864
  import path18 from "node:path";
31093
30865
  import {
31094
- tool as tool5
30866
+ tool as tool4
31095
30867
  } from "@opencode-ai/plugin";
31096
30868
 
31097
30869
  // src/tools/smartfetch/binary.ts
@@ -32873,10 +32645,10 @@ async function runSecondaryModelWithFallback(client, directory, models, prompt,
32873
32645
  }
32874
32646
 
32875
32647
  // src/tools/smartfetch/tool.ts
32876
- var z6 = tool5.schema;
32648
+ var z6 = tool4.schema;
32877
32649
  function createWebfetchTool(pluginCtx, options = {}) {
32878
32650
  const binaryDir = options.binaryDir || path18.join(os6.tmpdir(), "opencode-smartfetch");
32879
- return tool5({
32651
+ return tool4({
32880
32652
  description: WEBFETCH_DESCRIPTION,
32881
32653
  args: {
32882
32654
  url: z6.httpUrl(),
@@ -33467,7 +33239,6 @@ var OhMyOpenCodeLite = async (ctx) => {
33467
33239
  let applyPatchHook;
33468
33240
  let jsonErrorRecoveryHook;
33469
33241
  let foregroundFallback;
33470
- let todoContinuationHook;
33471
33242
  let deepworkCommandHook;
33472
33243
  let taskSessionManagerHook;
33473
33244
  let backgroundJobBoard;
@@ -33560,13 +33331,6 @@ var OhMyOpenCodeLite = async (ctx) => {
33560
33331
  applyPatchHook = createApplyPatchHook(ctx);
33561
33332
  jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
33562
33333
  foregroundFallback = new ForegroundFallbackManager(ctx.client, runtimeChains, config.fallback?.enabled !== false && Object.keys(runtimeChains).length > 0);
33563
- todoContinuationHook = createTodoContinuationHook(ctx, {
33564
- maxContinuations: config.todoContinuation?.maxContinuations ?? 5,
33565
- cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
33566
- autoEnable: config.todoContinuation?.autoEnable ?? false,
33567
- autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
33568
- backgroundJobBoard
33569
- });
33570
33334
  deepworkCommandHook = createDeepworkCommandHook();
33571
33335
  taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
33572
33336
  maxSessionsPerAgent: config.backgroundJobs?.maxSessionsPerAgent ?? 2,
@@ -33583,7 +33347,7 @@ var OhMyOpenCodeLite = async (ctx) => {
33583
33347
  backgroundJobBoard,
33584
33348
  shouldManageSession: (sessionID) => sessionAgentMap.get(sessionID) === "orchestrator"
33585
33349
  });
33586
- toolCount = Object.keys(councilTools).length + Object.keys(cancelTaskTools).length + Object.keys(todoContinuationHook.tool).length + 1 + 2;
33350
+ toolCount = Object.keys(councilTools).length + Object.keys(cancelTaskTools).length + 1 + 2;
33587
33351
  } catch (err) {
33588
33352
  log("[plugin] FATAL: init failed", String(err));
33589
33353
  await appLog(ctx, "error", `INIT FAILED: ${String(err)}. Report at github.com/alvinunreal/oh-my-opencode-slim/issues/310`);
@@ -33627,7 +33391,6 @@ var OhMyOpenCodeLite = async (ctx) => {
33627
33391
  ...councilTools,
33628
33392
  ...cancelTaskTools,
33629
33393
  webfetch,
33630
- ...todoContinuationHook.tool,
33631
33394
  ast_grep_search,
33632
33395
  ast_grep_replace
33633
33396
  },
@@ -33814,16 +33577,6 @@ var OhMyOpenCodeLite = async (ctx) => {
33814
33577
  }
33815
33578
  agentConfigEntry.permission = agentPermission;
33816
33579
  }
33817
- const configCommand = opencodeConfig.command;
33818
- if (!configCommand?.["auto-continue"]) {
33819
- if (!opencodeConfig.command) {
33820
- opencodeConfig.command = {};
33821
- }
33822
- opencodeConfig.command["auto-continue"] = {
33823
- template: "Call the auto_continue tool with enabled=true",
33824
- description: "Enable auto-continuation — orchestrator keeps working through incomplete todos"
33825
- };
33826
- }
33827
33580
  interviewManager.registerCommand(opencodeConfig);
33828
33581
  deepworkCommandHook.registerCommand(opencodeConfig);
33829
33582
  presetManager.registerCommand(opencodeConfig);
@@ -33850,7 +33603,6 @@ var OhMyOpenCodeLite = async (ctx) => {
33850
33603
  await multiplexerSessionManager.onSessionStatus(event);
33851
33604
  await multiplexerSessionManager.onSessionDeleted(event);
33852
33605
  await foregroundFallback.handleEvent(input.event);
33853
- await todoContinuationHook.handleEvent(input);
33854
33606
  await autoUpdateChecker.event(input);
33855
33607
  await interviewManager.handleEvent(input);
33856
33608
  await taskSessionManagerHook.event(input);
@@ -33908,7 +33660,6 @@ var OhMyOpenCodeLite = async (ctx) => {
33908
33660
  }
33909
33661
  },
33910
33662
  "command.execute.before": async (input, output) => {
33911
- await todoContinuationHook.handleCommandExecuteBefore(input, output);
33912
33663
  await interviewManager.handleCommandExecuteBefore(input, output);
33913
33664
  await presetManager.handleCommandExecuteBefore(input, output);
33914
33665
  await deepworkCommandHook.handleCommandExecuteBefore(input, output);
@@ -33923,10 +33674,6 @@ var OhMyOpenCodeLite = async (ctx) => {
33923
33674
  if (agent) {
33924
33675
  sessionAgentMap.set(input.sessionID, agent);
33925
33676
  }
33926
- todoContinuationHook.handleChatMessage({
33927
- sessionID: input.sessionID,
33928
- agent
33929
- });
33930
33677
  },
33931
33678
  "experimental.chat.system.transform": async (input, output) => {
33932
33679
  const agentName = input.sessionID ? sessionAgentMap.get(input.sessionID) : undefined;
@@ -33961,9 +33708,6 @@ ${output.system[0]}` : "");
33961
33708
  disabledAgents,
33962
33709
  log
33963
33710
  });
33964
- await todoContinuationHook.handleMessagesTransform({
33965
- messages: typedOutput.messages
33966
- });
33967
33711
  await taskSessionManagerHook["experimental.chat.messages.transform"](input, typedOutput);
33968
33712
  await phaseReminderHook["experimental.chat.messages.transform"](input, typedOutput);
33969
33713
  await filterAvailableSkillsHook["experimental.chat.messages.transform"](input, typedOutput);
@@ -33985,7 +33729,6 @@ ${output.system[0]}` : "");
33985
33729
  };
33986
33730
  await runPostToolHook("delegate-task-retry", () => delegateTaskRetryHook["tool.execute.after"](input, output));
33987
33731
  await runPostToolHook("json-error-recovery", () => jsonErrorRecoveryHook["tool.execute.after"](input, output));
33988
- await runPostToolHook("todo-continuation", () => todoContinuationHook.handleToolExecuteAfter(input, output));
33989
33732
  await runPostToolHook("post-file-tool-nudge", () => postFileToolNudgeHook["tool.execute.after"](input, output));
33990
33733
  await runPostToolHook("task-session-manager", () => taskSessionManagerHook["tool.execute.after"](input, output));
33991
33734
  if (input.tool.toLowerCase() === "task") {