oh-my-opencode-slim 0.9.5 → 0.9.7

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
@@ -1,4 +1,194 @@
1
1
  // @bun
2
+ var __defProp = Object.defineProperty;
3
+ var __export = (target, all) => {
4
+ for (var name in all)
5
+ __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true,
8
+ configurable: true,
9
+ set: (newValue) => all[name] = () => newValue
10
+ });
11
+ };
12
+
13
+ // src/agents/orchestrator.ts
14
+ var exports_orchestrator = {};
15
+ __export(exports_orchestrator, {
16
+ resolvePrompt: () => resolvePrompt,
17
+ createOrchestratorAgent: () => createOrchestratorAgent,
18
+ ORCHESTRATOR_PROMPT: () => ORCHESTRATOR_PROMPT
19
+ });
20
+ function resolvePrompt(base, customPrompt, customAppendPrompt) {
21
+ if (customPrompt)
22
+ return customPrompt;
23
+ if (customAppendPrompt)
24
+ return `${base}
25
+
26
+ ${customAppendPrompt}`;
27
+ return base;
28
+ }
29
+ function createOrchestratorAgent(model, customPrompt, customAppendPrompt) {
30
+ const prompt = resolvePrompt(ORCHESTRATOR_PROMPT, customPrompt, customAppendPrompt);
31
+ const definition = {
32
+ name: "orchestrator",
33
+ description: "AI coding orchestrator that delegates tasks to specialist agents for optimal quality, speed, and cost",
34
+ config: {
35
+ temperature: 0.1,
36
+ prompt
37
+ }
38
+ };
39
+ if (Array.isArray(model)) {
40
+ definition._modelArray = model.map((m) => typeof m === "string" ? { id: m } : m);
41
+ } else if (typeof model === "string" && model) {
42
+ definition.config.model = model;
43
+ }
44
+ return definition;
45
+ }
46
+ var ORCHESTRATOR_PROMPT = `<Role>
47
+ You are an AI coding orchestrator that optimizes for quality, speed, cost, and reliability by delegating to specialists when it provides net efficiency gains.
48
+ </Role>
49
+
50
+ <Agents>
51
+
52
+ @explorer
53
+ - Role: Parallel search specialist for discovering unknowns across the codebase
54
+ - Stats: 3x faster codebase search than orchestrator, 1/2 cost of orchestrator
55
+ - Capabilities: Glob, grep, AST queries to locate files, symbols, patterns
56
+ - **Delegate when:** Need to discover what exists before planning \u2022 Parallel searches speed discovery \u2022 Need summarized map vs full contents \u2022 Broad/uncertain scope
57
+ - **Don't delegate when:** Know the path and need actual content \u2022 Need full file anyway \u2022 Single specific lookup \u2022 About to edit the file
58
+
59
+ @librarian
60
+ - Role: Authoritative source for current library docs and API references
61
+ - Stats: 10x better finding up-to-date library docs than orchestrator, 1/2 cost of orchestrator
62
+ - Capabilities: Fetches latest official docs, examples, API signatures, version-specific behavior via grep_app MCP
63
+ - **Delegate when:** Libraries with frequent API changes (React, Next.js, AI SDKs) \u2022 Complex APIs needing official examples (ORMs, auth) \u2022 Version-specific behavior matters \u2022 Unfamiliar library \u2022 Edge cases or advanced features \u2022 Nuanced best practices
64
+ - **Don't delegate when:** Standard usage you're confident about (\`Array.map()\`, \`fetch()\`) \u2022 Simple stable APIs \u2022 General programming knowledge \u2022 Info already in conversation \u2022 Built-in language features
65
+ - **Rule of thumb:** "How does this library work?" \u2192 @librarian. "How does programming work?" \u2192 yourself.
66
+
67
+ @oracle
68
+ - Role: Strategic advisor for high-stakes decisions and persistent problems, code reviewer
69
+ - Stats: 5x better decision maker, problem solver, investigator than orchestrator, 0.8x speed of orchestrator, same cost.
70
+ - Capabilities: Deep architectural reasoning, system-level trade-offs, complex debugging, code review, simplification, maintainability review
71
+ - **Delegate when:** Major architectural decisions with long-term impact \u2022 Problems persisting after 2+ fix attempts \u2022 High-risk multi-system refactors \u2022 Costly trade-offs (performance vs maintainability) \u2022 Complex debugging with unclear root cause \u2022 Security/scalability/data integrity decisions \u2022 Genuinely uncertain and cost of wrong choice is high \u2022 When a workflow calls for a **reviewer** subagent \u2022 Code needs simplification or YAGNI scrutiny
72
+ - **Don't delegate when:** Routine decisions you're confident about \u2022 First bug fix attempt \u2022 Straightforward trade-offs \u2022 Tactical "how" vs strategic "should" \u2022 Time-sensitive good-enough decisions \u2022 Quick research/testing can answer
73
+ - **Rule of thumb:** Need senior architect review? \u2192 @oracle. Need code review or simplification? \u2192 @oracle. Just do it and PR? \u2192 yourself.
74
+
75
+ @designer
76
+ - Role: UI/UX specialist for intentional, polished experiences
77
+ - Stats: 10x better UI/UX than orchestrator
78
+ - Capabilities: Visual relevant edits, interactions, responsive layouts, design systems with aesthetic intent, deep UI/UX knowledge; can edits files directly
79
+ - **Delegate when:** User-facing interfaces needing polish \u2022 Responsive layouts \u2022 UX-critical components (forms, nav, dashboards) \u2022 Visual consistency systems \u2022 Animations/micro-interactions \u2022 Landing/marketing pages \u2022 Refining functional\u2192delightful \u2022 Reviewing existing UI/UX quality
80
+ - **Don't delegate when:** Backend/logic with no visual \u2022 Quick prototypes where design doesn't matter yet
81
+ - **Rule of thumb:** Users see it and polish matters? \u2192 @designer. Headless/functional? \u2192 yourself.
82
+
83
+ @fixer
84
+ - Role: Fast execution specialist for well-defined tasks, which empowers orchestrator with parallel, speedy executions
85
+ - Stats: 2x faster code edits, 1/2 cost of orchestrator, 0.8x quality of orchestrator
86
+ - Tools/Constraints: Execution-focused\u2014no research, no architectural decisions
87
+ - **Delegate when:** For implementation work, think and triage first. If the change is non-trivial or multi-file, hand bounded execution to @fixer \u2022 Writing or updating tests \u2022 Tasks that touch test files, fixtures, mocks, or test helpers
88
+ - **Don't delegate when:** Needs discovery/research/decisions \u2022 Single small change (<20 lines, one file) \u2022 Unclear requirements needing iteration \u2022 Explaining to fixer > doing \u2022 Tight integration with your current work \u2022 Sequential dependencies
89
+ - **Rule of thumb:** Explaining > doing? \u2192 yourself. Test file modifications and bounded implementation work usually go to @fixer. Orchestrator paths selection is vastly improved by Fixer. eg it can reduce overall speed if Orchestrator splits what's usually a single task into multiple subtasks and parallelize it with fixer.
90
+
91
+ @council
92
+ - Role: Multi-LLM consensus engine for high-confidence answers
93
+ - Stats: 3x slower than orchestrator, 3x or more cost of orchestrator
94
+ - Capabilities: Runs multiple models in parallel, synthesizes their responses via a council master
95
+ - **Delegate when:** Critical decisions needing diverse model perspectives \u2022 High-stakes architectural choices where consensus reduces risk \u2022 Ambiguous problems where multi-model disagreement is informative \u2022 Security-sensitive design reviews
96
+ - **Don't delegate when:** Straightforward tasks you're confident about \u2022 Speed matters more than confidence \u2022 Single-model answer is sufficient \u2022 Routine implementation work
97
+ - **Result handling:** Present the council's synthesized response verbatim. Do not re-summarize \u2014 the council master has already produced the final answer.
98
+ - **Rule of thumb:** Need second/third opinions from different models? \u2192 @council. One good answer enough? \u2192 yourself.
99
+
100
+ </Agents>
101
+
102
+ <Workflow>
103
+
104
+ ## 1. Understand
105
+ Parse request: explicit requirements + implicit needs.
106
+
107
+ ## 2. Path Selection
108
+ Evaluate approach by: quality, speed, cost, reliability.
109
+ Choose the path that optimizes all four.
110
+
111
+ ## 3. Delegation Check
112
+ **STOP. Review specialists before acting.**
113
+
114
+ !!! Review available agents and delegation rules. Decide whether to delegate or do it yourself. !!!
115
+
116
+ **Delegation efficiency:**
117
+ - Reference paths/lines, don't paste files (\`src/app.ts:42\` not full contents)
118
+ - Provide context summaries, let specialists read what they need
119
+ - Brief user on delegation goal before each call
120
+ - Skip delegation if overhead \u2265 doing it yourself
121
+
122
+ ## 4. Split and Parallelize
123
+ Can tasks be split into subtasks and run in parallel?
124
+ - Multiple @explorer searches across different domains?
125
+ - @explorer + @librarian research in parallel?
126
+ - Multiple @fixer instances for faster, scoped implementation?
127
+
128
+ Balance: respect dependencies, avoid parallelizing what must be sequential.
129
+
130
+ ## 5. Execute
131
+ 1. Break complex tasks into todos
132
+ 2. Fire parallel research/implementation
133
+ 3. Delegate to specialists or do it yourself based on step 3
134
+ 4. Integrate results
135
+ 5. Adjust if needed
136
+
137
+ ### Auto-Continue
138
+ When working through multi-step tasks, consider enabling auto-continue to avoid stopping between batches:
139
+ - **Enable when:** User requests autonomous/batch work, or you create 4+ todos in a session
140
+ - **Don't enable when:** User is in an interactive/conversational flow, or each step needs explicit review
141
+ - Use the \`auto_continue\` tool with \`enabled: true\` to activate. The system will automatically resume you when incomplete todos remain after you stop.
142
+ - The user can toggle this anytime via the \`/auto-continue\` command.
143
+
144
+ ### Validation routing
145
+ - Validation is a workflow stage owned by the Orchestrator, not a separate specialist
146
+ - Route UI/UX validation and review to @designer
147
+ - Route code review, simplification, maintainability review, and YAGNI checks to @oracle
148
+ - Route test writing, test updates, and changes touching test files to @fixer
149
+ - If a request spans multiple lanes, delegate only the lanes that add clear value
150
+
151
+ ## 6. Verify
152
+ - Run \`lsp_diagnostics\` for errors
153
+ - Use validation routing when applicable instead of doing all review work yourself
154
+ - If test files are involved, prefer @fixer for bounded test changes and @oracle only for test strategy or quality review
155
+ - Confirm specialists completed successfully
156
+ - Verify solution meets requirements
157
+
158
+ </Workflow>
159
+
160
+ <Communication>
161
+
162
+ ## Clarity Over Assumptions
163
+ - If request is vague or has multiple valid interpretations, ask a targeted question before proceeding
164
+ - Don't guess at critical details (file paths, API choices, architectural decisions)
165
+ - Do make reasonable assumptions for minor details and state them briefly
166
+
167
+ ## Concise Execution
168
+ - Answer directly, no preamble
169
+ - Don't summarize what you did unless asked
170
+ - Don't explain code unless asked
171
+ - One-word answers are fine when appropriate
172
+ - Brief delegation notices: "Checking docs via @librarian..." not "I'm going to delegate to @librarian because..."
173
+
174
+ ## No Flattery
175
+ Never: "Great question!" "Excellent idea!" "Smart choice!" or any praise of user input.
176
+
177
+ ## Honest Pushback
178
+ When user's approach seems problematic:
179
+ - State concern + alternative concisely
180
+ - Ask if they want to proceed anyway
181
+ - Don't lecture, don't blindly implement
182
+
183
+ ## Example
184
+ **Bad:** "Great question! Let me think about the best approach here. I'm going to delegate to @librarian to check the latest Next.js documentation for the App Router, and then I'll implement the solution for you."
185
+
186
+ **Good:** "Checking Next.js App Router docs via @librarian..."
187
+ [proceeds with implementation]
188
+
189
+ </Communication>
190
+ `;
191
+
2
192
  // src/cli/paths.ts
3
193
  import { homedir } from "os";
4
194
  import { dirname, join } from "path";
@@ -341,6 +531,11 @@ var McpNameSchema = z2.enum(["websearch", "context7", "grep_app"]);
341
531
  var BackgroundTaskConfigSchema = z2.object({
342
532
  maxConcurrentStarts: z2.number().min(1).max(50).default(10)
343
533
  });
