oh-my-opencode-slim 2.0.4 → 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(),
@@ -19352,10 +19362,31 @@ function createOrchestratorAgent(model, customPrompt, customAppendPrompt, disabl
19352
19362
  return definition;
19353
19363
  }
19354
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
+
19355
19386
  // src/agents/council.ts
19356
19387
  var COUNCIL_AGENT_PROMPT = `You are the Council agent — a multi-LLM orchestration system that runs consensus across multiple models.
19357
19388
 
19358
- **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.
19359
19390
 
19360
19391
  **When to use**:
19361
19392
  - When invoked by a user with a request
@@ -19416,7 +19447,11 @@ function createCouncilAgent(model, customPrompt, customAppendPrompt) {
19416
19447
  description: "Multi-LLM council agent that synthesizes responses from multiple models for higher-quality outputs",
19417
19448
  config: {
19418
19449
  temperature: 0.1,
19419
- prompt
19450
+ prompt,
19451
+ permission: {
19452
+ ...createReadOnlyAgentPermission(),
19453
+ council_session: "allow"
19454
+ }
19420
19455
  }
19421
19456
  };
19422
19457
  if (model) {
@@ -19525,17 +19560,7 @@ function createCouncillorAgent(model, customPrompt, customAppendPrompt) {
19525
19560
  model,
19526
19561
  temperature: 0.2,
19527
19562
  prompt,
19528
- permission: {
19529
- "*": "deny",
19530
- question: "deny",
19531
- read: "allow",
19532
- glob: "allow",
19533
- grep: "allow",
19534
- lsp: "allow",
19535
- list: "allow",
19536
- codesearch: "allow",
19537
- ast_grep_search: "allow"
19538
- }
19563
+ permission: createReadOnlyAgentPermission()
19539
19564
  }
19540
19565
  };
19541
19566
  }
@@ -19875,7 +19900,38 @@ function normalizeDisplayName(displayName) {
19875
19900
  const trimmed = displayName.trim();
19876
19901
  return trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
19877
19902
  }
19878
- 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) {
19879
19935
  const description = config.description ?? `External ACP agent '${name}' via ${config.command}`;
19880
19936
  const prompt = config.prompt ?? [
19881
19937
  `You are the ${name} ACP wrapper agent.`,
@@ -19889,7 +19945,7 @@ function buildAcpAgentDefinition(name, config) {
19889
19945
  name,
19890
19946
  description,
19891
19947
  config: {
19892
- model: config.wrapperModel ?? DEFAULT_MODELS.fixer ?? DEFAULT_MODELS.librarian ?? DEFAULT_MODELS.orchestrator ?? DEFAULT_MODELS.oracle,
19948
+ model: config.wrapperModel ?? fallbackModel ?? DEFAULT_MODELS.oracle,
19893
19949
  temperature: 0,
19894
19950
  prompt,
19895
19951
  permission: {
@@ -19954,10 +20010,11 @@ function hasCustomAgentModel(override) {
19954
20010
  }
19955
20011
  function buildCustomAgentDefinition(name, override, filePrompt, fileAppendPrompt) {
19956
20012
  const basePrompt = override.prompt ?? `You are the ${name} specialist.`;
20013
+ const primaryModel = getPrimaryModelFromOverride(override);
19957
20014
  return {
19958
20015
  name,
19959
20016
  config: {
19960
- model: typeof override.model === "string" ? override.model : DEFAULT_MODELS.orchestrator ?? DEFAULT_MODELS.oracle,
20017
+ model: primaryModel ?? DEFAULT_MODELS.oracle,
19961
20018
  temperature: 0.2,
19962
20019
  prompt: resolvePrompt(basePrompt, filePrompt, fileAppendPrompt)
19963
20020
  }
@@ -19974,9 +20031,9 @@ function injectDisplayNames(orchestrator, nameMap) {
19974
20031
  }
19975
20032
  orchestrator.config.prompt = prompt;
19976
20033
  }
19977
- function applyDefaultPermissions(agent, configuredSkills) {
20034
+ function applyDefaultPermissions(agent, configuredSkills, disabledSkills) {
19978
20035
  const existing = agent.config.permission ?? {};
19979
- const skillPermissions = getSkillPermissionsForAgent(agent.name, configuredSkills);
20036
+ const skillPermissions = getSkillPermissionsForAgent(agent.name, configuredSkills, disabledSkills);
19980
20037
  const questionPerm = existing.question === "deny" ? "deny" : "allow";
19981
20038
  const councilSessionPerm = COUNCIL_TOOL_ALLOWED_AGENTS.has(agent.name) ? existing.council_session ?? "allow" : "deny";
19982
20039
  const cancelTaskPerm = CANCEL_TASK_ALLOWED_AGENTS.has(agent.name) ? existing.cancel_task ?? "allow" : "deny";
@@ -20009,6 +20066,7 @@ function createAgents(config) {
20009
20066
  if (!config?.council) {
20010
20067
  disabled.add("council");
20011
20068
  }
20069
+ const primaryModel = getConfigPrimaryModel(config);
20012
20070
  const getModelForAgent = (name) => {
20013
20071
  if (name === "fixer" && !getAgentOverride(config, "fixer")?.model) {
20014
20072
  const librarianOverride = getAgentOverride(config, "librarian")?.model;
@@ -20019,9 +20077,9 @@ function createAgents(config) {
20019
20077
  } else {
20020
20078
  librarianModel = librarianOverride;
20021
20079
  }
20022
- return librarianModel ?? DEFAULT_MODELS.librarian;
20080
+ return librarianModel ?? primaryModel ?? DEFAULT_MODELS.librarian;
20023
20081
  }
20024
- return DEFAULT_MODELS[name];
20082
+ return primaryModel ?? DEFAULT_MODELS[name];
20025
20083
  };
20026
20084
  const protoSubAgents = Object.entries(SUBAGENT_FACTORIES).filter(([name]) => !disabled.has(name)).map(([name, factory]) => {
20027
20085
  const customPrompts = loadAgentPrompt(name, config?.preset);
@@ -20063,14 +20121,14 @@ function createAgents(config) {
20063
20121
  const acp = config?.acpAgents?.[name];
20064
20122
  if (!acp)
20065
20123
  throw new Error(`ACP agent '${name}' is missing config`);
20066
- return buildAcpAgentDefinition(name, acp);
20124
+ return buildAcpAgentDefinition(name, acp, primaryModel);
20067
20125
  });
20068
20126
  const builtInSubAgents = protoSubAgents.map((agent) => {
20069
20127
  const override = getAgentOverride(config, agent.name);
20070
20128
  if (override) {
20071
20129
  applyOverrides(agent, override);
20072
20130
  }
20073
- applyDefaultPermissions(agent, override?.skills);
20131
+ applyDefaultPermissions(agent, override?.skills, config?.disabled_skills);
20074
20132
  return agent;
20075
20133
  });
20076
20134
  const legacyMasterModel = config?.council?._legacyMasterModel;
@@ -20085,11 +20143,11 @@ function createAgents(config) {
20085
20143
  if (override) {
20086
20144
  applyOverrides(agent, override);
20087
20145
  }
20088
- applyDefaultPermissions(agent, override?.skills);
20146
+ applyDefaultPermissions(agent, override?.skills, config?.disabled_skills);
20089
20147
  return agent;
20090
20148
  });
20091
20149
  const acpSubAgents = protoAcpAgents.map((agent) => {
20092
- applyDefaultPermissions(agent);
20150
+ applyDefaultPermissions(agent, undefined, config?.disabled_skills);
20093
20151
  return agent;
20094
20152
  });
20095
20153
  const allSubAgents = [
@@ -20101,7 +20159,7 @@ function createAgents(config) {
20101
20159
  const orchestratorModel = orchestratorOverride?.model ?? DEFAULT_MODELS.orchestrator;
20102
20160
  const orchestratorPrompts = loadAgentPrompt("orchestrator", config?.preset);
20103
20161
  const orchestrator = createOrchestratorAgent(orchestratorModel, orchestratorPrompts.prompt, orchestratorPrompts.appendPrompt, disabled);
20104
- applyDefaultPermissions(orchestrator, orchestratorOverride?.skills);
20162
+ applyDefaultPermissions(orchestrator, orchestratorOverride?.skills, config?.disabled_skills);
20105
20163
  if (orchestratorOverride) {
20106
20164
  applyOverrides(orchestrator, orchestratorOverride);
20107
20165
  }
@@ -21024,7 +21082,15 @@ class CouncilManager {
21024
21082
  const body = {
21025
21083
  agent: options.agent,
21026
21084
  model: modelRef,
21027
- 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
+ },
21028
21094
  parts: [{ type: "text", text: options.promptText }]
21029
21095
  };
21030
21096
  if (options.variant) {
@@ -23551,6 +23617,7 @@ var AGENT_PREFIX = {
23551
23617
  class BackgroundJobBoard {
23552
23618
  jobs = new Map;
23553
23619
  counters = new Map;
23620
+ terminalStateListener;
23554
23621
  maxReusablePerAgent;
23555
23622
  readContextMinLines;
23556
23623
  readContextMaxFiles;
@@ -23559,6 +23626,9 @@ class BackgroundJobBoard {
23559
23626
  this.readContextMinLines = options.readContextMinLines ?? 10;
23560
23627
  this.readContextMaxFiles = options.readContextMaxFiles ?? 8;
23561
23628
  }
23629
+ setTerminalStateListener(listener) {
23630
+ this.terminalStateListener = listener;
23631
+ }
23562
23632
  registerLaunch(input) {
23563
23633
  const now = input.now ?? Date.now();
23564
23634
  const existing = this.jobs.get(input.taskID);
@@ -23616,6 +23686,7 @@ class BackgroundJobBoard {
23616
23686
  }
23617
23687
  const now = input.now ?? Date.now();
23618
23688
  const terminal = TERMINAL_STATES.has(input.state);
23689
+ const notifyTerminal = terminal && !TERMINAL_STATES.has(existing.state);
23619
23690
  const updated = {
23620
23691
  ...existing,
23621
23692
  state: input.state,
@@ -23630,6 +23701,8 @@ class BackgroundJobBoard {
23630
23701
  };
23631
23702
  this.jobs.set(input.taskID, updated);
23632
23703
  this.trimReusable(input.taskID);
23704
+ if (notifyTerminal)
23705
+ this.terminalStateListener?.(input.taskID);
23633
23706
  return updated;
23634
23707
  }
23635
23708
  updateFromStatusOutput(output) {
@@ -23694,6 +23767,7 @@ class BackgroundJobBoard {
23694
23767
  if (TERMINAL_STATES.has(existing.state))
23695
23768
  return existing;
23696
23769
  }
23770
+ const notifyTerminal = !TERMINAL_STATES.has(existing.state) && existing.state !== "reconciled";
23697
23771
  const summary = normalizeCancelReason(reason);
23698
23772
  const updated = {
23699
23773
  ...existing,
@@ -23709,6 +23783,8 @@ class BackgroundJobBoard {
23709
23783
  lastStatusError: undefined
23710
23784
  };
23711
23785
  this.jobs.set(taskID, updated);
23786
+ if (notifyTerminal)
23787
+ this.terminalStateListener?.(taskID);
23712
23788
  return updated;
23713
23789
  }
23714
23790
  get(taskID) {
@@ -24161,7 +24237,7 @@ function createFilterAvailableSkillsHook(_ctx, config) {
24161
24237
  return cached;
24162
24238
  }
24163
24239
  const configuredSkills = getAgentOverride(config, agentName)?.skills;
24164
- const permissionRules = getSkillPermissionsForAgent(agentName, configuredSkills);
24240
+ const permissionRules = getSkillPermissionsForAgent(agentName, configuredSkills, config.disabled_skills);
24165
24241
  permissionRulesByAgent.set(agentName, permissionRules);
24166
24242
  return permissionRules;
24167
24243
  };
@@ -25435,26 +25511,25 @@ function slugify(value) {
25435
25511
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
25436
25512
  }
25437
25513
  function extractHistorySection(document) {
25438
- const marker = `## Q&A history
25439
-
25440
- `;
25441
- const index = document.indexOf(marker);
25442
- 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();
25443
25519
  }
25444
25520
  function extractSummarySection(document) {
25445
25521
  const marker = `## Current spec
