oh-my-opencode-slim 2.1.0 → 2.2.0

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.
Files changed (61) hide show
  1. package/README.ja-JP.md +31 -30
  2. package/README.ko-KR.md +31 -30
  3. package/README.md +57 -33
  4. package/README.zh-CN.md +29 -28
  5. package/dist/agents/index.d.ts +0 -4
  6. package/dist/cli/index.js +90 -16
  7. package/dist/cli/providers.d.ts +7 -7
  8. package/dist/companion/manager.d.ts +3 -0
  9. package/dist/config/constants.d.ts +12 -1
  10. package/dist/config/council-schema.d.ts +37 -12
  11. package/dist/config/schema.d.ts +691 -6
  12. package/dist/config/strip-orchestrator-model.d.ts +9 -0
  13. package/dist/council/council-manager.d.ts +7 -3
  14. package/dist/hooks/command-hook-utils.d.ts +5 -0
  15. package/dist/hooks/foreground-fallback/index.d.ts +53 -7
  16. package/dist/hooks/image-hook.d.ts +1 -0
  17. package/dist/hooks/index.d.ts +2 -1
  18. package/dist/hooks/phase-reminder/index.d.ts +3 -1
  19. package/dist/hooks/post-file-tool-nudge/index.d.ts +15 -9
  20. package/dist/hooks/session-lifecycle.d.ts +11 -0
  21. package/dist/hooks/task-session-manager/index.d.ts +14 -2
  22. package/dist/index.js +2804 -801
  23. package/dist/interview/document.d.ts +2 -1
  24. package/dist/interview/service.d.ts +2 -0
  25. package/dist/interview/ui.d.ts +0 -1
  26. package/dist/multiplexer/cmux/close-policy.d.ts +20 -0
  27. package/dist/multiplexer/cmux/index.d.ts +102 -0
  28. package/dist/multiplexer/cmux/session-lifecycle.d.ts +92 -0
  29. package/dist/multiplexer/cmux/session-state.d.ts +45 -0
  30. package/dist/multiplexer/factory.d.ts +0 -9
  31. package/dist/multiplexer/herdr/index.d.ts +7 -3
  32. package/dist/multiplexer/index.d.ts +3 -1
  33. package/dist/multiplexer/session-manager.d.ts +14 -6
  34. package/dist/multiplexer/shared.d.ts +31 -0
  35. package/dist/multiplexer/tmux/index.d.ts +0 -1
  36. package/dist/multiplexer/types.d.ts +5 -2
  37. package/dist/multiplexer/zellij/index.d.ts +0 -1
  38. package/dist/tools/cancel-task.d.ts +2 -2
  39. package/dist/tools/smartfetch/utils.d.ts +3 -1
  40. package/dist/tui-state.d.ts +5 -5
  41. package/dist/tui.d.ts +1 -0
  42. package/dist/tui.js +130 -29
  43. package/dist/utils/background-job-board.d.ts +5 -1
  44. package/dist/utils/background-job-coordinator.d.ts +72 -0
  45. package/dist/utils/background-job-store.d.ts +46 -0
  46. package/dist/utils/councillor-models.d.ts +20 -0
  47. package/dist/utils/escape-html.d.ts +1 -0
  48. package/dist/utils/frontmatter.d.ts +6 -0
  49. package/dist/utils/index.d.ts +3 -1
  50. package/dist/utils/internal-initiator.d.ts +6 -1
  51. package/dist/utils/logger.d.ts +0 -2
  52. package/dist/utils/session.d.ts +1 -1
  53. package/oh-my-opencode-slim.schema.json +728 -1
  54. package/package.json +1 -1
  55. package/src/skills/clonedeps/SKILL.md +32 -29
  56. package/src/skills/codemap.md +7 -3
  57. package/src/skills/deepwork/SKILL.md +17 -6
  58. package/src/skills/oh-my-opencode-slim/SKILL.md +3 -3
  59. package/src/skills/release-smoke-test/SKILL.md +2 -2
  60. package/src/skills/verification-planning/SKILL.md +103 -0
  61. package/src/skills/worktrees/SKILL.md +14 -7
package/dist/tui.js CHANGED
@@ -131,7 +131,12 @@ var POLL_INTERVAL_BACKGROUND_MS = 2000;
131
131
  var MAX_POLL_TIME_MS = 5 * 60 * 1000;
132
132
  var DEFAULT_MAX_SUBAGENT_DEPTH = 3;
133
133
  var PHASE_REMINDER_TEXT = `!IMPORTANT! Scheduler workflow: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. Do not poll running jobs, consume running-job output, or advance dependent work. !END!`;
