oh-my-opencode-slim 2.0.5 → 2.1.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 (52) hide show
  1. package/README.ja-JP.md +47 -21
  2. package/README.ko-KR.md +45 -19
  3. package/README.md +56 -32
  4. package/README.zh-CN.md +48 -24
  5. package/dist/agents/council.d.ts +1 -1
  6. package/dist/agents/index.d.ts +7 -2
  7. package/dist/agents/orchestrator.d.ts +1 -1
  8. package/dist/cli/custom-skills-registry.d.ts +18 -0
  9. package/dist/cli/custom-skills.d.ts +3 -19
  10. package/dist/cli/index.js +1089 -185
  11. package/dist/cli/providers.d.ts +5 -9
  12. package/dist/cli/skills.d.ts +2 -2
  13. package/dist/companion/manager.d.ts +7 -0
  14. package/dist/config/loader.d.ts +5 -2
  15. package/dist/config/schema.d.ts +4 -0
  16. package/dist/council/council-manager.d.ts +1 -1
  17. package/dist/hooks/auto-update-checker/skill-sync.d.ts +59 -1
  18. package/dist/hooks/filter-available-skills/index.d.ts +1 -2
  19. package/dist/hooks/foreground-fallback/index.d.ts +5 -1
  20. package/dist/hooks/image-hook.d.ts +1 -1
  21. package/dist/hooks/index.d.ts +1 -0
  22. package/dist/hooks/loop-command/index.d.ts +13 -0
  23. package/dist/hooks/phase-reminder/index.d.ts +1 -2
  24. package/dist/hooks/task-session-manager/index.d.ts +1 -2
  25. package/dist/hooks/task-session-manager/pending-call-tracker.d.ts +13 -0
  26. package/dist/hooks/task-session-manager/task-context-tracker.d.ts +14 -0
  27. package/dist/hooks/types.d.ts +3 -1
  28. package/dist/index.js +2116 -670
  29. package/dist/interview/dashboard-manager.d.ts +21 -0
  30. package/dist/interview/manager.d.ts +0 -14
  31. package/dist/interview/service.d.ts +7 -0
  32. package/dist/interview/session-server.d.ts +21 -0
  33. package/dist/interview/types.d.ts +3 -1
  34. package/dist/loop/loop-session.d.ts +64 -0
  35. package/dist/multiplexer/factory.d.ts +5 -5
  36. package/dist/multiplexer/herdr/index.d.ts +31 -0
  37. package/dist/multiplexer/index.d.ts +1 -0
  38. package/dist/multiplexer/session-manager.d.ts +1 -0
  39. package/dist/multiplexer/types.d.ts +4 -4
  40. package/dist/tui.js +82 -39
  41. package/dist/utils/background-job-board.d.ts +17 -1
  42. package/dist/utils/session.d.ts +1 -1
  43. package/oh-my-opencode-slim.schema.json +6 -1
  44. package/package.json +1 -1
  45. package/src/skills/clonedeps/SKILL.md +2 -2
  46. package/src/skills/clonedeps/codemap.md +23 -32
  47. package/src/skills/codemap/SKILL.md +2 -2
  48. package/src/skills/codemap.md +63 -36
  49. package/src/skills/loop-engineering/SKILL.md +30 -0
  50. package/src/skills/reflect/SKILL.md +133 -0
  51. package/src/skills/release-smoke-test/SKILL.md +159 -0
  52. package/src/skills/simplify/SKILL.md +6 -6