25446
25522
 
25447
25523
  `;
25448
- const historyMarker = `
25449
-
25450
- ## Q&A history`;
25451
25524
  const start = document.indexOf(marker);
25452
25525
  if (start < 0) {
25453
25526
  return "";
25454
25527
  }
25455
25528
  const summaryStart = start + marker.length;
25456
- const summaryEnd = document.indexOf(historyMarker, summaryStart);
25457
- 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();
25458
25533
  }
25459
25534
  function extractTitle(document) {
25460
25535
  const match = document.match(/^#\s+(.+)$/m);
@@ -25463,11 +25538,19 @@ function extractTitle(document) {
25463
25538
  function buildInterviewDocument(idea, summary, history, meta) {
25464
25539
  const normalizedSummary = summary.trim() || "Waiting for interview answers.";
25465
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"];
25466
25545
  const frontmatter = meta?.sessionID ? [
25467
25546
  "---",
25468
25547
  `sessionID: ${meta.sessionID}`,
25469
25548
  `baseMessageCount: ${meta.baseMessageCount ?? 0}`,
25470
- `updatedAt: ${new Date().toISOString()}`,
25549
+ `updatedAt: ${now.toISOString()}`,
25550
+ `version: 1.0`,
25551
+ `date_created: ${dateStr}`,
25552
+ `owner: ${owner}`,
25553
+ `tags: [${tags.join(", ")}]`,
25471
25554
  "---",
25472
25555
  ""
25473
25556
  ].join(`
@@ -25549,6 +25632,55 @@ A: ${answer.answer.trim()}` : null;
25549
25632
  baseMessageCount: record.baseMessageCount
25550
25633
  }), "utf8");
25551
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
+ }
25552
25684
 
25553
25685
  // src/interview/helpers.ts
25554
25686
  function sendJson(response, status, value) {
@@ -26038,6 +26170,65 @@ function renderInterviewPage(interviewId, resumeSlug) {
26038
26170
  <style>
26039
26171
  ${sharedStyles()}
26040
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
+ }
26041
26232
  h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 12px; line-height: 1.2; }
26042
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; }
26043
26234
  h3 { font-size: 18px; font-weight: 500; margin-bottom: 16px; line-height: 1.4; }
@@ -26403,9 +26594,102 @@ function renderInterviewPage(interviewId, resumeSlug) {
26403
26594
  background: rgba(52,211,153,0.05);
26404
26595
  }
26405
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; }
26406
26689
  </style>
26407
26690
  </head>
26408
26691
  <body>
26692
+ <nav id="tocSidebar" class="toc-sidebar"></nav>
26409
26693
  <div class="wrap">
26410
26694
  <a href="/" class="back-link">← All Interviews</a>
26411
26695
  <div class="brand-header">
