oh-my-opencode-slim 2.0.3 → 2.0.5

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
@@ -18420,7 +18420,8 @@ var PERMISSION_ONLY_SKILLS = [
18420
18420
  description: "Code review template for reviewer subagents in multi-step workflows"
18421
18421
  }
18422
18422
  ];
18423
- function getSkillPermissionsForAgent(agentName, skillList) {
18423
+ function getSkillPermissionsForAgent(agentName, skillList, disabledSkillNames) {
18424
+ const disabledSkills = new Set(disabledSkillNames ?? []);
18424
18425
  const permissions = {
18425
18426
  "*": agentName === "orchestrator" ? "allow" : "deny"
18426
18427
  };
@@ -18431,24 +18432,30 @@ function getSkillPermissionsForAgent(agentName, skillList) {
18431
18432
  permissions["*"] = "allow";
18432
18433
  } else if (name.startsWith("!")) {
18433
18434
  permissions[name.slice(1)] = "deny";
18434
- } else {
18435
+ } else if (!disabledSkills.has(name)) {
18435
18436
  permissions[name] = "allow";
18436
18437
  }
18437
18438
  }
18439
+ for (const name of disabledSkills) {
18440
+ permissions[name] = "deny";
18441
+ }
18438
18442
  return permissions;
18439
18443
  }