package/dist/index.js CHANGED
@@ -6344,9 +6344,9 @@ var require_URL = __commonJS((exports, module) => {
6344
6344
  s += "#" + this.fragment;
6345
6345
  return s;
6346
6346
  },
6347
- resolve: function(relative2) {
6347
+ resolve: function(relative3) {
6348
6348
  var base = this;
6349
- var r = new URL4(relative2);
6349
+ var r = new URL4(relative3);
6350
6350
  var t = new URL4;
6351
6351
  if (r.scheme !== undefined) {
6352
6352
  t.scheme = r.scheme;
@@ -6398,33 +6398,33 @@ var require_URL = __commonJS((exports, module) => {
6398
6398
  else
6399
6399
  return basepath.substring(0, lastslash + 1) + refpath;
6400
6400
  }
6401
- function remove_dot_segments(path19) {
6402
- if (!path19)
6403
- return path19;
6401
+ function remove_dot_segments(path20) {
6402
+ if (!path20)
6403
+ return path20;
6404
6404
  var output = "";
6405
- while (path19.length > 0) {
6406
- if (path19 === "." || path19 === "..") {
6407
- path19 = "";
6405
+ while (path20.length > 0) {
6406
+ if (path20 === "." || path20 === "..") {
6407
+ path20 = "";
6408
6408
  break;
6409
6409
  }
6410
- var twochars = path19.substring(0, 2);
6411
- var threechars = path19.substring(0, 3);
6412
- var fourchars = path19.substring(0, 4);
6410
+ var twochars = path20.substring(0, 2);
6411
+ var threechars = path20.substring(0, 3);
6412
+ var fourchars = path20.substring(0, 4);
6413
6413
  if (threechars === "../") {
6414
- path19 = path19.substring(3);
6414
+ path20 = path20.substring(3);
6415
6415
  } else if (twochars === "./") {
6416
- path19 = path19.substring(2);
6416
+ path20 = path20.substring(2);
6417
6417
  } else if (threechars === "/./") {
6418
- path19 = "/" + path19.substring(3);
6419
- } else if (twochars === "/." && path19.length === 2) {
6420
- path19 = "/";
6421
- } else if (fourchars === "/../" || threechars === "/.." && path19.length === 3) {
6422
- path19 = "/" + path19.substring(4);
6418
+ path20 = "/" + path20.substring(3);
6419
+ } else if (twochars === "/." && path20.length === 2) {
6420
+ path20 = "/";
6421
+ } else if (fourchars === "/../" || threechars === "/.." && path20.length === 3) {
6422
+ path20 = "/" + path20.substring(4);
6423
6423
  output = output.replace(/\/?[^\/]*$/, "");
6424
6424
  } else {
6425
- var segment = path19.match(/(\/?([^\/]*))/)[0];
6425
+ var segment = path20.match(/(\/?([^\/]*))/)[0];
6426
6426
  output += segment;
6427
- path19 = path19.substring(segment.length);
6427
+ path20 = path20.substring(segment.length);
6428
6428
  }
6429
6429
  }
6430
6430
  return output;
@@ -18337,36 +18337,7 @@ var require_turndown_cjs = __commonJS((exports, module) => {
18337
18337
  module.exports = TurndownService;
18338
18338
  });
18339
18339
 
18340
- // src/cli/paths.ts
18341
- import { homedir } from "node:os";
18342
- import { dirname, join } from "node:path";
18343
- function getDefaultOpenCodeConfigDir() {
18344
- const userConfigDir = process.env.XDG_CONFIG_HOME ? process.env.XDG_CONFIG_HOME : join(homedir(), ".config");
18345
- return join(userConfigDir, "opencode");
18346
- }
18347
- function getCustomOpenCodeConfigDir() {
18348
- const configDir = process.env.OPENCODE_CONFIG_DIR?.trim();
18349
- return configDir || undefined;
18350
- }
18351
- function getConfigDir() {
18352
- const customConfigDir = getCustomOpenCodeConfigDir();
18353
- if (customConfigDir) {
18354
- return customConfigDir;
18355
- }
18356
- return getDefaultOpenCodeConfigDir();
18357
- }
18358
- function getConfigSearchDirs() {
18359
- const dirs = [getCustomOpenCodeConfigDir(), getDefaultOpenCodeConfigDir()];
18360
- return dirs.filter((dir, index) => {
18361
- return Boolean(dir) && dirs.indexOf(dir) === index;
18362
- });
18363
- }
18364
- function getOpenCodeConfigPaths() {
18365
- const configDir = getConfigDir();
18366
- return [join(configDir, "opencode.json"), join(configDir, "opencode.jsonc")];
18367
- }
18368
-
18369
- // src/cli/custom-skills.ts
18340
+ // src/cli/custom-skills-registry.ts
18370
18341
  var CUSTOM_SKILLS = [
18371
18342
  {
18372
18343
  name: "simplify",
@@ -18404,6 +18375,12 @@ var CUSTOM_SKILLS = [
18404
18375
  allowedAgents: ["orchestrator"],
18405
18376
  sourcePath: "src/skills/oh-my-opencode-slim"
18406
18377
  },
18378
+ {
18379
+ name: "release-smoke-test",
18380
+ description: "Validate packed release candidates and bugfixes before public publish",
18381
+ allowedAgents: ["orchestrator"],
18382
+ sourcePath: "src/skills/release-smoke-test"
18383
+ },
18407
18384
  {
18408
18385
  name: "worktrees",
18409
18386
  description: "Manage Git worktrees as OMO safe isolated coding lanes for complex/risky/parallel work",
@@ -18412,6 +18389,35 @@ var CUSTOM_SKILLS = [
18412
18389
  }
18413
18390
  ];
18414
18391
 
18392
+ // src/cli/paths.ts
18393
+ import { homedir } from "node:os";
18394
+ import { dirname, join } from "node:path";
18395
+ function getDefaultOpenCodeConfigDir() {
18396
+ const userConfigDir = process.env.XDG_CONFIG_HOME ? process.env.XDG_CONFIG_HOME : join(homedir(), ".config");
18397
+ return join(userConfigDir, "opencode");
18398
+ }
18399
+ function getCustomOpenCodeConfigDir() {
18400
+ const configDir = process.env.OPENCODE_CONFIG_DIR?.trim();
18401
+ return configDir || undefined;
18402
+ }
18403
+ function getConfigDir() {
18404
+ const customConfigDir = getCustomOpenCodeConfigDir();
18405
+ if (customConfigDir) {
18406
+ return customConfigDir;
18407
+ }
18408
+ return getDefaultOpenCodeConfigDir();
18409
+ }
18410
+ function getConfigSearchDirs() {
18411
+ const dirs = [getCustomOpenCodeConfigDir(), getDefaultOpenCodeConfigDir()];
18412
+ return dirs.filter((dir, index) => {
18413
+ return Boolean(dir) && dirs.indexOf(dir) === index;
18414
+ });
18415
+ }
18416
+ function getOpenCodeConfigPaths() {
18417
+ const configDir = getConfigDir();
18418
+ return [join(configDir, "opencode.json"), join(configDir, "opencode.jsonc")];
18419
+ }
18420
+
18415
18421
  // src/cli/skills.ts
18416
18422
  var PERMISSION_ONLY_SKILLS = [
18417
18423
  {
@@ -18556,7 +18562,7 @@ var CouncilConfigSchema = z.object({
18556
18562
  default_preset: z.string().default("default"),
18557
18563
  councillor_execution_mode: CouncillorExecutionModeSchema.describe('Execution mode for councillors. "serial" runs them one at a time (required for single-model systems). "parallel" runs them concurrently (default, faster for multi-model systems).'),
18558
18564
  councillor_retries: z.number().int().min(0).max(5).default(3).describe("Number of retry attempts for councillors that return empty responses " + "(e.g. due to provider rate limiting). Default: 3 retries."),
18559
- master: z.unknown().optional().describe("DEPRECATED ignored. Council agent synthesizes directly.")
18565
+ master: z.unknown().optional().describe("DEPRECATED - ignored. Council agent synthesizes directly.")
18560
18566
  }).transform((data) => {
18561
18567
  const deprecated = [];
18562
18568
  if (data.master !== undefined)
@@ -18671,7 +18677,13 @@ var AgentOverrideConfigSchema = z2.object({
18671
18677
  options: z2.record(z2.string(), z2.unknown()).optional(),
18672
18678
  displayName: z2.string().min(1).optional()
18673
18679
  }).strict();
18674
- var MultiplexerTypeSchema = z2.enum(["auto", "tmux", "zellij", "none"]);
18680
+ var MultiplexerTypeSchema = z2.enum([
18681
+ "auto",
18682
+ "tmux",
18683
+ "zellij",
18684
+ "herdr",
18685
+ "none"
18686
+ ]);
18675
18687
  var MultiplexerLayoutSchema = z2.enum([
18676
18688
  "main-horizontal",
18677
18689
  "main-vertical",
@@ -18740,24 +18752,13 @@ var AcpAgentConfigSchema = z2.object({
18740
18752
  permissionMode: AcpAgentPermissionModeSchema.default("ask")
18741
18753
  }).strict();
18742
18754
  var AcpAgentsConfigSchema = z2.record(z2.string(), AcpAgentConfigSchema);
18743
- function validateCustomOnlyPromptFields(overrides, ctx, pathPrefix) {
18755
+ function rejectOrchestratorPromptOnOrchestrator(overrides, ctx, pathPrefix) {
18744
18756
  for (const [name, override] of Object.entries(overrides)) {
18745
- const isBuiltInOrAlias = ALL_AGENT_NAMES.includes(name) || AGENT_ALIASES[name] !== undefined;
18746
- if (!isBuiltInOrAlias) {
18747
- continue;
18748
- }
18749
- if (override.prompt !== undefined) {
18750
- ctx.addIssue({
18751
- code: z2.ZodIssueCode.custom,
18752
- path: [...pathPrefix, name, "prompt"],
18753
- message: "prompt is only supported for custom agents"
18754
- });
18755
- }
18756
- if (override.orchestratorPrompt !== undefined) {
18757
+ if (name === "orchestrator" && override.orchestratorPrompt !== undefined) {
18757
18758
  ctx.addIssue({
18758
18759
  code: z2.ZodIssueCode.custom,
18759
18760
  path: [...pathPrefix, name, "orchestratorPrompt"],
18760
- message: "orchestratorPrompt is only supported for custom agents"
18761
+ message: "orchestratorPrompt is not supported for the orchestrator agent"
18761
18762
  });
18762
18763
  }
18763
18764
  }
@@ -18765,6 +18766,7 @@ function validateCustomOnlyPromptFields(overrides, ctx, pathPrefix) {
18765
18766
  var PluginConfigSchema = z2.object({
18766
18767
  preset: z2.string().optional(),
18767
18768
  setDefaultAgent: z2.boolean().optional(),
18769
+ compactSidebar: z2.boolean().optional().describe("Use the compact TUI sidebar layout when enabled."),
18768
18770
  autoUpdate: z2.boolean().optional().describe("Disable automatic installation of plugin updates when false. Defaults to true."),
18769
18771
  presets: z2.record(z2.string(), PresetSchema).optional(),
18770
18772
  agents: z2.record(z2.string(), AgentOverrideConfigSchema).optional(),
@@ -18783,11 +18785,14 @@ var PluginConfigSchema = z2.object({
18783
18785
  acpAgents: AcpAgentsConfigSchema.optional()
18784
18786
  }).superRefine((value, ctx) => {
18785
18787
  if (value.agents) {
18786
- validateCustomOnlyPromptFields(value.agents, ctx, ["agents"]);
18788
+ rejectOrchestratorPromptOnOrchestrator(value.agents, ctx, ["agents"]);
18787
18789
  }
18788
18790
  if (value.presets) {
18789
18791
  for (const [presetName, preset] of Object.entries(value.presets)) {
18790
- validateCustomOnlyPromptFields(preset, ctx, ["presets", presetName]);
18792
+ rejectOrchestratorPromptOnOrchestrator(preset, ctx, [
18793
+ "presets",
18794
+ presetName
18795
+ ]);
18791
18796
  }
18792
18797
  }
18793
18798
  });
@@ -18874,6 +18879,7 @@ function mergePluginConfigs(base, override) {
18874
18879
  ...base,
18875
18880
  ...override,
18876
18881
  agents: deepMerge(base.agents, override.agents),
18882
+ presets: deepMerge(base.presets, override.presets),
18877
18883
  tmux: deepMerge(base.tmux, override.tmux),
18878
18884
  multiplexer: deepMerge(base.multiplexer, override.multiplexer),
18879
18885
  interview: deepMerge(base.interview, override.interview),
@@ -18945,15 +18951,33 @@ function loadPluginConfig(directory, options) {
18945
18951
  }
18946
18952
  return config;
18947
18953
  }
18948
- function loadAgentPrompt(agentName, preset) {
18954
+ function loadAgentPrompt(agentName, optionsOrPreset) {
18955
+ let preset;
18956
+ let projectDirectory;
18957
+ if (typeof optionsOrPreset === "string") {
18958
+ preset = optionsOrPreset;
18959
+ } else if (optionsOrPreset && typeof optionsOrPreset === "object") {
18960
+ preset = optionsOrPreset.preset;
18961
+ projectDirectory = optionsOrPreset.projectDirectory;
18962
+ }
18949
18963
  const presetDirName = preset && /^[a-zA-Z0-9_-]+$/.test(preset) ? preset : undefined;
18950
- const promptSearchDirs = getConfigSearchDirs().flatMap((configDir) => {
18951
- const promptsDir = path.join(configDir, PROMPTS_DIR_NAME);
18952
- return presetDirName ? [path.join(promptsDir, presetDirName), promptsDir] : [promptsDir];
18953
- });
18954
- const result = {};
18964
+ const searchDirs = [];
18965
+ if (projectDirectory && presetDirName) {
18966
+ searchDirs.push(path.join(projectDirectory, ".opencode", PROMPTS_DIR_NAME, presetDirName));
18967
+ }
18968
+ if (projectDirectory) {
18969
+ searchDirs.push(path.join(projectDirectory, ".opencode", PROMPTS_DIR_NAME));
18970
+ }
18971
+ if (presetDirName) {
18972
+ for (const userDir of getConfigSearchDirs()) {
18973
+ searchDirs.push(path.join(userDir, PROMPTS_DIR_NAME, presetDirName));
18974
+ }
18975
+ }
18976
+ for (const userDir of getConfigSearchDirs()) {
18977
+ searchDirs.push(path.join(userDir, PROMPTS_DIR_NAME));
18978
+ }
18955
18979
  const readFirstPrompt = (fileName, errorPrefix) => {
18956
- for (const dir of promptSearchDirs) {
18980
+ for (const dir of searchDirs) {
18957
18981
  const promptPath = path.join(dir, fileName);
18958
18982
  if (!fs.existsSync(promptPath)) {
18959
18983
  continue;
@@ -18966,6 +18990,7 @@ function loadAgentPrompt(agentName, preset) {
18966
18990
  }
18967
18991
  return;
18968
18992
  };
18993
+ const result = {};
18969
18994
  result.prompt = readFirstPrompt(`${agentName}.md`, "Error reading prompt file");
18970
18995
  result.appendPrompt = readFirstPrompt(`${agentName}_append.md`, "Error reading append prompt file");
18971
18996
  return result;
@@ -19113,13 +19138,10 @@ async function extractSessionResult(client, sessionId, options) {
19113
19138
 
19114
19139
  // src/agents/orchestrator.ts
19115
19140
  function resolvePrompt(base, customPrompt, customAppendPrompt) {
19116
- if (customPrompt)
19117
- return customPrompt;
19118
- if (customAppendPrompt)
19119
- return `${base}
19141
+ const effectiveBase = customPrompt !== undefined ? customPrompt : base;
19142
+ return customAppendPrompt !== undefined ? `${effectiveBase}
19120
19143
 
19121
- ${customAppendPrompt}`;
19122
- return base;
19144
+ ${customAppendPrompt}` : effectiveBase;
19123
19145
  }
19124
19146
  var AGENT_DESCRIPTIONS = {
19125
19147
  explorer: `@explorer
@@ -19162,7 +19184,7 @@ var AGENT_DESCRIPTIONS = {
19162
19184
  - Permissions: read_files, write_files
19163
19185
  - Stats: 2x faster code edits, 1/2 cost of orchestrator
19164
19186
  - Weakness: design, taste
19165
- - Tools/Constraints: Execution-focusedno research, no architectural decisions
19187
+ - Tools/Constraints: Execution-focused-no research, no architectural decisions
19166
19188
  - **Delegate when:** For implementation work, think and triage first. If the change is non-trivial or multi-file, hand bounded execution to @fixer • Parallelization benefits: Task involves multiple folders and multiple files modification, scoping work per folder and spawning parallel @fixers for each folder.
19167
19189
  - **Don't delegate when:** Needs discovery/research/decisions • Single small change (<20 lines, one file) • Unclear requirements needing iteration • Explaining to fixer > doing • Tight integration with your current work • Requires design taste, visual hierarchy, interaction polish, responsive layout decisions, animation/motion, component feel, or UI copy/design trade-offs
19168
19190
  - **Rule of thumb:** Headless/mechanical implementation → @fixer. User-visible design or polish → @designer. If @designer already set direction, @fixer may only do bounded mechanical follow-up that preserves that design exactly.`,
@@ -19181,12 +19203,12 @@ var AGENT_DESCRIPTIONS = {
19181
19203
  - Lane: Visual/media analysis isolated from orchestrator context
19182
19204
  - Role: Visual analysis specialist for images, PDFs, and diagrams
19183
19205
  - Permissions: Read files
19184
- - Stats: Saves main context tokens Observer processes raw files, returns structured observations
19206
+ - Stats: Saves main context tokens - Observer processes raw files, returns structured observations
19185
19207
  - Capabilities: Interprets images, screenshots, PDFs, and diagrams via native read tool; extracts UI elements, layouts, text, relationships
19186
19208
  - **Delegate when:** Need to analyze a multimedia file• Extract information
19187
19209
  - **Don't delegate when:** Plain text files that Read can handle directly • Files that need editing afterward (need literal content from Read)
19188
- - **Rule of thumb:** Even if your model supports vision, delegate visual analysis to @observer it isolates large image/PDF bytes from your context window, returning only concise structured text. Need exact file contents for routing? → Read only the minimal context yourself.
19189
- - **IMPORTANT:** When delegating to @observer, always include the **full file path** in the prompt so it can read the file. Example: "Analyze the screenshot at /path/to/file.png describe the UI elements and error messages."`
19210
+ - **Rule of thumb:** Even if your model supports vision, delegate visual analysis to @observer - it isolates large image/PDF bytes from your context window, returning only concise structured text. Need exact file contents for routing? → Read only the minimal context yourself.
19211
+ - **IMPORTANT:** When delegating to @observer, always include the **full file path** in the prompt so it can read the file. Example: "Analyze the screenshot at /path/to/file.png - describe the UI elements and error messages."`
19190
19212
  };
19191
19213
  var VALIDATION_ROUTING = [
19192
19214
  "- Route UI/UX validation and review to @designer",
@@ -19384,7 +19406,7 @@ function createReadOnlyAgentPermission() {
19384
19406
  }
19385
19407
 
19386
19408
  // src/agents/council.ts
19387
- var COUNCIL_AGENT_PROMPT = `You are the Council agent a multi-LLM orchestration system that runs consensus across multiple models.
19409
+ var COUNCIL_AGENT_PROMPT = `You are the Council agent - a multi-LLM orchestration system that runs consensus across multiple models.
19388
19410
 
19389
19411
  **Tool**: You have access to the \`council_session\` tool. You also have read-only codebase inspection tools. You do not have write, edit, shell, or subagent-delegation tools.
19390
19412
 
@@ -19400,9 +19422,9 @@ var COUNCIL_AGENT_PROMPT = `You are the Council agent — a multi-LLM orchestrat
19400
19422
  4. Follow the Synthesis Process below
19401
19423
  5. Present the result to the user
19402
19424
 
19403
- **Synthesis Process** (MANDATORY follow in order):
19425
+ **Synthesis Process** (MANDATORY - follow in order):
19404
19426
  1. Read the original user prompt
19405
- 2. Review each councillor's response individually note each councillor's key insight and unique contribution by name
19427
+ 2. Review each councillor's response individually - note each councillor's key insight and unique contribution by name
19406
19428
  3. Identify agreements and contradictions between councillors
19407
19429
  4. Resolve contradictions with explicit reasoning
19408
19430
  5. Synthesize the optimal final answer
@@ -19416,7 +19438,7 @@ var COUNCIL_AGENT_PROMPT = `You are the Council agent — a multi-LLM orchestrat
19416
19438
  - Do not omit per-councillor details from the final response
19417
19439
  - Do not collapse the output into only a final summary
19418
19440
  - Be transparent about trade-offs when different approaches have valid pros/cons
19419
- - Don't just average responses choose the best approach and improve upon it
19441
+ - Don't just average responses - choose the best approach and improve upon it
19420
19442
 
19421
19443
  ${READONLY_FILE_OPERATIONS_RULES}
19422
19444
 
@@ -19477,10 +19499,10 @@ ${cr.result}`;
19477
19499
  }).join(`
19478
19500
 
19479
19501
  `);
19480
- const failedSection = councillorResults.filter((cr) => cr.status !== "completed").map((cr) => `**${cr.name}**: ${cr.status} ${cr.error ?? "Unknown"}`).join(`
19502
+ const failedSection = councillorResults.filter((cr) => cr.status !== "completed").map((cr) => `**${cr.name}**: ${cr.status} - ${cr.error ?? "Unknown"}`).join(`
19481
19503
  `);
19482
19504
  if (completedWithResults.length === 0) {
19483
- const errorDetails = councillorResults.map((cr) => `**${cr.name}** (${shortModelLabel(cr.model)}): ${cr.status} ${cr.error ?? "Unknown"}`).join(`
19505
+ const errorDetails = councillorResults.map((cr) => `**${cr.name}** (${shortModelLabel(cr.model)}): ${cr.status} - ${cr.error ?? "Unknown"}`).join(`
19484
19506
  `);
19485
19507
  return `---
19486
19508
 
@@ -19538,12 +19560,12 @@ You CANNOT edit files, write files, run shell commands, or delegate to other age
19538
19560
  ${NO_SHELL_READONLY_FILE_OPERATIONS_RULES}
19539
19561
 
19540
19562
  **Behavior**:
19541
- - **Examine the codebase** before answering your read access is what makes council valuable. Don't guess at code you can see.
19563
+ - **Examine the codebase** before answering - your read access is what makes council valuable. Don't guess at code you can see.
19542
19564
  - Analyze the problem thoroughly
19543
19565
  - Provide a complete, well-reasoned response
19544
19566
  - Focus on the quality and correctness of your solution
19545
19567
  - Be direct and concise
19546
- - Don't be influenced by what other councillors might say you won't see their responses
19568
+ - Don't be influenced by what other councillors might say - you won't see their responses
19547
19569
 
19548
19570
  **Output**:
19549
19571
  - Give your honest assessment
@@ -19574,7 +19596,7 @@ var DESIGNER_PROMPT = `You are a Designer - a frontend UI/UX specialist who crea
19574
19596
 
19575
19597
  **Typography**
19576
19598
  - Choose distinctive, characterful fonts that elevate aesthetics
19577
- - Avoid generic defaults (Arial, Inter)opt for unexpected, beautiful choices
19599
+ - Avoid generic defaults (Arial, Inter)-opt for unexpected, beautiful choices
19578
19600
  - Pair display fonts with refined body fonts for hierarchy
19579
19601
 
19580
19602
  **Color & Theme**
@@ -19591,7 +19613,7 @@ var DESIGNER_PROMPT = `You are a Designer - a frontend UI/UX specialist who crea
19591
19613
 
19592
19614
  **Spatial Composition**
19593
19615
  - Break conventions: asymmetry, overlap, diagonal flow, grid-breaking
19594
- - Generous negative space OR controlled densitycommit to the choice
19616
+ - Generous negative space OR controlled density-commit to the choice
19595
19617
  - Unexpected layouts that guide the eye
19596
19618
 
19597
19619
  **Visual Depth**
@@ -19600,7 +19622,7 @@ var DESIGNER_PROMPT = `You are a Designer - a frontend UI/UX specialist who crea
19600
19622
  - Contextual effects that match the aesthetic (grain overlays, custom cursors)
19601
19623
 
19602
19624
  **Styling Approach**
19603
- - Default to Tailwind CSS utility classes when availablefast, maintainable, consistent
19625
+ - Default to Tailwind CSS utility classes when available-fast, maintainable, consistent
19604
19626
  - Use custom CSS when the vision requires it: complex animations, unique effects, advanced compositions
19605
19627
  - Balance utility-first speed with creative freedom where it matters
19606
19628
 
@@ -19612,7 +19634,7 @@ var DESIGNER_PROMPT = `You are a Designer - a frontend UI/UX specialist who crea
19612
19634
  ## Constraints
19613
19635
  - Respect existing design systems when present
19614
19636
  - Leverage component libraries where available
19615
- - Prioritize visual excellencecode perfection comes second
19637
+ - Prioritize visual excellence-code perfection comes second
19616
19638
  - Use grounded, normal, regular english - don't use jargon or overly technical language
19617
19639
 
19618
19640
  ${WRITABLE_FILE_OPERATIONS_RULES}
@@ -19716,7 +19738,7 @@ ${WRITABLE_FILE_OPERATIONS_RULES}
19716
19738
  - NO external research (no websearch, context7, gh_grep)
19717
19739
  - NO delegation or spawning subagents
19718
19740
  - No multi-step research/planning; minimal execution sequence ok
19719
- - If context is insufficient: use grep/glob/read directly do not delegate
19741
+ - If context is insufficient: use grep/glob/read directly - do not delegate
19720
19742
  - Only ask for missing inputs you truly cannot retrieve yourself
19721
19743
  - Do not act as the primary reviewer; implement requested changes and surface obvious issues briefly
19722
19744
 
@@ -19806,21 +19828,21 @@ ${customAppendPrompt}`;
19806
19828
  }
19807
19829
 
19808
19830
  // src/agents/observer.ts
19809
- var OBSERVER_PROMPT = `You are Observer a visual analysis specialist.
19831
+ var OBSERVER_PROMPT = `You are Observer - a visual analysis specialist.
19810
19832
 
19811
19833
  **Role**: Interpret images, screenshots, PDFs, and diagrams. Extract structured observations for the Orchestrator to act on.
19812
19834
 
19813
19835
  **Behavior**:
19814
19836
  - Read the file(s) specified in the prompt
19815
- - Analyze visual content layouts, UI elements, text, relationships, flows
19816
- - For screenshots with text/code/errors: extract the **exact text** via OCR never paraphrase error messages or code
19837
+ - Analyze visual content - layouts, UI elements, text, relationships, flows
19838
+ - For screenshots with text/code/errors: extract the **exact text** via OCR - never paraphrase error messages or code
19817
19839
  - For multiple files: analyze each, then compare or relate as requested
19818
19840
  - Return ONLY the extracted information relevant to the goal
19819
- - If the image is unclear, blurry, or partially visible: state what you CAN see and explicitly note what is uncertain never guess or fabricate details
19841
+ - If the image is unclear, blurry, or partially visible: state what you CAN see and explicitly note what is uncertain - never guess or fabricate details
19820
19842
 
19821
19843
  **Constraints**:
19822
19844
  - READ-ONLY: Analyze and report, don't modify files
19823
- - Save context tokens the Orchestrator never processes the raw file
19845
+ - Save context tokens - the Orchestrator never processes the raw file
19824
19846
  - Match the language of the request
19825
19847
  - If info not found, state clearly what's missing
19826
19848
 
@@ -19837,7 +19859,7 @@ ${customAppendPrompt}`;
19837
19859
  }
19838
19860
  return {
19839
19861
  name: "observer",
19840
- description: "Visual analysis. Use for interpreting images, screenshots, PDFs, and diagrams extracts structured observations without loading raw files into main context. Requires a vision-capable model.",
19862
+ description: "Visual analysis. Use for interpreting images, screenshots, PDFs, and diagrams - extracts structured observations without loading raw files into main context. Requires a vision-capable model.",
19841
19863
  config: {
19842
19864
  model,
19843
19865
  temperature: 0.1,
@@ -19974,7 +19996,7 @@ function applyOverrides(agent, override) {
19974
19996
  if (override.model) {
19975
19997
  if (Array.isArray(override.model)) {
19976
19998
  agent._modelArray = override.model.map((m) => typeof m === "string" ? { id: m } : m);
19977
- agent.config.model = undefined;
19999
+ agent.config.model = agent._modelArray[0].id;
19978
20000
  } else {
19979
20001
  agent.config.model = override.model;
19980
20002
  }
@@ -20061,7 +20083,7 @@ var SUBAGENT_FACTORIES = {
20061
20083
  council: createCouncilAgent,
20062
20084
  councillor: createCouncillorAgent
20063
20085
  };
20064
- function createAgents(config) {
20086
+ function createAgents(config, options) {
20065
20087
  const disabled = getDisabledAgents(config);
20066
20088
  if (!config?.council) {
20067
20089
  disabled.add("council");
@@ -20082,8 +20104,17 @@ function createAgents(config) {
20082
20104
  return primaryModel ?? DEFAULT_MODELS[name];
20083
20105
  };
20084
20106
  const protoSubAgents = Object.entries(SUBAGENT_FACTORIES).filter(([name]) => !disabled.has(name)).map(([name, factory]) => {
20085
- const customPrompts = loadAgentPrompt(name, config?.preset);
20086
- return factory(getModelForAgent(name), customPrompts.prompt, customPrompts.appendPrompt);
20107
+ const agent = factory(getModelForAgent(name), undefined, undefined);
20108
+ const customPrompts = loadAgentPrompt(name, {
20109
+ preset: config?.preset,
20110
+ projectDirectory: options?.projectDirectory
20111
+ });
20112
+ const override = getAgentOverride(config, name);
20113
+ const inlinePrompt = override?.prompt;
20114
+ const defaultPrompt = agent.config.prompt ?? "";
20115
+ const basePrompt = inlinePrompt !== undefined ? inlinePrompt : defaultPrompt;
20116
+ agent.config.prompt = resolvePrompt(basePrompt, customPrompts.prompt, customPrompts.appendPrompt);
20117
+ return agent;
20087
20118
  });
20088
20119
  const customAgentNames = getCustomAgentNames(config).map(normalizeCustomAgentName).filter((name) => name.length > 0).filter((name) => {
20089
20120
  if (!isSafeCustomAgentName(name)) {
@@ -20100,7 +20131,10 @@ function createAgents(config) {
20100
20131
  console.warn(`[oh-my-opencode] Custom agent '${name}' skipped: 'model' is required`);
20101
20132
  return [];
20102
20133
  }
20103
- const customPrompts = loadAgentPrompt(name, config?.preset);
20134
+ const customPrompts = loadAgentPrompt(name, {
20135
+ preset: config?.preset,
20136
+ projectDirectory: options?.projectDirectory
20137
+ });
20104
20138
  return [
20105
20139
  buildCustomAgentDefinition(name, override, customPrompts.prompt, customPrompts.appendPrompt)
20106
20140
  ];
@@ -20157,8 +20191,15 @@ function createAgents(config) {
20157
20191
  ];
20158
20192
  const orchestratorOverride = getAgentOverride(config, "orchestrator");
20159
20193
  const orchestratorModel = orchestratorOverride?.model ?? DEFAULT_MODELS.orchestrator;
20160
- const orchestratorPrompts = loadAgentPrompt("orchestrator", config?.preset);
20161
- const orchestrator = createOrchestratorAgent(orchestratorModel, orchestratorPrompts.prompt, orchestratorPrompts.appendPrompt, disabled);
20194
+ const orchestratorPrompts = loadAgentPrompt("orchestrator", {
20195
+ preset: config?.preset,
20196
+ projectDirectory: options?.projectDirectory
20197
+ });
20198
+ const orchestrator = createOrchestratorAgent(orchestratorModel, undefined, undefined, disabled);
20199
+ const inlineOrchestratorPrompt = orchestratorOverride?.prompt;
20200
+ const defaultOrchestratorPrompt = orchestrator.config.prompt ?? "";
20201
+ const baseOrchestratorPrompt = inlineOrchestratorPrompt !== undefined ? inlineOrchestratorPrompt : defaultOrchestratorPrompt;
20202
+ orchestrator.config.prompt = resolvePrompt(baseOrchestratorPrompt, orchestratorPrompts.prompt, orchestratorPrompts.appendPrompt);
20162
20203
  applyDefaultPermissions(orchestrator, orchestratorOverride?.skills, config?.disabled_skills);
20163
20204
  if (orchestratorOverride) {
20164
20205
  applyOverrides(orchestrator, orchestratorOverride);
@@ -20172,7 +20213,7 @@ function createAgents(config) {
20172
20213
  displayNameMap.set(agent.name, agent.displayName);
20173
20214
  }
20174
20215
  }
20175
- const customOrchestratorPrompts = customSubAgents.map((agent) => {
20216
+ const extraOrchestratorPromptsList = [...builtInSubAgents, ...customSubAgents].map((agent) => {
20176
20217
  const override = getAgentOverride(config, agent.name);
20177
20218
  return override?.orchestratorPrompt;
20178
20219
  }).filter((prompt) => Boolean(prompt));
@@ -20207,28 +20248,37 @@ function createAgents(config) {
20207
20248
  }
20208
20249
  }
20209
20250
  injectDisplayNames(orchestrator, displayNameMap);
20210
- const extraOrchestratorPrompts = [
20211
- ...customOrchestratorPrompts,
20212
- ...acpOrchestratorPrompts
20213
- ];
20214
- if (extraOrchestratorPrompts.length > 0) {
20215
- const rewrittenPrompts = extraOrchestratorPrompts.map((promptText) => {
20216
- let text = promptText;
20217
- for (const [internalName, displayName] of displayNameMap) {
20218
- text = text.replace(new RegExp(`@${escapeRegExp(internalName)}\\b`, "g"), `@${normalizeDisplayName(displayName)}`);
20219
- }
20220
- return text;
20221
- });
20222
- orchestrator.config.prompt = `${orchestrator.config.prompt}
20251
+ const rewritePrompt = (promptText) => {
20252
+ let text = promptText;
20253
+ for (const [internalName, displayName] of displayNameMap) {
20254
+ text = text.replace(new RegExp(`@${escapeRegExp(internalName)}\\b`, "g"), `@${normalizeDisplayName(displayName)}`);
20255
+ }
20256
+ return text;
20257
+ };
20258
+ const rewrittenOverrides = extraOrchestratorPromptsList.map(rewritePrompt);
20259
+ const rewrittenAcps = acpOrchestratorPrompts.map(rewritePrompt);
20260
+ let updatedPrompt = orchestrator.config.prompt ?? "";
20261
+ if (rewrittenOverrides.length > 0) {
20262
+ updatedPrompt = `${updatedPrompt}
20263
+
20264
+ # Project-specific routing guidance
20265
+
20266
+ ${rewrittenOverrides.join(`
20223
20267
 
20224
- ${rewrittenPrompts.join(`
20268
+ `)}`;
20269
+ }
20270
+ if (rewrittenAcps.length > 0) {
20271
+ updatedPrompt = `${updatedPrompt}
20272
+
20273
+ ${rewrittenAcps.join(`
20225
20274
 
20226
20275
  `)}`;
20227
20276
  }
20277
+ orchestrator.config.prompt = updatedPrompt;
20228
20278
  return [orchestrator, ...allSubAgents];
20229
20279
  }
20230
- function getAgentConfigs(config) {
20231
- const agents = createAgents(config);
20280
+ function getAgentConfigs(config, options) {
20281
+ const agents = createAgents(config, options);
20232
20282
  const applyClassification = (name, sdkConfig) => {
20233
20283
  if (name === "council") {
20234
20284
  sdkConfig.mode = "all";
@@ -20368,6 +20418,8 @@ function log(message, data) {
20368
20418
  }
20369
20419
 
20370
20420
  // src/companion/manager.ts
20421
+ var activeExitListener = null;
20422
+ var activeManagers = new Set;
20371
20423
  function stateFilePath() {
20372
20424
  const xdg = process.env.XDG_DATA_HOME?.trim();
20373
20425
  const base = xdg && path3.isAbsolute(xdg) ? xdg : path3.join(os2.homedir(), ".local", "share");
@@ -20446,6 +20498,7 @@ class CompanionManager {
20446
20498
  }
20447
20499
  onLoad() {
20448
20500
  if (this.config?.enabled !== true) {
20501
+ CompanionManager.disposeActiveManagers(this.id);
20449
20502
  try {
20450
20503
  if (!existsSync2(stateFilePath()))
20451
20504
  return;
@@ -20455,10 +20508,29 @@ class CompanionManager {
20455
20508
  } catch {}
20456
20509
  return;
20457
20510
  }
20458
- process.on("exit", () => this.onExit());
20511
+ this.registerActiveManager();
20459
20512
  this.flush();
20460
20513
  this.spawnIfAvailable();
20461
20514
  }
20515
+ registerActiveManager() {
20516
+ for (const manager of [...activeManagers]) {
20517
+ if (manager !== this && manager.id === this.id) {
20518
+ manager.onExit();
20519
+ }
20520
+ }
20521
+ activeManagers.add(this);
20522
+ if (!activeExitListener) {
20523
+ activeExitListener = () => CompanionManager.disposeActiveManagers();
20524
+ process.on("exit", activeExitListener);
20525
+ }
20526
+ }
20527
+ static disposeActiveManagers(sessionId) {
20528
+ for (const manager of [...activeManagers]) {
20529
+ if (sessionId && manager.id !== sessionId)
20530
+ continue;
20531
+ manager.onExit();
20532
+ }
20533
+ }
20462
20534
  onSessionStatus(input) {
20463
20535
  if (this.config?.enabled !== true)
20464
20536
  return;
@@ -20501,14 +20573,21 @@ class CompanionManager {
20501
20573
  this.flush();
20502
20574
  }
20503
20575
  onExit() {
20504
- if (this.config?.enabled !== true)
20505
- return;
20576
+ activeManagers.delete(this);
20506
20577
  if (this.companionProcess) {
20507
20578
  try {
20508
20579
  this.companionProcess.kill();
20509
20580
  } catch {}
20510
20581
  this.companionProcess = null;
20511
20582
  }
20583
+ if (activeManagers.size === 0 && activeExitListener) {
20584
+ try {
20585
+ process.removeListener("exit", activeExitListener);
20586
+ } catch {}
20587
+ activeExitListener = null;
20588
+ }
20589
+ if (this.config?.enabled !== true)
20590
+ return;
20512
20591
  writeState((state) => {
20513
20592
  state.sessions = state.sessions.filter((s) => s.session_id !== this.id);
20514
20593
  });
@@ -21003,7 +21082,7 @@ class CouncilManager {
21003
21082
  log(`[council-manager] Preset "${resolvedPreset}" has no councillors`);
21004
21083
  return {
21005
21084
  success: false,
21006
- error: `Preset "${resolvedPreset}" has no councillors configured. Note: the reserved key "master" is ignored use councillor names as keys`,
21085
+ error: `Preset "${resolvedPreset}" has no councillors configured. Note: the reserved key "master" is ignored - use councillor names as keys`,
21007
21086
  councillorResults: []
21008
21087
  };
21009
21088
  }
@@ -21039,7 +21118,7 @@ class CouncilManager {
21039
21118
  }
21040
21119
  async sendStartNotification(parentSessionId, councillorCount) {
21041
21120
  const message = [
21042
- `⎔ Council starting ${councillorCount} councillors launching ctrl+x ↓ to watch`,
21121
+ `⎔ Council starting - ${councillorCount} councillors launching - ctrl+x ↓ to watch`,
21043
21122
  "",
21044
21123
  "[system status: continue without acknowledging this notification]"
21045
21124
  ].join(`
@@ -23179,17 +23258,65 @@ function preparePackageUpdate(version, packageName = PACKAGE_NAME, runtimePackag
23179
23258
  }
23180
23259
 
23181
23260
  // src/hooks/auto-update-checker/skill-sync.ts
23261
+ import * as crypto from "node:crypto";
23182
23262
  import {
23183
23263
  copyFileSync as copyFileSync2,
23184
23264
  existsSync as existsSync6,
23185
23265
  lstatSync,
23186
23266
  mkdirSync as mkdirSync4,
23187
- mkdtempSync as mkdtempSync2,
23188
23267
  readdirSync as readdirSync2,
23268
+ readFileSync as readFileSync6,
23189
23269
  renameSync as renameSync3,
23190
- rmSync as rmSync4
23270
+ rmSync as rmSync4,
23271
+ unlinkSync as unlinkSync2,
23272
+ writeFileSync as writeFileSync5
23191
23273
  } from "node:fs";
23274
+ import * as os4 from "node:os";
23192
23275
  import * as path10 from "node:path";
23276
+ var localProcessToken = globalThis.OMO_SKILL_SYNC_PROCESS_TOKEN;
23277
+ if (!localProcessToken) {
23278
+ localProcessToken = crypto.randomUUID();
23279
+ globalThis.OMO_SKILL_SYNC_PROCESS_TOKEN = localProcessToken;
23280
+ }
23281
+ var PROCESS_TOKEN = localProcessToken;
23282
+ var ACQUIRED_LOCKS = new Set;
23283
+ var LEGACY_MANAGED_SKILL_HASHES = {};
23284
+ function validateManifest(data) {
23285
+ if (typeof data !== "object" || data === null)
23286
+ return false;
23287
+ const d = data;
23288
+ if (d.schemaVersion !== 1)
23289
+ return false;
23290
+ if (typeof d.skills !== "object" || d.skills === null)
23291
+ return false;
23292
+ const allowedStatuses = new Set([
23293
+ "managed",
23294
+ "customized",
23295
+ "deleted",
23296
+ "conflict"
23297
+ ]);
23298
+ const skillsObj = d.skills;
23299
+ for (const key of Object.keys(skillsObj)) {
23300
+ const entry = skillsObj[key];
23301
+ if (typeof entry !== "object" || entry === null)
23302
+ return false;
23303
+ if (typeof entry.status !== "string" || !allowedStatuses.has(entry.status))
23304
+ return false;
23305
+ if (typeof entry.packageVersion !== "string")
23306
+ return false;
23307
+ if (typeof entry.sourceHash !== "string")
23308
+ return false;
23309
+ if (typeof entry.lastManagedHash !== "string")
23310
+ return false;
23311
+ if (typeof entry.lastSeenHash !== "string")
23312
+ return false;
23313
+ if (entry.stagedPath !== undefined && typeof entry.stagedPath !== "string")
23314
+ return false;
23315
+ if (typeof entry.updatedAt !== "string")
23316
+ return false;
23317
+ }
23318
+ return true;
23319
+ }
23193
23320
  function copyDirRecursive(src, dest) {
23194
23321
  const stat2 = lstatSync(src);
23195
23322
  if (stat2.isSymbolicLink()) {
@@ -23209,143 +23336,908 @@ function copyDirRecursive(src, dest) {
23209
23336
  copyFileSync2(src, dest);
23210
23337
  }
23211
23338
  }
23212
- function syncBundledSkillsFromPackage(packageRoot) {
23213
- const installed = [];
23214
- const skippedExisting = [];
23215
- const failed = [];
23216
- const sourceSkillsDir = path10.join(packageRoot, "src", "skills");
23217
- try {
23218
- const stat2 = lstatSync(sourceSkillsDir);
23219
- if (stat2.isSymbolicLink() || !stat2.isDirectory()) {
23220
- log(`[skill-sync] Source skills directory is not a valid directory: ${sourceSkillsDir}`);
23221
- return { installed, skippedExisting, failed };
23339
+ function computeDirectoryHash(dirPath) {
23340
+ const hash = crypto.createHash("sha256");
23341
+ const entriesToHash = [];
23342
+ function traverse(currentDir) {
23343
+ const entries = readdirSync2(currentDir);
23344
+ for (const entry of entries) {
23345
+ const absolutePath = path10.join(currentDir, entry);
23346
+ const stat2 = lstatSync(absolutePath);
23347
+ const relativePath = path10.relative(dirPath, absolutePath);
23348
+ if (stat2.isSymbolicLink()) {
23349
+ continue;
23350
+ }
23351
+ if (stat2.isDirectory()) {
23352
+ entriesToHash.push({
23353
+ relativePath,
23354
+ absolutePath,
23355
+ kind: "directory",
23356
+ mode: stat2.mode
23357
+ });
23358
+ traverse(absolutePath);
23359
+ } else if (stat2.isFile()) {
23360
+ entriesToHash.push({
23361
+ relativePath,
23362
+ absolutePath,
23363
+ kind: "file",
23364
+ mode: stat2.mode
23365
+ });
23366
+ }
23222
23367
  }
23223
- } catch {
23224
- log(`[skill-sync] Source skills directory does not exist or is unreadable: ${sourceSkillsDir}`);
23225
- return { installed, skippedExisting, failed };
23226
23368
  }
23227
- const destSkillsDir = path10.join(getConfigDir(), "skills");
23228
- try {
23229
- if (!existsSync6(destSkillsDir)) {
23230
- mkdirSync4(destSkillsDir, { recursive: true });
23369
+ traverse(dirPath);
23370
+ entriesToHash.sort((a, b) => {
23371
+ if (a.relativePath < b.relativePath)
23372
+ return -1;
23373
+ if (a.relativePath > b.relativePath)
23374
+ return 1;
23375
+ return 0;
23376
+ });
23377
+ for (const entry of entriesToHash) {
23378
+ hash.update(entry.kind);
23379
+ hash.update("\x00");
23380
+ hash.update(entry.relativePath);
23381
+ hash.update("\x00");
23382
+ hash.update(String(entry.mode & 4095));
23383
+ hash.update("\x00");
23384
+ if (entry.kind === "file") {
23385
+ const content = readFileSync6(entry.absolutePath);
23386
+ hash.update(content);
23231
23387
  }
23232
- } catch (err) {
23233
- log(`[skill-sync] Failed to create destination skills directory: ${destSkillsDir}`, err);
23234
23388
  }
23235
- let entries = [];
23389
+ return hash.digest("hex");
23390
+ }
23391
+ function isPidRunning(pid) {
23236
23392
  try {
23237
- entries = readdirSync2(sourceSkillsDir);
23393
+ process.kill(pid, 0);
23394
+ return true;
23238
23395
  } catch (err) {
23239
- log(`[skill-sync] Failed to read source skills directory: ${sourceSkillsDir}`, err);
23240
- return { installed, skippedExisting, failed };
23396
+ return err.code === "EPERM";
23241
23397
  }
23242
- for (const entry of entries) {
23243
- const entryPath = path10.join(sourceSkillsDir, entry);
23398
+ }
23399
+ var CROSS_HOST_LOCK_EXPIRY_MS = 5 * 60 * 1000;
23400
+ function acquireLock(lockDir) {
23401
+ const metadataPath2 = path10.join(lockDir, "owner.json");
23402
+ const currentHost = os4.hostname();
23403
+ const currentPid = process.pid;
23404
+ const writeMetadata = () => {
23244
23405
  try {
23245
- if (entry.startsWith(".")) {
23246
- continue;
23247
- }
23248
- const entryStat = lstatSync(entryPath);
23249
- if (entryStat.isSymbolicLink() || !entryStat.isDirectory()) {
23250
- continue;
23251
- }
23252
- const skillMdPath = path10.join(entryPath, "SKILL.md");
23406
+ const metadata = {
23407
+ pid: currentPid,
23408
+ host: currentHost,
23409
+ time: Date.now(),
23410
+ token: PROCESS_TOKEN
23411
+ };
23412
+ writeFileSync5(metadataPath2, JSON.stringify(metadata), "utf-8");
23413
+ } catch {}
23414
+ };
23415
+ try {
23416
+ mkdirSync4(lockDir);
23417
+ writeMetadata();
23418
+ ACQUIRED_LOCKS.add(path10.resolve(lockDir));
23419
+ return true;
23420
+ } catch (err) {
23421
+ if (err.code !== "EEXIST") {
23422
+ throw err;
23423
+ }
23424
+ }
23425
+ try {
23426
+ let shouldSteal = false;
23427
+ let ageMs = 0;
23428
+ if (existsSync6(metadataPath2)) {
23253
23429
  try {
23254
- const skillMdStat = lstatSync(skillMdPath);
23255
- if (skillMdStat.isSymbolicLink() || !skillMdStat.isFile()) {
23256
- continue;
23430
+ const content = readFileSync6(metadataPath2, "utf-8");
23431
+ const metadata = JSON.parse(content);
23432
+ ageMs = Date.now() - metadata.time;
23433
+ if (metadata.host === currentHost) {
23434
+ if (!isPidRunning(metadata.pid)) {
23435
+ log(`[skill-sync] Lock owner process ${metadata.pid} is not running on this host. Recovery path.`);
23436
+ shouldSteal = true;
23437
+ }
23438
+ } else {
23439
+ if (ageMs > CROSS_HOST_LOCK_EXPIRY_MS) {
23440
+ log(`[skill-sync] Lock owned by different host ${metadata.host} has expired (${Math.round(ageMs / 1000)}s old). Reclaiming lock.`);
23441
+ shouldSteal = true;
23442
+ } else {
23443
+ log(`[skill-sync] Lock is owned by different host ${metadata.host}; failing closed.`);
23444
+ }
23257
23445
  }
23258
23446
  } catch {
23259
- continue;
23447
+ shouldSteal = true;
23260
23448
  }
23261
- const destPath = path10.join(destSkillsDir, entry);
23262
- let destExists = false;
23263
- try {
23264
- lstatSync(destPath);
23265
- destExists = true;
23266
- } catch {}
23267
- if (destExists) {
23268
- log(`[skill-sync] Skill already exists in destination: ${entry}`);
23269
- skippedExisting.push(entry);
23270
- continue;
23449
+ } else {
23450
+ const stat2 = lstatSync(lockDir);
23451
+ ageMs = Date.now() - stat2.mtimeMs;
23452
+ if (ageMs > 30000) {
23453
+ shouldSteal = true;
23271
23454
  }
23272
- const stagingDir = mkdtempSync2(path10.join(destSkillsDir, `.sync-staging-${entry}-`));
23455
+ }
23456
+ if (!shouldSteal)
23457
+ return false;
23458
+ log(`[skill-sync] Stealing/recovering lock directory.`);
23459
+ rmSync4(lockDir, { recursive: true, force: true });
23460
+ mkdirSync4(lockDir);
23461
+ writeMetadata();
23462
+ ACQUIRED_LOCKS.add(path10.resolve(lockDir));
23463
+ return true;
23464
+ } catch (err) {
23465
+ log(`[skill-sync] Failed to check/recover lock at ${lockDir}:`, err);
23466
+ return false;
23467
+ }
23468
+ }
23469
+ function releaseLock(lockDir) {
23470
+ const resolvedPath = path10.resolve(lockDir);
23471
+ try {
23472
+ let isOurLock = false;
23473
+ const metadataPath2 = path10.join(lockDir, "owner.json");
23474
+ if (existsSync6(metadataPath2)) {
23273
23475
  try {
23274
- copyDirRecursive(entryPath, stagingDir);
23275
- let destExistsLate = false;
23276
- try {
23277
- lstatSync(destPath);
23278
- destExistsLate = true;
23279
- } catch {}
23280
- if (destExistsLate) {
23281
- log(`[skill-sync] Destination path was created during staging for ${entry}, skipping promotion.`);
23282
- skippedExisting.push(entry);
23476
+ const content = readFileSync6(metadataPath2, "utf-8");
23477
+ const metadata = JSON.parse(content);
23478
+ if (metadata.host === os4.hostname() && metadata.pid === process.pid && metadata.token === PROCESS_TOKEN) {
23479
+ isOurLock = true;
23283
23480
  } else {
23284
- renameSync3(stagingDir, destPath);
23285
- installed.push(entry);
23286
- log(`[skill-sync] Successfully synced skill: ${entry}`);
23481
+ isOurLock = false;
23287
23482
  }
23288
23483
  } catch (err) {
23289
- log(`[skill-sync] Failed to sync skill ${entry}:`, err);
23290
- failed.push(entry);
23291
- } finally {
23292
- try {
23293
- if (existsSync6(stagingDir)) {
23294
- rmSync4(stagingDir, { recursive: true, force: true });
23295
- }
23296
- } catch (err) {
23297
- log(`[skill-sync] Failed to clean up staging directory ${stagingDir}:`, err);
23298
- }
23484
+ log(`[skill-sync] Lock owner.json is unreadable/corrupt:`, err);
23485
+ isOurLock = false;
23299
23486
  }
23300
- } catch (err) {
23301
- log(`[skill-sync] Error processing source entry ${entry}:`, err);
23302
- failed.push(entry);
23487
+ } else if (ACQUIRED_LOCKS.has(resolvedPath)) {
23488
+ isOurLock = true;
23303
23489
  }
23490
+ if (isOurLock) {
23491
+ if (existsSync6(lockDir)) {
23492
+ rmSync4(lockDir, { recursive: true, force: true });
23493
+ }
23494
+ } else if (existsSync6(lockDir)) {
23495
+ log(`[skill-sync] Skipping lock directory removal: lock is not owned by this process/token or owner.json check failed.`);
23496
+ }
23497
+ } catch (err) {
23498
+ log(`[skill-sync] Failed to release lock at ${lockDir}:`, err);
23499
+ } finally {
23500
+ ACQUIRED_LOCKS.delete(resolvedPath);
23304
23501
  }
23305
- return { installed, skippedExisting, failed };
23306
23502
  }
23307
-
23308
- // src/hooks/auto-update-checker/index.ts
23309
- function createAutoUpdateCheckerHook(ctx, options = {}) {
23310
- const { autoUpdate = true, companion } = options;
23311
- let hasChecked = false;
23312
- return {
23313
- event: ({ event }) => {
23314
- if (event.type !== "session.created")
23315
- return;
23316
- if (hasChecked)
23317
- return;
23318
- const props = event.properties;
23319
- if (props?.info?.parentID)
23320
- return;
23321
- hasChecked = true;
23322
- setTimeout(async () => {
23323
- const localDevVersion = getLocalDevVersion(ctx.directory);
23324
- if (localDevVersion) {
23325
- log("[auto-update-checker] Local development mode");
23326
- return;
23503
+ function atomicReplaceDir(sourceDir, destDir) {
23504
+ const parentDir = path10.dirname(destDir);
23505
+ if (!existsSync6(parentDir)) {
23506
+ mkdirSync4(parentDir, { recursive: true });
23507
+ }
23508
+ const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
23509
+ const stagingDir = path10.join(parentDir, `.staging-${path10.basename(destDir)}-${uniqueSuffix}`);
23510
+ const backupDir = path10.join(parentDir, `.backup-${path10.basename(destDir)}-${uniqueSuffix}`);
23511
+ let backupCreated = false;
23512
+ try {
23513
+ copyDirRecursive(sourceDir, stagingDir);
23514
+ if (existsSync6(destDir)) {
23515
+ renameSync3(destDir, backupDir);
23516
+ backupCreated = true;
23517
+ }
23518
+ renameSync3(stagingDir, destDir);
23519
+ if (backupCreated) {
23520
+ rmSync4(backupDir, { recursive: true, force: true });
23521
+ }
23522
+ } catch (err) {
23523
+ log(`[skill-sync] Error during atomic replace for ${destDir}. Rolling back:`, err);
23524
+ if (backupCreated) {
23525
+ try {
23526
+ if (existsSync6(destDir)) {
23527
+ rmSync4(destDir, { recursive: true, force: true });
23327
23528
  }
23328
- runBackgroundUpdateCheck(ctx, autoUpdate, companion).catch((err) => {
23329
- log("[auto-update-checker] Background update check failed:", err);
23330
- });
23331
- }, 0);
23529
+ renameSync3(backupDir, destDir);
23530
+ } catch (rollbackErr) {
23531
+ log(`[skill-sync] Critical error during rollback for ${destDir}:`, rollbackErr);
23532
+ }
23332
23533
  }
23333
- };
23334
- }
23335
- async function runBackgroundUpdateCheck(ctx, autoUpdate, companion) {
23336
- const pluginInfo = findPluginEntry(ctx.directory);
23337
- if (!pluginInfo) {
23338
- log("[auto-update-checker] Plugin not found in config");
23339
- return;
23534
+ try {
23535
+ if (existsSync6(stagingDir)) {
23536
+ rmSync4(stagingDir, { recursive: true, force: true });
23537
+ }
23538
+ } catch {}
23539
+ throw err;
23340
23540
  }
23341
- const cachedVersion = getCachedVersion();
23342
- const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion;
23343
- if (!currentVersion) {
23344
- log("[auto-update-checker] No version found (cached or pinned)");
23345
- return;
23541
+ }
23542
+ function matchesArtifactPattern(entry, prefix2, skillName) {
23543
+ if (!entry.startsWith(prefix2))
23544
+ return false;
23545
+ const rest = entry.slice(prefix2.length);
23546
+ if (!rest.startsWith(`${skillName}-`))
23547
+ return false;
23548
+ const suffix2 = rest.slice(skillName.length + 1);
23549
+ const firstPart = suffix2.split("-")[0];
23550
+ const timestamp = Number(firstPart);
23551
+ if (Number.isNaN(timestamp) || timestamp <= 0)
23552
+ return false;
23553
+ return true;
23554
+ }
23555
+ function recoverOrphanArtifacts(destSkillsDir, skillName) {
23556
+ if (!existsSync6(destSkillsDir))
23557
+ return false;
23558
+ let hadArtifacts = false;
23559
+ let entries = [];
23560
+ try {
23561
+ entries = readdirSync2(destSkillsDir);
23562
+ } catch {
23563
+ return false;
23346
23564
  }
23347
- const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion);
23348
- const latestInfo = await getLatestCompatibleVersion(currentVersion, channel);
23565
+ const backups = [];
23566
+ const stagings = [];
23567
+ for (const entry of entries) {
23568
+ if (matchesArtifactPattern(entry, ".backup-", skillName)) {
23569
+ backups.push(path10.join(destSkillsDir, entry));
23570
+ hadArtifacts = true;
23571
+ } else if (matchesArtifactPattern(entry, ".staging-", skillName)) {
23572
+ stagings.push(path10.join(destSkillsDir, entry));
23573
+ hadArtifacts = true;
23574
+ }
23575
+ }
23576
+ const destPath = path10.join(destSkillsDir, skillName);
23577
+ if (backups.length > 0) {
23578
+ backups.sort();
23579
+ const mostRecentBackup = backups[backups.length - 1];
23580
+ if (!existsSync6(destPath)) {
23581
+ backups.pop();
23582
+ try {
23583
+ renameSync3(mostRecentBackup, destPath);
23584
+ log(`[skill-sync] Recovered backup for ${skillName} back to destination.`);
23585
+ } catch (err) {
23586
+ log(`[skill-sync] Failed to restore backup for ${skillName}:`, err);
23587
+ }
23588
+ }
23589
+ for (const backup of backups) {
23590
+ try {
23591
+ rmSync4(backup, { recursive: true, force: true });
23592
+ } catch (err) {
23593
+ log(`[skill-sync] Failed to clean up backup folder ${backup}:`, err);
23594
+ }
23595
+ }
23596
+ }
23597
+ for (const staging of stagings) {
23598
+ try {
23599
+ rmSync4(staging, { recursive: true, force: true });
23600
+ } catch (err) {
23601
+ log(`[skill-sync] Failed to clean up staging folder ${staging}:`, err);
23602
+ }
23603
+ }
23604
+ return hadArtifacts;
23605
+ }
23606
+ function removeManagedStagedPath(stagedPath, manifestDir, skillName) {
23607
+ try {
23608
+ const absoluteStagedPath = path10.resolve(stagedPath);
23609
+ const absoluteAllowedRoot = path10.resolve(path10.join(manifestDir, "skill-updates"));
23610
+ const relative2 = path10.relative(absoluteAllowedRoot, absoluteStagedPath);
23611
+ const isUnderRoot = relative2 && !relative2.startsWith("..") && !path10.isAbsolute(relative2);
23612
+ if (isUnderRoot) {
23613
+ if (existsSync6(absoluteStagedPath)) {
23614
+ rmSync4(absoluteStagedPath, { recursive: true, force: true });
23615
+ log(`[skill-sync] Safely cleaned up staged path for ${skillName}: ${absoluteStagedPath}`);
23616
+ }
23617
+ } else {
23618
+ log(`[skill-sync] Refusing to delete staged path for ${skillName}: path ${absoluteStagedPath} is not under managed root ${absoluteAllowedRoot}`);
23619
+ }
23620
+ } catch (err) {
23621
+ log(`[skill-sync] Error while trying to verify and remove staged path for ${skillName} (${stagedPath}):`, err);
23622
+ }
23623
+ }
23624
+ function syncBundledSkillsFromPackage(packageRoot, options = {}) {
23625
+ const installed = [];
23626
+ const skippedExisting = [];
23627
+ const failed = [];
23628
+ const staged = [];
23629
+ const adopted = [];
23630
+ const customized = [];
23631
+ const sourceSkillsDir = path10.join(packageRoot, "src", "skills");
23632
+ try {
23633
+ const stat2 = lstatSync(sourceSkillsDir);
23634
+ if (stat2.isSymbolicLink() || !stat2.isDirectory()) {
23635
+ log(`[skill-sync] Source skills directory is not a valid directory: ${sourceSkillsDir}`);
23636
+ return {
23637
+ installed,
23638
+ skippedExisting,
23639
+ failed,
23640
+ staged,
23641
+ adopted,
23642
+ customized
23643
+ };
23644
+ }
23645
+ } catch {
23646
+ log(`[skill-sync] Source skills directory does not exist or is unreadable: ${sourceSkillsDir}`);
23647
+ return {
23648
+ installed,
23649
+ skippedExisting,
23650
+ failed,
23651
+ staged,
23652
+ adopted,
23653
+ customized
23654
+ };
23655
+ }
23656
+ let packageVersion = "unknown";
23657
+ try {
23658
+ const pkgJsonPath = path10.join(packageRoot, "package.json");
23659
+ if (existsSync6(pkgJsonPath)) {
23660
+ const content = readFileSync6(pkgJsonPath, "utf-8");
23661
+ const pkg = JSON.parse(content);
23662
+ if (pkg.version) {
23663
+ packageVersion = pkg.version;
23664
+ }
23665
+ }
23666
+ } catch (err) {
23667
+ log(`[skill-sync] Failed to read package version from ${packageRoot}:`, err);
23668
+ }
23669
+ const manifestDir = path10.join(getConfigDir(), ".oh-my-opencode-slim");
23670
+ const lockDir = path10.join(manifestDir, "skills.lock");
23671
+ try {
23672
+ mkdirSync4(manifestDir, { recursive: true });
23673
+ } catch (err) {
23674
+ log(`[skill-sync] Failed to create manifest directory: ${manifestDir}`, err);
23675
+ }
23676
+ if (!acquireLock(lockDir)) {
23677
+ log("[skill-sync] Failed to acquire lock for skill synchronization. Skipping.");
23678
+ return {
23679
+ installed,
23680
+ skippedExisting,
23681
+ failed: ["__lock__"],
23682
+ staged,
23683
+ adopted,
23684
+ customized
23685
+ };
23686
+ }
23687
+ try {
23688
+ const manifestPath = path10.join(manifestDir, "skills-manifest.json");
23689
+ let manifest = {
23690
+ schemaVersion: 1,
23691
+ updatedAt: new Date().toISOString(),
23692
+ skills: {}
23693
+ };
23694
+ let isManifestCorrupt = false;
23695
+ if (existsSync6(manifestPath)) {
23696
+ try {
23697
+ const content = readFileSync6(manifestPath, "utf-8");
23698
+ const parsed = JSON.parse(content);
23699
+ if (validateManifest(parsed)) {
23700
+ manifest = parsed;
23701
+ } else {
23702
+ throw new Error("Manifest validation failed");
23703
+ }
23704
+ } catch (err) {
23705
+ log("[skill-sync] Manifest is corrupt/unreadable. Failing closed.", err);
23706
+ isManifestCorrupt = true;
23707
+ }
23708
+ }
23709
+ const destSkillsDir = path10.join(getConfigDir(), "skills");
23710
+ try {
23711
+ if (!existsSync6(destSkillsDir)) {
23712
+ mkdirSync4(destSkillsDir, { recursive: true });
23713
+ }
23714
+ } catch (err) {
23715
+ log(`[skill-sync] Failed to create destination skills directory: ${destSkillsDir}`, err);
23716
+ }
23717
+ const skillsToProcess = (options.skills ?? CUSTOM_SKILLS).map((s) => ({
23718
+ name: s.name,
23719
+ sourcePath: s.sourcePath
23720
+ }));
23721
+ for (const skill of skillsToProcess) {
23722
+ try {
23723
+ const sourcePath = path10.join(packageRoot, skill.sourcePath);
23724
+ try {
23725
+ const stat2 = lstatSync(sourcePath);
23726
+ if (stat2.isSymbolicLink() || !stat2.isDirectory()) {
23727
+ continue;
23728
+ }
23729
+ const skillMdPath = path10.join(sourcePath, "SKILL.md");
23730
+ const skillMdStat = lstatSync(skillMdPath);
23731
+ if (skillMdStat.isSymbolicLink() || !skillMdStat.isFile()) {
23732
+ continue;
23733
+ }
23734
+ } catch {
23735
+ continue;
23736
+ }
23737
+ const destPath = path10.join(destSkillsDir, skill.name);
23738
+ const hadArtifacts = recoverOrphanArtifacts(destSkillsDir, skill.name);
23739
+ let destExists = false;
23740
+ let destIsDir = false;
23741
+ try {
23742
+ const destStat = lstatSync(destPath);
23743
+ destExists = true;
23744
+ destIsDir = destStat.isDirectory() && !destStat.isSymbolicLink();
23745
+ } catch {}
23746
+ if (destExists && !destIsDir) {
23747
+ log(`[skill-sync] Skill ${skill.name} destination is a file or symlink (conflict). Skipping.`);
23748
+ skippedExisting.push(skill.name);
23749
+ const sourceHash2 = computeDirectoryHash(sourcePath);
23750
+ const entry2 = manifest.skills[skill.name];
23751
+ if (entry2?.stagedPath) {
23752
+ removeManagedStagedPath(entry2.stagedPath, manifestDir, skill.name);
23753
+ }
23754
+ manifest.skills[skill.name] = {
23755
+ status: "conflict",
23756
+ packageVersion,
23757
+ sourceHash: sourceHash2,
23758
+ lastManagedHash: "",
23759
+ lastSeenHash: "",
23760
+ updatedAt: new Date().toISOString()
23761
+ };
23762
+ continue;
23763
+ }
23764
+ const sourceHash = computeDirectoryHash(sourcePath);
23765
+ if (isManifestCorrupt) {
23766
+ if (!destExists) {
23767
+ try {
23768
+ atomicReplaceDir(sourcePath, destPath);
23769
+ installed.push(skill.name);
23770
+ manifest.skills[skill.name] = {
23771
+ status: "managed",
23772
+ packageVersion,
23773
+ sourceHash,
23774
+ lastManagedHash: sourceHash,
23775
+ lastSeenHash: sourceHash,
23776
+ updatedAt: new Date().toISOString()
23777
+ };
23778
+ } catch (err) {
23779
+ log(`[skill-sync] Failed to install missing skill ${skill.name} (corrupt manifest mode):`, err);
23780
+ failed.push(skill.name);
23781
+ }
23782
+ } else {
23783
+ log(`[skill-sync] Skipping existing skill ${skill.name} because manifest is corrupt.`);
23784
+ skippedExisting.push(skill.name);
23785
+ const destHash2 = computeDirectoryHash(destPath);
23786
+ if (destHash2 === sourceHash) {
23787
+ manifest.skills[skill.name] = {
23788
+ status: "managed",
23789
+ packageVersion,
23790
+ sourceHash,
23791
+ lastManagedHash: sourceHash,
23792
+ lastSeenHash: sourceHash,
23793
+ updatedAt: new Date().toISOString()
23794
+ };
23795
+ } else {
23796
+ try {
23797
+ const stagedSkillDir = path10.join(manifestDir, "skill-updates", packageVersion, skill.name);
23798
+ if (existsSync6(stagedSkillDir)) {
23799
+ rmSync4(stagedSkillDir, { recursive: true, force: true });
23800
+ }
23801
+ mkdirSync4(stagedSkillDir, { recursive: true });
23802
+ copyDirRecursive(sourcePath, stagedSkillDir);
23803
+ manifest.skills[skill.name] = {
23804
+ status: "customized",
23805
+ packageVersion,
23806
+ sourceHash,
23807
+ lastManagedHash: "",
23808
+ lastSeenHash: destHash2,
23809
+ stagedPath: stagedSkillDir,
23810
+ updatedAt: new Date().toISOString()
23811
+ };
23812
+ staged.push(skill.name);
23813
+ customized.push(skill.name);
23814
+ } catch (err) {
23815
+ log(`[skill-sync] Failed to stage update for customized skill ${skill.name} during recovery:`, err);
23816
+ manifest.skills[skill.name] = {
23817
+ status: "customized",
23818
+ packageVersion: "unknown",
23819
+ sourceHash: "",
23820
+ lastManagedHash: "",
23821
+ lastSeenHash: destHash2,
23822
+ updatedAt: new Date().toISOString()
23823
+ };
23824
+ }
23825
+ }
23826
+ }
23827
+ continue;
23828
+ }
23829
+ const entry = manifest.skills[skill.name];
23830
+ if (!destExists) {
23831
+ if (entry && entry.status === "deleted") {
23832
+ log(`[skill-sync] Skill ${skill.name} was deleted by user. Skipping.`);
23833
+ skippedExisting.push(skill.name);
23834
+ continue;
23835
+ }
23836
+ if (entry && entry.status !== "deleted") {
23837
+ if (hadArtifacts) {
23838
+ log(`[skill-sync] Managed skill ${skill.name} has backup/staging artifacts. Skipping delete, re-installing.`);
23839
+ try {
23840
+ atomicReplaceDir(sourcePath, destPath);
23841
+ installed.push(skill.name);
23842
+ manifest.skills[skill.name] = {
23843
+ status: "managed",
23844
+ packageVersion,
23845
+ sourceHash,
23846
+ lastManagedHash: sourceHash,
23847
+ lastSeenHash: sourceHash,
23848
+ updatedAt: new Date().toISOString()
23849
+ };
23850
+ } catch (err) {
23851
+ log(`[skill-sync] Failed to re-install skill ${skill.name}:`, err);
23852
+ failed.push(skill.name);
23853
+ }
23854
+ continue;
23855
+ } else {
23856
+ if (entry.stagedPath) {
23857
+ removeManagedStagedPath(entry.stagedPath, manifestDir, skill.name);
23858
+ delete entry.stagedPath;
23859
+ }
23860
+ const rawEntry = entry;
23861
+ delete rawEntry.stagedVersion;
23862
+ delete rawEntry.stagedHash;
23863
+ entry.status = "deleted";
23864
+ entry.updatedAt = new Date().toISOString();
23865
+ log(`[skill-sync] Skill ${skill.name} was deleted by user (detected now). Skipping.`);
23866
+ skippedExisting.push(skill.name);
23867
+ continue;
23868
+ }
23869
+ }
23870
+ try {
23871
+ atomicReplaceDir(sourcePath, destPath);
23872
+ installed.push(skill.name);
23873
+ manifest.skills[skill.name] = {
23874
+ status: "managed",
23875
+ packageVersion,
23876
+ sourceHash,
23877
+ lastManagedHash: sourceHash,
23878
+ lastSeenHash: sourceHash,
23879
+ updatedAt: new Date().toISOString()
23880
+ };
23881
+ log(`[skill-sync] Successfully installed missing skill: ${skill.name}`);
23882
+ } catch (err) {
23883
+ log(`[skill-sync] Failed to install skill ${skill.name}:`, err);
23884
+ failed.push(skill.name);
23885
+ }
23886
+ continue;
23887
+ }
23888
+ const destHash = computeDirectoryHash(destPath);
23889
+ if (entry) {
23890
+ if (entry.status === "managed") {
23891
+ if (destHash === entry.lastManagedHash) {
23892
+ if (destHash === sourceHash) {
23893
+ entry.packageVersion = packageVersion;
23894
+ entry.sourceHash = sourceHash;
23895
+ entry.lastManagedHash = sourceHash;
23896
+ entry.lastSeenHash = sourceHash;
23897
+ entry.updatedAt = new Date().toISOString();
23898
+ skippedExisting.push(skill.name);
23899
+ } else {
23900
+ try {
23901
+ atomicReplaceDir(sourcePath, destPath);
23902
+ installed.push(skill.name);
23903
+ manifest.skills[skill.name] = {
23904
+ status: "managed",
23905
+ packageVersion,
23906
+ sourceHash,
23907
+ lastManagedHash: sourceHash,
23908
+ lastSeenHash: sourceHash,
23909
+ updatedAt: new Date().toISOString()
23910
+ };
23911
+ log(`[skill-sync] Updated managed skill: ${skill.name}`);
23912
+ } catch (err) {
23913
+ log(`[skill-sync] Failed to update managed skill ${skill.name}:`, err);
23914
+ failed.push(skill.name);
23915
+ }
23916
+ }
23917
+ } else {
23918
+ if (destHash === sourceHash) {
23919
+ manifest.skills[skill.name] = {
23920
+ status: "managed",
23921
+ packageVersion,
23922
+ sourceHash,
23923
+ lastManagedHash: sourceHash,
23924
+ lastSeenHash: sourceHash,
23925
+ updatedAt: new Date().toISOString()
23926
+ };
23927
+ skippedExisting.push(skill.name);
23928
+ } else {
23929
+ try {
23930
+ const stagedSkillDir = path10.join(manifestDir, "skill-updates", packageVersion, skill.name);
23931
+ if (entry.stagedPath && entry.stagedPath !== stagedSkillDir) {
23932
+ removeManagedStagedPath(entry.stagedPath, manifestDir, skill.name);
23933
+ }
23934
+ if (existsSync6(stagedSkillDir)) {
23935
+ rmSync4(stagedSkillDir, { recursive: true, force: true });
23936
+ }
23937
+ mkdirSync4(stagedSkillDir, { recursive: true });
23938
+ copyDirRecursive(sourcePath, stagedSkillDir);
23939
+ entry.status = "customized";
23940
+ entry.lastSeenHash = destHash;
23941
+ entry.stagedPath = stagedSkillDir;
23942
+ entry.sourceHash = sourceHash;
23943
+ entry.packageVersion = packageVersion;
23944
+ entry.updatedAt = new Date().toISOString();
23945
+ staged.push(skill.name);
23946
+ customized.push(skill.name);
23947
+ skippedExisting.push(skill.name);
23948
+ log(`[skill-sync] Skill ${skill.name} is customized. Staged update at ${stagedSkillDir}`);
23949
+ } catch (err) {
23950
+ log(`[skill-sync] Failed to stage update for customized skill ${skill.name}:`, err);
23951
+ failed.push(skill.name);
23952
+ }
23953
+ }
23954
+ }
23955
+ } else if (entry.status === "customized") {
23956
+ if (destHash === sourceHash) {
23957
+ if (entry.stagedPath) {
23958
+ removeManagedStagedPath(entry.stagedPath, manifestDir, skill.name);
23959
+ }
23960
+ entry.status = "managed";
23961
+ entry.lastManagedHash = sourceHash;
23962
+ entry.lastSeenHash = sourceHash;
23963
+ entry.sourceHash = sourceHash;
23964
+ entry.packageVersion = packageVersion;
23965
+ delete entry.stagedPath;
23966
+ entry.updatedAt = new Date().toISOString();
23967
+ adopted.push(skill.name);
23968
+ skippedExisting.push(skill.name);
23969
+ log(`[skill-sync] Customized skill ${skill.name} converged with current version. Adopted back to managed.`);
23970
+ } else {
23971
+ entry.lastSeenHash = destHash;
23972
+ entry.updatedAt = new Date().toISOString();
23973
+ if (destHash !== sourceHash && entry.sourceHash !== sourceHash) {
23974
+ try {
23975
+ const stagedSkillDir = path10.join(manifestDir, "skill-updates", packageVersion, skill.name);
23976
+ if (entry.stagedPath && entry.stagedPath !== stagedSkillDir) {
23977
+ removeManagedStagedPath(entry.stagedPath, manifestDir, skill.name);
23978
+ }
23979
+ if (existsSync6(stagedSkillDir)) {
23980
+ rmSync4(stagedSkillDir, { recursive: true, force: true });
23981
+ }
23982
+ mkdirSync4(stagedSkillDir, { recursive: true });
23983
+ copyDirRecursive(sourcePath, stagedSkillDir);
23984
+ entry.stagedPath = stagedSkillDir;
23985
+ entry.sourceHash = sourceHash;
23986
+ entry.packageVersion = packageVersion;
23987
+ staged.push(skill.name);
23988
+ customized.push(skill.name);
23989
+ skippedExisting.push(skill.name);
23990
+ log(`[skill-sync] Staged new update for customized skill ${skill.name} at ${stagedSkillDir}`);
23991
+ } catch (err) {
23992
+ log(`[skill-sync] Failed to stage update for customized skill ${skill.name}:`, err);
23993
+ failed.push(skill.name);
23994
+ }
23995
+ } else {
23996
+ customized.push(skill.name);
23997
+ skippedExisting.push(skill.name);
23998
+ }
23999
+ }
24000
+ } else if (entry.status === "deleted") {
24001
+ if (destHash === sourceHash) {
24002
+ entry.status = "managed";
24003
+ entry.packageVersion = packageVersion;
24004
+ entry.sourceHash = sourceHash;
24005
+ entry.lastManagedHash = sourceHash;
24006
+ entry.lastSeenHash = sourceHash;
24007
+ entry.updatedAt = new Date().toISOString();
24008
+ skippedExisting.push(skill.name);
24009
+ adopted.push(skill.name);
24010
+ log(`[skill-sync] Skill ${skill.name} re-created by user (matching current). Adopted as managed.`);
24011
+ } else {
24012
+ try {
24013
+ const stagedSkillDir = path10.join(manifestDir, "skill-updates", packageVersion, skill.name);
24014
+ if (entry.stagedPath && entry.stagedPath !== stagedSkillDir) {
24015
+ removeManagedStagedPath(entry.stagedPath, manifestDir, skill.name);
24016
+ }
24017
+ if (existsSync6(stagedSkillDir)) {
24018
+ rmSync4(stagedSkillDir, { recursive: true, force: true });
24019
+ }
24020
+ mkdirSync4(stagedSkillDir, { recursive: true });
24021
+ copyDirRecursive(sourcePath, stagedSkillDir);
24022
+ entry.status = "customized";
24023
+ entry.packageVersion = packageVersion;
24024
+ entry.sourceHash = sourceHash;
24025
+ entry.lastManagedHash = sourceHash;
24026
+ entry.lastSeenHash = destHash;
24027
+ entry.stagedPath = stagedSkillDir;
24028
+ entry.updatedAt = new Date().toISOString();
24029
+ staged.push(skill.name);
24030
+ customized.push(skill.name);
24031
+ skippedExisting.push(skill.name);
24032
+ log(`[skill-sync] Skill ${skill.name} re-created by user (custom). Marked customized and staged.`);
24033
+ } catch (err) {
24034
+ log(`[skill-sync] Failed to stage update for deleted/recreated skill ${skill.name}:`, err);
24035
+ failed.push(skill.name);
24036
+ }
24037
+ }
24038
+ } else if (entry.status === "conflict") {
24039
+ if (destHash === sourceHash) {
24040
+ if (entry.stagedPath) {
24041
+ removeManagedStagedPath(entry.stagedPath, manifestDir, skill.name);
24042
+ }
24043
+ entry.status = "managed";
24044
+ entry.packageVersion = packageVersion;
24045
+ entry.sourceHash = sourceHash;
24046
+ entry.lastManagedHash = sourceHash;
24047
+ entry.lastSeenHash = sourceHash;
24048
+ delete entry.stagedPath;
24049
+ entry.updatedAt = new Date().toISOString();
24050
+ adopted.push(skill.name);
24051
+ } else {
24052
+ try {
24053
+ const stagedSkillDir = path10.join(manifestDir, "skill-updates", packageVersion, skill.name);
24054
+ if (entry.stagedPath && entry.stagedPath !== stagedSkillDir) {
24055
+ removeManagedStagedPath(entry.stagedPath, manifestDir, skill.name);
24056
+ }
24057
+ if (existsSync6(stagedSkillDir)) {
24058
+ rmSync4(stagedSkillDir, { recursive: true, force: true });
24059
+ }
24060
+ mkdirSync4(stagedSkillDir, { recursive: true });
24061
+ copyDirRecursive(sourcePath, stagedSkillDir);
24062
+ entry.status = "customized";
24063
+ entry.packageVersion = packageVersion;
24064
+ entry.sourceHash = sourceHash;
24065
+ entry.lastManagedHash = sourceHash;
24066
+ entry.lastSeenHash = destHash;
24067
+ entry.stagedPath = stagedSkillDir;
24068
+ entry.updatedAt = new Date().toISOString();
24069
+ staged.push(skill.name);
24070
+ customized.push(skill.name);
24071
+ skippedExisting.push(skill.name);
24072
+ log(`[skill-sync] Conflicted skill ${skill.name} recovered as customized and staged at ${stagedSkillDir}`);
24073
+ } catch (err) {
24074
+ log(`[skill-sync] Failed to stage update for conflicted skill ${skill.name}:`, err);
24075
+ failed.push(skill.name);
24076
+ }
24077
+ }
24078
+ }
24079
+ } else {
24080
+ if (destHash === sourceHash) {
24081
+ manifest.skills[skill.name] = {
24082
+ status: "managed",
24083
+ packageVersion,
24084
+ sourceHash,
24085
+ lastManagedHash: sourceHash,
24086
+ lastSeenHash: sourceHash,
24087
+ updatedAt: new Date().toISOString()
24088
+ };
24089
+ skippedExisting.push(skill.name);
24090
+ adopted.push(skill.name);
24091
+ log(`[skill-sync] Adopted existing matching skill: ${skill.name}`);
24092
+ } else if (LEGACY_MANAGED_SKILL_HASHES[skill.name]?.includes(destHash)) {
24093
+ try {
24094
+ atomicReplaceDir(sourcePath, destPath);
24095
+ installed.push(skill.name);
24096
+ manifest.skills[skill.name] = {
24097
+ status: "managed",
24098
+ packageVersion,
24099
+ sourceHash,
24100
+ lastManagedHash: sourceHash,
24101
+ lastSeenHash: sourceHash,
24102
+ updatedAt: new Date().toISOString()
24103
+ };
24104
+ log(`[skill-sync] Adopted and updated legacy skill: ${skill.name}`);
24105
+ } catch (err) {
24106
+ log(`[skill-sync] Failed to update legacy skill ${skill.name}:`, err);
24107
+ failed.push(skill.name);
24108
+ }
24109
+ } else {
24110
+ try {
24111
+ const stagedSkillDir = path10.join(manifestDir, "skill-updates", packageVersion, skill.name);
24112
+ if (existsSync6(stagedSkillDir)) {
24113
+ rmSync4(stagedSkillDir, { recursive: true, force: true });
24114
+ }
24115
+ mkdirSync4(stagedSkillDir, { recursive: true });
24116
+ copyDirRecursive(sourcePath, stagedSkillDir);
24117
+ manifest.skills[skill.name] = {
24118
+ status: "customized",
24119
+ packageVersion,
24120
+ sourceHash,
24121
+ lastManagedHash: "",
24122
+ lastSeenHash: destHash,
24123
+ stagedPath: stagedSkillDir,
24124
+ updatedAt: new Date().toISOString()
24125
+ };
24126
+ staged.push(skill.name);
24127
+ customized.push(skill.name);
24128
+ skippedExisting.push(skill.name);
24129
+ log(`[skill-sync] Skill ${skill.name} is customized (no manifest entry). Staged update at ${stagedSkillDir}`);
24130
+ } catch (err) {
24131
+ log(`[skill-sync] Failed to stage update for customized skill ${skill.name}:`, err);
24132
+ failed.push(skill.name);
24133
+ }
24134
+ }
24135
+ }
24136
+ } catch (err) {
24137
+ log(`[skill-sync] Failed processing skill ${skill.name}:`, err);
24138
+ failed.push(skill.name);
24139
+ }
24140
+ }
24141
+ let manifestWriteFailed = false;
24142
+ manifest.updatedAt = new Date().toISOString();
24143
+ const tempManifestPath = `${manifestPath}.${Math.random().toString(36).slice(2, 9)}.tmp`;
24144
+ try {
24145
+ writeFileSync5(tempManifestPath, JSON.stringify(manifest, null, 2), "utf-8");
24146
+ renameSync3(tempManifestPath, manifestPath);
24147
+ } catch (err) {
24148
+ log("[skill-sync] Failed to write skills manifest atomically:", err);
24149
+ manifestWriteFailed = true;
24150
+ try {
24151
+ if (existsSync6(tempManifestPath)) {
24152
+ unlinkSync2(tempManifestPath);
24153
+ }
24154
+ } catch {}
24155
+ }
24156
+ if (manifestWriteFailed) {
24157
+ failed.push("__manifest__");
24158
+ }
24159
+ } finally {
24160
+ releaseLock(lockDir);
24161
+ }
24162
+ return {
24163
+ installed,
24164
+ skippedExisting,
24165
+ failed,
24166
+ staged,
24167
+ adopted,
24168
+ customized
24169
+ };
24170
+ }
24171
+
24172
+ // src/hooks/auto-update-checker/index.ts
24173
+ function createAutoUpdateCheckerHook(ctx, options = {}) {
24174
+ const { autoUpdate = true, companion } = options;
24175
+ let hasChecked = false;
24176
+ return {
24177
+ event: ({ event }) => {
24178
+ if (event.type !== "session.created")
24179
+ return;
24180
+ if (hasChecked)
24181
+ return;
24182
+ const props = event.properties;
24183
+ if (props?.info?.parentID)
24184
+ return;
24185
+ hasChecked = true;
24186
+ setTimeout(async () => {
24187
+ const localDevVersion = getLocalDevVersion(ctx.directory);
24188
+ if (localDevVersion) {
24189
+ log("[auto-update-checker] Local development mode");
24190
+ return;
24191
+ }
24192
+ runBackgroundUpdateCheck(ctx, autoUpdate, companion).catch((err) => {
24193
+ log("[auto-update-checker] Background update check failed:", err);
24194
+ });
24195
+ }, 0);
24196
+ }
24197
+ };
24198
+ }
24199
+ var hasReconciledAtStartup = false;
24200
+ async function runBackgroundUpdateCheck(ctx, autoUpdate, companion) {
24201
+ if (!hasReconciledAtStartup) {
24202
+ try {
24203
+ const runtimePackageJsonPath = getCurrentRuntimePackageJsonPath();
24204
+ if (runtimePackageJsonPath) {
24205
+ hasReconciledAtStartup = true;
24206
+ const packageRoot = path11.dirname(runtimePackageJsonPath);
24207
+ log("[auto-update-checker] Running startup skill reconciliation");
24208
+ const syncResult = syncBundledSkillsFromPackage(packageRoot);
24209
+ if (syncResult.installed.length > 0) {
24210
+ log(`[auto-update-checker] Startup skill sync installed: ${syncResult.installed.join(", ")}`);
24211
+ }
24212
+ if (syncResult.failed.length > 0) {
24213
+ log(`[auto-update-checker] Startup skill sync failures: ${syncResult.failed.join(", ")}`);
24214
+ }
24215
+ if (syncResult.staged.length > 0) {
24216
+ log(`[auto-update-checker] Startup skill sync staged: ${syncResult.staged.join(", ")}`);
24217
+ }
24218
+ if (syncResult.customized.length > 0) {
24219
+ log(`[auto-update-checker] Startup skill sync customized: ${syncResult.customized.join(", ")}`);
24220
+ }
24221
+ } else {
24222
+ log("[auto-update-checker] Could not resolve runtime package path for startup skill reconciliation");
24223
+ }
24224
+ } catch (err) {
24225
+ log("[auto-update-checker] Startup skill reconciliation failed:", err);
24226
+ }
24227
+ }
24228
+ const pluginInfo = findPluginEntry(ctx.directory);
24229
+ if (!pluginInfo) {
24230
+ log("[auto-update-checker] Plugin not found in config");
24231
+ return;
24232
+ }
24233
+ const cachedVersion = getCachedVersion();
24234
+ const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion;
24235
+ if (!currentVersion) {
24236
+ log("[auto-update-checker] No version found (cached or pinned)");
24237
+ return;
24238
+ }
24239
+ const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion);
24240
+ const latestInfo = await getLatestCompatibleVersion(currentVersion, channel);
23349
24241
  if (latestInfo.unsafeReason === "unparseable-current-version") {
23350
24242
  log(`[auto-update-checker] Current version is not semver; skipping auto-update: ${currentVersion}`);
23351
24243
  if (latestInfo.latestMajorVersion) {
@@ -23388,12 +24280,16 @@ Version is pinned. Update your plugin config to apply.`, "info", 8000);
23388
24280
  const installSuccess = await runBunInstallSafe(installDir);
23389
24281
  if (installSuccess) {
23390
24282
  let installedSkills = [];
24283
+ let stagedSkills = [];
24284
+ let customizedSkills = [];
23391
24285
  let companionUpdated = false;
23392
24286
  let companionWillRetry = false;
23393
24287
  const packageRoot = path11.join(installDir, "node_modules", PACKAGE_NAME);
23394
24288
  try {
23395
24289
  const syncResult = syncBundledSkillsFromPackage(packageRoot);
23396
24290
  installedSkills = syncResult.installed;
24291
+ stagedSkills = syncResult.staged;
24292
+ customizedSkills = syncResult.customized;
23397
24293
  if (syncResult.failed.length > 0) {
23398
24294
  log(`[auto-update-checker] Skill sync warnings/failures: ${syncResult.failed.join(", ")}`);
23399
24295
  }
@@ -23427,6 +24323,12 @@ Version is pinned. Update your plugin config to apply.`, "info", 8000);
23427
24323
  if (installedSkills.length > 0) {
23428
24324
  messageLines.push(`Added bundled skills: ${installedSkills.join(", ")}`);
23429
24325
  }
24326
+ if (stagedSkills.length > 0) {
24327
+ messageLines.push(`Staged skill updates: ${stagedSkills.join(", ")}`);
24328
+ }
24329
+ if (customizedSkills.length > 0) {
24330
+ messageLines.push(`Customized skills: ${customizedSkills.join(", ")}`);
24331
+ }
23430
24332
  if (companionUpdated) {
23431
24333
  messageLines.push("Companion updated.");
23432
24334
  } else if (companionWillRetry) {
@@ -23452,7 +24354,7 @@ async function runBunInstallSafe(installDir) {
23452
24354
  stdout: "pipe",
23453
24355
  stderr: "pipe"
23454
24356
  });
23455
- const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve("timeout"), 60000));
24357
+ const timeoutPromise = new Promise((resolve2) => setTimeout(() => resolve2("timeout"), 60000));
23456
24358
  const exitPromise = proc.exited.then(() => "completed");
23457
24359
  const result = await Promise.race([exitPromise, timeoutPromise]);
23458
24360
  if (result === "timeout") {
@@ -23617,7 +24519,7 @@ var AGENT_PREFIX = {
23617
24519
  class BackgroundJobBoard {
23618
24520
  jobs = new Map;
23619
24521
  counters = new Map;
23620
- terminalStateListener;
24522
+ terminalStateListeners = [];
23621
24523
  maxReusablePerAgent;
23622
24524
  readContextMinLines;
23623
24525
  readContextMaxFiles;
@@ -23626,8 +24528,19 @@ class BackgroundJobBoard {
23626
24528
  this.readContextMinLines = options.readContextMinLines ?? 10;
23627
24529
  this.readContextMaxFiles = options.readContextMaxFiles ?? 8;
23628
24530
  }
24531
+ addTerminalStateListener(listener) {
24532
+ this.terminalStateListeners.push(listener);
24533
+ }
24534
+ removeTerminalStateListener(listener) {
24535
+ this.terminalStateListeners = this.terminalStateListeners.filter((entry) => entry !== listener);
24536
+ }
23629
24537
  setTerminalStateListener(listener) {
23630
- this.terminalStateListener = listener;
24538
+ this.terminalStateListeners = listener ? [listener] : [];
24539
+ }
24540
+ notifyTerminalStateListeners(taskID) {
24541
+ for (const listener of this.terminalStateListeners) {
24542
+ listener(taskID);
24543
+ }
23631
24544
  }
23632
24545
  registerLaunch(input) {
23633
24546
  const now = input.now ?? Date.now();
@@ -23640,6 +24553,7 @@ class BackgroundJobBoard {
23640
24553
  objective: input.objective ?? existing.objective,
23641
24554
  state: "running",
23642
24555
  timedOut: false,
24556
+ recoverableAfterLiveBusy: false,
23643
24557
  statusUncertain: false,
23644
24558
  cancellationRequested: false,
23645
24559
  terminalUnreconciled: false,
@@ -23650,7 +24564,9 @@ class BackgroundJobBoard {
23650
24564
  lastLaunchedAt: now,
23651
24565
  lastLiveBusyAt: now,
23652
24566
  lastUsedAt: now,
23653
- updatedAt: now
24567
+ updatedAt: now,
24568
+ totalErrors: existing.totalErrors ?? 0,
24569
+ timeoutCount: existing.timeoutCount ?? 0
23654
24570
  };
23655
24571
  this.jobs.set(input.taskID, updated);
23656
24572
  return updated;
@@ -23663,6 +24579,7 @@ class BackgroundJobBoard {
23663
24579
  objective: input.objective,
23664
24580
  state: "running",
23665
24581
  timedOut: false,
24582
+ recoverableAfterLiveBusy: false,
23666
24583
  statusUncertain: false,
23667
24584
  cancellationRequested: false,
23668
24585
  terminalUnreconciled: false,
@@ -23672,7 +24589,9 @@ class BackgroundJobBoard {
23672
24589
  lastUsedAt: now,
23673
24590
  updatedAt: now,
23674
24591
  alias: this.nextAlias(input.parentSessionID, input.agent),
23675
- contextFiles: []
24592
+ contextFiles: [],
24593
+ totalErrors: 0,
24594
+ timeoutCount: 0
23676
24595
  };
23677
24596
  this.jobs.set(input.taskID, record);
23678
24597
  return record;
@@ -23691,6 +24610,7 @@ class BackgroundJobBoard {
23691
24610
  ...existing,
23692
24611
  state: input.state,
23693
24612
  timedOut: input.timedOut ?? false,
24613
+ recoverableAfterLiveBusy: input.state !== "running" ? false : input.timedOut === true ? false : existing.recoverableAfterLiveBusy,
23694
24614
  statusUncertain: input.statusUncertain ?? false,
23695
24615
  terminalUnreconciled: terminal ? true : existing.terminalUnreconciled,
23696
24616
  updatedAt: now,
@@ -23699,10 +24619,20 @@ class BackgroundJobBoard {
23699
24619
  resultSummary: input.resultSummary ?? existing.resultSummary,
23700
24620
  lastStatusError: input.lastStatusError
23701
24621
  };
24622
+ if (input.state === "completed") {
24623
+ updated.timeoutCount = 0;
24624
+ }
24625
+ if (input.state === "error") {
24626
+ updated.totalErrors = (existing.totalErrors ?? 0) + 1;
24627
+ updated.lastErrorAt = updated.updatedAt;
24628
+ }
24629
+ if (input.timedOut && input.state !== "completed") {
24630
+ updated.timeoutCount = (existing.timeoutCount ?? 0) + 1;
24631
+ }
23702
24632
  this.jobs.set(input.taskID, updated);
23703
24633
  this.trimReusable(input.taskID);
23704
24634
  if (notifyTerminal)
23705
- this.terminalStateListener?.(input.taskID);
24635
+ this.notifyTerminalStateListeners(input.taskID);
23706
24636
  return updated;
23707
24637
  }
23708
24638
  updateFromStatusOutput(output) {
@@ -23732,7 +24662,10 @@ class BackgroundJobBoard {
23732
24662
  const updated = {
23733
24663
  ...existing,
23734
24664
  updatedAt: now,
23735
- lastLiveBusyAt: now
24665
+ lastLiveBusyAt: now,
24666
+ timedOut: false,
24667
+ recoverableAfterLiveBusy: existing.recoverableAfterLiveBusy || existing.timedOut,
24668
+ statusUncertain: false
23736
24669
  };
23737
24670
  this.jobs.set(taskID, updated);
23738
24671
  return updated;
@@ -23773,6 +24706,7 @@ class BackgroundJobBoard {
23773
24706
  ...existing,
23774
24707
  state: "cancelled",
23775
24708
  timedOut: false,
24709
+ recoverableAfterLiveBusy: false,
23776
24710
  statusUncertain: false,
23777
24711
  cancellationRequested: true,
23778
24712
  terminalUnreconciled: true,
@@ -23784,12 +24718,35 @@ class BackgroundJobBoard {
23784
24718
  };
23785
24719
  this.jobs.set(taskID, updated);
23786
24720
  if (notifyTerminal)
23787
- this.terminalStateListener?.(taskID);
24721
+ this.notifyTerminalStateListeners(taskID);
23788
24722
  return updated;
23789
24723
  }
23790
24724
  get(taskID) {
23791
24725
  return this.jobs.get(taskID);
23792
24726
  }
24727
+ field(taskID, key) {
24728
+ return this.get(taskID)?.[key];
24729
+ }
24730
+ isRunning(taskID) {
24731
+ const job = this.get(taskID);
24732
+ return job?.state === "running";
24733
+ }
24734
+ isTerminalUnreconciled(taskID) {
24735
+ const job = this.get(taskID);
24736
+ return !!job?.terminalUnreconciled;
24737
+ }
24738
+ getResultSummary(taskID) {
24739
+ return this.field(taskID, "resultSummary");
24740
+ }
24741
+ getLastLiveBusyAt(taskID) {
24742
+ return this.field(taskID, "lastLiveBusyAt");
24743
+ }
24744
+ getParentSessionID(taskID) {
24745
+ return this.field(taskID, "parentSessionID");
24746
+ }
24747
+ getState(taskID) {
24748
+ return this.field(taskID, "state");
24749
+ }
23793
24750
  resolve(parentSessionID, taskIDOrAlias) {
23794
24751
  const value = taskIDOrAlias.trim();
23795
24752
  return this.list(parentSessionID).find((job) => job.taskID === value || job.alias === value);
@@ -23802,6 +24759,17 @@ class BackgroundJobBoard {
23802
24759
  return;
23803
24760
  return job;
23804
24761
  }
24762
+ resolveRecoverable(parentSessionID, taskIDOrAlias, agent) {
24763
+ const job = this.resolve(parentSessionID, taskIDOrAlias);
24764
+ if (!job)
24765
+ return;
24766
+ if (agent && job.agent !== agent)
24767
+ return;
24768
+ if (job.state !== "running" || !job.recoverableAfterLiveBusy) {
24769
+ return;
24770
+ }
24771
+ return job;
24772
+ }
23805
24773
  markUsed(parentSessionID, key, now = Date.now()) {
23806
24774
  const job = this.resolve(parentSessionID, key);
23807
24775
  if (!job)
@@ -23821,8 +24789,11 @@ class BackgroundJobBoard {
23821
24789
  for (const file of files) {
23822
24790
  const previous = existing.get(file.path);
23823
24791
  if (previous) {
23824
- previous.lineCount = Math.max(previous.lineCount, file.lineCount);
23825
- previous.lastReadAt = Math.max(previous.lastReadAt, file.lastReadAt);
24792
+ existing.set(file.path, {
24793
+ ...previous,
24794
+ lineCount: Math.max(previous.lineCount, file.lineCount),
24795
+ lastReadAt: Math.max(previous.lastReadAt, file.lastReadAt)
24796
+ });
23826
24797
  } else {
23827
24798
  existing.set(file.path, { ...file });
23828
24799
  }
@@ -23841,6 +24812,14 @@ class BackgroundJobBoard {
23841
24812
  hasTerminalUnreconciled(parentSessionID) {
23842
24813
  return this.list(parentSessionID).some((job) => job.terminalUnreconciled);
23843
24814
  }
24815
+ hasConvergenceSignals(taskID, threshold = 3) {
24816
+ const job = this.jobs.get(taskID);
24817
+ if (!job)
24818
+ return false;
24819
+ const errors = job.totalErrors ?? 0;
24820
+ const timeouts = job.timeoutCount ?? 0;
24821
+ return errors >= threshold || timeouts >= threshold;
24822
+ }
23844
24823
  formatForPrompt(parentSessionID, now = Date.now()) {
23845
24824
  const active = this.list(parentSessionID).filter((job) => job.state === "running" || job.terminalUnreconciled);
23846
24825
  const reusable = this.list(parentSessionID).filter(isReusable);
@@ -23849,7 +24828,10 @@ class BackgroundJobBoard {
23849
24828
  return [
23850
24829
  "### Background Job Board",
23851
24830
  "SENTINEL: background-job-board-v2",
23852
- "Do not poll running jobs. Wait for hook-driven completion, or use cancel_task only for explicit cancellation. Reconcile terminal jobs before final response. Reuse only completed sessions for the same specialist/context; never reuse cancelled or errored sessions.",
24831
+ "Do not poll running jobs. Wait for hook-driven completion, or use cancel_task only for explicit cancellation. Reconcile terminal jobs before final response.",
24832
+ "Completed or reconciled sessions are reusable by alias for the same specialist/context.",
24833
+ "Timed-out running sessions are recoverable by alias for safe resume after a live busy signal.",
24834
+ "Cancelled or errored sessions are not reusable.",
23853
24835
  "",
23854
24836
  "#### Active / Unreconciled",
23855
24837
  ...active.length > 0 ? active.map((job) => formatJob(job, now)) : ["- none"],
@@ -24180,13 +25162,25 @@ ${buildRetryGuidance(detected)}`;
24180
25162
  }
24181
25163
  };
24182
25164
  }
25165
+ // src/hooks/types.ts
25166
+ function isMessageWithParts(message) {
25167
+ if (!message || typeof message !== "object") {
25168
+ return false;
25169
+ }
25170
+ const candidate = message;
25171
+ return !!candidate.info && typeof candidate.info === "object" && typeof candidate.info.role === "string" && Array.isArray(candidate.parts);
25172
+ }
25173
+ function isUserMessageWithParts(message) {
25174
+ return isMessageWithParts(message) && message.info.role === "user";
25175
+ }
25176
+
24183
25177
  // src/hooks/filter-available-skills/index.ts
24184
25178
  var AVAILABLE_SKILLS_BLOCK_REGEX = /<available_skills>\s*([\s\S]*?)\s*<\/available_skills>/g;
24185
25179
  var SKILL_NAME_REGEX = /<name>([^<]+)<\/name>/;
24186
25180
  function getCurrentAgent(messages) {
24187
25181
  for (let index = messages.length - 1;index >= 0; index -= 1) {
24188
25182
  const message = messages[index];
24189
- if (message.info.role === "user") {
25183
+ if (isUserMessageWithParts(message)) {
24190
25184
  return message.info.agent ?? "orchestrator";
24191
25185
  }
24192
25186
  }
@@ -24243,7 +25237,7 @@ function createFilterAvailableSkillsHook(_ctx, config) {
24243
25237
  };
24244
25238
  return {
24245
25239
  "experimental.chat.messages.transform": async (_input, output) => {
24246
- const { messages } = output;
25240
+ const messages = (Array.isArray(output.messages) ? output.messages : []).filter(isMessageWithParts);
24247
25241
  if (messages.length === 0) {
24248
25242
  return;
24249
25243
  }
@@ -24274,7 +25268,11 @@ var RATE_LIMIT_PATTERNS = [
24274
25268
  /resource.?exhausted/i,
24275
25269
  /insufficient.?(quota|balance)/i,
24276
25270
  /high concurrency/i,
24277
- /reduce concurrency/i
25271
+ /reduce concurrency/i,
25272
+ /service unavailable/i,
25273
+ /monthly usage limit/i,
25274
+ /5-hour usage limit/i,
25275
+ /weekly usage limit/i
24278
25276
  ];
24279
25277
  function isRateLimitError(error) {
24280
25278
  if (!error || typeof error !== "object")
@@ -24300,6 +25298,7 @@ class ForegroundFallbackManager {
24300
25298
  sessionTried = new Map;
24301
25299
  inProgress = new Set;
24302
25300
  lastTrigger = new Map;
25301
+ lastTriggerModel = new Map;
24303
25302
  constructor(client, chains, enabled) {
24304
25303
  this.client = client;
24305
25304
  this.chains = chains;
@@ -24339,9 +25338,9 @@ class ForegroundFallbackManager {
24339
25338
  }
24340
25339
  case "session.status": {
24341
25340
  const props = event.properties;
24342
- if (!props?.sessionID || props.status?.type !== "retry")
25341
+ if (!props?.sessionID || !props.status?.message)
24343
25342
  break;
24344
- const msg = props.status.message?.toLowerCase() ?? "";
25343
+ const msg = props.status.message.toLowerCase();
24345
25344
  if (msg.includes("rate limit") || msg.includes("usage limit") || msg.includes("usage exceeded") || msg.includes("quota exceeded") || msg.includes("exceededbudget") || msg.includes("over budget") || msg.includes("insufficient") || msg.includes("high concurrency") || msg.includes("reduce concurrency")) {
24346
25345
  await this.tryFallback(props.sessionID);
24347
25346
  }
@@ -24363,6 +25362,7 @@ class ForegroundFallbackManager {
24363
25362
  this.sessionTried.delete(id);
24364
25363
  this.inProgress.delete(id);
24365
25364
  this.lastTrigger.delete(id);
25365
+ this.lastTriggerModel.delete(id);
24366
25366
  }
24367
25367
  break;
24368
25368
  }
@@ -24374,12 +25374,17 @@ class ForegroundFallbackManager {
24374
25374
  if (this.inProgress.has(sessionID))
24375
25375
  return;
24376
25376
  const now = Date.now();
24377
- if (now - (this.lastTrigger.get(sessionID) ?? 0) < DEDUP_WINDOW_MS)
25377
+ const curModel = this.sessionModel.get(sessionID);
25378
+ const modelChanged = this.lastTriggerModel.has(sessionID) && this.lastTriggerModel.get(sessionID) !== curModel;
25379
+ if (!modelChanged && now - (this.lastTrigger.get(sessionID) ?? 0) < DEDUP_WINDOW_MS)
24378
25380
  return;
24379
25381
  this.lastTrigger.set(sessionID, now);
25382
+ if (curModel !== undefined) {
25383
+ this.lastTriggerModel.set(sessionID, curModel);
25384
+ }
24380
25385
  this.inProgress.add(sessionID);
24381
25386
  try {
24382
- const currentModel = this.sessionModel.get(sessionID);
25387
+ let currentModel = this.sessionModel.get(sessionID);
24383
25388
  const agentName = this.sessionAgent.get(sessionID);
24384
25389
  const chain = this.resolveChain(agentName, currentModel);
24385
25390
  if (!chain.length) {
@@ -24389,20 +25394,42 @@ class ForegroundFallbackManager {
24389
25394
  });
24390
25395
  return;
24391
25396
  }
25397
+ if (!currentModel && agentName && chain.length > 0) {
25398
+ currentModel = chain[0];
25399
+ }
24392
25400
  if (!this.sessionTried.has(sessionID)) {
24393
25401
  this.sessionTried.set(sessionID, new Set);
24394
25402
  }
24395
- const tried = this.sessionTried.get(sessionID);
25403
+ let tried = this.sessionTried.get(sessionID);
24396
25404
  if (currentModel)
24397
25405
  tried.add(currentModel);
24398
- const nextModel = chain.find((m) => !tried.has(m));
25406
+ let nextModel = chain.find((m) => !tried.has(m));
24399
25407
  if (!nextModel) {
24400
- log("[foreground-fallback] fallback chain exhausted", {
24401
- sessionID,
24402
- agentName,
24403
- tried: [...tried]
24404
- });
24405
- return;
25408
+ if (chain.length > 1) {
25409
+ const primary = chain[0];
25410
+ const stickyFallback = chain[chain.length - 1];
25411
+ log("[foreground-fallback] resetting tried set for re-fallback", {
25412
+ sessionID,
25413
+ agentName,
25414
+ currentModel,
25415
+ prevTried: [...tried],
25416
+ nextModel: stickyFallback
25417
+ });
25418
+ tried = new Set;
25419
+ if (primary)
25420
+ tried.add(primary);
25421
+ if (currentModel && currentModel !== primary)
25422
+ tried.add(currentModel);
25423
+ this.sessionTried.set(sessionID, tried);
25424
+ nextModel = stickyFallback;
25425
+ } else {
25426
+ log("[foreground-fallback] fallback chain exhausted", {
25427
+ sessionID,
25428
+ agentName,
25429
+ tried: [...tried]
25430
+ });
25431
+ return;
25432
+ }
24406
25433
  }
24407
25434
  tried.add(nextModel);
24408
25435
  const ref = parseModelReference(nextModel);
@@ -24417,7 +25444,7 @@ class ForegroundFallbackManager {
24417
25444
  path: { id: sessionID }
24418
25445
  });
24419
25446
  const messages = result.data ?? [];
24420
- const lastUser = [...messages].reverse().find((m) => m.info.role === "user");
25447
+ const lastUser = [...messages].reverse().find(isUserMessageWithParts);
24421
25448
  if (!lastUser) {
24422
25449
  log("[foreground-fallback] no user message found", { sessionID });
24423
25450
  return;
@@ -24458,7 +25485,11 @@ class ForegroundFallbackManager {
24458
25485
  }
24459
25486
  resolveChain(agentName, currentModel) {
24460
25487
  if (agentName) {
24461
- return this.chains[agentName] ?? [];
25488
+ const chain = this.chains[agentName];
25489
+ if (chain)
25490
+ return chain;
25491
+ if (ALL_AGENT_NAMES.includes(agentName))
25492
+ return [];
24462
25493
  }
24463
25494
  if (currentModel) {
24464
25495
  for (const chain of Object.values(this.chains)) {
@@ -24480,17 +25511,17 @@ class ForegroundFallbackManager {
24480
25511
  }
24481
25512
  }
24482
25513
  // src/hooks/image-hook.ts
24483
- import { createHash as createHash2 } from "node:crypto";
25514
+ import { createHash as createHash3 } from "node:crypto";
24484
25515
  import {
24485
25516
  existsSync as existsSync7,
24486
25517
  mkdirSync as mkdirSync5,
24487
25518
  readdirSync as readdirSync3,
24488
25519
  rmdirSync,
24489
25520
  statSync as statSync4,
24490
- unlinkSync as unlinkSync2,
24491
- writeFileSync as writeFileSync5
25521
+ unlinkSync as unlinkSync3,
25522
+ writeFileSync as writeFileSync6
24492
25523
  } from "node:fs";
24493
- import { basename as basename2, extname, join as join11 } from "node:path";
25524
+ import { basename as basename3, extname, join as join11 } from "node:path";
24494
25525
  var lastCleanupByDir = new Map;
24495
25526
  var CLEANUP_INTERVAL = 10 * 60 * 1000;
24496
25527
  function isImagePart(p) {
@@ -24544,7 +25575,7 @@ function cleanupAllSessions(saveDir) {
24544
25575
  } else {
24545
25576
  try {
24546
25577
  if (now - statSync4(fp).mtimeMs > maxAge)
24547
- unlinkSync2(fp);
25578
+ unlinkSync3(fp);
24548
25579
  } catch {}
24549
25580
  }
24550
25581
  }
@@ -24558,7 +25589,7 @@ function cleanupAllSessions(saveDir) {
24558
25589
  const fp = join11(dir, f);
24559
25590
  try {
24560
25591
  if (now - statSync4(fp).mtimeMs > maxAge) {
24561
- unlinkSync2(fp);
25592
+ unlinkSync3(fp);
24562
25593
  } else {
24563
25594
  allRemoved = false;
24564
25595
  }
@@ -24576,7 +25607,7 @@ function cleanupAllSessions(saveDir) {
24576
25607
  }
24577
25608
  function writeUniqueFile(dir, name, data, log2) {
24578
25609
  const ext = extname(name);
24579
- const base = basename2(name, ext) || name;
25610
+ const base = basename3(name, ext) || name;
24580
25611
  let candidate = join11(dir, name);
24581
25612
  if (existsSync7(candidate)) {
24582
25613
  return candidate;
@@ -24585,7 +25616,7 @@ function writeUniqueFile(dir, name, data, log2) {
24585
25616
  const MAX_ATTEMPTS = 1000;
24586
25617
  for (let attempt = 0;attempt < MAX_ATTEMPTS; attempt++) {
24587
25618
  try {
24588
- writeFileSync5(candidate, data, { flag: "wx" });
25619
+ writeFileSync6(candidate, data, { flag: "wx" });
24589
25620
  return candidate;
24590
25621
  } catch (e) {
24591
25622
  if (e instanceof Error && e.code === "EEXIST") {
@@ -24607,7 +25638,7 @@ function processImageAttachments(args) {
24607
25638
  return;
24608
25639
  const messagesWithImages = [];
24609
25640
  for (const msg of messages) {
24610
- if (msg.info.role !== "user")
25641
+ if (!isUserMessageWithParts(msg))
24611
25642
  continue;
24612
25643
  const imageParts = msg.parts.filter(isImagePart);
24613
25644
  if (imageParts.length > 0) {
@@ -24624,7 +25655,7 @@ function processImageAttachments(args) {
24624
25655
  try {
24625
25656
  mkdirSync5(saveDir, { recursive: true });
24626
25657
  if (!existsSync7(gitignorePath))
24627
- writeFileSync5(gitignorePath, `*
25658
+ writeFileSync6(gitignorePath, `*
24628
25659
  `);
24629
25660
  } catch (e) {
24630
25661
  log2(`[image-hook] failed to create image directory: ${e}`);
@@ -24645,7 +25676,7 @@ function processImageAttachments(args) {
24645
25676
  if (url) {
24646
25677
  const decoded = decodeDataUrl(url);
24647
25678
  if (decoded) {
24648
- const hash = createHash2("sha1").update(decoded.data).digest("hex").slice(0, 8);
25679
+ const hash = createHash3("sha1").update(decoded.data).digest("hex").slice(0, 8);
24649
25680
  const sanitizedFilename = filename ? sanitizeFilename(filename) : undefined;
24650
25681
  const baseName = sanitizedFilename ? sanitizedFilename.replace(/\.[^.]+$/, "") || "image" : "image";
24651
25682
  const ext = sanitizedFilename ? extname(sanitizedFilename) || extFromMime(decoded.mime) : extFromMime(decoded.mime);
@@ -24717,17 +25748,84 @@ ${JSON_ERROR_REMINDER}`;
24717
25748
  }
24718
25749
  };
24719
25750
  }
25751
+ // src/hooks/loop-command/index.ts
25752
+ var COMMAND_NAME2 = "loop";
25753
+ function historyDir() {
25754
+ const shortID = Math.random().toString(36).slice(2, 8);
25755
+ const timestamp = Date.now().toString(36);
25756
+ return `.opencode/loop-history/loop-${timestamp}-${shortID}`;
25757
+ }
25758
+ function activationPrompt2(text) {
25759
+ const dir = historyDir();
25760
+ return [
25761
+ "The user ran `/loop`. From the text below, extract: goal, successCriteria, maxAttempts.",
25762
+ "",
25763
+ "If ANY are missing or unclear - push back and ask the user to clarify.",
25764
+ "Do not assume or guess. All three must be explicit.",
25765
+ "",
25766
+ "Once all three are clear, run the loop:",
25767
+ "",
25768
+ text,
25769
+ "",
25770
+ "For each attempt:",
25771
+ `1. Read \`${dir}/\` for prior results`,
25772
+ "2. Dispatch @fixer with the goal",
25773
+ "3. Verify per the successCriteria",
25774
+ `4. Write result to \`${dir}/history-{NNN}.md\` (PASS/FAIL + reason)`,
25775
+ "5. PASS -> stop. FAIL under maxAttempts -> retry. FAIL at max -> escalate."
25776
+ ].join(`
25777
+ `);
25778
+ }
25779
+ function helpPrompt() {
25780
+ return [
25781
+ "Usage: `/loop <description>`",
25782
+ "",
25783
+ "Describe what to accomplish, what success looks like, and how many tries.",
25784
+ "",
25785
+ "Examples:",
25786
+ " `/loop fix typescript errors until typecheck passes, max 3 tries`",
25787
+ " `/loop improve api performance until response under 500ms, try 5 times`",
25788
+ " `/loop refactor auth module, tests must pass, 4 attempts max`"
25789
+ ].join(`
25790
+ `);
25791
+ }
25792
+ function createLoopCommandHook() {
25793
+ return {
25794
+ registerCommand: (opencodeConfig) => {
25795
+ const cfg = opencodeConfig.command;
25796
+ if (cfg?.[COMMAND_NAME2])
25797
+ return;
25798
+ if (!opencodeConfig.command)
25799
+ opencodeConfig.command = {};
25800
+ opencodeConfig.command[COMMAND_NAME2] = {
25801
+ template: "Run an automated execute-verify loop",
25802
+ description: "Dispatch fixer, verify, iterate with file-based history on disk."
25803
+ };
25804
+ },
25805
+ handleCommandExecuteBefore: async (input, output) => {
25806
+ if (input.command !== COMMAND_NAME2)
25807
+ return;
25808
+ output.parts.length = 0;
25809
+ const args = input.arguments.trim();
25810
+ if (!args) {
25811
+ output.parts.push(createInternalAgentTextPart(helpPrompt()));
25812
+ return;
25813
+ }
25814
+ output.parts.push({ type: "text", text: activationPrompt2(args) });
25815
+ }
25816
+ };
25817
+ }
24720
25818
  // src/hooks/phase-reminder/index.ts
24721
25819
  function createPhaseReminderHook() {
24722
25820
  return {
24723
25821
  "experimental.chat.messages.transform": async (_input, output) => {
24724
- const { messages } = output;
25822
+ const messages = Array.isArray(output.messages) ? output.messages : [];
24725
25823
  if (messages.length === 0) {
24726
25824
  return;
24727
25825
  }
24728
25826
  let lastUserMessageIndex = -1;
24729
25827
  for (let i = messages.length - 1;i >= 0; i--) {
24730
- if (messages[i].info.role === "user") {
25828
+ if (isUserMessageWithParts(messages[i])) {
24731
25829
  lastUserMessageIndex = i;
24732
25830
  break;
24733
25831
  }
@@ -24736,6 +25834,9 @@ function createPhaseReminderHook() {
24736
25834
  return;
24737
25835
  }
24738
25836
  const lastUserMessage = messages[lastUserMessageIndex];
25837
+ if (!isUserMessageWithParts(lastUserMessage)) {
25838
+ return;
25839
+ }
24739
25840
  const agent = lastUserMessage.info.agent;
24740
25841
  if (agent && agent !== "orchestrator") {
24741
25842
  return;
@@ -24785,12 +25886,22 @@ ${PHASE_REMINDER}`;
24785
25886
  };
24786
25887
  }
24787
25888
  // src/hooks/reflect/index.ts
24788
- var COMMAND_NAME2 = "reflect";
24789
- function activationPrompt2(focus) {
25889
+ var COMMAND_NAME3 = "reflect";
25890
+ function activationPrompt3(focus, isSessionMode = false, lastN = 50) {
24790
25891
  const focusBlock = focus ? ["Focus:", focus] : [
24791
25892
  "Focus:",
24792
- "Review recent work broadly and identify repeated workflow friction worth improving."
25893
+ isSessionMode ? "Analyze recent sessions to find repeated patterns, friction, and improvement opportunities." : "Review recent work broadly and identify repeated workflow friction worth improving."
24793
25894
  ];
25895
+ const modeBlock = isSessionMode ? [
25896
+ "",
25897
+ "Session Reflection Mode:",
25898
+ `- Analyze the last ${lastN} sessions (use --last N to adjust)`,
25899
+ "- Extract session IDs from OpenCode logs",
25900
+ "- Load session content from SQLite database",
25901
+ "- Analyze each session for patterns and friction",
25902
+ "- Aggregate findings across all sessions",
25903
+ "- Report with scope (global/cross-repo/project-specific), confidence, and impact"
25904
+ ] : [];
24794
25905
  return [
24795
25906
  "Use the reflect skill for this request.",
24796
25907
  "",
@@ -24802,6 +25913,7 @@ function activationPrompt2(focus) {
24802
25913
  "- treat creating nothing as a valid result when evidence is weak;",
24803
25914
  "- ask before changing prompts, skills, commands, agents, MCP access, or config unless the user explicitly requested the exact edit;",
24804
25915
  "- return a compact report with findings, recommended changes, skipped candidates, and items needing more evidence.",
25916
+ ...modeBlock,
24805
25917
  "",
24806
25918
  ...focusBlock
24807
25919
  ].join(`
@@ -24812,32 +25924,155 @@ function createReflectCommandHook() {
24812
25924
  return {
24813
25925
  registerCommand: (opencodeConfig) => {
24814
25926
  const commandConfig = opencodeConfig.command;
24815
- if (commandConfig?.[COMMAND_NAME2]) {
25927
+ if (commandConfig?.[COMMAND_NAME3]) {
24816
25928
  shouldHandleCommand = false;
24817
25929
  return;
24818
25930
  }
24819
25931
  if (!opencodeConfig.command)
24820
25932
  opencodeConfig.command = {};
24821
- opencodeConfig.command[COMMAND_NAME2] = {
25933
+ opencodeConfig.command[COMMAND_NAME3] = {
24822
25934
  template: "Review repeated work and suggest workflow improvements",
24823
25935
  description: "Use reflect to learn from repeated workflows and suggest reusable improvements"
24824
25936
  };
24825
25937
  shouldHandleCommand = true;
24826
25938
  },
24827
25939
  handleCommandExecuteBefore: async (input, output) => {
24828
- if (input.command !== COMMAND_NAME2 || !shouldHandleCommand)
25940
+ if (input.command !== COMMAND_NAME3 || !shouldHandleCommand)
24829
25941
  return;
25942
+ const args = input.arguments.trim();
25943
+ const isSessionMode = args.includes("--sessions");
25944
+ const lastMatch = args.match(/--last\s+(\d+)/);
25945
+ const last = lastMatch ? Math.min(parseInt(lastMatch[1], 10), 100) : 50;
25946
+ const focus = args.replace(/--sessions/g, "").replace(/--last\s+\d+/g, "").trim();
24830
25947
  output.parts.length = 0;
24831
25948
  output.parts.push({
24832
25949
  type: "text",
24833
- text: activationPrompt2(input.arguments.trim())
25950
+ text: activationPrompt3(focus, isSessionMode, last)
24834
25951
  });
24835
25952
  }
24836
25953
  };
24837
25954
  }
24838
- // src/hooks/task-session-manager/index.ts
24839
- import path12 from "node:path";
25955
+ // src/hooks/task-session-manager/pending-call-tracker.ts
24840
25956
  var MAX_PENDING_TASK_CALLS = 100;
25957
+ function createPendingCallTracker() {
25958
+ const pendingCalls = new Map;
25959
+ let anonymousPendingCallId = 0;
25960
+ return {
25961
+ add(call) {
25962
+ pendingCalls.delete(call.callId);
25963
+ pendingCalls.set(call.callId, call);
25964
+ while (pendingCalls.size > MAX_PENDING_TASK_CALLS) {
25965
+ const firstKey = pendingCalls.keys().next().value;
25966
+ if (firstKey === undefined)
25967
+ break;
25968
+ pendingCalls.delete(firstKey);
25969
+ }
25970
+ },
25971
+ take(callId, parentSessionId) {
25972
+ if (!callId && parentSessionId) {
25973
+ for (const id of pendingCalls.keys()) {
25974
+ const call = pendingCalls.get(id);
25975
+ if (call && call.parentSessionId === parentSessionId) {
25976
+ callId = id;
25977
+ break;
25978
+ }
25979
+ }
25980
+ }
25981
+ if (!callId)
25982
+ return;
25983
+ const pending = pendingCalls.get(callId);
25984
+ pendingCalls.delete(callId);
25985
+ return pending;
25986
+ },
25987
+ clearSession(sessionId) {
25988
+ for (const [callId, pending] of pendingCalls.entries()) {
25989
+ if (pending.parentSessionId === sessionId) {
25990
+ pendingCalls.delete(callId);
25991
+ }
25992
+ }
25993
+ },
25994
+ pendingCallId(sessionID, callID) {
25995
+ return callID ?? `${sessionID ?? "unknown"}:anonymous-${++anonymousPendingCallId}`;
25996
+ }
25997
+ };
25998
+ }
25999
+
26000
+ // src/hooks/task-session-manager/task-context-tracker.ts
26001
+ import path12 from "node:path";
26002
+ function createTaskContextTracker() {
26003
+ const contextByTask = new Map;
26004
+ const pendingManagedTaskIds = new Set;
26005
+ return {
26006
+ pendingManagedTaskIds,
26007
+ addContext(taskId, files) {
26008
+ if (files.length === 0)
26009
+ return;
26010
+ let context = contextByTask.get(taskId);
26011
+ if (!context) {
26012
+ context = new Map;
26013
+ contextByTask.set(taskId, context);
26014
+ }
26015
+ for (const file of files) {
26016
+ const pending = context.get(file.path) ?? {
26017
+ path: file.path,
26018
+ lines: new Set,
26019
+ lastReadAt: file.lastReadAt
26020
+ };
26021
+ for (const line of file.lineNumbers ?? []) {
26022
+ pending.lines.add(line);
26023
+ }
26024
+ pending.lastReadAt = Math.max(pending.lastReadAt, file.lastReadAt);
26025
+ context.set(file.path, pending);
26026
+ }
26027
+ },
26028
+ prune(backgroundJobBoard) {
26029
+ const remembered = backgroundJobBoard.taskIDs();
26030
+ for (const taskId of contextByTask.keys()) {
26031
+ if (!pendingManagedTaskIds.has(taskId) && !remembered.has(taskId)) {
26032
+ contextByTask.delete(taskId);
26033
+ }
26034
+ }
26035
+ },
26036
+ clearSession(sessionId) {
26037
+ contextByTask.delete(sessionId);
26038
+ pendingManagedTaskIds.delete(sessionId);
26039
+ },
26040
+ contextFilesForPrompt(taskId) {
26041
+ const context = contextByTask.get(taskId);
26042
+ if (!context)
26043
+ return [];
26044
+ return [...context.values()].map((file) => ({
26045
+ path: file.path,
26046
+ lineCount: file.lines.size,
26047
+ lastReadAt: file.lastReadAt
26048
+ }));
26049
+ }
26050
+ };
26051
+ }
26052
+ function extractReadFiles(root, output) {
26053
+ if (typeof output.output !== "string")
26054
+ return [];
26055
+ const extractPath = /<path>([^<]+)<\/path>/.exec(output.output)?.[1];
26056
+ if (!extractPath)
26057
+ return [];
26058
+ const relative2 = path12.relative(root, extractPath);
26059
+ const normalized = !relative2 || relative2.startsWith("..") || path12.isAbsolute(relative2) ? extractPath : relative2;
26060
+ const matchedLines = new Set;
26061
+ for (const match of output.output.matchAll(/^([0-9]+):/gm)) {
26062
+ matchedLines.add(Number(match[1]));
26063
+ }
26064
+ const lineNumbers = [...matchedLines];
26065
+ return [
26066
+ {
26067
+ path: normalized,
26068
+ lineCount: lineNumbers.length,
26069
+ lineNumbers,
26070
+ lastReadAt: Date.now()
26071
+ }
26072
+ ];
26073
+ }
26074
+
26075
+ // src/hooks/task-session-manager/index.ts
24841
26076
  var BACKGROUND_JOB_BOARD_SENTINEL = "SENTINEL: background-job-board-v2";
24842
26077
  var BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
24843
26078
  var BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
@@ -24868,98 +26103,21 @@ function createOccurrenceId(part, message, partIndex) {
24868
26103
  const hash = djb2Hash(`${sessionID}:${content}`);
24869
26104
  return `anon:${hash}`;
24870
26105
  }
24871
- function extractPath(output) {
24872
- return /<path>([^<]+)<\/path>/.exec(output)?.[1];
24873
- }
24874
26106
  function extractTaskSummary(output) {
24875
26107
  const summary = /<summary>\s*([\s\S]*?)\s*<\/summary>/i.exec(output)?.[1];
24876
26108
  return summary?.trim() || undefined;
24877
26109
  }
24878
- function normalizePath(root, file) {
24879
- const relative = path12.relative(root, file);
24880
- if (!relative || relative.startsWith("..") || path12.isAbsolute(relative)) {
24881
- return file;
24882
- }
24883
- return relative;
24884
- }
24885
- function extractReadFiles(root, output) {
24886
- if (typeof output.output !== "string")
24887
- return [];
24888
- const file = extractPath(output.output);
24889
- if (!file)
24890
- return [];
24891
- return [
24892
- {
24893
- path: normalizePath(root, file),
24894
- lineCount: countReadLines(output.output).length,
24895
- lineNumbers: countReadLines(output.output),
24896
- lastReadAt: Date.now()
24897
- }
24898
- ];
24899
- }
24900
- function countReadLines(output) {
24901
- const lines = new Set;
24902
- for (const match of output.matchAll(/^([0-9]+):/gm)) {
24903
- lines.add(Number(match[1]));
24904
- }
24905
- return [...lines];
24906
- }
24907
26110
  function createTaskSessionManagerHook(_ctx, options) {
24908
26111
  const backgroundJobBoard = options.backgroundJobBoard ?? new BackgroundJobBoard({
24909
26112
  maxReusablePerAgent: options.maxSessionsPerAgent,
24910
26113
  readContextMinLines: options.readContextMinLines,
24911
26114
  readContextMaxFiles: options.readContextMaxFiles
24912
26115
  });
24913
- const pendingCalls = new Map;
24914
- const pendingCallOrder = [];
24915
- const contextByTask = new Map;
24916
- const pendingManagedTaskIds = new Set;
24917
- const terminalJobsInjectedByParent = new Map;
26116
+ const pendingCallTracker = createPendingCallTracker();
26117
+ const taskContextTracker = createTaskContextTracker();
24918
26118
  const processedInjectedCompletions = new Set;
24919
26119
  const processedInjectedCompletionOrder = [];
24920
- let anonymousPendingCallId = 0;
24921
- function addTaskContext(taskId, files) {
24922
- if (files.length === 0)
24923
- return;
24924
- let context = contextByTask.get(taskId);
24925
- if (!context) {
24926
- context = new Map;
24927
- contextByTask.set(taskId, context);
24928
- }
24929
- for (const file of files) {
24930
- const pending = context.get(file.path) ?? {
24931
- path: file.path,
24932
- lines: new Set,
24933
- lastReadAt: file.lastReadAt
24934
- };
24935
- for (const line of file.lineNumbers ?? []) {
24936
- pending.lines.add(line);
24937
- }
24938
- pending.lastReadAt = Math.max(pending.lastReadAt, file.lastReadAt);
24939
- context.set(file.path, pending);
24940
- }
24941
- backgroundJobBoard.addContext(taskId, contextFilesForPrompt(context));
24942
- }
24943
- function contextFilesForPrompt(context) {
24944
- if (!context)
24945
- return [];
24946
- return [...context.values()].map((file) => ({
24947
- path: file.path,
24948
- lineCount: file.lines.size,
24949
- lastReadAt: file.lastReadAt
24950
- }));
24951
- }
24952
- function canTrackTaskContext(taskId) {
24953
- return pendingManagedTaskIds.has(taskId) || backgroundJobBoard.taskIDs().has(taskId);
24954
- }
24955
- function pruneContext() {
24956
- const remembered = backgroundJobBoard.taskIDs();
24957
- for (const taskId of contextByTask.keys()) {
24958
- if (!pendingManagedTaskIds.has(taskId) && !remembered.has(taskId)) {
24959
- contextByTask.delete(taskId);
24960
- }
24961
- }
24962
- }
26120
+ const terminalJobsInjectedByParent = new Map;
24963
26121
  function updateBackgroundJobFromOutput(output) {
24964
26122
  if (typeof output !== "string")
24965
26123
  return;
@@ -24977,7 +26135,8 @@ function createTaskSessionManagerHook(_ctx, options) {
24977
26135
  log("[task-session-manager] suppressed late cancelled task error", {
24978
26136
  taskID: status.taskID,
24979
26137
  alias: existing?.alias,
24980
- state: existing?.state,
26138
+ parsedState: status.state,
26139
+ boardState: existing?.state,
24981
26140
  terminalState: existing?.terminalState,
24982
26141
  result: status.result
24983
26142
  });
@@ -25004,10 +26163,10 @@ function createTaskSessionManagerHook(_ctx, options) {
25004
26163
  terminalUnreconciled: updated.terminalUnreconciled,
25005
26164
  timedOut: updated.timedOut
25006
26165
  });
25007
- if (updated.terminalUnreconciled) {
25008
- pendingManagedTaskIds.delete(updated.taskID);
25009
- backgroundJobBoard.addContext(updated.taskID, contextFilesForPrompt(contextByTask.get(updated.taskID)));
25010
- pruneContext();
26166
+ if (backgroundJobBoard.isTerminalUnreconciled(updated.taskID)) {
26167
+ taskContextTracker.pendingManagedTaskIds.delete(updated.taskID);
26168
+ backgroundJobBoard.addContext(updated.taskID, taskContextTracker.contextFilesForPrompt(updated.taskID));
26169
+ taskContextTracker.prune(backgroundJobBoard);
25011
26170
  }
25012
26171
  return updated;
25013
26172
  }
@@ -25031,11 +26190,12 @@ function createTaskSessionManagerHook(_ctx, options) {
25031
26190
  const occurrenceId = createOccurrenceId(part, message, partIndex);
25032
26191
  const existing = backgroundJobBoard.get(status.taskID);
25033
26192
  if (isFailed && isLateCancelledTaskError(existing, status.state)) {
25034
- part.text = formatCancelledTaskStatusOutput(status.taskID, existing?.resultSummary);
26193
+ part.text = formatCancelledTaskStatusOutput(status.taskID, backgroundJobBoard.getResultSummary(status.taskID));
25035
26194
  log("[task-session-manager] normalized late cancelled injected failure", {
25036
26195
  taskID: status.taskID,
25037
26196
  alias: existing?.alias,
25038
- state: existing?.state,
26197
+ parsedState: status.state,
26198
+ boardState: existing?.state,
25039
26199
  terminalState: existing?.terminalState,
25040
26200
  result: status.result
25041
26201
  });
@@ -25075,41 +26235,6 @@ function createTaskSessionManagerHook(_ctx, options) {
25075
26235
  const firstLine = output.split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? "";
25076
26236
  return firstLine.startsWith("[error]") && firstLine.includes("session") && (firstLine.includes("not found") || firstLine.includes("no session"));
25077
26237
  }
25078
- function pendingCallId(input) {
25079
- return input.callID ?? `${input.sessionID ?? "unknown"}:anonymous-${++anonymousPendingCallId}`;
25080
- }
25081
- function rememberPendingCall(call) {
25082
- const existingIndex = pendingCallOrder.indexOf(call.callId);
25083
- if (existingIndex >= 0) {
25084
- pendingCallOrder.splice(existingIndex, 1);
25085
- }
25086
- pendingCalls.set(call.callId, call);
25087
- pendingCallOrder.push(call.callId);
25088
- while (pendingCallOrder.length > MAX_PENDING_TASK_CALLS) {
25089
- const evictedCallId = pendingCallOrder.shift();
25090
- if (!evictedCallId) {
25091
- break;
25092
- }
25093
- pendingCalls.delete(evictedCallId);
25094
- }
25095
- }
25096
- function takePendingCall(callId, parentSessionId) {
25097
- const resolvedCallId = callId ?? firstPendingCallForParent(parentSessionId);
25098
- if (!resolvedCallId)
25099
- return;
25100
- const pending = pendingCalls.get(resolvedCallId);
25101
- pendingCalls.delete(resolvedCallId);
25102
- const orderIndex = pendingCallOrder.indexOf(resolvedCallId);
25103
- if (orderIndex >= 0) {
25104
- pendingCallOrder.splice(orderIndex, 1);
25105
- }
25106
- return pending;
25107
- }
25108
- function firstPendingCallForParent(parentSessionId) {
25109
- if (!parentSessionId)
25110
- return;
25111
- return pendingCallOrder.find((callId) => pendingCalls.get(callId)?.parentSessionId === parentSessionId);
25112
- }
25113
26238
  function rememberInjectedTerminalJobs(parentSessionID) {
25114
26239
  const taskIDs = backgroundJobBoard.list(parentSessionID).filter((job) => job.terminalUnreconciled).map((job) => job.taskID);
25115
26240
  if (taskIDs.length === 0)
@@ -25161,45 +26286,50 @@ function createTaskSessionManagerHook(_ctx, options) {
25161
26286
  agentType
25162
26287
  });
25163
26288
  const pendingCall = {
25164
- callId: pendingCallId({
25165
- callID: input.callID,
25166
- sessionID: input.sessionID
25167
- }),
26289
+ callId: pendingCallTracker.pendingCallId(input.sessionID, input.callID),
25168
26290
  parentSessionId: input.sessionID,
25169
26291
  agentType,
25170
26292
  label
25171
26293
  };
25172
- rememberPendingCall(pendingCall);
26294
+ pendingCallTracker.add(pendingCall);
25173
26295
  if (typeof args.task_id !== "string" || args.task_id.trim() === "") {
25174
26296
  return;
25175
26297
  }
25176
26298
  const requested = args.task_id.trim();
25177
- const remembered = backgroundJobBoard.resolveReusable(input.sessionID, requested, agentType);
26299
+ const remembered = backgroundJobBoard.resolveReusable(input.sessionID, requested, agentType) ?? backgroundJobBoard.resolveRecoverable(input.sessionID, requested, agentType);
25178
26300
  if (!remembered) {
26301
+ const knownManagedTask = backgroundJobBoard.resolve(input.sessionID, requested);
26302
+ if (knownManagedTask) {
26303
+ delete args.task_id;
26304
+ return;
26305
+ }
25179
26306
  if (RAW_SESSION_ID_PATTERN.test(requested)) {
25180
26307
  pendingCall.resumedTaskId = requested;
25181
- rememberPendingCall(pendingCall);
26308
+ pendingCallTracker.add(pendingCall);
25182
26309
  return;
25183
26310
  }
25184
26311
  delete args.task_id;
25185
26312
  return;
25186
26313
  }
25187
26314
  args.task_id = remembered.taskID;
25188
- pendingManagedTaskIds.add(remembered.taskID);
26315
+ taskContextTracker.pendingManagedTaskIds.add(remembered.taskID);
25189
26316
  backgroundJobBoard.markUsed(input.sessionID, remembered.taskID);
25190
26317
  pendingCall.resumedTaskId = remembered.taskID;
25191
- rememberPendingCall(pendingCall);
26318
+ pendingCallTracker.add(pendingCall);
25192
26319
  },
25193
26320
  "tool.execute.after": async (input, output) => {
25194
26321
  if (input.tool.toLowerCase() === "read") {
25195
- if (input.sessionID && canTrackTaskContext(input.sessionID)) {
25196
- addTaskContext(input.sessionID, extractReadFiles(_ctx.directory, output));
26322
+ if (input.sessionID) {
26323
+ const canTrack = taskContextTracker.pendingManagedTaskIds.has(input.sessionID) || backgroundJobBoard.taskIDs().has(input.sessionID);
26324
+ if (canTrack) {
26325
+ taskContextTracker.addContext(input.sessionID, extractReadFiles(_ctx.directory, output));
26326
+ }
25197
26327
  }
25198
26328
  return;
25199
26329
  }
25200
26330
  if (input.tool.toLowerCase() !== "task")
25201
26331
  return;
25202
- const pending = takePendingCall(input.callID, input.sessionID);
26332
+ const pending = pendingCallTracker.take(input.callID, input.sessionID);
25203
26333
  if (!pending || typeof output.output !== "string")
25204
26334
  return;
25205
26335
  const launch = parseTaskLaunchOutput(output.output);
@@ -25219,8 +26349,8 @@ function createTaskSessionManagerHook(_ctx, options) {
25219
26349
  description: record.description,
25220
26350
  state: record.state
25221
26351
  });
25222
- backgroundJobBoard.addContext(launch.taskID, contextFilesForPrompt(contextByTask.get(launch.taskID)));
25223
- pendingManagedTaskIds.add(launch.taskID);
26352
+ taskContextTracker.pendingManagedTaskIds.add(launch.taskID);
26353
+ backgroundJobBoard.addContext(launch.taskID, taskContextTracker.contextFilesForPrompt(launch.taskID));
25224
26354
  return;
25225
26355
  }
25226
26356
  normalizeLateCancelledTaskOutput(output);
@@ -25250,10 +26380,9 @@ function createTaskSessionManagerHook(_ctx, options) {
25250
26380
  if (pending.resumedTaskId && pending.resumedTaskId !== status.taskID) {
25251
26381
  backgroundJobBoard.drop(pending.resumedTaskId);
25252
26382
  }
25253
- pendingManagedTaskIds.delete(status.taskID);
25254
- const contextFiles2 = contextFilesForPrompt(contextByTask.get(status.taskID));
25255
- backgroundJobBoard.addContext(status.taskID, contextFiles2);
25256
- pruneContext();
26383
+ taskContextTracker.pendingManagedTaskIds.delete(status.taskID);
26384
+ backgroundJobBoard.addContext(status.taskID, taskContextTracker.contextFilesForPrompt(status.taskID));
26385
+ taskContextTracker.prune(backgroundJobBoard);
25257
26386
  return;
25258
26387
  }
25259
26388
  const taskId = parseTaskIdFromTaskOutput(output.output);
@@ -25266,14 +26395,14 @@ function createTaskSessionManagerHook(_ctx, options) {
25266
26395
  if (pending.resumedTaskId && pending.resumedTaskId !== taskId) {
25267
26396
  backgroundJobBoard.drop(pending.resumedTaskId);
25268
26397
  }
25269
- pendingManagedTaskIds.delete(taskId);
25270
- const contextFiles = contextFilesForPrompt(contextByTask.get(taskId));
25271
- backgroundJobBoard.addContext(taskId, contextFiles);
25272
- pruneContext();
26398
+ taskContextTracker.pendingManagedTaskIds.delete(taskId);
26399
+ backgroundJobBoard.addContext(taskId, taskContextTracker.contextFilesForPrompt(taskId));
26400
+ taskContextTracker.prune(backgroundJobBoard);
25273
26401
  },
25274
26402
  "experimental.chat.messages.transform": async (_input, output) => {
25275
- for (const [messageIndex, message] of output.messages.entries()) {
25276
- if (message.info.role !== "user")
26403
+ const messages = Array.isArray(output.messages) ? output.messages : [];
26404
+ for (const [messageIndex, message] of messages.entries()) {
26405
+ if (!isUserMessageWithParts(message))
25277
26406
  continue;
25278
26407
  if (message.info.agent && message.info.agent !== "orchestrator") {
25279
26408
  continue;
@@ -25285,9 +26414,9 @@ function createTaskSessionManagerHook(_ctx, options) {
25285
26414
  updateFromInjectedCompletion(part, message, messageIndex, partIndex);
25286
26415
  }
25287
26416
  }
25288
- for (let i = output.messages.length - 1;i >= 0; i -= 1) {
25289
- const message = output.messages[i];
25290
- if (message.info.role !== "user")
26417
+ for (let i = messages.length - 1;i >= 0; i -= 1) {
26418
+ const message = messages[i];
26419
+ if (!isUserMessageWithParts(message))
25291
26420
  continue;
25292
26421
  if (message.info.agent && message.info.agent !== "orchestrator")
25293
26422
  return;
@@ -25323,7 +26452,7 @@ function createTaskSessionManagerHook(_ctx, options) {
25323
26452
  managesParent: info?.parentID ? options.shouldManageSession(info.parentID) : false
25324
26453
  });
25325
26454
  if (info?.id && info.parentID && options.shouldManageSession(info.parentID)) {
25326
- pendingManagedTaskIds.add(info.id);
26455
+ taskContextTracker.pendingManagedTaskIds.add(info.id);
25327
26456
  }
25328
26457
  return;
25329
26458
  }
@@ -25342,7 +26471,10 @@ function createTaskSessionManagerHook(_ctx, options) {
25342
26471
  if (input.event.type === "session.error") {
25343
26472
  const sessionId2 = input.event.properties?.info?.id ?? input.event.properties?.sessionID;
25344
26473
  if (sessionId2 && options.shouldManageSession(sessionId2)) {
25345
- terminalJobsInjectedByParent.delete(sessionId2);
26474
+ const props = input.event.properties;
26475
+ if (!props?.error || !isRateLimitError(props.error)) {
26476
+ terminalJobsInjectedByParent.delete(sessionId2);
26477
+ }
25346
26478
  }
25347
26479
  return;
25348
26480
  }
@@ -25356,9 +26488,7 @@ function createTaskSessionManagerHook(_ctx, options) {
25356
26488
  previousState: before.state,
25357
26489
  previousTerminalState: before.terminalState,
25358
26490
  terminalUnreconciled: before.terminalUnreconciled,
25359
- resultSummary: before.resultSummary,
25360
- updatedState: updated?.state,
25361
- updatedCancellationRequested: updated?.cancellationRequested
26491
+ resultSummary: before.resultSummary
25362
26492
  });
25363
26493
  }
25364
26494
  log("[task-session-manager] busy/status busy observed", {
@@ -25366,10 +26496,10 @@ function createTaskSessionManagerHook(_ctx, options) {
25366
26496
  managesSession: sessionId2 ? options.shouldManageSession(sessionId2) : false,
25367
26497
  previousState: before?.state,
25368
26498
  previousTerminalState: before?.terminalState,
25369
- previousCancellationRequested: before?.cancellationRequested,
26499
+ previousCancellationRequested: before?.cancellationRequested ?? false,
25370
26500
  previousLastLiveBusyAt: before?.lastLiveBusyAt,
25371
26501
  updatedState: updated?.state,
25372
- updatedCancellationRequested: updated?.cancellationRequested,
26502
+ updatedCancellationRequested: updated?.cancellationRequested ?? false,
25373
26503
  updatedLastLiveBusyAt: updated?.lastLiveBusyAt
25374
26504
  });
25375
26505
  return;
@@ -25381,26 +26511,23 @@ function createTaskSessionManagerHook(_ctx, options) {
25381
26511
  return;
25382
26512
  log("[task-session-manager] session.deleted observed; clearing job state", {
25383
26513
  sessionID: sessionId,
25384
- deletedJob: backgroundJobBoard.get(sessionId) ? {
25385
- state: backgroundJobBoard.get(sessionId)?.state,
25386
- parentSessionID: backgroundJobBoard.get(sessionId)?.parentSessionID,
25387
- alias: backgroundJobBoard.get(sessionId)?.alias
25388
- } : undefined,
26514
+ deletedJob: (() => {
26515
+ const record = backgroundJobBoard.get(sessionId);
26516
+ return record ? {
26517
+ state: record.state,
26518
+ parentSessionID: record.parentSessionID,
26519
+ alias: record.alias
26520
+ } : undefined;
26521
+ })(),
25389
26522
  childJobCount: backgroundJobBoard.list(sessionId).length,
25390
26523
  managesSession: options.shouldManageSession(sessionId)
25391
26524
  });
25392
26525
  backgroundJobBoard.drop(sessionId);
25393
26526
  backgroundJobBoard.clearParent(sessionId);
25394
26527
  terminalJobsInjectedByParent.delete(sessionId);
25395
- contextByTask.delete(sessionId);
25396
- pendingManagedTaskIds.delete(sessionId);
25397
- pruneContext();
25398
- for (const [callId, pending] of pendingCalls.entries()) {
25399
- if (pending.parentSessionId !== sessionId) {
25400
- continue;
25401
- }
25402
- takePendingCall(callId);
25403
- }
26528
+ taskContextTracker.clearSession(sessionId);
26529
+ taskContextTracker.prune(backgroundJobBoard);
26530
+ pendingCallTracker.clearSession(sessionId);
25404
26531
  }
25405
26532
  };
25406
26533
  function normalizeLateCancelledTaskOutput(output) {
@@ -25419,7 +26546,7 @@ function createTaskSessionManagerHook(_ctx, options) {
25419
26546
  terminalState: existing?.terminalState,
25420
26547
  result: status.result
25421
26548
  });
25422
- output.output = formatCancelledTaskStatusOutput(status.taskID, existing?.resultSummary);
26549
+ output.output = formatCancelledTaskStatusOutput(status.taskID, backgroundJobBoard.getResultSummary(status.taskID));
25423
26550
  if (isRecord(output) && isRecord(output.metadata)) {
25424
26551
  output.metadata.state = "cancelled";
25425
26552
  }
@@ -25443,17 +26570,14 @@ function formatCancelledTaskStatusOutput(taskID, summary = "cancelled") {
25443
26570
  ].join(`
25444
26571
  `);
25445
26572
  }
25446
- // src/interview/manager.ts
25447
- import path16 from "node:path";
25448
-
25449
26573
  // src/interview/dashboard.ts
25450
- import crypto from "node:crypto";
26574
+ import crypto2 from "node:crypto";
25451
26575
  import * as fsSync2 from "node:fs";
25452
26576
  import fs7 from "node:fs/promises";
25453
26577
  import {
25454
26578
  createServer
25455
26579
  } from "node:http";
25456
- import os4 from "node:os";
26580
+ import os5 from "node:os";
25457
26581
  import path14 from "node:path";
25458
26582
  import { URL as URL2 } from "node:url";
25459
26583
 
@@ -26035,8 +27159,8 @@ function renderDashboardPage(interviews, files, outputFolder) {
26035
27159
  <p class="muted">${totalCount} item${totalCount === 1 ? "" : "s"}</p>
26036
27160
  </div>
26037
27161
  <div class="info-box">
26038
- <strong>Interviews</strong> live sessions and recovered files. State is pushed from OpenCode sessions to this dashboard.<br>
26039
- <strong>Files without session</strong> <code>.md</code> files in <code>${escapeHtml(outputFolder)}</code> with no frontmatter. Resume with <code>/interview &lt;name&gt;</code>.
27162
+ <strong>Interviews</strong> - live sessions and recovered files. State is pushed from OpenCode sessions to this dashboard.<br>
27163
+ <strong>Files without session</strong> - <code>.md</code> files in <code>${escapeHtml(outputFolder)}</code> with no frontmatter. Resume with <code>/interview &lt;name&gt;</code>.
26040
27164
  </div>
26041
27165
  <details style="margin-bottom:24px">
26042
27166
  <summary style="cursor:pointer;font-size:13px;color:rgba(255,255,255,0.4);user-select:none">Settings</summary>
@@ -26064,7 +27188,7 @@ function renderDashboardPage(interviews, files, outputFolder) {
26064
27188
  ${fileSection}
26065
27189
  <div class="footer">OH MY OPENCODE SLIM</div>
26066
27190
  </div>
26067
- <div class="update-banner" id="updateBanner">Dashboard updated tap to refresh</div>
27191
+ <div class="update-banner" id="updateBanner">Dashboard updated - tap to refresh</div>
26068
27192
  <script>
26069
27193
  ${clipboardHelperJs()}
26070
27194
  document.querySelectorAll('.copy-btn').forEach(btn => {
@@ -26731,7 +27855,7 @@ function renderInterviewPage(interviewId, resumeSlug) {
26731
27855
  </div>
26732
27856
 
26733
27857
  <div class="chat-panel" id="chatPanel">
26734
- <input type="text" id="chatInput" class="chat-input" placeholder="Send a message to the agent add a section, revise content, ask questions..." autocomplete="off" />
27858
+ <input type="text" id="chatInput" class="chat-input" placeholder="Send a message to the agent - add a section, revise content, ask questions..." autocomplete="off" />
26735
27859
  <button class="chat-send" id="chatSendBtn" type="button" disabled>Send</button>
26736
27860
  </div>
26737
27861
 
@@ -27090,7 +28214,7 @@ function renderInterviewPage(interviewId, resumeSlug) {
27090
28214
 
27091
28215
  // simpleMarkdown: safe because escaping happens BEFORE markdown
27092
28216
  // processing. Adding raw HTML output from user content would break
27093
- // this safety always use textContent for user data.
28217
+ // this safety - always use textContent for user data.
27094
28218
  function simpleMarkdown(text) {
27095
28219
  if (!text) return '';
27096
28220
  const escaped = text
@@ -27236,7 +28360,7 @@ function renderInterviewPage(interviewId, resumeSlug) {
27236
28360
  aBadge.textContent = 'A';
27237
28361
  const aText = document.createElement('span');
27238
28362
  aText.className = 'qa-text qa-text-a';
27239
- aText.textContent = pair.a || '';
28363
+ aText.textContent = pair.a || '-';
27240
28364
  aRow.appendChild(aBadge);
27241
28365
  aRow.appendChild(aText);
27242
28366
  card.appendChild(aRow);
@@ -27304,7 +28428,7 @@ function renderInterviewPage(interviewId, resumeSlug) {
27304
28428
  if (busy) {
27305
28429
  input.placeholder = 'Agent is processing...';
27306
28430
  } else {
27307
- input.placeholder = 'Send a message to the agent add a section, revise content, ask questions...';
28431
+ input.placeholder = 'Send a message to the agent - add a section, revise content, ask questions...';
27308
28432
  }
27309
28433
  }
27310
28434
 
@@ -27462,7 +28586,7 @@ function renderInterviewPage(interviewId, resumeSlug) {
27462
28586
  statusEl.textContent = data.mode.toUpperCase();
27463
28587
  statusEl.className = isDone ? 'status-completed' : '';
27464
28588
 
27465
- // Render Markdown Path always visible in completed mode
28589
+ // Render Markdown Path - always visible in completed mode
27466
28590
  const pathContainer = document.getElementById('filePathContainer');
27467
28591
  const pathElement = document.getElementById('markdownPath');
27468
28592
  const mdPath = data.markdownPath || (data.interview && data.interview.markdownPath);
@@ -27480,7 +28604,7 @@ function renderInterviewPage(interviewId, resumeSlug) {
27480
28604
  pathContainer.style.display = 'none';
27481
28605
  }
27482
28606
 
27483
- // Resume command only in session-disconnected mode
28607
+ // Resume command - only in session-disconnected mode
27484
28608
  var resumeRow = document.getElementById('resumeRow');
27485
28609
  if (resumeRow) {
27486
28610
  var showResume = data.mode === 'session-disconnected';
@@ -27490,7 +28614,7 @@ function renderInterviewPage(interviewId, resumeSlug) {
27490
28614
  }
27491
28615
  }
27492
28616
 
27493
- // Nudge actions only for completed (live session)
28617
+ // Nudge actions - only for completed (live session)
27494
28618
  // session-disconnected can't nudge (no session to receive it)
27495
28619
  const nudgeActions = document.getElementById('nudgeActions');
27496
28620
  const moreBtn = document.getElementById('moreQuestionsBtn');
@@ -27613,7 +28737,7 @@ function renderInterviewPage(interviewId, resumeSlug) {
27613
28737
  if (confirmBtn) confirmBtn.disabled = false;
27614
28738
  } else {
27615
28739
  try { await refresh(); } catch (_) {}
27616
- schedulePoll(); // Restart polling nudge may reactivate interview
28740
+ schedulePoll(); // Restart polling - nudge may reactivate interview
27617
28741
  }
27618
28742
  } catch (_err) {
27619
28743
  document.getElementById('submitStatus').textContent = 'Network error.';
@@ -27691,7 +28815,7 @@ function renderInterviewPage(interviewId, resumeSlug) {
27691
28815
 
27692
28816
  // src/interview/dashboard.ts
27693
28817
  function getAuthFilePath(port) {
27694
- const dataHome = process.env.XDG_DATA_HOME || path14.join(os4.homedir(), ".local", "share");
28818
+ const dataHome = process.env.XDG_DATA_HOME || path14.join(os5.homedir(), ".local", "share");
27695
28819
  return path14.join(dataHome, "opencode", `.dashboard-${port}.json`);
27696
28820
  }
27697
28821
  function writeAuthFile(port, token) {
@@ -27747,7 +28871,7 @@ function hasLiveForSlug(slug, cache) {
27747
28871
  }
27748
28872
  var DEFAULT_DASHBOARD_PORT = 43211;
27749
28873
  function createDashboardServer(config) {
27750
- const authToken = crypto.randomBytes(32).toString("hex");
28874
+ const authToken = crypto2.randomBytes(32).toString("hex");
27751
28875
  let activeServer = null;
27752
28876
  let baseUrl = null;
27753
28877
  const sessions = new Map;
@@ -27804,15 +28928,19 @@ data: ${JSON.stringify(formatSseState(entry))}
27804
28928
  ]);
27805
28929
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
27806
28930
  const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
27807
- const cleanupTimer = setInterval(() => {
27808
- const cutoff = Date.now() - CACHE_TTL_MS;
27809
- for (const [id, entry] of stateCache) {
27810
- if (TERMINAL_MODES.has(entry.mode) && entry.lastUpdatedAt < cutoff) {
27811
- stateCache.delete(id);
28931
+ function createCleanupTimer() {
28932
+ const timer = setInterval(() => {
28933
+ const cutoff = Date.now() - CACHE_TTL_MS;
28934
+ for (const [id, entry] of stateCache) {
28935
+ if (TERMINAL_MODES.has(entry.mode) && entry.lastUpdatedAt < cutoff) {
28936
+ stateCache.delete(id);
28937
+ }
27812
28938
  }
27813
- }
27814
- }, CLEANUP_INTERVAL_MS);
27815
- cleanupTimer.unref();
28939
+ }, CLEANUP_INTERVAL_MS);
28940
+ timer.unref();
28941
+ return timer;
28942
+ }
28943
+ let cleanupTimer = null;
27816
28944
  let fileCache = null;
27817
28945
  const FILE_CACHE_TTL = 1e4;
27818
28946
  function isAuthenticated(request) {
@@ -27838,7 +28966,7 @@ data: ${JSON.stringify(formatSseState(entry))}
27838
28966
  let scanDays = 30;
27839
28967
  function getKnownDirectories() {
27840
28968
  const dirs = new Set;
27841
- dirs.add(os4.homedir());
28969
+ dirs.add(os5.homedir());
27842
28970
  for (const session2 of sessions.values()) {
27843
28971
  if (session2.directory)
27844
28972
  dirs.add(session2.directory);
@@ -28555,7 +29683,10 @@ data: ${JSON.stringify(formatSseState(entry))}
28555
29683
  function start() {
28556
29684
  if (baseUrl)
28557
29685
  return Promise.resolve(baseUrl);
28558
- return new Promise((resolve2, reject) => {
29686
+ if (!cleanupTimer) {
29687
+ cleanupTimer = createCleanupTimer();
29688
+ }
29689
+ return new Promise((resolve3, reject) => {
28559
29690
  const server = createServer((request, response) => {
28560
29691
  handleRequest(request, response).catch((error) => {
28561
29692
  sendJson(response, 500, {
@@ -28582,13 +29713,31 @@ data: ${JSON.stringify(formatSseState(entry))}
28582
29713
  activeServer = server;
28583
29714
  baseUrl = `http://127.0.0.1:${address.port}`;
28584
29715
  writeAuthFile(config.port, authToken);
28585
- resolve2(baseUrl);
29716
+ resolve3(baseUrl);
28586
29717
  });
28587
29718
  });
28588
29719
  }
28589
29720
  function close() {
29721
+ if (cleanupTimer) {
29722
+ clearInterval(cleanupTimer);
29723
+ cleanupTimer = null;
29724
+ }
29725
+ for (const clients of sseClients.values()) {
29726
+ for (const response of clients) {
29727
+ try {
29728
+ if (!response.writableEnded && !response.destroyed) {
29729
+ response.end();
29730
+ }
29731
+ } catch {
29732
+ try {
29733
+ response.destroy();
29734
+ } catch {}
29735
+ }
29736
+ }
29737
+ }
29738
+ sseClients.clear();
29739
+ removeAuthFile(config.port);
28590
29740
  if (activeServer) {
28591
- removeAuthFile(config.port);
28592
29741
  activeServer.closeAllConnections();
28593
29742
  activeServer.close();
28594
29743
  activeServer = null;
@@ -28730,7 +29879,7 @@ async function tryBecomeDashboard(config, maxAttempts = 3) {
28730
29879
  const message = error instanceof Error ? error.message : String(error);
28731
29880
  if (message.includes("already in use")) {
28732
29881
  if (attempt < maxAttempts - 1) {
28733
- await new Promise((resolve2) => setTimeout(resolve2, jitterMs()));
29882
+ await new Promise((resolve3) => setTimeout(resolve3, jitterMs()));
28734
29883
  continue;
28735
29884
  }
28736
29885
  return null;
@@ -28741,6 +29890,9 @@ async function tryBecomeDashboard(config, maxAttempts = 3) {
28741
29890
  return null;
28742
29891
  }
28743
29892
 
29893
+ // src/interview/dashboard-manager.ts
29894
+ import path16 from "node:path";
29895
+
28744
29896
  // src/interview/server.ts
28745
29897
  import {
28746
29898
  createServer as createServer2
@@ -28938,7 +30090,7 @@ function createInterviewServer(deps) {
28938
30090
  if (startPromise) {
28939
30091
  return startPromise;
28940
30092
  }
28941
- startPromise = new Promise((resolve2, reject) => {
30093
+ startPromise = new Promise((resolve3, reject) => {
28942
30094
  const server = createServer2((request, response) => {
28943
30095
  handle(request, response).catch((error) => {
28944
30096
  sendJson(response, 500, {
@@ -28967,7 +30119,7 @@ function createInterviewServer(deps) {
28967
30119
  return;
28968
30120
  }
28969
30121
  baseUrl = `http://127.0.0.1:${address.port}`;
28970
- resolve2(baseUrl);
30122
+ resolve3(baseUrl);
28971
30123
  });
28972
30124
  });
28973
30125
  return startPromise;
@@ -29252,8 +30404,9 @@ function buildAnswerPrompt(answers, questions, maxQuestions) {
29252
30404
  }
29253
30405
 
29254
30406
  // src/interview/service.ts
29255
- var COMMAND_NAME3 = "interview";
30407
+ var COMMAND_NAME4 = "interview";
29256
30408
  var DEFAULT_MAX_QUESTIONS = 2;
30409
+ var MAX_RETAINED_ABANDONED = 50;
29257
30410
  function isTruthyEnvFlag(value) {
29258
30411
  if (!value) {
29259
30412
  return false;
@@ -29312,6 +30465,7 @@ function createInterviewService(ctx, config, deps) {
29312
30465
  let onStateChange = null;
29313
30466
  let onInterviewCreated = null;
29314
30467
  let idCounter = 0;
30468
+ let abandonedOrderCounter = 0;
29315
30469
  function setBaseUrlResolver(resolver) {
29316
30470
  resolveBaseUrl = resolver;
29317
30471
  }
@@ -29386,7 +30540,7 @@ function createInterviewService(ctx, config, deps) {
29386
30540
  return messages;
29387
30541
  }
29388
30542
  }
29389
- await new Promise((resolve3) => setTimeout(resolve3, 250));
30543
+ await new Promise((resolve4) => setTimeout(resolve4, 250));
29390
30544
  }
29391
30545
  return loadMessages(sessionID);
29392
30546
  }
@@ -29396,6 +30550,29 @@ function createInterviewService(ctx, config, deps) {
29396
30550
  function getInterviewById(interviewId) {
29397
30551
  return interviewsById.get(interviewId) ?? null;
29398
30552
  }
30553
+ function abandonInterview(interview) {
30554
+ if (interview.status !== "abandoned") {
30555
+ interview.abandonedAt = nowIso();
30556
+ interview.abandonedOrder = ++abandonedOrderCounter;
30557
+ }
30558
+ interview.status = "abandoned";
30559
+ pruneAbandonedInterviews();
30560
+ }
30561
+ function pruneAbandonedInterviews() {
30562
+ const abandoned = [...interviewsById.values()].filter((record) => record.status === "abandoned");
30563
+ const overflow = abandoned.length - MAX_RETAINED_ABANDONED;
30564
+ if (overflow <= 0)
30565
+ return;
30566
+ abandoned.sort((a, b) => {
30567
+ const timeDelta = new Date(a.abandonedAt ?? a.createdAt).getTime() - new Date(b.abandonedAt ?? b.createdAt).getTime();
30568
+ if (timeDelta !== 0)
30569
+ return timeDelta;
30570
+ return (a.abandonedOrder ?? 0) - (b.abandonedOrder ?? 0);
30571
+ }).slice(0, overflow).forEach((record) => {
30572
+ interviewsById.delete(record.id);
30573
+ browserOpened.delete(record.id);
30574
+ });
30575
+ }
29399
30576
  async function createInterview(sessionID, idea) {
29400
30577
  const normalizedIdea = idea.trim();
29401
30578
  const activeId = activeInterviewIds.get(sessionID);
@@ -29405,7 +30582,7 @@ function createInterviewService(ctx, config, deps) {
29405
30582
  if (active.idea === normalizedIdea) {
29406
30583
  return active;
29407
30584
  }
29408
- active.status = "abandoned";
30585
+ abandonInterview(active);
29409
30586
  }
29410
30587
  }
29411
30588
  const messages = await loadMessages(sessionID);
@@ -29435,7 +30612,7 @@ function createInterviewService(ctx, config, deps) {
29435
30612
  if (active.markdownPath === markdownPath) {
29436
30613
  return active;
29437
30614
  }
29438
- active.status = "abandoned";
30615
+ abandonInterview(active);
29439
30616
  }
29440
30617
  }
29441
30618
  const document = await fs8.readFile(markdownPath, "utf8");
@@ -29531,11 +30708,11 @@ function createInterviewService(ctx, config, deps) {
29531
30708
  }
29532
30709
  function registerCommand(opencodeConfig) {
29533
30710
  const configCommand = opencodeConfig.command;
29534
- if (!configCommand?.[COMMAND_NAME3]) {
30711
+ if (!configCommand?.[COMMAND_NAME4]) {
29535
30712
  if (!opencodeConfig.command) {
29536
30713
  opencodeConfig.command = {};
29537
30714
  }
29538
- opencodeConfig.command[COMMAND_NAME3] = {
30715
+ opencodeConfig.command[COMMAND_NAME4] = {
29539
30716
  template: "Start an interview and write a live markdown spec",
29540
30717
  description: "Open a localhost interview UI linked to the current OpenCode session"
29541
30718
  };
@@ -29609,7 +30786,7 @@ function createInterviewService(ctx, config, deps) {
29609
30786
  }
29610
30787
  }
29611
30788
  async function handleCommandExecuteBefore(input, output) {
29612
- if (input.command !== COMMAND_NAME3) {
30789
+ if (input.command !== COMMAND_NAME4) {
29613
30790
  return;
29614
30791
  }
29615
30792
  const idea = input.arguments.trim();
@@ -29673,7 +30850,7 @@ function createInterviewService(ctx, config, deps) {
29673
30850
  if (!interview) {
29674
30851
  return;
29675
30852
  }
29676
- interview.status = "abandoned";
30853
+ abandonInterview(interview);
29677
30854
  fileCache = null;
29678
30855
  activeInterviewIds.delete(deletedSessionId);
29679
30856
  log("[interview] session deleted, interview marked abandoned", {
@@ -29802,7 +30979,7 @@ function createInterviewService(ctx, config, deps) {
29802
30979
  `The user sent a freeform message via the dashboard chat panel:`,
29803
30980
  `${message}`,
29804
30981
  ``,
29805
- `Process this request it may be a request to add a new section, revise existing content, ask clarifying questions, or make structural changes.`,
30982
+ `Process this request - it may be a request to add a new section, revise existing content, ask clarifying questions, or make structural changes.`,
29806
30983
  `Update the specification document accordingly and include the updated 11-section specification.`,
29807
30984
  `Ask up to ${maxQuestions} clarifying questions if needed using the same <interview_state> JSON block format as before.`
29808
30985
  ].join(`
@@ -29864,7 +31041,7 @@ function createInterviewService(ctx, config, deps) {
29864
31041
  `The user confirmed the interview spec is complete.`,
29865
31042
  ``,
29866
31043
  `Produce a final, polished version of the full spec document.`,
29867
- `Do NOT include any <interview_state> block just output the final spec as clean markdown.`,
31044
+ `Do NOT include any <interview_state> block - just output the final spec as clean markdown.`,
29868
31045
  `The spec should be comprehensive, well-structured, and ready for implementation.`
29869
31046
  ].join(`
29870
31047
  `);
@@ -29902,34 +31079,9 @@ function createInterviewService(ctx, config, deps) {
29902
31079
  };
29903
31080
  }
29904
31081
 
29905
- // src/interview/manager.ts
29906
- function createInterviewManager(ctx, config) {
31082
+ // src/interview/dashboard-manager.ts
31083
+ function createDashboardManager(ctx, config, dashboardPort, outputFolder) {
29907
31084
  const interviewConfig = config.interview;
29908
- const effectivePort = interviewConfig?.port ?? 0;
29909
- const dashboardEnabled = interviewConfig?.dashboard === true || effectivePort > 0;
29910
- const outputFolder = interviewConfig?.outputFolder ?? "interview";
29911
- if (!dashboardEnabled) {
29912
- const service2 = createInterviewService(ctx, interviewConfig);
29913
- const resolvedOutputPath = path16.join(ctx.directory, outputFolder);
29914
- const server = createInterviewServer({
29915
- getState: async (interviewId) => service2.getInterviewState(interviewId),
29916
- listInterviewFiles: async () => service2.listInterviewFiles(),
29917
- listInterviews: () => service2.listInterviews(),
29918
- submitAnswers: async (interviewId, answers) => service2.submitAnswers(interviewId, answers),
29919
- submitBlockComment: async (interviewId, section, comment) => service2.submitBlockComment(interviewId, section, comment),
29920
- submitChat: async (interviewId, message) => service2.submitChat(interviewId, message),
29921
- handleNudgeAction: async (interviewId, action) => service2.handleNudgeAction(interviewId, action),
29922
- outputFolder: resolvedOutputPath,
29923
- port: 0
29924
- });
29925
- service2.setBaseUrlResolver(() => server.ensureStarted());
29926
- return {
29927
- registerCommand: (c) => service2.registerCommand(c),
29928
- handleCommandExecuteBefore: async (input, output) => service2.handleCommandExecuteBefore(input, output),
29929
- handleEvent: async (input) => service2.handleEvent(input)
29930
- };
29931
- }
29932
- const dashboardPort = effectivePort > 0 ? effectivePort : DEFAULT_DASHBOARD_PORT;
29933
31085
  const service = createInterviewService(ctx, interviewConfig);
29934
31086
  let initDone = false;
29935
31087
  let isDashboard = false;
@@ -29939,6 +31091,12 @@ function createInterviewManager(ctx, config) {
29939
31091
  const registeredSessions = new Set;
29940
31092
  const FALLBACK_POLL_INTERVAL = 1e4;
29941
31093
  let fallbackTimer = null;
31094
+ const stopFallbackTimer = () => {
31095
+ if (!fallbackTimer)
31096
+ return;
31097
+ clearInterval(fallbackTimer);
31098
+ fallbackTimer = null;
31099
+ };
29942
31100
  const startFallbackTimer = () => {
29943
31101
  if (fallbackTimer)
29944
31102
  return;
@@ -30049,7 +31207,7 @@ function createInterviewManager(ctx, config) {
30049
31207
  log("[interview] dashboard election failed or unreachable. Falling back to per-session server.", { error: err instanceof Error ? err.message : String(err) });
30050
31208
  isDashboard = false;
30051
31209
  const resolvedOutputPath = path16.join(ctx.directory, outputFolder);
30052
- const server = createInterviewServer({
31210
+ const fallbackServer = createInterviewServer({
30053
31211
  getState: async (interviewId) => service.getInterviewState(interviewId),
30054
31212
  listInterviewFiles: async () => service.listInterviewFiles(),
30055
31213
  listInterviews: () => service.listInterviews(),
@@ -30060,7 +31218,7 @@ function createInterviewManager(ctx, config) {
30060
31218
  outputFolder: resolvedOutputPath,
30061
31219
  port: 0
30062
31220
  });
30063
- service.setBaseUrlResolver(() => server.ensureStarted());
31221
+ service.setBaseUrlResolver(() => fallbackServer.ensureStarted());
30064
31222
  service.setStatePushCallback(() => {});
30065
31223
  } finally {
30066
31224
  initDone = true;
@@ -30076,7 +31234,10 @@ function createInterviewManager(ctx, config) {
30076
31234
  if (!interviewId)
30077
31235
  return;
30078
31236
  try {
30079
- const res = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/pending?token=${authToken}`, { signal: AbortSignal.timeout(3000) });
31237
+ const res = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/pending`, {
31238
+ headers: authHeaders(authToken),
31239
+ signal: AbortSignal.timeout(3000)
31240
+ });
30080
31241
  const body = await res.json();
30081
31242
  if (res.ok && body.answers && body.answers.length > 0) {
30082
31243
  log("[interview] delivering pending answers (HTTP poll)", {
@@ -30096,7 +31257,10 @@ function createInterviewManager(ctx, config) {
30096
31257
  if (!interviewId)
30097
31258
  return;
30098
31259
  try {
30099
- const res = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/nudge?token=${authToken}`, { signal: AbortSignal.timeout(3000) });
31260
+ const res = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/nudge`, {
31261
+ headers: authHeaders(authToken),
31262
+ signal: AbortSignal.timeout(3000)
31263
+ });
30100
31264
  const body = await res.json();
30101
31265
  if (res.ok && body.action) {
30102
31266
  log("[interview] delivering nudge action (HTTP poll)", {
@@ -30116,7 +31280,10 @@ function createInterviewManager(ctx, config) {
30116
31280
  if (!interviewId)
30117
31281
  return;
30118
31282
  try {
30119
- const res = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/block-comment?token=${authToken}`, { signal: AbortSignal.timeout(3000) });
31283
+ const res = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/block-comment`, {
31284
+ headers: authHeaders(authToken),
31285
+ signal: AbortSignal.timeout(3000)
31286
+ });
30120
31287
  const body = await res.json();
30121
31288
  if (res.ok && body.section && body.comment) {
30122
31289
  log("[interview] delivering block comment (HTTP poll)", {
@@ -30136,7 +31303,10 @@ function createInterviewManager(ctx, config) {
30136
31303
  if (!interviewId)
30137
31304
  return;
30138
31305
  try {
30139
- const res = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/chat?token=${authToken}`, { signal: AbortSignal.timeout(3000) });
31306
+ const res = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/chat`, {
31307
+ headers: authHeaders(authToken),
31308
+ signal: AbortSignal.timeout(3000)
31309
+ });
30140
31310
  const body = await res.json();
30141
31311
  if (res.ok && body.message) {
30142
31312
  log("[interview] delivering chat message (HTTP poll)", {
@@ -30157,9 +31327,12 @@ function createInterviewManager(ctx, config) {
30157
31327
  const sessionID = input.sessionID;
30158
31328
  registeredSessions.add(sessionID);
30159
31329
  if (!isDashboard && dashboardBaseUrl) {
30160
- fetch(`${dashboardBaseUrl}/api/sessions?token=${authToken}`, {
31330
+ fetch(`${dashboardBaseUrl}/api/sessions`, {
30161
31331
  method: "POST",
30162
- headers: { "content-type": "application/json" },
31332
+ headers: {
31333
+ ...authHeaders(authToken),
31334
+ "content-type": "application/json"
31335
+ },
30163
31336
  body: JSON.stringify({
30164
31337
  sessionID,
30165
31338
  directory: ctx.directory,
@@ -30177,9 +31350,10 @@ function createInterviewManager(ctx, config) {
30177
31350
  const properties = event.properties ?? {};
30178
31351
  const sessionID = properties.sessionID ?? null;
30179
31352
  await service.handleEvent(input);
30180
- if (event.type === "session.status") {
31353
+ if (event.type === "session.status" || event.type === "session.idle") {
30181
31354
  const status = properties.status;
30182
- if (sessionID && status?.type === "idle") {
31355
+ const isIdleEvent = event.type === "session.idle" || status?.type === "idle";
31356
+ if (sessionID && isIdleEvent) {
30183
31357
  const interviewId = service.getActiveInterviewId(sessionID);
30184
31358
  if (!isDashboard && dashboardBaseUrl) {
30185
31359
  await pollPendingAnswers(sessionID);
@@ -30230,11 +31404,17 @@ function createInterviewManager(ctx, config) {
30230
31404
  }
30231
31405
  if (event.type === "session.deleted" && sessionID) {
30232
31406
  registeredSessions.delete(sessionID);
31407
+ if (!isDashboard && registeredSessions.size === 0) {
31408
+ stopFallbackTimer();
31409
+ }
30233
31410
  dashboard?.removeSession(sessionID);
30234
31411
  }
30235
31412
  }
30236
31413
  };
30237
31414
  }
31415
+ function authHeaders(token) {
31416
+ return { authorization: `Bearer ${token}` };
31417
+ }
30238
31418
  function stateToEntry(interviewId, state) {
30239
31419
  return {
30240
31420
  interviewId,
@@ -30261,17 +31441,23 @@ function stateToEntry(interviewId, state) {
30261
31441
  }
30262
31442
  async function pushStateViaHttp(dashboardUrl, token, interviewId, state) {
30263
31443
  const entry = stateToEntry(interviewId, state);
30264
- await fetch(`${dashboardUrl}/api/interviews/${interviewId}/state?token=${token}`, {
31444
+ await fetch(`${dashboardUrl}/api/interviews/${interviewId}/state`, {
30265
31445
  method: "POST",
30266
- headers: { "content-type": "application/json" },
31446
+ headers: {
31447
+ ...authHeaders(token),
31448
+ "content-type": "application/json"
31449
+ },
30267
31450
  body: JSON.stringify(entry),
30268
31451
  signal: AbortSignal.timeout(5000)
30269
31452
  });
30270
31453
  }
30271
31454
  async function registerInterviewViaHttp(dashboardUrl, token, interview) {
30272
- await fetch(`${dashboardUrl}/api/interviews?token=${token}`, {
31455
+ await fetch(`${dashboardUrl}/api/interviews`, {
30273
31456
  method: "POST",
30274
- headers: { "content-type": "application/json" },
31457
+ headers: {
31458
+ ...authHeaders(token),
31459
+ "content-type": "application/json"
31460
+ },
30275
31461
  body: JSON.stringify({
30276
31462
  interviewId: interview.id,
30277
31463
  sessionID: interview.sessionID,
@@ -30294,6 +31480,43 @@ async function registerInterviewViaHttp(dashboardUrl, token, interview) {
30294
31480
  });
30295
31481
  });
30296
31482
  }
31483
+
31484
+ // src/interview/session-server.ts
31485
+ import path17 from "node:path";
31486
+ function createPerSessionInterviewServer(ctx, interviewConfig, outputFolder) {
31487
+ const service = createInterviewService(ctx, interviewConfig);
31488
+ const resolvedOutputPath = path17.join(ctx.directory, outputFolder);
31489
+ const server = createInterviewServer({
31490
+ getState: async (interviewId) => service.getInterviewState(interviewId),
31491
+ listInterviewFiles: async () => service.listInterviewFiles(),
31492
+ listInterviews: () => service.listInterviews(),
31493
+ submitAnswers: async (interviewId, answers) => service.submitAnswers(interviewId, answers),
31494
+ submitBlockComment: async (interviewId, section, comment) => service.submitBlockComment(interviewId, section, comment),
31495
+ submitChat: async (interviewId, message) => service.submitChat(interviewId, message),
31496
+ handleNudgeAction: async (interviewId, action) => service.handleNudgeAction(interviewId, action),
31497
+ outputFolder: resolvedOutputPath,
31498
+ port: 0
31499
+ });
31500
+ service.setBaseUrlResolver(() => server.ensureStarted());
31501
+ return {
31502
+ registerCommand: (c) => service.registerCommand(c),
31503
+ handleCommandExecuteBefore: async (input, output) => service.handleCommandExecuteBefore(input, output),
31504
+ handleEvent: async (input) => service.handleEvent(input)
31505
+ };
31506
+ }
31507
+
31508
+ // src/interview/manager.ts
31509
+ function createInterviewManager(ctx, config) {
31510
+ const interviewConfig = config.interview;
31511
+ const effectivePort = interviewConfig?.port ?? 0;
31512
+ const dashboardEnabled = interviewConfig?.dashboard === true || effectivePort > 0;
31513
+ const outputFolder = interviewConfig?.outputFolder ?? "interview";
31514
+ if (!dashboardEnabled) {
31515
+ return createPerSessionInterviewServer(ctx, interviewConfig, outputFolder);
31516
+ }
31517
+ const dashboardPort = effectivePort > 0 ? effectivePort : DEFAULT_DASHBOARD_PORT;
31518
+ return createDashboardManager(ctx, config, dashboardPort, outputFolder);
31519
+ }
30297
31520
  // src/mcp/context7.ts
30298
31521
  var context7 = {
30299
31522
  type: "remote",
@@ -30350,6 +31573,209 @@ function createBuiltinMcps(disabledMcps = [], websearchConfig) {
30350
31573
  return mcps;
30351
31574
  }
30352
31575
 
31576
+ // src/multiplexer/herdr/index.ts
31577
+ init_compat();
31578
+ class HerdrMultiplexer {
31579
+ type = "herdr";
31580
+ binaryPath = null;
31581
+ hasChecked = false;
31582
+ parentPaneId = process.env.HERDR_PANE_ID;
31583
+ paneDirection;
31584
+ constructor(layout = "main-vertical", mainPaneSize = 60) {
31585
+ this.paneDirection = getPaneDirection(layout);
31586
+ }
31587
+ async isAvailable() {
31588
+ if (this.hasChecked) {
31589
+ return this.binaryPath !== null;
31590
+ }
31591
+ this.binaryPath = await this.findBinary();
31592
+ this.hasChecked = true;
31593
+ return this.binaryPath !== null;
31594
+ }
31595
+ isInsideSession() {
31596
+ return !!(process.env.HERDR_ENV || process.env.HERDR_PANE_ID);
31597
+ }
31598
+ async spawnPane(sessionId, description, serverUrl, directory) {
31599
+ const herdr = await this.getBinary();
31600
+ if (!herdr) {
31601
+ log("[herdr] spawnPane: herdr binary not found");
31602
+ return { success: false };
31603
+ }
31604
+ try {
31605
+ const splitArgs = [
31606
+ herdr,
31607
+ "pane",
31608
+ "split",
31609
+ ...this.targetPaneArg(),
31610
+ "--direction",
31611
+ this.paneDirection,
31612
+ "--cwd",
31613
+ directory,
31614
+ "--no-focus"
31615
+ ];
31616
+ log("[herdr] spawnPane: splitting pane", { args: splitArgs });
31617
+ const splitProc = crossSpawn(splitArgs, {
31618
+ stdout: "pipe",
31619
+ stderr: "pipe"
31620
+ });
31621
+ const splitExitCode = await splitProc.exited;
31622
+ const splitStdout = await splitProc.stdout();
31623
+ const splitStderr = await splitProc.stderr();
31624
+ if (splitExitCode !== 0) {
31625
+ log("[herdr] spawnPane: split failed", {
31626
+ exitCode: splitExitCode,
31627
+ stderr: splitStderr.trim()
31628
+ });
31629
+ return { success: false };
31630
+ }
31631
+ const paneId = parsePaneId(splitStdout);
31632
+ if (!paneId) {
31633
+ log("[herdr] spawnPane: could not parse pane_id from output", {
31634
+ stdout: splitStdout.trim()
31635
+ });
31636
+ return { success: false };
31637
+ }
31638
+ await crossSpawn([herdr, "pane", "rename", paneId, description.slice(0, 30)], { stdout: "ignore", stderr: "ignore" }).exited;
31639
+ const opencodeCmd = buildOpencodeAttachCommand(sessionId, serverUrl, directory);
31640
+ log("[herdr] spawnPane: running attach command", {
31641
+ paneId,
31642
+ command: opencodeCmd
31643
+ });
31644
+ const runProc = crossSpawn([herdr, "pane", "run", paneId, opencodeCmd], {
31645
+ stdout: "pipe",
31646
+ stderr: "pipe"
31647
+ });
31648
+ const runExitCode = await runProc.exited;
31649
+ if (runExitCode !== 0) {
31650
+ const runStderr = await runProc.stderr();
31651
+ log("[herdr] spawnPane: run failed", {
31652
+ exitCode: runExitCode,
31653
+ stderr: runStderr.trim()
31654
+ });
31655
+ return { success: false };
31656
+ }
31657
+ log("[herdr] spawnPane: SUCCESS", { paneId });
31658
+ return { success: true, paneId };
31659
+ } catch (err) {
31660
+ log("[herdr] spawnPane: exception", { error: String(err) });
31661
+ return { success: false };
31662
+ }
31663
+ }
31664
+ async closePane(paneId) {
31665
+ if (!paneId || paneId === "unknown")
31666
+ return true;
31667
+ const herdr = await this.getBinary();
31668
+ if (!herdr) {
31669
+ log("[herdr] closePane: herdr binary not found");
31670
+ return false;
31671
+ }
31672
+ try {
31673
+ log("[herdr] closePane: sending Ctrl+C", { paneId });
31674
+ await crossSpawn([herdr, "pane", "send-keys", paneId, "ctrl+c"], {
31675
+ stdout: "ignore",
31676
+ stderr: "ignore"
31677
+ }).exited;
31678
+ await new Promise((r) => setTimeout(r, 250));
31679
+ log("[herdr] closePane: closing pane", { paneId });
31680
+ const proc = crossSpawn([herdr, "pane", "close", paneId], {
31681
+ stdout: "pipe",
31682
+ stderr: "pipe"
31683
+ });
31684
+ const exitCode = await proc.exited;
31685
+ const stderr = await proc.stderr();
31686
+ log("[herdr] closePane: result", { exitCode, stderr: stderr.trim() });
31687
+ if (exitCode === 0 || exitCode === 1) {
31688
+ return true;
31689
+ }
31690
+ log("[herdr] closePane: failed (pane may already be closed)", {
31691
+ paneId
31692
+ });
31693
+ return false;
31694
+ } catch (err) {
31695
+ log("[herdr] closePane: exception", { error: String(err) });
31696
+ return false;
31697
+ }
31698
+ }
31699
+ async applyLayout(_layout, _mainPaneSize) {}
31700
+ targetPaneArg() {
31701
+ return this.parentPaneId ? [this.parentPaneId] : ["--current"];
31702
+ }
31703
+ async getBinary() {
31704
+ await this.isAvailable();
31705
+ return this.binaryPath;
31706
+ }
31707
+ async findBinary() {
31708
+ const cmd = process.platform === "win32" ? "where" : "which";
31709
+ try {
31710
+ const proc = crossSpawn([cmd, "herdr"], {
31711
+ stdout: "pipe",
31712
+ stderr: "pipe"
31713
+ });
31714
+ const exitCode = await proc.exited;
31715
+ if (exitCode !== 0) {
31716
+ log("[herdr] findBinary: 'which herdr' failed", { exitCode });
31717
+ return null;
31718
+ }
31719
+ const stdout = await proc.stdout();
31720
+ const path18 = stdout.trim().split(`
31721
+ `)[0];
31722
+ if (!path18) {
31723
+ log("[herdr] findBinary: no path in output");
31724
+ return null;
31725
+ }
31726
+ log("[herdr] findBinary: found", { path: path18 });
31727
+ return path18;
31728
+ } catch (err) {
31729
+ log("[herdr] findBinary: exception", { error: String(err) });
31730
+ return null;
31731
+ }
31732
+ }
31733
+ }
31734
+ function parsePaneId(stdout) {
31735
+ const trimmed = stdout.trim();
31736
+ if (!trimmed)
31737
+ return null;
31738
+ for (const line of trimmed.split(`
31739
+ `)) {
31740
+ const candidate = line.trim();
31741
+ if (!candidate)
31742
+ continue;
31743
+ try {
31744
+ const response = JSON.parse(candidate);
31745
+ const paneId = response.result?.pane?.pane_id;
31746
+ if (paneId)
31747
+ return paneId;
31748
+ } catch {}
31749
+ }
31750
+ log("[herdr] parsePaneId: no pane_id found in output", { stdout: trimmed });
31751
+ return null;
31752
+ }
31753
+ function getPaneDirection(layout) {
31754
+ switch (layout) {
31755
+ case "main-horizontal":
31756
+ case "even-vertical":
31757
+ return "down";
31758
+ case "main-vertical":
31759
+ case "even-horizontal":
31760
+ case "tiled":
31761
+ return "right";
31762
+ }
31763
+ }
31764
+ function buildOpencodeAttachCommand(sessionId, serverUrl, directory) {
31765
+ return [
31766
+ "opencode",
31767
+ "attach",
31768
+ quoteShellArg(serverUrl),
31769
+ "--session",
31770
+ quoteShellArg(sessionId),
31771
+ "--dir",
31772
+ quoteShellArg(directory)
31773
+ ].join(" ");
31774
+ }
31775
+ function quoteShellArg(value) {
31776
+ return `'${value.replace(/'/g, `'\\''`)}'`;
31777
+ }
31778
+
30353
31779
  // src/multiplexer/tmux/index.ts
30354
31780
  init_compat();
30355
31781
  var TMUX_LAYOUT_DEBOUNCE_MS = 150;
@@ -30385,9 +31811,9 @@ class TmuxMultiplexer {
30385
31811
  return { success: false };
30386
31812
  }
30387
31813
  try {
30388
- const quotedDirectory = quoteShellArg(directory);
30389
- const quotedUrl = quoteShellArg(serverUrl);
30390
- const quotedSessionId = quoteShellArg(sessionId);
31814
+ const quotedDirectory = quoteShellArg2(directory);
31815
+ const quotedUrl = quoteShellArg2(serverUrl);
31816
+ const quotedSessionId = quoteShellArg2(sessionId);
30391
31817
  const opencodeCmd = [
30392
31818
  "opencode",
30393
31819
  "attach",
@@ -30569,30 +31995,30 @@ class TmuxMultiplexer {
30569
31995
  return null;
30570
31996
  }
30571
31997
  const stdout = await proc.stdout();
30572
- const path17 = stdout.trim().split(`
31998
+ const path18 = stdout.trim().split(`
30573
31999
  `)[0];
30574
- if (!path17) {
32000
+ if (!path18) {
30575
32001
  log("[tmux] findBinary: no path in output");
30576
32002
  return null;
30577
32003
  }
30578
- const verifyProc = crossSpawn([path17, "-V"], {
32004
+ const verifyProc = crossSpawn([path18, "-V"], {
30579
32005
  stdout: "pipe",
30580
32006
  stderr: "pipe"
30581
32007
  });
30582
32008
  const verifyExit = await verifyProc.exited;
30583
32009
  if (verifyExit !== 0) {
30584
- log("[tmux] findBinary: tmux -V failed", { path: path17, verifyExit });
32010
+ log("[tmux] findBinary: tmux -V failed", { path: path18, verifyExit });
30585
32011
  return null;
30586
32012
  }
30587
- log("[tmux] findBinary: found", { path: path17 });
30588
- return path17;
32013
+ log("[tmux] findBinary: found", { path: path18 });
32014
+ return path18;
30589
32015
  } catch (err) {
30590
32016
  log("[tmux] findBinary: exception", { error: String(err) });
30591
32017
  return null;
30592
32018
  }
30593
32019
  }
30594
32020
  }
30595
- function quoteShellArg(value) {
32021
+ function quoteShellArg2(value) {
30596
32022
  return `'${value.replace(/'/g, `'\\''`)}'`;
30597
32023
  }
30598
32024
 
@@ -30612,7 +32038,7 @@ class ZellijMultiplexer {
30612
32038
  paneDirection;
30613
32039
  constructor(layout = "main-vertical", mainPaneSize = 60, paneMode = "agent-tab") {
30614
32040
  this.paneMode = paneMode;
30615
- this.paneDirection = getPaneDirection(layout);
32041
+ this.paneDirection = getPaneDirection2(layout);
30616
32042
  }
30617
32043
  async isAvailable() {
30618
32044
  if (this.hasChecked) {
@@ -30653,7 +32079,7 @@ class ZellijMultiplexer {
30653
32079
  }
30654
32080
  }
30655
32081
  async createPaneInCurrentTab(zellij, sessionId, serverUrl, directory, description) {
30656
- const opencodeCmd = buildOpencodeAttachCommand(sessionId, serverUrl, directory);
32082
+ const opencodeCmd = buildOpencodeAttachCommand2(sessionId, serverUrl, directory);
30657
32083
  const paneName = description.slice(0, 30).replace(/"/g, "\\\"");
30658
32084
  const targetTabId = await this.getParentTabId(zellij);
30659
32085
  const args = [
@@ -30682,7 +32108,7 @@ class ZellijMultiplexer {
30682
32108
  return { success: false };
30683
32109
  }
30684
32110
  async createPaneInAgentTab(zellij, sessionId, serverUrl, directory, description) {
30685
- const opencodeCmd = buildOpencodeAttachCommand(sessionId, serverUrl, directory);
32111
+ const opencodeCmd = buildOpencodeAttachCommand2(sessionId, serverUrl, directory);
30686
32112
  const paneName = description.slice(0, 30).replace(/"/g, "\\\"");
30687
32113
  const currentTabId = await this.getCurrentTabId(zellij);
30688
32114
  const inAgentTab = currentTabId === this.agentTabId;
@@ -30751,7 +32177,7 @@ class ZellijMultiplexer {
30751
32177
  }
30752
32178
  async runInPane(zellij, paneId, sessionId, serverUrl, directory, description) {
30753
32179
  try {
30754
- const opencodeCmd = buildOpencodeAttachCommand(sessionId, serverUrl, directory);
32180
+ const opencodeCmd = buildOpencodeAttachCommand2(sessionId, serverUrl, directory);
30755
32181
  await crossSpawn([zellij, "action", "focus-pane", "--pane-id", paneId], {
30756
32182
  stdout: "ignore",
30757
32183
  stderr: "ignore"
@@ -30975,7 +32401,7 @@ class ZellijMultiplexer {
30975
32401
  function normalizePaneId(paneId) {
30976
32402
  return paneId.replace(/^terminal_/, "");
30977
32403
  }
30978
- function getPaneDirection(layout) {
32404
+ function getPaneDirection2(layout) {
30979
32405
  switch (layout) {
30980
32406
  case "main-vertical":
30981
32407
  return "right";
@@ -30987,21 +32413,21 @@ function getPaneDirection(layout) {
30987
32413
  return null;
30988
32414
  }
30989
32415
  }
30990
- function buildOpencodeAttachCommand(sessionId, serverUrl, directory) {
32416
+ function buildOpencodeAttachCommand2(sessionId, serverUrl, directory) {
30991
32417
  return [
30992
32418
  "opencode",
30993
32419
  "attach",
30994
- quoteShellArg2(serverUrl),
32420
+ quoteShellArg3(serverUrl),
30995
32421
  "--session",
30996
- quoteShellArg2(sessionId),
32422
+ quoteShellArg3(sessionId),
30997
32423
  "--dir",
30998
- quoteShellArg2(directory)
32424
+ quoteShellArg3(directory)
30999
32425
  ].join(" ");
31000
32426
  }
31001
32427
  function buildShellLaunchCommand(command) {
31002
- return ["sh", "-lc", quoteShellArg2(command)].join(" ");
32428
+ return ["sh", "-lc", quoteShellArg3(command)].join(" ");
31003
32429
  }
31004
- function quoteShellArg2(value) {
32430
+ function quoteShellArg3(value) {
31005
32431
  return `'${value.replace(/'/g, `'\\''`)}'`;
31006
32432
  }
31007
32433
 
@@ -31022,6 +32448,10 @@ function getMultiplexer(config) {
31022
32448
  multiplexer = new ZellijMultiplexer(config.layout, config.main_pane_size, config.zellij_pane_mode);
31023
32449
  actualType = "zellij";
31024
32450
  break;
32451
+ case "herdr":
32452
+ multiplexer = new HerdrMultiplexer(config.layout, config.main_pane_size);
32453
+ actualType = "herdr";
32454
+ break;
31025
32455
  case "auto": {
31026
32456
  if (process.env.TMUX) {
31027
32457
  multiplexer = new TmuxMultiplexer(config.layout, config.main_pane_size);
@@ -31029,6 +32459,9 @@ function getMultiplexer(config) {
31029
32459
  } else if (process.env.ZELLIJ) {
31030
32460
  multiplexer = new ZellijMultiplexer(config.layout, config.main_pane_size, config.zellij_pane_mode);
31031
32461
  actualType = "zellij";
32462
+ } else if (process.env.HERDR_ENV || process.env.HERDR_PANE_ID) {
32463
+ multiplexer = new HerdrMultiplexer(config.layout, config.main_pane_size);
32464
+ actualType = "herdr";
31032
32465
  } else {
31033
32466
  log("[multiplexer] auto: not inside any session, disabling");
31034
32467
  return null;
@@ -31194,7 +32627,7 @@ class MultiplexerSessionManager {
31194
32627
  tracked: this.sessions.has(sessionId2),
31195
32628
  known: this.knownSessions.has(sessionId2),
31196
32629
  ownerInstanceId: this.sessions.get(sessionId2)?.ownerInstanceId,
31197
- backgroundJobState: this.backgroundJobBoard?.get(sessionId2)?.state
32630
+ backgroundJobState: this.backgroundJobState(sessionId2)
31198
32631
  });
31199
32632
  await this.closeSession(sessionId2, "idle");
31200
32633
  return;
@@ -31212,22 +32645,23 @@ class MultiplexerSessionManager {
31212
32645
  tracked: this.sessions.has(sessionId),
31213
32646
  known: this.knownSessions.has(sessionId),
31214
32647
  ownerInstanceId: this.sessions.get(sessionId)?.ownerInstanceId,
31215
- backgroundJobState: this.backgroundJobBoard?.get(sessionId)?.state
32648
+ backgroundJobState: this.backgroundJobState(sessionId)
31216
32649
  });
31217
32650
  await this.closeSession(sessionId, "idle");
31218
32651
  return;
31219
32652
  }
31220
32653
  if (statusType) {
31221
- this.deferredIdleCloses.delete(sessionId);
31222
- if (statusType !== "busy")
32654
+ if (statusType !== "busy") {
32655
+ this.deferredIdleCloses.delete(sessionId);
31223
32656
  return;
32657
+ }
31224
32658
  log("[multiplexer-session-manager] session busy event received", {
31225
32659
  instanceId: this.instanceId,
31226
32660
  sessionId,
31227
32661
  tracked: this.sessions.has(sessionId),
31228
32662
  known: this.knownSessions.has(sessionId),
31229
32663
  ownerInstanceId: this.sessions.get(sessionId)?.ownerInstanceId,
31230
- backgroundJobState: this.backgroundJobBoard?.get(sessionId)?.state
32664
+ backgroundJobState: this.backgroundJobState(sessionId)
31231
32665
  });
31232
32666
  await this.respawnIfKnown(sessionId);
31233
32667
  }
@@ -31246,7 +32680,7 @@ class MultiplexerSessionManager {
31246
32680
  tracked: this.sessions.has(sessionId),
31247
32681
  known: this.knownSessions.has(sessionId),
31248
32682
  ownerInstanceId: this.sessions.get(sessionId)?.ownerInstanceId,
31249
- backgroundJobState: this.backgroundJobBoard?.get(sessionId)?.state
32683
+ backgroundJobState: this.backgroundJobState(sessionId)
31250
32684
  });
31251
32685
  this.deferredIdleCloses.delete(sessionId);
31252
32686
  await this.closeSession(sessionId, "deleted");
@@ -31363,7 +32797,7 @@ class MultiplexerSessionManager {
31363
32797
  sessionId,
31364
32798
  paneId: tracked.paneId,
31365
32799
  reason,
31366
- backgroundJobState: this.backgroundJobBoard?.get(sessionId)?.state
32800
+ backgroundJobState: this.backgroundJobState(sessionId)
31367
32801
  });
31368
32802
  return;
31369
32803
  }
@@ -31374,7 +32808,7 @@ class MultiplexerSessionManager {
31374
32808
  sessionId,
31375
32809
  paneId: tracked.paneId,
31376
32810
  reason,
31377
- backgroundJobState: this.backgroundJobBoard?.get(sessionId)?.state,
32811
+ backgroundJobState: this.backgroundJobState(sessionId),
31378
32812
  parentId: tracked.parentId,
31379
32813
  title: tracked.title
31380
32814
  });
@@ -31475,8 +32909,11 @@ class MultiplexerSessionManager {
31475
32909
  getSessionId(event) {
31476
32910
  return event.properties?.info?.id ?? event.properties?.sessionID;
31477
32911
  }
32912
+ backgroundJobState(sessionId) {
32913
+ return this.backgroundJobBoard?.getState(sessionId);
32914
+ }
31478
32915
  isRunningBackgroundJob(sessionId) {
31479
- return this.backgroundJobBoard?.get(sessionId)?.state === "running";
32916
+ return this.backgroundJobBoard?.isRunning(sessionId) ?? false;
31480
32917
  }
31481
32918
  async retryDeferredIdleClose(sessionId) {
31482
32919
  if (!this.enabled)
@@ -31633,8 +33070,8 @@ class AcpClient {
31633
33070
  request(method, params) {
31634
33071
  const id = this.next++;
31635
33072
  const payload = { jsonrpc: "2.0", id, method, params };
31636
- return new Promise((resolve3, reject) => {
31637
- this.pending.set(id, { resolve: resolve3, reject });
33073
+ return new Promise((resolve4, reject) => {
33074
+ this.pending.set(id, { resolve: resolve4, reject });
31638
33075
  this.child.stdin.write(`${JSON.stringify(payload)}
31639
33076
  `, (error) => {
31640
33077
  if (!error)
@@ -31651,7 +33088,7 @@ class AcpClient {
31651
33088
  async drain() {
31652
33089
  this.lastUpdate = Date.now();
31653
33090
  while (this.activeRequests > 0 || Date.now() - this.lastUpdate < 100) {
31654
- await new Promise((resolve3) => setTimeout(resolve3, 25));
33091
+ await new Promise((resolve4) => setTimeout(resolve4, 25));
31655
33092
  }
31656
33093
  }
31657
33094
  async receive(line) {
@@ -31876,10 +33313,10 @@ import { existsSync as existsSync11 } from "node:fs";
31876
33313
  // src/tools/ast-grep/constants.ts
31877
33314
  import { existsSync as existsSync10, statSync as statSync5 } from "node:fs";
31878
33315
  import { createRequire as createRequire3 } from "node:module";
31879
- import { dirname as dirname9, join as join15 } from "node:path";
33316
+ import { dirname as dirname10, join as join15 } from "node:path";
31880
33317
 
31881
33318
  // src/tools/ast-grep/downloader.ts
31882
- import { chmodSync as chmodSync2, existsSync as existsSync9, mkdirSync as mkdirSync7, unlinkSync as unlinkSync4 } from "node:fs";
33319
+ import { chmodSync as chmodSync2, existsSync as existsSync9, mkdirSync as mkdirSync7, unlinkSync as unlinkSync5 } from "node:fs";
31883
33320
  import { createRequire as createRequire2 } from "node:module";
31884
33321
  import { homedir as homedir6 } from "node:os";
31885
33322
  import { join as join14 } from "node:path";
@@ -31934,8 +33371,8 @@ async function downloadAstGrep(version = DEFAULT_VERSION) {
31934
33371
  if (existsSync9(binaryPath)) {
31935
33372
  return binaryPath;
31936
33373
  }
31937
- const { arch, os: os5 } = platformInfo;
31938
- const assetName = `app-${arch}-${os5}.zip`;
33374
+ const { arch, os: os6 } = platformInfo;
33375
+ const assetName = `app-${arch}-${os6}.zip`;
31939
33376
  const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`;
31940
33377
  console.log(`[oh-my-opencode-slim] Downloading ast-grep binary...`);
31941
33378
  try {
@@ -31951,7 +33388,7 @@ async function downloadAstGrep(version = DEFAULT_VERSION) {
31951
33388
  await crossWrite(archivePath, arrayBuffer);
31952
33389
  await extractZip(archivePath, cacheDir);
31953
33390
  if (existsSync9(archivePath)) {
31954
- unlinkSync4(archivePath);
33391
+ unlinkSync5(archivePath);
31955
33392
  }
31956
33393
  if (process.platform !== "win32" && existsSync9(binaryPath)) {
31957
33394
  chmodSync2(binaryPath, 493);
@@ -32034,7 +33471,7 @@ function findSgCliPathSync() {
32034
33471
  try {
32035
33472
  const require2 = createRequire3(import.meta.url);
32036
33473
  const cliPkgPath = require2.resolve("@ast-grep/cli/package.json");
32037
- const cliDir = dirname9(cliPkgPath);
33474
+ const cliDir = dirname10(cliPkgPath);
32038
33475
  const sgPath = join15(cliDir, binaryName);
32039
33476
  if (existsSync10(sgPath) && isValidBinary(sgPath)) {
32040
33477
  return sgPath;
@@ -32045,7 +33482,7 @@ function findSgCliPathSync() {
32045
33482
  try {
32046
33483
  const require2 = createRequire3(import.meta.url);
32047
33484
  const pkgPath = require2.resolve(`${platformPkg}/package.json`);
32048
- const pkgDir = dirname9(pkgPath);
33485
+ const pkgDir = dirname10(pkgPath);
32049
33486
  const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep";
32050
33487
  const binaryPath = join15(pkgDir, astGrepName);
32051
33488
  if (existsSync10(binaryPath) && isValidBinary(binaryPath)) {
@@ -32055,9 +33492,9 @@ function findSgCliPathSync() {
32055
33492
  }
32056
33493
  if (process.platform === "darwin") {
32057
33494
  const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"];
32058
- for (const path17 of homebrewPaths) {
32059
- if (existsSync10(path17) && isValidBinary(path17)) {
32060
- return path17;
33495
+ for (const path18 of homebrewPaths) {
33496
+ if (existsSync10(path18) && isValidBinary(path18)) {
33497
+ return path18;
32061
33498
  }
32062
33499
  }
32063
33500
  }
@@ -32074,8 +33511,8 @@ function getSgCliPath() {
32074
33511
  }
32075
33512
  return "sg";
32076
33513
  }
32077
- function setSgCliPath(path17) {
32078
- resolvedCliPath = path17;
33514
+ function setSgCliPath(path18) {
33515
+ resolvedCliPath = path18;
32079
33516
  }
32080
33517
  var DEFAULT_TIMEOUT_MS = 300000;
32081
33518
  var DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024;
@@ -32449,9 +33886,9 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
32449
33886
  parentSessionID,
32450
33887
  requested,
32451
33888
  resolvedTaskID: job?.taskID,
32452
- alias: job?.alias,
32453
- state: job?.state,
32454
- terminalState: job?.terminalState,
33889
+ alias: job ? options.backgroundJobBoard.field(job.taskID, "alias") : undefined,
33890
+ state: job ? options.backgroundJobBoard.field(job.taskID, "state") : undefined,
33891
+ terminalState: job ? options.backgroundJobBoard.field(job.taskID, "terminalState") : undefined,
32455
33892
  cancellationRequested: job?.cancellationRequested
32456
33893
  });
32457
33894
  if (!job) {
@@ -32464,11 +33901,12 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
32464
33901
  return unknownTaskOutput(requested, "cannot cancel parent session");
32465
33902
  }
32466
33903
  const knownJob = options.backgroundJobBoard.get(requested);
32467
- if (knownJob && knownJob.parentSessionID !== parentSessionID) {
33904
+ const ownerParentSessionID = options.backgroundJobBoard.getParentSessionID(requested);
33905
+ if (knownJob && ownerParentSessionID !== parentSessionID) {
32468
33906
  log("[cancel-task] rejected unowned tracked raw session", {
32469
33907
  parentSessionID,
32470
33908
  taskID: requested,
32471
- ownerParentSessionID: knownJob.parentSessionID
33909
+ ownerParentSessionID
32472
33910
  });
32473
33911
  return unknownTaskOutput(requested, "unknown or unowned background task");
32474
33912
  }
@@ -32493,9 +33931,11 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
32493
33931
  await abortAndVerifySession(options, job.taskID);
32494
33932
  } catch (error) {
32495
33933
  const stillRunning = error instanceof SessionStillRunningError;
33934
+ const boardRunning = options.backgroundJobBoard.isRunning(job.taskID);
32496
33935
  log("[cancel-task] abort failed", {
32497
33936
  taskID: job.taskID,
32498
33937
  stillRunning,
33938
+ boardRunning,
32499
33939
  error: error instanceof Error ? error.message : String(error)
32500
33940
  });
32501
33941
  options.backgroundJobBoard.updateStatus({
@@ -32514,20 +33954,20 @@ Use only for obsolete, wrong, conflicting, or user-requested cancellation. Accep
32514
33954
  ].join(`
32515
33955
  `);
32516
33956
  }
32517
- const cancelled = options.backgroundJobBoard.markCancelled(job.taskID, args.reason, Date.now(), { force: true });
33957
+ options.backgroundJobBoard.markCancelled(job.taskID, args.reason, Date.now(), { force: true });
33958
+ const state = options.backgroundJobBoard.getState(job.taskID);
32518
33959
  log("[cancel-task] marked job cancelled after verified abort", {
32519
33960
  taskID: job.taskID,
32520
- alias: job.alias,
32521
- previousState: job.state,
32522
- state: cancelled?.state,
32523
- cancellationRequested: cancelled?.cancellationRequested
33961
+ alias: options.backgroundJobBoard.field(job.taskID, "alias"),
33962
+ state,
33963
+ cancellationRequested: options.backgroundJobBoard.field(job.taskID, "cancellationRequested")
32524
33964
  });
32525
33965
  return [
32526
33966
  `task_id: ${job.taskID}`,
32527
- `state: ${cancelled?.state ?? "cancelled"}`,
33967
+ `state: ${state ?? "cancelled"}`,
32528
33968
  "",
32529
33969
  "<task_error>",
32530
- cancelled?.resultSummary ?? "cancelled",
33970
+ options.backgroundJobBoard.getResultSummary(job.taskID) ?? "cancelled",
32531
33971
  "</task_error>"
32532
33972
  ].join(`
32533
33973
  `);
@@ -32609,10 +34049,10 @@ async function abortAndVerifySession(options, taskID) {
32609
34049
  statusKeys: statusSnapshot.keys,
32610
34050
  stableStoppedSince,
32611
34051
  stableStoppedForMs: stableStoppedSince ? Date.now() - stableStoppedSince : 0,
32612
- boardState: options.backgroundJobBoard.get(taskID)?.state,
32613
- boardLastLiveBusyAt: options.backgroundJobBoard.get(taskID)?.lastLiveBusyAt
34052
+ boardState: options.backgroundJobBoard.getState(taskID),
34053
+ boardLastLiveBusyAt: options.backgroundJobBoard.getLastLiveBusyAt(taskID)
32614
34054
  });
32615
- const boardLastLiveBusyAt = options.backgroundJobBoard.get(taskID)?.lastLiveBusyAt;
34055
+ const boardLastLiveBusyAt = options.backgroundJobBoard.getLastLiveBusyAt(taskID);
32616
34056
  if (boardLastLiveBusyAt && boardLastLiveBusyAt >= abortStartedAt) {
32617
34057
  log("[cancel-task] abort verification saw board busy after abort", {
32618
34058
  taskID,
@@ -32769,7 +34209,7 @@ async function getSessionStatus(client, taskID) {
32769
34209
  }
32770
34210
  }
32771
34211
  function delay2(ms) {
32772
- return new Promise((resolve3) => setTimeout(resolve3, ms));
34212
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
32773
34213
  }
32774
34214
  function isSessionID(value) {
32775
34215
  return /^ses_[\w-]+$/.test(value);
@@ -32861,10 +34301,10 @@ Returns the councillor responses with a summary footer.`,
32861
34301
  const trulyIgnored = hasMaster && !legacyMasterModel ? deprecated : deprecated.filter((f) => f !== "master");
32862
34302
  const parts = [];
32863
34303
  if (hasMaster && legacyMasterModel) {
32864
- parts.push(`\`council.master\` is deprecated and will be removed in a future version. Its \`model\` is currently used as a fallback for the council agent add a \`council\` entry to your preset to make this explicit.`);
34304
+ parts.push(`\`council.master\` is deprecated and will be removed in a future version. Its \`model\` is currently used as a fallback for the council agent - add a \`council\` entry to your preset to make this explicit.`);
32865
34305
  }
32866
34306
  if (trulyIgnored.length > 0) {
32867
- parts.push(`${trulyIgnored.map((f) => `\`council.${f}\``).join(", ")} ${trulyIgnored.length === 1 ? "is" : "are"} deprecated and ignored remove ${trulyIgnored.length === 1 ? "it" : "them"} from your config.`);
34307
+ parts.push(`${trulyIgnored.map((f) => `\`council.${f}\``).join(", ")} ${trulyIgnored.length === 1 ? "is" : "are"} deprecated and ignored - remove ${trulyIgnored.length === 1 ? "it" : "them"} from your config.`);
32868
34308
  }
32869
34309
  output += `
32870
34310
  ⚠ Config warning: ${parts.join(" ")}`;
@@ -32879,15 +34319,15 @@ import * as fs10 from "node:fs";
32879
34319
 
32880
34320
  // src/tui-state.ts
32881
34321
  import * as fs9 from "node:fs";
32882
- import * as os5 from "node:os";
32883
- import * as path17 from "node:path";
34322
+ import * as os6 from "node:os";
34323
+ import * as path18 from "node:path";
32884
34324
  var STATE_DIR = "oh-my-opencode-slim";
32885
34325
  var STATE_FILE = "tui-state.json";
32886
34326
  function dataDir() {
32887
- return process.env.XDG_DATA_HOME ?? path17.join(os5.homedir(), ".local", "share");
34327
+ return process.env.XDG_DATA_HOME ?? path18.join(os6.homedir(), ".local", "share");
32888
34328
  }
32889
34329
  function getTuiStatePath() {
32890
- return path17.join(dataDir(), "opencode", "storage", STATE_DIR, STATE_FILE);
34330
+ return path18.join(dataDir(), "opencode", "storage", STATE_DIR, STATE_FILE);
32891
34331
  }
32892
34332
  function emptySnapshot() {
32893
34333
  return {
@@ -32925,7 +34365,7 @@ async function readTuiSnapshotAsync() {
32925
34365
  function writeTuiSnapshot(snapshot) {
32926
34366
  try {
32927
34367
  const filePath = getTuiStatePath();
32928
- fs9.mkdirSync(path17.dirname(filePath), { recursive: true });
34368
+ fs9.mkdirSync(path18.dirname(filePath), { recursive: true });
32929
34369
  fs9.writeFileSync(filePath, `${JSON.stringify(snapshot)}
32930
34370
  `);
32931
34371
  } catch {}
@@ -32956,11 +34396,11 @@ function recordTuiAgentModel(input) {
32956
34396
  }
32957
34397
 
32958
34398
  // src/tools/preset-manager.ts
32959
- var COMMAND_NAME4 = "preset";
34399
+ var COMMAND_NAME5 = "preset";
32960
34400
  function createPresetManager(ctx, config) {
32961
34401
  let activePreset = getActiveRuntimePreset() ?? config.preset ?? null;
32962
34402
  async function handleCommandExecuteBefore(input, output) {
32963
- if (input.command !== COMMAND_NAME4) {
34403
+ if (input.command !== COMMAND_NAME5) {
32964
34404
  return;
32965
34405
  }
32966
34406
  output.parts.length = 0;
@@ -32979,11 +34419,11 @@ function createPresetManager(ctx, config) {
32979
34419
  }
32980
34420
  function registerCommand(opencodeConfig) {
32981
34421
  const configCommand = opencodeConfig.command;
32982
- if (!configCommand?.[COMMAND_NAME4]) {
34422
+ if (!configCommand?.[COMMAND_NAME5]) {
32983
34423
  if (!opencodeConfig.command) {
32984
34424
  opencodeConfig.command = {};
32985
34425
  }
32986
- opencodeConfig.command[COMMAND_NAME4] = {
34426
+ opencodeConfig.command[COMMAND_NAME5] = {
32987
34427
  template: "List available presets and switch between them",
32988
34428
  description: "Switch agent presets at runtime (e.g., /preset cheap, /preset powerful)"
32989
34429
  };
@@ -33138,15 +34578,15 @@ var BINARY_PREFIXES = [
33138
34578
  ];
33139
34579
  var WEBFETCH_DESCRIPTION = "Fetch a URL with better extraction for static/docs pages. Supports llms.txt probing, content-focused HTML extraction, metadata, redirects, and an optional prompt processed by a cheap secondary model.";
33140
34580
  // src/tools/smartfetch/tool.ts
33141
- import os6 from "node:os";
33142
- import path21 from "node:path";
34581
+ import os7 from "node:os";
34582
+ import path22 from "node:path";
33143
34583
  import {
33144
34584
  tool as tool5
33145
34585
  } from "@opencode-ai/plugin";
33146
34586
 
33147
34587
  // src/tools/smartfetch/binary.ts
33148
34588
  import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
33149
- import path18 from "node:path";
34589
+ import path19 from "node:path";
33150
34590
  function extensionForMime(contentType) {
33151
34591
  const mime = contentType.split(";")[0]?.trim().toLowerCase();
33152
34592
  const map = {
@@ -33167,10 +34607,10 @@ function buildBinaryResultMessage(fetchResult, savedPath) {
33167
34607
  async function saveBinary(binaryDir, data, contentType, filename) {
33168
34608
  await mkdir2(binaryDir, { recursive: true });
33169
34609
  const initialName = filename || `webfetch-${Date.now()}.${extensionForMime(contentType)}`;
33170
- const parsed = path18.parse(initialName);
34610
+ const parsed = path19.parse(initialName);
33171
34611
  for (let attempt = 0;attempt < 1000; attempt++) {
33172
34612
  const candidateName = attempt === 0 ? initialName : `${parsed.name}-${attempt}${parsed.ext || `.${extensionForMime(contentType)}`}`;
33173
- const file = path18.join(binaryDir, candidateName);
34613
+ const file = path19.join(binaryDir, candidateName);
33174
34614
  try {
33175
34615
  await writeFile2(file, data, { flag: "wx" });
33176
34616
  return file;
@@ -33184,21 +34624,21 @@ async function saveBinary(binaryDir, data, contentType, filename) {
33184
34624
  throw new Error("Unable to allocate unique filename for binary content");
33185
34625
  }
33186
34626
 
33187
- // node_modules/.pnpm/lru-cache@11.3.6/node_modules/lru-cache/dist/esm/node/index.min.js
33188
- import { tracingChannel as I, channel as G } from "node:diagnostics_channel";
33189
- var S = G("lru-cache:metrics");
33190
- var W = I("lru-cache");
33191
- var C = typeof performance == "object" && performance && typeof performance.now == "function" ? performance : Date;
34627
+ // node_modules/lru-cache/dist/esm/node/index.min.js
34628
+ import { tracingChannel as j, channel as I } from "node:diagnostics_channel";
34629
+ var S = I("lru-cache:metrics");
34630
+ var W = j("lru-cache");
33192
34631
  var D = () => S.hasSubscribers || W.hasSubscribers;
33193
- var U = new Set;
33194
- var L = typeof process == "object" && process ? process : {};
34632
+ var G = typeof performance == "object" && performance && typeof performance.now == "function" ? performance : Date;
34633
+ var M = new Set;
34634
+ var C = typeof process == "object" && process ? process : {};
33195
34635
  var P = (u, e, t, i) => {
33196
- typeof L.emitWarning == "function" ? L.emitWarning(u, e, t, i) : console.error(`[${t}] ${e}: ${u}`);
34636
+ typeof C.emitWarning == "function" ? C.emitWarning(u, e, t, i) : console.error(`[${t}] ${e}: ${u}`);
33197
34637
  };
33198
- var H = (u) => !U.has(u);
33199
- var X = Symbol("type");
34638
+ var H = (u) => !M.has(u);
34639
+ var $ = Symbol("type");
33200
34640
  var F = (u) => !!u && u === Math.floor(u) && u > 0 && isFinite(u);
33201
- var j = (u) => F(u) ? u <= Math.pow(2, 8) ? Uint8Array : u <= Math.pow(2, 16) ? Uint16Array : u <= Math.pow(2, 32) ? Uint32Array : u <= Number.MAX_SAFE_INTEGER ? O : null : null;
34641
+ var U = (u) => F(u) ? u <= Math.pow(2, 8) ? Uint8Array : u <= Math.pow(2, 16) ? Uint16Array : u <= Math.pow(2, 32) ? Uint32Array : u <= Number.MAX_SAFE_INTEGER ? O : null : null;
33202
34642
  var O = class extends Array {
33203
34643
  constructor(e) {
33204
34644
  super(e), this.fill(0);
@@ -33209,7 +34649,7 @@ var R = class u {
33209
34649
  length;
33210
34650
  static #o = false;
33211
34651
  static create(e) {
33212
- let t = j(e);
34652
+ let t = U(e);
33213
34653
  if (!t)
33214
34654
  return [];
33215
34655
  u.#o = true;
@@ -33228,11 +34668,11 @@ var R = class u {
33228
34668
  return this.heap[--this.length];
33229
34669
  }
33230
34670
  };
33231
- var M = class u2 {
34671
+ var L = class u2 {
33232
34672
  #o;
33233
34673
  #u;
33234
34674
  #w;
33235
- #x;
34675
+ #D;
33236
34676
  #S;
33237
34677
  #M;
33238
34678
  #U;
@@ -33303,7 +34743,7 @@ var M = class u2 {
33303
34743
  return this.#w;
33304
34744
  }
33305
34745
  get onInsert() {
33306
- return this.#x;
34746
+ return this.#D;
33307
34747
  }
33308
34748
  get disposeAfter() {
33309
34749
  return this.#S;
@@ -33312,9 +34752,9 @@ var M = class u2 {
33312
34752
  let { max: t = 0, ttl: i, ttlResolution: s = 1, ttlAutopurge: n, updateAgeOnGet: o, updateAgeOnHas: r, allowStale: h, dispose: l, onInsert: c, disposeAfter: f, noDisposeOnSet: g, noUpdateTTL: p, maxSize: T = 0, maxEntrySize: w = 0, sizeCalculation: y, fetchMethod: a, memoMethod: m, noDeleteOnFetchRejection: _, noDeleteOnStaleGet: b, allowStaleOnFetchRejection: d, allowStaleOnFetchAbort: A, ignoreFetchAbort: z7, perf: x } = e;
33313
34753
  if (x !== undefined && typeof x?.now != "function")
33314
34754
  throw new TypeError("perf option must have a now() method if specified");
33315
- if (this.#m = x ?? C, t !== 0 && !F(t))
34755
+ if (this.#m = x ?? G, t !== 0 && !F(t))
33316
34756
  throw new TypeError("max option must be a nonnegative integer");
33317
- let v = t ? j(t) : Array;
34757
+ let v = t ? U(t) : Array;
33318
34758
  if (!v)
33319
34759
  throw new Error("invalid max value: " + t);
33320
34760
  if (this.#o = t, this.#u = T, this.maxEntrySize = w || this.#u, this.sizeCalculation = y, this.sizeCalculation) {
@@ -33327,7 +34767,7 @@ var M = class u2 {
33327
34767
  throw new TypeError("memoMethod must be a function if defined");
33328
34768
  if (this.#U = m, a !== undefined && typeof a != "function")
33329
34769
  throw new TypeError("fetchMethod must be a function if specified");
33330
- if (this.#M = a, this.#W = !!a, this.#s = new Map, this.#i = Array.from({ length: t }).fill(undefined), this.#t = Array.from({ length: t }).fill(undefined), this.#a = new v(t), this.#c = new v(t), this.#l = 0, this.#h = 0, this.#y = R.create(t), this.#n = 0, this.#b = 0, typeof l == "function" && (this.#w = l), typeof c == "function" && (this.#x = c), typeof f == "function" ? (this.#S = f, this.#r = []) : (this.#S = undefined, this.#r = undefined), this.#T = !!this.#w, this.#j = !!this.#x, this.#f = !!this.#S, this.noDisposeOnSet = !!g, this.noUpdateTTL = !!p, this.noDeleteOnFetchRejection = !!_, this.allowStaleOnFetchRejection = !!d, this.allowStaleOnFetchAbort = !!A, this.ignoreFetchAbort = !!z7, this.maxEntrySize !== 0) {
34770
+ if (this.#M = a, this.#W = !!a, this.#s = new Map, this.#i = Array.from({ length: t }).fill(undefined), this.#t = Array.from({ length: t }).fill(undefined), this.#a = new v(t), this.#c = new v(t), this.#l = 0, this.#h = 0, this.#y = R.create(t), this.#n = 0, this.#b = 0, typeof l == "function" && (this.#w = l), typeof c == "function" && (this.#D = c), typeof f == "function" ? (this.#S = f, this.#r = []) : (this.#S = undefined, this.#r = undefined), this.#T = !!this.#w, this.#j = !!this.#D, this.#f = !!this.#S, this.noDisposeOnSet = !!g, this.noUpdateTTL = !!p, this.noDeleteOnFetchRejection = !!_, this.allowStaleOnFetchRejection = !!d, this.allowStaleOnFetchAbort = !!A, this.ignoreFetchAbort = !!z7, this.maxEntrySize !== 0) {
33331
34771
  if (this.#u !== 0 && !F(this.#u))
33332
34772
  throw new TypeError("maxSize must be a positive integer if specified");
33333
34773
  if (!F(this.maxEntrySize))
@@ -33343,7 +34783,7 @@ var M = class u2 {
33343
34783
  throw new TypeError("At least one of max, maxSize, or ttl is required");
33344
34784
  if (!this.ttlAutopurge && !this.#o && !this.#u) {
33345
34785
  let E = "LRU_CACHE_UNBOUNDED";
33346
- H(E) && (U.add(E), P("TTL caching without ttlAutopurge, max, or maxSize can result in unbounded memory consumption.", "UnboundedCacheWarning", E, u2));
34786
+ H(E) && (M.add(E), P("TTL caching without ttlAutopurge, max, or maxSize can result in unbounded memory consumption.", "UnboundedCacheWarning", E, u2));
33347
34787
  }
33348
34788
  }
33349
34789
  getRemainingTTL(e) {
@@ -33355,7 +34795,7 @@ var M = class u2 {
33355
34795
  let i = this.ttlAutopurge ? Array.from({ length: this.#o }) : undefined;
33356
34796
  this.#g = i, this.#N = (r, h, l = this.#m.now()) => {
33357
34797
  t[r] = h !== 0 ? l : 0, e[r] = h, s(r, h);
33358
- }, this.#D = (r) => {
34798
+ }, this.#x = (r) => {
33359
34799
  t[r] = e[r] !== 0 ? this.#m.now() : 0, s(r, e[r]);
33360
34800
  };
33361
34801
  let s = this.ttlAutopurge ? (r, h) => {
@@ -33399,7 +34839,7 @@ var M = class u2 {
33399
34839
  return !!l && !!h && (n || o()) - h > l;
33400
34840
  };
33401
34841
  }
33402
- #D = () => {};
34842
+ #x = () => {};
33403
34843
  #E = () => {};
33404
34844
  #N = () => {};
33405
34845
  #p = () => false;
@@ -33565,7 +35005,7 @@ var M = class u2 {
33565
35005
  return this.#v(e, "set"), h && (h.set = "miss", h.maxEntrySizeExceeded = true), this;
33566
35006
  let f = this.#n === 0 ? undefined : this.#s.get(e);
33567
35007
  if (f === undefined)
33568
- f = this.#n === 0 ? this.#h : this.#y.length !== 0 ? this.#y.pop() : this.#n === this.#o ? this.#G(false) : this.#n, this.#i[f] = e, this.#t[f] = t, this.#s.set(e, f), this.#a[this.#h] = f, this.#c[f] = this.#h, this.#h = f, this.#n++, this.#I(f, c, h), h && (h.set = "add"), l = false, this.#j && this.#x?.(t, e, "add");
35008
+ f = this.#n === 0 ? this.#h : this.#y.length !== 0 ? this.#y.pop() : this.#n === this.#o ? this.#G(false) : this.#n, this.#i[f] = e, this.#t[f] = t, this.#s.set(e, f), this.#a[this.#h] = f, this.#c[f] = this.#h, this.#h = f, this.#n++, this.#I(f, c, h), h && (h.set = "add"), l = false, this.#j && this.#D?.(t, e, "add");
33569
35009
  else {
33570
35010
  this.#L(f);
33571
35011
  let g = this.#t[f];
@@ -33629,7 +35069,7 @@ var M = class u2 {
33629
35069
  if (this.#p(n))
33630
35070
  s && (s.has = "stale", this.#E(s, n));
33631
35071
  else
33632
- return i && this.#D(n), s && (s.has = "hit", this.#E(s, n)), true;
35072
+ return i && this.#x(n), s && (s.has = "hit", this.#E(s, n)), true;
33633
35073
  } else
33634
35074
  s && (s.has = "miss");
33635
35075
  return false;
@@ -33687,7 +35127,7 @@ var M = class u2 {
33687
35127
  let i = W.hasSubscribers, { status: s = D() ? {} : undefined } = t;
33688
35128
  t.status = s, s && t.context && (s.context = t.context);
33689
35129
  let n = this.#B(e, t);
33690
- return s && i && (s.trace = true, W.tracePromise(() => n, s).catch(() => {})), n;
35130
+ return s && D() && i && (s.trace = true, W.tracePromise(() => n, s).catch(() => {})), n;
33691
35131
  }
33692
35132
  async#B(e, t = {}) {
33693
35133
  let { allowStale: i = this.allowStale, updateAgeOnGet: s = this.updateAgeOnGet, noDeleteOnStaleGet: n = this.noDeleteOnStaleGet, ttl: o = this.ttl, noDisposeOnSet: r = this.noDisposeOnSet, size: h = 0, sizeCalculation: l = this.sizeCalculation, noUpdateTTL: c = this.noUpdateTTL, noDeleteOnFetchRejection: f = this.noDeleteOnFetchRejection, allowStaleOnFetchRejection: g = this.allowStaleOnFetchRejection, ignoreFetchAbort: p = this.ignoreFetchAbort, allowStaleOnFetchAbort: T = this.allowStaleOnFetchAbort, context: w, forceRefresh: y = false, status: a, signal: m } = t;
@@ -33706,7 +35146,7 @@ var M = class u2 {
33706
35146
  }
33707
35147
  let A = this.#p(b);
33708
35148
  if (!y && !A)
33709
- return a && (a.fetch = "hit"), this.#L(b), s && this.#D(b), a && this.#E(a, b), d;
35149
+ return a && (a.fetch = "hit"), this.#L(b), s && this.#x(b), a && this.#E(a, b), d;
33710
35150
  let z7 = this.#P(e, b, _, w), v = z7.__staleWhileFetching !== undefined && i;
33711
35151
  return a && (a.fetch = A ? "stale" : "refresh", v && A && (a.returnedStale = true)), v ? z7.__staleWhileFetching : z7.__returned = z7;
33712
35152
  }
@@ -33715,7 +35155,7 @@ var M = class u2 {
33715
35155
  let i = W.hasSubscribers, { status: s = D() ? {} : undefined } = t;
33716
35156
  t.status = s, s && t.context && (s.context = t.context);
33717
35157
  let n = this.#K(e, t);
33718
- return s && i && (s.trace = true, W.tracePromise(() => n, s).catch(() => {})), n;
35158
+ return s && D() && i && (s.trace = true, W.tracePromise(() => n, s).catch(() => {})), n;
33719
35159
  }
33720
35160
  async#K(e, t = {}) {
33721
35161
  let i = await this.#B(e, t);
@@ -33754,7 +35194,7 @@ var M = class u2 {
33754
35194
  return;
33755
35195
  }
33756
35196
  let h = this.#t[r], l = this.#e(h);
33757
- return o && this.#E(o, r), this.#p(r) ? l ? (o && (o.get = "stale-fetching"), i && h.__staleWhileFetching !== undefined ? (o && (o.returnedStale = true), h.__staleWhileFetching) : undefined) : (n || this.#v(e, "expire"), o && (o.get = "stale"), i ? (o && (o.returnedStale = true), h) : undefined) : (o && (o.get = l ? "fetching" : "hit"), this.#L(r), s && this.#D(r), l ? h.__staleWhileFetching : h);
35197
+ return o && this.#E(o, r), this.#p(r) ? l ? (o && (o.get = "stale-fetching"), i && h.__staleWhileFetching !== undefined ? (o && (o.returnedStale = true), h.__staleWhileFetching) : undefined) : (n || this.#v(e, "expire"), o && (o.get = "stale"), i ? (o && (o.returnedStale = true), h) : undefined) : (o && (o.get = l ? "fetching" : "hit"), this.#L(r), s && this.#x(r), l ? h.__staleWhileFetching : h);
33758
35198
  }
33759
35199
  #$(e, t) {
33760
35200
  this.#c[t] = e, this.#a[e] = t;
@@ -33824,7 +35264,7 @@ var M = class u2 {
33824
35264
  };
33825
35265
 
33826
35266
  // src/tools/smartfetch/network.ts
33827
- import path19 from "node:path";
35267
+ import path20 from "node:path";
33828
35268
 
33829
35269
  // src/tools/smartfetch/utils.ts
33830
35270
  var import_readability = __toESM(require_readability(), 1);
@@ -34549,7 +35989,7 @@ function inferFilenameFromUrl(url) {
34549
35989
  function truncateFilename(name, maxLength = 180) {
34550
35990
  if (name.length <= maxLength)
34551
35991
  return name;
34552
- const parsed = path19.parse(name);
35992
+ const parsed = path20.parse(name);
34553
35993
  const ext = parsed.ext || "";
34554
35994
  const baseLimit = Math.max(1, maxLength - ext.length);
34555
35995
  return `${parsed.name.slice(0, baseLimit)}${ext}`;
@@ -34659,7 +36099,7 @@ async function probeLlmsText(url, timeoutMs, signal, fallbackOrigin) {
34659
36099
  }
34660
36100
 
34661
36101
  // src/tools/smartfetch/cache.ts
34662
- var CACHE = new M({
36102
+ var CACHE = new L({
34663
36103
  maxSize: 50 * 1024 * 1024,
34664
36104
  ttl: 15 * 60 * 1000,
34665
36105
  sizeCalculation: (value) => {
@@ -34721,7 +36161,7 @@ function isInvalidLlmsResult(fetchResult) {
34721
36161
  // src/tools/smartfetch/secondary-model.ts
34722
36162
  import { existsSync as existsSync12 } from "node:fs";
34723
36163
  import { readFile as readFile4 } from "node:fs/promises";
34724
- import path20 from "node:path";
36164
+ import path21 from "node:path";
34725
36165
  function parseModelRef(value) {
34726
36166
  if (!value)
34727
36167
  return;
@@ -34747,7 +36187,7 @@ function pickAgentModelRef(value) {
34747
36187
  }
34748
36188
  function findPreferredOpenCodeConfigPath(baseDir) {
34749
36189
  for (const file of ["opencode.jsonc", "opencode.json"]) {
34750
- const fullPath = path20.join(baseDir, file);
36190
+ const fullPath = path21.join(baseDir, file);
34751
36191
  if (existsSync12(fullPath))
34752
36192
  return fullPath;
34753
36193
  }
@@ -34764,7 +36204,7 @@ async function readOpenCodeConfigFile(configPath) {
34764
36204
  }
34765
36205
  }
34766
36206
  async function readEffectiveOpenCodeConfig(directory) {
34767
- const projectDir = path20.join(directory, ".opencode");
36207
+ const projectDir = path21.join(directory, ".opencode");
34768
36208
  const userDirs = getConfigSearchDirs();
34769
36209
  const projectPath = findPreferredOpenCodeConfigPath(projectDir);
34770
36210
  const userPath = userDirs.map((configDir) => findPreferredOpenCodeConfigPath(configDir)).find(Boolean);
@@ -34864,7 +36304,7 @@ async function deleteSessionSafely(client, sessionId, directory) {
34864
36304
  console.warn(`[smartfetch] Failed to clean up secondary session ${sessionId} ` + `after ${SESSION_DELETE_RETRIES} attempts: ` + (error instanceof Error ? error.message : String(error)));
34865
36305
  return;
34866
36306
  }
34867
- await new Promise((resolve3) => setTimeout(resolve3, _testConfig.deleteRetryDelayMs));
36307
+ await new Promise((resolve4) => setTimeout(resolve4, _testConfig.deleteRetryDelayMs));
34868
36308
  }
34869
36309
  }
34870
36310
  }
@@ -34946,7 +36386,7 @@ async function runSecondaryModelWithFallback(client, directory, models, prompt,
34946
36386
  // src/tools/smartfetch/tool.ts
34947
36387
  var z7 = tool5.schema;
34948
36388
  function createWebfetchTool(pluginCtx, options = {}) {
34949
- const binaryDir = options.binaryDir || path21.join(os6.tmpdir(), "opencode-smartfetch");
36389
+ const binaryDir = options.binaryDir || path22.join(os7.tmpdir(), "opencode-smartfetch");
34950
36390
  return tool5({
34951
36391
  description: WEBFETCH_DESCRIPTION,
34952
36392
  args: {
@@ -35554,6 +36994,7 @@ var OhMyOpenCodeLite = async (ctx) => {
35554
36994
  let foregroundFallback;
35555
36995
  let deepworkCommandHook;
35556
36996
  let reflectCommandHook;
36997
+ let loopCommandHook;
35557
36998
  let taskSessionManagerHook;
35558
36999
  let backgroundJobBoard;
35559
37000
  let interviewManager;
@@ -35578,8 +37019,8 @@ var OhMyOpenCodeLite = async (ctx) => {
35578
37019
  }
35579
37020
  disabledAgents = getDisabledAgents(config);
35580
37021
  rewriteDisplayNameMentions = createDisplayNameMentionRewriter(config);
35581
- agentDefs = createAgents(config);
35582
- agents = getAgentConfigs(config);
37022
+ agentDefs = createAgents(config, { projectDirectory: ctx.directory });
37023
+ agents = getAgentConfigs(config, { projectDirectory: ctx.directory });
35583
37024
  modelArrayMap = {};
35584
37025
  runtimeChains = {};
35585
37026
  for (const agentDef of agentDefs) {
@@ -35615,7 +37056,7 @@ var OhMyOpenCodeLite = async (ctx) => {
35615
37056
  readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8
35616
37057
  });
35617
37058
  multiplexerSessionManager = new MultiplexerSessionManager(ctx, multiplexerConfig, backgroundJobBoard);
35618
- backgroundJobBoard.setTerminalStateListener((taskID) => {
37059
+ backgroundJobBoard.addTerminalStateListener((taskID) => {
35619
37060
  multiplexerSessionManager.retryDeferredIdleClose(taskID);
35620
37061
  });
35621
37062
  autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
@@ -35635,6 +37076,7 @@ var OhMyOpenCodeLite = async (ctx) => {
35635
37076
  foregroundFallback = new ForegroundFallbackManager(ctx.client, runtimeChains, config.fallback?.enabled !== false && Object.keys(runtimeChains).length > 0);
35636
37077
  deepworkCommandHook = createDeepworkCommandHook();
35637
37078
  reflectCommandHook = createReflectCommandHook();
37079
+ loopCommandHook = createLoopCommandHook();
35638
37080
  taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
35639
37081
  maxSessionsPerAgent: config.backgroundJobs?.maxSessionsPerAgent ?? 2,
35640
37082
  readContextMinLines: config.backgroundJobs?.readContextMinLines ?? 10,
@@ -35764,9 +37206,11 @@ var OhMyOpenCodeLite = async (ctx) => {
35764
37206
  const chosen = models[0];
35765
37207
  const entry = configAgent[agentName];
35766
37208
  if (entry) {
35767
- entry.model = chosen.id;
35768
- if (chosen.variant) {
35769
- entry.variant = chosen.variant;
37209
+ if (entry.model === undefined) {
37210
+ entry.model = chosen.id;
37211
+ if (chosen.variant) {
37212
+ entry.variant = chosen.variant;
37213
+ }
35770
37214
  }
35771
37215
  } else {
35772
37216
  configAgent[agentName] = {
@@ -35906,6 +37350,7 @@ var OhMyOpenCodeLite = async (ctx) => {
35906
37350
  interviewManager.registerCommand(opencodeConfig);
35907
37351
  deepworkCommandHook.registerCommand(opencodeConfig);
35908
37352
  reflectCommandHook.registerCommand(opencodeConfig);
37353
+ loopCommandHook.registerCommand(opencodeConfig);
35909
37354
  presetManager.registerCommand(opencodeConfig);
35910
37355
  },
35911
37356
  event: async (input) => {
@@ -35979,6 +37424,7 @@ var OhMyOpenCodeLite = async (ctx) => {
35979
37424
  await presetManager.handleCommandExecuteBefore(input, output);
35980
37425
  await deepworkCommandHook.handleCommandExecuteBefore(input, output);
35981
37426
  await reflectCommandHook.handleCommandExecuteBefore(input, output);
37427
+ await loopCommandHook.handleCommandExecuteBefore(input, output);
35982
37428
  },
35983
37429
  "chat.headers": chatHeadersHook["chat.headers"],
35984
37430
  "chat.message": async (input, output) => {