@@ -26446,6 +26730,11 @@ function renderInterviewPage(interviewId, resumeSlug) {
26446
26730
  <div class="status-text" id="loadingText">Processing...</div>
26447
26731
  </div>
26448
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
+
26449
26738
  <script>
26450
26739
  ${clipboardHelperJs()}
26451
26740
  const interviewId = ${JSON.stringify(interviewId).replace(/</g, "\\u003c")};
@@ -26815,21 +27104,94 @@ function renderInterviewPage(interviewId, resumeSlug) {
26815
27104
  return paragraphs.map(p => \`<p>\${p.replace(/\\n/g, '<br>')}</p>\`).join('');
26816
27105
  }
26817
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
+
26818
27135
  function renderCompletedView(data) {
26819
27136
  const container = document.getElementById('questions');
26820
- const { spec, qaPairs } = parseDocument(data.document);
26821
27137
  const frag = document.createDocumentFragment();
26822
27138
 
26823
- // Spec section
26824
- if (spec) {
27139
+ const blocks = data.blocks || [];
27140
+ if (blocks.length > 0) {
26825
27141
  const specLabel = document.createElement('div');
26826
27142
  specLabel.className = 'section-label';
26827
- specLabel.textContent = 'Current Spec';
27143
+ specLabel.textContent = 'Interactive Specifications (11 Sections)';
26828
27144
  frag.appendChild(specLabel);
26829
- const specBlock = document.createElement('div');
26830
- specBlock.className = 'spec-block';
26831
- specBlock.innerHTML = simpleMarkdown(spec);
26832
- 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
+ }
26833
27195
  }
26834
27196
 
26835
27197
  // Q&A section
@@ -26838,6 +27200,7 @@ function renderInterviewPage(interviewId, resumeSlug) {
26838
27200
  qaLabel.textContent = 'Q&A History';
26839
27201
  frag.appendChild(qaLabel);
26840
27202
 
27203
+ const { qaPairs } = parseDocument(data.document);
26841
27204
  if (!qaPairs.length) {
26842
27205
  const empty = document.createElement('p');
26843
27206
  empty.className = 'qa-empty';
@@ -26886,6 +27249,103 @@ function renderInterviewPage(interviewId, resumeSlug) {
26886
27249
  container.replaceChildren(frag);
26887
27250
  }
26888
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
+
26889
27349
  function renderQuestions(questions) {
26890
27350
  const sig = JSON.stringify([questions, state.data?.mode]);
26891
27351
  const container = document.getElementById('questions');
@@ -27042,6 +27502,19 @@ function renderInterviewPage(interviewId, resumeSlug) {
27042
27502
 
27043
27503
  renderQuestions(data.questions || []);
27044
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
+ }
27045
27518
  }
27046
27519
 
27047
27520
  async function refresh() {
@@ -27152,19 +27625,65 @@ function renderInterviewPage(interviewId, resumeSlug) {
27152
27625
  document.getElementById('moreQuestionsBtn').addEventListener('click', () => sendNudge('more-questions'));
27153
27626
  document.getElementById('confirmCompleteBtn').addEventListener('click', () => sendNudge('confirm-complete'));
27154
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
+
27155
27667
  function schedulePoll() {
27156
- 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 () => {
27157
27675
  try { await refresh(); } catch (_) {}
27158
- // Stop polling for terminal states
27159
- const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
27160
- if (!terminalModes.includes(state.data?.mode)) schedulePoll();
27676
+ pollFallbackTimer = null;
27677
+ if (!sseConnected) {
27678
+ schedulePoll();
27679
+ }
27161
27680
  }, 2500);
27162
27681
  }
27163
27682
 
27164
27683
  refresh().catch((error) => {
27165
27684
  document.getElementById('submitStatus').textContent = error.message || 'Failed to load interview.';
27166
27685
  });
27167
- schedulePoll();
27686
+ connectSse();
27168
27687
  </script>
27169
27688
  </body>
27170
27689
  </html>`;
@@ -27233,6 +27752,51 @@ function createDashboardServer(config) {
27233
27752
  let baseUrl = null;
27234
27753
  const sessions = new Map;
27235
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
+ }
27236
27800
  const TERMINAL_MODES = new Set([
27237
27801
  "abandoned",
27238
27802
  "completed",
@@ -27391,7 +27955,9 @@ function createDashboardServer(config) {
27391
27955
  pendingAnswers: null,
27392
27956
  lastUpdatedAt: fm.updatedAt ? new Date(fm.updatedAt).getTime() : Date.now(),
27393
27957
  filePath: path14.join(interviewDir, entry),
27394
- nudgeAction: null
27958
+ nudgeAction: null,
27959
+ pendingBlockComment: null,
27960
+ pendingChatMessage: null
27395
27961
  });
27396
27962
  if (!sessions.has(fm.sessionID)) {
27397
27963
  sessions.set(fm.sessionID, {
@@ -27572,7 +28138,9 @@ function createDashboardServer(config) {
27572
28138
  pendingAnswers: null,
27573
28139
  lastUpdatedAt: Date.now(),
27574
28140
  filePath: "",
27575
- nudgeAction: null
28141
+ nudgeAction: null,
28142
+ pendingBlockComment: null,
28143
+ pendingChatMessage: null
27576
28144
  });
27577
28145
  dedupRecovered(interviewId, stateCache);
27578
28146
  fileCache = null;
@@ -27583,6 +28151,48 @@ function createDashboardServer(config) {
27583
28151
  });
27584
28152
  return;
27585
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
+ }
27586
28196
  if (request.method === "POST" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/state")) {
27587
28197
  const interviewId = pathname.replace("/api/interviews/", "").replace("/state", "");
27588
28198
  if (!interviewId || !isValidId(interviewId)) {
@@ -27609,10 +28219,15 @@ function createDashboardServer(config) {
27609
28219
  existing.questions = state.questions;
27610
28220
  if (state.filePath)
27611
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;
27612
28226
  existing.lastUpdatedAt = Date.now();
27613
28227
  dedupRecovered(interviewId, stateCache);
28228
+ broadcastSse(interviewId, existing);
27614
28229
  } else {
27615
- stateCache.set(interviewId, {
28230
+ const entry = {
27616
28231
  interviewId,
27617
28232
  sessionID: state.sessionID ?? "",
27618
28233
  idea: state.idea ?? "",
@@ -27623,8 +28238,14 @@ function createDashboardServer(config) {
27623
28238
  pendingAnswers: null,
27624
28239
  lastUpdatedAt: Date.now(),
27625
28240
  filePath: state.filePath ?? "",
27626
- nudgeAction: null
27627
- });
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);
27628
28249
  }
27629
28250
  sendJson(response, 200, { status: "ok" });
27630
28251
  return;
@@ -27682,7 +28303,8 @@ function createDashboardServer(config) {
27682
28303
  questions: entry.questions,
27683
28304
  document,
27684
28305
  lastUpdatedAt: entry.lastUpdatedAt,
27685
- nudgeAction: entry.nudgeAction
28306
+ nudgeAction: entry.nudgeAction,
28307
+ blocks: parseSpecBlocks(document)
27686
28308
  });
27687
28309
  return;
27688
28310
  }
@@ -27717,12 +28339,12 @@ function createDashboardServer(config) {
27717
28339
  sendJson(response, 200, { status: "ok" });
27718
28340
  return;
27719
28341
  }
27720
- if (request.method === "GET" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/pending")) {
28342
+ if (request.method === "POST" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/block-comment")) {
27721
28343
  if (!isAuthenticated(request)) {
27722
28344
  sendJson(response, 401, { error: "Unauthorized" });
27723
28345
  return;
27724
28346
  }
27725
- const interviewId = pathname.replace("/api/interviews/", "").replace("/pending", "");
28347
+ const interviewId = pathname.replace("/api/interviews/", "").replace("/block-comment", "");
27726
28348
  if (!isValidId(interviewId)) {
27727
28349
  sendJson(response, 400, { error: "Invalid interview ID" });
27728
28350
  return;
@@ -27732,21 +28354,32 @@ function createDashboardServer(config) {
27732
28354
  sendJson(response, 404, { error: "Interview not found" });
27733
28355
  return;
27734
28356
  }
27735
- const answers = entry.pendingAnswers;
27736
- if (answers) {
27737
- entry.pendingAnswers = null;
28357
+ let body;
28358
+ try {
28359
+ body = await readJsonBody(request);
28360
+ } catch {
28361
+ sendJson(response, 400, { error: "Invalid JSON" });
28362
+ return;
27738
28363
  }
27739
- sendJson(response, 200, {
27740
- answers
27741
- });
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" });
27742
28375
  return;
27743
28376
  }
27744
- if (request.method === "POST" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/nudge")) {
28377
+ if (request.method === "GET" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/block-comment")) {
27745
28378
  if (!isAuthenticated(request)) {
27746
28379
  sendJson(response, 401, { error: "Unauthorized" });
27747
28380
  return;
27748
28381
  }
27749
- const interviewId = pathname.replace("/api/interviews/", "").replace("/nudge", "");
28382
+ const interviewId = pathname.replace("/api/interviews/", "").replace("/block-comment", "");
27750
28383
  if (!isValidId(interviewId)) {
27751
28384
  sendJson(response, 400, { error: "Invalid interview ID" });
27752
28385
  return;
@@ -27756,10 +28389,113 @@ function createDashboardServer(config) {
27756
28389
  sendJson(response, 404, { error: "Interview not found" });
27757
28390
  return;
27758
28391
  }
27759
- let body;
27760
- try {
27761
- body = await readJsonBody(request);
27762
- } catch {
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
28477
+ });
28478
+ return;
28479
+ }
28480
+ if (request.method === "POST" && pathname.startsWith("/api/interviews/") && pathname.endsWith("/nudge")) {
28481
+ if (!isAuthenticated(request)) {
28482
+ sendJson(response, 401, { error: "Unauthorized" });
28483
+ return;
28484
+ }
28485
+ const interviewId = pathname.replace("/api/interviews/", "").replace("/nudge", "");
28486
+ if (!isValidId(interviewId)) {
28487
+ sendJson(response, 400, { error: "Invalid interview ID" });
28488
+ return;
28489
+ }
28490
+ const entry = stateCache.get(interviewId);
28491
+ if (!entry) {
28492
+ sendJson(response, 404, { error: "Interview not found" });
28493
+ return;
28494
+ }
28495
+ let body;
28496
+ try {
28497
+ body = await readJsonBody(request);
28498
+ } catch {
27763
28499
  sendJson(response, 400, { error: "Invalid JSON" });
27764
28500
  return;
27765
28501
  }
@@ -27886,9 +28622,20 @@ function createDashboardServer(config) {
27886
28622
  entry.pendingAnswers ??= existing.pendingAnswers;
27887
28623
  if (existing.nudgeAction)
27888
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
+ }
27889
28635
  }
27890
28636
  stateCache.set(entry.interviewId, entry);
27891
28637
  dedupRecovered(entry.interviewId, stateCache);
28638
+ broadcastSse(entry.interviewId, entry);
27892
28639
  },
27893
28640
  getState: (id) => stateCache.get(id),
27894
28641
  storeAnswers: (id, answers) => {
@@ -27916,6 +28663,22 @@ function createDashboardServer(config) {
27916
28663
  entry.nudgeAction = null;
27917
28664
  return action;
27918
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
+ },
27919
28682
  authToken,
27920
28683
  discoverSessionDirectories,
27921
28684
  addManualFolder: (dir) => {
@@ -28103,6 +28866,50 @@ function createInterviewServer(deps) {
28103
28866
  }
28104
28867
  return;
28105
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
+ }
28106
28913
  const nudgeMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/nudge$/);
28107
28914
  if (request.method === "POST" && nudgeMatch) {
28108
28915
  try {
@@ -28217,6 +29024,39 @@ function normalizeQuestion(value, index) {
28217
29024
  suggested: typeof result.data.suggested === "string" && result.data.suggested.trim().length > 0 ? result.data.suggested.trim() : undefined
28218
29025
  };
28219
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
+ }
28220
29060
  function flattenMessage(message) {
28221
29061
  return (message.parts ?? []).map((part) => part.text ?? "").join(`
28222
29062
  `).trim();
@@ -28233,8 +29073,14 @@ function parseAssistantState(text, maxQuestions = 2) {
28233
29073
  if (!match) {
28234
29074
  return { state: null };
28235
29075
  }
29076
+ let rawJson = match[1].trim();
28236
29077
  try {
28237
- 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);
28238
29084
  const parsed = RawInterviewStateSchema.parse(raw);
28239
29085
  const summary = typeof parsed.summary === "string" ? parsed.summary.trim() : "";
28240
29086
  const title = typeof parsed.title === "string" && parsed.title.trim().length > 0 ? parsed.title.trim() : undefined;
@@ -28292,17 +29138,67 @@ ${suggested}`;
28292
29138
 
28293
29139
  `);
28294
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
+ `;
28295
29189
  function buildKickoffPrompt(idea, maxQuestions) {
28296
29190
  return [
28297
29191
  "You are running an interview q&a session for the user inside their repository.",
28298
29192
  `Initial idea: ${idea}`,
29193
+ `Goal: Iteratively generate and populate a highly structured Specification document.`,
29194
+ SPECIFICATION_TEMPLATE_GUIDELINE,
28299
29195
  `Clarify the idea through short rounds of at most ${maxQuestions} questions at a time.`,
28300
29196
  "When useful, each question may include 2 to 4 answer options and one suggested option.",
28301
29197
  "Be practical. Focus on the highest-ambiguity and highest-risk decisions first.",
28302
29198
  "After any short human-friendly preface, you MUST include a machine-readable block in this exact format:",
28303
29199
  "<interview_state>",
28304
29200
  "{",
28305
- ' "summary": "one short paragraph about the current understanding",',
29201
+ ' "summary": "Full specification markdown (strictly matching the 11 section titles above)",',
28306
29202
  ' "title": "concise-kebab-case-title-for-filename",',
28307
29203
  ' "questions": [',
28308
29204
  " {",
@@ -28316,7 +29212,7 @@ function buildKickoffPrompt(idea, maxQuestions) {
28316
29212
  "</interview_state>",
28317
29213
  "Rules:",
28318
29214
  `- Return 0 to ${maxQuestions} questions.`,
28319
- "- 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.",
28320
29216
  `- Do not ask more than ${maxQuestions} questions in one round.`,
28321
29217
  '- Provide a concise "title" field (kebab-case, 3-6 words) suitable for a filename.'
28322
29218
  ].join(`
@@ -28326,12 +29222,13 @@ function buildResumePrompt(document, maxQuestions) {
28326
29222
  return [
28327
29223
  "Resume the interview from this existing markdown document.",
28328
29224
  "Use the current spec and Q&A history as ground truth so far.",
29225
+ SPECIFICATION_TEMPLATE_GUIDELINE,
28329
29226
  "Do not restart from scratch.",
28330
29227
  "",
28331
29228
  document,
28332
29229
  "",
28333
29230
  `Ask the next highest-value clarifying questions, up to ${maxQuestions} at a time.`,
28334
- "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.",
28335
29232
  "Return the same <interview_state> JSON block format as before."
28336
29233
  ].join(`
28337
29234
  `);
@@ -28341,12 +29238,13 @@ function buildAnswerPrompt(answers, questions, maxQuestions) {
28341
29238
  `);
28342
29239
  return [
28343
29240
  "Continue the same interview.",
29241
+ SPECIFICATION_TEMPLATE_GUIDELINE,
28344
29242
  "These were the active questions:",
28345
29243
  formatQuestionContext(questions),
28346
29244
  "The user answered:",
28347
29245
  answerText,
28348
- "Now update your understanding and ask the next highest-value clarifying questions.",
28349
- `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.`,
28350
29248
  "Return the same <interview_state> JSON block format as before."
28351
29249
  ].join(`
28352
29250
 
@@ -28406,6 +29304,7 @@ function createInterviewService(ctx, config, deps) {
28406
29304
  const browserOpener = deps?.openBrowser ?? openBrowser;
28407
29305
  const activeInterviewIds = new Map;
28408
29306
  const interviewsById = new Map;
29307
+ const activeSyncs = new Map;
28409
29308
  const sessionBusy = new Map;
28410
29309
  const sessionModel = new Map;
28411
29310
  const browserOpened = new Set;
@@ -28478,6 +29377,19 @@ function createInterviewService(ctx, config, deps) {
28478
29377
  });
28479
29378
  return result.data;
28480
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
+ }
28481
29393
  function isUserVisibleMessage(message) {
28482
29394
  return !(message.parts ?? []).some((part) => hasInternalInitiatorMarker(part));
28483
29395
  }
@@ -28546,8 +29458,19 @@ function createInterviewService(ctx, config, deps) {
28546
29458
  }
28547
29459
  return record;
28548
29460
  }
28549
- async function syncInterview(interview) {
28550
- 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);
28551
29474
  const interviewMessages = allMessages.slice(interview.baseMessageCount).filter(isUserVisibleMessage);
28552
29475
  const parsed = findLatestAssistantState(interviewMessages, maxQuestions);
28553
29476
  const existingDocument = await readInterviewDocument(interview);
@@ -28557,17 +29480,24 @@ function createInterviewService(ctx, config, deps) {
28557
29480
  summary: extractSummarySection(existingDocument) || fallbackState.summary
28558
29481
  };
28559
29482
  await maybeRenameWithTitle(interview, state.title);
28560
- 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);
28561
29490
  const interviewState = {
28562
29491
  interview,
28563
29492
  url: `${await ensureServer()}/interview/${interview.id}`,
28564
29493
  markdownPath: relativeInterviewPath(ctx.directory, interview.markdownPath),
28565
- 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",
28566
29495
  lastParseError: parsed.latestAssistantError,
28567
29496
  isBusy: sessionBusy.get(interview.sessionID) === true,
28568
29497
  summary: state.summary,
28569
29498
  questions: state.questions,
28570
- document
29499
+ document,
29500
+ blocks
28571
29501
  };
28572
29502
  if (onStateChange) {
28573
29503
  onStateChange(interview.id, interviewState);
@@ -28793,6 +29723,105 @@ function createInterviewService(ctx, config, deps) {
28793
29723
  fileCache = { items: sorted, at: Date.now() };
28794
29724
  return sorted;
28795
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
+ }
28796
29825
  async function handleNudgeAction(interviewId, action) {
28797
29826
  const interview = getInterviewById(interviewId);
28798
29827
  if (!interview) {
@@ -28808,12 +29837,17 @@ function createInterviewService(ctx, config, deps) {
28808
29837
  let promptSent = false;
28809
29838
  try {
28810
29839
  const state = await getInterviewState(interviewId);
29840
+ const relativePath = relativeInterviewPath(ctx.directory, interview.markdownPath);
28811
29841
  let prompt;
28812
29842
  if (action === "more-questions") {
28813
29843
  prompt = [
28814
- `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
+ `\`\`\``,
28815
29849
  ``,
28816
- `Current spec summary: ${state.summary}`,
29850
+ `The user reviewed the completed interview spec and wants you to continue.`,
28817
29851
  ``,
28818
29852
  `Ask up to ${maxQuestions} new clarifying questions about aspects that are still unclear or underspecified.`,
28819
29853
  `Include the structured <interview_state> block with new questions.`
@@ -28821,9 +29855,13 @@ function createInterviewService(ctx, config, deps) {
28821
29855
  `);
28822
29856
  } else {
28823
29857
  prompt = [
28824
- `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
+ `\`\`\``,
28825
29863
  ``,
28826
- `Current spec summary: ${state.summary}`,
29864
+ `The user confirmed the interview spec is complete.`,
28827
29865
  ``,
28828
29866
  `Produce a final, polished version of the full spec document.`,
28829
29867
  `Do NOT include any <interview_state> block — just output the final spec as clean markdown.`,
@@ -28858,6 +29896,8 @@ function createInterviewService(ctx, config, deps) {
28858
29896
  listInterviewFiles,
28859
29897
  listInterviews,
28860
29898
  submitAnswers,
29899
+ submitBlockComment,
29900
+ submitChat,
28861
29901
  handleNudgeAction
28862
29902
  };
28863
29903
  }
@@ -28876,6 +29916,8 @@ function createInterviewManager(ctx, config) {
28876
29916
  listInterviewFiles: async () => service2.listInterviewFiles(),
28877
29917
  listInterviews: () => service2.listInterviews(),
28878
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),
28879
29921
  handleNudgeAction: async (interviewId, action) => service2.handleNudgeAction(interviewId, action),
28880
29922
  outputFolder: resolvedOutputPath,
28881
29923
  port: 0
@@ -28909,6 +29951,8 @@ function createInterviewManager(ctx, config) {
28909
29951
  continue;
28910
29952
  pollPendingAnswers(sessionID).catch(() => {});
28911
29953
  pollNudgeAction(sessionID).catch(() => {});
29954
+ pollBlockComment(sessionID).catch(() => {});
29955
+ pollChat(sessionID).catch(() => {});
28912
29956
  }
28913
29957
  }, FALLBACK_POLL_INTERVAL);
28914
29958
  fallbackTimer?.unref();
@@ -28940,7 +29984,9 @@ function createInterviewManager(ctx, config) {
28940
29984
  pendingAnswers: null,
28941
29985
  lastUpdatedAt: Date.now(),
28942
29986
  filePath: interview.markdownPath,
28943
- nudgeAction: null
29987
+ nudgeAction: null,
29988
+ pendingBlockComment: null,
29989
+ pendingChatMessage: null
28944
29990
  });
28945
29991
  dashboard?.registerSession({
28946
29992
  sessionID: interview.sessionID,
@@ -28966,122 +30012,140 @@ function createInterviewManager(ctx, config) {
28966
30012
  await new Promise((r) => setTimeout(r, 500));
28967
30013
  const retry = await probeDashboard(dashboardPort);
28968
30014
  if (!retry.alive) {
28969
- log("[interview] dashboard mode: no dashboard found, falling back to per-session server", {
28970
- port: dashboardPort
28971
- });
28972
- const perSessionServer = createInterviewServer({
28973
- getState: async (interviewId) => service.getInterviewState(interviewId),
28974
- listInterviewFiles: async () => service.listInterviewFiles(),
28975
- listInterviews: () => service.listInterviews(),
28976
- submitAnswers: async (interviewId, answers) => service.submitAnswers(interviewId, answers),
28977
- handleNudgeAction: async (interviewId, action) => service.handleNudgeAction(interviewId, action),
28978
- outputFolder: path16.join(ctx.directory, outputFolder),
28979
- port: 0
28980
- });
28981
- service.setBaseUrlResolver(() => perSessionServer.ensureStarted());
28982
- isDashboard = false;
28983
- initDone = true;
28984
- return;
30015
+ log("[interview] dashboard probe failed twice, falling back to local server");
30016
+ throw new Error("Dashboard not reachable");
28985
30017
  }
28986
30018
  }
30019
+ const creds = await readDashboardAuthFile(dashboardPort);
30020
+ if (!creds) {
30021
+ throw new Error("Dashboard credentials file missing");
30022
+ }
28987
30023
  dashboardBaseUrl = `http://127.0.0.1:${dashboardPort}`;
28988
- const auth = await readDashboardAuthFile(dashboardPort);
28989
- authToken = auth?.token ?? "";
30024
+ authToken = creds.token;
28990
30025
  service.setBaseUrlResolver(() => Promise.resolve(dashboardBaseUrl));
28991
30026
  service.setStatePushCallback((id, state) => {
28992
- pushStateViaHttp(dashboardBaseUrl, authToken, id, state).catch((err) => {
28993
- log("[interview] failed to push state to dashboard", {
28994
- 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
+ });
28995
30032
  });
28996
- });
30033
+ }
28997
30034
  });
28998
30035
  service.setOnInterviewCreated((interview) => {
28999
- registerInterviewViaHttp(dashboardBaseUrl, authToken, interview).catch((err) => {
29000
- log("[interview] failed to register interview with dashboard", {
29001
- 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
+ });
29002
30041
  });
29003
- });
30042
+ }
29004
30043
  });
29005
- log("[interview] dashboard mode: we are a session", {
29006
- port: dashboardPort
30044
+ log("[interview] dashboard mode: registered as session client", {
30045
+ dashboardUrl: dashboardBaseUrl
29007
30046
  });
29008
- startFallbackTimer();
29009
30047
  }
29010
30048
  } catch (err) {
29011
- log("[interview] dashboard mode init failed", {
29012
- 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
29013
30062
  });
30063
+ service.setBaseUrlResolver(() => server.ensureStarted());
30064
+ service.setStatePushCallback(() => {});
29014
30065
  } finally {
29015
30066
  initDone = true;
29016
30067
  }
29017
30068
  })();
29018
- async function ensureInit() {
29019
- if (!initDone)
30069
+ async function ensureInitialized() {
30070
+ if (!initDone) {
29020
30071
  await initPromise;
30072
+ }
29021
30073
  }
29022
- async function registerSessionIfNeeded(sessionID) {
29023
- if (registeredSessions.has(sessionID))
29024
- return;
29025
- registeredSessions.add(sessionID);
29026
- if (isDashboard)
30074
+ async function pollPendingAnswers(sessionID) {
30075
+ const interviewId = service.getActiveInterviewId(sessionID);
30076
+ if (!interviewId)
29027
30077
  return;
29028
30078
  try {
29029
- await fetch(`${dashboardBaseUrl}/api/register?token=${authToken}`, {
29030
- method: "POST",
29031
- headers: { "content-type": "application/json" },
29032
- body: JSON.stringify({
29033
- sessionID,
29034
- directory: ctx.directory,
29035
- pid: process.pid
29036
- }),
29037
- signal: AbortSignal.timeout(3000)
29038
- });
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
+ }
29039
30088
  } catch (err) {
29040
- log("[interview] failed to register session with dashboard", {
30089
+ log("[interview] failed polling pending answers:", {
29041
30090
  error: err instanceof Error ? err.message : String(err)
29042
30091
  });
29043
30092
  }
29044
30093
  }
29045
- async function pollPendingAnswers(sessionID) {
30094
+ async function pollNudgeAction(sessionID) {
29046
30095
  const interviewId = service.getActiveInterviewId(sessionID);
29047
30096
  if (!interviewId)
29048
30097
  return;
29049
30098
  try {
29050
- const response = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/pending?token=${authToken}`, { signal: AbortSignal.timeout(3000) });
29051
- if (!response.ok)
29052
- return;
29053
- const data = await response.json();
29054
- if (!data.answers || data.answers.length === 0)
29055
- return;
29056
- log("[interview] delivering pending answers from dashboard", {
29057
- interviewId,
29058
- count: data.answers.length
29059
- });
29060
- 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
+ }
29061
30108
  } catch (err) {
29062
- log("[interview] failed to poll pending answers", {
30109
+ log("[interview] failed polling nudge action:", {
29063
30110
  error: err instanceof Error ? err.message : String(err)
29064
30111
  });
29065
30112
  }
29066
30113
  }
29067
- async function pollNudgeAction(sessionID) {
30114
+ async function pollBlockComment(sessionID) {
29068
30115
  const interviewId = service.getActiveInterviewId(sessionID);
29069
30116
  if (!interviewId)
29070
30117
  return;
29071
30118
  try {
29072
- const response = await fetch(`${dashboardBaseUrl}/api/interviews/${interviewId}/nudge?token=${authToken}`, { signal: AbortSignal.timeout(3000) });
29073
- if (!response.ok)
29074
- return;
29075
- const data = await response.json();
29076
- if (!data.action)
29077
- return;
29078
- log("[interview] delivering nudge action from dashboard", {
29079
- interviewId,
29080
- 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)
29081
30131
  });
29082
- 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
+ }
29083
30147
  } catch (err) {
29084
- log("[interview] failed to poll nudge action", {
30148
+ log("[interview] failed polling chat message:", {
29085
30149
  error: err instanceof Error ? err.message : String(err)
29086
30150
  });
29087
30151
  }
@@ -29089,28 +30153,39 @@ function createInterviewManager(ctx, config) {
29089
30153
  return {
29090
30154
  registerCommand: (c) => service.registerCommand(c),
29091
30155
  handleCommandExecuteBefore: async (input, output) => {
29092
- await ensureInit();
29093
- await service.handleCommandExecuteBefore(input, output);
29094
- if (input.sessionID) {
29095
- 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();
29096
30171
  }
30172
+ await service.handleCommandExecuteBefore(input, output);
29097
30173
  },
29098
30174
  handleEvent: async (input) => {
29099
- await ensureInit();
29100
- await service.handleEvent(input);
30175
+ await ensureInitialized();
29101
30176
  const { event } = input;
29102
30177
  const properties = event.properties ?? {};
29103
- const sessionID = properties.sessionID;
29104
- if (sessionID) {
29105
- await registerSessionIfNeeded(sessionID);
29106
- }
29107
- if (event.type === "session.status" && sessionID) {
30178
+ const sessionID = properties.sessionID ?? null;
30179
+ await service.handleEvent(input);
30180
+ if (event.type === "session.status") {
29108
30181
  const status = properties.status;
29109
- if (status?.type === "idle") {
30182
+ if (sessionID && status?.type === "idle") {
29110
30183
  const interviewId = service.getActiveInterviewId(sessionID);
29111
- if (!isDashboard) {
30184
+ if (!isDashboard && dashboardBaseUrl) {
29112
30185
  await pollPendingAnswers(sessionID);
29113
30186
  await pollNudgeAction(sessionID);
30187
+ await pollBlockComment(sessionID);
30188
+ await pollChat(sessionID);
29114
30189
  } else if (interviewId && dashboard) {
29115
30190
  const pending = dashboard.consumePendingAnswers(interviewId);
29116
30191
  if (pending && pending.length > 0) {
@@ -29128,6 +30203,21 @@ function createInterviewManager(ctx, config) {
29128
30203
  });
29129
30204
  await service.handleNudgeAction(interviewId, nudge);
29130
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
+ }
29131
30221
  }
29132
30222
  if (interviewId) {
29133
30223
  service.getInterviewState(interviewId).catch((err) => {
@@ -29162,7 +30252,11 @@ function stateToEntry(interviewId, state) {
29162
30252
  pendingAnswers: null,
29163
30253
  lastUpdatedAt: Date.now(),
29164
30254
  filePath: state.interview.markdownPath,
29165
- nudgeAction: null
30255
+ nudgeAction: null,
30256
+ pendingBlockComment: null,
30257
+ pendingChatMessage: null,
30258
+ document: state.document,
30259
+ blocks: state.blocks
29166
30260
  };
29167
30261
  }
29168
30262
  async function pushStateViaHttp(dashboardUrl, token, interviewId, state) {
@@ -29181,9 +30275,23 @@ async function registerInterviewViaHttp(dashboardUrl, token, interview) {
29181
30275
  body: JSON.stringify({
29182
30276
  interviewId: interview.id,
29183
30277
  sessionID: interview.sessionID,
29184
- 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
29185
30289
  }),
29186
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
+ });
29187
30295
  });
29188
30296
  }
29189
30297
  // src/mcp/context7.ts
@@ -29941,7 +31049,6 @@ function startAvailabilityCheck(config) {
29941
31049
  }
29942
31050
  }
29943
31051
  // src/multiplexer/session-manager.ts
29944
- var SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
29945
31052
  var SHARED_STATE_KEY = Symbol.for("oh-my-opencode-slim.multiplexer-session-manager.state");
29946
31053
  function getSharedState() {
29947
31054
  const globalWithState = globalThis;
@@ -29949,7 +31056,8 @@ function getSharedState() {
29949
31056
  sessions: new Map,
29950
31057
  knownSessions: new Map,
29951
31058
  spawningSessions: new Set,
29952
- closingSessions: new Map
31059
+ closingSessions: new Map,
31060
+ deferredIdleCloses: new Set
29953
31061
  };
29954
31062
  return globalWithState[SHARED_STATE_KEY];
29955
31063
  }
@@ -29963,6 +31071,7 @@ class MultiplexerSessionManager {
29963
31071
  knownSessions;
29964
31072
  spawningSessions;
29965
31073
  closingSessions;
31074
+ deferredIdleCloses;
29966
31075
  pollInterval;
29967
31076
  enabled = false;
29968
31077
  constructor(ctx, config, backgroundJobBoard) {
@@ -29972,6 +31081,7 @@ class MultiplexerSessionManager {
29972
31081
  this.knownSessions = sharedState.knownSessions;
29973
31082
  this.spawningSessions = sharedState.spawningSessions;
29974
31083
  this.closingSessions = sharedState.closingSessions;
31084
+ this.deferredIdleCloses = sharedState.deferredIdleCloses;
29975
31085
  this.directory = ctx.directory;
29976
31086
  const defaultPort = process.env.OPENCODE_PORT ?? "4096";
29977
31087
  this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`;
@@ -30053,17 +31163,13 @@ class MultiplexerSessionManager {
30053
31163
  }));
30054
31164
  return;
30055
31165
  }
30056
- const now = Date.now();
30057
31166
  this.sessions.set(sessionId, {
30058
31167
  sessionId,
30059
31168
  paneId: paneResult.paneId,
30060
31169
  parentId,
30061
31170
  title,
30062
31171
  directory,
30063
- ownerInstanceId: this.instanceId,
30064
- createdAt: now,
30065
- lastSeenAt: now,
30066
- seenInStatus: false
31172
+ ownerInstanceId: this.instanceId
30067
31173
  });
30068
31174
  log("[multiplexer-session-manager] pane spawned", {
30069
31175
  instanceId: this.instanceId,
@@ -30098,7 +31204,8 @@ class MultiplexerSessionManager {
30098
31204
  const sessionId = event.properties?.sessionID;
30099
31205
  if (!sessionId)
30100
31206
  return;
30101
- if (event.properties?.status?.type === "idle") {
31207
+ const statusType = event.properties?.status?.type;
31208
+ if (statusType === "idle") {
30102
31209
  log("[multiplexer-session-manager] session status idle received", {
30103
31210
  instanceId: this.instanceId,
30104
31211
  sessionId,
@@ -30110,7 +31217,10 @@ class MultiplexerSessionManager {
30110
31217
  await this.closeSession(sessionId, "idle");
30111
31218
  return;
30112
31219
  }
30113
- if (event.properties?.status?.type === "busy") {
31220
+ if (statusType) {
31221
+ this.deferredIdleCloses.delete(sessionId);
31222
+ if (statusType !== "busy")
31223
+ return;
30114
31224
  log("[multiplexer-session-manager] session busy event received", {
30115
31225
  instanceId: this.instanceId,
30116
31226
  sessionId,
@@ -30138,6 +31248,7 @@ class MultiplexerSessionManager {
30138
31248
  ownerInstanceId: this.sessions.get(sessionId)?.ownerInstanceId,
30139
31249
  backgroundJobState: this.backgroundJobBoard?.get(sessionId)?.state
30140
31250
  });
31251
+ this.deferredIdleCloses.delete(sessionId);
30141
31252
  await this.closeSession(sessionId, "deleted");
30142
31253
  }
30143
31254
  startPolling() {
@@ -30164,7 +31275,6 @@ class MultiplexerSessionManager {
30164
31275
  }
30165
31276
  try {
30166
31277
  const allStatuses = await this.fetchSessionStatuses();
30167
- const now = Date.now();
30168
31278
  const sessionsToClose = [];
30169
31279
  for (const [sessionId, tracked] of this.sessions.entries()) {
30170
31280
  if (tracked.ownerInstanceId !== this.instanceId) {
@@ -30177,34 +31287,16 @@ class MultiplexerSessionManager {
30177
31287
  continue;
30178
31288
  }
30179
31289
  const status = allStatuses[sessionId];
30180
- const isIdle = status?.type === "idle";
30181
- if (status) {
30182
- tracked.lastSeenAt = now;
30183
- tracked.seenInStatus = true;
30184
- tracked.missingSince = undefined;
30185
- } else if (!tracked.missingSince) {
30186
- tracked.missingSince = now;
30187
- }
30188
- const missingTooLong = !!tracked.missingSince && now - tracked.missingSince >= SESSION_MISSING_GRACE_MS;
30189
- const shouldKeepRunningBackgroundJob = (isIdle || missingTooLong) && this.isRunningBackgroundJob(sessionId);
30190
- if (isIdle || missingTooLong) {
30191
- if (shouldKeepRunningBackgroundJob) {
30192
- log("[multiplexer-session-manager] keeping running background pane", {
30193
- instanceId: this.instanceId,
30194
- sessionId,
30195
- paneId: tracked.paneId,
30196
- seenInStatus: tracked.seenInStatus
30197
- });
30198
- continue;
30199
- }
30200
- sessionsToClose.push({
30201
- sessionId,
30202
- reason: isIdle ? "idle" : "missing"
30203
- });
31290
+ if (!status)
31291
+ continue;
31292
+ if (status.type !== "idle") {
31293
+ this.deferredIdleCloses.delete(sessionId);
31294
+ continue;
30204
31295
  }
31296
+ sessionsToClose.push(sessionId);
30205
31297
  }
30206
- for (const { sessionId, reason } of sessionsToClose) {
30207
- await this.closeSession(sessionId, reason);
31298
+ for (const sessionId of sessionsToClose) {
31299
+ await this.closeSession(sessionId, "idle");
30208
31300
  }
30209
31301
  } catch (err) {
30210
31302
  log("[multiplexer-session-manager] poll error", { error: String(err) });
@@ -30229,6 +31321,7 @@ class MultiplexerSessionManager {
30229
31321
  async closeSession(sessionId, reason) {
30230
31322
  if (reason === "deleted") {
30231
31323
  this.knownSessions.delete(sessionId);
31324
+ this.deferredIdleCloses.delete(sessionId);
30232
31325
  }
30233
31326
  const existingClose = this.closingSessions.get(sessionId);
30234
31327
  if (existingClose)
@@ -30264,6 +31357,7 @@ class MultiplexerSessionManager {
30264
31357
  });
30265
31358
  }
30266
31359
  if (reason === "idle" && this.isRunningBackgroundJob(sessionId)) {
31360
+ this.deferredIdleCloses.add(sessionId);
30267
31361
  log("[multiplexer-session-manager] close skipped; background job running", {
30268
31362
  instanceId: this.instanceId,
30269
31363
  sessionId,
@@ -30273,6 +31367,7 @@ class MultiplexerSessionManager {
30273
31367
  });
30274
31368
  return;
30275
31369
  }
31370
+ this.deferredIdleCloses.delete(sessionId);
30276
31371
  this.sessions.delete(sessionId);
30277
31372
  log("[multiplexer-session-manager] closing session pane", {
30278
31373
  instanceId: this.instanceId,
@@ -30348,18 +31443,15 @@ class MultiplexerSessionManager {
30348
31443
  }));
30349
31444
  return;
30350
31445
  }
30351
- const now = Date.now();
30352
31446
  this.sessions.set(sessionId, {
30353
31447
  sessionId,
30354
31448
  paneId: paneResult.paneId,
30355
31449
  parentId: known.parentId,
30356
31450
  title: known.title,
30357
31451
  directory: known.directory,
30358
- ownerInstanceId: this.instanceId,
30359
- createdAt: now,
30360
- lastSeenAt: now,
30361
- seenInStatus: false
31452
+ ownerInstanceId: this.instanceId
30362
31453
  });
31454
+ this.deferredIdleCloses.delete(sessionId);
30363
31455
  log("[multiplexer-session-manager] pane respawned on busy", {
30364
31456
  instanceId: this.instanceId,
30365
31457
  sessionId,
@@ -30386,6 +31478,13 @@ class MultiplexerSessionManager {
30386
31478
  isRunningBackgroundJob(sessionId) {
30387
31479
  return this.backgroundJobBoard?.get(sessionId)?.state === "running";
30388
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
+ }
30389
31488
  async cleanup() {
30390
31489
  this.stopPolling();
30391
31490
  if (this.closingSessions.size > 0) {
@@ -30406,6 +31505,7 @@ class MultiplexerSessionManager {
30406
31505
  this.knownSessions.clear();
30407
31506
  this.spawningSessions.clear();
30408
31507
  this.closingSessions.clear();
31508
+ this.deferredIdleCloses.clear();
30409
31509
  log("[multiplexer-session-manager] cleanup complete");
30410
31510
  }
30411
31511
  }
@@ -30657,7 +31757,7 @@ function createAcpRunTool(agents = {}) {
30657
31757
  agent: z4.string().describe("Configured ACP agent name"),
30658
31758
  prompt: z4.string().describe("Task or question to send to the ACP agent"),
30659
31759
  cwd: z4.string().optional().describe("Optional absolute working directory override"),
30660
- 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.")
30661
31761
  },
30662
31762
  async execute(args, ctx) {
30663
31763
  if (ctx.agent !== args.agent) {
@@ -30693,11 +31793,12 @@ function createAcpRunTool(agents = {}) {
30693
31793
  });
30694
31794
  const timeoutMs = args.timeout_ms ?? config.timeoutMs;
30695
31795
  let timer;
30696
- 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;
30697
31797
  const abort = () => client.close();
30698
31798
  ctx.abort.addEventListener("abort", abort, { once: true });
30699
31799
  try {
30700
- 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;
30701
31802
  } finally {
30702
31803
  if (timer)
30703
31804
  clearTimeout(timer);
@@ -31792,7 +32893,8 @@ function emptySnapshot() {
31792
32893
  return {
31793
32894
  version: 1,
31794
32895
  updatedAt: Date.now(),
31795
- agentModels: {}
32896
+ agentModels: {},
32897
+ agentVariants: {}
31796
32898
  };
31797
32899
  }
31798
32900
  function parseSnapshot(value) {
@@ -31802,7 +32904,8 @@ function parseSnapshot(value) {
31802
32904
  return {
31803
32905
  version: 1,
31804
32906
  updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
31805
- agentModels: parsed.agentModels ?? {}
32907
+ agentModels: parsed.agentModels ?? {},
32908
+ agentVariants: parsed.agentVariants ?? {}
31806
32909
  };
31807
32910
  }
31808
32911
  function readTuiSnapshot() {
@@ -31836,11 +32939,19 @@ function updateSnapshot(mutator) {
31836
32939
  function recordTuiAgentModels(input) {
31837
32940
  updateSnapshot((snapshot) => {
31838
32941
  snapshot.agentModels = { ...input.agentModels };
32942
+ snapshot.agentVariants = { ...input.agentVariants ?? {} };
31839
32943
  });
31840
32944
  }
31841
32945
  function recordTuiAgentModel(input) {
31842
32946
  updateSnapshot((snapshot) => {
31843
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
+ }
31844
32955
  });
31845
32956
  }
31846
32957
 
@@ -31912,12 +33023,18 @@ function createPresetManager(ctx, config) {
31912
33023
  } catch {}
31913
33024
  const snapshot = readTuiSnapshot();
31914
33025
  const agentModels = { ...snapshot.agentModels };
33026
+ const agentVariants = { ...snapshot.agentVariants };
31915
33027
  for (const [agentName, agentConfig] of Object.entries(agentUpdates)) {
31916
33028
  if (typeof agentConfig.model === "string") {
31917
33029
  agentModels[agentName] = agentConfig.model;
31918
33030
  }
33031
+ if (typeof agentConfig.variant === "string") {
33032
+ agentVariants[agentName] = agentConfig.variant;
33033
+ } else {
33034
+ delete agentVariants[agentName];
33035
+ }
31919
33036
  }
31920
- recordTuiAgentModels({ agentModels });
33037
+ recordTuiAgentModels({ agentModels, agentVariants });
31921
33038
  activePreset = presetName;
31922
33039
  const summaryParts = [];
31923
33040
  for (const [name, cfg] of Object.entries(agentUpdates)) {
@@ -34310,6 +35427,16 @@ function createWebfetchTool(pluginCtx, options = {}) {
34310
35427
  }
34311
35428
  });
34312
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
+
34313
35440
  // src/utils/subagent-depth.ts
34314
35441
  class SubagentDepthTracker {
34315
35442
  depthBySession = new Map;
@@ -34400,6 +35527,10 @@ async function probeJSDOM() {
34400
35527
  var OhMyOpenCodeLite = async (ctx) => {
34401
35528
  const sessionId = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
34402
35529
  initLogger(sessionId);
35530
+ if (isPluginDisabledByEnv()) {
35531
+ log("[plugin] disabled by OH_MY_OPENCODE_SLIM_DISABLE");
35532
+ return {};
35533
+ }
34403
35534
  let config;
34404
35535
  let disabledAgents;
34405
35536
  let agentDefs;
@@ -34432,6 +35563,7 @@ var OhMyOpenCodeLite = async (ctx) => {
34432
35563
  let cancelTaskTools;
34433
35564
  let acpRunTools;
34434
35565
  let webfetch;
35566
+ let tools;
34435
35567
  let rewriteDisplayNameMentions;
34436
35568
  let toolCount = 0;
34437
35569
  try {
@@ -34483,6 +35615,9 @@ var OhMyOpenCodeLite = async (ctx) => {
34483
35615
  readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8
34484
35616
  });
34485
35617
  multiplexerSessionManager = new MultiplexerSessionManager(ctx, multiplexerConfig, backgroundJobBoard);
35618
+ backgroundJobBoard.setTerminalStateListener((taskID) => {
35619
+ multiplexerSessionManager.retryDeferredIdleClose(taskID);
35620
+ });
34486
35621
  autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
34487
35622
  autoUpdate: config.autoUpdate ?? true,
34488
35623
  companion: config.companion
@@ -34515,7 +35650,19 @@ var OhMyOpenCodeLite = async (ctx) => {
34515
35650
  backgroundJobBoard,
34516
35651
  shouldManageSession: (sessionID) => sessionAgentMap.get(sessionID) === "orchestrator"
34517
35652
  });
34518
- 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;
34519
35666
  } catch (err) {
34520
35667
  log("[plugin] FATAL: init failed", String(err));
34521
35668
  await appLog(ctx, "error", `INIT FAILED: ${String(err)}. Report at github.com/alvinunreal/oh-my-opencode-slim/issues/310`);
@@ -34568,17 +35715,25 @@ var OhMyOpenCodeLite = async (ctx) => {
34568
35715
  }
34569
35716
  }
34570
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
+ }
34571
35733
  return {
34572
35734
  name: "oh-my-opencode-slim",
34573
35735
  agent: agents,
34574
- tool: {
34575
- ...councilTools,
34576
- ...cancelTaskTools,
34577
- ...acpRunTools,
34578
- webfetch,
34579
- ast_grep_search,
34580
- ast_grep_replace
34581
- },
35736
+ tool: tools,
34582
35737
  mcp: mcps,
34583
35738
  config: async (opencodeConfig) => {
34584
35739
  if (config.setDefaultAgent !== false && !opencodeConfig.default_agent) {
@@ -34704,14 +35859,22 @@ var OhMyOpenCodeLite = async (ctx) => {
34704
35859
  }
34705
35860
  }
34706
35861
  const tuiAgentModels = {};
35862
+ const tuiAgentVariants = {};
34707
35863
  for (const agentDef of agentDefs) {
34708
35864
  if (agentDef.name === "councillor")
34709
35865
  continue;
34710
35866
  const entry = configAgent[agentDef.name];
34711
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;
34712
35869
  tuiAgentModels[agentDef.name] = resolvedModel ?? "default";
35870
+ if (resolvedVariant) {
35871
+ tuiAgentVariants[agentDef.name] = resolvedVariant;
35872
+ }
34713
35873
  }
34714
- recordTuiAgentModels({ agentModels: tuiAgentModels });
35874
+ recordTuiAgentModels({
35875
+ agentModels: tuiAgentModels,
35876
+ agentVariants: tuiAgentVariants
35877
+ });
34715
35878
  const configMcp = opencodeConfig.mcp;
34716
35879
  if (!configMcp) {
34717
35880
  opencodeConfig.mcp = { ...mcps };
@@ -34749,10 +35912,16 @@ var OhMyOpenCodeLite = async (ctx) => {
34749
35912
  const event = input.event;
34750
35913
  if (event.type === "message.updated") {
34751
35914
  const info = event.properties?.info;
34752
- 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);
34753
35921
  recordTuiAgentModel({
34754
- agentName: resolveRuntimeAgentName(config, info.agent),
34755
- model: `${info.providerID}/${info.modelID}`
35922
+ agentName,
35923
+ model,
35924
+ variant: variant ?? null
34756
35925
  });
34757
35926
  }
34758
35927
  }