oh-my-opencode-slim 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6246,33 +6246,33 @@ var require_URL = __commonJS((exports, module) => {
6246
6246
  else
6247
6247
  return basepath.substring(0, lastslash + 1) + refpath;
6248
6248
  }
6249
- function remove_dot_segments(path13) {
6250
- if (!path13)
6251
- return path13;
6249
+ function remove_dot_segments(path14) {
6250
+ if (!path14)
6251
+ return path14;
6252
6252
  var output = "";
6253
- while (path13.length > 0) {
6254
- if (path13 === "." || path13 === "..") {
6255
- path13 = "";
6253
+ while (path14.length > 0) {
6254
+ if (path14 === "." || path14 === "..") {
6255
+ path14 = "";
6256
6256
  break;
6257
6257
  }
6258
- var twochars = path13.substring(0, 2);
6259
- var threechars = path13.substring(0, 3);
6260
- var fourchars = path13.substring(0, 4);
6258
+ var twochars = path14.substring(0, 2);
6259
+ var threechars = path14.substring(0, 3);
6260
+ var fourchars = path14.substring(0, 4);
6261
6261
  if (threechars === "../") {
6262
- path13 = path13.substring(3);
6262
+ path14 = path14.substring(3);
6263
6263
  } else if (twochars === "./") {
6264
- path13 = path13.substring(2);
6264
+ path14 = path14.substring(2);
6265
6265
  } else if (threechars === "/./") {
6266
- path13 = "/" + path13.substring(3);
6267
- } else if (twochars === "/." && path13.length === 2) {
6268
- path13 = "/";
6269
- } else if (fourchars === "/../" || threechars === "/.." && path13.length === 3) {
6270
- path13 = "/" + path13.substring(4);
6266
+ path14 = "/" + path14.substring(3);
6267
+ } else if (twochars === "/." && path14.length === 2) {
6268
+ path14 = "/";
6269
+ } else if (fourchars === "/../" || threechars === "/.." && path14.length === 3) {
6270
+ path14 = "/" + path14.substring(4);
6271
6271
  output = output.replace(/\/?[^\/]*$/, "");
6272
6272
  } else {
6273
- var segment = path13.match(/(\/?([^\/]*))/)[0];
6273
+ var segment = path14.match(/(\/?([^\/]*))/)[0];
6274
6274
  output += segment;
6275
- path13 = path13.substring(segment.length);
6275
+ path14 = path14.substring(segment.length);
6276
6276
  }
6277
6277
  }
6278
6278
  return output;
@@ -18302,7 +18302,7 @@ var ALL_AGENT_NAMES = [ORCHESTRATOR_NAME, ...SUBAGENT_NAMES];
18302
18302
  var PROTECTED_AGENTS = new Set(["orchestrator", "councillor"]);
18303
18303
  var DEFAULT_MODELS = {
18304
18304
  orchestrator: undefined,
18305
- oracle: "openai/gpt-5.4",
18305
+ oracle: "openai/gpt-5.5",
18306
18306
  librarian: "openai/gpt-5.4-mini",
18307
18307
  explorer: "openai/gpt-5.4-mini",
18308
18308
  designer: "openai/gpt-5.4-mini",
@@ -18316,7 +18316,7 @@ var DEFAULT_TIMEOUT_MS = 2 * 60 * 1000;
18316
18316
  var MAX_POLL_TIME_MS = 5 * 60 * 1000;
18317
18317
  var DEFAULT_MAX_SUBAGENT_DEPTH = 3;
18318
18318
  var PHASE_REMINDER_TEXT = `!IMPORTANT! Recall the workflow rules:
18319
- Understand → choose the best parallelized path based on your agents delegation rules → don't prefer solo → execute → verify.
18319
+ Understand → choose the best parallelized path based on your capabilities and agents delegation rules → execute → verify.
18320
18320
  If delegating, launch the specialist in the same turn you mention it !END!`;
18321
18321
  var TMUX_SPAWN_DELAY_MS = 500;
18322
18322
  var COUNCILLOR_STAGGER_MS = 250;
@@ -18525,6 +18525,11 @@ var InterviewConfigSchema = z2.object({
18525
18525
  port: z2.number().int().min(0).max(65535).default(0),
18526
18526
  dashboard: z2.boolean().default(false)
18527
18527
  });
18528
+ var SessionManagerConfigSchema = z2.object({
18529
+ maxSessionsPerAgent: z2.number().int().min(1).max(10).default(2),
18530
+ readContextMinLines: z2.number().int().min(0).max(1000).default(10),
18531
+ readContextMaxFiles: z2.number().int().min(0).max(50).default(8)
18532
+ });
18528
18533
  var TodoContinuationConfigSchema = z2.object({
18529
18534
  maxContinuations: z2.number().int().min(1).max(50).default(5).describe("Maximum consecutive auto-continuations before stopping to ask user"),
18530
18535
  cooldownMs: z2.number().int().min(0).max(30000).default(3000).describe("Delay in ms before auto-continuing (gives user time to abort)"),
@@ -18566,6 +18571,7 @@ var PluginConfigSchema = z2.object({
18566
18571
  scoringEngineVersion: z2.enum(["v1", "v2-shadow", "v2"]).optional(),
18567
18572
  balanceProviderUsage: z2.boolean().optional(),
18568
18573
  showStartupToast: z2.boolean().optional().describe("Show the startup activation toast when OpenCode starts. Defaults to true."),
18574
+ autoUpdate: z2.boolean().optional().describe("Disable automatic installation of plugin updates when false. Defaults to true."),
18569
18575
  manualPlan: ManualPlanSchema.optional(),
18570
18576
  presets: z2.record(z2.string(), PresetSchema).optional(),
18571
18577
  agents: z2.record(z2.string(), AgentOverrideConfigSchema).optional(),
@@ -18575,6 +18581,7 @@ var PluginConfigSchema = z2.object({
18575
18581
  tmux: TmuxConfigSchema.optional(),
18576
18582
  websearch: WebsearchConfigSchema.optional(),
18577
18583
  interview: InterviewConfigSchema.optional(),
18584
+ sessionManager: SessionManagerConfigSchema.optional(),
18578
18585
  todoContinuation: TodoContinuationConfigSchema.optional(),
18579
18586
  fallback: FailoverConfigSchema.optional(),
18580
18587
  council: CouncilConfigSchema.optional()
@@ -18660,6 +18667,7 @@ function loadPluginConfig(directory) {
18660
18667
  tmux: deepMerge(config.tmux, projectConfig.tmux),
18661
18668
  multiplexer: deepMerge(config.multiplexer, projectConfig.multiplexer),
18662
18669
  interview: deepMerge(config.interview, projectConfig.interview),
18670
+ sessionManager: deepMerge(config.sessionManager, projectConfig.sessionManager),
18663
18671
  fallback: deepMerge(config.fallback, projectConfig.fallback),
18664
18672
  council: deepMerge(config.council, projectConfig.council)
18665
18673
  };
@@ -18931,6 +18939,12 @@ ${enabledParallelExamples}
18931
18939
 
18932
18940
  Balance: respect dependencies, avoid parallelizing what must be sequential.
18933
18941
 
18942
+ ### OpenCode subagent execution model
18943
+ - A delegated specialist runs in a separate child session.
18944
+ - Delegation is blocking for the parent at that point: send work out, then continue that line after results return.
18945
+ - Parallel delegation means launching multiple independent child-session branches.
18946
+ - Only parallelize branches that are truly independent; reconcile dependent steps after delegated results come back.
18947
+
18934
18948
  ## 5. Execute
18935
18949
  1. Break complex tasks into todos
18936
18950
  2. Fire parallel research/implementation
@@ -18938,6 +18952,12 @@ Balance: respect dependencies, avoid parallelizing what must be sequential.
18938
18952
  4. Integrate results
18939
18953
  5. Adjust if needed
18940
18954
 
18955
+ ### Session Reuse
18956
+ - Reuse an available specialist session only for clear follow-up work on the same thread.
18957
+ - Prefer a fresh session for unrelated work, even with the same specialist.
18958
+ - If multiple remembered sessions fit, prefer the most recently used matching session.
18959
+ - If reuse is unclear, start a fresh session.
18960
+
18941
18961
  ### Auto-Continue
18942
18962
  When working through multi-step tasks, consider enabling auto-continue to avoid stopping between batches:
18943
18963
  - **Enable when:** User requests autonomous/batch work, or you create 4+ todos in a session
@@ -19751,12 +19771,14 @@ function getDisabledAgents(config) {
19751
19771
 
19752
19772
  // src/utils/logger.ts
19753
19773
  import * as fs2 from "node:fs";
19774
+ import { appendFile } from "node:fs/promises";
19754
19775
  import * as os from "node:os";
19755
19776
  import * as path2 from "node:path";
19756
19777
  var LOG_PREFIX = "oh-my-opencode-slim.";
19757
19778
  var LOG_SUFFIX = ".log";
19758
19779
  var RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
19759
19780
  var logFile = null;
19781
+ var writeChain = Promise.resolve();
19760
19782
  function getLogDir() {
19761
19783
  return process.env.OPENCODE_LOG_DIR ?? path2.join(os.homedir(), ".local/share/opencode");
19762
19784
  }
@@ -19799,10 +19821,14 @@ function initLogger(sessionId) {
19799
19821
  fs2.mkdirSync(dir, { recursive: true });
19800
19822
  } catch {}
19801
19823
  logFile = path2.join(dir, `${LOG_PREFIX}${sessionId}${LOG_SUFFIX}`);
19824
+ try {
19825
+ fs2.closeSync(fs2.openSync(logFile, "a"));
19826
+ } catch {}
19802
19827
  cleanupOldLogs(dir);
19803
19828
  }
19804
19829
  function log(message, data) {
19805
- if (!logFile)
19830
+ const target = logFile;
19831
+ if (!target)
19806
19832
  return;
19807
19833
  try {
19808
19834
  const timestamp = new Date().toISOString();
@@ -19816,7 +19842,7 @@ function log(message, data) {
19816
19842
  }
19817
19843
  const logEntry = `[${timestamp}] ${message} ${dataStr}
19818
19844
  `;
19819
- fs2.appendFileSync(logFile, logEntry);
19845
+ writeChain = writeChain.then(() => appendFile(target, logEntry)).catch(() => {});
19820
19846
  } catch {}
19821
19847
  }
19822
19848
 
@@ -20002,7 +20028,7 @@ class CouncilManager {
20002
20028
  }
20003
20029
  } else {
20004
20030
  const promises = entries.map(([name, config], index) => (async () => {
20005
- if (index > 0) {
20031
+ if (this.tmuxEnabled && index > 0) {
20006
20032
  await new Promise((r) => setTimeout(r, index * COUNCILLOR_STAGGER_MS));
20007
20033
  }
20008
20034
  return this.runCouncillorWithRetry(name, config, prompt, parentSessionId, timeout, maxRetries);
@@ -22018,11 +22044,8 @@ function resolveRuntimeAgentName(config, agentName) {
22018
22044
  function escapeRegExp2(value) {
22019
22045
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22020
22046
  }
22021
- function rewriteDisplayNameMentions(config, text) {
22022
- if (!text.includes("@")) {
22023
- return text;
22024
- }
22025
- let rewritten = text;
22047
+ function createDisplayNameMentionRewriter(config) {
22048
+ const replacements = [];
22026
22049
  for (const internalName of getRuntimeAgentNames(config)) {
22027
22050
  const displayName = getAgentOverride(config, internalName)?.displayName;
22028
22051
  if (!displayName) {
@@ -22032,9 +22055,24 @@ function rewriteDisplayNameMentions(config, text) {
22032
22055
  if (!normalizedDisplayName || normalizedDisplayName === internalName) {
22033
22056
  continue;
22034
22057
  }
22035
- rewritten = rewritten.replace(new RegExp(`(^|[^\\w.])@${escapeRegExp2(normalizedDisplayName)}\\b`, "g"), `$1@${internalName}`);
22058
+ replacements.push({
22059
+ regex: new RegExp(`(^|[^\\w.])@${escapeRegExp2(normalizedDisplayName)}\\b`, "g"),
22060
+ internalName
22061
+ });
22062
+ }
22063
+ if (replacements.length === 0) {
22064
+ return (text) => text;
22036
22065
  }
22037
- return rewritten;
22066
+ return (text) => {
22067
+ if (!text.includes("@")) {
22068
+ return text;
22069
+ }
22070
+ let rewritten = text;
22071
+ for (const replacement of replacements) {
22072
+ rewritten = rewritten.replace(replacement.regex, `$1@${replacement.internalName}`);
22073
+ }
22074
+ return rewritten;
22075
+ };
22038
22076
  }
22039
22077
  // src/utils/internal-initiator.ts
22040
22078
  var SLIM_INTERNAL_INITIATOR_MARKER = "<!-- SLIM_INTERNAL_INITIATOR -->";
@@ -22057,6 +22095,249 @@ function hasInternalInitiatorMarker(part) {
22057
22095
  }
22058
22096
  return part.text.includes(SLIM_INTERNAL_INITIATOR_MARKER);
22059
22097
  }
22098
+ // src/utils/session-manager.ts
22099
+ var MIN_CONTEXT_FILE_LINES = 10;
22100
+ var MAX_CONTEXT_FILES_PER_SESSION = 8;
22101
+ function aliasPrefix(agentType) {
22102
+ switch (agentType) {
22103
+ case "explorer":
22104
+ return "exp";
22105
+ case "librarian":
22106
+ return "lib";
22107
+ case "oracle":
22108
+ return "ora";
22109
+ case "designer":
22110
+ return "des";
22111
+ case "fixer":
22112
+ return "fix";
22113
+ case "observer":
22114
+ return "obs";
22115
+ case "council":
22116
+ return "cnc";
22117
+ case "councillor":
22118
+ return "clr";
22119
+ case "orchestrator":
22120
+ return "orc";
22121
+ }
22122
+ }
22123
+ function normalizeWhitespace(value) {
22124
+ return value.replace(/\s+/g, " ").trim();
22125
+ }
22126
+ function deriveTaskSessionLabel(input) {
22127
+ const preferred = normalizeWhitespace(input.description ?? "");
22128
+ if (preferred) {
22129
+ return preferred.slice(0, 48);
22130
+ }
22131
+ const firstPromptLine = (input.prompt ?? "").split(/\r?\n/).map((line) => normalizeWhitespace(line)).find(Boolean);
22132
+ if (firstPromptLine) {
22133
+ return firstPromptLine.slice(0, 48);
22134
+ }
22135
+ return `recent ${input.agentType} task`;
22136
+ }
22137
+
22138
+ class SessionManager {
22139
+ maxSessionsPerAgent;
22140
+ readContextMinLines;
22141
+ readContextMaxFiles;
22142
+ sessionsByParent = new Map;
22143
+ nextAliasIndexByParent = new Map;
22144
+ orderCounter = 0;
22145
+ constructor(maxSessionsPerAgent, options = {}) {
22146
+ this.maxSessionsPerAgent = maxSessionsPerAgent;
22147
+ this.readContextMinLines = options.readContextMinLines ?? MIN_CONTEXT_FILE_LINES;
22148
+ this.readContextMaxFiles = options.readContextMaxFiles ?? MAX_CONTEXT_FILES_PER_SESSION;
22149
+ }
22150
+ remember(input) {
22151
+ const now = this.nextOrder();
22152
+ const group = this.getAgentGroup(input.parentSessionId, input.agentType, true);
22153
+ if (!group) {
22154
+ throw new Error("Failed to initialize session group");
22155
+ }
22156
+ const existing = group.find((entry) => entry.taskId === input.taskId);
22157
+ if (existing) {
22158
+ existing.label = input.label;
22159
+ existing.lastUsedAt = this.nextOrder();
22160
+ return existing;
22161
+ }
22162
+ const remembered = {
22163
+ alias: this.nextAlias(input.parentSessionId, input.agentType),
22164
+ taskId: input.taskId,
22165
+ agentType: input.agentType,
22166
+ label: input.label,
22167
+ contextFiles: [],
22168
+ createdAt: now,
22169
+ lastUsedAt: now
22170
+ };
22171
+ group.push(remembered);
22172
+ this.trimGroup(group);
22173
+ return remembered;
22174
+ }
22175
+ markUsed(parentSessionId, agentType, key) {
22176
+ const group = this.getAgentGroup(parentSessionId, agentType, false);
22177
+ const match = group?.find((entry) => entry.alias === key || entry.taskId === key);
22178
+ if (match) {
22179
+ match.lastUsedAt = this.nextOrder();
22180
+ }
22181
+ }
22182
+ resolve(parentSessionId, agentType, key) {
22183
+ const group = this.getAgentGroup(parentSessionId, agentType, false);
22184
+ return group?.find((entry) => entry.alias === key || entry.taskId === key);
22185
+ }
22186
+ drop(parentSessionId, agentType, key) {
22187
+ const group = this.getAgentGroup(parentSessionId, agentType, false);
22188
+ if (!group)
22189
+ return;
22190
+ const next = group.filter((entry) => entry.alias !== key && entry.taskId !== key);
22191
+ this.setAgentGroup(parentSessionId, agentType, next);
22192
+ }
22193
+ dropTask(taskId) {
22194
+ for (const [parentSessionId, groups] of this.sessionsByParent.entries()) {
22195
+ for (const [agentType, group] of groups.entries()) {
22196
+ const next = group.filter((entry) => entry.taskId !== taskId);
22197
+ this.setAgentGroup(parentSessionId, agentType, next);
22198
+ }
22199
+ }
22200
+ }
22201
+ taskIds() {
22202
+ const ids = new Set;
22203
+ for (const groups of this.sessionsByParent.values()) {
22204
+ for (const group of groups.values()) {
22205
+ for (const entry of group) {
22206
+ ids.add(entry.taskId);
22207
+ }
22208
+ }
22209
+ }
22210
+ return ids;
22211
+ }
22212
+ addContext(taskId, files) {
22213
+ if (files.length === 0)
22214
+ return;
22215
+ for (const groups of this.sessionsByParent.values()) {
22216
+ for (const group of groups.values()) {
22217
+ const match = group.find((entry) => entry.taskId === taskId);
22218
+ if (!match)
22219
+ continue;
22220
+ const existing = new Map(match.contextFiles.map((file) => [file.path, file]));
22221
+ for (const file of files) {
22222
+ const previous = existing.get(file.path);
22223
+ if (previous) {
22224
+ previous.lineCount = Math.max(previous.lineCount, file.lineCount);
22225
+ previous.lastReadAt = Math.max(previous.lastReadAt, file.lastReadAt);
22226
+ continue;
22227
+ }
22228
+ match.contextFiles.push({ ...file });
22229
+ }
22230
+ this.trimContextFiles(match);
22231
+ }
22232
+ }
22233
+ }
22234
+ clearParent(parentSessionId) {
22235
+ this.sessionsByParent.delete(parentSessionId);
22236
+ this.nextAliasIndexByParent.delete(parentSessionId);
22237
+ }
22238
+ formatForPrompt(parentSessionId) {
22239
+ const groups = this.sessionsByParent.get(parentSessionId);
22240
+ if (!groups || groups.size === 0)
22241
+ return;
22242
+ const lines = [...groups.entries()].map(([agentType, entries]) => [
22243
+ agentType,
22244
+ [...entries].sort((a, b) => b.lastUsedAt - a.lastUsedAt)
22245
+ ]).filter(([, entries]) => entries.length > 0).sort((a, b) => b[1][0].lastUsedAt - a[1][0].lastUsedAt).map(([agentType, entries]) => [
22246
+ `- ${agentType}: ${entries.map((entry) => `${entry.alias} ${entry.label}`).join("; ")}`,
22247
+ ...entries.map((entry) => [
22248
+ entry,
22249
+ formatContextFiles(entry.contextFiles, {
22250
+ minLines: this.readContextMinLines,
22251
+ maxFiles: this.readContextMaxFiles
22252
+ })
22253
+ ]).filter(([, context]) => context.length > 0).map(([entry, context]) => ` Context read by ${entry.alias}: ${context}`)
22254
+ ].join(`
22255
+ `));
22256
+ if (lines.length === 0)
22257
+ return;
22258
+ return [
22259
+ "### Resumable Sessions",
22260
+ "Reuse only for clear continuation of the same thread. Otherwise start fresh.",
22261
+ "",
22262
+ ...lines
22263
+ ].join(`
22264
+ `);
22265
+ }
22266
+ getAgentGroup(parentSessionId, agentType, create) {
22267
+ let groups = this.sessionsByParent.get(parentSessionId);
22268
+ if (!groups && create) {
22269
+ groups = new Map;
22270
+ this.sessionsByParent.set(parentSessionId, groups);
22271
+ }
22272
+ let group = groups?.get(agentType);
22273
+ if (!group && create && groups) {
22274
+ group = [];
22275
+ groups.set(agentType, group);
22276
+ }
22277
+ return group;
22278
+ }
22279
+ setAgentGroup(parentSessionId, agentType, entries) {
22280
+ const groups = this.sessionsByParent.get(parentSessionId);
22281
+ if (!groups)
22282
+ return;
22283
+ if (entries.length === 0) {
22284
+ groups.delete(agentType);
22285
+ if (groups.size === 0) {
22286
+ this.sessionsByParent.delete(parentSessionId);
22287
+ this.nextAliasIndexByParent.delete(parentSessionId);
22288
+ }
22289
+ return;
22290
+ }
22291
+ groups.set(agentType, entries);
22292
+ }
22293
+ nextAlias(parentSessionId, agentType) {
22294
+ let counters = this.nextAliasIndexByParent.get(parentSessionId);
22295
+ if (!counters) {
22296
+ counters = new Map;
22297
+ this.nextAliasIndexByParent.set(parentSessionId, counters);
22298
+ }
22299
+ const next = (counters.get(agentType) ?? 0) + 1;
22300
+ counters.set(agentType, next);
22301
+ return `${aliasPrefix(agentType)}-${next}`;
22302
+ }
22303
+ trimGroup(group) {
22304
+ group.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
22305
+ if (group.length > this.maxSessionsPerAgent) {
22306
+ group.length = this.maxSessionsPerAgent;
22307
+ }
22308
+ }
22309
+ trimContextFiles(entry) {
22310
+ if (this.readContextMaxFiles === 0) {
22311
+ entry.contextFiles = [];
22312
+ return;
22313
+ }
22314
+ entry.contextFiles = entry.contextFiles.filter((file) => file.lineCount >= this.readContextMinLines).sort((a, b) => b.lastReadAt - a.lastReadAt).slice(0, this.readContextMaxFiles + 1);
22315
+ }
22316
+ nextOrder() {
22317
+ this.orderCounter += 1;
22318
+ return this.orderCounter;
22319
+ }
22320
+ }
22321
+ function formatContextFiles(files, options) {
22322
+ const eligible = files.filter((file) => file.lineCount >= options.minLines).sort((a, b) => b.lastReadAt - a.lastReadAt);
22323
+ const shown = eligible.slice(0, options.maxFiles);
22324
+ const rest = eligible.length - shown.length;
22325
+ const rendered = shown.map((file) => `${file.path} (${file.lineCount} lines)`);
22326
+ return `${rendered.join(", ")}${rest > 0 ? ` (+${rest} more)` : ""}`;
22327
+ }
22328
+ // src/utils/task.ts
22329
+ function parseTaskIdFromTaskOutput(output) {
22330
+ const lines = output.split(/\r?\n/);
22331
+ for (const line of lines) {
22332
+ const trimmed = line.trim();
22333
+ const match = /^task_id:\s*([^\s()]+)(?:\s*\(.*)?$/.exec(trimmed);
22334
+ if (!match) {
22335
+ continue;
22336
+ }
22337
+ return match[1];
22338
+ }
22339
+ return;
22340
+ }
22060
22341
  // src/utils/zip-extractor.ts
22061
22342
  import { spawnSync } from "node:child_process";
22062
22343
  import { release } from "node:os";
@@ -22345,6 +22626,17 @@ ${allowedEntries.map((entry) => entry.block).join(`
22345
22626
  });
22346
22627
  }
22347
22628
  function createFilterAvailableSkillsHook(_ctx, config) {
22629
+ const permissionRulesByAgent = new Map;
22630
+ const getPermissionRules = (agentName) => {
22631
+ const cached = permissionRulesByAgent.get(agentName);
22632
+ if (cached) {
22633
+ return cached;
22634
+ }
22635
+ const configuredSkills = getAgentOverride(config, agentName)?.skills;
22636
+ const permissionRules = getSkillPermissionsForAgent(agentName, configuredSkills);
22637
+ permissionRulesByAgent.set(agentName, permissionRules);
22638
+ return permissionRules;
22639
+ };
22348
22640
  return {
22349
22641
  "experimental.chat.messages.transform": async (_input, output) => {
22350
22642
  const { messages } = output;
@@ -22352,8 +22644,7 @@ function createFilterAvailableSkillsHook(_ctx, config) {
22352
22644
  return;
22353
22645
  }
22354
22646
  const agentName = getCurrentAgent(messages);
22355
- const configuredSkills = getAgentOverride(config, agentName)?.skills;
22356
- const permissionRules = getSkillPermissionsForAgent(agentName, configuredSkills);
22647
+ const permissionRules = getPermissionRules(agentName);
22357
22648
  for (const message of messages) {
22358
22649
  for (const part of message.parts) {
22359
22650
  if (part.type !== "text" || !part.text || !part.text.includes("<available_skills>")) {
@@ -22372,6 +22663,8 @@ var RATE_LIMIT_PATTERNS = [
22372
22663
  /too many requests/i,
22373
22664
  /quota.?exceeded/i,
22374
22665
  /usage.?exceeded/i,
22666
+ /ExceededBudget/i,
22667
+ /over.?budget/i,
22375
22668
  /usage limit/i,
22376
22669
  /overloaded/i,
22377
22670
  /resource.?exhausted/i,
@@ -22450,7 +22743,7 @@ class ForegroundFallbackManager {
22450
22743
  if (!props?.sessionID || props.status?.type !== "retry")
22451
22744
  break;
22452
22745
  const msg = props.status.message?.toLowerCase() ?? "";
22453
- if (msg.includes("rate limit") || msg.includes("usage limit") || msg.includes("usage exceeded") || msg.includes("quota exceeded") || msg.includes("high concurrency") || msg.includes("reduce concurrency")) {
22746
+ 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("high concurrency") || msg.includes("reduce concurrency")) {
22454
22747
  await this.tryFallback(props.sessionID);
22455
22748
  }
22456
22749
  break;
@@ -22704,7 +22997,21 @@ function processImageAttachments(args) {
22704
22997
  const observerEnabled = !disabledAgents.has("observer");
22705
22998
  if (!observerEnabled)
22706
22999
  return;
23000
+ const messagesWithImages = [];
23001
+ for (const msg of messages) {
23002
+ if (msg.info.role !== "user")
23003
+ continue;
23004
+ const imageParts = msg.parts.filter(isImagePart);
23005
+ if (imageParts.length > 0) {
23006
+ messagesWithImages.push({ msg, imageParts });
23007
+ }
23008
+ }
22707
23009
  const saveDir = join7(workDir, ".opencode", "images");
23010
+ if (messagesWithImages.length === 0) {
23011
+ if (existsSync4(saveDir))
23012
+ cleanupAllSessions(saveDir);
23013
+ return;
23014
+ }
22708
23015
  const gitignorePath = join7(workDir, ".opencode", ".gitignore");
22709
23016
  try {
22710
23017
  mkdirSync2(saveDir, { recursive: true });
@@ -22715,12 +23022,7 @@ function processImageAttachments(args) {
22715
23022
  log2(`[image-hook] failed to create image directory: ${e}`);
22716
23023
  }
22717
23024
  cleanupAllSessions(saveDir);
22718
- for (const msg of messages) {
22719
- if (msg.info.role !== "user")
22720
- continue;
22721
- const imageParts = msg.parts.filter(isImagePart);
22722
- if (imageParts.length === 0)
22723
- continue;
23025
+ for (const { msg, imageParts } of messagesWithImages) {
22724
23026
  const sessionSubdir = msg.info.sessionID ? sanitizeFilename(msg.info.sessionID) : undefined;
22725
23027
  const targetDir = sessionSubdir ? join7(saveDir, sessionSubdir) : saveDir;
22726
23028
  try {
@@ -22880,6 +23182,255 @@ function createPostFileToolNudgeHook(options = {}) {
22880
23182
  }
22881
23183
  };
22882
23184
  }
23185
+ // src/hooks/task-session-manager/index.ts
23186
+ import path8 from "node:path";
23187
+ var AGENT_NAME_SET = new Set([
23188
+ "orchestrator",
23189
+ "oracle",
23190
+ "designer",
23191
+ "explorer",
23192
+ "librarian",
23193
+ "fixer",
23194
+ "observer",
23195
+ "council",
23196
+ "councillor"
23197
+ ]);
23198
+ var MAX_PENDING_TASK_CALLS = 100;
23199
+ function isAgentName(value) {
23200
+ return typeof value === "string" && AGENT_NAME_SET.has(value);
23201
+ }
23202
+ function isObjectRecord(value) {
23203
+ return typeof value === "object" && value !== null;
23204
+ }
23205
+ function extractPath(output) {
23206
+ return /<path>([^<]+)<\/path>/.exec(output)?.[1];
23207
+ }
23208
+ function normalizePath(root, file) {
23209
+ const relative = path8.relative(root, file);
23210
+ if (!relative || relative.startsWith("..") || path8.isAbsolute(relative)) {
23211
+ return file;
23212
+ }
23213
+ return relative;
23214
+ }
23215
+ function extractReadFiles(root, output) {
23216
+ if (typeof output.output !== "string")
23217
+ return [];
23218
+ const file = extractPath(output.output);
23219
+ if (!file)
23220
+ return [];
23221
+ return [
23222
+ {
23223
+ path: normalizePath(root, file),
23224
+ lineCount: countReadLines(output.output).length,
23225
+ lineNumbers: countReadLines(output.output),
23226
+ lastReadAt: Date.now()
23227
+ }
23228
+ ];
23229
+ }
23230
+ function countReadLines(output) {
23231
+ const lines = new Set;
23232
+ for (const match of output.matchAll(/^([0-9]+):/gm)) {
23233
+ lines.add(Number(match[1]));
23234
+ }
23235
+ return [...lines];
23236
+ }
23237
+ function createTaskSessionManagerHook(_ctx, options) {
23238
+ const sessionManager = new SessionManager(options.maxSessionsPerAgent, {
23239
+ readContextMinLines: options.readContextMinLines,
23240
+ readContextMaxFiles: options.readContextMaxFiles
23241
+ });
23242
+ const pendingCalls = new Map;
23243
+ const pendingCallOrder = [];
23244
+ const contextByTask = new Map;
23245
+ const pendingManagedTaskIds = new Set;
23246
+ function addTaskContext(taskId, files) {
23247
+ if (files.length === 0)
23248
+ return;
23249
+ let context = contextByTask.get(taskId);
23250
+ if (!context) {
23251
+ context = new Map;
23252
+ contextByTask.set(taskId, context);
23253
+ }
23254
+ for (const file of files) {
23255
+ const pending = context.get(file.path) ?? {
23256
+ path: file.path,
23257
+ lines: new Set,
23258
+ lastReadAt: file.lastReadAt
23259
+ };
23260
+ for (const line of file.lineNumbers ?? []) {
23261
+ pending.lines.add(line);
23262
+ }
23263
+ pending.lastReadAt = Math.max(pending.lastReadAt, file.lastReadAt);
23264
+ context.set(file.path, pending);
23265
+ }
23266
+ sessionManager.addContext(taskId, contextFilesForPrompt(context));
23267
+ }
23268
+ function contextFilesForPrompt(context) {
23269
+ if (!context)
23270
+ return [];
23271
+ return [...context.values()].map((file) => ({
23272
+ path: file.path,
23273
+ lineCount: file.lines.size,
23274
+ lastReadAt: file.lastReadAt
23275
+ }));
23276
+ }
23277
+ function canTrackTaskContext(taskId) {
23278
+ return pendingManagedTaskIds.has(taskId) || sessionManager.taskIds().has(taskId);
23279
+ }
23280
+ function pruneContext() {
23281
+ const remembered = sessionManager.taskIds();
23282
+ for (const taskId of contextByTask.keys()) {
23283
+ if (!pendingManagedTaskIds.has(taskId) && !remembered.has(taskId)) {
23284
+ contextByTask.delete(taskId);
23285
+ }
23286
+ }
23287
+ }
23288
+ function isMissingRememberedSessionError(output) {
23289
+ const firstLine = output.split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? "";
23290
+ return firstLine.startsWith("[error]") && firstLine.includes("session") && (firstLine.includes("not found") || firstLine.includes("no session"));
23291
+ }
23292
+ function rememberPendingCall(call) {
23293
+ const existingIndex = pendingCallOrder.indexOf(call.callId);
23294
+ if (existingIndex >= 0) {
23295
+ pendingCallOrder.splice(existingIndex, 1);
23296
+ }
23297
+ pendingCalls.set(call.callId, call);
23298
+ pendingCallOrder.push(call.callId);
23299
+ while (pendingCallOrder.length > MAX_PENDING_TASK_CALLS) {
23300
+ const evictedCallId = pendingCallOrder.shift();
23301
+ if (!evictedCallId) {
23302
+ break;
23303
+ }
23304
+ pendingCalls.delete(evictedCallId);
23305
+ }
23306
+ }
23307
+ function takePendingCall(callId) {
23308
+ if (!callId)
23309
+ return;
23310
+ const pending = pendingCalls.get(callId);
23311
+ pendingCalls.delete(callId);
23312
+ const orderIndex = pendingCallOrder.indexOf(callId);
23313
+ if (orderIndex >= 0) {
23314
+ pendingCallOrder.splice(orderIndex, 1);
23315
+ }
23316
+ return pending;
23317
+ }
23318
+ return {
23319
+ "tool.execute.before": async (input, output) => {
23320
+ if (input.tool.toLowerCase() !== "task")
23321
+ return;
23322
+ if (!input.sessionID || !options.shouldManageSession(input.sessionID)) {
23323
+ return;
23324
+ }
23325
+ if (!isObjectRecord(output.args))
23326
+ return;
23327
+ const args = output.args;
23328
+ if (!isAgentName(args.subagent_type))
23329
+ return;
23330
+ const label = deriveTaskSessionLabel({
23331
+ description: typeof args.description === "string" ? args.description : undefined,
23332
+ prompt: typeof args.prompt === "string" ? args.prompt : undefined,
23333
+ agentType: args.subagent_type
23334
+ });
23335
+ if (input.callID) {
23336
+ rememberPendingCall({
23337
+ callId: input.callID,
23338
+ parentSessionId: input.sessionID,
23339
+ agentType: args.subagent_type,
23340
+ label
23341
+ });
23342
+ }
23343
+ if (typeof args.task_id !== "string" || args.task_id.trim() === "") {
23344
+ return;
23345
+ }
23346
+ const requested = args.task_id.trim();
23347
+ const remembered = sessionManager.resolve(input.sessionID, args.subagent_type, requested);
23348
+ if (!remembered) {
23349
+ delete args.task_id;
23350
+ return;
23351
+ }
23352
+ args.task_id = remembered.taskId;
23353
+ pendingManagedTaskIds.add(remembered.taskId);
23354
+ sessionManager.markUsed(input.sessionID, args.subagent_type, remembered.taskId);
23355
+ if (input.callID) {
23356
+ rememberPendingCall({
23357
+ callId: input.callID,
23358
+ parentSessionId: input.sessionID,
23359
+ agentType: args.subagent_type,
23360
+ label,
23361
+ resumedTaskId: remembered.taskId
23362
+ });
23363
+ }
23364
+ },
23365
+ "tool.execute.after": async (input, output) => {
23366
+ if (input.tool.toLowerCase() === "read") {
23367
+ if (input.sessionID && canTrackTaskContext(input.sessionID)) {
23368
+ addTaskContext(input.sessionID, extractReadFiles(_ctx.directory, output));
23369
+ }
23370
+ return;
23371
+ }
23372
+ if (input.tool.toLowerCase() !== "task")
23373
+ return;
23374
+ const pending = takePendingCall(input.callID);
23375
+ if (!pending || typeof output.output !== "string")
23376
+ return;
23377
+ const taskId = parseTaskIdFromTaskOutput(output.output);
23378
+ if (!taskId) {
23379
+ if (pending.resumedTaskId && isMissingRememberedSessionError(output.output)) {
23380
+ sessionManager.drop(pending.parentSessionId, pending.agentType, pending.resumedTaskId);
23381
+ }
23382
+ return;
23383
+ }
23384
+ if (pending.resumedTaskId && pending.resumedTaskId !== taskId) {
23385
+ sessionManager.drop(pending.parentSessionId, pending.agentType, pending.resumedTaskId);
23386
+ }
23387
+ sessionManager.remember({
23388
+ parentSessionId: pending.parentSessionId,
23389
+ taskId,
23390
+ agentType: pending.agentType,
23391
+ label: pending.label
23392
+ });
23393
+ pendingManagedTaskIds.delete(taskId);
23394
+ const contextFiles = contextFilesForPrompt(contextByTask.get(taskId));
23395
+ sessionManager.addContext(taskId, contextFiles);
23396
+ pruneContext();
23397
+ },
23398
+ "experimental.chat.system.transform": async (input, output) => {
23399
+ if (!input.sessionID || !options.shouldManageSession(input.sessionID)) {
23400
+ return;
23401
+ }
23402
+ const reminder = sessionManager.formatForPrompt(input.sessionID);
23403
+ if (!reminder)
23404
+ return;
23405
+ output.system.push(reminder);
23406
+ },
23407
+ event: async (input) => {
23408
+ if (input.event.type === "session.created") {
23409
+ const info = input.event.properties?.info;
23410
+ if (info?.id && info.parentID && options.shouldManageSession(info.parentID)) {
23411
+ pendingManagedTaskIds.add(info.id);
23412
+ }
23413
+ return;
23414
+ }
23415
+ if (input.event.type !== "session.deleted")
23416
+ return;
23417
+ const sessionId = input.event.properties?.info?.id ?? input.event.properties?.sessionID;
23418
+ if (!sessionId)
23419
+ return;
23420
+ sessionManager.clearParent(sessionId);
23421
+ sessionManager.dropTask(sessionId);
23422
+ contextByTask.delete(sessionId);
23423
+ pendingManagedTaskIds.delete(sessionId);
23424
+ pruneContext();
23425
+ for (const [callId, pending] of pendingCalls.entries()) {
23426
+ if (pending.parentSessionId !== sessionId) {
23427
+ continue;
23428
+ }
23429
+ takePendingCall(callId);
23430
+ }
23431
+ }
23432
+ };
23433
+ }
22883
23434
  // src/hooks/todo-continuation/index.ts
22884
23435
  import { tool } from "@opencode-ai/plugin/tool";
22885
23436
 
@@ -23542,7 +24093,7 @@ function createTodoContinuationHook(ctx, config) {
23542
24093
  };
23543
24094
  }
23544
24095
  // src/interview/manager.ts
23545
- import path11 from "node:path";
24096
+ import path12 from "node:path";
23546
24097
 
23547
24098
  // src/interview/dashboard.ts
23548
24099
  import crypto from "node:crypto";
@@ -23552,27 +24103,27 @@ import {
23552
24103
  createServer
23553
24104
  } from "node:http";
23554
24105
  import os3 from "node:os";
23555
- import path9 from "node:path";
24106
+ import path10 from "node:path";
23556
24107
  import { URL as URL2 } from "node:url";
23557
24108
 
23558
24109
  // src/interview/document.ts
23559
24110
  import * as fsSync from "node:fs";
23560
24111
  import * as fs6 from "node:fs/promises";
23561
- import * as path8 from "node:path";
24112
+ import * as path9 from "node:path";
23562
24113
  var DEFAULT_OUTPUT_FOLDER = "interview";
23563
24114
  function normalizeOutputFolder(outputFolder) {
23564
24115
  const normalized = outputFolder.trim().replace(/^\/+|\/+$/g, "");
23565
24116
  return normalized || DEFAULT_OUTPUT_FOLDER;
23566
24117
  }
23567
24118
  function createInterviewDirectoryPath(directory, outputFolder) {
23568
- return path8.join(directory, normalizeOutputFolder(outputFolder));
24119
+ return path9.join(directory, normalizeOutputFolder(outputFolder));
23569
24120
  }
23570
24121
  function createInterviewFilePath(directory, outputFolder, idea) {
23571
24122
  const fileName = `${slugify(idea) || "interview"}.md`;
23572
- return path8.join(createInterviewDirectoryPath(directory, outputFolder), fileName);
24123
+ return path9.join(createInterviewDirectoryPath(directory, outputFolder), fileName);
23573
24124
  }
23574
24125
  function relativeInterviewPath(directory, filePath) {
23575
- return path8.relative(directory, filePath) || path8.basename(filePath);
24126
+ return path9.relative(directory, filePath) || path9.basename(filePath);
23576
24127
  }
23577
24128
  function resolveExistingInterviewPath(directory, outputFolder, value) {
23578
24129
  const trimmed = value.trim();
@@ -23581,22 +24132,22 @@ function resolveExistingInterviewPath(directory, outputFolder, value) {
23581
24132
  }
23582
24133
  const outputDir = createInterviewDirectoryPath(directory, outputFolder);
23583
24134
  const candidates = new Set;
23584
- const resolvedRoot = path8.resolve(directory);
23585
- if (path8.isAbsolute(trimmed)) {
24135
+ const resolvedRoot = path9.resolve(directory);
24136
+ if (path9.isAbsolute(trimmed)) {
23586
24137
  candidates.add(trimmed);
23587
24138
  } else {
23588
- candidates.add(path8.resolve(directory, trimmed));
23589
- candidates.add(path8.join(outputDir, trimmed));
24139
+ candidates.add(path9.resolve(directory, trimmed));
24140
+ candidates.add(path9.join(outputDir, trimmed));
23590
24141
  if (!trimmed.endsWith(".md")) {
23591
- candidates.add(path8.join(outputDir, `${trimmed}.md`));
24142
+ candidates.add(path9.join(outputDir, `${trimmed}.md`));
23592
24143
  }
23593
24144
  }
23594
24145
  for (const candidate of candidates) {
23595
- if (path8.extname(candidate) !== ".md") {
24146
+ if (path9.extname(candidate) !== ".md") {
23596
24147
  continue;
23597
24148
  }
23598
- const resolved = path8.resolve(candidate);
23599
- if (!resolved.startsWith(resolvedRoot + path8.sep) && resolved !== resolvedRoot) {
24149
+ const resolved = path9.resolve(candidate);
24150
+ if (!resolved.startsWith(resolvedRoot + path9.sep) && resolved !== resolvedRoot) {
23600
24151
  continue;
23601
24152
  }
23602
24153
  if (fsSync.existsSync(candidate)) {
@@ -23676,7 +24227,7 @@ function parseFrontmatter(content) {
23676
24227
  return result;
23677
24228
  }
23678
24229
  async function ensureInterviewFile(record) {
23679
- await fs6.mkdir(path8.dirname(record.markdownPath), { recursive: true });
24230
+ await fs6.mkdir(path9.dirname(record.markdownPath), { recursive: true });
23680
24231
  try {
23681
24232
  await fs6.access(record.markdownPath);
23682
24233
  } catch {
@@ -23764,6 +24315,7 @@ async function readJsonBody(request) {
23764
24315
  }
23765
24316
 
23766
24317
  // src/interview/ui.ts
24318
+ var BRAND_LOGO_URL = "https://ohmyopencodeslim.com/android-chrome-512x512.png";
23767
24319
  function escapeHtml(value) {
23768
24320
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
23769
24321
  }
@@ -23861,12 +24413,8 @@ function sharedStyles() {
23861
24413
  }
23862
24414
  .footer { margin-top: 32px; text-align: center; font-size: 13px; color: rgba(255,255,255,0.4); }`;
23863
24415
  }
23864
- function brandSvg(size) {
23865
- return `<svg class="brand-mark" viewBox="0 0 144 144" role="img" aria-label="Oh My Opencode Slim" style="width:${size}px;height:${size}px">
23866
- <rect x="12" y="12" width="120" height="120" rx="32" fill="rgba(255,255,255,0.08)" stroke="rgba(255,255,255,0.18)" stroke-width="2"/>
23867
- <path d="M50 48h18c16 0 26 10 26 24s-10 24-26 24H50z" fill="none" stroke="white" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
23868
- <path d="M74 48h20c10 0 18 8 18 18v12c0 10-8 18-18 18H74" fill="none" stroke="white" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" opacity="0.65"/>
23869
- </svg>`;
24416
+ function brandImage(size) {
24417
+ return `<img class="brand-mark" src="${BRAND_LOGO_URL}" alt="Oh My Opencode Slim" width="${size}" height="${size}" />`;
23870
24418
  }
23871
24419
  function renderDashboardPage(interviews, files, outputFolder) {
23872
24420
  const activeHtml = interviews.length === 0 ? "" : interviews.map((item) => {
@@ -24075,7 +24623,7 @@ function renderDashboardPage(interviews, files, outputFolder) {
24075
24623
  <body>
24076
24624
  <div class="wrap">
24077
24625
  <div class="brand-header">
24078
- ${brandSvg(96)}
24626
+ ${brandImage(96)}
24079
24627
  <h1>Interviews</h1>
24080
24628
  <p class="muted">${totalCount} item${totalCount === 1 ? "" : "s"}</p>
24081
24629
  </div>
@@ -24276,6 +24824,37 @@ function renderInterviewPage(interviewId, resumeSlug) {
24276
24824
  }
24277
24825
 
24278
24826
  .options { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }
24827
+ .question-hint {
24828
+ display: flex;
24829
+ flex-wrap: wrap;
24830
+ gap: 8px;
24831
+ margin: -4px 0 16px;
24832
+ color: rgba(255,255,255,0.5);
24833
+ font-size: 13px;
24834
+ line-height: 1.5;
24835
+ transition: color 0.2s ease;
24836
+ }
24837
+ .active-question .question-hint {
24838
+ color: rgba(255,255,255,0.78);
24839
+ }
24840
+ .hint-chip {
24841
+ display: inline-flex;
24842
+ align-items: center;
24843
+ gap: 6px;
24844
+ padding: 4px 10px;
24845
+ border-radius: 999px;
24846
+ background: rgba(255,255,255,0.06);
24847
+ border: 1px solid rgba(255,255,255,0.08);
24848
+ }
24849
+ .hint-chip kbd {
24850
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
24851
+ font-size: 12px;
24852
+ padding: 2px 6px;
24853
+ border-radius: 6px;
24854
+ background: rgba(255,255,255,0.12);
24855
+ border: 1px solid rgba(255,255,255,0.08);
24856
+ color: rgba(255,255,255,0.95);
24857
+ }
24279
24858
 
24280
24859
  .option {
24281
24860
  border: 1px solid rgba(255,255,255,0.1);
@@ -24555,7 +25134,7 @@ function renderInterviewPage(interviewId, resumeSlug) {
24555
25134
  <div class="wrap">
24556
25135
  <a href="/" class="back-link">← All Interviews</a>
24557
25136
  <div class="brand-header">
24558
- ${brandSvg(144)}
25137
+ ${brandImage(144)}
24559
25138
  </div>
24560
25139
  <h1 id="idea">Connecting...</h1>
24561
25140
  <p class="muted" id="summary">Preparing interview session</p>
@@ -24596,7 +25175,15 @@ function renderInterviewPage(interviewId, resumeSlug) {
24596
25175
  ${clipboardHelperJs()}
24597
25176
  const interviewId = ${JSON.stringify(interviewId).replace(/</g, "\\u003c")};
24598
25177
  const resumeSlug = ${JSON.stringify(resumeSlug).replace(/</g, "\\u003c")};
24599
- const state = { data: null, answers: {}, activeQuestionIndex: 0, lastSig: null, customMode: {} };
25178
+ const state = {
25179
+ data: null,
25180
+ answers: {},
25181
+ activeQuestionIndex: 0,
25182
+ lastQuestionIds: [],
25183
+ lastSig: null,
25184
+ customMode: {},
25185
+ isSubmitting: false,
25186
+ };
24600
25187
 
24601
25188
  function updateSubmitButton() {
24602
25189
  const button = document.getElementById('submitButton');
@@ -24609,7 +25196,11 @@ function renderInterviewPage(interviewId, resumeSlug) {
24609
25196
  const allAnswered = questions.every((question) =>
24610
25197
  (state.answers[question.id] || '').trim().length > 0,
24611
25198
  );
24612
- button.disabled = state.data.isBusy || !questions.length || !allAnswered;
25199
+ button.disabled =
25200
+ state.data.isBusy ||
25201
+ state.isSubmitting ||
25202
+ !questions.length ||
25203
+ !allAnswered;
24613
25204
  const hideSubmit = ['completed', 'session-disconnected'];
24614
25205
  button.style.display = hideSubmit.includes(state.data.mode) ? 'none' : '';
24615
25206
 
@@ -24760,6 +25351,54 @@ function renderInterviewPage(interviewId, resumeSlug) {
24760
25351
  });
24761
25352
  }
24762
25353
 
25354
+ function scrollToActiveQuestion(behavior) {
25355
+ const questions = state.data?.questions || [];
25356
+ const activeQ = questions[state.activeQuestionIndex];
25357
+ if (!activeQ) return;
25358
+
25359
+ const wrapper = document.getElementById('question-' + activeQ.id);
25360
+ if (wrapper) {
25361
+ wrapper.scrollIntoView({ behavior, block: 'center' });
25362
+ }
25363
+ }
25364
+
25365
+ function syncActiveQuestionIndex(questions) {
25366
+ if (!questions.length) {
25367
+ state.activeQuestionIndex = 0;
25368
+ state.lastQuestionIds = [];
25369
+ return;
25370
+ }
25371
+
25372
+ const nextQuestionIds = questions.map((question) => question.id);
25373
+ const previousQuestionIds = state.lastQuestionIds || [];
25374
+ const activeQuestionId = previousQuestionIds[state.activeQuestionIndex];
25375
+ const nextActiveIndex = activeQuestionId
25376
+ ? nextQuestionIds.indexOf(activeQuestionId)
25377
+ : -1;
25378
+
25379
+ if (nextActiveIndex >= 0) {
25380
+ state.activeQuestionIndex = nextActiveIndex;
25381
+ } else {
25382
+ state.activeQuestionIndex = 0;
25383
+ }
25384
+
25385
+ state.lastQuestionIds = nextQuestionIds;
25386
+ }
25387
+
25388
+ function isTextEntryTarget(target) {
25389
+ return target &&
25390
+ (target.tagName === 'TEXTAREA' ||
25391
+ target.tagName === 'INPUT' ||
25392
+ target.isContentEditable);
25393
+ }
25394
+
25395
+ function isShortcutBlockedTarget(target) {
25396
+ if (!target) return false;
25397
+ return !!target.closest(
25398
+ 'button, a, select, summary, textarea, input, [contenteditable="true"]',
25399
+ );
25400
+ }
25401
+
24763
25402
  document.addEventListener('keydown', (e) => {
24764
25403
  const isSubmitShortcut =
24765
25404
  (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) ||
@@ -24773,12 +25412,41 @@ function renderInterviewPage(interviewId, resumeSlug) {
24773
25412
  return;
24774
25413
  }
24775
25414
 
24776
- if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
25415
+ if (isTextEntryTarget(e.target)) return;
24777
25416
  if (e.ctrlKey || e.metaKey || e.altKey) return;
24778
25417
 
24779
25418
  const questions = state.data?.questions || [];
24780
25419
  if (!questions.length) return;
24781
25420
 
25421
+ if (e.key === 'Enter') {
25422
+ if (e.repeat) {
25423
+ e.preventDefault();
25424
+ return;
25425
+ }
25426
+ if (isShortcutBlockedTarget(e.target)) return;
25427
+ if (state.data.isBusy || state.isSubmitting) return;
25428
+
25429
+ const activeQ = questions[state.activeQuestionIndex];
25430
+ if (!activeQ) return;
25431
+
25432
+ const answer = (state.answers[activeQ.id] || '').trim();
25433
+ if (!answer) return;
25434
+
25435
+ const isLastQuestion =
25436
+ state.activeQuestionIndex === questions.length - 1;
25437
+ if (isLastQuestion) {
25438
+ const submitBtn = document.getElementById('submitButton');
25439
+ if (submitBtn && !submitBtn.disabled) {
25440
+ submitBtn.click();
25441
+ }
25442
+ } else {
25443
+ advanceToNextQuestion(activeQ.id);
25444
+ }
25445
+
25446
+ e.preventDefault();
25447
+ return;
25448
+ }
25449
+
24782
25450
  const num = parseInt(e.key, 10);
24783
25451
  if (num >= 1 && num <= 9) {
24784
25452
  const activeQ = questions[state.activeQuestionIndex];
@@ -24946,6 +25614,10 @@ function renderInterviewPage(interviewId, resumeSlug) {
24946
25614
  function renderQuestions(questions) {
24947
25615
  const sig = JSON.stringify([questions, state.data?.mode]);
24948
25616
  const container = document.getElementById('questions');
25617
+ const previousActiveQuestionId =
25618
+ state.lastQuestionIds[state.activeQuestionIndex];
25619
+
25620
+ syncActiveQuestionIndex(questions);
24949
25621
 
24950
25622
  if (state.lastSig === sig) {
24951
25623
  questions.forEach((q) => updateOptionsDOM(q.id));
@@ -24986,6 +25658,15 @@ function renderInterviewPage(interviewId, resumeSlug) {
24986
25658
  wrapper.appendChild(title);
24987
25659
 
24988
25660
  const predefined = question.options || [];
25661
+ if (predefined.length) {
25662
+ const hint = document.createElement('div');
25663
+ hint.className = 'question-hint';
25664
+ hint.innerHTML =
25665
+ '<span class="hint-chip"><kbd>1-9</kbd><span>Choose an option</span></span>' +
25666
+ '<span class="hint-chip"><kbd>Enter</kbd><span>Accept selected answer</span></span>';
25667
+ wrapper.appendChild(hint);
25668
+ }
25669
+
24989
25670
  if (predefined.length) {
24990
25671
  const options = document.createElement('div');
24991
25672
  options.className = 'options';
@@ -25021,6 +25702,13 @@ function renderInterviewPage(interviewId, resumeSlug) {
25021
25702
 
25022
25703
  updateActiveQuestionFocus();
25023
25704
  questions.forEach(q => updateOptionsDOM(q.id));
25705
+ const currentActiveQuestionId = questions[state.activeQuestionIndex]?.id;
25706
+ if (
25707
+ questions.length > 0 &&
25708
+ previousActiveQuestionId !== currentActiveQuestionId
25709
+ ) {
25710
+ scrollToActiveQuestion('smooth');
25711
+ }
25024
25712
  }
25025
25713
 
25026
25714
  function render(data) {
@@ -25094,7 +25782,8 @@ function renderInterviewPage(interviewId, resumeSlug) {
25094
25782
  }
25095
25783
 
25096
25784
  document.getElementById('submitButton').addEventListener('click', async () => {
25097
- if (!state.data) return;
25785
+ if (!state.data || state.isSubmitting) return;
25786
+ document.getElementById('submitButton').blur();
25098
25787
  const answers = (state.data.questions || []).map((question) => {
25099
25788
  return {
25100
25789
  questionId: question.id,
@@ -25102,6 +25791,9 @@ function renderInterviewPage(interviewId, resumeSlug) {
25102
25791
  };
25103
25792
  });
25104
25793
 
25794
+ state.isSubmitting = true;
25795
+ updateSubmitButton();
25796
+
25105
25797
  const overlay = document.getElementById('loadingOverlay');
25106
25798
  const overlayText = document.getElementById('loadingText');
25107
25799
  overlay.classList.add('active');
@@ -25119,6 +25811,8 @@ function renderInterviewPage(interviewId, resumeSlug) {
25119
25811
  } catch (err) {
25120
25812
  document.getElementById('submitStatus').textContent = 'Error submitting answers.';
25121
25813
  }
25814
+ state.isSubmitting = false;
25815
+ updateSubmitButton();
25122
25816
  try {
25123
25817
  await refresh();
25124
25818
  } catch (_error) {
@@ -25203,12 +25897,12 @@ function renderInterviewPage(interviewId, resumeSlug) {
25203
25897
 
25204
25898
  // src/interview/dashboard.ts
25205
25899
  function getAuthFilePath(port) {
25206
- const dataHome = process.env.XDG_DATA_HOME || path9.join(os3.homedir(), ".local", "share");
25207
- return path9.join(dataHome, "opencode", `.dashboard-${port}.json`);
25900
+ const dataHome = process.env.XDG_DATA_HOME || path10.join(os3.homedir(), ".local", "share");
25901
+ return path10.join(dataHome, "opencode", `.dashboard-${port}.json`);
25208
25902
  }
25209
25903
  function writeAuthFile(port, token) {
25210
25904
  const filePath = getAuthFilePath(port);
25211
- const dir = path9.dirname(filePath);
25905
+ const dir = path10.dirname(filePath);
25212
25906
  try {
25213
25907
  fsSync2.mkdirSync(dir, { recursive: true });
25214
25908
  } catch {}
@@ -25345,7 +26039,7 @@ function createDashboardServer(config) {
25345
26039
  const directories = getKnownDirectories();
25346
26040
  const items = [];
25347
26041
  for (const dir of directories) {
25348
- const interviewDir = path9.join(dir, config.outputFolder);
26042
+ const interviewDir = path10.join(dir, config.outputFolder);
25349
26043
  let entries;
25350
26044
  try {
25351
26045
  entries = await fs7.readdir(interviewDir);
@@ -25357,7 +26051,7 @@ function createDashboardServer(config) {
25357
26051
  continue;
25358
26052
  let content;
25359
26053
  try {
25360
- content = await fs7.readFile(path9.join(interviewDir, entry), "utf8");
26054
+ content = await fs7.readFile(path10.join(interviewDir, entry), "utf8");
25361
26055
  } catch {
25362
26056
  continue;
25363
26057
  }
@@ -25383,7 +26077,7 @@ function createDashboardServer(config) {
25383
26077
  const directories = getKnownDirectories();
25384
26078
  let rebuilt = 0;
25385
26079
  for (const dir of directories) {
25386
- const interviewDir = path9.join(dir, config.outputFolder);
26080
+ const interviewDir = path10.join(dir, config.outputFolder);
25387
26081
  let entries;
25388
26082
  try {
25389
26083
  entries = await fs7.readdir(interviewDir);
@@ -25395,7 +26089,7 @@ function createDashboardServer(config) {
25395
26089
  continue;
25396
26090
  let content;
25397
26091
  try {
25398
- content = await fs7.readFile(path9.join(interviewDir, entry), "utf8");
26092
+ content = await fs7.readFile(path10.join(interviewDir, entry), "utf8");
25399
26093
  } catch {
25400
26094
  continue;
25401
26095
  }
@@ -25421,7 +26115,7 @@ function createDashboardServer(config) {
25421
26115
  questions: [],
25422
26116
  pendingAnswers: null,
25423
26117
  lastUpdatedAt: fm.updatedAt ? new Date(fm.updatedAt).getTime() : Date.now(),
25424
- filePath: path9.join(interviewDir, entry),
26118
+ filePath: path10.join(interviewDir, entry),
25425
26119
  nudgeAction: null
25426
26120
  });
25427
26121
  if (!sessions.has(fm.sessionID)) {
@@ -25685,7 +26379,7 @@ function createDashboardServer(config) {
25685
26379
  const dirs = getKnownDirectories();
25686
26380
  for (const dir of dirs) {
25687
26381
  const slug = extractResumeSlug(interviewId);
25688
- const candidate = path9.join(dir, config.outputFolder, `${slug}.md`);
26382
+ const candidate = path10.join(dir, config.outputFolder, `${slug}.md`);
25689
26383
  try {
25690
26384
  document = await fs7.readFile(candidate, "utf8");
25691
26385
  markdownPath = candidate;
@@ -26213,7 +26907,7 @@ function createInterviewServer(deps) {
26213
26907
  // src/interview/service.ts
26214
26908
  import { spawn } from "node:child_process";
26215
26909
  import * as fs8 from "node:fs/promises";
26216
- import * as path10 from "node:path";
26910
+ import * as path11 from "node:path";
26217
26911
 
26218
26912
  // src/interview/types.ts
26219
26913
  import { z as z3 } from "zod";
@@ -26438,6 +27132,7 @@ function createInterviewService(ctx, config, deps) {
26438
27132
  const activeInterviewIds = new Map;
26439
27133
  const interviewsById = new Map;
26440
27134
  const sessionBusy = new Map;
27135
+ const sessionModel = new Map;
26441
27136
  const browserOpened = new Set;
26442
27137
  let resolveBaseUrl = null;
26443
27138
  let onStateChange = null;
@@ -26479,12 +27174,12 @@ function createInterviewService(ctx, config, deps) {
26479
27174
  if (!newSlug) {
26480
27175
  return;
26481
27176
  }
26482
- const currentFileName = path10.basename(interview.markdownPath, ".md");
27177
+ const currentFileName = path11.basename(interview.markdownPath, ".md");
26483
27178
  if (currentFileName === newSlug) {
26484
27179
  return;
26485
27180
  }
26486
- const dir = path10.dirname(interview.markdownPath);
26487
- const newPath = path10.join(dir, `${newSlug}.md`);
27181
+ const dir = path11.dirname(interview.markdownPath);
27182
+ const newPath = path11.join(dir, `${newSlug}.md`);
26488
27183
  try {
26489
27184
  await fs8.access(newPath);
26490
27185
  return;
@@ -26560,9 +27255,9 @@ function createInterviewService(ctx, config, deps) {
26560
27255
  const messages = await loadMessages(sessionID);
26561
27256
  const title = extractTitle(document);
26562
27257
  const record = {
26563
- id: `${Date.now()}-${++idCounter}-${slugify(path10.basename(markdownPath, ".md")) || "interview"}`,
27258
+ id: `${Date.now()}-${++idCounter}-${slugify(path11.basename(markdownPath, ".md")) || "interview"}`,
26564
27259
  sessionID,
26565
- idea: title || path10.basename(markdownPath, ".md"),
27260
+ idea: title || path11.basename(markdownPath, ".md"),
26566
27261
  markdownPath,
26567
27262
  createdAt: nowIso(),
26568
27263
  status: "active",
@@ -26693,10 +27388,12 @@ function createInterviewService(ctx, config, deps) {
26693
27388
  }
26694
27389
  await appendInterviewAnswers(interview, state.questions, answers);
26695
27390
  const prompt = buildAnswerPrompt(answers, state.questions, maxQuestions);
27391
+ const model = sessionModel.get(interview.sessionID);
26696
27392
  await ctx.client.session.promptAsync({
26697
27393
  path: { id: interview.sessionID },
26698
27394
  body: {
26699
- parts: [createInternalAgentTextPart(prompt)]
27395
+ parts: [createInternalAgentTextPart(prompt)],
27396
+ ...model ? { model: parseModelReference(model) ?? undefined } : {}
26700
27397
  }
26701
27398
  });
26702
27399
  promptSent = true;
@@ -26746,12 +27443,23 @@ function createInterviewService(ctx, config, deps) {
26746
27443
  }
26747
27444
  return;
26748
27445
  }
27446
+ if (event.type === "message.updated") {
27447
+ const info = properties;
27448
+ const sessionID = info?.info?.sessionID;
27449
+ const providerID = info?.info?.providerID;
27450
+ const modelID = info?.info?.modelID;
27451
+ if (sessionID && providerID && modelID) {
27452
+ sessionModel.set(sessionID, `${providerID}/${modelID}`);
27453
+ }
27454
+ return;
27455
+ }
26749
27456
  if (event.type === "session.deleted") {
26750
27457
  const deletedSessionId = (properties.info?.id ?? properties.sessionID) || null;
26751
27458
  if (!deletedSessionId) {
26752
27459
  return;
26753
27460
  }
26754
27461
  sessionBusy.delete(deletedSessionId);
27462
+ sessionModel.delete(deletedSessionId);
26755
27463
  const interviewId = activeInterviewIds.get(deletedSessionId);
26756
27464
  if (!interviewId) {
26757
27465
  return;
@@ -26776,7 +27484,7 @@ function createInterviewService(ctx, config, deps) {
26776
27484
  return fileCache.items;
26777
27485
  }
26778
27486
  const outputDir = createInterviewDirectoryPath(ctx.directory, outputFolder);
26779
- const activePaths = new Set([...interviewsById.values()].filter((i) => i.status === "active").map((i) => path10.resolve(i.markdownPath)));
27487
+ const activePaths = new Set([...interviewsById.values()].filter((i) => i.status === "active").map((i) => path11.resolve(i.markdownPath)));
26780
27488
  let entries;
26781
27489
  try {
26782
27490
  entries = await fs8.readdir(outputDir);
@@ -26787,8 +27495,8 @@ function createInterviewService(ctx, config, deps) {
26787
27495
  for (const entry of entries) {
26788
27496
  if (!entry.endsWith(".md"))
26789
27497
  continue;
26790
- const fullPath = path10.join(outputDir, entry);
26791
- if (activePaths.has(path10.resolve(fullPath)))
27498
+ const fullPath = path11.join(outputDir, entry);
27499
+ if (activePaths.has(path11.resolve(fullPath)))
26792
27500
  continue;
26793
27501
  let content;
26794
27502
  try {
@@ -26848,10 +27556,12 @@ function createInterviewService(ctx, config, deps) {
26848
27556
  ].join(`
26849
27557
  `);
26850
27558
  }
27559
+ const model = sessionModel.get(interview.sessionID);
26851
27560
  await ctx.client.session.promptAsync({
26852
27561
  path: { id: interview.sessionID },
26853
27562
  body: {
26854
- parts: [createInternalAgentTextPart(prompt)]
27563
+ parts: [createInternalAgentTextPart(prompt)],
27564
+ ...model ? { model: parseModelReference(model) ?? undefined } : {}
26855
27565
  }
26856
27566
  });
26857
27567
  promptSent = true;
@@ -26885,7 +27595,7 @@ function createInterviewManager(ctx, config) {
26885
27595
  const outputFolder = interviewConfig?.outputFolder ?? "interview";
26886
27596
  if (!dashboardEnabled) {
26887
27597
  const service2 = createInterviewService(ctx, interviewConfig);
26888
- const resolvedOutputPath = path11.join(ctx.directory, outputFolder);
27598
+ const resolvedOutputPath = path12.join(ctx.directory, outputFolder);
26889
27599
  const server = createInterviewServer({
26890
27600
  getState: async (interviewId) => service2.getInterviewState(interviewId),
26891
27601
  listInterviewFiles: async () => service2.listInterviewFiles(),
@@ -26990,7 +27700,7 @@ function createInterviewManager(ctx, config) {
26990
27700
  listInterviews: () => service.listInterviews(),
26991
27701
  submitAnswers: async (interviewId, answers) => service.submitAnswers(interviewId, answers),
26992
27702
  handleNudgeAction: async (interviewId, action) => service.handleNudgeAction(interviewId, action),
26993
- outputFolder: path11.join(ctx.directory, outputFolder),
27703
+ outputFolder: path12.join(ctx.directory, outputFolder),
26994
27704
  port: 0
26995
27705
  });
26996
27706
  service.setBaseUrlResolver(() => perSessionServer.ensureStarted());
@@ -27430,23 +28140,23 @@ class TmuxMultiplexer {
27430
28140
  return null;
27431
28141
  }
27432
28142
  const stdout = await proc.stdout();
27433
- const path12 = stdout.trim().split(`
28143
+ const path13 = stdout.trim().split(`
27434
28144
  `)[0];
27435
- if (!path12) {
28145
+ if (!path13) {
27436
28146
  log("[tmux] findBinary: no path in output");
27437
28147
  return null;
27438
28148
  }
27439
- const verifyProc = crossSpawn([path12, "-V"], {
28149
+ const verifyProc = crossSpawn([path13, "-V"], {
27440
28150
  stdout: "pipe",
27441
28151
  stderr: "pipe"
27442
28152
  });
27443
28153
  const verifyExit = await verifyProc.exited;
27444
28154
  if (verifyExit !== 0) {
27445
- log("[tmux] findBinary: tmux -V failed", { path: path12, verifyExit });
28155
+ log("[tmux] findBinary: tmux -V failed", { path: path13, verifyExit });
27446
28156
  return null;
27447
28157
  }
27448
- log("[tmux] findBinary: found", { path: path12 });
27449
- return path12;
28158
+ log("[tmux] findBinary: found", { path: path13 });
28159
+ return path13;
27450
28160
  } catch (err) {
27451
28161
  log("[tmux] findBinary: exception", { error: String(err) });
27452
28162
  return null;
@@ -27825,6 +28535,8 @@ class MultiplexerSessionManager {
27825
28535
  directory;
27826
28536
  multiplexer = null;
27827
28537
  sessions = new Map;
28538
+ knownSessions = new Map;
28539
+ spawningSessions = new Set;
27828
28540
  pollInterval;
27829
28541
  enabled = false;
27830
28542
  constructor(ctx, config) {
@@ -27853,45 +28565,59 @@ class MultiplexerSessionManager {
27853
28565
  const parentId = info.parentID;
27854
28566
  const title = info.title ?? "Subagent";
27855
28567
  const directory = info.directory ?? this.directory;
27856
- if (this.sessions.has(sessionId)) {
27857
- log("[multiplexer-session-manager] session already tracked", {
28568
+ this.knownSessions.set(sessionId, {
28569
+ parentId,
28570
+ title,
28571
+ directory
28572
+ });
28573
+ if (this.isTrackedOrSpawning(sessionId)) {
28574
+ log("[multiplexer-session-manager] session already tracked or spawning", {
27858
28575
  sessionId
27859
28576
  });
27860
28577
  return;
27861
28578
  }
27862
- const serverRunning = await isServerRunning(this.serverUrl);
27863
- if (!serverRunning) {
27864
- log("[multiplexer-session-manager] server not running, skipping", {
27865
- serverUrl: this.serverUrl
27866
- });
27867
- return;
27868
- }
27869
- log("[multiplexer-session-manager] child session created, spawning pane", {
27870
- sessionId,
27871
- parentId,
27872
- title
27873
- });
27874
- const paneResult = await this.multiplexer.spawnPane(sessionId, title, this.serverUrl, directory).catch((err) => {
27875
- log("[multiplexer-session-manager] failed to spawn pane", {
27876
- error: String(err)
27877
- });
27878
- return { success: false, paneId: undefined };
27879
- });
27880
- if (paneResult.success && paneResult.paneId) {
27881
- const now = Date.now();
27882
- this.sessions.set(sessionId, {
28579
+ this.spawningSessions.add(sessionId);
28580
+ try {
28581
+ const serverRunning = await isServerRunning(this.serverUrl);
28582
+ if (!serverRunning) {
28583
+ log("[multiplexer-session-manager] server not running, skipping", {
28584
+ serverUrl: this.serverUrl
28585
+ });
28586
+ return;
28587
+ }
28588
+ if (this.sessions.has(sessionId)) {
28589
+ return;
28590
+ }
28591
+ log("[multiplexer-session-manager] child session created, spawning pane", {
27883
28592
  sessionId,
27884
- paneId: paneResult.paneId,
27885
28593
  parentId,
27886
- title,
27887
- createdAt: now,
27888
- lastSeenAt: now
28594
+ title
27889
28595
  });
27890
- log("[multiplexer-session-manager] pane spawned", {
27891
- sessionId,
27892
- paneId: paneResult.paneId
28596
+ const paneResult = await this.multiplexer.spawnPane(sessionId, title, this.serverUrl, directory).catch((err) => {
28597
+ log("[multiplexer-session-manager] failed to spawn pane", {
28598
+ error: String(err)
28599
+ });
28600
+ return { success: false, paneId: undefined };
27893
28601
  });
27894
- this.startPolling();
28602
+ if (paneResult.success && paneResult.paneId) {
28603
+ const now = Date.now();
28604
+ this.sessions.set(sessionId, {
28605
+ sessionId,
28606
+ paneId: paneResult.paneId,
28607
+ parentId,
28608
+ title,
28609
+ directory,
28610
+ createdAt: now,
28611
+ lastSeenAt: now
28612
+ });
28613
+ log("[multiplexer-session-manager] pane spawned", {
28614
+ sessionId,
28615
+ paneId: paneResult.paneId
28616
+ });
28617
+ this.startPolling();
28618
+ }
28619
+ } finally {
28620
+ this.spawningSessions.delete(sessionId);
27895
28621
  }
27896
28622
  }
27897
28623
  async onSessionStatus(event) {
@@ -27904,6 +28630,10 @@ class MultiplexerSessionManager {
27904
28630
  return;
27905
28631
  if (event.properties?.status?.type === "idle") {
27906
28632
  await this.closeSession(sessionId);
28633
+ return;
28634
+ }
28635
+ if (event.properties?.status?.type === "busy") {
28636
+ await this.respawnIfKnown(sessionId);
27907
28637
  }
27908
28638
  }
27909
28639
  async onSessionDeleted(event) {
@@ -27918,6 +28648,7 @@ class MultiplexerSessionManager {
27918
28648
  sessionId
27919
28649
  });
27920
28650
  await this.closeSession(sessionId);
28651
+ this.knownSessions.delete(sessionId);
27921
28652
  }
27922
28653
  startPolling() {
27923
28654
  if (this.pollInterval)
@@ -27978,6 +28709,61 @@ class MultiplexerSessionManager {
27978
28709
  this.stopPolling();
27979
28710
  }
27980
28711
  }
28712
+ async respawnIfKnown(sessionId) {
28713
+ if (!this.enabled || !this.multiplexer)
28714
+ return;
28715
+ if (this.isTrackedOrSpawning(sessionId))
28716
+ return;
28717
+ const known = this.knownSessions.get(sessionId);
28718
+ if (!known)
28719
+ return;
28720
+ this.spawningSessions.add(sessionId);
28721
+ try {
28722
+ const serverRunning = await isServerRunning(this.serverUrl);
28723
+ if (!serverRunning) {
28724
+ log("[multiplexer-session-manager] server not running, skipping busy respawn", {
28725
+ serverUrl: this.serverUrl,
28726
+ sessionId
28727
+ });
28728
+ return;
28729
+ }
28730
+ if (this.sessions.has(sessionId))
28731
+ return;
28732
+ log("[multiplexer-session-manager] child session busy again, respawning pane", {
28733
+ sessionId,
28734
+ parentId: known.parentId,
28735
+ title: known.title
28736
+ });
28737
+ const paneResult = await this.multiplexer.spawnPane(sessionId, known.title, this.serverUrl, known.directory).catch((err) => {
28738
+ log("[multiplexer-session-manager] failed to respawn pane", {
28739
+ error: String(err)
28740
+ });
28741
+ return { success: false, paneId: undefined };
28742
+ });
28743
+ if (!paneResult.success || !paneResult.paneId)
28744
+ return;
28745
+ const now = Date.now();
28746
+ this.sessions.set(sessionId, {
28747
+ sessionId,
28748
+ paneId: paneResult.paneId,
28749
+ parentId: known.parentId,
28750
+ title: known.title,
28751
+ directory: known.directory,
28752
+ createdAt: now,
28753
+ lastSeenAt: now
28754
+ });
28755
+ log("[multiplexer-session-manager] pane respawned on busy", {
28756
+ sessionId,
28757
+ paneId: paneResult.paneId
28758
+ });
28759
+ this.startPolling();
28760
+ } finally {
28761
+ this.spawningSessions.delete(sessionId);
28762
+ }
28763
+ }
28764
+ isTrackedOrSpawning(sessionId) {
28765
+ return this.sessions.has(sessionId) || this.spawningSessions.has(sessionId);
28766
+ }
27981
28767
  async cleanup() {
27982
28768
  this.stopPolling();
27983
28769
  if (this.sessions.size > 0 && this.multiplexer) {
@@ -27992,6 +28778,8 @@ class MultiplexerSessionManager {
27992
28778
  await Promise.all(closePromises);
27993
28779
  this.sessions.clear();
27994
28780
  }
28781
+ this.knownSessions.clear();
28782
+ this.spawningSessions.clear();
27995
28783
  log("[multiplexer-session-manager] cleanup complete");
27996
28784
  }
27997
28785
  }
@@ -28203,9 +28991,9 @@ function findSgCliPathSync() {
28203
28991
  }
28204
28992
  if (process.platform === "darwin") {
28205
28993
  const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"];
28206
- for (const path12 of homebrewPaths) {
28207
- if (existsSync7(path12) && isValidBinary(path12)) {
28208
- return path12;
28994
+ for (const path13 of homebrewPaths) {
28995
+ if (existsSync7(path13) && isValidBinary(path13)) {
28996
+ return path13;
28209
28997
  }
28210
28998
  }
28211
28999
  }
@@ -28222,8 +29010,8 @@ function getSgCliPath() {
28222
29010
  }
28223
29011
  return "sg";
28224
29012
  }
28225
- function setSgCliPath(path12) {
28226
- resolvedCliPath = path12;
29013
+ function setSgCliPath(path13) {
29014
+ resolvedCliPath = path13;
28227
29015
  }
28228
29016
  var DEFAULT_TIMEOUT_MS2 = 300000;
28229
29017
  var DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024;
@@ -28791,14 +29579,14 @@ var BINARY_PREFIXES = [
28791
29579
  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.";
28792
29580
  // src/tools/smartfetch/tool.ts
28793
29581
  import os4 from "node:os";
28794
- import path15 from "node:path";
29582
+ import path16 from "node:path";
28795
29583
  import {
28796
29584
  tool as tool4
28797
29585
  } from "@opencode-ai/plugin";
28798
29586
 
28799
29587
  // src/tools/smartfetch/binary.ts
28800
29588
  import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
28801
- import path12 from "node:path";
29589
+ import path13 from "node:path";
28802
29590
  function extensionForMime(contentType) {
28803
29591
  const mime = contentType.split(";")[0]?.trim().toLowerCase();
28804
29592
  const map = {
@@ -28819,10 +29607,10 @@ function buildBinaryResultMessage(fetchResult, savedPath) {
28819
29607
  async function saveBinary(binaryDir, data, contentType, filename) {
28820
29608
  await mkdir2(binaryDir, { recursive: true });
28821
29609
  const initialName = filename || `webfetch-${Date.now()}.${extensionForMime(contentType)}`;
28822
- const parsed = path12.parse(initialName);
29610
+ const parsed = path13.parse(initialName);
28823
29611
  for (let attempt = 0;attempt < 1000; attempt++) {
28824
29612
  const candidateName = attempt === 0 ? initialName : `${parsed.name}-${attempt}${parsed.ext || `.${extensionForMime(contentType)}`}`;
28825
- const file = path12.join(binaryDir, candidateName);
29613
+ const file = path13.join(binaryDir, candidateName);
28826
29614
  try {
28827
29615
  await writeFile2(file, data, { flag: "wx" });
28828
29616
  return file;
@@ -29476,7 +30264,7 @@ var L = class u2 {
29476
30264
  };
29477
30265
 
29478
30266
  // src/tools/smartfetch/network.ts
29479
- import path13 from "node:path";
30267
+ import path14 from "node:path";
29480
30268
 
29481
30269
  // src/tools/smartfetch/utils.ts
29482
30270
  var import_readability = __toESM(require_readability(), 1);
@@ -30201,7 +30989,7 @@ function inferFilenameFromUrl(url) {
30201
30989
  function truncateFilename(name, maxLength = 180) {
30202
30990
  if (name.length <= maxLength)
30203
30991
  return name;
30204
- const parsed = path13.parse(name);
30992
+ const parsed = path14.parse(name);
30205
30993
  const ext = parsed.ext || "";
30206
30994
  const baseLimit = Math.max(1, maxLength - ext.length);
30207
30995
  return `${parsed.name.slice(0, baseLimit)}${ext}`;
@@ -30373,7 +31161,7 @@ function isInvalidLlmsResult(fetchResult) {
30373
31161
  // src/tools/smartfetch/secondary-model.ts
30374
31162
  import { existsSync as existsSync9 } from "node:fs";
30375
31163
  import { readFile as readFile4 } from "node:fs/promises";
30376
- import path14 from "node:path";
31164
+ import path15 from "node:path";
30377
31165
  function parseModelRef(value) {
30378
31166
  if (!value)
30379
31167
  return;
@@ -30399,7 +31187,7 @@ function pickAgentModelRef(value) {
30399
31187
  }
30400
31188
  function findPreferredOpenCodeConfigPath(baseDir) {
30401
31189
  for (const file of ["opencode.jsonc", "opencode.json"]) {
30402
- const fullPath = path14.join(baseDir, file);
31190
+ const fullPath = path15.join(baseDir, file);
30403
31191
  if (existsSync9(fullPath))
30404
31192
  return fullPath;
30405
31193
  }
@@ -30416,7 +31204,7 @@ async function readOpenCodeConfigFile(configPath) {
30416
31204
  }
30417
31205
  }
30418
31206
  async function readEffectiveOpenCodeConfig(directory) {
30419
- const projectDir = path14.join(directory, ".opencode");
31207
+ const projectDir = path15.join(directory, ".opencode");
30420
31208
  const userDirs = getConfigSearchDirs();
30421
31209
  const projectPath = findPreferredOpenCodeConfigPath(projectDir);
30422
31210
  const userPath = userDirs.map((configDir) => findPreferredOpenCodeConfigPath(configDir)).find(Boolean);
@@ -30577,7 +31365,7 @@ async function runSecondaryModelWithFallback(client, directory, models, prompt,
30577
31365
  // src/tools/smartfetch/tool.ts
30578
31366
  var z5 = tool4.schema;
30579
31367
  function createWebfetchTool(pluginCtx, options = {}) {
30580
- const binaryDir = options.binaryDir || path15.join(os4.tmpdir(), "opencode-smartfetch");
31368
+ const binaryDir = options.binaryDir || path16.join(os4.tmpdir(), "opencode-smartfetch");
30581
31369
  return tool4({
30582
31370
  description: WEBFETCH_DESCRIPTION,
30583
31371
  args: {
@@ -31099,6 +31887,27 @@ class SubagentDepthTracker {
31099
31887
  }
31100
31888
  }
31101
31889
 
31890
+ // src/utils/system-collapse.ts
31891
+ function collapseSystemInPlace(system2) {
31892
+ if (system2.length === 0) {
31893
+ return;
31894
+ }
31895
+ if (system2.length === 1) {
31896
+ if (system2[0]) {
31897
+ return;
31898
+ }
31899
+ system2.length = 0;
31900
+ return;
31901
+ }
31902
+ const joined = system2.join(`
31903
+
31904
+ `);
31905
+ system2.length = 0;
31906
+ if (joined) {
31907
+ system2.push(joined);
31908
+ }
31909
+ }
31910
+
31102
31911
  // src/index.ts
31103
31912
  async function appLog(ctx, level, message) {
31104
31913
  try {
@@ -31128,6 +31937,7 @@ var OhMyOpenCodeLite = async (ctx) => {
31128
31937
  const sessionId = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
31129
31938
  initLogger(sessionId);
31130
31939
  let config;
31940
+ let disabledAgents;
31131
31941
  let agentDefs;
31132
31942
  let agents;
31133
31943
  let mcps;
@@ -31148,13 +31958,17 @@ var OhMyOpenCodeLite = async (ctx) => {
31148
31958
  let jsonErrorRecoveryHook;
31149
31959
  let foregroundFallback;
31150
31960
  let todoContinuationHook;
31961
+ let taskSessionManagerHook;
31151
31962
  let interviewManager;
31152
31963
  let presetManager;
31153
31964
  let councilTools;
31154
31965
  let webfetch;
31966
+ let rewriteDisplayNameMentions;
31155
31967
  let toolCount = 0;
31156
31968
  try {
31157
31969
  config = loadPluginConfig(ctx.directory);
31970
+ disabledAgents = getDisabledAgents(config);
31971
+ rewriteDisplayNameMentions = createDisplayNameMentionRewriter(config);
31158
31972
  agentDefs = createAgents(config);
31159
31973
  agents = getAgentConfigs(config);
31160
31974
  modelArrayMap = {};
@@ -31191,7 +32005,7 @@ var OhMyOpenCodeLite = async (ctx) => {
31191
32005
  main_pane_size: config.multiplexer?.main_pane_size ?? 60
31192
32006
  };
31193
32007
  const multiplexer = getMultiplexer(multiplexerConfig);
31194
- multiplexerEnabled = multiplexerConfig.type !== "none" && multiplexer !== null;
32008
+ multiplexerEnabled = multiplexerConfig.type !== "none" && multiplexer !== null && multiplexer.isInsideSession();
31195
32009
  log("[plugin] initialized with multiplexer config", {
31196
32010
  multiplexerConfig,
31197
32011
  enabled: multiplexerEnabled,
@@ -31207,7 +32021,7 @@ var OhMyOpenCodeLite = async (ctx) => {
31207
32021
  multiplexerSessionManager = new MultiplexerSessionManager(ctx, multiplexerConfig);
31208
32022
  autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
31209
32023
  showStartupToast: config.showStartupToast ?? true,
31210
- autoUpdate: true
32024
+ autoUpdate: config.autoUpdate ?? true
31211
32025
  });
31212
32026
  phaseReminderHook = createPhaseReminderHook();
31213
32027
  filterAvailableSkillsHook = createFilterAvailableSkillsHook(ctx, config);
@@ -31226,6 +32040,12 @@ var OhMyOpenCodeLite = async (ctx) => {
31226
32040
  autoEnable: config.todoContinuation?.autoEnable ?? false,
31227
32041
  autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4
31228
32042
  });
32043
+ taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
32044
+ maxSessionsPerAgent: config.sessionManager?.maxSessionsPerAgent ?? 2,
32045
+ readContextMinLines: config.sessionManager?.readContextMinLines ?? 10,
32046
+ readContextMaxFiles: config.sessionManager?.readContextMaxFiles ?? 8,
32047
+ shouldManageSession: (sessionID) => sessionAgentMap.get(sessionID) === "orchestrator"
32048
+ });
31229
32049
  interviewManager = createInterviewManager(ctx, config);
31230
32050
  presetManager = createPresetManager(ctx, config);
31231
32051
  toolCount = Object.keys(councilTools).length + Object.keys(todoContinuationHook.tool).length + 1 + 2;
@@ -31401,6 +32221,7 @@ var OhMyOpenCodeLite = async (ctx) => {
31401
32221
  await multiplexerSessionManager.onSessionDeleted(event);
31402
32222
  await interviewManager.handleEvent(input);
31403
32223
  await postFileToolNudgeHook.event(input);
32224
+ await taskSessionManagerHook.event(input);
31404
32225
  if (input.event.type === "session.deleted") {
31405
32226
  const props = input.event.properties;
31406
32227
  const sessionID = props?.info?.id ?? props?.sessionID;
@@ -31414,6 +32235,7 @@ var OhMyOpenCodeLite = async (ctx) => {
31414
32235
  },
31415
32236
  "tool.execute.before": async (input, output) => {
31416
32237
  await applyPatchHook["tool.execute.before"](input, output);
32238
+ await taskSessionManagerHook["tool.execute.before"](input, output);
31417
32239
  },
31418
32240
  "command.execute.before": async (input, output) => {
31419
32241
  await todoContinuationHook.handleCommandExecuteBefore(input, output);
@@ -31441,7 +32263,7 @@ var OhMyOpenCodeLite = async (ctx) => {
31441
32263
  const alreadyInjected = output.system.some((s) => typeof s === "string" && s.includes("<Role>") && s.includes("orchestrator"));
31442
32264
  if (!alreadyInjected) {
31443
32265
  const orchestratorDef = agentDefs.find((a) => a.name === "orchestrator");
31444
- const orchestratorPrompt = typeof orchestratorDef?.config?.prompt === "string" ? orchestratorDef.config.prompt : buildOrchestratorPrompt(getDisabledAgents(config));
32266
+ const orchestratorPrompt = typeof orchestratorDef?.config?.prompt === "string" ? orchestratorDef.config.prompt : buildOrchestratorPrompt(disabledAgents);
31445
32267
  output.system[0] = orchestratorPrompt + (output.system[0] ? `
31446
32268
 
31447
32269
  ${output.system[0]}` : "");
@@ -31449,10 +32271,8 @@ ${output.system[0]}` : "");
31449
32271
  }
31450
32272
  await todoContinuationHook.handleChatSystemTransform(input, output);
31451
32273
  await postFileToolNudgeHook["experimental.chat.system.transform"](input, output);
31452
- const joined = output.system.join(`
31453
-
31454
- `);
31455
- output.system = joined ? [joined] : [];
32274
+ await taskSessionManagerHook["experimental.chat.system.transform"](input, output);
32275
+ collapseSystemInPlace(output.system);
31456
32276
  },
31457
32277
  "experimental.chat.messages.transform": async (input, output) => {
31458
32278
  const typedOutput = output;
@@ -31464,13 +32284,13 @@ ${output.system[0]}` : "");
31464
32284
  if (part.type !== "text" || typeof part.text !== "string") {
31465
32285
  continue;
31466
32286
  }
31467
- part.text = rewriteDisplayNameMentions(config, part.text);
32287
+ part.text = rewriteDisplayNameMentions(part.text);
31468
32288
  }
31469
32289
  }
31470
32290
  processImageAttachments({
31471
32291
  messages: typedOutput.messages,
31472
32292
  workDir: ctx.directory,
31473
- disabledAgents: getDisabledAgents(config),
32293
+ disabledAgents,
31474
32294
  log
31475
32295
  });
31476
32296
  await todoContinuationHook.handleMessagesTransform({
@@ -31484,6 +32304,7 @@ ${output.system[0]}` : "");
31484
32304
  await jsonErrorRecoveryHook["tool.execute.after"](input, output);
31485
32305
  await todoContinuationHook.handleToolExecuteAfter(input);
31486
32306
  await postFileToolNudgeHook["tool.execute.after"](input, output);
32307
+ await taskSessionManagerHook["tool.execute.after"](input, output);
31487
32308
  }
31488
32309
  };
31489
32310
  };