18440
18444
  for (const skill of CUSTOM_SKILLS) {
18441
18445
  const isAllowed = skill.allowedAgents.includes("*") || skill.allowedAgents.includes(agentName);
18442
- if (isAllowed) {
18446
+ if (isAllowed && !disabledSkills.has(skill.name)) {
18443
18447
  permissions[skill.name] = "allow";
18444
18448
  }
18445
18449
  }
18446
18450
  for (const skill of PERMISSION_ONLY_SKILLS) {
18447
18451
  const isAllowed = skill.allowedAgents.includes("*") || skill.allowedAgents.includes(agentName);
18448
- if (isAllowed) {
18452
+ if (isAllowed && !disabledSkills.has(skill.name)) {
18449
18453
  permissions[skill.name] = "allow";
18450
18454
  }
18451
18455
  }
18456
+ for (const name of disabledSkills) {
18457
+ permissions[name] = "deny";
18458
+ }
18452
18459
  return permissions;
18453
18460
  }
18454
18461
 
@@ -18471,14 +18478,14 @@ var ALL_AGENT_NAMES = ["orchestrator", ...SUBAGENT_NAMES];
18471
18478
  var PROTECTED_AGENTS = new Set(["orchestrator", "councillor"]);
18472
18479
  var DEFAULT_MODELS = {
18473
18480
  orchestrator: undefined,
18474
- oracle: "openai/gpt-5.5",
18475
- librarian: "openai/gpt-5.4-mini",
18476
- explorer: "openai/gpt-5.4-mini",
18477
- designer: "openai/gpt-5.4-mini",
18478
- fixer: "openai/gpt-5.4-mini",
18479
- observer: "openai/gpt-5.4-mini",
18480
- council: "openai/gpt-5.4-mini",
18481
- councillor: "openai/gpt-5.4-mini"
18481
+ oracle: undefined,
18482
+ librarian: undefined,
18483
+ explorer: undefined,
18484
+ designer: undefined,
18485
+ fixer: undefined,
18486
+ observer: undefined,
18487
+ council: undefined,
18488
+ councillor: undefined
18482
18489
  };
18483
18490
  var POLL_INTERVAL_BACKGROUND_MS = 2000;
18484
18491
  var MAX_POLL_TIME_MS = 5 * 60 * 1000;
@@ -18719,6 +18726,7 @@ var CompanionConfigSchema = z2.object({
18719
18726
  debug: z2.boolean().optional().describe("Enable verbose native companion debug logs.")
18720
18727
  });
18721
18728
  var AcpAgentPermissionModeSchema = z2.enum(["ask", "allow", "reject"]);
18729
+ var MAX_ACP_TIMEOUT_MS = 2147483647;
18722
18730
  var AcpAgentConfigSchema = z2.object({
18723
18731
  command: z2.string().min(1),
18724
18732
  args: z2.array(z2.string()).default([]),
@@ -18728,7 +18736,7 @@ var AcpAgentConfigSchema = z2.object({
18728
18736
  prompt: z2.string().min(1).optional(),
18729
18737
  orchestratorPrompt: z2.string().min(1).optional(),
18730
18738
  wrapperModel: ProviderModelIdSchema.optional(),
18731
- timeoutMs: z2.number().int().min(1000).max(900000).default(300000),
18739
+ timeoutMs: z2.number().int().min(0).max(MAX_ACP_TIMEOUT_MS).default(0).describe("Timeout for a single ACP run in milliseconds. Set to 0 to disable the timeout."),
18732
18740
  permissionMode: AcpAgentPermissionModeSchema.default("ask")
18733
18741
  }).strict();
18734
18742
  var AcpAgentsConfigSchema = z2.record(z2.string(), AcpAgentConfigSchema);
@@ -18762,6 +18770,8 @@ var PluginConfigSchema = z2.object({
18762
18770
  agents: z2.record(z2.string(), AgentOverrideConfigSchema).optional(),
18763
18771
  disabled_agents: z2.array(z2.string()).optional().describe("Agent names to disable completely. " + "Disabled agents are not instantiated and cannot be delegated to. " + "Orchestrator and council internal agents (councillor) cannot be disabled. " + "By default, 'observer' is disabled. Remove it from this list and configure a vision-capable model to enable."),
18764
18772
  disabled_mcps: z2.array(z2.string()).optional(),
18773
+ disabled_tools: z2.array(z2.string()).optional().describe("Tool names to disable completely. Disabled tools are not registered with OpenCode and cannot be used by agents."),
18774
+ disabled_skills: z2.array(z2.string()).optional().describe("Skill names to disable completely. Disabled skills are not granted to agents, even when referenced by presets or agent overrides."),
18765
18775
  multiplexer: MultiplexerConfigSchema.optional(),
18766
18776
  tmux: TmuxConfigSchema.optional(),
18767
18777
  websearch: WebsearchConfigSchema.optional(),
@@ -19251,6 +19261,11 @@ Build a short work graph before dispatching:
19251
19261
  - Advisory ownership for write-capable lanes
19252
19262
  - Verification/review lanes that run after implementation
19253
19263
 
19264
+ ### Todo Continuity
19265
+ - When the user adds a new task while a todo list exists, append the new task to the end of the existing todo list instead of replacing the list.
19266
+ - Preserve existing todo order, statuses, and priorities unless the user explicitly asks to reprioritize, cancel, or replace them.
19267
+ - Finish the current in-progress task before starting the newly appended task unless the current task is blocked or the user explicitly overrides the order.
19268
+
19254
19269
  Can tasks be split into background specialist work?
19255
19270
  ${enabledParallelExamples}
19256
19271
 
@@ -19258,6 +19273,7 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
19258
19273
 
19259
19274
  ### Background Task Discipline
19260
19275
  - Prefer \`task(..., background: true)\` for delegated work that can run independently.
19276
+ - Launch specialist agents in the background by default so the orchestrator stays unblocked and can reconcile results when they return.
19261
19277
  - Track each task's specialist, objective, task/session ID, and file/topic ownership.
19262
19278
  - Continue orchestration only on non-overlapping work; otherwise briefly report what was launched and stop.
19263
19279
  - Before local edits or another writer task, compare against running task scopes.
@@ -19346,10 +19362,31 @@ function createOrchestratorAgent(model, customPrompt, customAppendPrompt, disabl
19346
19362
  return definition;
19347
19363
  }
19348
19364
 
19365
+ // src/agents/permissions.ts
19366
+ function createReadOnlyAgentPermission() {
19367
+ return {
19368
+ "*": "deny",
19369
+ bash: "deny",
19370
+ edit: "deny",
19371
+ write: "deny",
19372
+ apply_patch: "deny",
19373
+ ast_grep_replace: "deny",
19374
+ task: "deny",
19375
+ question: "deny",
19376
+ read: "allow",
19377
+ glob: "allow",
19378
+ grep: "allow",
19379
+ lsp: "allow",
19380
+ list: "allow",
19381
+ codesearch: "allow",
19382
+ ast_grep_search: "allow"
19383
+ };
19384
+ }
19385
+
19349
19386
  // src/agents/council.ts
19350
19387
  var COUNCIL_AGENT_PROMPT = `You are the Council agent — a multi-LLM orchestration system that runs consensus across multiple models.
19351
19388
 
19352
- **Tool**: You have access to the \`council_session\` tool.
19389
+ **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.
19353
19390
 
19354
19391
  **When to use**:
19355
19392
  - When invoked by a user with a request
@@ -19410,7 +19447,11 @@ function createCouncilAgent(model, customPrompt, customAppendPrompt) {
19410
19447
  description: "Multi-LLM council agent that synthesizes responses from multiple models for higher-quality outputs",
19411
19448
  config: {
19412
19449
  temperature: 0.1,
19413
- prompt
19450
+ prompt,
19451
+ permission: {
19452
+ ...createReadOnlyAgentPermission(),
19453
+ council_session: "allow"
19454
+ }
19414
19455
  }
19415
19456
  };
19416
19457
  if (model) {
@@ -19519,17 +19560,7 @@ function createCouncillorAgent(model, customPrompt, customAppendPrompt) {
19519
19560
  model,
19520
19561
  temperature: 0.2,
19521
19562
  prompt,
19522
- permission: {
19523
- "*": "deny",
19524
- question: "deny",
19525
- read: "allow",
19526
- glob: "allow",
19527
- grep: "allow",
19528
- lsp: "allow",
19529
- list: "allow",
19530
- codesearch: "allow",
19531
- ast_grep_search: "allow"
19532
- }
19563
+ permission: createReadOnlyAgentPermission()
19533
19564
  }
19534
19565
  };
19535
19566
  }
@@ -19869,7 +19900,38 @@ function normalizeDisplayName(displayName) {
19869
19900
  const trimmed = displayName.trim();
19870
19901
  return trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
19871
19902
  }
19872
- function buildAcpAgentDefinition(name, config) {
19903
+ function getPrimaryModelFromOverride(override) {
19904
+ const model = override?.model;
19905
+ if (typeof model === "string") {
19906
+ return model;
19907
+ }
19908
+ if (Array.isArray(model) && model.length > 0) {
19909
+ const first = model[0];
19910
+ return typeof first === "string" ? first : first?.id;
19911
+ }
19912
+ return;
19913
+ }
19914
+ function getActivePresetPrimaryModel(config) {
19915
+ const activePreset = config?.preset ? config.presets?.[config.preset] : undefined;
19916
+ if (!activePreset) {
19917
+ return;
19918
+ }
19919
+ const orchestratorModel = getPrimaryModelFromOverride(activePreset.orchestrator);
19920
+ if (orchestratorModel) {
19921
+ return orchestratorModel;
19922
+ }
19923
+ for (const name of SUBAGENT_NAMES) {
19924
+ const model = getPrimaryModelFromOverride(activePreset[name]);
19925
+ if (model) {
19926
+ return model;
19927
+ }
19928
+ }
19929
+ return;
19930
+ }
19931
+ function getConfigPrimaryModel(config) {
19932
+ return getActivePresetPrimaryModel(config);
19933
+ }
19934
+ function buildAcpAgentDefinition(name, config, fallbackModel) {
19873
19935
  const description = config.description ?? `External ACP agent '${name}' via ${config.command}`;
19874
19936
  const prompt = config.prompt ?? [
19875
19937
  `You are the ${name} ACP wrapper agent.`,
@@ -19883,7 +19945,7 @@ function buildAcpAgentDefinition(name, config) {
19883
19945
  name,
19884
19946
  description,
19885
19947
  config: {
19886
- model: config.wrapperModel ?? DEFAULT_MODELS.fixer ?? DEFAULT_MODELS.librarian ?? DEFAULT_MODELS.orchestrator ?? DEFAULT_MODELS.oracle,
19948
+ model: config.wrapperModel ?? fallbackModel ?? DEFAULT_MODELS.oracle,
19887
19949
  temperature: 0,
19888
19950
  prompt,
19889
19951
  permission: {
@@ -19948,10 +20010,11 @@ function hasCustomAgentModel(override) {
19948
20010
  }
19949
20011
  function buildCustomAgentDefinition(name, override, filePrompt, fileAppendPrompt) {
19950
20012
  const basePrompt = override.prompt ?? `You are the ${name} specialist.`;
20013
+ const primaryModel = getPrimaryModelFromOverride(override);
19951
20014
  return {
19952
20015
  name,
19953
20016
  config: {
19954
- model: typeof override.model === "string" ? override.model : DEFAULT_MODELS.orchestrator ?? DEFAULT_MODELS.oracle,
20017
+ model: primaryModel ?? DEFAULT_MODELS.oracle,
19955
20018
  temperature: 0.2,
19956
20019
  prompt: resolvePrompt(basePrompt, filePrompt, fileAppendPrompt)
19957
20020
  }
@@ -19968,9 +20031,9 @@ function injectDisplayNames(orchestrator, nameMap) {
19968
20031
  }
19969
20032
  orchestrator.config.prompt = prompt;
19970
20033
  }
19971
- function applyDefaultPermissions(agent, configuredSkills) {
20034
+ function applyDefaultPermissions(agent, configuredSkills, disabledSkills) {
19972
20035
  const existing = agent.config.permission ?? {};
19973
- const skillPermissions = getSkillPermissionsForAgent(agent.name, configuredSkills);
20036
+ const skillPermissions = getSkillPermissionsForAgent(agent.name, configuredSkills, disabledSkills);
19974
20037
  const questionPerm = existing.question === "deny" ? "deny" : "allow";
19975
20038
  const councilSessionPerm = COUNCIL_TOOL_ALLOWED_AGENTS.has(agent.name) ? existing.council_session ?? "allow" : "deny";
19976
20039
  const cancelTaskPerm = CANCEL_TASK_ALLOWED_AGENTS.has(agent.name) ? existing.cancel_task ?? "allow" : "deny";
@@ -20003,6 +20066,7 @@ function createAgents(config) {
20003
20066
  if (!config?.council) {
20004
20067
  disabled.add("council");
20005
20068
  }
20069
+ const primaryModel = getConfigPrimaryModel(config);
20006
20070
  const getModelForAgent = (name) => {
20007
20071
  if (name === "fixer" && !getAgentOverride(config, "fixer")?.model) {
20008
20072
  const librarianOverride = getAgentOverride(config, "librarian")?.model;
@@ -20013,9 +20077,9 @@ function createAgents(config) {
20013
20077
  } else {
20014
20078
  librarianModel = librarianOverride;
20015
20079
  }
20016
- return librarianModel ?? DEFAULT_MODELS.librarian;
20080
+ return librarianModel ?? primaryModel ?? DEFAULT_MODELS.librarian;
20017
20081
  }
20018
- return DEFAULT_MODELS[name];
20082
+ return primaryModel ?? DEFAULT_MODELS[name];
20019
20083
  };
20020
20084
  const protoSubAgents = Object.entries(SUBAGENT_FACTORIES).filter(([name]) => !disabled.has(name)).map(([name, factory]) => {
20021
20085
  const customPrompts = loadAgentPrompt(name, config?.preset);
@@ -20057,14 +20121,14 @@ function createAgents(config) {
20057
20121
  const acp = config?.acpAgents?.[name];
20058
20122
  if (!acp)
20059
20123
  throw new Error(`ACP agent '${name}' is missing config`);
20060
- return buildAcpAgentDefinition(name, acp);
20124
+ return buildAcpAgentDefinition(name, acp, primaryModel);
20061
20125
  });
20062
20126
  const builtInSubAgents = protoSubAgents.map((agent) => {
20063
20127
  const override = getAgentOverride(config, agent.name);
20064
20128
  if (override) {
20065
20129
  applyOverrides(agent, override);
20066
20130
  }
20067
- applyDefaultPermissions(agent, override?.skills);
20131
+ applyDefaultPermissions(agent, override?.skills, config?.disabled_skills);
20068
20132
  return agent;
20069
20133
  });
20070
20134
  const legacyMasterModel = config?.council?._legacyMasterModel;
@@ -20079,11 +20143,11 @@ function createAgents(config) {
20079
20143
  if (override) {
20080
20144
  applyOverrides(agent, override);
20081
20145
  }
20082
- applyDefaultPermissions(agent, override?.skills);
20146
+ applyDefaultPermissions(agent, override?.skills, config?.disabled_skills);
20083
20147
  return agent;
20084
20148
  });
20085
20149
  const acpSubAgents = protoAcpAgents.map((agent) => {
20086
- applyDefaultPermissions(agent);
20150
+ applyDefaultPermissions(agent, undefined, config?.disabled_skills);
20087
20151
  return agent;
20088
20152
  });
20089
20153
  const allSubAgents = [
@@ -20095,7 +20159,7 @@ function createAgents(config) {
20095
20159
  const orchestratorModel = orchestratorOverride?.model ?? DEFAULT_MODELS.orchestrator;
20096
20160
  const orchestratorPrompts = loadAgentPrompt("orchestrator", config?.preset);
20097
20161
  const orchestrator = createOrchestratorAgent(orchestratorModel, orchestratorPrompts.prompt, orchestratorPrompts.appendPrompt, disabled);
20098
- applyDefaultPermissions(orchestrator, orchestratorOverride?.skills);
20162
+ applyDefaultPermissions(orchestrator, orchestratorOverride?.skills, config?.disabled_skills);
20099
20163
  if (orchestratorOverride) {
20100
20164
  applyOverrides(orchestrator, orchestratorOverride);
20101
20165
  }
@@ -20374,6 +20438,7 @@ class CompanionManager {
20374
20438
  status = "idle";
20375
20439
  busyAgentSessions = new Map;
20376
20440
  config;
20441
+ companionProcess = null;
20377
20442
  constructor(sessionId, cwd, config) {
20378
20443
  this.id = sessionId;
20379
20444
  this.cwd = cwd;
@@ -20438,6 +20503,12 @@ class CompanionManager {
20438
20503
  onExit() {
20439
20504
  if (this.config?.enabled !== true)
20440
20505
  return;
20506
+ if (this.companionProcess) {
20507
+ try {
20508
+ this.companionProcess.kill();
20509
+ } catch {}
20510
+ this.companionProcess = null;
20511
+ }
20441
20512
  writeState((state) => {
20442
20513
  state.sessions = state.sessions.filter((s) => s.session_id !== this.id);
20443
20514
  });
@@ -20514,6 +20585,7 @@ class CompanionManager {
20514
20585
  },
20515
20586
  stdio: "ignore"
20516
20587
  });
20588
+ this.companionProcess = child;
20517
20589
  child.unref();
20518
20590
  log("[companion] spawned", JSON.stringify({
20519
20591
  bin,
@@ -21010,7 +21082,15 @@ class CouncilManager {
21010
21082
  const body = {
21011
21083
  agent: options.agent,
21012
21084
  model: modelRef,
21013
- tools: { task: false },
21085
+ tools: {
21086
+ task: false,
21087
+ question: false,
21088
+ edit: false,
21089
+ write: false,
21090
+ apply_patch: false,
21091
+ ast_grep_replace: false,
21092
+ bash: false
21093
+ },
21014
21094
  parts: [{ type: "text", text: options.promptText }]
21015
21095
  };
21016
21096
  if (options.variant) {
@@ -23537,6 +23617,7 @@ var AGENT_PREFIX = {
23537
23617
  class BackgroundJobBoard {
23538
23618
  jobs = new Map;
23539
23619
  counters = new Map;
23620
+ terminalStateListener;
23540
23621
  maxReusablePerAgent;
23541
23622
  readContextMinLines;
23542
23623
  readContextMaxFiles;
@@ -23545,6 +23626,9 @@ class BackgroundJobBoard {
23545
23626
  this.readContextMinLines = options.readContextMinLines ?? 10;
23546
23627
  this.readContextMaxFiles = options.readContextMaxFiles ?? 8;
23547
23628
  }
23629
+ setTerminalStateListener(listener) {
23630
+ this.terminalStateListener = listener;
23631
+ }
23548
23632
  registerLaunch(input) {
23549
23633
  const now = input.now ?? Date.now();
23550
23634
  const existing = this.jobs.get(input.taskID);
@@ -23602,6 +23686,7 @@ class BackgroundJobBoard {
23602
23686
  }
23603
23687
  const now = input.now ?? Date.now();
23604
23688
  const terminal = TERMINAL_STATES.has(input.state);
23689
+ const notifyTerminal = terminal && !TERMINAL_STATES.has(existing.state);
23605
23690
  const updated = {
23606
23691
  ...existing,
23607
23692
  state: input.state,
@@ -23616,6 +23701,8 @@ class BackgroundJobBoard {
23616
23701
  };
23617
23702
  this.jobs.set(input.taskID, updated);
23618
23703
  this.trimReusable(input.taskID);
23704
+ if (notifyTerminal)
23705
+ this.terminalStateListener?.(input.taskID);
23619
23706
  return updated;
23620
23707
  }
23621
23708
  updateFromStatusOutput(output) {
@@ -23680,6 +23767,7 @@ class BackgroundJobBoard {
23680
23767
  if (TERMINAL_STATES.has(existing.state))
23681
23768
  return existing;
23682
23769
  }
23770
+ const notifyTerminal = !TERMINAL_STATES.has(existing.state) && existing.state !== "reconciled";
23683
23771
  const summary = normalizeCancelReason(reason);
23684
23772
  const updated = {
23685
23773
  ...existing,
@@ -23695,6 +23783,8 @@ class BackgroundJobBoard {
23695
23783
  lastStatusError: undefined
23696
23784
  };
23697
23785
  this.jobs.set(taskID, updated);
23786
+ if (notifyTerminal)
23787
+ this.terminalStateListener?.(taskID);
23698
23788
  return updated;
23699
23789
  }
23700
23790
  get(taskID) {
@@ -24147,7 +24237,7 @@ function createFilterAvailableSkillsHook(_ctx, config) {
24147
24237
  return cached;
24148
24238
  }
24149
24239
  const configuredSkills = getAgentOverride(config, agentName)?.skills;
24150
- const permissionRules = getSkillPermissionsForAgent(agentName, configuredSkills);
24240
+ const permissionRules = getSkillPermissionsForAgent(agentName, configuredSkills, config.disabled_skills);
24151
24241
  permissionRulesByAgent.set(agentName, permissionRules);
24152
24242
  return permissionRules;
24153
24243
  };
@@ -24747,17 +24837,6 @@ function createReflectCommandHook() {
24747
24837
  }
24748
24838
  // src/hooks/task-session-manager/index.ts
24749
24839
  import path12 from "node:path";
24750
- var AGENT_NAME_SET = new Set([
24751
- "orchestrator",
24752
- "oracle",
24753
- "designer",
24754
- "explorer",
24755
- "librarian",
24756
- "fixer",
24757
- "observer",
24758
- "council",
24759
- "councillor"
24760
- ]);
24761
24840
  var MAX_PENDING_TASK_CALLS = 100;
24762
24841
  var BACKGROUND_JOB_BOARD_SENTINEL = "SENTINEL: background-job-board-v2";
24763
24842
  var BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
@@ -24789,9 +24868,6 @@ function createOccurrenceId(part, message, partIndex) {
24789
24868
  const hash = djb2Hash(`${sessionID}:${content}`);
24790
24869
  return `anon:${hash}`;
24791
24870
  }
24792
- function isAgentName(value) {
24793
- return typeof value === "string" && AGENT_NAME_SET.has(value);
24794
- }
24795
24871
  function extractPath(output) {
24796
24872
  return /<path>([^<]+)<\/path>/.exec(output)?.[1];
24797
24873
  }
@@ -25072,16 +25148,17 @@ function createTaskSessionManagerHook(_ctx, options) {
25072
25148
  if (!isRecord(output.args))
25073
25149
  return;
25074
25150
  const args = output.args;
25075
- if (!isAgentName(args.subagent_type)) {
25151
+ if (typeof args.subagent_type !== "string" || args.subagent_type.trim() === "") {
25076
25152
  if (typeof args.task_id === "string" && args.task_id.trim() !== "") {
25077
25153
  delete args.task_id;
25078
25154
  }
25079
25155
  return;
25080
25156
  }
25157
+ const agentType = args.subagent_type.trim();
25081
25158
  const label = deriveTaskSessionLabel({
25082
25159
  description: typeof args.description === "string" ? args.description : undefined,
25083
25160
  prompt: typeof args.prompt === "string" ? args.prompt : undefined,
25084
- agentType: args.subagent_type
25161
+ agentType
25085
25162
  });
25086
25163
  const pendingCall = {
25087
25164
  callId: pendingCallId({
@@ -25089,7 +25166,7 @@ function createTaskSessionManagerHook(_ctx, options) {
25089
25166
  sessionID: input.sessionID
25090
25167
  }),
25091
25168
  parentSessionId: input.sessionID,
25092
- agentType: args.subagent_type,
25169
+ agentType,
25093
25170
  label
25094
25171
  };
25095
25172
  rememberPendingCall(pendingCall);
@@ -25097,7 +25174,7 @@ function createTaskSessionManagerHook(_ctx, options) {
25097
25174
  return;
25098
25175
  }
25099
25176
  const requested = args.task_id.trim();
25100
- const remembered = backgroundJobBoard.resolveReusable(input.sessionID, requested, args.subagent_type);
25177
+ const remembered = backgroundJobBoard.resolveReusable(input.sessionID, requested, agentType);
25101
25178
  if (!remembered) {
25102
25179
  if (RAW_SESSION_ID_PATTERN.test(requested)) {
25103
25180
  pendingCall.resumedTaskId = requested;
@@ -25434,26 +25511,25 @@ function slugify(value) {
25434
25511
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
25435
25512
  }
25436
25513
  function extractHistorySection(document) {
25437
- const marker = `## Q&A history
25438
-
25439
- `;
25440
- const index = document.indexOf(marker);
25441
- return index >= 0 ? document.slice(index + marker.length).trim() : "";
25514
+ const marker = /## Q&A history/i;
25515
+ const match = document.match(marker);
25516
+ if (!match || match.index === undefined)
25517
+ return "";
25518
+ return document.slice(match.index + match[0].length).trim();
25442
25519
  }
25443
25520
  function extractSummarySection(document) {
25444
25521
  const marker = `## Current spec
25445
25522
 
25446
25523
  `;
25447
- const historyMarker = `
25448
-
25449
- ## Q&A history`;
25450
25524
  const start = document.indexOf(marker);
25451
25525
  if (start < 0) {
25452
25526
  return "";
25453
25527
  }
25454
25528
  const summaryStart = start + marker.length;
25455
- const summaryEnd = document.indexOf(historyMarker, summaryStart);
25456
- return document.slice(summaryStart, summaryEnd >= 0 ? summaryEnd : undefined).trim();
25529
+ const historyMarker = /\n\n## Q&A history/i;
25530
+ const historyMatch = document.slice(summaryStart).match(historyMarker);
25531
+ const summaryEnd = historyMatch?.index !== undefined ? summaryStart + historyMatch.index : undefined;
25532
+ return document.slice(summaryStart, summaryEnd).trim();
25457
25533
  }
25458
25534
  function extractTitle(document) {
25459
25535
  const match = document.match(/^#\s+(.+)$/m);
@@ -25462,11 +25538,19 @@ function extractTitle(document) {
25462
25538
  function buildInterviewDocument(idea, summary, history, meta) {
25463
25539
  const normalizedSummary = summary.trim() || "Waiting for interview answers.";
25464
25540
  const normalizedHistory = history.trim() || "No answers yet.";
25541
+ const now = new Date;
25542
+ const dateStr = now.toISOString().split("T")[0];
25543
+ const owner = meta?.owner ?? "agent";
25544
+ const tags = meta?.tags ?? ["spec", "diagnostic"];
25465
25545
  const frontmatter = meta?.sessionID ? [
25466
25546
  "---",
25467
25547
  `sessionID: ${meta.sessionID}`,
25468
25548
  `baseMessageCount: ${meta.baseMessageCount ?? 0}`,
25469
- `updatedAt: ${new Date().toISOString()}`,
25549
+ `updatedAt: ${now.toISOString()}`,
25550
+ `version: 1.0`,
25551
+ `date_created: ${dateStr}`,
25552
+ `owner: ${owner}`,
25553
+ `tags: [${tags.join(", ")}]`,
25470
25554
  "---",
25471
25555
  ""
25472
25556
  ].join(`
@@ -25548,6 +25632,55 @@ A: ${answer.answer.trim()}` : null;
25548
25632
  baseMessageCount: record.baseMessageCount
25549
25633
  }), "utf8");
25550
25634
  }
25635
+ function parseSpecBlocks(markdown) {
25636
+ const blocks = [];
25637
+ const lines = markdown.split(`
25638
+ `);
25639
+ let currentBlockId = null;
25640
+ let currentBlockTitle = null;
25641
+ let currentBlockLines = [];
25642
+ const flush = () => {
25643
+ if (currentBlockId) {
25644
+ blocks.push({
25645
+ id: currentBlockId,
25646
+ title: currentBlockTitle || currentBlockId,
25647
+ content: currentBlockLines.join(`
25648
+ `).trim()
25649
+ });
25650
+ }
25651
+ };
25652
+ for (const line of lines) {
25653
+ if (/^##\s+Q&A history\s*$/i.test(line)) {
25654
+ break;
25655
+ }
25656
+ const headerMatch = line.match(/^##\s+(\d+)\.\s+(.+)$/);
25657
+ if (headerMatch) {
25658
+ flush();
25659
+ const num = headerMatch[1];
25660
+ const name = headerMatch[2].trim();
25661
+ currentBlockId = `section-${num}`;
25662
+ currentBlockTitle = `${num}. ${name}`;
25663
+ currentBlockLines = [];
25664
+ } else if (line.startsWith("# ") && !line.startsWith("## ")) {
25665
+ if (currentBlockId === null) {
25666
+ currentBlockId = "section-0";
25667
+ currentBlockTitle = "Introduction";
25668
+ currentBlockLines = [];
25669
+ }
25670
+ } else if (line.startsWith("## ") && !headerMatch) {
25671
+ flush();
25672
+ const name = line.replace(/^##\s+/, "").trim();
25673
+ currentBlockId = `section-${slugify(name)}`;
25674
+ currentBlockTitle = name;
25675
+ currentBlockLines = [];
25676
+ }
25677
+ if (currentBlockId !== null) {
25678
+ currentBlockLines.push(line);
25679
+ }
25680
+ }
25681
+ flush();
25682
+ return blocks;
25683
+ }
25551
25684
 
25552
25685
  // src/interview/helpers.ts
25553
25686
  function sendJson(response, status, value) {
@@ -26037,6 +26170,65 @@ function renderInterviewPage(interviewId, resumeSlug) {
26037
26170
  <style>
26038
26171
  ${sharedStyles()}
26039
26172
  .brand-mark { width: 144px; height: 144px; }
26173
+ .spec-block-card {
26174
+ background: rgba(255,255,255,0.01);
26175
+ border: 1px solid rgba(255,255,255,0.05);
26176
+ border-radius: 8px;
26177
+ padding: 24px;
26178
+ margin-bottom: 20px;
26179
+ text-align: left;
26180
+ transition: all 0.2s ease;
26181
+ }
26182
+ .spec-block-card:hover {
26183
+ border-color: rgba(255,255,255,0.15);
26184
+ background: rgba(255,255,255,0.02);
26185
+ }
26186
+ .spec-block-card h3 {
26187
+ margin-top: 0;
26188
+ font-size: 18px;
26189
+ color: #ffffff;
26190
+ border-bottom: 1px solid rgba(255,255,255,0.08);
26191
+ padding-bottom: 8px;
26192
+ margin-bottom: 14px;
26193
+ }
26194
+ .spec-block-content {
26195
+ font-size: 15px;
26196
+ line-height: 1.6;
26197
+ color: rgba(255,255,255,0.8);
26198
+ }
26199
+ .spec-block-comment-box {
26200
+ margin-top: 16px;
26201
+ padding-top: 16px;
26202
+ border-top: 1px dashed rgba(255,255,255,0.08);
26203
+ display: flex;
26204
+ flex-direction: column;
26205
+ gap: 10px;
26206
+ }
26207
+ .spec-block-comment-input {
26208
+ min-height: 60px !important;
26209
+ font-size: 14px !important;
26210
+ padding: 10px !important;
26211
+ }
26212
+ .comment-submit-btn {
26213
+ align-self: flex-end;
26214
+ background: transparent;
26215
+ border: 1px solid rgba(255,255,255,0.2);
26216
+ color: rgba(255,255,255,0.8);
26217
+ padding: 8px 16px;
26218
+ border-radius: 6px;
26219
+ font-size: 13px;
26220
+ cursor: pointer;
26221
+ transition: all 0.2s ease;
26222
+ }
26223
+ .comment-submit-btn:hover:not(:disabled) {
26224
+ border-color: #34d399;
26225
+ color: #34d399;
26226
+ background: rgba(52,211,153,0.05);
26227
+ }
26228
+ .comment-submit-btn:disabled {
26229
+ opacity: 0.3;
26230
+ cursor: not-allowed;
26231
+ }
26040
26232
  h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 12px; line-height: 1.2; }
26041
26233
  h2 { font-size: 18px; font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase; color: rgba(255,255,255,0.4); margin-bottom: 24px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 12px; }
26042
26234
  h3 { font-size: 18px; font-weight: 500; margin-bottom: 16px; line-height: 1.4; }
@@ -26402,9 +26594,102 @@ function renderInterviewPage(interviewId, resumeSlug) {
26402
26594
  background: rgba(52,211,153,0.05);
26403
26595
  }
26404
26596
  .nudge-btn:disabled { opacity: 0.3; cursor: not-allowed; }
26597
+
26598
+ /* ── TOC Sidebar ──────────────────────────────────────────────── */
26599
+ .toc-sidebar {
26600
+ position: fixed;
26601
+ top: 0; left: 0; bottom: 0;
26602
+ width: 240px;
26603
+ background: rgba(255,255,255,0.02);
26604
+ border-right: 1px solid rgba(255,255,255,0.06);
26605
+ padding: 24px 0 80px;
26606
+ overflow-y: auto;
26607
+ z-index: 50;
26608
+ display: none;
26609
+ }
26610
+ .toc-sidebar.visible { display: block; }
26611
+ .toc-header {
26612
+ font-size: 11px;
26613
+ font-weight: 700;
26614
+ letter-spacing: 0.12em;
26615
+ text-transform: uppercase;
26616
+ color: rgba(255,255,255,0.28);
26617
+ padding: 0 20px 14px;
26618
+ border-bottom: 1px solid rgba(255,255,255,0.05);
26619
+ margin-bottom: 8px;
26620
+ }
26621
+ .toc-item {
26622
+ display: block;
26623
+ padding: 7px 20px;
26624
+ font-size: 13px;
26625
+ color: rgba(255,255,255,0.5);
26626
+ text-decoration: none;
26627
+ cursor: pointer;
26628
+ transition: all 0.15s ease;
26629
+ border-left: 2px solid transparent;
26630
+ line-height: 1.4;
26631
+ }
26632
+ .toc-item:hover {
26633
+ color: rgba(255,255,255,0.85);
26634
+ background: rgba(255,255,255,0.03);
26635
+ }
26636
+ .toc-item.active {
26637
+ color: #ffffff;
26638
+ border-left-color: #ffffff;
26639
+ background: rgba(255,255,255,0.04);
26640
+ }
26641
+ body.toc-visible .wrap {
26642
+ margin-left: 240px;
26643
+ }
26644
+
26645
+ /* ── Chat Panel ───────────────────────────────────────────────── */
26646
+ .chat-panel {
26647
+ position: fixed;
26648
+ bottom: 0; left: 0; right: 0;
26649
+ background: rgba(0,0,0,0.92);
26650
+ backdrop-filter: blur(12px);
26651
+ border-top: 1px solid rgba(255,255,255,0.08);
26652
+ padding: 12px 20px;
26653
+ z-index: 60;
26654
+ display: none;
26655
+ gap: 10px;
26656
+ align-items: center;
26657
+ }
26658
+ .chat-panel.visible { display: flex; }
26659
+ body.chat-visible .wrap { padding-bottom: 72px; }
26660
+ body.toc-visible .chat-panel { left: 240px; }
26661
+ .chat-input {
26662
+ flex: 1;
26663
+ background: rgba(255,255,255,0.06);
26664
+ border: 1px solid rgba(255,255,255,0.1);
26665
+ border-radius: 8px;
26666
+ color: #ffffff;
26667
+ font-family: inherit;
26668
+ font-size: 14px;
26669
+ padding: 10px 14px;
26670
+ outline: none;
26671
+ transition: border-color 0.2s ease;
26672
+ }
26673
+ .chat-input:focus { border-color: rgba(255,255,255,0.35); }
26674
+ .chat-input::placeholder { color: rgba(255,255,255,0.3); }
26675
+ .chat-send {
26676
+ flex-shrink: 0;
26677
+ background: #ffffff;
26678
+ color: #000000;
26679
+ border: 0;
26680
+ border-radius: 8px;
26681
+ padding: 10px 18px;
26682
+ font-size: 14px;
26683
+ font-weight: 600;
26684
+ cursor: pointer;
26685
+ transition: opacity 0.2s ease;
26686
+ }
26687
+ .chat-send:hover:not(:disabled) { opacity: 0.85; }
26688
+ .chat-send:disabled { opacity: 0.3; cursor: not-allowed; }
26405
26689
  </style>
26406
26690
  </head>
26407
26691
  <body>
26692
+ <nav id="tocSidebar" class="toc-sidebar"></nav>
26408
26693
  <div class="wrap">
26409
26694
  <a href="/" class="back-link">← All Interviews</a>
26410
26695
  <div class="brand-header">
@@ -26445,6 +26730,11 @@ function renderInterviewPage(interviewId, resumeSlug) {
26445
26730
  <div class="status-text" id="loadingText">Processing...</div>
26446
26731
  </div>
26447
26732
 
26733
+ <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" />
26735
+ <button class="chat-send" id="chatSendBtn" type="button" disabled>Send</button>
26736
+ </div>
26737
+
26448
26738
  <script>
26449
26739
  ${clipboardHelperJs()}
26450
26740
  const interviewId = ${JSON.stringify(interviewId).replace(/</g, "\\u003c")};
@@ -26814,21 +27104,94 @@ function renderInterviewPage(interviewId, resumeSlug) {
26814
27104
  return paragraphs.map(p => \`<p>\${p.replace(/\\n/g, '<br>')}</p>\`).join('');
26815
27105
  }
26816
27106
 
27107
+ async function submitBlockComment(sectionTitle, commentText, button) {
27108
+ if (!commentText.trim()) return;
27109
+ button.disabled = true;
27110
+ const submitStatus = document.getElementById('submitStatus');
27111
+ submitStatus.textContent = '';
27112
+
27113
+ try {
27114
+ const res = await fetch('/api/interviews/' + encodeURIComponent(interviewId) + '/block-comment', {
27115
+ method: 'POST',
27116
+ headers: { 'content-type': 'application/json' },
27117
+ body: JSON.stringify({ section: sectionTitle, comment: commentText }),
27118
+ });
27119
+ const payload = await res.json();
27120
+ if (res.ok) {
27121
+ submitStatus.textContent = 'Feedback queued for block: ' + sectionTitle;
27122
+ const input = button.previousElementSibling;
27123
+ if (input) input.value = '';
27124
+ refresh().catch(() => {});
27125
+ } else {
27126
+ submitStatus.textContent = payload.error || 'Failed to submit comment.';
27127
+ }
27128
+ } catch (err) {
27129
+ submitStatus.textContent = 'Error submitting block connection feedback.';
27130
+ } finally {
27131
+ button.disabled = false;
27132
+ }
27133
+ }
27134
+
26817
27135
  function renderCompletedView(data) {
26818
27136
  const container = document.getElementById('questions');
26819
- const { spec, qaPairs } = parseDocument(data.document);
26820
27137
  const frag = document.createDocumentFragment();
26821
27138
 
26822
- // Spec section
26823
- if (spec) {
27139
+ const blocks = data.blocks || [];
27140
+ if (blocks.length > 0) {
26824
27141
  const specLabel = document.createElement('div');
26825
27142
  specLabel.className = 'section-label';
26826
- specLabel.textContent = 'Current Spec';
27143
+ specLabel.textContent = 'Interactive Specifications (11 Sections)';
26827
27144
  frag.appendChild(specLabel);
26828
- const specBlock = document.createElement('div');
26829
- specBlock.className = 'spec-block';
26830
- specBlock.innerHTML = simpleMarkdown(spec);
26831
- frag.appendChild(specBlock);
27145
+
27146
+ for (const block of blocks) {
27147
+ const card = document.createElement('div');
27148
+ card.className = 'spec-block-card';
27149
+
27150
+ const header = document.createElement('h3');
27151
+ header.textContent = block.title;
27152
+ card.appendChild(header);
27153
+
27154
+ const content = document.createElement('div');
27155
+ content.className = 'spec-block-content';
27156
+ content.innerHTML = simpleMarkdown(block.content);
27157
+ card.appendChild(content);
27158
+
27159
+ // Per-block comment integration for micro annotations
27160
+ const commentBox = document.createElement('div');
27161
+ commentBox.className = 'spec-block-comment-box';
27162
+
27163
+ const textarea = document.createElement('textarea');
27164
+ textarea.className = 'spec-block-comment-input';
27165
+ textarea.placeholder = 'Provide review comment/revision for ' + block.title + '...';
27166
+ commentBox.appendChild(textarea);
27167
+
27168
+ const sendBtn = document.createElement('button');
27169
+ sendBtn.className = 'comment-submit-btn';
27170
+ sendBtn.type = 'button';
27171
+ sendBtn.textContent = 'Revise Section';
27172
+ sendBtn.addEventListener('click', () => {
27173
+ submitBlockComment(block.title, textarea.value, sendBtn);
27174
+ });
27175
+ commentBox.appendChild(sendBtn);
27176
+
27177
+ card.appendChild(commentBox);
27178
+ frag.appendChild(card);
27179
+ }
27180
+ } else {
27181
+ // Fallback legacy parse if blocks array isn't populated
27182
+ const { spec, qaPairs } = parseDocument(data.document);
27183
+
27184
+ // Spec section
27185
+ if (spec) {
27186
+ const specLabel = document.createElement('div');
27187
+ specLabel.className = 'section-label';
27188
+ specLabel.textContent = 'Current Spec';
27189
+ frag.appendChild(specLabel);
27190
+ const specBlock = document.createElement('div');
27191
+ specBlock.className = 'spec-block';
27192
+ specBlock.innerHTML = simpleMarkdown(spec);
27193
+ frag.appendChild(specBlock);
27194
+ }
26832
27195
  }
26833
27196
 
26834
27197
  // Q&A section
@@ -26837,6 +27200,7 @@ function renderInterviewPage(interviewId, resumeSlug) {
26837
27200
  qaLabel.textContent = 'Q&A History';
26838
27201
  frag.appendChild(qaLabel);
26839
27202
 
27203
+ const { qaPairs } = parseDocument(data.document);
26840
27204
  if (!qaPairs.length) {
26841
27205
  const empty = document.createElement('p');
26842
27206
  empty.className = 'qa-empty';
@@ -26885,6 +27249,103 @@ function renderInterviewPage(interviewId, resumeSlug) {
26885
27249
  container.replaceChildren(frag);
26886
27250
  }
26887
27251
 
27252
+ // ── TOC Sidebar ──────────────────────────────────────────────
27253
+ function updateTocSidebar(data) {
27254
+ const sidebar = document.getElementById('tocSidebar');
27255
+ const blocks = data.blocks || [];
27256
+ const isDone = ['completed', 'session-disconnected'].includes(data.mode);
27257
+ if (!isDone || !blocks.length) {
27258
+ sidebar.classList.remove('visible');
27259
+ document.body.classList.remove('toc-visible');
27260
+ return;
27261
+ }
27262
+ sidebar.classList.add('visible');
27263
+ document.body.classList.add('toc-visible');
27264
+ sidebar.innerHTML = '';
27265
+ const header = document.createElement('div');
27266
+ header.className = 'toc-header';
27267
+ header.textContent = 'Sections';
27268
+ sidebar.appendChild(header);
27269
+ blocks.forEach((block) => {
27270
+ const item = document.createElement('a');
27271
+ item.className = 'toc-item';
27272
+ item.textContent = block.title;
27273
+ item.addEventListener('click', () => {
27274
+ const cards = document.querySelectorAll('.spec-block-card h3');
27275
+ for (const h3 of cards) {
27276
+ if (h3.textContent === block.title) {
27277
+ h3.closest('.spec-block-card').scrollIntoView({ behavior: 'smooth', block: 'start' });
27278
+ break;
27279
+ }
27280
+ }
27281
+ document.querySelectorAll('.toc-item').forEach((el) => el.classList.remove('active'));
27282
+ item.classList.add('active');
27283
+ });
27284
+ sidebar.appendChild(item);
27285
+ });
27286
+ }
27287
+
27288
+ // ── Chat Panel ───────────────────────────────────────────────
27289
+ function updateChatPanel(data) {
27290
+ const panel = document.getElementById('chatPanel');
27291
+ const input = document.getElementById('chatInput');
27292
+ const sendBtn = document.getElementById('chatSendBtn');
27293
+ const isDone = ['completed', 'session-disconnected'].includes(data.mode);
27294
+ if (!isDone) {
27295
+ panel.classList.remove('visible');
27296
+ document.body.classList.remove('chat-visible');
27297
+ return;
27298
+ }
27299
+ panel.classList.add('visible');
27300
+ document.body.classList.add('chat-visible');
27301
+ const busy = data.isBusy || false;
27302
+ sendBtn.disabled = busy;
27303
+ input.disabled = busy;
27304
+ if (busy) {
27305
+ input.placeholder = 'Agent is processing...';
27306
+ } else {
27307
+ input.placeholder = 'Send a message to the agent — add a section, revise content, ask questions...';
27308
+ }
27309
+ }
27310
+
27311
+ async function sendChatMessage() {
27312
+ const input = document.getElementById('chatInput');
27313
+ const sendBtn = document.getElementById('chatSendBtn');
27314
+ const message = input.value.trim();
27315
+ if (!message) return;
27316
+ sendBtn.disabled = true;
27317
+ input.value = '';
27318
+ const submitStatus = document.getElementById('submitStatus');
27319
+ submitStatus.textContent = '';
27320
+ try {
27321
+ const res = await fetch('/api/interviews/' + encodeURIComponent(interviewId) + '/chat', {
27322
+ method: 'POST',
27323
+ headers: { 'content-type': 'application/json' },
27324
+ body: JSON.stringify({ message }),
27325
+ });
27326
+ const payload = await res.json();
27327
+ if (res.ok) {
27328
+ submitStatus.textContent = 'Chat message sent to agent.';
27329
+ refresh().catch(() => {});
27330
+ schedulePoll();
27331
+ } else {
27332
+ submitStatus.textContent = payload.message || payload.error || 'Failed to send chat message.';
27333
+ sendBtn.disabled = false;
27334
+ }
27335
+ } catch (_err) {
27336
+ submitStatus.textContent = 'Network error sending chat message.';
27337
+ sendBtn.disabled = false;
27338
+ }
27339
+ }
27340
+
27341
+ document.getElementById('chatSendBtn').addEventListener('click', sendChatMessage);
27342
+ document.getElementById('chatInput').addEventListener('keydown', (e) => {
27343
+ if (e.key === 'Enter' && !e.shiftKey) {
27344
+ e.preventDefault();
27345
+ sendChatMessage();
27346
+ }
27347
+ });
27348
+
26888
27349
  function renderQuestions(questions) {
26889
27350
  const sig = JSON.stringify([questions, state.data?.mode]);
26890
27351
  const container = document.getElementById('questions');
@@ -27041,6 +27502,19 @@ function renderInterviewPage(interviewId, resumeSlug) {
27041
27502
 
27042
27503
  renderQuestions(data.questions || []);
27043
27504
  updateSubmitButton();
27505
+ updateTocSidebar(data);
27506
+ updateChatPanel(data);
27507
+
27508
+ // If we transitioned to a non-terminal state and connection/polling is stopped, restart them
27509
+ const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
27510
+ if (!terminalModes.includes(data.mode)) {
27511
+ if (!sseConnected && !activeEs) {
27512
+ connectSse();
27513
+ }
27514
+ if (!sseConnected && !pollFallbackTimer) {
27515
+ schedulePoll();
27516
+ }
27517
+ }
27044
27518
  }
27045
27519
 
27046
27520
  async function refresh() {
@@ -27151,19 +27625,65 @@ function renderInterviewPage(interviewId, resumeSlug) {
27151
27625
  document.getElementById('moreQuestionsBtn').addEventListener('click', () => sendNudge('more-questions'));
27152
27626
  document.getElementById('confirmCompleteBtn').addEventListener('click', () => sendNudge('confirm-complete'));
27153
27627
 
27628
+ // ── Real-time updates via SSE ─────────────────────────────────
27629
+ let sseConnected = false;
27630
+ let pollFallbackTimer = null;
27631
+ let activeEs = null;
27632
+
27633
+ function connectSse() {
27634
+ if (activeEs) {
27635
+ activeEs.close();
27636
+ }
27637
+ const sseUrl = '/api/interviews/' + encodeURIComponent(interviewId) + '/events';
27638
+ const es = new EventSource(sseUrl);
27639
+ activeEs = es;
27640
+
27641
+ es.addEventListener('state', (e) => {
27642
+ sseConnected = true;
27643
+ if (pollFallbackTimer) {
27644
+ clearTimeout(pollFallbackTimer);
27645
+ pollFallbackTimer = null;
27646
+ }
27647
+ try {
27648
+ const data = JSON.parse(e.data);
27649
+ render(data);
27650
+ } catch (_) {}
27651
+ });
27652
+
27653
+ es.onerror = () => {
27654
+ sseConnected = false;
27655
+ es.close();
27656
+ activeEs = null;
27657
+ const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
27658
+ if (state.data && terminalModes.includes(state.data.mode)) {
27659
+ return;
27660
+ }
27661
+ // Retry SSE after 3s, fall back to polling in the meantime
27662
+ if (!pollFallbackTimer) schedulePoll();
27663
+ setTimeout(() => connectSse(), 3000);
27664
+ };
27665
+ }
27666
+
27154
27667
  function schedulePoll() {
27155
- setTimeout(async () => {
27668
+ // Only poll if SSE is not connected
27669
+ if (sseConnected) return;
27670
+ const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
27671
+ if (state.data && terminalModes.includes(state.data.mode)) {
27672
+ return;
27673
+ }
27674
+ pollFallbackTimer = setTimeout(async () => {
27156
27675
  try { await refresh(); } catch (_) {}
27157
- // Stop polling for terminal states
27158
- const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
27159
- if (!terminalModes.includes(state.data?.mode)) schedulePoll();
27676
+ pollFallbackTimer = null;
27677
+ if (!sseConnected) {
27678
+ schedulePoll();
27679
+ }
27160
27680
  }, 2500);
27161
27681
  }
27162
27682
 
27163
27683
  refresh().catch((error) => {
27164
27684
  document.getElementById('submitStatus').textContent = error.message || 'Failed to load interview.';
27165
27685
  });
27166
- schedulePoll();
27686
+ connectSse();
27167
27687
  </script>
27168
27688
  </body>
27169
27689
  </html>`;
@@ -27232,6 +27752,51 @@ function createDashboardServer(config) {
27232
27752
  let baseUrl = null;
27233
27753
  const sessions = new Map;
27234
27754
  const stateCache = new Map;
27755
+ const sseClients = new Map;
27756
+ function formatSseState(entry) {
27757
+ const markdownPath = entry.filePath;
27758
+ const displayPath = markdownPath ? markdownPath.split("/").pop() || markdownPath : "interview.md";
27759
+ const document = entry.document ?? "";
27760
+ return {
27761
+ interview: {
27762
+ id: entry.interviewId,
27763
+ sessionID: entry.sessionID,
27764
+ idea: entry.idea,
27765
+ markdownPath: displayPath,
27766
+ createdAt: new Date(entry.lastUpdatedAt).toISOString(),
27767
+ status: entry.mode === "session-disconnected" ? "abandoned" : "active",
27768
+ baseMessageCount: 0
27769
+ },
27770
+ url: `${baseUrl}/interview/${entry.interviewId}`,
27771
+ markdownPath,
27772
+ mode: entry.mode,
27773
+ isBusy: entry.mode === "awaiting-agent",
27774
+ summary: entry.summary,
27775
+ questions: entry.questions,
27776
+ document,
27777
+ lastUpdatedAt: entry.lastUpdatedAt,
27778
+ nudgeAction: entry.nudgeAction,
27779
+ blocks: entry.blocks ?? parseSpecBlocks(document)
27780
+ };
27781
+ }
27782
+ function broadcastSse(interviewId, entry) {
27783
+ const clients = sseClients.get(interviewId);
27784
+ if (!clients || clients.size === 0)
27785
+ return;
27786
+ const payload = `event: state
27787
+ data: ${JSON.stringify(formatSseState(entry))}
27788
+
27789
+ `;
27790
+ for (const res of clients) {
27791
+ try {
27792
+ res.write(payload);
27793
+ } catch {
27794
+ clients.delete(res);
27795
+ }
27796
+ }
27797
+ if (clients.size === 0)
27798
+ sseClients.delete(interviewId);
27799
+ }
27235
27800
  const TERMINAL_MODES = new Set([
27236
27801
  "abandoned",
27237
27802
  "completed",
@@ -27390,7 +27955,9 @@ function createDashboardServer(config) {
27390
27955
  pendingAnswers: null,
27391
27956
  lastUpdatedAt: fm.updatedAt ? new Date(fm.updatedAt).getTime() : Date.now(),
27392
27957
  filePath: path14.join(interviewDir, entry),
27393
- nudgeAction: null
27958
+ nudgeAction: null,
27959
+ pendingBlockComment: null,
27960
+ pendingChatMessage: null
27394
27961
  });
27395
27962
  if (!sessions.has(fm.sessionID)) {
27396
27963
  sessions.set(fm.sessionID, {
@@ -27571,7 +28138,9 @@ function createDashboardServer(config) {
27571
28138
  pendingAnswers: null,
27572
28139
  lastUpdatedAt: Date.now(),
27573
28140
  filePath: "",
27574
- nudgeAction: null
28141
+ nudgeAction: null,
28142
+ pendingBlockComment: null,
28143
+ pendingChatMessage: null
27575
28144
  });
27576
28145
  dedupRecovered(interviewId, stateCache);
27577
28146
  fileCache = null;
@@ -27582,6 +28151,48 @@ function createDashboardServer(config) {
27582
28151
  });
27583
28152
  return;
27584
28153
  }
28154
+ if (request.method === "GET" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/events")) {
28155
+ const interviewId = pathname.replace("/api/interviews/", "").replace("/events", "");
28156
+ if (!interviewId || !isValidId(interviewId)) {
28157
+ sendJson(response, 400, { error: "Invalid interview ID" });
28158
+ return;
28159
+ }
28160
+ const entry = stateCache.get(interviewId);
28161
+ if (!entry) {
28162
+ sendJson(response, 404, { error: "Interview not found" });
28163
+ return;
28164
+ }
28165
+ response.writeHead(200, {
28166
+ "content-type": "text/event-stream",
28167
+ "cache-control": "no-cache",
28168
+ connection: "keep-alive",
28169
+ "access-control-allow-origin": "*"
28170
+ });
28171
+ let clients = sseClients.get(interviewId);
28172
+ if (!clients) {
28173
+ clients = new Set;
28174
+ sseClients.set(interviewId, clients);
28175
+ }
28176
+ clients.add(response);
28177
+ response.write(`event: state
28178
+ data: ${JSON.stringify(formatSseState(entry))}
28179
+
28180
+ `);
28181
+ const heartbeat = setInterval(() => {
28182
+ try {
28183
+ response.write(`: hb
28184
+
28185
+ `);
28186
+ } catch {}
28187
+ }, 15000);
28188
+ request.on("close", () => {
28189
+ clearInterval(heartbeat);
28190
+ clients?.delete(response);
28191
+ if (clients && clients.size === 0)
28192
+ sseClients.delete(interviewId);
28193
+ });
28194
+ return;
28195
+ }
27585
28196
  if (request.method === "POST" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/state")) {
27586
28197
  const interviewId = pathname.replace("/api/interviews/", "").replace("/state", "");
27587
28198
  if (!interviewId || !isValidId(interviewId)) {
@@ -27608,10 +28219,15 @@ function createDashboardServer(config) {
27608
28219
  existing.questions = state.questions;
27609
28220
  if (state.filePath)
27610
28221
  existing.filePath = state.filePath;
28222
+ if (state.document !== undefined)
28223
+ existing.document = state.document;
28224
+ if (state.blocks !== undefined)
28225
+ existing.blocks = state.blocks;
27611
28226
  existing.lastUpdatedAt = Date.now();
27612
28227
  dedupRecovered(interviewId, stateCache);
28228
+ broadcastSse(interviewId, existing);
27613
28229
  } else {
27614
- stateCache.set(interviewId, {
28230
+ const entry = {
27615
28231
  interviewId,
27616
28232
  sessionID: state.sessionID ?? "",
27617
28233
  idea: state.idea ?? "",
@@ -27622,8 +28238,14 @@ function createDashboardServer(config) {
27622
28238
  pendingAnswers: null,
27623
28239
  lastUpdatedAt: Date.now(),
27624
28240
  filePath: state.filePath ?? "",
27625
- nudgeAction: null
27626
- });
28241
+ nudgeAction: null,
28242
+ pendingBlockComment: state.pendingBlockComment ?? null,
28243
+ pendingChatMessage: state.pendingChatMessage ?? null,
28244
+ document: state.document,
28245
+ blocks: state.blocks
28246
+ };
28247
+ stateCache.set(interviewId, entry);
28248
+ broadcastSse(interviewId, entry);
27627
28249
  }
27628
28250
  sendJson(response, 200, { status: "ok" });
27629
28251
  return;
@@ -27681,7 +28303,8 @@ function createDashboardServer(config) {
27681
28303
  questions: entry.questions,
27682
28304
  document,
27683
28305
  lastUpdatedAt: entry.lastUpdatedAt,
27684
- nudgeAction: entry.nudgeAction
28306
+ nudgeAction: entry.nudgeAction,
28307
+ blocks: parseSpecBlocks(document)
27685
28308
  });
27686
28309
  return;
27687
28310
  }
@@ -27716,12 +28339,12 @@ function createDashboardServer(config) {
27716
28339
  sendJson(response, 200, { status: "ok" });
27717
28340
  return;
27718
28341
  }
27719
- if (request.method === "GET" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/pending")) {
28342
+ if (request.method === "POST" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/block-comment")) {
27720
28343
  if (!isAuthenticated(request)) {
27721
28344
  sendJson(response, 401, { error: "Unauthorized" });
27722
28345
  return;
27723
28346
  }
27724
- const interviewId = pathname.replace("/api/interviews/", "").replace("/pending", "");
28347
+ const interviewId = pathname.replace("/api/interviews/", "").replace("/block-comment", "");
27725
28348
  if (!isValidId(interviewId)) {
27726
28349
  sendJson(response, 400, { error: "Invalid interview ID" });
27727
28350
  return;
@@ -27731,12 +28354,126 @@ function createDashboardServer(config) {
27731
28354
  sendJson(response, 404, { error: "Interview not found" });
27732
28355
  return;
27733
28356
  }
27734
- const answers = entry.pendingAnswers;
27735
- if (answers) {
27736
- entry.pendingAnswers = null;
28357
+ let body;
28358
+ try {
28359
+ body = await readJsonBody(request);
28360
+ } catch {
28361
+ sendJson(response, 400, { error: "Invalid JSON" });
28362
+ return;
27737
28363
  }
27738
- sendJson(response, 200, {
27739
- answers
28364
+ const { section, comment } = body;
28365
+ if (typeof section !== "string" || typeof comment !== "string") {
28366
+ sendJson(response, 400, {
28367
+ error: "section and comment must be strings"
28368
+ });
28369
+ return;
28370
+ }
28371
+ entry.pendingBlockComment = { section, comment };
28372
+ entry.mode = "awaiting-agent";
28373
+ entry.lastUpdatedAt = Date.now();
28374
+ sendJson(response, 200, { status: "ok" });
28375
+ return;
28376
+ }
28377
+ if (request.method === "GET" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/block-comment")) {
28378
+ if (!isAuthenticated(request)) {
28379
+ sendJson(response, 401, { error: "Unauthorized" });
28380
+ return;
28381
+ }
28382
+ const interviewId = pathname.replace("/api/interviews/", "").replace("/block-comment", "");
28383
+ if (!isValidId(interviewId)) {
28384
+ sendJson(response, 400, { error: "Invalid interview ID" });
28385
+ return;
28386
+ }
28387
+ const entry = stateCache.get(interviewId);
28388
+ if (!entry) {
28389
+ sendJson(response, 404, { error: "Interview not found" });
28390
+ return;
28391
+ }
28392
+ const val = entry.pendingBlockComment;
28393
+ if (val) {
28394
+ entry.pendingBlockComment = null;
28395
+ }
28396
+ sendJson(response, 200, val || {});
28397
+ return;
28398
+ }
28399
+ if (request.method === "POST" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/chat")) {
28400
+ if (!isAuthenticated(request)) {
28401
+ sendJson(response, 401, { error: "Unauthorized" });
28402
+ return;
28403
+ }
28404
+ const interviewId = pathname.replace("/api/interviews/", "").replace("/chat", "");
28405
+ if (!isValidId(interviewId)) {
28406
+ sendJson(response, 400, { error: "Invalid interview ID" });
28407
+ return;
28408
+ }
28409
+ const entry = stateCache.get(interviewId);
28410
+ if (!entry) {
28411
+ sendJson(response, 404, { error: "Interview not found" });
28412
+ return;
28413
+ }
28414
+ let body;
28415
+ try {
28416
+ body = await readJsonBody(request);
28417
+ } catch {
28418
+ sendJson(response, 400, { error: "Invalid JSON" });
28419
+ return;
28420
+ }
28421
+ const { message } = body;
28422
+ if (typeof message !== "string" || !message.trim()) {
28423
+ sendJson(response, 400, {
28424
+ error: "message must be a non-empty string"
28425
+ });
28426
+ return;
28427
+ }
28428
+ entry.pendingChatMessage = message.trim();
28429
+ entry.mode = "awaiting-agent";
28430
+ entry.lastUpdatedAt = Date.now();
28431
+ sendJson(response, 200, { status: "ok" });
28432
+ return;
28433
+ }
28434
+ if (request.method === "GET" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/chat")) {
28435
+ if (!isAuthenticated(request)) {
28436
+ sendJson(response, 401, { error: "Unauthorized" });
28437
+ return;
28438
+ }
28439
+ const interviewId = pathname.replace("/api/interviews/", "").replace("/chat", "");
28440
+ if (!isValidId(interviewId)) {
28441
+ sendJson(response, 400, { error: "Invalid interview ID" });
28442
+ return;
28443
+ }
28444
+ const entry = stateCache.get(interviewId);
28445
+ if (!entry) {
28446
+ sendJson(response, 404, { error: "Interview not found" });
28447
+ return;
28448
+ }
28449
+ const val = entry.pendingChatMessage;
28450
+ if (val) {
28451
+ entry.pendingChatMessage = null;
28452
+ }
28453
+ sendJson(response, 200, { message: val || null });
28454
+ return;
28455
+ }
28456
+ if (request.method === "GET" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/pending")) {
28457
+ if (!isAuthenticated(request)) {
28458
+ sendJson(response, 401, { error: "Unauthorized" });
28459
+ return;
28460
+ }
28461
+ const interviewId = pathname.replace("/api/interviews/", "").replace("/pending", "");
28462
+ if (!isValidId(interviewId)) {
28463
+ sendJson(response, 400, { error: "Invalid interview ID" });
28464
+ return;
28465
+ }
28466
+ const entry = stateCache.get(interviewId);
28467
+ if (!entry) {
28468
+ sendJson(response, 404, { error: "Interview not found" });
28469
+ return;
28470
+ }
28471
+ const answers = entry.pendingAnswers;
28472
+ if (answers) {
28473
+ entry.pendingAnswers = null;
28474
+ }
28475
+ sendJson(response, 200, {
28476
+ answers
27740
28477
  });
27741
28478
  return;
27742
28479
  }
@@ -27885,9 +28622,20 @@ function createDashboardServer(config) {
27885
28622
  entry.pendingAnswers ??= existing.pendingAnswers;
27886
28623
  if (existing.nudgeAction)
27887
28624
  entry.nudgeAction ??= existing.nudgeAction;
28625
+ if (existing.pendingBlockComment)
28626
+ entry.pendingBlockComment ??= existing.pendingBlockComment;
28627
+ if (existing.pendingChatMessage)
28628
+ entry.pendingChatMessage ??= existing.pendingChatMessage;
28629
+ if (entry.document === undefined && existing.document !== undefined) {
28630
+ entry.document = existing.document;
28631
+ }
28632
+ if (entry.blocks === undefined && existing.blocks !== undefined) {
28633
+ entry.blocks = existing.blocks;
28634
+ }
27888
28635
  }
27889
28636
  stateCache.set(entry.interviewId, entry);
27890
28637
  dedupRecovered(entry.interviewId, stateCache);
28638
+ broadcastSse(entry.interviewId, entry);
27891
28639
  },
27892
28640
  getState: (id) => stateCache.get(id),
27893
28641
  storeAnswers: (id, answers) => {
@@ -27915,6 +28663,22 @@ function createDashboardServer(config) {
27915
28663
  entry.nudgeAction = null;
27916
28664
  return action;
27917
28665
  },
28666
+ consumeBlockComment: (id) => {
28667
+ const entry = stateCache.get(id);
28668
+ if (!entry?.pendingBlockComment)
28669
+ return null;
28670
+ const comment = entry.pendingBlockComment;
28671
+ entry.pendingBlockComment = null;
28672
+ return comment;
28673
+ },
28674
+ consumeChatMessage: (id) => {
28675
+ const entry = stateCache.get(id);
28676
+ if (!entry?.pendingChatMessage)
28677
+ return null;
28678
+ const message = entry.pendingChatMessage;
28679
+ entry.pendingChatMessage = null;
28680
+ return message;
28681
+ },
27918
28682
  authToken,
27919
28683
  discoverSessionDirectories,
27920
28684
  addManualFolder: (dir) => {
@@ -28102,6 +28866,50 @@ function createInterviewServer(deps) {
28102
28866
  }
28103
28867
  return;
28104
28868
  }
28869
+ const blockCommentMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/block-comment$/);
28870
+ if (request.method === "POST" && blockCommentMatch) {
28871
+ try {
28872
+ const body = await readJsonBody(request);
28873
+ if (typeof body.section !== "string" || typeof body.comment !== "string") {
28874
+ sendJson(response, 400, {
28875
+ error: "section and comment must be strings"
28876
+ });
28877
+ return;
28878
+ }
28879
+ await deps.submitBlockComment(decodeURIComponent(blockCommentMatch[1]), body.section, body.comment);
28880
+ sendJson(response, 200, {
28881
+ ok: true,
28882
+ message: "Block feedback forwarded."
28883
+ });
28884
+ } catch (error) {
28885
+ const message = error instanceof Error ? error.message : "Failed to submit block comment.";
28886
+ const status = getSubmissionStatus(error);
28887
+ sendJson(response, status, { ok: false, message });
28888
+ }
28889
+ return;
28890
+ }
28891
+ const chatMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/chat$/);
28892
+ if (request.method === "POST" && chatMatch) {
28893
+ try {
28894
+ const body = await readJsonBody(request);
28895
+ if (typeof body.message !== "string" || !body.message.trim()) {
28896
+ sendJson(response, 400, {
28897
+ error: "message must be a non-empty string"
28898
+ });
28899
+ return;
28900
+ }
28901
+ await deps.submitChat(decodeURIComponent(chatMatch[1]), body.message.trim());
28902
+ sendJson(response, 200, {
28903
+ ok: true,
28904
+ message: "Chat message forwarded to agent."
28905
+ });
28906
+ } catch (error) {
28907
+ const message = error instanceof Error ? error.message : "Failed to submit chat message.";
28908
+ const status = getSubmissionStatus(error);
28909
+ sendJson(response, status, { ok: false, message });
28910
+ }
28911
+ return;
28912
+ }
28105
28913
  const nudgeMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/nudge$/);
28106
28914
  if (request.method === "POST" && nudgeMatch) {
28107
28915
  try {
@@ -28216,6 +29024,39 @@ function normalizeQuestion(value, index) {
28216
29024
  suggested: typeof result.data.suggested === "string" && result.data.suggested.trim().length > 0 ? result.data.suggested.trim() : undefined
28217
29025
  };
28218
29026
  }
29027
+ function repairJsonNewlines(json) {
29028
+ let result = "";
29029
+ let inString = false;
29030
+ let escaped = false;
29031
+ for (let i = 0;i < json.length; i++) {
29032
+ const char = json[i];
29033
+ if (inString) {
29034
+ if (escaped) {
29035
+ result += char;
29036
+ escaped = false;
29037
+ } else if (char === "\\") {
29038
+ result += char;
29039
+ escaped = true;
29040
+ } else if (char === '"') {
29041
+ result += char;
29042
+ inString = false;
29043
+ } else if (char === `
29044
+ `) {
29045
+ result += "\\n";
29046
+ } else if (char === "\r") {
29047
+ result += "\\r";
29048
+ } else {
29049
+ result += char;
29050
+ }
29051
+ } else {
29052
+ if (char === '"') {
29053
+ inString = true;
29054
+ }
29055
+ result += char;
29056
+ }
29057
+ }
29058
+ return result;
29059
+ }
28219
29060
  function flattenMessage(message) {
28220
29061
  return (message.parts ?? []).map((part) => part.text ?? "").join(`
28221
29062
  `).trim();
@@ -28232,8 +29073,14 @@ function parseAssistantState(text, maxQuestions = 2) {
28232
29073
  if (!match) {
28233
29074
  return { state: null };
28234
29075
  }
29076
+ let rawJson = match[1].trim();
28235
29077
  try {
28236
- const raw = JSON.parse(match[1]);
29078
+ JSON.parse(rawJson);
29079
+ } catch {
29080
+ rawJson = repairJsonNewlines(rawJson);
29081
+ }
29082
+ try {
29083
+ const raw = JSON.parse(rawJson);
28237
29084
  const parsed = RawInterviewStateSchema.parse(raw);
28238
29085
  const summary = typeof parsed.summary === "string" ? parsed.summary.trim() : "";
28239
29086
  const title = typeof parsed.title === "string" && parsed.title.trim().length > 0 ? parsed.title.trim() : undefined;
@@ -28291,17 +29138,67 @@ ${suggested}`;
28291
29138
 
28292
29139
  `);
28293
29140
  }
29141
+ var SPECIFICATION_TEMPLATE_GUIDELINE = `
29142
+ Your target document MUST be structured strictly using the following 11-section template (exclude the frontmatter itself from the summary JSON field, as the tool handles frontmatter automatically):
29143
+
29144
+ # Introduction
29145
+ [Short intro to the spec and goals]
29146
+
29147
+ ## 1. Purpose & Scope
29148
+ [Intended audience, boundaries, and assumptions]
29149
+
29150
+ ## 2. Definitions
29151
+ [Acronyms, terms defined]
29152
+
29153
+ ## 3. Requirements, Constraints & Guidelines
29154
+ [Explicitly list requirements using:
29155
+ - **REQ-001**: Description
29156
+ - **SEC-001**: Security constraints
29157
+ - **CON-001**: System constraints/technologies
29158
+ - **GUD-001**: Guidelines]
29159
+
29160
+ ## 4. Interfaces & Data Contracts
29161
+ [APIs, JSON schemas, protocol buffers, or class/data structures]
29162
+
29163
+ ## 5. Acceptance Criteria
29164
+ [Define testable criteria in Given-When-Then format:
29165
+ - **AC-001**: Given [context], When [action], Then [expected outcome]]
29166
+
29167
+ ## 6. Test Automation Strategy
29168
+ [Mocking approach, test framework, unit/integration details]
29169
+
29170
+ ## 7. Rationale & Context
29171
+ [Trade-offs and architectural decisions]
29172
+
29173
+ ## 8. Dependencies & External Integrations
29174
+ [Conceptual integrations or external dependencies:
29175
+ - **EXT-001**: Dependency details]
29176
+
29177
+ ## 9. Examples & Edge Cases
29178
+ [Concrete code examples, settings, or JSON structures]
29179
+
29180
+ ## 10. Validation Criteria
29181
+ [Testing or validation check logic]
29182
+
29183
+ ## 11. Related Specifications / Further Reading
29184
+ [Internal doc references]
29185
+
29186
+ ANTI-ASSUMPTION & SUB-AGENT DELEGATION RULE:
29187
+ Do not invent file structures, API signatures, package lists, or library behaviors. If you need to trace local code, verify file paths, or check configurations, you MUST call the sub-agent '@explorer' or search files directly. If you need to search documentation or web info for external APIs/libraries, you MUST call the sub-agent '@librarian' or search the web. Do not guess. Pause, run discovery, and integrate facts into the spec.
29188
+ `;
28294
29189
  function buildKickoffPrompt(idea, maxQuestions) {
28295
29190
  return [
28296
29191
  "You are running an interview q&a session for the user inside their repository.",
28297
29192
  `Initial idea: ${idea}`,
29193
+ `Goal: Iteratively generate and populate a highly structured Specification document.`,
29194
+ SPECIFICATION_TEMPLATE_GUIDELINE,
28298
29195
  `Clarify the idea through short rounds of at most ${maxQuestions} questions at a time.`,
28299
29196
  "When useful, each question may include 2 to 4 answer options and one suggested option.",
28300
29197
  "Be practical. Focus on the highest-ambiguity and highest-risk decisions first.",
28301
29198
  "After any short human-friendly preface, you MUST include a machine-readable block in this exact format:",
28302
29199
  "<interview_state>",
28303
29200
  "{",
28304
- ' "summary": "one short paragraph about the current understanding",',
29201
+ ' "summary": "Full specification markdown (strictly matching the 11 section titles above)",',
28305
29202
  ' "title": "concise-kebab-case-title-for-filename",',
28306
29203
  ' "questions": [',
28307
29204
  " {",
@@ -28315,7 +29212,7 @@ function buildKickoffPrompt(idea, maxQuestions) {
28315
29212
  "</interview_state>",
28316
29213
  "Rules:",
28317
29214
  `- Return 0 to ${maxQuestions} questions.`,
28318
- "- If there are no more useful questions, return zero questions.",
29215
+ "- If there are no more useful questions or the specification is complete, return zero questions.",
28319
29216
  `- Do not ask more than ${maxQuestions} questions in one round.`,
28320
29217
  '- Provide a concise "title" field (kebab-case, 3-6 words) suitable for a filename.'
28321
29218
  ].join(`
@@ -28325,12 +29222,13 @@ function buildResumePrompt(document, maxQuestions) {
28325
29222
  return [
28326
29223
  "Resume the interview from this existing markdown document.",
28327
29224
  "Use the current spec and Q&A history as ground truth so far.",
29225
+ SPECIFICATION_TEMPLATE_GUIDELINE,
28328
29226
  "Do not restart from scratch.",
28329
29227
  "",
28330
29228
  document,
28331
29229
  "",
28332
29230
  `Ask the next highest-value clarifying questions, up to ${maxQuestions} at a time.`,
28333
- "If there are no more useful questions, return zero questions.",
29231
+ "If there are no more useful questions or the spec is complete, return zero questions.",
28334
29232
  "Return the same <interview_state> JSON block format as before."
28335
29233
  ].join(`
28336
29234
  `);
@@ -28340,12 +29238,13 @@ function buildAnswerPrompt(answers, questions, maxQuestions) {
28340
29238
  `);
28341
29239
  return [
28342
29240
  "Continue the same interview.",
29241
+ SPECIFICATION_TEMPLATE_GUIDELINE,
28343
29242
  "These were the active questions:",
28344
29243
  formatQuestionContext(questions),
28345
29244
  "The user answered:",
28346
29245
  answerText,
28347
- "Now update your understanding and ask the next highest-value clarifying questions.",
28348
- `Return 0 to ${maxQuestions} questions. If there are no more useful questions, return zero questions.`,
29246
+ "Now update the specification summary document and ask the next highest-value clarifying questions.",
29247
+ `Return 0 to ${maxQuestions} questions. If there are no more useful questions or the spec is complete, return zero questions.`,
28349
29248
  "Return the same <interview_state> JSON block format as before."
28350
29249
  ].join(`
28351
29250
 
@@ -28405,6 +29304,7 @@ function createInterviewService(ctx, config, deps) {
28405
29304
  const browserOpener = deps?.openBrowser ?? openBrowser;
28406
29305
  const activeInterviewIds = new Map;
28407
29306
  const interviewsById = new Map;
29307
+ const activeSyncs = new Map;
28408
29308
  const sessionBusy = new Map;
28409
29309
  const sessionModel = new Map;
28410
29310
  const browserOpened = new Set;
@@ -28477,6 +29377,19 @@ function createInterviewService(ctx, config, deps) {
28477
29377
  });
28478
29378
  return result.data;
28479
29379
  }
29380
+ async function loadMessagesWithRetry(sessionID) {
29381
+ for (let i = 0;i < 8; i++) {
29382
+ const messages = await loadMessages(sessionID);
29383
+ if (messages.length > 0) {
29384
+ const last = messages[messages.length - 1];
29385
+ if (last?.info?.role === "assistant") {
29386
+ return messages;
29387
+ }
29388
+ }
29389
+ await new Promise((resolve3) => setTimeout(resolve3, 250));
29390
+ }
29391
+ return loadMessages(sessionID);
29392
+ }
28480
29393
  function isUserVisibleMessage(message) {
28481
29394
  return !(message.parts ?? []).some((part) => hasInternalInitiatorMarker(part));
28482
29395
  }
@@ -28545,8 +29458,19 @@ function createInterviewService(ctx, config, deps) {
28545
29458
  }
28546
29459
  return record;
28547
29460
  }
28548
- async function syncInterview(interview) {
28549
- const allMessages = await loadMessages(interview.sessionID);
29461
+ function syncInterview(interview) {
29462
+ const existing = activeSyncs.get(interview.id);
29463
+ if (existing) {
29464
+ return existing;
29465
+ }
29466
+ const sync = performSyncInterview(interview).finally(() => {
29467
+ activeSyncs.delete(interview.id);
29468
+ });
29469
+ activeSyncs.set(interview.id, sync);
29470
+ return sync;
29471
+ }
29472
+ async function performSyncInterview(interview) {
29473
+ const allMessages = await loadMessagesWithRetry(interview.sessionID);
28550
29474
  const interviewMessages = allMessages.slice(interview.baseMessageCount).filter(isUserVisibleMessage);
28551
29475
  const parsed = findLatestAssistantState(interviewMessages, maxQuestions);
28552
29476
  const existingDocument = await readInterviewDocument(interview);
@@ -28556,17 +29480,24 @@ function createInterviewService(ctx, config, deps) {
28556
29480
  summary: extractSummarySection(existingDocument) || fallbackState.summary
28557
29481
  };
28558
29482
  await maybeRenameWithTitle(interview, state.title);
28559
- const document = await rewriteInterviewDocument(interview, state.summary);
29483
+ let document;
29484
+ if (parsed.state) {
29485
+ document = await rewriteInterviewDocument(interview, state.summary);
29486
+ } else {
29487
+ document = await readInterviewDocument(interview);
29488
+ }
29489
+ const blocks = parseSpecBlocks(document);
28560
29490
  const interviewState = {
28561
29491
  interview,
28562
29492
  url: `${await ensureServer()}/interview/${interview.id}`,
28563
29493
  markdownPath: relativeInterviewPath(ctx.directory, interview.markdownPath),
28564
- mode: interview.status === "abandoned" ? "abandoned" : parsed.state && state.questions.length === 0 ? "completed" : sessionBusy.get(interview.sessionID) === true ? "awaiting-agent" : state.questions.length > 0 ? "awaiting-user" : parsed.latestAssistantError ? "error" : "awaiting-agent",
29494
+ mode: interview.status === "abandoned" ? "abandoned" : parsed.state && state.questions.length === 0 ? "completed" : sessionBusy.get(interview.sessionID) === true ? "awaiting-agent" : state.questions.length > 0 ? "awaiting-user" : parsed.latestAssistantError ? "error" : !parsed.state && sessionBusy.get(interview.sessionID) === false ? "completed" : "awaiting-agent",
28565
29495
  lastParseError: parsed.latestAssistantError,
28566
29496
  isBusy: sessionBusy.get(interview.sessionID) === true,
28567
29497
  summary: state.summary,
28568
29498
  questions: state.questions,
28569
- document
29499
+ document,
29500
+ blocks
28570
29501
  };
28571
29502
  if (onStateChange) {
28572
29503
  onStateChange(interview.id, interviewState);
@@ -28792,6 +29723,105 @@ function createInterviewService(ctx, config, deps) {
28792
29723
  fileCache = { items: sorted, at: Date.now() };
28793
29724
  return sorted;
28794
29725
  }
29726
+ async function submitBlockComment(interviewId, sectionTitle, comment) {
29727
+ const interview = getInterviewById(interviewId);
29728
+ if (!interview) {
29729
+ throw new Error("Interview not found");
29730
+ }
29731
+ if (interview.status === "abandoned") {
29732
+ throw new Error("Interview session is no longer active.");
29733
+ }
29734
+ if (sessionBusy.get(interview.sessionID) === true) {
29735
+ throw new Error("Interview session is busy. Wait for the current response.");
29736
+ }
29737
+ sessionBusy.set(interview.sessionID, true);
29738
+ let promptSent = false;
29739
+ try {
29740
+ const state = await getInterviewState(interviewId);
29741
+ if (state.mode === "error") {
29742
+ throw new Error("Interview is waiting for a valid agent update.");
29743
+ }
29744
+ const relativePath = relativeInterviewPath(ctx.directory, interview.markdownPath);
29745
+ const prompt = [
29746
+ `You are updating the active interview specification document at "${relativePath}".`,
29747
+ `The current document content on disk is:`,
29748
+ `\`\`\`markdown`,
29749
+ state.document,
29750
+ `\`\`\``,
29751
+ ``,
29752
+ `The user submitted specific feedback/comments for the section "${sectionTitle}".`,
29753
+ `Feedback: ${comment}`,
29754
+ ``,
29755
+ `Update the specification summary (focusing heavily on making changes to the "${sectionTitle}" section) to address this feedback.`,
29756
+ `If this feedback implies other parts of the spec should change, update them too.`,
29757
+ `Include the updated 11-section specification and ask the next highest-value clarifying questions as questions (up to ${maxQuestions} questions) if needed.`,
29758
+ `Return the same <interview_state> JSON block format as before.`
29759
+ ].join(`
29760
+ `);
29761
+ const model = sessionModel.get(interview.sessionID);
29762
+ await ctx.client.session.promptAsync({
29763
+ path: { id: interview.sessionID },
29764
+ body: {
29765
+ parts: [createInternalAgentTextPart(prompt)],
29766
+ ...model ? { model: parseModelReference(model) ?? undefined } : {}
29767
+ }
29768
+ });
29769
+ promptSent = true;
29770
+ } finally {
29771
+ if (!promptSent) {
29772
+ sessionBusy.set(interview.sessionID, false);
29773
+ }
29774
+ }
29775
+ }
29776
+ async function submitChat(interviewId, message) {
29777
+ const interview = getInterviewById(interviewId);
29778
+ if (!interview) {
29779
+ throw new Error("Interview not found");
29780
+ }
29781
+ if (interview.status === "abandoned") {
29782
+ throw new Error("Interview session is no longer active.");
29783
+ }
29784
+ if (sessionBusy.get(interview.sessionID) === true) {
29785
+ throw new Error("Interview session is busy. Wait for the current response.");
29786
+ }
29787
+ sessionBusy.set(interview.sessionID, true);
29788
+ let promptSent = false;
29789
+ try {
29790
+ const state = await getInterviewState(interviewId);
29791
+ if (state.mode === "error") {
29792
+ throw new Error("Interview is waiting for a valid agent update.");
29793
+ }
29794
+ const relativePath = relativeInterviewPath(ctx.directory, interview.markdownPath);
29795
+ const prompt = [
29796
+ `You are continuing the interview for the specification document at "${relativePath}".`,
29797
+ `The current document content on disk is:`,
29798
+ `\`\`\`markdown`,
29799
+ state.document,
29800
+ `\`\`\``,
29801
+ ``,
29802
+ `The user sent a freeform message via the dashboard chat panel:`,
29803
+ `${message}`,
29804
+ ``,
29805
+ `Process this request — it may be a request to add a new section, revise existing content, ask clarifying questions, or make structural changes.`,
29806
+ `Update the specification document accordingly and include the updated 11-section specification.`,
29807
+ `Ask up to ${maxQuestions} clarifying questions if needed using the same <interview_state> JSON block format as before.`
29808
+ ].join(`
29809
+ `);
29810
+ const model = sessionModel.get(interview.sessionID);
29811
+ await ctx.client.session.promptAsync({
29812
+ path: { id: interview.sessionID },
29813
+ body: {
29814
+ parts: [createInternalAgentTextPart(prompt)],
29815
+ ...model ? { model: parseModelReference(model) ?? undefined } : {}
29816
+ }
29817
+ });
29818
+ promptSent = true;
29819
+ } finally {
29820
+ if (!promptSent) {
29821
+ sessionBusy.set(interview.sessionID, false);
29822
+ }
29823
+ }
29824
+ }
28795
29825
  async function handleNudgeAction(interviewId, action) {
28796
29826
  const interview = getInterviewById(interviewId);
28797
29827
  if (!interview) {
@@ -28807,12 +29837,17 @@ function createInterviewService(ctx, config, deps) {
28807
29837
  let promptSent = false;
28808
29838
  try {
28809
29839
  const state = await getInterviewState(interviewId);
29840
+ const relativePath = relativeInterviewPath(ctx.directory, interview.markdownPath);
28810
29841
  let prompt;
28811
29842
  if (action === "more-questions") {
28812
29843
  prompt = [
28813
- `The user reviewed the completed interview spec and wants you to continue.`,
29844
+ `You are continuing the interview for the specification document at "${relativePath}".`,
29845
+ `The current document content on disk is:`,
29846
+ `\`\`\`markdown`,
29847
+ state.document,
29848
+ `\`\`\``,
28814
29849
  ``,
28815
- `Current spec summary: ${state.summary}`,
29850
+ `The user reviewed the completed interview spec and wants you to continue.`,
28816
29851
  ``,
28817
29852
  `Ask up to ${maxQuestions} new clarifying questions about aspects that are still unclear or underspecified.`,
28818
29853
  `Include the structured <interview_state> block with new questions.`
@@ -28820,9 +29855,13 @@ function createInterviewService(ctx, config, deps) {
28820
29855
  `);
28821
29856
  } else {
28822
29857
  prompt = [
28823
- `The user confirmed the interview spec is complete.`,
29858
+ `You are finishing the interview for the specification document at "${relativePath}".`,
29859
+ `The current document content on disk is:`,
29860
+ `\`\`\`markdown`,
29861
+ state.document,
29862
+ `\`\`\``,
28824
29863
  ``,
28825
- `Current spec summary: ${state.summary}`,
29864
+ `The user confirmed the interview spec is complete.`,
28826
29865
  ``,
28827
29866
  `Produce a final, polished version of the full spec document.`,
28828
29867
  `Do NOT include any <interview_state> block — just output the final spec as clean markdown.`,
@@ -28857,6 +29896,8 @@ function createInterviewService(ctx, config, deps) {
28857
29896
  listInterviewFiles,
28858
29897
  listInterviews,
28859
29898
  submitAnswers,
29899
+ submitBlockComment,
29900
+ submitChat,
28860
29901
  handleNudgeAction
28861
29902
  };
28862
29903
  }
@@ -28875,6 +29916,8 @@ function createInterviewManager(ctx, config) {
28875
29916
  listInterviewFiles: async () => service2.listInterviewFiles(),
28876
29917
  listInterviews: () => service2.listInterviews(),
28877
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),
28878
29921
  handleNudgeAction: async (interviewId, action) => service2.handleNudgeAction(interviewId, action),
28879
29922
  outputFolder: resolvedOutputPath,
28880
29923
  port: 0
@@ -28908,6 +29951,8 @@ function createInterviewManager(ctx, config) {
28908
29951
  continue;
28909
29952
  pollPendingAnswers(sessionID).catch(() => {});
28910
29953
  pollNudgeAction(sessionID).catch(() => {});
29954
+ pollBlockComment(sessionID).catch(() => {});
29955
+ pollChat(sessionID).catch(() => {});
28911
29956
  }
28912
29957
  }, FALLBACK_POLL_INTERVAL);
28913
29958
  fallbackTimer?.unref();
@@ -28939,7 +29984,9 @@ function createInterviewManager(ctx, config) {
28939
29984
  pendingAnswers: null,
28940
29985
  lastUpdatedAt: Date.now(),
28941
29986
  filePath: interview.markdownPath,
28942
- nudgeAction: null
29987
+ nudgeAction: null,
29988
+ pendingBlockComment: null,
29989
+ pendingChatMessage: null
28943
29990
  });
28944
29991
  dashboard?.registerSession({
28945
29992
  sessionID: interview.sessionID,
@@ -28965,122 +30012,140 @@ function createInterviewManager(ctx, config) {
28965
30012
  await new Promise((r) => setTimeout(r, 500));
28966
30013
  const retry = await probeDashboard(dashboardPort);
28967
30014
  if (!retry.alive) {
28968
- log("[interview] dashboard mode: no dashboard found, falling back to per-session server", {
28969
- port: dashboardPort
28970
- });
28971
- const perSessionServer = createInterviewServer({
28972
- getState: async (interviewId) => service.getInterviewState(interviewId),
28973
- listInterviewFiles: async () => service.listInterviewFiles(),
28974
- listInterviews: () => service.listInterviews(),
28975
- submitAnswers: async (interviewId, answers) => service.submitAnswers(interviewId, answers),
28976
- handleNudgeAction: async (interviewId, action) => service.handleNudgeAction(interviewId, action),
28977
- outputFolder: path16.join(ctx.directory, outputFolder),
28978
- port: 0
28979
- });
28980
- service.setBaseUrlResolver(() => perSessionServer.ensureStarted());
28981
- isDashboard = false;
28982
- initDone = true;
28983
- return;
30015
+ log("[interview] dashboard probe failed twice, falling back to local server");
30016
+ throw new Error("Dashboard not reachable");
28984
30017
  }
28985
30018
  }
30019
+ const creds = await readDashboardAuthFile(dashboardPort);
30020
+ if (!creds) {
30021
+ throw new Error("Dashboard credentials file missing");
30022
+ }
28986
30023
  dashboardBaseUrl = `http://127.0.0.1:${dashboardPort}`;
28987
- const auth = await readDashboardAuthFile(dashboardPort);
28988
- authToken = auth?.token ?? "";
30024
+ authToken = creds.token;
28989
30025
  service.setBaseUrlResolver(() => Promise.resolve(dashboardBaseUrl));
28990
30026
  service.setStatePushCallback((id, state) => {
28991
- pushStateViaHttp(dashboardBaseUrl, authToken, id, state).catch((err) => {
28992
- log("[interview] failed to push state to dashboard", {
28993
- error: err instanceof Error ? err.message : String(err)
30027
+ if (dashboardBaseUrl && authToken) {
30028
+ pushStateViaHttp(dashboardBaseUrl, authToken, id, state).catch((err) => {
30029
+ log("[interview] failed to push state to dashboard:", {
30030
+ error: err instanceof Error ? err.message : String(err)
30031
+ });
28994
30032
  });
28995
- });
30033
+ }
28996
30034
  });
28997
30035
  service.setOnInterviewCreated((interview) => {
28998
- registerInterviewViaHttp(dashboardBaseUrl, authToken, interview).catch((err) => {
28999
- log("[interview] failed to register interview with dashboard", {
29000
- error: err instanceof Error ? err.message : String(err)
30036
+ if (dashboardBaseUrl && authToken) {
30037
+ registerInterviewViaHttp(dashboardBaseUrl, authToken, interview).catch((err) => {
30038
+ log("[interview] failed to register interview with dashboard:", {
30039
+ error: err instanceof Error ? err.message : String(err)
30040
+ });
29001
30041
  });
29002
- });
30042
+ }
29003
30043
  });
29004
- log("[interview] dashboard mode: we are a session", {
29005
- port: dashboardPort
30044
+ log("[interview] dashboard mode: registered as session client", {
30045
+ dashboardUrl: dashboardBaseUrl
29006
30046
  });
29007
- startFallbackTimer();
29008
30047
  }
29009
30048
  } catch (err) {
29010
- log("[interview] dashboard mode init failed", {
29011
- error: err instanceof Error ? err.message : String(err)
30049
+ log("[interview] dashboard election failed or unreachable. Falling back to per-session server.", { error: err instanceof Error ? err.message : String(err) });
30050
+ isDashboard = false;
30051
+ const resolvedOutputPath = path16.join(ctx.directory, outputFolder);
30052
+ const server = createInterviewServer({
30053
+ getState: async (interviewId) => service.getInterviewState(interviewId),
30054
+ listInterviewFiles: async () => service.listInterviewFiles(),
30055
+ listInterviews: () => service.listInterviews(),
30056
+ submitAnswers: async (interviewId, answers) => service.submitAnswers(interviewId, answers),
30057
+ submitBlockComment: async (interviewId, section, comment) => service.submitBlockComment(interviewId, section, comment),
30058
+ submitChat: async (interviewId, message) => service.submitChat(interviewId, message),
30059
+ handleNudgeAction: async (interviewId, action) => service.handleNudgeAction(interviewId, action),
30060
+ outputFolder: resolvedOutputPath,
30061
+ port: 0
29012
30062
  });
30063
+ service.setBaseUrlResolver(() => server.ensureStarted());
30064
+ service.setStatePushCallback(() => {});
29013
30065
  } finally {
29014
30066
  initDone = true;
29015
30067
  }
29016
30068
  })();
29017
- async function ensureInit() {
29018
- if (!initDone)
30069
+ async function ensureInitialized() {
30070
+ if (!initDone) {
29019
30071
  await initPromise;
30072
+ }
29020
30073
  }
29021
- async function registerSessionIfNeeded(sessionID) {
29022
- if (registeredSessions.has(sessionID))
29023
- return;
29024
- registeredSessions.add(sessionID);
29025
- if (isDashboard)
30074
+ async function pollPendingAnswers(sessionID) {
30075
+ const interviewId = service.getActiveInterviewId(sessionID);
30076
+ if (!interviewId)
29026
30077
  return;
29027
30078
  try {
29028
- await fetch(`${dashboardBaseUrl}/api/register?token=${authToken}`, {
29029
- method: "POST",
29030
- headers: { "content-type": "application/json" },
29031
- body: JSON.stringify({
29032
- sessionID,
29033
- directory: ctx.directory,
29034
- pid: process.pid
29035
- }),
29036
- signal: AbortSignal.timeout(3000)
29037
- });
30079
+ const res = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/pending?token=${authToken}`, { signal: AbortSignal.timeout(3000) });
30080
+ const body = await res.json();
30081
+ if (res.ok && body.answers && body.answers.length > 0) {
30082
+ log("[interview] delivering pending answers (HTTP poll)", {
30083
+ interviewId,
30084
+ count: body.answers.length
30085
+ });
30086
+ await service.submitAnswers(interviewId, body.answers);
30087
+ }
29038
30088
  } catch (err) {
29039
- log("[interview] failed to register session with dashboard", {
30089
+ log("[interview] failed polling pending answers:", {
29040
30090
  error: err instanceof Error ? err.message : String(err)
29041
30091
  });
29042
30092
  }
29043
30093
  }
29044
- async function pollPendingAnswers(sessionID) {
30094
+ async function pollNudgeAction(sessionID) {
29045
30095
  const interviewId = service.getActiveInterviewId(sessionID);
29046
30096
  if (!interviewId)
29047
30097
  return;
29048
30098
  try {
29049
- const response = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/pending?token=${authToken}`, { signal: AbortSignal.timeout(3000) });
29050
- if (!response.ok)
29051
- return;
29052
- const data = await response.json();
29053
- if (!data.answers || data.answers.length === 0)
29054
- return;
29055
- log("[interview] delivering pending answers from dashboard", {
29056
- interviewId,
29057
- count: data.answers.length
29058
- });
29059
- await service.submitAnswers(interviewId, data.answers);
30099
+ const res = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/nudge?token=${authToken}`, { signal: AbortSignal.timeout(3000) });
30100
+ const body = await res.json();
30101
+ if (res.ok && body.action) {
30102
+ log("[interview] delivering nudge action (HTTP poll)", {
30103
+ interviewId,
30104
+ action: body.action
30105
+ });
30106
+ await service.handleNudgeAction(interviewId, body.action);
30107
+ }
29060
30108
  } catch (err) {
29061
- log("[interview] failed to poll pending answers", {
30109
+ log("[interview] failed polling nudge action:", {
29062
30110
  error: err instanceof Error ? err.message : String(err)
29063
30111
  });
29064
30112
  }
29065
30113
  }
29066
- async function pollNudgeAction(sessionID) {
30114
+ async function pollBlockComment(sessionID) {
29067
30115
  const interviewId = service.getActiveInterviewId(sessionID);
29068
30116
  if (!interviewId)
29069
30117
  return;
29070
30118
  try {
29071
- const response = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/nudge?token=${authToken}`, { signal: AbortSignal.timeout(3000) });
29072
- if (!response.ok)
29073
- return;
29074
- const data = await response.json();
29075
- if (!data.action)
29076
- return;
29077
- log("[interview] delivering nudge action from dashboard", {
29078
- interviewId,
29079
- action: data.action
30119
+ const res = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/block-comment?token=${authToken}`, { signal: AbortSignal.timeout(3000) });
30120
+ const body = await res.json();
30121
+ if (res.ok && body.section && body.comment) {
30122
+ log("[interview] delivering block comment (HTTP poll)", {
30123
+ interviewId,
30124
+ section: body.section
30125
+ });
30126
+ await service.submitBlockComment(interviewId, body.section, body.comment);
30127
+ }
30128
+ } catch (err) {
30129
+ log("[interview] failed polling block comment:", {
30130
+ error: err instanceof Error ? err.message : String(err)
29080
30131
  });
29081
- await service.handleNudgeAction(interviewId, data.action);
30132
+ }
30133
+ }
30134
+ async function pollChat(sessionID) {
30135
+ const interviewId = service.getActiveInterviewId(sessionID);
30136
+ if (!interviewId)
30137
+ return;
30138
+ try {
30139
+ const res = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/chat?token=${authToken}`, { signal: AbortSignal.timeout(3000) });
30140
+ const body = await res.json();
30141
+ if (res.ok && body.message) {
30142
+ log("[interview] delivering chat message (HTTP poll)", {
30143
+ interviewId
30144
+ });
30145
+ await service.submitChat(interviewId, body.message);
30146
+ }
29082
30147
  } catch (err) {
29083
- log("[interview] failed to poll nudge action", {
30148
+ log("[interview] failed polling chat message:", {
29084
30149
  error: err instanceof Error ? err.message : String(err)
29085
30150
  });
29086
30151
  }
@@ -29088,28 +30153,39 @@ function createInterviewManager(ctx, config) {
29088
30153
  return {
29089
30154
  registerCommand: (c) => service.registerCommand(c),
29090
30155
  handleCommandExecuteBefore: async (input, output) => {
29091
- await ensureInit();
29092
- await service.handleCommandExecuteBefore(input, output);
29093
- if (input.sessionID) {
29094
- await registerSessionIfNeeded(input.sessionID);
30156
+ await ensureInitialized();
30157
+ const sessionID = input.sessionID;
30158
+ registeredSessions.add(sessionID);
30159
+ if (!isDashboard && dashboardBaseUrl) {
30160
+ fetch(`${dashboardBaseUrl}/api/sessions?token=${authToken}`, {
30161
+ method: "POST",
30162
+ headers: { "content-type": "application/json" },
30163
+ body: JSON.stringify({
30164
+ sessionID,
30165
+ directory: ctx.directory,
30166
+ pid: process.pid
30167
+ }),
30168
+ signal: AbortSignal.timeout(3000)
30169
+ }).catch(() => {});
30170
+ startFallbackTimer();
29095
30171
  }
30172
+ await service.handleCommandExecuteBefore(input, output);
29096
30173
  },
29097
30174
  handleEvent: async (input) => {
29098
- await ensureInit();
29099
- await service.handleEvent(input);
30175
+ await ensureInitialized();
29100
30176
  const { event } = input;
29101
30177
  const properties = event.properties ?? {};
29102
- const sessionID = properties.sessionID;
29103
- if (sessionID) {
29104
- await registerSessionIfNeeded(sessionID);
29105
- }
29106
- if (event.type === "session.status" && sessionID) {
30178
+ const sessionID = properties.sessionID ?? null;
30179
+ await service.handleEvent(input);
30180
+ if (event.type === "session.status") {
29107
30181
  const status = properties.status;
29108
- if (status?.type === "idle") {
30182
+ if (sessionID && status?.type === "idle") {
29109
30183
  const interviewId = service.getActiveInterviewId(sessionID);
29110
- if (!isDashboard) {
30184
+ if (!isDashboard && dashboardBaseUrl) {
29111
30185
  await pollPendingAnswers(sessionID);
29112
30186
  await pollNudgeAction(sessionID);
30187
+ await pollBlockComment(sessionID);
30188
+ await pollChat(sessionID);
29113
30189
  } else if (interviewId && dashboard) {
29114
30190
  const pending = dashboard.consumePendingAnswers(interviewId);
29115
30191
  if (pending && pending.length > 0) {
@@ -29127,6 +30203,21 @@ function createInterviewManager(ctx, config) {
29127
30203
  });
29128
30204
  await service.handleNudgeAction(interviewId, nudge);
29129
30205
  }
30206
+ const comment = dashboard.consumeBlockComment(interviewId);
30207
+ if (comment) {
30208
+ log("[interview] delivering block comment (in-process)", {
30209
+ interviewId,
30210
+ section: comment.section
30211
+ });
30212
+ await service.submitBlockComment(interviewId, comment.section, comment.comment);
30213
+ }
30214
+ const chat = dashboard.consumeChatMessage(interviewId);
30215
+ if (chat) {
30216
+ log("[interview] delivering chat message (in-process)", {
30217
+ interviewId
30218
+ });
30219
+ await service.submitChat(interviewId, chat);
30220
+ }
29130
30221
  }
29131
30222
  if (interviewId) {
29132
30223
  service.getInterviewState(interviewId).catch((err) => {
@@ -29161,7 +30252,11 @@ function stateToEntry(interviewId, state) {
29161
30252
  pendingAnswers: null,
29162
30253
  lastUpdatedAt: Date.now(),
29163
30254
  filePath: state.interview.markdownPath,
29164
- nudgeAction: null
30255
+ nudgeAction: null,
30256
+ pendingBlockComment: null,
30257
+ pendingChatMessage: null,
30258
+ document: state.document,
30259
+ blocks: state.blocks
29165
30260
  };
29166
30261
  }
29167
30262
  async function pushStateViaHttp(dashboardUrl, token, interviewId, state) {
@@ -29180,9 +30275,23 @@ async function registerInterviewViaHttp(dashboardUrl, token, interview) {
29180
30275
  body: JSON.stringify({
29181
30276
  interviewId: interview.id,
29182
30277
  sessionID: interview.sessionID,
29183
- idea: interview.idea
30278
+ idea: interview.idea,
30279
+ mode: "awaiting-agent",
30280
+ summary: "Interview created.",
30281
+ title: interview.idea,
30282
+ questions: [],
30283
+ pendingAnswers: null,
30284
+ lastUpdatedAt: Date.now(),
30285
+ filePath: interview.markdownPath,
30286
+ nudgeAction: null,
30287
+ pendingBlockComment: null,
30288
+ pendingChatMessage: null
29184
30289
  }),
29185
30290
  signal: AbortSignal.timeout(3000)
30291
+ }).catch((err) => {
30292
+ log("[interview] failed to register interview with dashboard via HTTP:", {
30293
+ error: err instanceof Error ? err.message : String(err)
30294
+ });
29186
30295
  });
29187
30296
  }
29188
30297
  // src/mcp/context7.ts
@@ -29940,7 +31049,6 @@ function startAvailabilityCheck(config) {
29940
31049
  }
29941
31050
  }
29942
31051
  // src/multiplexer/session-manager.ts
29943
- var SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
29944
31052
  var SHARED_STATE_KEY = Symbol.for("oh-my-opencode-slim.multiplexer-session-manager.state");
29945
31053
  function getSharedState() {
29946
31054
  const globalWithState = globalThis;
@@ -29948,7 +31056,8 @@ function getSharedState() {
29948
31056
  sessions: new Map,
29949
31057
  knownSessions: new Map,
29950
31058
  spawningSessions: new Set,
29951
- closingSessions: new Map
31059
+ closingSessions: new Map,
31060
+ deferredIdleCloses: new Set
29952
31061
  };
29953
31062
  return globalWithState[SHARED_STATE_KEY];
29954
31063
  }
@@ -29962,6 +31071,7 @@ class MultiplexerSessionManager {
29962
31071
  knownSessions;
29963
31072
  spawningSessions;
29964
31073
  closingSessions;
31074
+ deferredIdleCloses;
29965
31075
  pollInterval;
29966
31076
  enabled = false;
29967
31077
  constructor(ctx, config, backgroundJobBoard) {
@@ -29971,6 +31081,7 @@ class MultiplexerSessionManager {
29971
31081
  this.knownSessions = sharedState.knownSessions;
29972
31082
  this.spawningSessions = sharedState.spawningSessions;
29973
31083
  this.closingSessions = sharedState.closingSessions;
31084
+ this.deferredIdleCloses = sharedState.deferredIdleCloses;
29974
31085
  this.directory = ctx.directory;
29975
31086
  const defaultPort = process.env.OPENCODE_PORT ?? "4096";
29976
31087
  this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`;
@@ -30052,17 +31163,13 @@ class MultiplexerSessionManager {
30052
31163
  }));
30053
31164
  return;
30054
31165
  }
30055
- const now = Date.now();
30056
31166
  this.sessions.set(sessionId, {
30057
31167
  sessionId,
30058
31168
  paneId: paneResult.paneId,
30059
31169
  parentId,
30060
31170
  title,
30061
31171
  directory,
30062
- ownerInstanceId: this.instanceId,
30063
- createdAt: now,
30064
- lastSeenAt: now,
30065
- seenInStatus: false
31172
+ ownerInstanceId: this.instanceId
30066
31173
  });
30067
31174
  log("[multiplexer-session-manager] pane spawned", {
30068
31175
  instanceId: this.instanceId,
@@ -30097,7 +31204,8 @@ class MultiplexerSessionManager {
30097
31204
  const sessionId = event.properties?.sessionID;
30098
31205
  if (!sessionId)
30099
31206
  return;
30100
- if (event.properties?.status?.type === "idle") {
31207
+ const statusType = event.properties?.status?.type;
31208
+ if (statusType === "idle") {
30101
31209
  log("[multiplexer-session-manager] session status idle received", {
30102
31210
  instanceId: this.instanceId,
30103
31211
  sessionId,
@@ -30109,7 +31217,10 @@ class MultiplexerSessionManager {
30109
31217
  await this.closeSession(sessionId, "idle");
30110
31218
  return;
30111
31219
  }
30112
- if (event.properties?.status?.type === "busy") {
31220
+ if (statusType) {
31221
+ this.deferredIdleCloses.delete(sessionId);
31222
+ if (statusType !== "busy")
31223
+ return;
30113
31224
  log("[multiplexer-session-manager] session busy event received", {
30114
31225
  instanceId: this.instanceId,
30115
31226
  sessionId,
@@ -30137,6 +31248,7 @@ class MultiplexerSessionManager {
30137
31248
  ownerInstanceId: this.sessions.get(sessionId)?.ownerInstanceId,
30138
31249
  backgroundJobState: this.backgroundJobBoard?.get(sessionId)?.state
30139
31250
  });
31251
+ this.deferredIdleCloses.delete(sessionId);
30140
31252
  await this.closeSession(sessionId, "deleted");
30141
31253
  }
30142
31254
  startPolling() {
@@ -30163,7 +31275,6 @@ class MultiplexerSessionManager {
30163
31275
  }
30164
31276
  try {
30165
31277
  const allStatuses = await this.fetchSessionStatuses();
30166
- const now = Date.now();
30167
31278
  const sessionsToClose = [];
30168
31279
  for (const [sessionId, tracked] of this.sessions.entries()) {
30169
31280
  if (tracked.ownerInstanceId !== this.instanceId) {
@@ -30176,34 +31287,16 @@ class MultiplexerSessionManager {
30176
31287
  continue;
30177
31288
  }
30178
31289
  const status = allStatuses[sessionId];
30179
- const isIdle = status?.type === "idle";
30180
- if (status) {
30181
- tracked.lastSeenAt = now;
30182
- tracked.seenInStatus = true;
30183
- tracked.missingSince = undefined;
30184
- } else if (!tracked.missingSince) {
30185
- tracked.missingSince = now;
30186
- }
30187
- const missingTooLong = !!tracked.missingSince && now - tracked.missingSince >= SESSION_MISSING_GRACE_MS;
30188
- const shouldKeepRunningBackgroundJob = (isIdle || missingTooLong) && this.isRunningBackgroundJob(sessionId);
30189
- if (isIdle || missingTooLong) {
30190
- if (shouldKeepRunningBackgroundJob) {
30191
- log("[multiplexer-session-manager] keeping running background pane", {
30192
- instanceId: this.instanceId,
30193
- sessionId,
30194
- paneId: tracked.paneId,
30195
- seenInStatus: tracked.seenInStatus
30196
- });
30197
- continue;
30198
- }
30199
- sessionsToClose.push({
30200
- sessionId,
30201
- reason: isIdle ? "idle" : "missing"
30202
- });
31290
+ if (!status)
31291
+ continue;
31292
+ if (status.type !== "idle") {
31293
+ this.deferredIdleCloses.delete(sessionId);
31294
+ continue;
30203
31295
  }
31296
+ sessionsToClose.push(sessionId);
30204
31297
  }
30205
- for (const { sessionId, reason } of sessionsToClose) {
30206
- await this.closeSession(sessionId, reason);
31298
+ for (const sessionId of sessionsToClose) {
31299
+ await this.closeSession(sessionId, "idle");
30207
31300
  }
30208
31301
  } catch (err) {
30209
31302
  log("[multiplexer-session-manager] poll error", { error: String(err) });
@@ -30228,6 +31321,7 @@ class MultiplexerSessionManager {
30228
31321
  async closeSession(sessionId, reason) {
30229
31322
  if (reason === "deleted") {
30230
31323
  this.knownSessions.delete(sessionId);
31324
+ this.deferredIdleCloses.delete(sessionId);
30231
31325
  }
30232
31326
  const existingClose = this.closingSessions.get(sessionId);
30233
31327
  if (existingClose)
@@ -30263,6 +31357,7 @@ class MultiplexerSessionManager {
30263
31357
  });
30264
31358
  }
30265
31359
  if (reason === "idle" && this.isRunningBackgroundJob(sessionId)) {
31360
+ this.deferredIdleCloses.add(sessionId);
30266
31361
  log("[multiplexer-session-manager] close skipped; background job running", {
30267
31362
  instanceId: this.instanceId,
30268
31363
  sessionId,
@@ -30272,6 +31367,7 @@ class MultiplexerSessionManager {
30272
31367
  });
30273
31368
  return;
30274
31369
  }
31370
+ this.deferredIdleCloses.delete(sessionId);
30275
31371
  this.sessions.delete(sessionId);
30276
31372
  log("[multiplexer-session-manager] closing session pane", {
30277
31373
  instanceId: this.instanceId,
@@ -30347,18 +31443,15 @@ class MultiplexerSessionManager {
30347
31443
  }));
30348
31444
  return;
30349
31445
  }
30350
- const now = Date.now();
30351
31446
  this.sessions.set(sessionId, {
30352
31447
  sessionId,
30353
31448
  paneId: paneResult.paneId,
30354
31449
  parentId: known.parentId,
30355
31450
  title: known.title,
30356
31451
  directory: known.directory,
30357
- ownerInstanceId: this.instanceId,
30358
- createdAt: now,
30359
- lastSeenAt: now,
30360
- seenInStatus: false
31452
+ ownerInstanceId: this.instanceId
30361
31453
  });
31454
+ this.deferredIdleCloses.delete(sessionId);
30362
31455
  log("[multiplexer-session-manager] pane respawned on busy", {
30363
31456
  instanceId: this.instanceId,
30364
31457
  sessionId,
@@ -30385,6 +31478,13 @@ class MultiplexerSessionManager {
30385
31478
  isRunningBackgroundJob(sessionId) {
30386
31479
  return this.backgroundJobBoard?.get(sessionId)?.state === "running";
30387
31480
  }
31481
+ async retryDeferredIdleClose(sessionId) {
31482
+ if (!this.enabled)
31483
+ return;
31484
+ if (!this.deferredIdleCloses.has(sessionId))
31485
+ return;
31486
+ await this.closeSession(sessionId, "idle");
31487
+ }
30388
31488
  async cleanup() {
30389
31489
  this.stopPolling();
30390
31490
  if (this.closingSessions.size > 0) {
@@ -30405,6 +31505,7 @@ class MultiplexerSessionManager {
30405
31505
  this.knownSessions.clear();
30406
31506
  this.spawningSessions.clear();
30407
31507
  this.closingSessions.clear();
31508
+ this.deferredIdleCloses.clear();
30408
31509
  log("[multiplexer-session-manager] cleanup complete");
30409
31510
  }
30410
31511
  }
@@ -30656,7 +31757,7 @@ function createAcpRunTool(agents = {}) {
30656
31757
  agent: z4.string().describe("Configured ACP agent name"),
30657
31758
  prompt: z4.string().describe("Task or question to send to the ACP agent"),
30658
31759
  cwd: z4.string().optional().describe("Optional absolute working directory override"),
30659
- timeout_ms: z4.number().int().min(1000).max(900000).optional().describe("Optional timeout override in milliseconds")
31760
+ timeout_ms: z4.number().int().min(0).max(MAX_ACP_TIMEOUT_MS).optional().describe("Optional timeout override in milliseconds. Set to 0 to disable the timeout.")
30660
31761
  },
30661
31762
  async execute(args, ctx) {
30662
31763
  if (ctx.agent !== args.agent) {
@@ -30692,11 +31793,12 @@ function createAcpRunTool(agents = {}) {
30692
31793
  });
30693
31794
  const timeoutMs = args.timeout_ms ?? config.timeoutMs;
30694
31795
  let timer;
30695
- const timeout = new Promise((_, reject) => timer = setTimeout(() => reject(new Error(`ACP agent '${args.agent}' timed out after ${timeoutMs}ms`)), timeoutMs));
31796
+ const timeout = timeoutMs > 0 ? new Promise((_, reject) => timer = setTimeout(() => reject(new Error(`ACP agent '${args.agent}' timed out after ${timeoutMs}ms`)), timeoutMs)) : undefined;
30696
31797
  const abort = () => client.close();
30697
31798
  ctx.abort.addEventListener("abort", abort, { once: true });
30698
31799
  try {
30699
- return await Promise.race([client.run(args.prompt), timeout]);
31800
+ const run = client.run(args.prompt);
31801
+ return timeout ? await Promise.race([run, timeout]) : await run;
30700
31802
  } finally {
30701
31803
  if (timer)
30702
31804
  clearTimeout(timer);
@@ -31791,7 +32893,8 @@ function emptySnapshot() {
31791
32893
  return {
31792
32894
  version: 1,
31793
32895
  updatedAt: Date.now(),
31794
- agentModels: {}
32896
+ agentModels: {},
32897
+ agentVariants: {}
31795
32898
  };
31796
32899
  }
31797
32900
  function parseSnapshot(value) {
@@ -31801,7 +32904,8 @@ function parseSnapshot(value) {
31801
32904
  return {
31802
32905
  version: 1,
31803
32906
  updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
31804
- agentModels: parsed.agentModels ?? {}
32907
+ agentModels: parsed.agentModels ?? {},
32908
+ agentVariants: parsed.agentVariants ?? {}
31805
32909
  };
31806
32910
  }
31807
32911
  function readTuiSnapshot() {
@@ -31835,11 +32939,19 @@ function updateSnapshot(mutator) {
31835
32939
  function recordTuiAgentModels(input) {
31836
32940
  updateSnapshot((snapshot) => {
31837
32941
  snapshot.agentModels = { ...input.agentModels };
32942
+ snapshot.agentVariants = { ...input.agentVariants ?? {} };
31838
32943
  });
31839
32944
  }
31840
32945
  function recordTuiAgentModel(input) {
31841
32946
  updateSnapshot((snapshot) => {
31842
32947
  snapshot.agentModels[input.agentName] = input.model;
32948
+ if (input.variant !== undefined) {
32949
+ if (input.variant === null) {
32950
+ delete snapshot.agentVariants[input.agentName];
32951
+ } else {
32952
+ snapshot.agentVariants[input.agentName] = input.variant;
32953
+ }
32954
+ }
31843
32955
  });
31844
32956
  }
31845
32957
 
@@ -31911,12 +33023,18 @@ function createPresetManager(ctx, config) {
31911
33023
  } catch {}
31912
33024
  const snapshot = readTuiSnapshot();
31913
33025
  const agentModels = { ...snapshot.agentModels };
33026
+ const agentVariants = { ...snapshot.agentVariants };
31914
33027
  for (const [agentName, agentConfig] of Object.entries(agentUpdates)) {
31915
33028
  if (typeof agentConfig.model === "string") {
31916
33029
  agentModels[agentName] = agentConfig.model;
31917
33030
  }
33031
+ if (typeof agentConfig.variant === "string") {
33032
+ agentVariants[agentName] = agentConfig.variant;
33033
+ } else {
33034
+ delete agentVariants[agentName];
33035
+ }
31918
33036
  }
31919
- recordTuiAgentModels({ agentModels });
33037
+ recordTuiAgentModels({ agentModels, agentVariants });
31920
33038
  activePreset = presetName;
31921
33039
  const summaryParts = [];
31922
33040
  for (const [name, cfg] of Object.entries(agentUpdates)) {
@@ -34309,6 +35427,16 @@ function createWebfetchTool(pluginCtx, options = {}) {
34309
35427
  }
34310
35428
  });
34311
35429
  }
35430
+ // src/utils/env.ts
35431
+ var TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
35432
+ var PLUGIN_DISABLE_ENV = "OH_MY_OPENCODE_SLIM_DISABLE";
35433
+ function isTruthyEnvValue(value) {
35434
+ return TRUTHY_ENV_VALUES.has(value?.trim().toLowerCase() ?? "");
35435
+ }
35436
+ function isPluginDisabledByEnv(env = process.env) {
35437
+ return isTruthyEnvValue(env[PLUGIN_DISABLE_ENV]);
35438
+ }
35439
+
34312
35440
  // src/utils/subagent-depth.ts
34313
35441
  class SubagentDepthTracker {
34314
35442
  depthBySession = new Map;
@@ -34399,6 +35527,10 @@ async function probeJSDOM() {
34399
35527
  var OhMyOpenCodeLite = async (ctx) => {
34400
35528
  const sessionId = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
34401
35529
  initLogger(sessionId);
35530
+ if (isPluginDisabledByEnv()) {
35531
+ log("[plugin] disabled by OH_MY_OPENCODE_SLIM_DISABLE");
35532
+ return {};
35533
+ }
34402
35534
  let config;
34403
35535
  let disabledAgents;
34404
35536
  let agentDefs;
@@ -34431,6 +35563,7 @@ var OhMyOpenCodeLite = async (ctx) => {
34431
35563
  let cancelTaskTools;
34432
35564
  let acpRunTools;
34433
35565
  let webfetch;
35566
+ let tools;
34434
35567
  let rewriteDisplayNameMentions;
34435
35568
  let toolCount = 0;
34436
35569
  try {
@@ -34482,6 +35615,9 @@ var OhMyOpenCodeLite = async (ctx) => {
34482
35615
  readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8
34483
35616
  });
34484
35617
  multiplexerSessionManager = new MultiplexerSessionManager(ctx, multiplexerConfig, backgroundJobBoard);
35618
+ backgroundJobBoard.setTerminalStateListener((taskID) => {
35619
+ multiplexerSessionManager.retryDeferredIdleClose(taskID);
35620
+ });
34485
35621
  autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
34486
35622
  autoUpdate: config.autoUpdate ?? true,
34487
35623
  companion: config.companion
@@ -34514,7 +35650,19 @@ var OhMyOpenCodeLite = async (ctx) => {
34514
35650
  backgroundJobBoard,
34515
35651
  shouldManageSession: (sessionID) => sessionAgentMap.get(sessionID) === "orchestrator"
34516
35652
  });
34517
- toolCount = Object.keys(councilTools).length + Object.keys(cancelTaskTools).length + Object.keys(acpRunTools).length + 1 + 2;
35653
+ tools = {
35654
+ ...councilTools,
35655
+ ...cancelTaskTools,
35656
+ ...acpRunTools,
35657
+ webfetch,
35658
+ ast_grep_search,
35659
+ ast_grep_replace
35660
+ };
35661
+ if (config.disabled_tools && config.disabled_tools.length > 0) {
35662
+ const disabledTools = new Set(config.disabled_tools);
35663
+ tools = Object.fromEntries(Object.entries(tools).filter(([name]) => !disabledTools.has(name)));
35664
+ }
35665
+ toolCount = Object.keys(tools).length;
34518
35666
  } catch (err) {
34519
35667
  log("[plugin] FATAL: init failed", String(err));
34520
35668
  await appLog(ctx, "error", `INIT FAILED: ${String(err)}. Report at github.com/alvinunreal/oh-my-opencode-slim/issues/310`);
@@ -34567,17 +35715,25 @@ var OhMyOpenCodeLite = async (ctx) => {
34567
35715
  }
34568
35716
  }
34569
35717
  companionManager.onLoad();
35718
+ function resolveTuiVariantForModel(agentName, model) {
35719
+ const configEntry = config.agents?.[agentName];
35720
+ const defaultVariant = typeof configEntry?.variant === "string" ? configEntry.variant : undefined;
35721
+ const chainMatches = modelArrayMap[agentName]?.filter((entry) => entry.id === model);
35722
+ if (chainMatches) {
35723
+ if (chainMatches.length === 1) {
35724
+ return chainMatches[0].variant ?? defaultVariant;
35725
+ }
35726
+ return;
35727
+ }
35728
+ if (typeof configEntry?.model === "string" && configEntry.model === model && defaultVariant) {
35729
+ return defaultVariant;
35730
+ }
35731
+ return;
35732
+ }
34570
35733
  return {
34571
35734
  name: "oh-my-opencode-slim",
34572
35735
  agent: agents,
34573
- tool: {
34574
- ...councilTools,
34575
- ...cancelTaskTools,
34576
- ...acpRunTools,
34577
- webfetch,
34578
- ast_grep_search,
34579
- ast_grep_replace
34580
- },
35736
+ tool: tools,
34581
35737
  mcp: mcps,
34582
35738
  config: async (opencodeConfig) => {
34583
35739
  if (config.setDefaultAgent !== false && !opencodeConfig.default_agent) {
@@ -34703,14 +35859,22 @@ var OhMyOpenCodeLite = async (ctx) => {
34703
35859
  }
34704
35860
  }
34705
35861
  const tuiAgentModels = {};
35862
+ const tuiAgentVariants = {};
34706
35863
  for (const agentDef of agentDefs) {
34707
35864
  if (agentDef.name === "councillor")
34708
35865
  continue;
34709
35866
  const entry = configAgent[agentDef.name];
34710
35867
  const resolvedModel = typeof entry?.model === "string" ? entry.model : runtimeChains[agentDef.name]?.[0] ? runtimeChains[agentDef.name][0] : typeof agentDef.config.model === "string" ? agentDef.config.model : undefined;
35868
+ const resolvedVariant = typeof entry?.variant === "string" ? entry.variant : typeof agentDef.config.variant === "string" ? agentDef.config.variant : undefined;
34711
35869
  tuiAgentModels[agentDef.name] = resolvedModel ?? "default";
35870
+ if (resolvedVariant) {
35871
+ tuiAgentVariants[agentDef.name] = resolvedVariant;
35872
+ }
34712
35873
  }
34713
- recordTuiAgentModels({ agentModels: tuiAgentModels });
35874
+ recordTuiAgentModels({
35875
+ agentModels: tuiAgentModels,
35876
+ agentVariants: tuiAgentVariants
35877
+ });
34714
35878
  const configMcp = opencodeConfig.mcp;
34715
35879
  if (!configMcp) {
34716
35880
  opencodeConfig.mcp = { ...mcps };
@@ -34748,10 +35912,16 @@ var OhMyOpenCodeLite = async (ctx) => {
34748
35912
  const event = input.event;
34749
35913
  if (event.type === "message.updated") {
34750
35914
  const info = event.properties?.info;
34751
- if (typeof info?.agent === "string" && typeof info.providerID === "string" && typeof info.modelID === "string") {
35915
+ const providerID = typeof info?.providerID === "string" ? info.providerID : typeof info?.model?.providerID === "string" ? info.model.providerID : undefined;
35916
+ const modelID = typeof info?.modelID === "string" ? info.modelID : typeof info?.model?.modelID === "string" ? info.model.modelID : undefined;
35917
+ if (typeof info?.agent === "string" && providerID && modelID) {
35918
+ const agentName = resolveRuntimeAgentName(config, info.agent);
35919
+ const model = `${providerID}/${modelID}`;
35920
+ const variant = resolveTuiVariantForModel(agentName, model);
34752
35921
  recordTuiAgentModel({
34753
- agentName: resolveRuntimeAgentName(config, info.agent),
34754
- model: `${info.providerID}/${info.modelID}`
35922
+ agentName,
35923
+ model,
35924
+ variant: variant ?? null
34755
35925
  });
34756
35926
  }
34757
35927
  }