134
- var PHASE_REMINDER = `<internal_reminder>${PHASE_REMINDER_TEXT}</internal_reminder>`;
134
+ function formatSystemReminder(text) {
135
+ return `<system-reminder>
136
+ ${text}
137
+ </system-reminder>`;
138
+ }
139
+ var PHASE_REMINDER = formatSystemReminder(PHASE_REMINDER_TEXT);
135
140
  var WRITABLE_FILE_OPERATIONS_RULES = `**File Operations Rules**:
136
141
  - Prefer dedicated file tools for normal code work: glob/grep/ast_grep_search for discovery, read for file contents, and edit/write/apply_patch for targeted source changes.
137
142
  - Use bash for execution and automation: git, package managers, tests, builds, scripts, diagnostics, and shell-native filesystem operations.
@@ -150,6 +155,13 @@ var NO_SHELL_READONLY_FILE_OPERATIONS_RULES = `**File Operations Rules**:
150
155
  var TMUX_SPAWN_DELAY_MS = 500;
151
156
  var COUNCILLOR_STAGGER_MS = 250;
152
157
  var DEFAULT_DISABLED_AGENTS = ["observer"];
158
+ var DEFAULT_MAX_SESSIONS_PER_AGENT = 2;
159
+ var DEFAULT_READ_CONTEXT_MIN_LINES = 10;
160
+ var DEFAULT_READ_CONTEXT_MAX_FILES = 8;
161
+ var DEFAULT_IMAGE_ROUTING = "auto";
162
+ function resolveImageRouting(imageRouting) {
163
+ return imageRouting ?? DEFAULT_IMAGE_ROUTING;
164
+ }
153
165
 
154
166
  // src/config/loader.ts
155
167
  import * as fs from "node:fs";
@@ -188,11 +200,35 @@ function getOpenCodeConfigPaths() {
188
200
  }
189
201
  // src/config/council-schema.ts
190
202
  import { z } from "zod";
191
- var ModelIdSchema = z.string().regex(/^[^/\s]+\/[^\s]+$/, 'Expected provider/model format (e.g. "openai/gpt-5.4-mini")');
203
+
204
+ // src/utils/councillor-models.ts
205
+ function normalizeCouncillorModels(model, fallbackVariant) {
206
+ const raw = Array.isArray(model) ? model : [model];
207
+ return raw.map((entry) => typeof entry === "string" ? { id: entry, variant: fallbackVariant } : { id: entry.id, variant: entry.variant ?? fallbackVariant });
208
+ }
209
+
210
+ // src/config/council-schema.ts
211
+ var ModelIdSchema = z.string().regex(/^[^/\s]+\/[^\s]+$/, 'Expected provider/model format (e.g. "openai/gpt-5.6-luna")');
212
+ var CouncillorModelEntrySchema = z.object({
213
+ id: ModelIdSchema,
214
+ variant: z.string().optional()
215
+ });
216
+ var CouncillorModelSchema = z.union([
217
+ ModelIdSchema,
218
+ z.array(z.union([ModelIdSchema, CouncillorModelEntrySchema])).min(1)
219
+ ]).describe('Model ID in provider/model format (e.g. "openai/gpt-5.6-luna"), or an ' + "ordered fallback chain (array of model IDs or { id, variant } entries) " + "tried in order until one responds.");
192
220
  var CouncillorConfigSchema = z.object({
193
- model: ModelIdSchema.describe('Model ID in provider/model format (e.g. "openai/gpt-5.4-mini")'),
221
+ model: CouncillorModelSchema,
194
222
  variant: z.string().optional(),
195
223
  prompt: z.string().optional().describe("Optional role/guidance injected into the councillor user prompt")
224
+ }).transform((c) => {
225
+ const models = normalizeCouncillorModels(c.model, c.variant);
226
+ return {
227
+ model: models[0].id,
228
+ variant: c.variant,
229
+ prompt: c.prompt,
230
+ models
231
+ };
196
232
  });