534
+ var InterviewConfigSchema = z2.object({
535
+ maxQuestions: z2.number().int().min(1).max(10).default(2),
536
+ outputFolder: z2.string().min(1).default("interview"),
537
+ autoOpenBrowser: z2.boolean().default(true)
538
+ });
344
539
  var TodoContinuationConfigSchema = z2.object({
345
540
  maxContinuations: z2.number().int().min(1).max(50).default(5).describe("Maximum consecutive auto-continuations before stopping to ask user"),
346
541
  cooldownMs: z2.number().int().min(0).max(30000).default(3000).describe("Delay in ms before auto-continuing (gives user time to abort)"),
@@ -367,6 +562,7 @@ var PluginConfigSchema = z2.object({
367
562
  tmux: TmuxConfigSchema.optional(),
368
563
  websearch: WebsearchConfigSchema.optional(),
369
564
  background: BackgroundTaskConfigSchema.optional(),
565
+ interview: InterviewConfigSchema.optional(),
370
566
  todoContinuation: TodoContinuationConfigSchema.optional(),
371
567
  fallback: FailoverConfigSchema.optional(),
372
568
  council: CouncilConfigSchema.optional()
@@ -442,6 +638,7 @@ function loadPluginConfig(directory) {
442
638
  agents: deepMerge(config.agents, projectConfig.agents),
443
639
  tmux: deepMerge(config.tmux, projectConfig.tmux),
444
640
  multiplexer: deepMerge(config.multiplexer, projectConfig.multiplexer),
641
+ interview: deepMerge(config.interview, projectConfig.interview),
445
642
  fallback: deepMerge(config.fallback, projectConfig.fallback),
446
643
  council: deepMerge(config.council, projectConfig.council)
447
644
  };
@@ -568,221 +765,48 @@ async function extractSessionResult(client, sessionId, options) {
568
765
  return { text, empty: text.length === 0 };
569
766
  }
570
767
 
571
- // src/agents/orchestrator.ts
572
- function resolvePrompt(base, customPrompt, customAppendPrompt) {
573
- if (customPrompt)
574
- return customPrompt;
575
- if (customAppendPrompt)
576
- return `${base}
577
-
578
- ${customAppendPrompt}`;
579
- return base;
580
- }
581
- var ORCHESTRATOR_PROMPT = `<Role>
582
- You are an AI coding orchestrator that optimizes for quality, speed, cost, and reliability by delegating to specialists when it provides net efficiency gains.
583
- </Role>
768
+ // src/agents/council.ts
769
+ var COUNCIL_AGENT_PROMPT = `You are the Council agent \u2014 a multi-LLM orchestration system that runs consensus across multiple models.
584
770
 
585
- <Agents>
771
+ **Tool**: You have access to the \`council_session\` tool.
586
772
 
587
- @explorer
588
- - Role: Parallel search specialist for discovering unknowns across the codebase
589
- - Stats: 3x faster codebase search than orchestrator, 1/2 cost of orchestrator
590
- - Capabilities: Glob, grep, AST queries to locate files, symbols, patterns
591
- - **Delegate when:** Need to discover what exists before planning \u2022 Parallel searches speed discovery \u2022 Need summarized map vs full contents \u2022 Broad/uncertain scope
592
- - **Don't delegate when:** Know the path and need actual content \u2022 Need full file anyway \u2022 Single specific lookup \u2022 About to edit the file
773
+ **When to use**:
774
+ - When invoked by a user with a request
775
+ - When you want multiple expert opinions on a complex problem
776
+ - When higher confidence is needed through model consensus
593
777
 
594
- @librarian
595
- - Role: Authoritative source for current library docs and API references
596
- - Stats: 10x better finding up-to-date library docs than orchestrator, 1/2 cost of orchestrator
597
- - Capabilities: Fetches latest official docs, examples, API signatures, version-specific behavior via grep_app MCP
598
- - **Delegate when:** Libraries with frequent API changes (React, Next.js, AI SDKs) \u2022 Complex APIs needing official examples (ORMs, auth) \u2022 Version-specific behavior matters \u2022 Unfamiliar library \u2022 Edge cases or advanced features \u2022 Nuanced best practices
599
- - **Don't delegate when:** Standard usage you're confident about (\`Array.map()\`, \`fetch()\`) \u2022 Simple stable APIs \u2022 General programming knowledge \u2022 Info already in conversation \u2022 Built-in language features
600
- - **Rule of thumb:** "How does this library work?" \u2192 @librarian. "How does programming work?" \u2192 yourself.
778
+ **Usage**:
779
+ 1. Call the \`council_session\` tool with the user's prompt
780
+ 2. Optionally specify a preset (default: "default")
781
+ 3. Receive the synthesized response from the council master
782
+ 4. Present the result to the user
601
783
 
602
- @oracle
603
- - Role: Strategic advisor for high-stakes decisions and persistent problems, code reviewer
604
- - Stats: 5x better decision maker, problem solver, investigator than orchestrator, 0.8x speed of orchestrator, same cost.
605
- - Capabilities: Deep architectural reasoning, system-level trade-offs, complex debugging, code review, simplification, maintainability review
606
- - **Delegate when:** Major architectural decisions with long-term impact \u2022 Problems persisting after 2+ fix attempts \u2022 High-risk multi-system refactors \u2022 Costly trade-offs (performance vs maintainability) \u2022 Complex debugging with unclear root cause \u2022 Security/scalability/data integrity decisions \u2022 Genuinely uncertain and cost of wrong choice is high \u2022 When a workflow calls for a **reviewer** subagent \u2022 Code needs simplification or YAGNI scrutiny
607
- - **Don't delegate when:** Routine decisions you're confident about \u2022 First bug fix attempt \u2022 Straightforward trade-offs \u2022 Tactical "how" vs strategic "should" \u2022 Time-sensitive good-enough decisions \u2022 Quick research/testing can answer
608
- - **Rule of thumb:** Need senior architect review? \u2192 @oracle. Need code review or simplification? \u2192 @oracle. Just do it and PR? \u2192 yourself.
784
+ **Behavior**:
785
+ - Delegate requests directly to council_session
786
+ - Don't pre-analyze or filter the prompt
787
+ - Present the synthesized result verbatim \u2014 do not re-summarize or condense
788
+ - Briefly explain the consensus if requested`;
789
+ function createCouncilAgent(model, customPrompt, customAppendPrompt) {
790
+ const prompt = resolvePrompt(COUNCIL_AGENT_PROMPT, customPrompt, customAppendPrompt);
791
+ const definition = {
792
+ name: "council",
793
+ description: "Multi-LLM council agent that synthesizes responses from multiple models for higher-quality outputs",
794
+ config: {
795
+ temperature: 0.1,
796
+ prompt
797
+ }
798
+ };
799
+ if (model) {
800
+ definition.config.model = model;
801
+ }
802
+ return definition;
803
+ }
804
+ function formatCouncillorPrompt(userPrompt, councillorPrompt) {
805
+ if (!councillorPrompt)
806
+ return userPrompt;
807
+ return `${councillorPrompt}
609
808
 
610
- @designer
611
- - Role: UI/UX specialist for intentional, polished experiences
612
- - Stats: 10x better UI/UX than orchestrator
613
- - Capabilities: Visual direction, interactions, responsive layouts, design systems with aesthetic intent, UI/UX review
614
- - **Delegate when:** User-facing interfaces needing polish \u2022 Responsive layouts \u2022 UX-critical components (forms, nav, dashboards) \u2022 Visual consistency systems \u2022 Animations/micro-interactions \u2022 Landing/marketing pages \u2022 Refining functional\u2192delightful \u2022 Reviewing existing UI/UX quality
615
- - **Don't delegate when:** Backend/logic with no visual \u2022 Quick prototypes where design doesn't matter yet
616
- - **Rule of thumb:** Users see it and polish matters? \u2192 @designer. Headless/functional? \u2192 yourself.
617
-
618
- @fixer
619
- - Role: Fast execution specialist for well-defined tasks, which empowers orchestrator with parallel, speedy executions
620
- - Stats: 2x faster code edits, 1/2 cost of orchestrator, 0.8x quality of orchestrator
621
- - Tools/Constraints: Execution-focused\u2014no research, no architectural decisions
622
- - **Delegate when:** For implementation work, think and triage first. If the change is non-trivial or multi-file, hand bounded execution to @fixer \u2022 Writing or updating tests \u2022 Tasks that touch test files, fixtures, mocks, or test helpers
623
- - **Don't delegate when:** Needs discovery/research/decisions \u2022 Single small change (<20 lines, one file) \u2022 Unclear requirements needing iteration \u2022 Explaining to fixer > doing \u2022 Tight integration with your current work \u2022 Sequential dependencies
624
- - **Rule of thumb:** Explaining > doing? \u2192 yourself. Test file modifications and bounded implementation work usually go to @fixer. Orchestrator paths selection is vastly improved by Fixer. eg it can reduce overall speed if Orchestrator splits what's usually a single task into multiple subtasks and parallelize it with fixer.
625
-
626
- @council
627
- - Role: Multi-LLM consensus engine for high-confidence answers
628
- - Stats: 3x slower than orchestrator, 3x or more cost of orchestrator
629
- - Capabilities: Runs multiple models in parallel, synthesizes their responses via a council master
630
- - **Delegate when:** Critical decisions needing diverse model perspectives \u2022 High-stakes architectural choices where consensus reduces risk \u2022 Ambiguous problems where multi-model disagreement is informative \u2022 Security-sensitive design reviews
631
- - **Don't delegate when:** Straightforward tasks you're confident about \u2022 Speed matters more than confidence \u2022 Single-model answer is sufficient \u2022 Routine implementation work
632
- - **Result handling:** Present the council's synthesized response verbatim. Do not re-summarize \u2014 the council master has already produced the final answer.
633
- - **Rule of thumb:** Need second/third opinions from different models? \u2192 @council. One good answer enough? \u2192 yourself.
634
-
635
- </Agents>
636
-
637
- <Workflow>
638
-
639
- ## 1. Understand
640
- Parse request: explicit requirements + implicit needs.
641
-
642
- ## 2. Path Selection
643
- Evaluate approach by: quality, speed, cost, reliability.
644
- Choose the path that optimizes all four.
645
-
646
- ## 3. Delegation Check
647
- **STOP. Review specialists before acting.**
648
-
649
- !!! Review available agents and delegation rules. Decide whether to delegate or do it yourself. !!!
650
-
651
- **Delegation efficiency:**
652
- - Reference paths/lines, don't paste files (\`src/app.ts:42\` not full contents)
653
- - Provide context summaries, let specialists read what they need
654
- - Brief user on delegation goal before each call
655
- - Skip delegation if overhead \u2265 doing it yourself
656
-
657
- ## 4. Split and Parallelize
658
- Can tasks be split into subtasks and run in parallel?
659
- - Multiple @explorer searches across different domains?
660
- - @explorer + @librarian research in parallel?
661
- - Multiple @fixer instances for faster, scoped implementation?
662
-
663
- Balance: respect dependencies, avoid parallelizing what must be sequential.
664
-
665
- ## 5. Execute
666
- 1. Break complex tasks into todos
667
- 2. Fire parallel research/implementation
668
- 3. Delegate to specialists or do it yourself based on step 3
669
- 4. Integrate results
670
- 5. Adjust if needed
671
-
672
- ### Auto-Continue
673
- When working through multi-step tasks, consider enabling auto-continue to avoid stopping between batches:
674
- - **Enable when:** User requests autonomous/batch work, or you create 4+ todos in a session
675
- - **Don't enable when:** User is in an interactive/conversational flow, or each step needs explicit review
676
- - Use the \`auto_continue\` tool with \`enabled: true\` to activate. The system will automatically resume you when incomplete todos remain after you stop.
677
- - The user can toggle this anytime via the \`/auto-continue\` command.
678
-
679
- ### Validation routing
680
- - Validation is a workflow stage owned by the Orchestrator, not a separate specialist
681
- - Route UI/UX validation and review to @designer
682
- - Route code review, simplification, maintainability review, and YAGNI checks to @oracle
683
- - Route test writing, test updates, and changes touching test files to @fixer
684
- - If a request spans multiple lanes, delegate only the lanes that add clear value
685
-
686
- ## 6. Verify
687
- - Run \`lsp_diagnostics\` for errors
688
- - Use validation routing when applicable instead of doing all review work yourself
689
- - If test files are involved, prefer @fixer for bounded test changes and @oracle only for test strategy or quality review
690
- - Confirm specialists completed successfully
691
- - Verify solution meets requirements
692
-
693
- </Workflow>
694
-
695
- <Communication>
696
-
697
- ## Clarity Over Assumptions
698
- - If request is vague or has multiple valid interpretations, ask a targeted question before proceeding
699
- - Don't guess at critical details (file paths, API choices, architectural decisions)
700
- - Do make reasonable assumptions for minor details and state them briefly
701
-
702
- ## Concise Execution
703
- - Answer directly, no preamble
704
- - Don't summarize what you did unless asked
705
- - Don't explain code unless asked
706
- - One-word answers are fine when appropriate
707
- - Brief delegation notices: "Checking docs via @librarian..." not "I'm going to delegate to @librarian because..."
708
-
709
- ## No Flattery
710
- Never: "Great question!" "Excellent idea!" "Smart choice!" or any praise of user input.
711
-
712
- ## Honest Pushback
713
- When user's approach seems problematic:
714
- - State concern + alternative concisely
715
- - Ask if they want to proceed anyway
716
- - Don't lecture, don't blindly implement
717
-
718
- ## Example
719
- **Bad:** "Great question! Let me think about the best approach here. I'm going to delegate to @librarian to check the latest Next.js documentation for the App Router, and then I'll implement the solution for you."
720
-
721
- **Good:** "Checking Next.js App Router docs via @librarian..."
722
- [proceeds with implementation]
723
-
724
- </Communication>
725
- `;
726
- function createOrchestratorAgent(model, customPrompt, customAppendPrompt) {
727
- const prompt = resolvePrompt(ORCHESTRATOR_PROMPT, customPrompt, customAppendPrompt);
728
- const definition = {
729
- name: "orchestrator",
730
- description: "AI coding orchestrator that delegates tasks to specialist agents for optimal quality, speed, and cost",
731
- config: {
732
- temperature: 0.1,
733
- prompt
734
- }
735
- };
736
- if (Array.isArray(model)) {
737
- definition._modelArray = model.map((m) => typeof m === "string" ? { id: m } : m);
738
- } else if (typeof model === "string" && model) {
739
- definition.config.model = model;
740
- }
741
- return definition;
742
- }
743
-
744
- // src/agents/council.ts
745
- var COUNCIL_AGENT_PROMPT = `You are the Council agent \u2014 a multi-LLM orchestration system that runs consensus across multiple models.
746
-
747
- **Tool**: You have access to the \`council_session\` tool.
748
-
749
- **When to use**:
750
- - When invoked by a user with a request
751
- - When you want multiple expert opinions on a complex problem
752
- - When higher confidence is needed through model consensus
753
-
754
- **Usage**:
755
- 1. Call the \`council_session\` tool with the user's prompt
756
- 2. Optionally specify a preset (default: "default")
757
- 3. Receive the synthesized response from the council master
758
- 4. Present the result to the user
759
-
760
- **Behavior**:
761
- - Delegate requests directly to council_session
762
- - Don't pre-analyze or filter the prompt
763
- - Present the synthesized result verbatim \u2014 do not re-summarize or condense
764
- - Briefly explain the consensus if requested`;
765
- function createCouncilAgent(model, customPrompt, customAppendPrompt) {
766
- const prompt = resolvePrompt(COUNCIL_AGENT_PROMPT, customPrompt, customAppendPrompt);
767
- const definition = {
768
- name: "council",
769
- description: "Multi-LLM council agent that synthesizes responses from multiple models for higher-quality outputs",
770
- config: {
771
- temperature: 0.1,
772
- prompt
773
- }
774
- };
775
- if (model) {
776
- definition.config.model = model;
777
- }
778
- return definition;
779
- }
780
- function formatCouncillorPrompt(userPrompt, councillorPrompt) {
781
- if (!councillorPrompt)
782
- return userPrompt;
783
- return `${councillorPrompt}
784
-
785
- ---
809
+ ---
786
810
 
787
811
  ${userPrompt}`;
788
812
  }
@@ -3259,7 +3283,7 @@ Version is pinned. Update your plugin config to apply.`, "info", 8000);
3259
3283
  return;
3260
3284
  }
3261
3285
  invalidatePackage(PACKAGE_NAME);
3262
- const installSuccess = await runBunInstallSafe(ctx);
3286
+ const installSuccess = await runBunInstallSafe();
3263
3287
  if (installSuccess) {
3264
3288
  showToast(ctx, "OMO-Slim Updated!", `v${currentVersion} \u2192 v${latestVersion}
3265
3289
  Restart OpenCode to apply.`, "success", 8000);
@@ -3269,10 +3293,14 @@ Restart OpenCode to apply.`, "success", 8000);
3269
3293
  log("[auto-update-checker] bun install failed; update not installed");
3270
3294
  }
3271
3295
  }
3272
- async function runBunInstallSafe(ctx) {
3296
+ function getAutoUpdateInstallDir() {
3297
+ return CACHE_DIR;
3298
+ }
3299
+ async function runBunInstallSafe() {
3273
3300
  try {
3301
+ const installDir = getAutoUpdateInstallDir();
3274
3302
  const proc = Bun.spawn(["bun", "install"], {
3275
- cwd: ctx.directory,
3303
+ cwd: installDir,
3276
3304
  stdout: "pipe",
3277
3305
  stderr: "pipe"
3278
3306
  });
@@ -3996,196 +4024,1657 @@ function createTodoContinuationHook(ctx, config) {
3996
4024
  const lastText = lastAssistantMessage.parts.map((p) => p.text ?? "").join(" ");
3997
4025
  lastAssistantIsQuestion = isQuestion(lastText);
3998
4026
  }
3999
- log(`[${HOOK_NAME}] Fetched messages`, {
4000
- sessionID,
4001
- lastAssistantIsQuestion
4002
- });
4003
- } catch (error) {
4004
- log(`[${HOOK_NAME}] Warning: failed to fetch messages`, {
4005
- sessionID,
4006
- error: error instanceof Error ? error.message : String(error)
4007
- });
4008
- return;
4027
+ log(`[${HOOK_NAME}] Fetched messages`, {
4028
+ sessionID,
4029
+ lastAssistantIsQuestion
4030
+ });
4031
+ } catch (error) {
4032
+ log(`[${HOOK_NAME}] Warning: failed to fetch messages`, {
4033
+ sessionID,
4034
+ error: error instanceof Error ? error.message : String(error)
4035
+ });
4036
+ return;
4037
+ }
4038
+ if (lastAssistantIsQuestion) {
4039
+ log(`[${HOOK_NAME}] Skipped: last message is question`, {
4040
+ sessionID
4041
+ });
4042
+ return;
4043
+ }
4044
+ if (state.consecutiveContinuations >= maxContinuations) {
4045
+ log(`[${HOOK_NAME}] Skipped: max continuations reached`, {
4046
+ sessionID,
4047
+ consecutive: state.consecutiveContinuations,
4048
+ max: maxContinuations
4049
+ });
4050
+ return;
4051
+ }
4052
+ const now = Date.now();
4053
+ if (now < state.suppressUntil) {
4054
+ log(`[${HOOK_NAME}] Skipped: in suppress window`, {
4055
+ sessionID,
4056
+ suppressUntil: state.suppressUntil
4057
+ });
4058
+ return;
4059
+ }
4060
+ if (state.pendingTimer !== null || state.isAutoInjecting) {
4061
+ log(`[${HOOK_NAME}] Skipped: timer pending or injection in flight`, {
4062
+ sessionID
4063
+ });
4064
+ return;
4065
+ }
4066
+ log(`[${HOOK_NAME}] Scheduling continuation`, {
4067
+ sessionID,
4068
+ delayMs: cooldownMs
4069
+ });
4070
+ ctx.client.session.prompt({
4071
+ path: { id: sessionID },
4072
+ body: {
4073
+ noReply: true,
4074
+ parts: [
4075
+ {
4076
+ type: "text",
4077
+ text: [
4078
+ `\u2394 Auto-continue: ${incompleteCount} incomplete todos remaining \u2014 resuming in ${cooldownMs / 1000}s \u2014 Esc\xD72 to cancel`,
4079
+ "",
4080
+ "[system status: continue without acknowledging this notification]"
4081
+ ].join(`
4082
+ `)
4083
+ }
4084
+ ]
4085
+ }
4086
+ }).catch(() => {});
4087
+ state.pendingTimer = setTimeout(async () => {
4088
+ state.pendingTimer = null;
4089
+ if (!state.enabled) {
4090
+ log(`[${HOOK_NAME}] Cancelled: disabled during cooldown`, {
4091
+ sessionID
4092
+ });
4093
+ return;
4094
+ }
4095
+ state.isAutoInjecting = true;
4096
+ try {
4097
+ await ctx.client.session.prompt({
4098
+ path: { id: sessionID },
4099
+ body: {
4100
+ parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)]
4101
+ }
4102
+ });
4103
+ state.consecutiveContinuations++;
4104
+ log(`[${HOOK_NAME}] Continuation injected`, {
4105
+ sessionID,
4106
+ consecutive: state.consecutiveContinuations
4107
+ });
4108
+ } catch (error) {
4109
+ log(`[${HOOK_NAME}] Error: failed to inject continuation`, {
4110
+ sessionID,
4111
+ error: error instanceof Error ? error.message : String(error)
4112
+ });
4113
+ } finally {
4114
+ state.isAutoInjecting = false;
4115
+ }
4116
+ }, cooldownMs);
4117
+ } else if (event.type === "session.status") {
4118
+ const status = properties.status;
4119
+ const sessionID = properties.sessionID;
4120
+ if (status?.type === "busy") {
4121
+ const isOrchestrator = sessionID === state.orchestratorSessionId;
4122
+ if (isOrchestrator) {
4123
+ cancelPendingTimer(state);
4124
+ }
4125
+ if (!state.isAutoInjecting && isOrchestrator && state.consecutiveContinuations > 0) {
4126
+ state.consecutiveContinuations = 0;
4127
+ log(`[${HOOK_NAME}] Reset consecutive count on user activity`, {
4128
+ sessionID
4129
+ });
4130
+ }
4131
+ }
4132
+ } else if (event.type === "session.error") {
4133
+ const error = properties.error;
4134
+ const sessionID = properties.sessionID;
4135
+ const errorName = error?.name;
4136
+ const isOrchestrator = sessionID === state.orchestratorSessionId;
4137
+ if (isOrchestrator && (errorName === "MessageAbortedError" || errorName === "AbortError")) {
4138
+ state.suppressUntil = Date.now() + SUPPRESS_AFTER_ABORT_MS;
4139
+ log(`[${HOOK_NAME}] Suppressed continuation after abort`, {
4140
+ sessionID,
4141
+ errorName
4142
+ });
4143
+ }
4144
+ if (isOrchestrator) {
4145
+ cancelPendingTimer(state);
4146
+ log(`[${HOOK_NAME}] Cancelled pending timer on error`, {
4147
+ sessionID
4148
+ });
4149
+ }
4150
+ } else if (event.type === "session.deleted") {
4151
+ const deletedSessionId = properties.info?.id ?? properties.sessionID;
4152
+ if (state.orchestratorSessionId === deletedSessionId) {
4153
+ cancelPendingTimer(state);
4154
+ log(`[${HOOK_NAME}] Cancelled pending timer on orchestrator delete`, {
4155
+ sessionID: deletedSessionId
4156
+ });
4157
+ resetState(state);
4158
+ state.orchestratorSessionId = null;
4159
+ log(`[${HOOK_NAME}] Reset orchestrator session on delete`, {
4160
+ sessionID: deletedSessionId
4161
+ });
4162
+ }
4163
+ }
4164
+ }
4165
+ async function handleCommandExecuteBefore(input, output) {
4166
+ if (input.command !== COMMAND_NAME) {
4167
+ return;
4168
+ }
4169
+ if (!state.orchestratorSessionId) {
4170
+ state.orchestratorSessionId = input.sessionID;
4171
+ }
4172
+ output.parts.length = 0;
4173
+ const arg = input.arguments.trim().toLowerCase();
4174
+ let newEnabled;
4175
+ if (arg === "on") {
4176
+ newEnabled = true;
4177
+ } else if (arg === "off") {
4178
+ newEnabled = false;
4179
+ } else {
4180
+ newEnabled = !state.enabled;
4181
+ }
4182
+ state.enabled = newEnabled;
4183
+ state.consecutiveContinuations = 0;
4184
+ if (!newEnabled) {
4185
+ cancelPendingTimer(state);
4186
+ output.parts.push(createInternalAgentTextPart("[Auto-continue: disabled by user command.]"));
4187
+ log(`[${HOOK_NAME}] Disabled via /${COMMAND_NAME} command`);
4188
+ return;
4189
+ }
4190
+ state.suppressUntil = 0;
4191
+ log(`[${HOOK_NAME}] Enabled via /${COMMAND_NAME} command`, {
4192
+ maxContinuations
4193
+ });
4194
+ let hasIncompleteTodos = false;
4195
+ try {
4196
+ const todosResult = await ctx.client.session.todo({
4197
+ path: { id: input.sessionID }
4198
+ });
4199
+ const todos = todosResult.data;
4200
+ hasIncompleteTodos = todos.some((t) => !TERMINAL_TODO_STATUSES.includes(t.status));
4201
+ } catch (error) {
4202
+ log(`[${HOOK_NAME}] Warning: failed to fetch todos in command hook`, {
4203
+ sessionID: input.sessionID,
4204
+ error: error instanceof Error ? error.message : String(error)
4205
+ });
4206
+ }
4207
+ if (hasIncompleteTodos) {
4208
+ output.parts.push(createInternalAgentTextPart(`${CONTINUATION_PROMPT} [Auto-continue enabled: up to ${maxContinuations} continuations.]`));
4209
+ } else {
4210
+ output.parts.push(createInternalAgentTextPart(`[Auto-continue: enabled for up to ${maxContinuations} continuations. No incomplete todos right now.]`));
4211
+ }
4212
+ }
4213
+ return {
4214
+ tool: { auto_continue: autoContinue },
4215
+ handleEvent,
4216
+ handleCommandExecuteBefore
4217
+ };
4218
+ }
4219
+ // src/interview/server.ts
4220
+ import {
4221
+ createServer
4222
+ } from "http";
4223
+ import { URL as URL2 } from "url";
4224
+
4225
+ // src/interview/ui.ts
4226
+ function renderInterviewPage(interviewId) {
4227
+ const safeTitle = interviewId.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
4228
+ return `<!doctype html>
4229
+ <html lang="en">
4230
+ <head>
4231
+ <meta charset="utf-8" />
4232
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
4233
+ <title>Interview ${safeTitle}</title>
4234
+ <style>
4235
+ :root { color-scheme: dark; }
4236
+ body {
4237
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
4238
+ margin: 0;
4239
+ background: #000000;
4240
+ color: #ffffff;
4241
+ line-height: 1.6;
4242
+ -webkit-font-smoothing: antialiased;
4243
+ font-size: 16px;
4244
+ }
4245
+ .wrap { max-width: 680px; margin: 0 auto; padding: 56px 24px; }
4246
+ .brand-header {
4247
+ display: flex;
4248
+ flex-direction: column;
4249
+ align-items: center;
4250
+ gap: 16px;
4251
+ margin-bottom: 32px;
4252
+ text-align: center;
4253
+ }
4254
+ .brand-mark {
4255
+ width: 144px;
4256
+ height: 144px;
4257
+ object-fit: contain;
4258
+ filter: drop-shadow(0 10px 30px rgba(255,255,255,0.1));
4259
+ }
4260
+ h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 12px; line-height: 1.2; }
4261
+ 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; }
4262
+ h3 { font-size: 18px; font-weight: 500; margin-bottom: 16px; line-height: 1.4; }
4263
+ p { margin-top: 0; }
4264
+ .muted { color: rgba(255,255,255,0.5); font-size: 16px; }
4265
+ .meta { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: rgba(255,255,255,0.4); margin-bottom: 16px; letter-spacing: 0.05em; text-transform: uppercase; }
4266
+
4267
+ .file-path-container {
4268
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
4269
+ font-size: 13px;
4270
+ color: rgba(255,255,255,0.6);
4271
+ background: rgba(255,255,255,0.05);
4272
+ padding: 8px 12px;
4273
+ border-radius: 6px;
4274
+ margin-bottom: 36px;
4275
+ display: flex;
4276
+ align-items: center;
4277
+ gap: 8px;
4278
+ border: 1px solid rgba(255,255,255,0.08);
4279
+ }
4280
+ .file-path-icon {
4281
+ opacity: 0.5;
4282
+ }
4283
+
4284
+ .question {
4285
+ background: rgba(255,255,255,0.02);
4286
+ border: 1px solid rgba(255,255,255,0.1);
4287
+ border-left: 1px solid rgba(255,255,255,0.1);
4288
+ border-radius: 6px;
4289
+ padding: 28px;
4290
+ margin-bottom: 32px;
4291
+ transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
4292
+ }
4293
+ .question:focus-within {
4294
+ border-color: rgba(255,255,255,0.3);
4295
+ }
4296
+
4297
+ /* Make active question much clearer */
4298
+ .question.active-question {
4299
+ background: rgba(255,255,255,0.04);
4300
+ border-color: rgba(255,255,255,0.4);
4301
+ border-left: 4px solid #ffffff;
4302
+ box-shadow: 0 8px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.1);
4303
+ transform: translateX(4px);
4304
+ }
4305
+
4306
+ .options { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }
4307
+
4308
+ .option {
4309
+ border: 1px solid rgba(255,255,255,0.1);
4310
+ background: transparent;
4311
+ color: inherit;
4312
+ border-radius: 6px;
4313
+ padding: 14px 18px;
4314
+ cursor: pointer;
4315
+ text-align: left;
4316
+ font-size: 16px;
4317
+ transition: all 0.2s ease;
4318
+ display: flex;
4319
+ align-items: center;
4320
+ }
4321
+ .option:hover {
4322
+ background: rgba(255,255,255,0.06);
4323
+ border-color: rgba(255,255,255,0.3);
4324
+ }
4325
+ .option.selected {
4326
+ background: #ffffff;
4327
+ color: #000000;
4328
+ border-color: #ffffff;
4329
+ font-weight: 500;
4330
+ }
4331
+
4332
+ .shortcut {
4333
+ display: inline-flex;
4334
+ align-items: center;
4335
+ justify-content: center;
4336
+ background: rgba(255,255,255,0.1);
4337
+ color: rgba(255,255,255,0.8);
4338
+ border-radius: 4px;
4339
+ min-width: 20px;
4340
+ height: 20px;
4341
+ padding: 0 4px;
4342
+ font-size: 12px;
4343
+ margin-right: 12px;
4344
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
4345
+ }
4346
+ .option.selected .shortcut {
4347
+ background: rgba(0,0,0,0.15);
4348
+ color: rgba(0,0,0,0.9);
4349
+ }
4350
+
4351
+ .option-text {
4352
+ flex: 1;
4353
+ line-height: 1.4;
4354
+ }
4355
+
4356
+ .recommended-badge {
4357
+ font-size: 11px;
4358
+ text-transform: uppercase;
4359
+ letter-spacing: 0.05em;
4360
+ background: rgba(255,255,255,0.15);
4361
+ color: rgba(255,255,255,0.9);
4362
+ padding: 4px 8px;
4363
+ border-radius: 999px;
4364
+ margin-left: 12px;
4365
+ font-weight: 600;
4366
+ }
4367
+ .option.selected .recommended-badge {
4368
+ background: rgba(0,0,0,0.15);
4369
+ color: rgba(0,0,0,0.8);
4370
+ }
4371
+
4372
+ .submit-shortcut {
4373
+ display: inline-block;
4374
+ margin-left: 10px;
4375
+ padding: 3px 8px;
4376
+ border-radius: 999px;
4377
+ background: rgba(0,0,0,0.08);
4378
+ color: rgba(0,0,0,0.7);
4379
+ font-size: 12px;
4380
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
4381
+ }
4382
+
4383
+ textarea {
4384
+ width: 100%;
4385
+ box-sizing: border-box;
4386
+ min-height: 140px;
4387
+ border-radius: 6px;
4388
+ border: 1px solid rgba(255,255,255,0.15);
4389
+ background: rgba(0,0,0,0.6);
4390
+ color: inherit;
4391
+ padding: 16px;
4392
+ font-family: inherit;
4393
+ font-size: 16px;
4394
+ line-height: 1.5;
4395
+ resize: vertical;
4396
+ outline: none;
4397
+ transition: border-color 0.2s ease;
4398
+ }
4399
+ textarea:focus {
4400
+ border-color: rgba(255,255,255,0.5);
4401
+ box-shadow: 0 0 0 1px rgba(255,255,255,0.1);
4402
+ }
4403
+
4404
+ .hidden-textarea {
4405
+ display: none;
4406
+ }
4407
+
4408
+ button.primary {
4409
+ background: #ffffff;
4410
+ color: #000000;
4411
+ border: 0;
4412
+ border-radius: 6px;
4413
+ padding: 16px 24px;
4414
+ font-size: 16px;
4415
+ font-weight: 600;
4416
+ cursor: pointer;
4417
+ width: 100%;
4418
+ transition: opacity 0.2s ease, transform 0.1s ease;
4419
+ }
4420
+ button.primary:hover:not(:disabled) {
4421
+ opacity: 0.9;
4422
+ transform: translateY(-1px);
4423
+ }
4424
+ button.primary:active:not(:disabled) {
4425
+ transform: translateY(1px);
4426
+ }
4427
+ button.primary:disabled {
4428
+ opacity: 0.3;
4429
+ cursor: not-allowed;
4430
+ }
4431
+
4432
+ .footer {
4433
+ margin-top: 32px;
4434
+ text-align: center;
4435
+ font-size: 13px;
4436
+ color: rgba(255,255,255,0.4);
4437
+ }
4438
+
4439
+ /* Loading State Overlay */
4440
+ .loading-overlay {
4441
+ position: fixed;
4442
+ top: 0; left: 0; right: 0; bottom: 0;
4443
+ background: rgba(0, 0, 0, 0.85);
4444
+ display: flex;
4445
+ flex-direction: column;
4446
+ align-items: center;
4447
+ justify-content: center;
4448
+ z-index: 100;
4449
+ opacity: 0;
4450
+ pointer-events: none;
4451
+ backdrop-filter: blur(8px);
4452
+ transition: opacity 0.3s ease;
4453
+ }
4454
+ .loading-overlay.active {
4455
+ opacity: 1;
4456
+ pointer-events: all;
4457
+ }
4458
+
4459
+ .loading-overlay .status-text {
4460
+ font-size: 15px;
4461
+ letter-spacing: 0.1em;
4462
+ text-transform: uppercase;
4463
+ color: #ffffff;
4464
+ font-weight: 500;
4465
+ }
4466
+ </style>
4467
+ </head>
4468
+ <body>
4469
+ <div class="wrap">
4470
+ <div class="brand-header">
4471
+ <svg
4472
+ class="brand-mark"
4473
+ viewBox="0 0 144 144"
4474
+ role="img"
4475
+ aria-label="Oh My Opencode Slim"
4476
+ >
4477
+ <rect
4478
+ x="12"
4479
+ y="12"
4480
+ width="120"
4481
+ height="120"
4482
+ rx="32"
4483
+ fill="rgba(255,255,255,0.08)"
4484
+ stroke="rgba(255,255,255,0.18)"
4485
+ stroke-width="2"
4486
+ />
4487
+ <path
4488
+ d="M50 48h18c16 0 26 10 26 24s-10 24-26 24H50z"
4489
+ fill="none"
4490
+ stroke="white"
4491
+ stroke-width="8"
4492
+ stroke-linecap="round"
4493
+ stroke-linejoin="round"
4494
+ />
4495
+ <path
4496
+ d="M74 48h20c10 0 18 8 18 18v12c0 10-8 18-18 18H74"
4497
+ fill="none"
4498
+ stroke="white"
4499
+ stroke-width="8"
4500
+ stroke-linecap="round"
4501
+ stroke-linejoin="round"
4502
+ opacity="0.65"
4503
+ />
4504
+ </svg>
4505
+ </div>
4506
+ <h1 id="idea">Connecting...</h1>
4507
+ <p class="muted" id="summary">Preparing interview session</p>
4508
+
4509
+ <div class="meta">
4510
+ <span id="status">INITIALIZING</span>
4511
+ <span>OH MY OPENCODE SLIM</span>
4512
+ </div>
4513
+
4514
+ <div id="filePathContainer" class="file-path-container" style="display: none;">
4515
+ <span class="file-path-icon">\uD83D\uDCC4</span>
4516
+ <span id="markdownPath"></span>
4517
+ </div>
4518
+
4519
+ <div id="questions"></div>
4520
+
4521
+ <button class="primary" id="submitButton" disabled>Submit Answers <span class="submit-shortcut">\u2318\u21B5</span></button>
4522
+
4523
+ <div class="footer" id="submitStatus"></div>
4524
+ </div>
4525
+
4526
+ <div class="loading-overlay" id="loadingOverlay">
4527
+ <div class="status-text" id="loadingText">Processing...</div>
4528
+ </div>
4529
+
4530
+ <script>
4531
+ const interviewId = ${JSON.stringify(interviewId)};
4532
+ const state = { data: null, answers: {}, activeQuestionIndex: 0, lastSig: null, customMode: {} };
4533
+
4534
+ function updateSubmitButton() {
4535
+ const button = document.getElementById('submitButton');
4536
+ if (!state.data) {
4537
+ button.disabled = true;
4538
+ return;
4539
+ }
4540
+
4541
+ const questions = state.data.questions || [];
4542
+ const allAnswered = questions.every((question) =>
4543
+ (state.answers[question.id] || '').trim().length > 0,
4544
+ );
4545
+ button.disabled = state.data.isBusy || !questions.length || !allAnswered;
4546
+
4547
+ const overlay = document.getElementById('loadingOverlay');
4548
+ const overlayText = document.getElementById('loadingText');
4549
+ if (state.data.isBusy) {
4550
+ overlay.classList.add('active');
4551
+ overlayText.textContent = "Agent Thinking...";
4552
+ } else {
4553
+ overlay.classList.remove('active');
4554
+ }
4555
+ }
4556
+
4557
+ function getOptionButtonId(questionId, index) {
4558
+ return 'opt-' + questionId + '-' + index;
4559
+ }
4560
+
4561
+ function createOption(question, option, index, isCustom) {
4562
+ const button = document.createElement('button');
4563
+ button.type = 'button';
4564
+ button.className = 'option';
4565
+ button.id = getOptionButtonId(question.id, index);
4566
+
4567
+ const shortcut = index < 9 ? (index + 1) : '';
4568
+ if (shortcut) {
4569
+ const kbd = document.createElement('span');
4570
+ kbd.className = 'shortcut';
4571
+ kbd.textContent = shortcut;
4572
+ button.appendChild(kbd);
4573
+ }
4574
+
4575
+ const text = document.createElement('span');
4576
+ text.className = 'option-text';
4577
+ text.textContent = isCustom ? 'Custom' : option;
4578
+ button.appendChild(text);
4579
+
4580
+ // Visual marking for suggested/recommended answers
4581
+ if (!isCustom && question.suggested === option) {
4582
+ const badge = document.createElement('span');
4583
+ badge.className = 'recommended-badge';
4584
+ badge.textContent = 'Recommended';
4585
+ button.appendChild(badge);
4586
+ }
4587
+
4588
+ button.addEventListener('click', () => {
4589
+ const questions = state.data?.questions || [];
4590
+ const qIdx = questions.findIndex(q => q.id === question.id);
4591
+ if (qIdx !== -1) {
4592
+ state.activeQuestionIndex = qIdx;
4593
+ updateActiveQuestionFocus();
4594
+ }
4595
+ handleOptionSelect(question, option, isCustom);
4596
+ });
4597
+
4598
+ return button;
4599
+ }
4600
+
4601
+ function handleOptionSelect(question, option, isCustom) {
4602
+ const textarea = document.getElementById('answer-' + question.id);
4603
+
4604
+ if (isCustom) {
4605
+ state.customMode[question.id] = true;
4606
+ state.answers[question.id] = state.customMode[question.id]
4607
+ ? state.answers[question.id] || ''
4608
+ : '';
4609
+ updateTextareaVisibility(question.id);
4610
+ updateOptionsDOM(question.id);
4611
+ if (textarea) {
4612
+ textarea.focus();
4613
+ }
4614
+ } else {
4615
+ state.customMode[question.id] = false;
4616
+ state.answers[question.id] = option;
4617
+ updateTextareaVisibility(question.id);
4618
+ advanceToNextQuestion(question.id);
4619
+ }
4620
+
4621
+ updateSubmitButton();
4622
+ updateOptionsDOM(question.id);
4623
+ }
4624
+
4625
+ function updateTextareaVisibility(questionId) {
4626
+ const textarea = document.getElementById('answer-' + questionId);
4627
+ if (!textarea) return;
4628
+ if (state.customMode[questionId]) {
4629
+ textarea.classList.remove('hidden-textarea');
4630
+ } else {
4631
+ textarea.classList.add('hidden-textarea');
4632
+ }
4633
+ }
4634
+
4635
+ function advanceToNextQuestion(currentQuestionId) {
4636
+ const questions = state.data?.questions || [];
4637
+ const currentIndex = questions.findIndex(q => q.id === currentQuestionId);
4638
+
4639
+ if (currentIndex >= 0 && currentIndex < questions.length - 1) {
4640
+ state.activeQuestionIndex = currentIndex + 1;
4641
+ updateActiveQuestionFocus();
4642
+ const nextQuestion = questions[currentIndex + 1];
4643
+ const nextEl = document.getElementById('question-' + nextQuestion.id);
4644
+ if (nextEl) {
4645
+ nextEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
4646
+ }
4647
+ } else if (currentIndex === questions.length - 1) {
4648
+ const submitBtn = document.getElementById('submitButton');
4649
+ if (submitBtn && !submitBtn.disabled) {
4650
+ submitBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
4651
+ }
4652
+ }
4653
+ }
4654
+
4655
+ function updateOptionsDOM(questionId) {
4656
+ const question = (state.data?.questions || []).find(q => q.id === questionId);
4657
+ if (!question) return;
4658
+
4659
+ const predefined = question.options || [];
4660
+ const currentAnswer = state.answers[question.id];
4661
+
4662
+ predefined.forEach((opt, idx) => {
4663
+ const btn = document.getElementById(getOptionButtonId(questionId, idx));
4664
+ if (btn) {
4665
+ if (currentAnswer === opt) btn.classList.add('selected');
4666
+ else btn.classList.remove('selected');
4667
+ }
4668
+ });
4669
+
4670
+ const customBtn = document.getElementById(getOptionButtonId(questionId, predefined.length));
4671
+ if (customBtn) {
4672
+ if (state.customMode[questionId]) {
4673
+ customBtn.classList.add('selected');
4674
+ } else {
4675
+ customBtn.classList.remove('selected');
4676
+ }
4677
+ }
4678
+ }
4679
+
4680
+ function updateActiveQuestionFocus() {
4681
+ const questions = state.data?.questions || [];
4682
+ questions.forEach((q, idx) => {
4683
+ const wrapper = document.getElementById('question-' + q.id);
4684
+ if (wrapper) {
4685
+ if (idx === state.activeQuestionIndex) {
4686
+ wrapper.classList.add('active-question');
4687
+ } else {
4688
+ wrapper.classList.remove('active-question');
4689
+ }
4690
+ }
4691
+ });
4692
+ }
4693
+
4694
+ document.addEventListener('keydown', (e) => {
4695
+ const isSubmitShortcut =
4696
+ (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) ||
4697
+ (e.key === 's' && (e.metaKey || e.ctrlKey));
4698
+ if (isSubmitShortcut) {
4699
+ const submitBtn = document.getElementById('submitButton');
4700
+ if (submitBtn && !submitBtn.disabled) {
4701
+ submitBtn.click();
4702
+ e.preventDefault();
4703
+ }
4704
+ return;
4705
+ }
4706
+
4707
+ if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
4708
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
4709
+
4710
+ const questions = state.data?.questions || [];
4711
+ if (!questions.length) return;
4712
+
4713
+ const num = parseInt(e.key, 10);
4714
+ if (num >= 1 && num <= 9) {
4715
+ const activeQ = questions[state.activeQuestionIndex];
4716
+ if (!activeQ) return;
4717
+
4718
+ const options = activeQ.options || [];
4719
+ if (!options.length) return;
4720
+
4721
+ const idx = num - 1;
4722
+
4723
+ if (idx < options.length) {
4724
+ handleOptionSelect(activeQ, options[idx], false);
4725
+ e.preventDefault();
4726
+ } else if (idx === options.length) {
4727
+ handleOptionSelect(activeQ, 'Custom', true);
4728
+ e.preventDefault();
4729
+ }
4730
+
4731
+ }
4732
+
4733
+ if (e.key === 'ArrowDown') {
4734
+ if (state.activeQuestionIndex < questions.length - 1) {
4735
+ state.activeQuestionIndex++;
4736
+ updateActiveQuestionFocus();
4737
+ const wrapper = document.getElementById('question-' + questions[state.activeQuestionIndex].id);
4738
+ if (wrapper) wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
4739
+ e.preventDefault();
4740
+ }
4741
+ }
4742
+ if (e.key === 'ArrowUp') {
4743
+ if (state.activeQuestionIndex > 0) {
4744
+ state.activeQuestionIndex--;
4745
+ updateActiveQuestionFocus();
4746
+ const wrapper = document.getElementById('question-' + questions[state.activeQuestionIndex].id);
4747
+ if (wrapper) wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
4748
+ e.preventDefault();
4749
+ }
4750
+ }
4751
+ });
4752
+
4753
+ function renderQuestions(questions) {
4754
+ const sig = JSON.stringify(questions);
4755
+ const container = document.getElementById('questions');
4756
+
4757
+ if (state.lastSig === sig) {
4758
+ questions.forEach((q) => updateOptionsDOM(q.id));
4759
+ updateActiveQuestionFocus();
4760
+ return;
4761
+ }
4762
+
4763
+ state.lastSig = sig;
4764
+ container.replaceChildren();
4765
+
4766
+ if (!questions.length && !state.data?.isBusy) {
4767
+ const empty = document.createElement('p');
4768
+ empty.className = 'muted';
4769
+ empty.style.textAlign = 'center';
4770
+ empty.style.padding = '48px 0';
4771
+ empty.textContent = 'No active questions right now.';
4772
+ container.appendChild(empty);
4773
+ return;
4774
+ }
4775
+
4776
+ questions.forEach((question, idx) => {
4777
+ const wrapper = document.createElement('div');
4778
+ wrapper.className = 'question';
4779
+ wrapper.id = 'question-' + question.id;
4780
+
4781
+ if (question.suggested && !state.answers[question.id]) {
4782
+ state.answers[question.id] = question.suggested;
4783
+ state.customMode[question.id] = false;
4784
+ }
4785
+
4786
+ const title = document.createElement('h3');
4787
+ title.textContent = question.question;
4788
+ wrapper.appendChild(title);
4789
+
4790
+ const predefined = question.options || [];
4791
+ if (predefined.length) {
4792
+ const options = document.createElement('div');
4793
+ options.className = 'options';
4794
+ predefined.forEach((option, optIdx) => {
4795
+ options.appendChild(createOption(question, option, optIdx, false));
4796
+ });
4797
+ options.appendChild(createOption(question, 'Custom', predefined.length, true));
4798
+ wrapper.appendChild(options);
4799
+ }
4800
+
4801
+ const textarea = document.createElement('textarea');
4802
+ textarea.id = 'answer-' + question.id;
4803
+ textarea.placeholder = 'Type your answer here...';
4804
+ textarea.value = state.customMode[question.id] ? (state.answers[question.id] || '') : '';
4805
+ if (!state.customMode[question.id]) {
4806
+ textarea.classList.add('hidden-textarea');
4807
+ }
4808
+
4809
+ textarea.addEventListener('focus', () => {
4810
+ state.activeQuestionIndex = idx;
4811
+ updateActiveQuestionFocus();
4812
+ });
4813
+
4814
+ textarea.addEventListener('input', () => {
4815
+ state.answers[question.id] = textarea.value;
4816
+ updateSubmitButton();
4817
+ updateOptionsDOM(question.id);
4818
+ });
4819
+ wrapper.appendChild(textarea);
4820
+
4821
+ container.appendChild(wrapper);
4822
+ });
4823
+
4824
+ updateActiveQuestionFocus();
4825
+ questions.forEach(q => updateOptionsDOM(q.id));
4826
+ }
4827
+
4828
+ function render(data) {
4829
+ state.data = data;
4830
+ document.getElementById('idea').textContent = data.interview.idea || 'Interview';
4831
+ document.getElementById('summary').textContent = data.summary || 'Session in progress.';
4832
+ document.getElementById('status').textContent = data.mode.toUpperCase();
4833
+
4834
+ // Render Markdown Path
4835
+ const pathContainer = document.getElementById('filePathContainer');
4836
+ const pathElement = document.getElementById('markdownPath');
4837
+ const mdPath = data.markdownPath || (data.interview && data.interview.markdownPath);
4838
+ if (mdPath) {
4839
+ pathElement.textContent = mdPath;
4840
+ pathContainer.style.display = 'flex';
4841
+ } else {
4842
+ pathContainer.style.display = 'none';
4843
+ }
4844
+
4845
+ renderQuestions(data.questions || []);
4846
+ updateSubmitButton();
4847
+ }
4848
+
4849
+ async function refresh() {
4850
+ const response = await fetch('/api/interviews/' + encodeURIComponent(interviewId) + '/state');
4851
+ const data = await response.json();
4852
+ if (!response.ok) throw new Error(data.error || 'Failed to load state');
4853
+ render(data);
4854
+ }
4855
+
4856
+ document.getElementById('submitButton').addEventListener('click', async () => {
4857
+ if (!state.data) return;
4858
+ const answers = (state.data.questions || []).map((question) => {
4859
+ return {
4860
+ questionId: question.id,
4861
+ answer: (state.answers[question.id] || '').trim(),
4862
+ };
4863
+ });
4864
+
4865
+ const overlay = document.getElementById('loadingOverlay');
4866
+ const overlayText = document.getElementById('loadingText');
4867
+ overlay.classList.add('active');
4868
+ overlayText.textContent = "Submitting Answers...";
4869
+
4870
+ try {
4871
+ const response = await fetch('/api/interviews/' + encodeURIComponent(interviewId) + '/answers', {
4872
+ method: 'POST',
4873
+ headers: { 'content-type': 'application/json' },
4874
+ body: JSON.stringify({ answers }),
4875
+ });
4876
+ const payload = await response.json();
4877
+ document.getElementById('submitStatus').textContent = payload.message || (response.ok ? 'Answers submitted successfully.' : 'Submission failed.');
4878
+ } catch (err) {
4879
+ document.getElementById('submitStatus').textContent = 'Error submitting answers.';
4880
+ }
4881
+ try {
4882
+ await refresh();
4883
+ } catch (_error) {
4884
+ overlay.classList.remove('active');
4885
+ }
4886
+ });
4887
+
4888
+ refresh().catch((error) => {
4889
+ document.getElementById('submitStatus').textContent = error.message || 'Failed to load interview.';
4890
+ });
4891
+ setInterval(() => {
4892
+ refresh().catch(() => {});
4893
+ }, 2500);
4894
+ </script>
4895
+ </body>
4896
+ </html>`;
4897
+ }
4898
+
4899
+ // src/interview/server.ts
4900
+ function getSubmissionStatus(error) {
4901
+ if (error instanceof SyntaxError) {
4902
+ return 400;
4903
+ }
4904
+ const message = error instanceof Error ? error.message : "";
4905
+ if (message === "Interview not found") {
4906
+ return 404;
4907
+ }
4908
+ if (message.includes("busy")) {
4909
+ return 409;
4910
+ }
4911
+ if (message.includes("waiting for a valid agent update") || message.includes("There are no active interview questions") || message.includes("Answer every active interview question") || message.includes("Answers do not match") || message.includes("Request body too large") || message.includes("Invalid answers payload") || message.includes("no longer active")) {
4912
+ return 400;
4913
+ }
4914
+ return 500;
4915
+ }
4916
+ function parseAnswersPayload(value) {
4917
+ if (!value || typeof value !== "object") {
4918
+ throw new Error("Invalid answers payload.");
4919
+ }
4920
+ const answersRaw = value.answers;
4921
+ if (!Array.isArray(answersRaw)) {
4922
+ throw new Error("Invalid answers payload.");
4923
+ }
4924
+ return {
4925
+ answers: answersRaw.map((answer) => {
4926
+ if (!answer || typeof answer !== "object") {
4927
+ throw new Error("Invalid answers payload.");
4928
+ }
4929
+ const record = answer;
4930
+ if (typeof record.questionId !== "string" || typeof record.answer !== "string") {
4931
+ throw new Error("Invalid answers payload.");
4932
+ }
4933
+ return {
4934
+ questionId: record.questionId.trim(),
4935
+ answer: record.answer.trim()
4936
+ };
4937
+ })
4938
+ };
4939
+ }
4940
+ async function readJsonBody(request) {
4941
+ const chunks = [];
4942
+ let size = 0;
4943
+ for await (const chunk of request) {
4944
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
4945
+ size += buffer.length;
4946
+ if (size > 64 * 1024) {
4947
+ throw new Error("Request body too large");
4948
+ }
4949
+ chunks.push(buffer);
4950
+ }
4951
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
4952
+ return raw ? JSON.parse(raw) : {};
4953
+ }
4954
+ function sendJson(response, status, value) {
4955
+ response.statusCode = status;
4956
+ response.setHeader("content-type", "application/json; charset=utf-8");
4957
+ response.end(`${JSON.stringify(value)}
4958
+ `);
4959
+ }
4960
+ function sendHtml(response, html) {
4961
+ response.statusCode = 200;
4962
+ response.setHeader("content-type", "text/html; charset=utf-8");
4963
+ response.end(html);
4964
+ }
4965
+ function createInterviewServer(deps) {
4966
+ let baseUrl = null;
4967
+ let startPromise = null;
4968
+ async function handle(request, response) {
4969
+ const url = new URL2(request.url ?? "/", "http://127.0.0.1");
4970
+ const pathname = url.pathname;
4971
+ if (request.method === "GET" && pathname.startsWith("/interview/")) {
4972
+ sendHtml(response, renderInterviewPage(pathname.split("/").pop() ?? "unknown"));
4973
+ return;
4974
+ }
4975
+ const stateMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/state$/);
4976
+ if (request.method === "GET" && stateMatch) {
4977
+ try {
4978
+ const state = await deps.getState(stateMatch[1]);
4979
+ sendJson(response, 200, state);
4980
+ } catch (error) {
4981
+ sendJson(response, 404, {
4982
+ error: error instanceof Error ? error.message : "Interview not found"
4983
+ });
4984
+ }
4985
+ return;
4986
+ }
4987
+ const answersMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/answers$/);
4988
+ if (request.method === "POST" && answersMatch) {
4989
+ try {
4990
+ const body = parseAnswersPayload(await readJsonBody(request));
4991
+ await deps.submitAnswers(answersMatch[1], body.answers);
4992
+ sendJson(response, 200, {
4993
+ ok: true,
4994
+ message: "Answers submitted to the OpenCode session."
4995
+ });
4996
+ } catch (error) {
4997
+ const message = error instanceof Error ? error.message : "Failed to submit answers.";
4998
+ const status = getSubmissionStatus(error);
4999
+ sendJson(response, status, {
5000
+ ok: false,
5001
+ message
5002
+ });
5003
+ }
5004
+ return;
5005
+ }
5006
+ sendJson(response, 404, { error: "Not found" });
5007
+ }
5008
+ async function ensureStarted() {
5009
+ if (baseUrl) {
5010
+ return baseUrl;
5011
+ }
5012
+ if (startPromise) {
5013
+ return startPromise;
5014
+ }
5015
+ startPromise = new Promise((resolve, reject) => {
5016
+ const server = createServer((request, response) => {
5017
+ handle(request, response).catch((error) => {
5018
+ sendJson(response, 500, {
5019
+ error: error instanceof Error ? error.message : "Internal server error"
5020
+ });
5021
+ });
5022
+ });
5023
+ server.on("error", (error) => {
5024
+ startPromise = null;
5025
+ reject(error);
5026
+ });
5027
+ server.listen(0, "127.0.0.1", () => {
5028
+ const address = server.address();
5029
+ if (!address || typeof address === "string") {
5030
+ startPromise = null;
5031
+ reject(new Error("Failed to start interview server"));
5032
+ return;
5033
+ }
5034
+ baseUrl = `http://127.0.0.1:${address.port}`;
5035
+ resolve(baseUrl);
5036
+ });
5037
+ });
5038
+ return startPromise;
5039
+ }
5040
+ return {
5041
+ ensureStarted
5042
+ };
5043
+ }
5044
+
5045
+ // src/interview/service.ts
5046
+ import { spawn as spawn4 } from "child_process";
5047
+ import * as fsSync from "fs";
5048
+ import * as fs5 from "fs/promises";
5049
+ import * as path6 from "path";
5050
+
5051
+ // src/interview/parser.ts
5052
+ var INTERVIEW_BLOCK_REGEX = /<interview_state>\s*([\s\S]*?)\s*<\/interview_state>/i;
5053
+ function normalizeQuestion(value, index) {
5054
+ const question = typeof value.question === "string" ? value.question.trim() : "";
5055
+ if (!question) {
5056
+ return null;
5057
+ }
5058
+ const options = Array.isArray(value.options) ? value.options.filter((option) => typeof option === "string").map((option) => option.trim()).filter(Boolean).slice(0, 4) : [];
5059
+ return {
5060
+ id: typeof value.id === "string" && value.id.trim().length > 0 ? value.id.trim() : `q-${index + 1}`,
5061
+ question,
5062
+ options,
5063
+ suggested: typeof value.suggested === "string" && value.suggested.trim().length > 0 ? value.suggested.trim() : undefined
5064
+ };
5065
+ }
5066
+ function flattenMessage(message) {
5067
+ return (message.parts ?? []).map((part) => part.text ?? "").join(`
5068
+ `).trim();
5069
+ }
5070
+ function buildFallbackState(messages) {
5071
+ const answerCount = messages.filter((message) => message.info?.role === "user").length;
5072
+ return {
5073
+ summary: answerCount > 0 ? "Interview in progress." : "Waiting for the first interview response.",
5074
+ questions: []
5075
+ };
5076
+ }
5077
+ function parseAssistantState(text, maxQuestions = 2) {
5078
+ const match = text.match(INTERVIEW_BLOCK_REGEX);
5079
+ if (!match) {
5080
+ return { state: null };
5081
+ }
5082
+ try {
5083
+ const parsed = JSON.parse(match[1]);
5084
+ const summary = typeof parsed.summary === "string" ? parsed.summary.trim() : "";
5085
+ const title = typeof parsed.title === "string" && parsed.title.trim().length > 0 ? parsed.title.trim() : undefined;
5086
+ const questions = Array.isArray(parsed.questions) ? parsed.questions.filter((value) => typeof value === "object" && value !== null).map((value, index) => normalizeQuestion(value, index)).filter((value) => value !== null).slice(0, maxQuestions) : [];
5087
+ return {
5088
+ state: {
5089
+ summary,
5090
+ title,
5091
+ questions
5092
+ }
5093
+ };
5094
+ } catch (error) {
5095
+ return {
5096
+ state: null,
5097
+ error: error instanceof Error ? error.message : "Failed to parse interview state"
5098
+ };
5099
+ }
5100
+ }
5101
+ function findLatestAssistantState(messages, maxQuestions = 2) {
5102
+ for (let index = messages.length - 1;index >= 0; index -= 1) {
5103
+ const message = messages[index];
5104
+ if (message.info?.role !== "assistant") {
5105
+ continue;
5106
+ }
5107
+ const parsed = parseAssistantState(flattenMessage(message), maxQuestions);
5108
+ if (parsed.state) {
5109
+ return {
5110
+ state: parsed.state
5111
+ };
5112
+ }
5113
+ return {
5114
+ state: null,
5115
+ latestAssistantError: parsed.error ?? "Missing <interview_state> block"
5116
+ };
5117
+ }
5118
+ return {
5119
+ state: null
5120
+ };
5121
+ }
5122
+
5123
+ // src/interview/prompts.ts
5124
+ function formatQuestionContext(questions) {
5125
+ if (questions.length === 0) {
5126
+ return "No current interview questions were parsed.";
5127
+ }
5128
+ return questions.map((question, index) => {
5129
+ const options = question.options.length ? `Options: ${question.options.join(" | ")}` : "Options: freeform";
5130
+ const suggested = question.suggested ? `Suggested: ${question.suggested}` : "Suggested: none";
5131
+ return `${index + 1}. ${question.question}
5132
+ ${options}
5133
+ ${suggested}`;
5134
+ }).join(`
5135
+
5136
+ `);
5137
+ }
5138
+ function buildKickoffPrompt(idea, maxQuestions) {
5139
+ return [
5140
+ "You are running an interview q&a session for the user inside their repository.",
5141
+ `Initial idea: ${idea}`,
5142
+ `Clarify the idea through short rounds of at most ${maxQuestions} questions at a time.`,
5143
+ "When useful, each question may include 2 to 4 answer options and one suggested option.",
5144
+ "Be practical. Focus on the highest-ambiguity and highest-risk decisions first.",
5145
+ "After any short human-friendly preface, you MUST include a machine-readable block in this exact format:",
5146
+ "<interview_state>",
5147
+ "{",
5148
+ ' "summary": "one short paragraph about the current understanding",',
5149
+ ' "title": "concise-kebab-case-title-for-filename",',
5150
+ ' "questions": [',
5151
+ " {",
5152
+ ' "id": "short-kebab-id-2",',
5153
+ ' "question": "question text",',
5154
+ ' "options": ["option 1", "option 2", "option 3"],',
5155
+ ' "suggested": "best suggested option"',
5156
+ " }",
5157
+ " ]",
5158
+ "}",
5159
+ "</interview_state>",
5160
+ "Rules:",
5161
+ `- Return 0 to ${maxQuestions} questions.`,
5162
+ "- If there are no more useful questions, return zero questions.",
5163
+ `- Do not ask more than ${maxQuestions} questions in one round.`,
5164
+ '- Provide a concise "title" field (kebab-case, 3-6 words) suitable for a filename.'
5165
+ ].join(`
5166
+ `);
5167
+ }
5168
+ function buildResumePrompt(document, maxQuestions) {
5169
+ return [
5170
+ "Resume the interview from this existing markdown document.",
5171
+ "Use the current spec and Q&A history as ground truth so far.",
5172
+ "Do not restart from scratch.",
5173
+ "",
5174
+ document,
5175
+ "",
5176
+ `Ask the next highest-value clarifying questions, up to ${maxQuestions} at a time.`,
5177
+ "If there are no more useful questions, return zero questions.",
5178
+ "Return the same <interview_state> JSON block format as before."
5179
+ ].join(`
5180
+ `);
5181
+ }
5182
+ function buildAnswerPrompt(answers, questions, maxQuestions) {
5183
+ const answerText = answers.map((answer, index) => `${index + 1}. ${answer.questionId}: ${answer.answer.trim()}`).join(`
5184
+ `);
5185
+ return [
5186
+ "Continue the same interview.",
5187
+ "These were the active questions:",
5188
+ formatQuestionContext(questions),
5189
+ "The user answered:",
5190
+ answerText,
5191
+ "Now update your understanding and ask the next highest-value clarifying questions.",
5192
+ `Return 0 to ${maxQuestions} questions. If there are no more useful questions, return zero questions.`,
5193
+ "Return the same <interview_state> JSON block format as before."
5194
+ ].join(`
5195
+
5196
+ `);
5197
+ }
5198
+
5199
+ // src/interview/service.ts
5200
+ var COMMAND_NAME2 = "interview";
5201
+ var DEFAULT_MAX_QUESTIONS = 2;
5202
+ var DEFAULT_OUTPUT_FOLDER = "interview";
5203
+ var DEFAULT_AUTO_OPEN_BROWSER = true;
5204
+ function slugify(value) {
5205
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
5206
+ }
5207
+ function openBrowser(url) {
5208
+ const platform = process.platform;
5209
+ let command;
5210
+ let args;
5211
+ if (platform === "darwin") {
5212
+ command = "open";
5213
+ args = [url];
5214
+ } else if (platform === "win32") {
5215
+ command = "cmd";
5216
+ args = ["/c", "start", "", url];
5217
+ } else {
5218
+ command = "xdg-open";
5219
+ args = [url];
5220
+ }
5221
+ try {
5222
+ const child = spawn4(command, args, { detached: true, stdio: "ignore" });
5223
+ child.on("error", (error) => {
5224
+ log("[interview] failed to open browser:", { error: error.message, url });
5225
+ });
5226
+ child.unref();
5227
+ } catch (error) {
5228
+ log("[interview] failed to spawn browser opener:", {
5229
+ error: error instanceof Error ? error.message : String(error),
5230
+ url
5231
+ });
5232
+ }
5233
+ }
5234
+ function nowIso() {
5235
+ return new Date().toISOString();
5236
+ }
5237
+ function normalizeOutputFolder(outputFolder) {
5238
+ const normalized = outputFolder.trim().replace(/^\/+|\/+$/g, "");
5239
+ return normalized || DEFAULT_OUTPUT_FOLDER;
5240
+ }
5241
+ function createInterviewDirectoryPath(directory, outputFolder) {
5242
+ return path6.join(directory, normalizeOutputFolder(outputFolder));
5243
+ }
5244
+ function createInterviewFilePath(directory, outputFolder, idea) {
5245
+ const fileName = `${slugify(idea) || "interview"}.md`;
5246
+ return path6.join(createInterviewDirectoryPath(directory, outputFolder), fileName);
5247
+ }
5248
+ function relativeInterviewPath(directory, filePath) {
5249
+ return path6.relative(directory, filePath) || path6.basename(filePath);
5250
+ }
5251
+ function extractHistorySection(document) {
5252
+ const marker = `## Q&A history
5253
+
5254
+ `;
5255
+ const index = document.indexOf(marker);
5256
+ return index >= 0 ? document.slice(index + marker.length).trim() : "";
5257
+ }
5258
+ function extractSummarySection(document) {
5259
+ const marker = `## Current spec
5260
+
5261
+ `;
5262
+ const historyMarker = `
5263
+
5264
+ ## Q&A history`;
5265
+ const start = document.indexOf(marker);
5266
+ if (start < 0) {
5267
+ return "";
5268
+ }
5269
+ const summaryStart = start + marker.length;
5270
+ const summaryEnd = document.indexOf(historyMarker, summaryStart);
5271
+ return document.slice(summaryStart, summaryEnd >= 0 ? summaryEnd : undefined).trim();
5272
+ }
5273
+ function extractTitle(document) {
5274
+ const match = document.match(/^#\s+(.+)$/m);
5275
+ return match?.[1]?.trim() ?? "";
5276
+ }
5277
+ function buildInterviewDocument(idea, summary, history) {
5278
+ const normalizedSummary = summary.trim() || "Waiting for interview answers.";
5279
+ const normalizedHistory = history.trim() || "No answers yet.";
5280
+ return [
5281
+ `# ${idea}`,
5282
+ "",
5283
+ "## Current spec",
5284
+ "",
5285
+ normalizedSummary,
5286
+ "",
5287
+ "## Q&A history",
5288
+ "",
5289
+ normalizedHistory,
5290
+ ""
5291
+ ].join(`
5292
+ `);
5293
+ }
5294
+ async function ensureInterviewFile(record) {
5295
+ await fs5.mkdir(path6.dirname(record.markdownPath), { recursive: true });
5296
+ try {
5297
+ await fs5.access(record.markdownPath);
5298
+ } catch {
5299
+ await fs5.writeFile(record.markdownPath, buildInterviewDocument(record.idea, "", ""), "utf8");
5300
+ }
5301
+ }
5302
+ async function readInterviewDocument(record) {
5303
+ await ensureInterviewFile(record);
5304
+ return fs5.readFile(record.markdownPath, "utf8");
5305
+ }
5306
+ async function rewriteInterviewDocument(record, summary) {
5307
+ const existing = await readInterviewDocument(record);
5308
+ const history = extractHistorySection(existing);
5309
+ const next = buildInterviewDocument(record.idea, summary, history);
5310
+ await fs5.writeFile(record.markdownPath, next, "utf8");
5311
+ return next;
5312
+ }
5313
+ async function appendInterviewAnswers(record, questions, answers) {
5314
+ const existing = await readInterviewDocument(record);
5315
+ const summary = extractSummarySection(existing);
5316
+ const history = extractHistorySection(existing);
5317
+ const questionMap = new Map(questions.map((question) => [question.id, question]));
5318
+ const appended = answers.map((answer) => {
5319
+ const question = questionMap.get(answer.questionId);
5320
+ return question ? `Q: ${question.question}
5321
+ A: ${answer.answer.trim()}` : null;
5322
+ }).filter((value) => value !== null).join(`
5323
+
5324
+ `);
5325
+ const nextHistory = [history === "No answers yet." ? "" : history, appended].filter(Boolean).join(`
5326
+
5327
+ `);
5328
+ await fs5.writeFile(record.markdownPath, buildInterviewDocument(record.idea, summary, nextHistory), "utf8");
5329
+ }
5330
+ function resolveExistingInterviewPath(directory, outputFolder, value) {
5331
+ const trimmed = value.trim();
5332
+ if (!trimmed) {
5333
+ return null;
5334
+ }
5335
+ const outputDir = createInterviewDirectoryPath(directory, outputFolder);
5336
+ const candidates = new Set;
5337
+ if (path6.isAbsolute(trimmed)) {
5338
+ candidates.add(trimmed);
5339
+ } else {
5340
+ candidates.add(path6.resolve(directory, trimmed));
5341
+ candidates.add(path6.join(outputDir, trimmed));
5342
+ if (!trimmed.endsWith(".md")) {
5343
+ candidates.add(path6.join(outputDir, `${trimmed}.md`));
5344
+ }
5345
+ }
5346
+ for (const candidate of candidates) {
5347
+ if (path6.extname(candidate) !== ".md") {
5348
+ continue;
5349
+ }
5350
+ if (fsSync.existsSync(candidate)) {
5351
+ return candidate;
5352
+ }
5353
+ }
5354
+ return null;
5355
+ }
5356
+ function createInterviewService(ctx, config, deps) {
5357
+ const maxQuestions = config?.maxQuestions ?? DEFAULT_MAX_QUESTIONS;
5358
+ const outputFolder = normalizeOutputFolder(config?.outputFolder ?? DEFAULT_OUTPUT_FOLDER);
5359
+ const autoOpenBrowser = config?.autoOpenBrowser ?? DEFAULT_AUTO_OPEN_BROWSER;
5360
+ const browserOpener = deps?.openBrowser ?? openBrowser;
5361
+ const activeInterviewIds = new Map;
5362
+ const interviewsById = new Map;
5363
+ const sessionBusy = new Map;
5364
+ const browserOpened = new Set;
5365
+ let resolveBaseUrl = null;
5366
+ function setBaseUrlResolver(resolver) {
5367
+ resolveBaseUrl = resolver;
5368
+ }
5369
+ async function ensureServer() {
5370
+ if (!resolveBaseUrl) {
5371
+ throw new Error("Interview server is not attached");
5372
+ }
5373
+ return resolveBaseUrl();
5374
+ }
5375
+ function maybeOpenBrowser(interviewId, url) {
5376
+ if (!autoOpenBrowser) {
5377
+ return;
5378
+ }
5379
+ if (browserOpened.has(interviewId)) {
5380
+ return;
5381
+ }
5382
+ browserOpened.add(interviewId);
5383
+ browserOpener(url);
5384
+ }
5385
+ async function maybeRenameWithTitle(interview, assistantTitle) {
5386
+ if (!assistantTitle) {
5387
+ return;
5388
+ }
5389
+ const newSlug = slugify(assistantTitle);
5390
+ if (!newSlug) {
5391
+ return;
5392
+ }
5393
+ const currentFileName = path6.basename(interview.markdownPath, ".md");
5394
+ if (currentFileName === newSlug) {
5395
+ return;
5396
+ }
5397
+ const dir = path6.dirname(interview.markdownPath);
5398
+ const newPath = path6.join(dir, `${newSlug}.md`);
5399
+ try {
5400
+ await fs5.access(newPath);
5401
+ return;
5402
+ } catch {}
5403
+ try {
5404
+ await fs5.rename(interview.markdownPath, newPath);
5405
+ interview.markdownPath = newPath;
5406
+ log("[interview] renamed file with assistant title:", {
5407
+ from: currentFileName,
5408
+ to: newSlug
5409
+ });
5410
+ } catch (error) {
5411
+ log("[interview] failed to rename file:", {
5412
+ error: error instanceof Error ? error.message : String(error)
5413
+ });
5414
+ }
5415
+ }
5416
+ async function loadMessages(sessionID) {
5417
+ const result = await ctx.client.session.messages({
5418
+ path: { id: sessionID }
5419
+ });
5420
+ return result.data;
5421
+ }
5422
+ function isUserVisibleMessage(message) {
5423
+ return !(message.parts ?? []).some((part) => hasInternalInitiatorMarker(part));
5424
+ }
5425
+ function getInterviewById(interviewId) {
5426
+ return interviewsById.get(interviewId) ?? null;
5427
+ }
5428
+ async function createInterview(sessionID, idea) {
5429
+ const normalizedIdea = idea.trim();
5430
+ const activeId = activeInterviewIds.get(sessionID);
5431
+ if (activeId) {
5432
+ const active = interviewsById.get(activeId);
5433
+ if (active && active.status === "active") {
5434
+ if (active.idea === normalizedIdea) {
5435
+ return active;
5436
+ }
5437
+ active.status = "abandoned";
5438
+ }
5439
+ }
5440
+ const messages = await loadMessages(sessionID);
5441
+ const record = {
5442
+ id: `${Date.now()}-${slugify(idea) || "interview"}`,
5443
+ sessionID,
5444
+ idea: normalizedIdea,
5445
+ markdownPath: createInterviewFilePath(ctx.directory, outputFolder, idea),
5446
+ createdAt: nowIso(),
5447
+ status: "active",
5448
+ baseMessageCount: messages.length
5449
+ };
5450
+ await ensureInterviewFile(record);
5451
+ activeInterviewIds.set(sessionID, record.id);
5452
+ interviewsById.set(record.id, record);
5453
+ return record;
5454
+ }
5455
+ async function resumeInterview(sessionID, markdownPath) {
5456
+ const activeId = activeInterviewIds.get(sessionID);
5457
+ if (activeId) {
5458
+ const active = interviewsById.get(activeId);
5459
+ if (active && active.status === "active") {
5460
+ if (active.markdownPath === markdownPath) {
5461
+ return active;
5462
+ }
5463
+ active.status = "abandoned";
5464
+ }
5465
+ }
5466
+ const document = await fs5.readFile(markdownPath, "utf8");
5467
+ const messages = await loadMessages(sessionID);
5468
+ const title = extractTitle(document);
5469
+ const record = {
5470
+ id: `${Date.now()}-${slugify(path6.basename(markdownPath, ".md")) || "interview"}`,
5471
+ sessionID,
5472
+ idea: title || path6.basename(markdownPath, ".md"),
5473
+ markdownPath,
5474
+ createdAt: nowIso(),
5475
+ status: "active",
5476
+ baseMessageCount: messages.length
5477
+ };
5478
+ activeInterviewIds.set(sessionID, record.id);
5479
+ interviewsById.set(record.id, record);
5480
+ return record;
5481
+ }
5482
+ async function syncInterview(interview) {
5483
+ const allMessages = await loadMessages(interview.sessionID);
5484
+ const interviewMessages = allMessages.slice(interview.baseMessageCount).filter(isUserVisibleMessage);
5485
+ const parsed = findLatestAssistantState(interviewMessages, maxQuestions);
5486
+ const existingDocument = await readInterviewDocument(interview);
5487
+ const fallbackState = buildFallbackState(interviewMessages);
5488
+ const state = parsed.state ?? {
5489
+ ...fallbackState,
5490
+ summary: extractSummarySection(existingDocument) || fallbackState.summary
5491
+ };
5492
+ await maybeRenameWithTitle(interview, state.title);
5493
+ const document = await rewriteInterviewDocument(interview, state.summary);
5494
+ return {
5495
+ interview,
5496
+ url: `${await ensureServer()}/interview/${interview.id}`,
5497
+ markdownPath: relativeInterviewPath(ctx.directory, interview.markdownPath),
5498
+ mode: interview.status === "abandoned" ? "abandoned" : parsed.latestAssistantError ? "error" : sessionBusy.get(interview.sessionID) === true ? "awaiting-agent" : state.questions.length > 0 ? "awaiting-user" : "awaiting-agent",
5499
+ lastParseError: parsed.latestAssistantError,
5500
+ isBusy: sessionBusy.get(interview.sessionID) === true,
5501
+ summary: state.summary,
5502
+ questions: state.questions,
5503
+ document
5504
+ };
5505
+ }
5506
+ async function notifyInterviewUrl(sessionID, interview) {
5507
+ const baseUrl = await ensureServer();
5508
+ const url = `${baseUrl}/interview/${interview.id}`;
5509
+ maybeOpenBrowser(interview.id, url);
5510
+ await ctx.client.session.prompt({
5511
+ path: { id: sessionID },
5512
+ body: {
5513
+ noReply: true,
5514
+ parts: [
5515
+ {
5516
+ type: "text",
5517
+ text: [
5518
+ "\u2394 Interview UI ready",
5519
+ "",
5520
+ `Open: ${url}`,
5521
+ `Document: ${relativeInterviewPath(ctx.directory, interview.markdownPath)}`,
5522
+ "",
5523
+ "[system status: continue without acknowledging this notification]"
5524
+ ].join(`
5525
+ `)
5526
+ }
5527
+ ]
4009
5528
  }
4010
- if (lastAssistantIsQuestion) {
4011
- log(`[${HOOK_NAME}] Skipped: last message is question`, {
4012
- sessionID
4013
- });
4014
- return;
5529
+ });
5530
+ }
5531
+ function registerCommand(opencodeConfig) {
5532
+ const configCommand = opencodeConfig.command;
5533
+ if (!configCommand?.[COMMAND_NAME2]) {
5534
+ if (!opencodeConfig.command) {
5535
+ opencodeConfig.command = {};
4015
5536
  }
4016
- if (state.consecutiveContinuations >= maxContinuations) {
4017
- log(`[${HOOK_NAME}] Skipped: max continuations reached`, {
4018
- sessionID,
4019
- consecutive: state.consecutiveContinuations,
4020
- max: maxContinuations
4021
- });
4022
- return;
5537
+ opencodeConfig.command[COMMAND_NAME2] = {
5538
+ template: "Start an interview and write a live markdown spec",
5539
+ description: "Open a localhost interview UI linked to the current OpenCode session"
5540
+ };
5541
+ }
5542
+ }
5543
+ async function getInterviewState(interviewId) {
5544
+ const interview = getInterviewById(interviewId);
5545
+ if (!interview) {
5546
+ throw new Error("Interview not found");
5547
+ }
5548
+ return syncInterview(interview);
5549
+ }
5550
+ async function submitAnswers(interviewId, answers) {
5551
+ const interview = getInterviewById(interviewId);
5552
+ if (!interview) {
5553
+ throw new Error("Interview not found");
5554
+ }
5555
+ if (interview.status === "abandoned") {
5556
+ throw new Error("Interview session is no longer active.");
5557
+ }
5558
+ if (sessionBusy.get(interview.sessionID) === true) {
5559
+ throw new Error("Interview session is busy. Wait for the current response.");
5560
+ }
5561
+ sessionBusy.set(interview.sessionID, true);
5562
+ let promptSent = false;
5563
+ try {
5564
+ const state = await getInterviewState(interviewId);
5565
+ if (state.mode === "error") {
5566
+ throw new Error("Interview is waiting for a valid agent update.");
4023
5567
  }
4024
- const now = Date.now();
4025
- if (now < state.suppressUntil) {
4026
- log(`[${HOOK_NAME}] Skipped: in suppress window`, {
4027
- sessionID,
4028
- suppressUntil: state.suppressUntil
4029
- });
4030
- return;
5568
+ const activeQuestionIds = new Set(state.questions.map((question) => question.id));
5569
+ if (activeQuestionIds.size === 0) {
5570
+ throw new Error("There are no active interview questions to answer.");
4031
5571
  }
4032
- if (state.pendingTimer !== null || state.isAutoInjecting) {
4033
- log(`[${HOOK_NAME}] Skipped: timer pending or injection in flight`, {
4034
- sessionID
4035
- });
4036
- return;
5572
+ if (answers.length !== activeQuestionIds.size) {
5573
+ throw new Error("Answer every active interview question before submitting.");
4037
5574
  }
4038
- log(`[${HOOK_NAME}] Scheduling continuation`, {
4039
- sessionID,
4040
- delayMs: cooldownMs
4041
- });
4042
- ctx.client.session.prompt({
4043
- path: { id: sessionID },
5575
+ const invalidAnswer = answers.find((answer) => !activeQuestionIds.has(answer.questionId) || !answer.answer.trim());
5576
+ if (invalidAnswer) {
5577
+ throw new Error("Answers do not match the current interview questions.");
5578
+ }
5579
+ await appendInterviewAnswers(interview, state.questions, answers);
5580
+ const prompt = buildAnswerPrompt(answers, state.questions, maxQuestions);
5581
+ await ctx.client.session.prompt({
5582
+ path: { id: interview.sessionID },
4044
5583
  body: {
4045
- noReply: true,
4046
- parts: [
4047
- {
4048
- type: "text",
4049
- text: [
4050
- `\u2394 Auto-continue: ${incompleteCount} incomplete todos remaining \u2014 resuming in ${cooldownMs / 1000}s \u2014 Esc\xD72 to cancel`,
4051
- "",
4052
- "[system status: continue without acknowledging this notification]"
4053
- ].join(`
4054
- `)
4055
- }
4056
- ]
4057
- }
4058
- }).catch(() => {});
4059
- state.pendingTimer = setTimeout(async () => {
4060
- state.pendingTimer = null;
4061
- if (!state.enabled) {
4062
- log(`[${HOOK_NAME}] Cancelled: disabled during cooldown`, {
4063
- sessionID
4064
- });
4065
- return;
4066
- }
4067
- state.isAutoInjecting = true;
4068
- try {
4069
- await ctx.client.session.prompt({
4070
- path: { id: sessionID },
4071
- body: {
4072
- parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)]
4073
- }
4074
- });
4075
- state.consecutiveContinuations++;
4076
- log(`[${HOOK_NAME}] Continuation injected`, {
4077
- sessionID,
4078
- consecutive: state.consecutiveContinuations
4079
- });
4080
- } catch (error) {
4081
- log(`[${HOOK_NAME}] Error: failed to inject continuation`, {
4082
- sessionID,
4083
- error: error instanceof Error ? error.message : String(error)
4084
- });
4085
- } finally {
4086
- state.isAutoInjecting = false;
4087
- }
4088
- }, cooldownMs);
4089
- } else if (event.type === "session.status") {
4090
- const status = properties.status;
4091
- const sessionID = properties.sessionID;
4092
- if (status?.type === "busy") {
4093
- const isOrchestrator = sessionID === state.orchestratorSessionId;
4094
- if (isOrchestrator) {
4095
- cancelPendingTimer(state);
4096
- }
4097
- if (!state.isAutoInjecting && isOrchestrator && state.consecutiveContinuations > 0) {
4098
- state.consecutiveContinuations = 0;
4099
- log(`[${HOOK_NAME}] Reset consecutive count on user activity`, {
4100
- sessionID
4101
- });
5584
+ parts: [createInternalAgentTextPart(prompt)]
4102
5585
  }
4103
- }
4104
- } else if (event.type === "session.error") {
4105
- const error = properties.error;
4106
- const sessionID = properties.sessionID;
4107
- const errorName = error?.name;
4108
- const isOrchestrator = sessionID === state.orchestratorSessionId;
4109
- if (isOrchestrator && (errorName === "MessageAbortedError" || errorName === "AbortError")) {
4110
- state.suppressUntil = Date.now() + SUPPRESS_AFTER_ABORT_MS;
4111
- log(`[${HOOK_NAME}] Suppressed continuation after abort`, {
4112
- sessionID,
4113
- errorName
4114
- });
4115
- }
4116
- if (isOrchestrator) {
4117
- cancelPendingTimer(state);
4118
- log(`[${HOOK_NAME}] Cancelled pending timer on error`, {
4119
- sessionID
4120
- });
4121
- }
4122
- } else if (event.type === "session.deleted") {
4123
- const deletedSessionId = properties.info?.id ?? properties.sessionID;
4124
- if (state.orchestratorSessionId === deletedSessionId) {
4125
- cancelPendingTimer(state);
4126
- log(`[${HOOK_NAME}] Cancelled pending timer on orchestrator delete`, {
4127
- sessionID: deletedSessionId
4128
- });
4129
- resetState(state);
4130
- state.orchestratorSessionId = null;
4131
- log(`[${HOOK_NAME}] Reset orchestrator session on delete`, {
4132
- sessionID: deletedSessionId
4133
- });
5586
+ });
5587
+ promptSent = true;
5588
+ } finally {
5589
+ if (!promptSent) {
5590
+ sessionBusy.set(interview.sessionID, false);
4134
5591
  }
4135
5592
  }
4136
5593
  }
4137
5594
  async function handleCommandExecuteBefore(input, output) {
4138
- if (input.command !== COMMAND_NAME) {
5595
+ if (input.command !== COMMAND_NAME2) {
4139
5596
  return;
4140
5597
  }
4141
- if (!state.orchestratorSessionId) {
4142
- state.orchestratorSessionId = input.sessionID;
4143
- }
5598
+ const idea = input.arguments.trim();
4144
5599
  output.parts.length = 0;
4145
- const arg = input.arguments.trim().toLowerCase();
4146
- let newEnabled;
4147
- if (arg === "on") {
4148
- newEnabled = true;
4149
- } else if (arg === "off") {
4150
- newEnabled = false;
4151
- } else {
4152
- newEnabled = !state.enabled;
5600
+ if (!idea) {
5601
+ const activeId = activeInterviewIds.get(input.sessionID);
5602
+ const interview2 = activeId ? interviewsById.get(activeId) : null;
5603
+ if (!interview2 || interview2.status !== "active") {
5604
+ output.parts.push(createInternalAgentTextPart("The user ran /interview without an idea. Ask them for the product idea in one sentence."));
5605
+ return;
5606
+ }
5607
+ await notifyInterviewUrl(input.sessionID, interview2);
5608
+ output.parts.push(createInternalAgentTextPart(`The interview UI was reopened for the current session. If your latest interview turn already contains unanswered questions, do not repeat them. Otherwise continue the interview with up to ${maxQuestions} clarifying questions and include the structured <interview_state> block.`));
5609
+ return;
4153
5610
  }
4154
- state.enabled = newEnabled;
4155
- state.consecutiveContinuations = 0;
4156
- if (!newEnabled) {
4157
- cancelPendingTimer(state);
4158
- output.parts.push(createInternalAgentTextPart("[Auto-continue: disabled by user command.]"));
4159
- log(`[${HOOK_NAME}] Disabled via /${COMMAND_NAME} command`);
5611
+ const resumePath = resolveExistingInterviewPath(ctx.directory, outputFolder, idea);
5612
+ if (resumePath) {
5613
+ const interview2 = await resumeInterview(input.sessionID, resumePath);
5614
+ const document = await fs5.readFile(interview2.markdownPath, "utf8");
5615
+ await notifyInterviewUrl(input.sessionID, interview2);
5616
+ output.parts.push(createInternalAgentTextPart(buildResumePrompt(document, maxQuestions)));
4160
5617
  return;
4161
5618
  }
4162
- state.suppressUntil = 0;
4163
- log(`[${HOOK_NAME}] Enabled via /${COMMAND_NAME} command`, {
4164
- maxContinuations
4165
- });
4166
- let hasIncompleteTodos = false;
4167
- try {
4168
- const todosResult = await ctx.client.session.todo({
4169
- path: { id: input.sessionID }
4170
- });
4171
- const todos = todosResult.data;
4172
- hasIncompleteTodos = todos.some((t) => !TERMINAL_TODO_STATUSES.includes(t.status));
4173
- } catch (error) {
4174
- log(`[${HOOK_NAME}] Warning: failed to fetch todos in command hook`, {
4175
- sessionID: input.sessionID,
4176
- error: error instanceof Error ? error.message : String(error)
4177
- });
5619
+ const interview = await createInterview(input.sessionID, idea);
5620
+ await notifyInterviewUrl(input.sessionID, interview);
5621
+ output.parts.push(createInternalAgentTextPart(buildKickoffPrompt(idea, maxQuestions)));
5622
+ }
5623
+ async function handleEvent(input) {
5624
+ const { event } = input;
5625
+ const properties = event.properties ?? {};
5626
+ if (event.type === "session.status") {
5627
+ const sessionID = properties.sessionID;
5628
+ const status = properties.status;
5629
+ if (sessionID) {
5630
+ sessionBusy.set(sessionID, status?.type === "busy");
5631
+ }
5632
+ return;
4178
5633
  }
4179
- if (hasIncompleteTodos) {
4180
- output.parts.push(createInternalAgentTextPart(`${CONTINUATION_PROMPT} [Auto-continue enabled: up to ${maxContinuations} continuations.]`));
4181
- } else {
4182
- output.parts.push(createInternalAgentTextPart(`[Auto-continue: enabled for up to ${maxContinuations} continuations. No incomplete todos right now.]`));
5634
+ if (event.type === "session.deleted") {
5635
+ const deletedSessionId = (properties.info?.id ?? properties.sessionID) || null;
5636
+ if (!deletedSessionId) {
5637
+ return;
5638
+ }
5639
+ sessionBusy.delete(deletedSessionId);
5640
+ const interviewId = activeInterviewIds.get(deletedSessionId);
5641
+ if (!interviewId) {
5642
+ return;
5643
+ }
5644
+ const interview = interviewsById.get(interviewId);
5645
+ if (!interview) {
5646
+ return;
5647
+ }
5648
+ interview.status = "abandoned";
5649
+ activeInterviewIds.delete(deletedSessionId);
5650
+ log("[interview] session deleted, interview marked abandoned", {
5651
+ sessionID: deletedSessionId,
5652
+ interviewId
5653
+ });
4183
5654
  }
4184
5655
  }
4185
5656
  return {
4186
- tool: { auto_continue: autoContinue },
5657
+ setBaseUrlResolver,
5658
+ registerCommand,
5659
+ handleCommandExecuteBefore,
4187
5660
  handleEvent,
4188
- handleCommandExecuteBefore
5661
+ getInterviewState,
5662
+ submitAnswers
5663
+ };
5664
+ }
5665
+
5666
+ // src/interview/manager.ts
5667
+ function createInterviewManager(ctx, config) {
5668
+ const service = createInterviewService(ctx, config.interview);
5669
+ const server = createInterviewServer({
5670
+ getState: async (interviewId) => service.getInterviewState(interviewId),
5671
+ submitAnswers: async (interviewId, answers) => service.submitAnswers(interviewId, answers)
5672
+ });
5673
+ service.setBaseUrlResolver(() => server.ensureStarted());
5674
+ return {
5675
+ registerCommand: (config2) => service.registerCommand(config2),
5676
+ handleCommandExecuteBefore: async (input, output) => service.handleCommandExecuteBefore(input, output),
5677
+ handleEvent: async (input) => service.handleEvent(input)
4189
5678
  };
4190
5679
  }
4191
5680
  // src/mcp/context7.ts
@@ -4248,19 +5737,19 @@ function createBuiltinMcps(disabledMcps = [], websearchConfig) {
4248
5737
  import { tool as tool2 } from "@opencode-ai/plugin/tool";
4249
5738
 
4250
5739
  // src/tools/ast-grep/cli.ts
4251
- import { existsSync as existsSync6 } from "fs";
4252
- var {spawn: spawn4 } = globalThis.Bun;
5740
+ import { existsSync as existsSync7 } from "fs";
5741
+ var {spawn: spawn5 } = globalThis.Bun;
4253
5742
 
4254
5743
  // src/tools/ast-grep/constants.ts
4255
- import { existsSync as existsSync5, statSync as statSync2 } from "fs";
5744
+ import { existsSync as existsSync6, statSync as statSync2 } from "fs";
4256
5745
  import { createRequire as createRequire2 } from "module";
4257
- import { dirname as dirname4, join as join8 } from "path";
5746
+ import { dirname as dirname5, join as join9 } from "path";
4258
5747
 
4259
5748
  // src/tools/ast-grep/downloader.ts
4260
- import { chmodSync, existsSync as existsSync4, mkdirSync as mkdirSync2, unlinkSync } from "fs";
5749
+ import { chmodSync, existsSync as existsSync5, mkdirSync as mkdirSync2, unlinkSync } from "fs";
4261
5750
  import { createRequire } from "module";
4262
5751
  import { homedir as homedir3 } from "os";
4263
- import { join as join7 } from "path";
5752
+ import { join as join8 } from "path";
4264
5753
  var REPO = "ast-grep/ast-grep";
4265
5754
  var DEFAULT_VERSION = "0.40.0";
4266
5755
  function getAstGrepVersion() {
@@ -4284,19 +5773,19 @@ var PLATFORM_MAP = {
4284
5773
  function getCacheDir2() {
4285
5774
  if (process.platform === "win32") {
4286
5775
  const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA;
4287
- const base2 = localAppData || join7(homedir3(), "AppData", "Local");
4288
- return join7(base2, "oh-my-opencode-slim", "bin");
5776
+ const base2 = localAppData || join8(homedir3(), "AppData", "Local");
5777
+ return join8(base2, "oh-my-opencode-slim", "bin");
4289
5778
  }
4290
5779
  const xdgCache = process.env.XDG_CACHE_HOME;
4291
- const base = xdgCache || join7(homedir3(), ".cache");
4292
- return join7(base, "oh-my-opencode-slim", "bin");
5780
+ const base = xdgCache || join8(homedir3(), ".cache");
5781
+ return join8(base, "oh-my-opencode-slim", "bin");
4293
5782
  }
4294
5783
  function getBinaryName() {
4295
5784
  return process.platform === "win32" ? "sg.exe" : "sg";
4296
5785
  }
4297
5786
  function getCachedBinaryPath() {
4298
- const binaryPath = join7(getCacheDir2(), getBinaryName());
4299
- return existsSync4(binaryPath) ? binaryPath : null;
5787
+ const binaryPath = join8(getCacheDir2(), getBinaryName());
5788
+ return existsSync5(binaryPath) ? binaryPath : null;
4300
5789
  }
4301
5790
  async function downloadAstGrep(version = DEFAULT_VERSION) {
4302
5791
  const platformKey = `${process.platform}-${process.arch}`;
@@ -4307,8 +5796,8 @@ async function downloadAstGrep(version = DEFAULT_VERSION) {
4307
5796
  }
4308
5797
  const cacheDir = getCacheDir2();
4309
5798
  const binaryName = getBinaryName();
4310
- const binaryPath = join7(cacheDir, binaryName);
4311
- if (existsSync4(binaryPath)) {
5799
+ const binaryPath = join8(cacheDir, binaryName);
5800
+ if (existsSync5(binaryPath)) {
4312
5801
  return binaryPath;
4313
5802
  }
4314
5803
  const { arch, os: os3 } = platformInfo;
@@ -4316,21 +5805,21 @@ async function downloadAstGrep(version = DEFAULT_VERSION) {
4316
5805
  const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`;
4317
5806
  console.log(`[oh-my-opencode-slim] Downloading ast-grep binary...`);
4318
5807
  try {
4319
- if (!existsSync4(cacheDir)) {
5808
+ if (!existsSync5(cacheDir)) {
4320
5809
  mkdirSync2(cacheDir, { recursive: true });
4321
5810
  }
4322
5811
  const response = await fetch(downloadUrl, { redirect: "follow" });
4323
5812
  if (!response.ok) {
4324
5813
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
4325
5814
  }
4326
- const archivePath = join7(cacheDir, assetName);
5815
+ const archivePath = join8(cacheDir, assetName);
4327
5816
  const arrayBuffer = await response.arrayBuffer();
4328
5817
  await Bun.write(archivePath, arrayBuffer);
4329
5818
  await extractZip(archivePath, cacheDir);
4330
- if (existsSync4(archivePath)) {
5819
+ if (existsSync5(archivePath)) {
4331
5820
  unlinkSync(archivePath);
4332
5821
  }
4333
- if (process.platform !== "win32" && existsSync4(binaryPath)) {
5822
+ if (process.platform !== "win32" && existsSync5(binaryPath)) {
4334
5823
  chmodSync(binaryPath, 493);
4335
5824
  }
4336
5825
  console.log(`[oh-my-opencode-slim] ast-grep binary ready.`);
@@ -4411,9 +5900,9 @@ function findSgCliPathSync() {
4411
5900
  try {
4412
5901
  const require2 = createRequire2(import.meta.url);
4413
5902
  const cliPkgPath = require2.resolve("@ast-grep/cli/package.json");
4414
- const cliDir = dirname4(cliPkgPath);
4415
- const sgPath = join8(cliDir, binaryName);
4416
- if (existsSync5(sgPath) && isValidBinary(sgPath)) {
5903
+ const cliDir = dirname5(cliPkgPath);
5904
+ const sgPath = join9(cliDir, binaryName);
5905
+ if (existsSync6(sgPath) && isValidBinary(sgPath)) {
4417
5906
  return sgPath;
4418
5907
  }
4419
5908
  } catch {}
@@ -4422,19 +5911,19 @@ function findSgCliPathSync() {
4422
5911
  try {
4423
5912
  const require2 = createRequire2(import.meta.url);
4424
5913
  const pkgPath = require2.resolve(`${platformPkg}/package.json`);
4425
- const pkgDir = dirname4(pkgPath);
5914
+ const pkgDir = dirname5(pkgPath);
4426
5915
  const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep";
4427
- const binaryPath = join8(pkgDir, astGrepName);
4428
- if (existsSync5(binaryPath) && isValidBinary(binaryPath)) {
5916
+ const binaryPath = join9(pkgDir, astGrepName);
5917
+ if (existsSync6(binaryPath) && isValidBinary(binaryPath)) {
4429
5918
  return binaryPath;
4430
5919
  }
4431
5920
  } catch {}
4432
5921
  }
4433
5922
  if (process.platform === "darwin") {
4434
5923
  const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"];
4435
- for (const path6 of homebrewPaths) {
4436
- if (existsSync5(path6) && isValidBinary(path6)) {
4437
- return path6;
5924
+ for (const path7 of homebrewPaths) {
5925
+ if (existsSync6(path7) && isValidBinary(path7)) {
5926
+ return path7;
4438
5927
  }
4439
5928
  }
4440
5929
  }
@@ -4451,8 +5940,8 @@ function getSgCliPath() {
4451
5940
  }
4452
5941
  return "sg";
4453
5942
  }
4454
- function setSgCliPath(path6) {
4455
- resolvedCliPath = path6;
5943
+ function setSgCliPath(path7) {
5944
+ resolvedCliPath = path7;
4456
5945
  }
4457
5946
  var DEFAULT_TIMEOUT_MS2 = 300000;
4458
5947
  var DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024;
@@ -4462,7 +5951,7 @@ var DEFAULT_MAX_MATCHES = 500;
4462
5951
  var initPromise = null;
4463
5952
  async function getAstGrepPath() {
4464
5953
  const currentPath = getSgCliPath();
4465
- if (currentPath !== "sg" && existsSync6(currentPath)) {
5954
+ if (currentPath !== "sg" && existsSync7(currentPath)) {
4466
5955
  return currentPath;
4467
5956
  }
4468
5957
  if (initPromise) {
@@ -4470,7 +5959,7 @@ async function getAstGrepPath() {
4470
5959
  }
4471
5960
  initPromise = (async () => {
4472
5961
  const syncPath = findSgCliPathSync();
4473
- if (syncPath && existsSync6(syncPath)) {
5962
+ if (syncPath && existsSync7(syncPath)) {
4474
5963
  setSgCliPath(syncPath);
4475
5964
  return syncPath;
4476
5965
  }
@@ -4509,14 +5998,14 @@ async function runSg(options) {
4509
5998
  const paths2 = options.paths && options.paths.length > 0 ? options.paths : ["."];
4510
5999
  args.push(...paths2);
4511
6000
  let cliPath = getSgCliPath();
4512
- if (!existsSync6(cliPath) && cliPath !== "sg") {
6001
+ if (!existsSync7(cliPath) && cliPath !== "sg") {
4513
6002
  const downloadedPath = await getAstGrepPath();
4514
6003
  if (downloadedPath) {
4515
6004
  cliPath = downloadedPath;
4516
6005
  }
4517
6006
  }
4518
6007
  const timeout = DEFAULT_TIMEOUT_MS2;
4519
- const proc = spawn4([cliPath, ...args], {
6008
+ const proc = spawn5([cliPath, ...args], {
4520
6009
  stdout: "pipe",
4521
6010
  stderr: "pipe"
4522
6011
  });
@@ -4971,10 +6460,10 @@ Returns the synthesized result with councillor summary.`,
4971
6460
  }
4972
6461
  // src/tools/lsp/client.ts
4973
6462
  import { readFileSync as readFileSync4 } from "fs";
4974
- import { extname, resolve as resolve3 } from "path";
6463
+ import { extname as extname2, resolve as resolve4 } from "path";
4975
6464
  import { Readable, Writable } from "stream";
4976
6465
  import { pathToFileURL } from "url";
4977
- var {spawn: spawn5 } = globalThis.Bun;
6466
+ var {spawn: spawn6 } = globalThis.Bun;
4978
6467
  import {
4979
6468
  createMessageConnection,
4980
6469
  StreamMessageReader,
@@ -4982,9 +6471,9 @@ import {
4982
6471
  } from "vscode-jsonrpc/node";
4983
6472
 
4984
6473
  // src/tools/lsp/config.ts
4985
- import { existsSync as existsSync8 } from "fs";
6474
+ import { existsSync as existsSync9 } from "fs";
4986
6475
  import { homedir as homedir4 } from "os";
4987
- import { dirname as dirname6, join as join9, resolve as resolve2 } from "path";
6476
+ import { dirname as dirname7, join as join10, resolve as resolve3 } from "path";
4988
6477
  import whichSync from "which";
4989
6478
 
4990
6479
  // src/tools/lsp/config-store.ts
@@ -5015,8 +6504,8 @@ function hasUserLspConfig() {
5015
6504
  }
5016
6505
 
5017
6506
  // src/tools/lsp/constants.ts
5018
- import { existsSync as existsSync7, readdirSync, statSync as statSync3 } from "fs";
5019
- import { dirname as dirname5, resolve } from "path";
6507
+ import { existsSync as existsSync8, readdirSync, statSync as statSync3 } from "fs";
6508
+ import { dirname as dirname6, resolve as resolve2 } from "path";
5020
6509
  var SEVERITY_MAP = {
5021
6510
  1: "error",
5022
6511
  2: "warning",
@@ -5033,13 +6522,13 @@ var LOCK_FILE_PATTERNS = [
5033
6522
  "yarn.lock"
5034
6523
  ];
5035
6524
  function* walkUpDirectories(start, stop) {
5036
- let dir = resolve(start);
6525
+ let dir = resolve2(start);
5037
6526
  try {
5038
6527
  if (!statSync3(dir).isDirectory()) {
5039
- dir = dirname5(dir);
6528
+ dir = dirname6(dir);
5040
6529
  }
5041
6530
  } catch {
5042
- dir = dirname5(dir);
6531
+ dir = dirname6(dir);
5043
6532
  }
5044
6533
  let prevDir = "";
5045
6534
  while (dir !== prevDir && dir !== "/") {
@@ -5047,7 +6536,7 @@ function* walkUpDirectories(start, stop) {
5047
6536
  prevDir = dir;
5048
6537
  if (dir === stop)
5049
6538
  break;
5050
- dir = dirname5(dir);
6539
+ dir = dirname6(dir);
5051
6540
  }
5052
6541
  }
5053
6542
  function NearestRoot(includePatterns, excludePatterns) {
@@ -5056,7 +6545,7 @@ function NearestRoot(includePatterns, excludePatterns) {
5056
6545
  if (excludePatterns) {
5057
6546
  for (const dir of walkUpDirectories(file, cwd)) {
5058
6547
  for (const pattern of excludePatterns) {
5059
- if (existsSync7(`${dir}/${pattern}`)) {
6548
+ if (existsSync8(`${dir}/${pattern}`)) {
5060
6549
  return;
5061
6550
  }
5062
6551
  }
@@ -5072,7 +6561,7 @@ function NearestRoot(includePatterns, excludePatterns) {
5072
6561
  return dir;
5073
6562
  }
5074
6563
  } catch {}
5075
- } else if (existsSync7(`${dir}/${pattern}`)) {
6564
+ } else if (existsSync8(`${dir}/${pattern}`)) {
5076
6565
  return dir;
5077
6566
  }
5078
6567
  }
@@ -5593,7 +7082,7 @@ function getServerWorkspace(config, filePath) {
5593
7082
  return;
5594
7083
  }
5595
7084
  if (!config.root) {
5596
- return dirname6(resolve2(filePath));
7085
+ return dirname7(resolve3(filePath));
5597
7086
  }
5598
7087
  return config.root(filePath);
5599
7088
  }
@@ -5617,7 +7106,7 @@ function findInstalledServer(configs, filePath) {
5617
7106
  let firstNotInstalled = null;
5618
7107
  for (const config of configs) {
5619
7108
  const workspace = getServerWorkspace(config, filePath);
5620
- const resolvedCommand = resolveServerCommand(config.command, workspace ?? (filePath ? dirname6(resolve2(filePath)) : undefined));
7109
+ const resolvedCommand = resolveServerCommand(config.command, workspace ?? (filePath ? dirname7(resolve3(filePath)) : undefined));
5621
7110
  const server = toResolvedServer(config, resolvedCommand ?? undefined);
5622
7111
  log(`[LSP] Considering server for ${config.extensions.join(", ")}: ${config.id} with command ${config.command.join(" ")}`);
5623
7112
  if (resolvedCommand) {
@@ -5660,11 +7149,11 @@ function resolveServerCommand(command, cwd) {
5660
7149
  return null;
5661
7150
  const [cmd, ...args] = command;
5662
7151
  if (cmd.includes("/") || cmd.includes("\\")) {
5663
- return existsSync8(cmd) ? command : null;
7152
+ return existsSync9(cmd) ? command : null;
5664
7153
  }
5665
7154
  const isWindows = process.platform === "win32";
5666
7155
  const ext = isWindows ? ".exe" : "";
5667
- const opencodeBin = join9(homedir4(), ".config", "opencode", "bin");
7156
+ const opencodeBin = join10(homedir4(), ".config", "opencode", "bin");
5668
7157
  const searchPath = (process.env.PATH ?? "") + (isWindows ? ";" : ":") + opencodeBin;
5669
7158
  const result = whichSync.sync(cmd, {
5670
7159
  path: searchPath,
@@ -5675,11 +7164,11 @@ function resolveServerCommand(command, cwd) {
5675
7164
  return [result, ...args];
5676
7165
  }
5677
7166
  const localBinRoot = cwd ?? process.cwd();
5678
- const localBin = join9(localBinRoot, "node_modules", ".bin", cmd);
5679
- if (existsSync8(localBin)) {
7167
+ const localBin = join10(localBinRoot, "node_modules", ".bin", cmd);
7168
+ if (existsSync9(localBin)) {
5680
7169
  return [localBin, ...args];
5681
7170
  }
5682
- if (existsSync8(localBin + ext)) {
7171
+ if (existsSync9(localBin + ext)) {
5683
7172
  return [localBin + ext, ...args];
5684
7173
  }
5685
7174
  return null;
@@ -5724,7 +7213,7 @@ function getDiagnosticsCapabilitySummary({
5724
7213
  };
5725
7214
  }
5726
7215
  function withTimeout(promise, ms, label, onTimeout) {
5727
- return new Promise((resolve4, reject) => {
7216
+ return new Promise((resolve5, reject) => {
5728
7217
  let settled = false;
5729
7218
  const timer = setTimeout(() => {
5730
7219
  if (settled) {
@@ -5740,7 +7229,7 @@ function withTimeout(promise, ms, label, onTimeout) {
5740
7229
  }
5741
7230
  settled = true;
5742
7231
  clearTimeout(timer);
5743
- resolve4(value);
7232
+ resolve5(value);
5744
7233
  }, (error) => {
5745
7234
  if (settled) {
5746
7235
  return;
@@ -5944,7 +7433,7 @@ class LSPClient {
5944
7433
  command: command.join(" "),
5945
7434
  root: this.root
5946
7435
  });
5947
- this.proc = spawn5(command, {
7436
+ this.proc = spawn6(command, {
5948
7437
  stdin: "pipe",
5949
7438
  stdout: "pipe",
5950
7439
  stderr: "pipe",
@@ -6030,7 +7519,7 @@ class LSPClient {
6030
7519
  this.processExited = true;
6031
7520
  });
6032
7521
  this.connection.listen();
6033
- await new Promise((resolve4) => setTimeout(resolve4, 100));
7522
+ await new Promise((resolve5) => setTimeout(resolve5, 100));
6034
7523
  if (this.proc.exitCode !== null) {
6035
7524
  const stderr = this.stderrBuffer.join(`
6036
7525
  `);
@@ -6137,10 +7626,10 @@ stderr: ${stderr}` : ""));
6137
7626
  await this.ensureDocumentSynced(filePath);
6138
7627
  }
6139
7628
  async ensureDocumentSynced(filePath) {
6140
- const absPath = resolve3(filePath);
7629
+ const absPath = resolve4(filePath);
6141
7630
  const uri = pathToFileURL(absPath).href;
6142
7631
  const text = readFileSync4(absPath, "utf-8");
6143
- const ext = extname(absPath);
7632
+ const ext = extname2(absPath);
6144
7633
  const languageId = getLanguageId(ext);
6145
7634
  const existing = this.documents.get(uri);
6146
7635
  if (!existing) {
@@ -6179,7 +7668,7 @@ stderr: ${stderr}` : ""));
6179
7668
  }
6180
7669
  }
6181
7670
  async definition(filePath, line, character) {
6182
- const absPath = resolve3(filePath);
7671
+ const absPath = resolve4(filePath);
6183
7672
  await this.openFile(absPath);
6184
7673
  return this.connection ? withTimeout(this.connection.sendRequest("textDocument/definition", {
6185
7674
  textDocument: { uri: pathToFileURL(absPath).href },
@@ -6187,7 +7676,7 @@ stderr: ${stderr}` : ""));
6187
7676
  }), LSP_TIMEOUTS.request, `LSP definition (${this.server.id})`) : undefined;
6188
7677
  }
6189
7678
  async references(filePath, line, character, includeDeclaration = true) {
6190
- const absPath = resolve3(filePath);
7679
+ const absPath = resolve4(filePath);
6191
7680
  await this.openFile(absPath);
6192
7681
  return this.connection ? withTimeout(this.connection.sendRequest("textDocument/references", {
6193
7682
  textDocument: { uri: pathToFileURL(absPath).href },
@@ -6196,7 +7685,7 @@ stderr: ${stderr}` : ""));
6196
7685
  }), LSP_TIMEOUTS.request, `LSP references (${this.server.id})`) : undefined;
6197
7686
  }
6198
7687
  async diagnostics(filePath) {
6199
- const absPath = resolve3(filePath);
7688
+ const absPath = resolve4(filePath);
6200
7689
  const uri = pathToFileURL(absPath).href;
6201
7690
  await this.openFile(absPath);
6202
7691
  await new Promise((r) => setTimeout(r, LSP_TIMEOUTS.diagnosticSettleDelay));
@@ -6251,7 +7740,7 @@ stderr: ${stderr}` : ""));
6251
7740
  throw new Error(`Unable to retrieve diagnostics from ${this.server.id}: request timed out or is unsupported.`);
6252
7741
  }
6253
7742
  async rename(filePath, line, character, newName) {
6254
- const absPath = resolve3(filePath);
7743
+ const absPath = resolve4(filePath);
6255
7744
  await this.openFile(absPath);
6256
7745
  return this.connection ? withTimeout(this.connection.sendRequest("textDocument/rename", {
6257
7746
  textDocument: { uri: pathToFileURL(absPath).href },
@@ -6290,19 +7779,19 @@ import { tool as tool5 } from "@opencode-ai/plugin/tool";
6290
7779
 
6291
7780
  // src/tools/lsp/utils.ts
6292
7781
  import {
6293
- existsSync as existsSync9,
7782
+ existsSync as existsSync10,
6294
7783
  readFileSync as readFileSync5,
6295
7784
  statSync as statSync4,
6296
7785
  unlinkSync as unlinkSync2,
6297
7786
  writeFileSync as writeFileSync3
6298
7787
  } from "fs";
6299
- import { dirname as dirname7, extname as extname2, join as join10, resolve as resolve4 } from "path";
7788
+ import { dirname as dirname8, extname as extname3, join as join11, resolve as resolve5 } from "path";
6300
7789
  import { fileURLToPath as fileURLToPath2 } from "url";
6301
7790
  function findServerProjectRoot(filePath, server) {
6302
7791
  if (server.root) {
6303
- return server.root(filePath) ?? dirname7(resolve4(filePath));
7792
+ return server.root(filePath) ?? dirname8(resolve5(filePath));
6304
7793
  }
6305
- return dirname7(resolve4(filePath));
7794
+ return dirname8(resolve5(filePath));
6306
7795
  }
6307
7796
  function uriToPath(uri) {
6308
7797
  return fileURLToPath2(uri);
@@ -6321,8 +7810,8 @@ function formatServerLookupError(result) {
6321
7810
  return `No LSP server configured for extension: ${result.extension}`;
6322
7811
  }
6323
7812
  async function withLspClient(filePath, fn) {
6324
- const absPath = resolve4(filePath);
6325
- const ext = extname2(absPath);
7813
+ const absPath = resolve5(filePath);
7814
+ const ext = extname3(absPath);
6326
7815
  const result = findServerForExtension(ext, absPath);
6327
7816
  if (result.status !== "found") {
6328
7817
  log("[lsp] withLspClient: server not found", {
@@ -6332,7 +7821,7 @@ async function withLspClient(filePath, fn) {
6332
7821
  throw new Error(formatServerLookupError(result));
6333
7822
  }
6334
7823
  const server = result.server;
6335
- const root = findServerProjectRoot(absPath, server) ?? dirname7(absPath);
7824
+ const root = findServerProjectRoot(absPath, server) ?? dirname8(absPath);
6336
7825
  log("[lsp] withLspClient: selected server", {
6337
7826
  filePath: absPath,
6338
7827
  extension: ext,
@@ -6695,14 +8184,14 @@ var BINARY_PREFIXES = [
6695
8184
  var WEBFETCH_DESCRIPTION = "Fetch a URL with better extraction for static/docs pages. Supports llms.txt probing, content-focused HTML extraction, metadata, redirects, and an optional prompt processed by a cheap secondary model.";
6696
8185
  // src/tools/smartfetch/tool.ts
6697
8186
  import os3 from "os";
6698
- import path9 from "path";
8187
+ import path10 from "path";
6699
8188
  import {
6700
8189
  tool as tool6
6701
8190
  } from "@opencode-ai/plugin";
6702
8191
 
6703
8192
  // src/tools/smartfetch/binary.ts
6704
- import { mkdir, writeFile } from "fs/promises";
6705
- import path6 from "path";
8193
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
8194
+ import path7 from "path";
6706
8195
  function extensionForMime(contentType) {
6707
8196
  const mime = contentType.split(";")[0]?.trim().toLowerCase();
6708
8197
  const map = {
@@ -6721,14 +8210,14 @@ function buildBinaryResultMessage(fetchResult, savedPath) {
6721
8210
  return `${subject} content omitted because it exceeds the download limit.`;
6722
8211
  }
6723
8212
  async function saveBinary(binaryDir, data, contentType, filename) {
6724
- await mkdir(binaryDir, { recursive: true });
8213
+ await mkdir2(binaryDir, { recursive: true });
6725
8214
  const initialName = filename || `webfetch-${Date.now()}.${extensionForMime(contentType)}`;
6726
- const parsed = path6.parse(initialName);
8215
+ const parsed = path7.parse(initialName);
6727
8216
  for (let attempt = 0;attempt < 1000; attempt++) {
6728
8217
  const candidateName = attempt === 0 ? initialName : `${parsed.name}-${attempt}${parsed.ext || `.${extensionForMime(contentType)}`}`;
6729
- const file = path6.join(binaryDir, candidateName);
8218
+ const file = path7.join(binaryDir, candidateName);
6730
8219
  try {
6731
- await writeFile(file, data, { flag: "wx" });
8220
+ await writeFile2(file, data, { flag: "wx" });
6732
8221
  return file;
6733
8222
  } catch (error) {
6734
8223
  if (typeof error === "object" && error && "code" in error && error.code === "EEXIST") {
@@ -6744,7 +8233,7 @@ async function saveBinary(binaryDir, data, contentType, filename) {
6744
8233
  import { LRUCache } from "lru-cache";
6745
8234
 
6746
8235
  // src/tools/smartfetch/network.ts
6747
- import path7 from "path";
8236
+ import path8 from "path";
6748
8237
 
6749
8238
  // src/tools/smartfetch/utils.ts
6750
8239
  import { Readability } from "@mozilla/readability";
@@ -7463,7 +8952,7 @@ function inferFilenameFromUrl(url) {
7463
8952
  function truncateFilename(name, maxLength = 180) {
7464
8953
  if (name.length <= maxLength)
7465
8954
  return name;
7466
- const parsed = path7.parse(name);
8955
+ const parsed = path8.parse(name);
7467
8956
  const ext = parsed.ext || "";
7468
8957
  const baseLimit = Math.max(1, maxLength - ext.length);
7469
8958
  return `${parsed.name.slice(0, baseLimit)}${ext}`;
@@ -7633,9 +9122,9 @@ function isInvalidLlmsResult(fetchResult) {
7633
9122
  }
7634
9123
 
7635
9124
  // src/tools/smartfetch/secondary-model.ts
7636
- import { existsSync as existsSync10 } from "fs";
7637
- import { readFile } from "fs/promises";
7638
- import path8 from "path";
9125
+ import { existsSync as existsSync11 } from "fs";
9126
+ import { readFile as readFile2 } from "fs/promises";
9127
+ import path9 from "path";
7639
9128
  function parseModelRef(value) {
7640
9129
  if (!value)
7641
9130
  return;
@@ -7661,8 +9150,8 @@ function pickAgentModelRef(value) {
7661
9150
  }
7662
9151
  function findPreferredOpenCodeConfigPath(baseDir) {
7663
9152
  for (const file of ["opencode.jsonc", "opencode.json"]) {
7664
- const fullPath = path8.join(baseDir, file);
7665
- if (existsSync10(fullPath))
9153
+ const fullPath = path9.join(baseDir, file);
9154
+ if (existsSync11(fullPath))
7666
9155
  return fullPath;
7667
9156
  }
7668
9157
  return;
@@ -7671,14 +9160,14 @@ async function readOpenCodeConfigFile(configPath) {
7671
9160
  if (!configPath)
7672
9161
  return;
7673
9162
  try {
7674
- const content = await readFile(configPath, "utf8");
9163
+ const content = await readFile2(configPath, "utf8");
7675
9164
  return JSON.parse(stripJsonComments(content));
7676
9165
  } catch {
7677
9166
  return;
7678
9167
  }
7679
9168
  }
7680
9169
  async function readEffectiveOpenCodeConfig(directory) {
7681
- const projectDir = path8.join(directory, ".opencode");
9170
+ const projectDir = path9.join(directory, ".opencode");
7682
9171
  const userDirs = getConfigSearchDirs();
7683
9172
  const projectPath = findPreferredOpenCodeConfigPath(projectDir);
7684
9173
  const userPath = userDirs.map((configDir) => findPreferredOpenCodeConfigPath(configDir)).find(Boolean);
@@ -7839,7 +9328,7 @@ async function runSecondaryModelWithFallback(client, directory, models, prompt,
7839
9328
  // src/tools/smartfetch/tool.ts
7840
9329
  var z5 = tool6.schema;
7841
9330
  function createWebfetchTool(pluginCtx, options = {}) {
7842
- const binaryDir = options.binaryDir || path9.join(os3.tmpdir(), "opencode-smartfetch");
9331
+ const binaryDir = options.binaryDir || path10.join(os3.tmpdir(), "opencode-smartfetch");
7843
9332
  return tool6({
7844
9333
  description: WEBFETCH_DESCRIPTION,
7845
9334
  args: {
@@ -8385,12 +9874,14 @@ var OhMyOpenCodeLite = async (ctx) => {
8385
9874
  const delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
8386
9875
  const jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
8387
9876
  const foregroundFallback = new ForegroundFallbackManager(ctx.client, runtimeChains, config.fallback?.enabled !== false && Object.keys(runtimeChains).length > 0);
9877
+ const sessionAgentMap = new Map;
8388
9878
  const todoContinuationHook = createTodoContinuationHook(ctx, {
8389
9879
  maxContinuations: config.todoContinuation?.maxContinuations ?? 5,
8390
9880
  cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
8391
9881
  autoEnable: config.todoContinuation?.autoEnable ?? false,
8392
9882
  autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4
8393
9883
  });
9884
+ const interviewManager = createInterviewManager(ctx, config);
8394
9885
  return {
8395
9886
  name: "oh-my-opencode-slim",
8396
9887
  agent: agents,
@@ -8510,6 +10001,7 @@ var OhMyOpenCodeLite = async (ctx) => {
8510
10001
  description: "Enable auto-continuation \u2014 orchestrator keeps working through incomplete todos"
8511
10002
  };
8512
10003
  }
10004
+ interviewManager.registerCommand(opencodeConfig);
8513
10005
  },
8514
10006
  event: async (input) => {
8515
10007
  await foregroundFallback.handleEvent(input.event);
@@ -8520,11 +10012,30 @@ var OhMyOpenCodeLite = async (ctx) => {
8520
10012
  await multiplexerSessionManager.onSessionStatus(input.event);
8521
10013
  await backgroundManager.handleSessionDeleted(input.event);
8522
10014
  await multiplexerSessionManager.onSessionDeleted(input.event);
10015
+ await interviewManager.handleEvent(input);
8523
10016
  },
8524
10017
  "command.execute.before": async (input, output) => {
8525
10018
  await todoContinuationHook.handleCommandExecuteBefore(input, output);
10019
+ await interviewManager.handleCommandExecuteBefore(input, output);
8526
10020
  },
8527
10021
  "chat.headers": chatHeadersHook["chat.headers"],
10022
+ "chat.message": async (input) => {
10023
+ if (input.agent) {
10024
+ sessionAgentMap.set(input.sessionID, input.agent);
10025
+ }
10026
+ },
10027
+ "experimental.chat.system.transform": async (input, output) => {
10028
+ const agentName = input.sessionID ? sessionAgentMap.get(input.sessionID) : undefined;
10029
+ if (agentName === "orchestrator") {
10030
+ const alreadyInjected = output.system.some((s) => typeof s === "string" && s.includes("<Role>") && s.includes("orchestrator"));
10031
+ if (!alreadyInjected) {
10032
+ const { ORCHESTRATOR_PROMPT: ORCHESTRATOR_PROMPT2 } = await Promise.resolve().then(() => exports_orchestrator);
10033
+ output.system[0] = ORCHESTRATOR_PROMPT2 + (output.system[0] ? `
10034
+
10035
+ ` + output.system[0] : "");
10036
+ }
10037
+ }
10038
+ },
8528
10039
  "experimental.chat.messages.transform": async (input, output) => {
8529
10040
  const typedOutput = output;
8530
10041
  await phaseReminderHook["experimental.chat.messages.transform"](input, typedOutput);