197
233
  var CouncilPresetSchema = z.record(z.string(), z.record(z.string(), z.unknown())).transform((entries, ctx) => {
198
234
  const councillors = {};
@@ -278,6 +314,33 @@ var ManualPlanSchema = z2.object({
278
314
  librarian: ManualAgentPlanSchema,
279
315
  fixer: ManualAgentPlanSchema
280
316
  }).strict();
317
+ var PermissionActionSchema = z2.enum(["ask", "allow", "deny"]);
318
+ var PermissionRuleSchema = z2.union([
319
+ PermissionActionSchema,
320
+ z2.record(z2.string(), PermissionActionSchema)
321
+ ]);
322
+ var PermissionObjectSchema = z2.object({
323
+ read: PermissionRuleSchema.optional(),
324
+ edit: PermissionRuleSchema.optional(),
325
+ glob: PermissionRuleSchema.optional(),
326
+ grep: PermissionRuleSchema.optional(),
327
+ list: PermissionRuleSchema.optional(),
328
+ bash: PermissionRuleSchema.optional(),
329
+ task: PermissionRuleSchema.optional(),
330
+ external_directory: PermissionRuleSchema.optional(),
331
+ lsp: PermissionRuleSchema.optional(),
332
+ skill: PermissionRuleSchema.optional(),
333
+ todowrite: PermissionActionSchema.optional(),
334
+ question: PermissionActionSchema.optional(),
335
+ webfetch: PermissionActionSchema.optional(),
336
+ websearch: PermissionActionSchema.optional(),
337
+ codesearch: PermissionActionSchema.optional(),
338
+ doom_loop: PermissionActionSchema.optional()
339
+ }).catchall(PermissionRuleSchema);
340
+ var PermissionConfigSchema = z2.union([
341
+ PermissionActionSchema,
342
+ PermissionObjectSchema
343
+ ]);
281
344
  var AgentOverrideConfigSchema = z2.object({
282
345
  model: z2.union([
283
346
  z2.string(),
@@ -296,13 +359,15 @@ var AgentOverrideConfigSchema = z2.object({
296
359
  prompt: z2.string().min(1).optional(),
297
360
  orchestratorPrompt: z2.string().min(1).optional(),
298
361
  options: z2.record(z2.string(), z2.unknown()).optional(),
299
- displayName: z2.string().min(1).optional()
362
+ displayName: z2.string().min(1).optional(),
363
+ permission: PermissionConfigSchema.optional()
300
364
  }).strict();
301
365
  var MultiplexerTypeSchema = z2.enum([
302
366
  "auto",
303
367
  "tmux",
304
368
  "zellij",
305
369
  "herdr",
370
+ "cmux",
306
371
  "none"
307
372
  ]);
308
373
  var MultiplexerLayoutSchema = z2.enum([
@@ -346,7 +411,9 @@ var FailoverConfigSchema = z2.object({
346
411
  enabled: z2.boolean().default(true),
347
412
  timeoutMs: z2.number().min(0).default(15000),
348
413
  retryDelayMs: z2.number().min(0).default(500),
349
- retry_on_empty: z2.boolean().default(true).describe("When true (default), empty provider responses are treated as failures, " + "triggering fallback/retry. Set to false to treat them as successes.")
414
+ maxRetries: z2.number().int().min(0).default(3).describe("Number of consecutive 429/rate-limit responses tolerated on the " + "same model before aborting (or swapping to the next fallback " + "model when a chain is configured)."),
415
+ retry_on_empty: z2.boolean().default(true).describe("When true (default), empty provider responses are treated as failures, " + "triggering fallback/retry. Set to false to treat them as successes."),
416
+ runtimeOverride: z2.boolean().optional().describe("DEPRECATED: no longer used. Previously controlled whether out-of-chain " + "runtime model picks triggered fallback. Fallback is now always " + "disabled when a user explicitly selects a model via /model.")
350
417
  }).strict();
351
418
  var CompanionConfigSchema = z2.object({
352
419
  enabled: z2.boolean().optional(),
@@ -387,12 +454,14 @@ function rejectOrchestratorPromptOnOrchestrator(overrides, ctx, pathPrefix) {
387
454
  var PluginConfigSchema = z2.object({
388
455
  preset: z2.string().optional(),
389
456
  setDefaultAgent: z2.boolean().optional(),
390
- compactSidebar: z2.boolean().optional().describe("Use the compact TUI sidebar layout when enabled."),
457
+ compactSidebar: z2.boolean().optional().describe("Use the compact TUI sidebar layout. Defaults to true; set false to use the expanded layout."),
458
+ stripOrchestratorModel: z2.boolean().optional().describe("When true, omit orchestrator.model and orchestrator.variant from the SDK config so OpenCode uses the session model selected with /model after subagent dispatch. An explicitly selected preset that sets orchestrator.model is preserved. Defaults to false."),
391
459
  autoUpdate: z2.boolean().optional().describe("Disable automatic installation of plugin updates when false. Defaults to true."),
392
460
  presets: z2.record(z2.string(), PresetSchema).optional(),
393
461
  agents: z2.record(z2.string(), AgentOverrideConfigSchema).optional(),
394
462
  disabled_agents: z2.array(z2.string()).optional().describe("Agent names to disable completely. " + "Disabled agents are not instantiated and cannot be delegated to. " + "Orchestrator and council internal agents (councillor) cannot be disabled. " + "By default, 'observer' is disabled. Remove it from this list and configure a vision-capable model to enable."),
395
- disabled_mcps: z2.array(z2.string()).optional(),
463
+ image_routing: z2.enum(["auto", "direct"]).optional().describe("How image attachments are handled. " + "When omitted, preserves legacy conditional behavior: intercept " + 'attachments only when observer is enabled. "auto": requires ' + "observer to be enabled and saves attachments to disk before " + 'nudging delegation to @observer. "direct": always passes ' + "attachments to the orchestrator untouched."),
464
+ disabled_mcps: z2.array(z2.string()).optional().describe("MCP server names to disable completely. Disabled servers are not " + "started and cannot be used by agents."),
396
465
  disabled_tools: z2.array(z2.string()).optional().describe("Tool names to disable completely. Disabled tools are not registered with OpenCode and cannot be used by agents."),
397
466
  disabled_skills: z2.array(z2.string()).optional().describe("Skill names to disable completely. Disabled skills are not granted to agents, even when referenced by presets or agent overrides."),
398
467
  multiplexer: MultiplexerConfigSchema.optional(),
@@ -495,6 +564,12 @@ var CUSTOM_SKILLS = [
495
564
  allowedAgents: ["orchestrator"],
496
565
  sourcePath: "src/skills/deepwork"
497
566
  },
567
+ {
568
+ name: "verification-planning",
569
+ description: "Plan credible, proportionate evidence before non-trivial implementation",
570
+ allowedAgents: ["orchestrator"],
571
+ sourcePath: "src/skills/verification-planning"
572
+ },
498
573
  {
499
574
  name: "reflect",
500
575
  description: "Review repeated work and suggest reusable workflow improvements",
@@ -599,6 +674,23 @@ function findConfigPathInDirs(configDirs, baseName) {
599
674
  }
600
675
  return null;
601
676
  }
677
+ function validateFinalImageRouting(config, configPath, options) {
678
+ if (config.image_routing !== "auto")
679
+ return true;
680
+ const disabledAgents = config.disabled_agents ?? DEFAULT_DISABLED_AGENTS;
681
+ if (!disabledAgents.includes("observer"))
682
+ return true;
683
+ const message = 'image_routing "auto" requires observer to be enabled. ' + 'Remove "observer" from disabled_agents.';
684
+ options?.onWarning?.({
685
+ path: configPath,
686
+ kind: "invalid-schema",
687
+ message
688
+ });
689
+ if (!options?.silent) {
690
+ console.warn(`[oh-my-opencode-slim] Invalid config: ${message}`);
691
+ }
692
+ return false;
693
+ }
602
694
  function findPluginConfigPaths(directory) {
603
695
  const userConfigPath = findConfigPathInDirs(getConfigSearchDirs(), "oh-my-opencode-slim");
604
696
  const projectConfigBasePath = path.join(directory, ".opencode", "oh-my-opencode-slim");
@@ -680,6 +772,7 @@ function loadPluginConfig(directory, options) {
680
772
  debug: config.companion.debug ?? false
681
773
  };
682
774
  }
775
+ validateFinalImageRouting(config, projectConfigPath ?? userConfigPath ?? "", options);
683
776
  return config;
684
777
  }
685
778
  function loadAgentPrompt(agentName, optionsOrPreset) {
@@ -745,6 +838,7 @@ function migrateTmuxToMultiplexer(config) {
745
838
  }
746
839
 
747
840
  // src/tui-state.ts
841
+ import { createHash } from "node:crypto";
748
842
  import * as fs2 from "node:fs";
749
843
  import * as os from "node:os";
750
844
  import * as path2 from "node:path";
@@ -753,8 +847,11 @@ var STATE_FILE = "tui-state.json";
753
847
  function dataDir() {
754
848
  return process.env.XDG_DATA_HOME ?? path2.join(os.homedir(), ".local", "share");
755
849
  }
756
- function getTuiStatePath() {
757
- return path2.join(dataDir(), "opencode", "storage", STATE_DIR, STATE_FILE);
850
+ function projectScope(projectDir) {
851
+ return createHash("sha256").update(path2.resolve(projectDir)).digest("hex").slice(0, 12);
852
+ }
853
+ function getTuiStatePath(projectDir) {
854
+ return path2.join(dataDir(), "opencode", "storage", STATE_DIR, projectScope(projectDir), STATE_FILE);
758
855
  }
759
856
  function emptySnapshot() {
760
857
  return {
@@ -775,42 +872,42 @@ function parseSnapshot(value) {
775
872
  agentVariants: parsed.agentVariants ?? {}
776
873
  };
777
874
  }
778
- function readTuiSnapshot() {
875
+ function readTuiSnapshot(projectDir) {
779
876
  try {
780
- return parseSnapshot(fs2.readFileSync(getTuiStatePath(), "utf8"));
877
+ return parseSnapshot(fs2.readFileSync(getTuiStatePath(projectDir), "utf8"));
781
878
  } catch {
782
879
  return emptySnapshot();
783
880
  }
784
881
  }
785
- async function readTuiSnapshotAsync() {
882
+ async function readTuiSnapshotAsync(projectDir) {
786
883
  try {
787
- return parseSnapshot(await fs2.promises.readFile(getTuiStatePath(), "utf8"));
884
+ return parseSnapshot(await fs2.promises.readFile(getTuiStatePath(projectDir), "utf8"));
788
885
  } catch {
789
886
  return emptySnapshot();
790
887
  }
791
888
  }
792
- function writeTuiSnapshot(snapshot) {
889
+ function writeTuiSnapshot(snapshot, projectDir) {
793
890
  try {
794
- const filePath = getTuiStatePath();
891
+ const filePath = getTuiStatePath(projectDir);
795
892
  fs2.mkdirSync(path2.dirname(filePath), { recursive: true });
796
893
  fs2.writeFileSync(filePath, `${JSON.stringify(snapshot)}
797
894
  `);
798
895
  } catch {}
799
896
  }
800
- function updateSnapshot(mutator) {
801
- const snapshot = readTuiSnapshot();
897
+ function updateSnapshot(projectDir, mutator) {
898
+ const snapshot = readTuiSnapshot(projectDir);
802
899
  mutator(snapshot);
803
900
  snapshot.updatedAt = Date.now();
804
- writeTuiSnapshot(snapshot);
901
+ writeTuiSnapshot(snapshot, projectDir);
805
902
  }
806
- function recordTuiAgentModels(input) {
807
- updateSnapshot((snapshot) => {
903
+ function recordTuiAgentModels(input, projectDir) {
904
+ updateSnapshot(projectDir, (snapshot) => {
808
905
  snapshot.agentModels = { ...input.agentModels };
809
906
  snapshot.agentVariants = { ...input.agentVariants ?? {} };
810
907
  });
811
908
  }
812
- function recordTuiAgentModel(input) {
813
- updateSnapshot((snapshot) => {
909
+ function recordTuiAgentModel(input, projectDir) {
910
+ updateSnapshot(projectDir, (snapshot) => {
814
911
  snapshot.agentModels[input.agentName] = input.model;
815
912
  if (input.variant !== undefined) {
816
913
  if (input.variant === null) {
@@ -902,15 +999,15 @@ function agentRow(label, model, variant, theme) {
902
999
  ...detailRows
903
1000
  ]);
904
1001
  }
905
- function compactAgentRow(label, model, variant, theme) {
906
- const value = variant ? `${model} (${variant})` : model;
1002
+ function compactAgentRow(label, model, _variant, theme) {
1003
+ const modelName = splitSidebarModelId(model).model;
907
1004
  return box({
908
1005
  width: "100%",
909
1006
  flexDirection: "row",
910
1007
  justifyContent: "space-between"
911
1008
  }, [
912
1009
  text({ fg: theme.textMuted, width: 14 }, [label]),
913
- text({ fg: theme.textMuted }, [value])
1010
+ text({ fg: theme.textMuted }, [modelName])
914
1011
  ]);
915
1012
  }
916
1013
  function renderSidebar(snapshot, version, theme, configInvalid, compactSidebar) {
@@ -931,7 +1028,7 @@ function renderSidebar(snapshot, version, theme, configInvalid, compactSidebar)
931
1028
  justifyContent: "space-between",
932
1029
  alignItems: "center"
933
1030
  }, [
934
- box({ paddingLeft: 1, paddingRight: 1, backgroundColor: theme.accent }, [text({ fg: theme.background }, ["OMO-Slim"])]),
1031
+ box({ paddingLeft: 1, paddingRight: 1, backgroundColor: theme.accent }, [text({ fg: theme.text }, ["OMO-Slim"])]),
935
1032
  text({ fg: theme.textMuted }, [`v${version}`])
936
1033
  ]),
937
1034
  configStatusRow,
@@ -969,12 +1066,15 @@ function readConfigState(directory) {
969
1066
  configInvalid = true;
970
1067
  }
971
1068
  });
972
- const compactSidebar = config.compactSidebar ?? false;
1069
+ const compactSidebar = config.compactSidebar ?? true;
973
1070
  return { configInvalid, compactSidebar };
974
1071
  }
975
1072
  function readConfigInvalid(directory) {
976
1073
  return readConfigState(directory).configInvalid;
977
1074
  }
1075
+ function readCompactSidebar(directory) {
1076
+ return readConfigState(directory).compactSidebar;
1077
+ }
978
1078
  var plugin = {
979
1079
  id: `${PLUGIN_NAME}:tui`,
980
1080
  tui: async (api, _options, meta) => {
@@ -983,11 +1083,11 @@ var plugin = {
983
1083
  const version = meta.version ?? await readPackageVersion() ?? "dev";
984
1084
  let configDirectory = getTuiDirectory(api);
985
1085
  let { configInvalid, compactSidebar } = readConfigState(configDirectory);
986
- let snapshot = readTuiSnapshot();
1086
+ let snapshot = readTuiSnapshot(configDirectory);
987
1087
  const renderTimer = setInterval(async () => {
988
1088
  try {
989
- snapshot = await readTuiSnapshotAsync();
990
1089
  const currentDirectory = getTuiDirectory(api);
1090
+ snapshot = await readTuiSnapshotAsync(currentDirectory);
991
1091
  if (currentDirectory !== configDirectory) {
992
1092
  configDirectory = currentDirectory;
993
1093
  ({ configInvalid, compactSidebar } = readConfigState(configDirectory));
@@ -1012,6 +1112,7 @@ var tui_default = plugin;
1012
1112
  export {
1013
1113
  splitSidebarModelId,
1014
1114
  readConfigInvalid,
1115
+ readCompactSidebar,
1015
1116
  getSidebarAgentNames,
1016
1117
  tui_default as default
1017
1118
  };
@@ -1,3 +1,4 @@
1
+ import type { BackgroundJobStore } from './background-job-store';
1
2
  import { type TaskOutputState } from './task';
2
3
  export interface ContextFile {
3
4
  path: string;
@@ -56,7 +57,7 @@ export interface BackgroundJobStatusInput {
56
57
  now?: number;
57
58
  }
58
59
  type TerminalStateListener = (taskID: string) => void;
59
- export declare class BackgroundJobBoard {
60
+ export declare class BackgroundJobBoard implements BackgroundJobStore {
60
61
  private readonly jobs;
61
62
  private readonly counters;
62
63
  private terminalStateListeners;
@@ -97,6 +98,9 @@ export declare class BackgroundJobBoard {
97
98
  formatForPrompt(parentSessionID: string, now?: number): string | undefined;
98
99
  clearParent(parentSessionID: string): void;
99
100
  drop(taskID: string): void;
101
+ deferIfRunning(_sessionId: string): boolean;
102
+ retryDeferredClose(_sessionId: string): boolean;
103
+ clearDeferredClose(_sessionId: string): void;
100
104
  private trimReusable;
101
105
  private formatReusableJob;
102
106
  private nextAlias;
@@ -0,0 +1,72 @@
1
+ import type { BackgroundJobBoard, BackgroundJobLaunchInput, BackgroundJobRecord, BackgroundJobStatusInput, ContextFile } from './background-job-board';
2
+ import type { BackgroundJobStore } from './background-job-store';
3
+ import type { TaskOutputState } from './task';
4
+ type TerminalStateListener = (taskID: string) => void;
5
+ /**
6
+ * BackgroundJobCoordinator owns the lifecycle policy for background jobs.
7
+ * It sits between the board and its consumers, providing:
8
+ * - Subscription interface for terminal state notifications (replaces fire-and-forget)
9
+ * - Lifecycle policy: determines when jobs are terminal, when closes should be deferred
10
+ * - Single-writer contract: coordinator is the sole writer to the board
11
+ *
12
+ * The board's guards prevent silent overwrites. The coordinator adds:
13
+ * - Centralized notification with guaranteed delivery
14
+ * - Re-checks board state before notifying (handles races)
15
+ */
16
+ export declare class BackgroundJobCoordinator implements BackgroundJobStore {
17
+ private readonly board;
18
+ private terminalStateListeners;
19
+ private readonly deferredIdleCloses;
20
+ constructor(board: BackgroundJobBoard);
21
+ addTerminalStateListener(listener: TerminalStateListener): void;
22
+ removeTerminalStateListener(listener: TerminalStateListener): void;
23
+ /**
24
+ * Handle terminal state from board. Re-checks board state to handle races.
25
+ * This is the centralized lifecycle policy.
26
+ */
27
+ private handleTerminalState;
28
+ /**
29
+ * Evaluate close policy. Returns true if session should close now.
30
+ * Mutates deferred state: adds to deferred set if running, removes if not.
31
+ */
32
+ deferIfRunning(sessionId: string): boolean;
33
+ /**
34
+ * Retry closing a deferred session. Called when a background job completes.
35
+ * Returns true if the session should now close.
36
+ */
37
+ retryDeferredClose(sessionId: string): boolean;
38
+ /**
39
+ * Clear deferred close state for a session being deleted.
40
+ */
41
+ clearDeferredClose(sessionId: string): void;
42
+ registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord;
43
+ updateStatus(input: BackgroundJobStatusInput): BackgroundJobRecord | undefined;
44
+ updateFromStatusOutput(output: string): BackgroundJobRecord | undefined;
45
+ markRunningFromLiveSession(taskID: string, now?: number): BackgroundJobRecord | undefined;
46
+ markReconciled(taskID: string, now?: number): BackgroundJobRecord | undefined;
47
+ markCancelled(taskID: string, reason?: string, now?: number, options?: {
48
+ force?: boolean;
49
+ }): BackgroundJobRecord | undefined;
50
+ get(taskID: string): BackgroundJobRecord | undefined;
51
+ field<K extends keyof BackgroundJobRecord>(taskID: string, key: K): BackgroundJobRecord[K] | undefined;
52
+ isRunning(taskID: string): boolean;
53
+ isTerminalUnreconciled(taskID: string): boolean;
54
+ getResultSummary(taskID: string): string | undefined;
55
+ getLastLiveBusyAt(taskID: string): number | undefined;
56
+ getParentSessionID(taskID: string): string | undefined;
57
+ getState(taskID: string): TaskOutputState | 'reconciled' | undefined;
58
+ resolve(parentSessionID: string, taskIDOrAlias: string): BackgroundJobRecord | undefined;
59
+ resolveReusable(parentSessionID: string, taskIDOrAlias: string, agent?: string): BackgroundJobRecord | undefined;
60
+ resolveRecoverable(parentSessionID: string, taskIDOrAlias: string, agent?: string): BackgroundJobRecord | undefined;
61
+ markUsed(parentSessionID: string, key: string, now?: number): void;
62
+ taskIDs(): Set<string>;
63
+ addContext(taskID: string, files: ContextFile[]): void;
64
+ list(parentSessionID?: string): BackgroundJobRecord[];
65
+ hasRunning(parentSessionID: string): boolean;
66
+ hasTerminalUnreconciled(parentSessionID: string): boolean;
67
+ hasConvergenceSignals(taskID: string, threshold?: number): boolean;
68
+ formatForPrompt(parentSessionID: string, now?: number): string | undefined;
69
+ clearParent(parentSessionID: string): void;
70
+ drop(taskID: string): void;
71
+ }
72
+ export {};
@@ -0,0 +1,46 @@
1
+ import type { BackgroundJobLaunchInput, BackgroundJobRecord, BackgroundJobStatusInput, ContextFile } from './background-job-board';
2
+ import type { TaskOutputState } from './task';
3
+ /**
4
+ * Unified interface for background job operations.
5
+ * Both BackgroundJobBoard and BackgroundJobCoordinator satisfy this.
6
+ *
7
+ * ponytail: single interface, both board and coordinator implement it.
8
+ */
9
+ export interface BackgroundJobStore {
10
+ registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord;
11
+ updateStatus(input: BackgroundJobStatusInput): BackgroundJobRecord | undefined;
12
+ updateFromStatusOutput(output: string): BackgroundJobRecord | undefined;
13
+ markRunningFromLiveSession(taskID: string, now?: number): BackgroundJobRecord | undefined;
14
+ markReconciled(taskID: string, now?: number): BackgroundJobRecord | undefined;
15
+ markCancelled(taskID: string, reason?: string, now?: number, options?: {
16
+ force?: boolean;
17
+ }): BackgroundJobRecord | undefined;
18
+ clearParent(parentSessionID: string): void;
19
+ drop(taskID: string): void;
20
+ addContext(taskID: string, files: ContextFile[]): void;
21
+ markUsed(parentSessionID: string, key: string, now?: number): void;
22
+ get(taskID: string): BackgroundJobRecord | undefined;
23
+ field<K extends keyof BackgroundJobRecord>(taskID: string, key: K): BackgroundJobRecord[K] | undefined;
24
+ isRunning(taskID: string): boolean;
25
+ isTerminalUnreconciled(taskID: string): boolean;
26
+ getResultSummary(taskID: string): string | undefined;
27
+ getLastLiveBusyAt(taskID: string): number | undefined;
28
+ getParentSessionID(taskID: string): string | undefined;
29
+ getState(taskID: string): TaskOutputState | 'reconciled' | undefined;
30
+ resolve(parentSessionID: string, taskIDOrAlias: string): BackgroundJobRecord | undefined;
31
+ resolveReusable(parentSessionID: string, taskIDOrAlias: string, agent?: string): BackgroundJobRecord | undefined;
32
+ resolveRecoverable(parentSessionID: string, taskIDOrAlias: string, agent?: string): BackgroundJobRecord | undefined;
33
+ taskIDs(): Set<string>;
34
+ list(parentSessionID?: string): BackgroundJobRecord[];
35
+ hasRunning(parentSessionID: string): boolean;
36
+ hasTerminalUnreconciled(parentSessionID: string): boolean;
37
+ hasConvergenceSignals(taskID: string, threshold?: number): boolean;
38
+ formatForPrompt(parentSessionID: string, now?: number): string | undefined;
39
+ /** Evaluate close policy. Returns true if session should close now.
40
+ * Mutates deferred state: adds to deferred set if running, removes if not. */
41
+ deferIfRunning(sessionId: string): boolean;
42
+ /** Retry closing a deferred session. Returns true if session should now close. */
43
+ retryDeferredClose(sessionId: string): boolean;
44
+ /** Clear deferred close state for a session being deleted. */
45
+ clearDeferredClose(sessionId: string): void;
46
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Pure helpers for councillor model fallback chains.
3
+ *
4
+ * Kept free of any schema/validation library import so that runtime consumers
5
+ * (e.g. `CouncilManager`) can resolve a councillor's ordered model chain
6
+ * without pulling in zod or the config schema module.
7
+ */
8
+ /** A single model in a councillor fallback chain, with optional variant. */
9
+ export type CouncillorModelEntry = {
10
+ id: string;
11
+ variant?: string;
12
+ };
13
+ /**
14
+ * Flatten a councillor model config into an ordered list of model entries.
15
+ *
16
+ * Accepts either a single "provider/model" string or an ordered fallback
17
+ * chain (array of strings and/or `{ id, variant }` entries). Entries that
18
+ * don't carry their own variant fall back to the shared `fallbackVariant`.
19
+ */
20
+ export declare function normalizeCouncillorModels(model: string | Array<string | CouncillorModelEntry>, fallbackVariant?: string): CouncillorModelEntry[];
@@ -0,0 +1 @@
1
+ export declare function escapeHtml(value: string): string;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Parse `---` delimited frontmatter from a string.
3
+ * Handles both `\n` and `\r\n` line endings.
4
+ * Returns null if no frontmatter is found.
5
+ */
6
+ export declare function parseFrontmatter(content: string): Record<string, string> | null;
@@ -1,7 +1,9 @@
1
1
  export * from './agent-variant';
2
2
  export * from './background-job-board';
3
+ export * from './background-job-coordinator';
4
+ export * from './background-job-store';
3
5
  export * from './internal-initiator';
4
- export { getLogDir, initLogger, log } from './logger';
6
+ export { initLogger, log } from './logger';
5
7
  export * from './polling';
6
8
  export * from './session';
7
9
  export * from './task';
@@ -1,6 +1,11 @@
1
1
  export declare const SLIM_INTERNAL_INITIATOR_MARKER = "<!-- SLIM_INTERNAL_INITIATOR -->";
2
+ export declare const INTERNAL_INITIATOR_METADATA_KEY = "oh-my-opencode-slim.internalInitiator";
2
3
  export declare function createInternalAgentTextPart(text: string): {
3
4
  type: 'text';
4
5
  text: string;
6
+ synthetic: true;
7
+ metadata: {
8
+ 'oh-my-opencode-slim.internalInitiator': true;
9
+ };
5
10
  };
6
- export declare function hasInternalInitiatorMarker(part: unknown): boolean;
11
+ export declare function isInternalInitiatorPart(part: unknown): boolean;
@@ -1,6 +1,4 @@
1
- declare function getLogDir(): string;
2
1
  export declare function initLogger(sessionId: string): void;
3
- export { getLogDir };
4
2
  /** @internal Reset logger state for testing */
5
3
  export declare function resetLogger(): void;
6
4
  /** @internal Wait for queued log writes in tests. */
@@ -11,7 +11,7 @@ export declare function withTimeout<T>(operation: Promise<T>, timeoutMs: number,
11
11
  export declare function abortSessionWithTimeout(client: OpencodeClient, sessionId: string, timeoutMs?: number): Promise<void>;
12
12
  /**
13
13
  * Extract the short model label from a "provider/model" string.
14
- * E.g. "openai/gpt-5.4-mini" → "gpt-5.4-mini"
14
+ * E.g. "openai/gpt-5.6-luna" → "gpt-5.6-luna"
15
15
  */
16
16
  export declare function shortModelLabel(model: string): string;
17
17
  export type PromptBody = {