Compare commits

...

32 Commits

Author SHA1 Message Date
github-actions[bot]
9c5d80af1d release: v3.7.3 2026-02-18 06:05:04 +00:00
YeonGyu-Kim
1e05f4770e fix(cli-run): retry server start on port binding race condition
When port appears available but binding fails (race with another opencode
instance), retry on next available port (auto mode) or attach to existing
server (explicit port mode) instead of crashing with exit code 1.
2026-02-18 15:01:09 +09:00
github-actions[bot]
b1c43aeb89 @codeg-dev has signed the CLA in code-yeongyu/oh-my-opencode#1927 2026-02-18 01:13:27 +00:00
github-actions[bot]
19cd79070e release: v3.7.2 2026-02-17 17:16:40 +00:00
YeonGyu-Kim
c21e0b094f fix(cli-run): strip ANSI codes in think block test assertions for CI compatibility 2026-02-18 02:13:41 +09:00
YeonGyu-Kim
2f659e9b97 fix(cli-run): improve agent header and think block spacing
Add newlines around agent header for visual separation, dim the thinking
label, and add trailing newline after think block close.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) assistance
2026-02-18 02:01:16 +09:00
YeonGyu-Kim
d9751bd5cb fix(cli-run): deduplicate tool headers and message counter resets on repeated events
Guard against duplicate tool header/output rendering when both tool.execute
and message.part.updated fire for the same tool, and prevent message counter
resets when message.updated fires multiple times for the same assistant message.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) assistance
2026-02-18 02:01:08 +09:00
YeonGyu-Kim
3313ec3e4f chore: regenerate AGENTS.md knowledge base
🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) assistance
2026-02-18 01:26:19 +09:00
YeonGyu-Kim
04e95d7e27 refactor(cli-run): stream reasoning text instead of summarized thinking line
Replace the single-line "Thinking: <summary>" rendering with direct streaming
of reasoning tokens via writePaddedText. Removes maybePrintThinkingLine and
renderThinkingLine in favor of incremental output with dim styling.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-02-18 01:14:01 +09:00
YeonGyu-Kim
0bffdc441e feat(hooks): add sisyphus-gpt-hephaestus-reminder hook
Shows error toast when Sisyphus runs with a GPT model, nudging user to
use Hephaestus instead.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-02-18 01:08:40 +09:00
YeonGyu-Kim
eaf315a8d7 feat(cli-run): add streaming delta, think block rendering, and rich tool headers
Adds message.part.delta event handling for real-time streaming output,
reasoning/think block display with in-place updates, per-agent profile
colors, padded text output, and semantic tool headers with icons.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-02-18 01:08:39 +09:00
github-actions[bot]
4bb8fa4a7f @rentiansheng has signed the CLA in code-yeongyu/oh-my-opencode#1889 2026-02-17 14:22:58 +00:00
github-actions[bot]
d937390f68 @feelsodev has signed the CLA in code-yeongyu/oh-my-opencode#1917 2026-02-17 12:24:15 +00:00
YeonGyu-Kim
24d5d50c6f fix(prometheus): replace single atomic write with incremental Write+Edit protocol (#1904) 2026-02-17 18:02:45 +09:00
YeonGyu-Kim
b0ff2ce589 chore: regenerate JSON schema with propertyNames and json-error-recovery hook 2026-02-17 18:02:35 +09:00
YeonGyu-Kim
d0bd24bede fix(cli-run): rely on continuation markers for completion
Use hook-written continuation marker state to gate run completion checks and remove the noisy event-stream shutdown timeout log in run mode.
2026-02-17 17:50:47 +09:00
YeonGyu-Kim
706ee61333 refactor: unify github-issue-triage + github-pr-triage into single github-triage skill
Replace two separate triage skills with one unified skill using 'free' category
for all subagents. Action-oriented: auto-answer questions, analyze bugs,
merge safe PRs. All items tracked via TaskCreate, [sisyphus-bot] comment prefix.
2026-02-17 17:30:52 +09:00
YeonGyu-Kim
0d888df879 fix(cli-run): avoid infinite wait on missing child status
Treat child sessions missing from session.status as transient so completion polling can proceed while still blocking on explicit non-idle descendants.
2026-02-17 16:15:25 +09:00
YeonGyu-Kim
5f9cfcbcf3 feat(cli-run): show agent/model header and suppress toast output 2026-02-17 16:11:34 +09:00
YeonGyu-Kim
4d3cce685d refactor: remove cli run timeout path and rely on strict completion 2026-02-17 16:01:57 +09:00
YeonGyu-Kim
7b2c2529fe fix: enforce continuation-aware completion gating 2026-02-17 16:01:57 +09:00
YeonGyu-Kim
47a8c3e4a9 fix: harden run completion checks and graceful timeout 2026-02-17 16:01:57 +09:00
YeonGyu-Kim
5f5b476f12 fix: gate run event traces behind --verbose 2026-02-17 16:01:57 +09:00
YeonGyu-Kim
991dcdb6c1 Merge pull request #1845 from iyoda/refactor/consolidate-port-utils
refactor(mcp-oauth): consolidate duplicate port utilities into shared/port-utils
2026-02-17 15:59:51 +09:00
YeonGyu-Kim
f4eef9f534 Merge pull request #1907 from BowTiedSwan/fix/json-retry-loop
feat(hooks): add json-error-recovery hook to prevent infinite retry loops
2026-02-17 15:59:44 +09:00
YeonGyu-Kim
8384fd1d07 Merge pull request #1911 from jkoelker/preserve-default-agent
fix(config): normalize configured default_agent
2026-02-17 15:59:36 +09:00
code-yeongyu
a2ad7ce6a7 fix(config): scope default_agent normalization to Sisyphus mode 2026-02-17 15:50:08 +09:00
YeonGyu-Kim
5f939f900a fix(hooks): harden json-error-recovery matching and scope 2026-02-17 15:46:21 +09:00
Jason Kölker
a562e3aa4b fix(config): normalize configured default_agent
Agent keys are remapped to display names, so preserving `default_agent`
values could still select a missing key at runtime.

This regression surfaced after d94a739203 remapped `config.agent` keys
to display names without canonicalizing configured defaults.

Normalize configured `default_agent` through display-name mapping before
fallback logic and extend tests to cover canonical and display-name
inputs.
2026-02-17 01:45:47 +00:00
bowtiedswan
86f2a93fc9 feat(hooks): add json-error-recovery hook to prevent infinite retry loops 2026-02-16 21:35:58 +02:00
IYODA Atsushi
e031695975 test(mcp-oauth): remove redundant findAvailablePort tests (covered by port-utils) 2026-02-15 04:44:23 +09:00
IYODA Atsushi
2048a877f7 refactor(mcp-oauth): delegate port utilities to shared/port-utils 2026-02-15 04:42:21 +09:00
73 changed files with 3529 additions and 1648 deletions

View File

@@ -1,489 +0,0 @@
---
name: github-issue-triage
description: "Triage GitHub issues with streaming analysis. CRITICAL: 1 issue = 1 background task. Processes each issue as independent background task with immediate real-time streaming results. Triggers: 'triage issues', 'analyze issues', 'issue report'."
---
# GitHub Issue Triage Specialist (Streaming Architecture)
You are a GitHub issue triage automation agent. Your job is to:
1. Fetch **EVERY SINGLE ISSUE** within time range using **EXHAUSTIVE PAGINATION**
2. **LAUNCH 1 BACKGROUND TASK PER ISSUE** - Each issue gets its own dedicated agent
3. **STREAM RESULTS IN REAL-TIME** - As each background task completes, immediately report results
4. Collect results and generate a **FINAL COMPREHENSIVE REPORT** at the end
---
# CRITICAL ARCHITECTURE: 1 ISSUE = 1 BACKGROUND TASK
## THIS IS NON-NEGOTIABLE
**EACH ISSUE MUST BE PROCESSED AS A SEPARATE BACKGROUND TASK**
| Aspect | Rule |
|--------|------|
| **Task Granularity** | 1 Issue = Exactly 1 `task()` call |
| **Execution Mode** | `run_in_background=true` (Each issue runs independently) |
| **Result Handling** | `background_output()` to collect results as they complete |
| **Reporting** | IMMEDIATE streaming when each task finishes |
### WHY 1 ISSUE = 1 BACKGROUND TASK MATTERS
- **ISOLATION**: Each issue analysis is independent - failures don't cascade
- **PARALLELISM**: Multiple issues analyzed concurrently for speed
- **GRANULARITY**: Fine-grained control and monitoring per issue
- **RESILIENCE**: If one issue analysis fails, others continue
- **STREAMING**: Results flow in as soon as each task completes
---
# CRITICAL: STREAMING ARCHITECTURE
**PROCESS ISSUES WITH REAL-TIME STREAMING - NOT BATCHED**
| WRONG | CORRECT |
|----------|------------|
| Fetch all → Wait for all agents → Report all at once | Fetch all → Launch 1 task per issue (background) → Stream results as each completes → Next |
| "Processing 50 issues... (wait 5 min) ...here are all results" | "Issue #123 analysis complete... [RESULT] Issue #124 analysis complete... [RESULT] ..." |
| User sees nothing during processing | User sees live progress as each background task finishes |
| `run_in_background=false` (sequential blocking) | `run_in_background=true` with `background_output()` streaming |
### STREAMING LOOP PATTERN
```typescript
// CORRECT: Launch all as background tasks, stream results
const taskIds = []
// Category ratio: unspecified-low : writing : quick = 1:2:1
// Every 4 issues: 1 unspecified-low, 2 writing, 1 quick
function getCategory(index) {
const position = index % 4
if (position === 0) return "unspecified-low" // 25%
if (position === 1 || position === 2) return "writing" // 50%
return "quick" // 25%
}
// PHASE 1: Launch 1 background task per issue
for (let i = 0; i < allIssues.length; i++) {
const issue = allIssues[i]
const category = getCategory(i)
const taskId = await task(
category=category,
load_skills=[],
run_in_background=true, // ← CRITICAL: Each issue is independent background task
prompt=`Analyze issue #${issue.number}...`
)
taskIds.push({ issue: issue.number, taskId, category })
console.log(`🚀 Launched background task for Issue #${issue.number} (${category})`)
}
// PHASE 2: Stream results as they complete
console.log(`\n📊 Streaming results for ${taskIds.length} issues...`)
const completed = new Set()
while (completed.size < taskIds.length) {
for (const { issue, taskId } of taskIds) {
if (completed.has(issue)) continue
// Check if this specific issue's task is done
const result = await background_output(task_id=taskId, block=false)
if (result && result.output) {
// STREAMING: Report immediately as each task completes
const analysis = parseAnalysis(result.output)
reportRealtime(analysis)
completed.add(issue)
console.log(`\n✅ Issue #${issue} analysis complete (${completed.size}/${taskIds.length})`)
}
}
// Small delay to prevent hammering
if (completed.size < taskIds.length) {
await new Promise(r => setTimeout(r, 1000))
}
}
```
### WHY STREAMING MATTERS
- **User sees progress immediately** - no 5-minute silence
- **Critical issues flagged early** - maintainer can act on urgent bugs while others process
- **Transparent** - user knows what's happening in real-time
- **Fail-fast** - if something breaks, we already have partial results
---
# CRITICAL: INITIALIZATION - TODO REGISTRATION (MANDATORY FIRST STEP)
**BEFORE DOING ANYTHING ELSE, CREATE TODOS.**
```typescript
// Create todos immediately
todowrite([
{ id: "1", content: "Fetch all issues with exhaustive pagination", status: "in_progress", priority: "high" },
{ id: "2", content: "Fetch PRs for bug correlation", status: "pending", priority: "high" },
{ id: "3", content: "Launch 1 background task per issue (1 issue = 1 task)", status: "pending", priority: "high" },
{ id: "4", content: "Stream-process results as each task completes", status: "pending", priority: "high" },
{ id: "5", content: "Generate final comprehensive report", status: "pending", priority: "high" }
])
```
---
# PHASE 1: Issue Collection (EXHAUSTIVE Pagination)
### 1.1 Use Bundled Script (MANDATORY)
```bash
# Default: last 48 hours
./scripts/gh_fetch.py issues --hours 48 --output json
# Custom time range
./scripts/gh_fetch.py issues --hours 72 --output json
```
### 1.2 Fallback: Manual Pagination
```bash
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
TIME_RANGE=48
CUTOFF_DATE=$(date -v-${TIME_RANGE}H +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -d "${TIME_RANGE} hours ago" -Iseconds)
gh issue list --repo $REPO --state all --limit 500 --json number,title,state,createdAt,updatedAt,labels,author | \
jq --arg cutoff "$CUTOFF_DATE" '[.[] | select(.createdAt >= $cutoff or .updatedAt >= $cutoff)]'
# Continue pagination if 500 returned...
```
**AFTER Phase 1:** Update todo status.
---
# PHASE 2: PR Collection (For Bug Correlation)
```bash
./scripts/gh_fetch.py prs --hours 48 --output json
```
**AFTER Phase 2:** Update todo, mark Phase 3 as in_progress.
---
# PHASE 3: LAUNCH 1 BACKGROUND TASK PER ISSUE
## THE 1-ISSUE-1-TASK PATTERN (MANDATORY)
**CRITICAL: DO NOT BATCH MULTIPLE ISSUES INTO ONE TASK**
```typescript
// Collection for tracking
const taskMap = new Map() // issueNumber -> taskId
// Category ratio: unspecified-low : writing : quick = 1:2:1
// Every 4 issues: 1 unspecified-low, 2 writing, 1 quick
function getCategory(index, issue) {
const position = index % 4
if (position === 0) return "unspecified-low" // 25%
if (position === 1 || position === 2) return "writing" // 50%
return "quick" // 25%
}
// Launch 1 background task per issue
for (let i = 0; i < allIssues.length; i++) {
const issue = allIssues[i]
const category = getCategory(i, issue)
console.log(`🚀 Launching background task for Issue #${issue.number} (${category})...`)
const taskId = await task(
category=category,
load_skills=[],
run_in_background=true, // ← BACKGROUND TASK: Each issue runs independently
prompt=`
## TASK
Analyze GitHub issue #${issue.number} for ${REPO}.
## ISSUE DATA
- Number: #${issue.number}
- Title: ${issue.title}
- State: ${issue.state}
- Author: ${issue.author.login}
- Created: ${issue.createdAt}
- Updated: ${issue.updatedAt}
- Labels: ${issue.labels.map(l => l.name).join(', ')}
## ISSUE BODY
${issue.body}
## FETCH COMMENTS
Use: gh issue view ${issue.number} --repo ${REPO} --json comments
## PR CORRELATION (Check these for fixes)
${PR_LIST.slice(0, 10).map(pr => `- PR #${pr.number}: ${pr.title}`).join('\n')}
## ANALYSIS CHECKLIST
1. **TYPE**: BUG | QUESTION | FEATURE | INVALID
2. **PROJECT_VALID**: Is this relevant to OUR project? (YES/NO/UNCLEAR)
3. **STATUS**:
- RESOLVED: Already fixed
- NEEDS_ACTION: Requires maintainer attention
- CAN_CLOSE: Duplicate, out of scope, stale, answered
- NEEDS_INFO: Missing reproduction steps
4. **COMMUNITY_RESPONSE**: NONE | HELPFUL | WAITING
5. **LINKED_PR**: PR # that might fix this (or NONE)
6. **CRITICAL**: Is this a blocking bug/security issue? (YES/NO)
## RETURN FORMAT (STRICT)
\`\`\`
ISSUE: #${issue.number}
TITLE: ${issue.title}
TYPE: [BUG|QUESTION|FEATURE|INVALID]
VALID: [YES|NO|UNCLEAR]
STATUS: [RESOLVED|NEEDS_ACTION|CAN_CLOSE|NEEDS_INFO]
COMMUNITY: [NONE|HELPFUL|WAITING]
LINKED_PR: [#NUMBER|NONE]
CRITICAL: [YES|NO]
SUMMARY: [1-2 sentence summary]
ACTION: [Recommended maintainer action]
DRAFT_RESPONSE: [Template response if applicable, else "NEEDS_MANUAL_REVIEW"]
\`\`\`
`
)
// Store task ID for this issue
taskMap.set(issue.number, taskId)
}
console.log(`\n✅ Launched ${taskMap.size} background tasks (1 per issue)`)
```
**AFTER Phase 3:** Update todo, mark Phase 4 as in_progress.
---
# PHASE 4: STREAM RESULTS AS EACH TASK COMPLETES
## REAL-TIME STREAMING COLLECTION
```typescript
const results = []
const critical = []
const closeImmediately = []
const autoRespond = []
const needsInvestigation = []
const featureBacklog = []
const needsInfo = []
const completedIssues = new Set()
const totalIssues = taskMap.size
console.log(`\n📊 Streaming results for ${totalIssues} issues...`)
// Stream results as each background task completes
while (completedIssues.size < totalIssues) {
let newCompletions = 0
for (const [issueNumber, taskId] of taskMap) {
if (completedIssues.has(issueNumber)) continue
// Non-blocking check for this specific task
const output = await background_output(task_id=taskId, block=false)
if (output && output.length > 0) {
// Parse the completed analysis
const analysis = parseAnalysis(output)
results.push(analysis)
completedIssues.add(issueNumber)
newCompletions++
// REAL-TIME STREAMING REPORT
console.log(`\n🔄 Issue #${issueNumber}: ${analysis.TITLE.substring(0, 60)}...`)
// Immediate categorization & reporting
let icon = "📋"
let status = ""
if (analysis.CRITICAL === 'YES') {
critical.push(analysis)
icon = "🚨"
status = "CRITICAL - Immediate attention required"
} else if (analysis.STATUS === 'CAN_CLOSE') {
closeImmediately.push(analysis)
icon = "⚠️"
status = "Can be closed"
} else if (analysis.STATUS === 'RESOLVED') {
closeImmediately.push(analysis)
icon = "✅"
status = "Resolved - can close"
} else if (analysis.DRAFT_RESPONSE !== 'NEEDS_MANUAL_REVIEW') {
autoRespond.push(analysis)
icon = "💬"
status = "Auto-response available"
} else if (analysis.TYPE === 'FEATURE') {
featureBacklog.push(analysis)
icon = "💡"
status = "Feature request"
} else if (analysis.STATUS === 'NEEDS_INFO') {
needsInfo.push(analysis)
icon = "❓"
status = "Needs more info"
} else if (analysis.TYPE === 'BUG') {
needsInvestigation.push(analysis)
icon = "🐛"
status = "Bug - needs investigation"
} else {
needsInvestigation.push(analysis)
icon = "👀"
status = "Needs investigation"
}
console.log(` ${icon} ${status}`)
console.log(` 📊 Action: ${analysis.ACTION}`)
// Progress update every 5 completions
if (completedIssues.size % 5 === 0) {
console.log(`\n📈 PROGRESS: ${completedIssues.size}/${totalIssues} issues analyzed`)
console.log(` Critical: ${critical.length} | Close: ${closeImmediately.length} | Auto-Reply: ${autoRespond.length} | Investigate: ${needsInvestigation.length} | Features: ${featureBacklog.length} | Needs Info: ${needsInfo.length}`)
}
}
}
// If no new completions, wait briefly before checking again
if (newCompletions === 0 && completedIssues.size < totalIssues) {
await new Promise(r => setTimeout(r, 2000))
}
}
console.log(`\n✅ All ${totalIssues} issues analyzed`)
```
---
# PHASE 5: FINAL COMPREHENSIVE REPORT
**GENERATE THIS AT THE VERY END - AFTER ALL PROCESSING**
```markdown
# Issue Triage Report - ${REPO}
**Time Range:** Last ${TIME_RANGE} hours
**Generated:** ${new Date().toISOString()}
**Total Issues Analyzed:** ${results.length}
**Processing Mode:** STREAMING (1 issue = 1 background task, real-time analysis)
---
## 📊 Summary
| Category | Count | Priority |
|----------|-------|----------|
| 🚨 CRITICAL | ${critical.length} | IMMEDIATE |
| ⚠️ Close Immediately | ${closeImmediately.length} | Today |
| 💬 Auto-Respond | ${autoRespond.length} | Today |
| 🐛 Needs Investigation | ${needsInvestigation.length} | This Week |
| 💡 Feature Backlog | ${featureBacklog.length} | Backlog |
| ❓ Needs Info | ${needsInfo.length} | Awaiting User |
---
## 🚨 CRITICAL (Immediate Action Required)
${critical.map(i => `| #${i.ISSUE} | ${i.TITLE.substring(0, 50)}... | ${i.TYPE} |`).join('\n')}
**Action:** These require immediate maintainer attention.
---
## ⚠️ Close Immediately
${closeImmediately.map(i => `| #${i.ISSUE} | ${i.TITLE.substring(0, 50)}... | ${i.STATUS} |`).join('\n')}
---
## 💬 Auto-Respond (Template Ready)
${autoRespond.map(i => `| #${i.ISSUE} | ${i.TITLE.substring(0, 40)}... |`).join('\n')}
**Draft Responses:**
${autoRespond.map(i => `### #${i.ISSUE}\n${i.DRAFT_RESPONSE}\n`).join('\n---\n')}
---
## 🐛 Needs Investigation
${needsInvestigation.map(i => `| #${i.ISSUE} | ${i.TITLE.substring(0, 50)}... | ${i.TYPE} |`).join('\n')}
---
## 💡 Feature Backlog
${featureBacklog.map(i => `| #${i.ISSUE} | ${i.TITLE.substring(0, 50)}... |`).join('\n')}
---
## ❓ Needs More Info
${needsInfo.map(i => `| #${i.ISSUE} | ${i.TITLE.substring(0, 50)}... |`).join('\n')}
---
## 🎯 Immediate Actions
1. **CRITICAL:** ${critical.length} issues need immediate attention
2. **CLOSE:** ${closeImmediately.length} issues can be closed now
3. **REPLY:** ${autoRespond.length} issues have draft responses ready
4. **INVESTIGATE:** ${needsInvestigation.length} bugs need debugging
---
## Processing Log
${results.map((r, i) => `${i+1}. #${r.ISSUE}: ${r.TYPE} (${r.CRITICAL === 'YES' ? 'CRITICAL' : r.STATUS})`).join('\n')}
```
---
## CRITICAL ANTI-PATTERNS (BLOCKING VIOLATIONS)
| Violation | Why It's Wrong | Severity |
|-----------|----------------|----------|
| **Batch multiple issues in one task** | Violates 1 issue = 1 task rule | CRITICAL |
| **Use `run_in_background=false`** | No parallelism, slower execution | CRITICAL |
| **Collect all tasks, report at end** | Loses streaming benefit | CRITICAL |
| **No `background_output()` polling** | Can't stream results | CRITICAL |
| No progress updates | User doesn't know if stuck or working | HIGH |
---
## EXECUTION CHECKLIST
- [ ] Created todos before starting
- [ ] Fetched ALL issues with exhaustive pagination
- [ ] Fetched PRs for correlation
- [ ] **LAUNCHED**: 1 background task per issue (`run_in_background=true`)
- [ ] **STREAMED**: Results via `background_output()` as each task completes
- [ ] Showed live progress every 5 issues
- [ ] Real-time categorization visible to user
- [ ] Critical issues flagged immediately
- [ ] **FINAL**: Comprehensive summary report at end
- [ ] All todos marked complete
---
## Quick Start
When invoked, immediately:
1. **CREATE TODOS**
2. `gh repo view --json nameWithOwner -q .nameWithOwner`
3. Parse time range (default: 48 hours)
4. Exhaustive pagination for issues
5. Exhaustive pagination for PRs
6. **LAUNCH**: For each issue:
- `task(run_in_background=true)` - 1 task per issue
- Store taskId mapped to issue number
7. **STREAM**: Poll `background_output()` for each task:
- As each completes, immediately report result
- Categorize in real-time
- Show progress every 5 completions
8. **GENERATE FINAL COMPREHENSIVE REPORT**

View File

@@ -1,484 +0,0 @@
---
name: github-pr-triage
description: "Triage GitHub Pull Requests with streaming analysis. CRITICAL: 1 PR = 1 background task. Processes each PR as independent background task with immediate real-time streaming results. Conservative auto-close. Triggers: 'triage PRs', 'analyze PRs', 'PR cleanup'."
---
# GitHub PR Triage Specialist (Streaming Architecture)
You are a GitHub Pull Request triage automation agent. Your job is to:
1. Fetch **EVERY SINGLE OPEN PR** using **EXHAUSTIVE PAGINATION**
2. **LAUNCH 1 BACKGROUND TASK PER PR** - Each PR gets its own dedicated agent
3. **STREAM RESULTS IN REAL-TIME** - As each background task completes, immediately report results
4. **CONSERVATIVELY** auto-close PRs that are clearly closeable
5. Generate a **FINAL COMPREHENSIVE REPORT** at the end
---
# CRITICAL ARCHITECTURE: 1 PR = 1 BACKGROUND TASK
## THIS IS NON-NEGOTIABLE
**EACH PR MUST BE PROCESSED AS A SEPARATE BACKGROUND TASK**
| Aspect | Rule |
|--------|------|
| **Task Granularity** | 1 PR = Exactly 1 `task()` call |
| **Execution Mode** | `run_in_background=true` (Each PR runs independently) |
| **Result Handling** | `background_output()` to collect results as they complete |
| **Reporting** | IMMEDIATE streaming when each task finishes |
### WHY 1 PR = 1 BACKGROUND TASK MATTERS
- **ISOLATION**: Each PR analysis is independent - failures don't cascade
- **PARALLELISM**: Multiple PRs analyzed concurrently for speed
- **GRANULARITY**: Fine-grained control and monitoring per PR
- **RESILIENCE**: If one PR analysis fails, others continue
- **STREAMING**: Results flow in as soon as each task completes
---
# CRITICAL: STREAMING ARCHITECTURE
**PROCESS PRs WITH REAL-TIME STREAMING - NOT BATCHED**
| WRONG | CORRECT |
|----------|------------|
| Fetch all → Wait for all agents → Report all at once | Fetch all → Launch 1 task per PR (background) → Stream results as each completes → Next |
| "Processing 50 PRs... (wait 5 min) ...here are all results" | "PR #123 analysis complete... [RESULT] PR #124 analysis complete... [RESULT] ..." |
| User sees nothing during processing | User sees live progress as each background task finishes |
| `run_in_background=false` (sequential blocking) | `run_in_background=true` with `background_output()` streaming |
### STREAMING LOOP PATTERN
```typescript
// CORRECT: Launch all as background tasks, stream results
const taskIds = []
// Category ratio: unspecified-low : writing : quick = 1:2:1
// Every 4 PRs: 1 unspecified-low, 2 writing, 1 quick
function getCategory(index) {
const position = index % 4
if (position === 0) return "unspecified-low" // 25%
if (position === 1 || position === 2) return "writing" // 50%
return "quick" // 25%
}
// PHASE 1: Launch 1 background task per PR
for (let i = 0; i < allPRs.length; i++) {
const pr = allPRs[i]
const category = getCategory(i)
const taskId = await task(
category=category,
load_skills=[],
run_in_background=true, // ← CRITICAL: Each PR is independent background task
prompt=`Analyze PR #${pr.number}...`
)
taskIds.push({ pr: pr.number, taskId, category })
console.log(`🚀 Launched background task for PR #${pr.number} (${category})`)
}
// PHASE 2: Stream results as they complete
console.log(`\n📊 Streaming results for ${taskIds.length} PRs...`)
const completed = new Set()
while (completed.size < taskIds.length) {
for (const { pr, taskId } of taskIds) {
if (completed.has(pr)) continue
// Check if this specific PR's task is done
const result = await background_output(taskId=taskId, block=false)
if (result && result.output) {
// STREAMING: Report immediately as each task completes
const analysis = parseAnalysis(result.output)
reportRealtime(analysis)
completed.add(pr)
console.log(`\n✅ PR #${pr} analysis complete (${completed.size}/${taskIds.length})`)
}
}
// Small delay to prevent hammering
if (completed.size < taskIds.length) {
await new Promise(r => setTimeout(r, 1000))
}
}
```
### WHY STREAMING MATTERS
- **User sees progress immediately** - no 5-minute silence
- **Early decisions visible** - maintainer can act on urgent PRs while others process
- **Transparent** - user knows what's happening in real-time
- **Fail-fast** - if something breaks, we already have partial results
---
# CRITICAL: INITIALIZATION - TODO REGISTRATION (MANDATORY FIRST STEP)
**BEFORE DOING ANYTHING ELSE, CREATE TODOS.**
```typescript
// Create todos immediately
todowrite([
{ id: "1", content: "Fetch all open PRs with exhaustive pagination", status: "in_progress", priority: "high" },
{ id: "2", content: "Launch 1 background task per PR (1 PR = 1 task)", status: "pending", priority: "high" },
{ id: "3", content: "Stream-process results as each task completes", status: "pending", priority: "high" },
{ id: "4", content: "Execute conservative auto-close for eligible PRs", status: "pending", priority: "high" },
{ id: "5", content: "Generate final comprehensive report", status: "pending", priority: "high" }
])
```
---
# PHASE 1: PR Collection (EXHAUSTIVE Pagination)
### 1.1 Use Bundled Script (MANDATORY)
```bash
./scripts/gh_fetch.py prs --output json
```
### 1.2 Fallback: Manual Pagination
```bash
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
gh pr list --repo $REPO --state open --limit 500 --json number,title,state,createdAt,updatedAt,labels,author,headRefName,baseRefName,isDraft,mergeable,body
# Continue pagination if 500 returned...
```
**AFTER Phase 1:** Update todo status to completed, mark Phase 2 as in_progress.
---
# PHASE 2: LAUNCH 1 BACKGROUND TASK PER PR
## THE 1-PR-1-TASK PATTERN (MANDATORY)
**CRITICAL: DO NOT BATCH MULTIPLE PRs INTO ONE TASK**
```typescript
// Collection for tracking
const taskMap = new Map() // prNumber -> taskId
// Category ratio: unspecified-low : writing : quick = 1:2:1
// Every 4 PRs: 1 unspecified-low, 2 writing, 1 quick
function getCategory(index) {
const position = index % 4
if (position === 0) return "unspecified-low" // 25%
if (position === 1 || position === 2) return "writing" // 50%
return "quick" // 25%
}
// Launch 1 background task per PR
for (let i = 0; i < allPRs.length; i++) {
const pr = allPRs[i]
const category = getCategory(i)
console.log(`🚀 Launching background task for PR #${pr.number} (${category})...`)
const taskId = await task(
category=category,
load_skills=[],
run_in_background=true, // ← BACKGROUND TASK: Each PR runs independently
prompt=`
## TASK
Analyze GitHub PR #${pr.number} for ${REPO}.
## PR DATA
- Number: #${pr.number}
- Title: ${pr.title}
- State: ${pr.state}
- Author: ${pr.author.login}
- Created: ${pr.createdAt}
- Updated: ${pr.updatedAt}
- Labels: ${pr.labels.map(l => l.name).join(', ')}
- Head Branch: ${pr.headRefName}
- Base Branch: ${pr.baseRefName}
- Is Draft: ${pr.isDraft}
- Mergeable: ${pr.mergeable}
## PR BODY
${pr.body}
## FETCH ADDITIONAL CONTEXT
1. Fetch PR comments: gh pr view ${pr.number} --repo ${REPO} --json comments
2. Fetch PR reviews: gh pr view ${pr.number} --repo ${REPO} --json reviews
3. Fetch PR files changed: gh pr view ${pr.number} --repo ${REPO} --json files
4. Check if branch exists: git ls-remote --heads origin ${pr.headRefName}
5. Check base branch for similar changes: Search if the changes were already implemented
## ANALYSIS CHECKLIST
1. **MERGE_READY**: Can this PR be merged? (approvals, CI passed, no conflicts, not draft)
2. **PROJECT_ALIGNED**: Does this PR align with current project direction?
3. **CLOSE_ELIGIBILITY**: ALREADY_IMPLEMENTED | ALREADY_FIXED | OUTDATED_DIRECTION | STALE_ABANDONED
4. **STALENESS**: ACTIVE (<30d) | STALE (30-180d) | ABANDONED (180d+)
## CONSERVATIVE CLOSE CRITERIA
MAY CLOSE ONLY IF:
- Exact same change already exists in main
- A merged PR already solved this differently
- Project explicitly deprecated the feature
- Author unresponsive for 6+ months despite requests
## RETURN FORMAT (STRICT)
\`\`\`
PR: #${pr.number}
TITLE: ${pr.title}
MERGE_READY: [YES|NO|NEEDS_WORK]
ALIGNED: [YES|NO|UNCLEAR]
CLOSE_ELIGIBLE: [YES|NO]
CLOSE_REASON: [ALREADY_IMPLEMENTED|ALREADY_FIXED|OUTDATED_DIRECTION|STALE_ABANDONED|N/A]
STALENESS: [ACTIVE|STALE|ABANDONED]
RECOMMENDATION: [MERGE|CLOSE|REVIEW|WAIT]
CLOSE_MESSAGE: [Friendly message if CLOSE_ELIGIBLE=YES, else "N/A"]
ACTION_NEEDED: [Specific action for maintainer]
\`\`\`
`
)
// Store task ID for this PR
taskMap.set(pr.number, taskId)
}
console.log(`\n✅ Launched ${taskMap.size} background tasks (1 per PR)`)
```
**AFTER Phase 2:** Update todo, mark Phase 3 as in_progress.
---
# PHASE 3: STREAM RESULTS AS EACH TASK COMPLETES
## REAL-TIME STREAMING COLLECTION
```typescript
const results = []
const autoCloseable = []
const readyToMerge = []
const needsReview = []
const needsWork = []
const stale = []
const drafts = []
const completedPRs = new Set()
const totalPRs = taskMap.size
console.log(`\n📊 Streaming results for ${totalPRs} PRs...`)
// Stream results as each background task completes
while (completedPRs.size < totalPRs) {
let newCompletions = 0
for (const [prNumber, taskId] of taskMap) {
if (completedPRs.has(prNumber)) continue
// Non-blocking check for this specific task
const output = await background_output(task_id=taskId, block=false)
if (output && output.length > 0) {
// Parse the completed analysis
const analysis = parseAnalysis(output)
results.push(analysis)
completedPRs.add(prNumber)
newCompletions++
// REAL-TIME STREAMING REPORT
console.log(`\n🔄 PR #${prNumber}: ${analysis.TITLE.substring(0, 60)}...`)
// Immediate categorization & reporting
if (analysis.CLOSE_ELIGIBLE === 'YES') {
autoCloseable.push(analysis)
console.log(` ⚠️ AUTO-CLOSE CANDIDATE: ${analysis.CLOSE_REASON}`)
} else if (analysis.MERGE_READY === 'YES') {
readyToMerge.push(analysis)
console.log(` ✅ READY TO MERGE`)
} else if (analysis.RECOMMENDATION === 'REVIEW') {
needsReview.push(analysis)
console.log(` 👀 NEEDS REVIEW`)
} else if (analysis.RECOMMENDATION === 'WAIT') {
needsWork.push(analysis)
console.log(` ⏳ WAITING FOR AUTHOR`)
} else if (analysis.STALENESS === 'STALE' || analysis.STALENESS === 'ABANDONED') {
stale.push(analysis)
console.log(` 💤 ${analysis.STALENESS}`)
} else {
drafts.push(analysis)
console.log(` 📝 DRAFT`)
}
console.log(` 📊 Action: ${analysis.ACTION_NEEDED}`)
// Progress update every 5 completions
if (completedPRs.size % 5 === 0) {
console.log(`\n📈 PROGRESS: ${completedPRs.size}/${totalPRs} PRs analyzed`)
console.log(` Ready: ${readyToMerge.length} | Review: ${needsReview.length} | Wait: ${needsWork.length} | Stale: ${stale.length} | Draft: ${drafts.length} | Close-Candidate: ${autoCloseable.length}`)
}
}
}
// If no new completions, wait briefly before checking again
if (newCompletions === 0 && completedPRs.size < totalPRs) {
await new Promise(r => setTimeout(r, 2000))
}
}
console.log(`\n✅ All ${totalPRs} PRs analyzed`)
```
---
# PHASE 4: Auto-Close Execution (CONSERVATIVE)
### 4.1 Confirm and Close
**Ask for confirmation before closing (unless user explicitly said auto-close is OK)**
```typescript
if (autoCloseable.length > 0) {
console.log(`\n🚨 FOUND ${autoCloseable.length} PR(s) ELIGIBLE FOR AUTO-CLOSE:`)
for (const pr of autoCloseable) {
console.log(` #${pr.PR}: ${pr.TITLE} (${pr.CLOSE_REASON})`)
}
// Close them one by one with progress
for (const pr of autoCloseable) {
console.log(`\n Closing #${pr.PR}...`)
await bash({
command: `gh pr close ${pr.PR} --repo ${REPO} --comment "${pr.CLOSE_MESSAGE}"`,
description: `Close PR #${pr.PR} with friendly message`
})
console.log(` ✅ Closed #${pr.PR}`)
}
}
```
---
# PHASE 5: FINAL COMPREHENSIVE REPORT
**GENERATE THIS AT THE VERY END - AFTER ALL PROCESSING**
```markdown
# PR Triage Report - ${REPO}
**Generated:** ${new Date().toISOString()}
**Total PRs Analyzed:** ${results.length}
**Processing Mode:** STREAMING (1 PR = 1 background task, real-time results)
---
## 📊 Summary
| Category | Count | Status |
|----------|-------|--------|
| ✅ Ready to Merge | ${readyToMerge.length} | Action: Merge immediately |
| ⚠️ Auto-Closed | ${autoCloseable.length} | Already processed |
| 👀 Needs Review | ${needsReview.length} | Action: Assign reviewers |
| ⏳ Needs Work | ${needsWork.length} | Action: Comment guidance |
| 💤 Stale | ${stale.length} | Action: Follow up |
| 📝 Draft | ${drafts.length} | No action needed |
---
## ✅ Ready to Merge
${readyToMerge.map(pr => `| #${pr.PR} | ${pr.TITLE.substring(0, 50)}... |`).join('\n')}
**Action:** These PRs can be merged immediately.
---
## ⚠️ Auto-Closed (During This Triage)
${autoCloseable.map(pr => `| #${pr.PR} | ${pr.TITLE.substring(0, 40)}... | ${pr.CLOSE_REASON} |`).join('\n')}
---
## 👀 Needs Review
${needsReview.map(pr => `| #${pr.PR} | ${pr.TITLE.substring(0, 50)}... |`).join('\n')}
**Action:** Assign maintainers for review.
---
## ⏳ Needs Work
${needsWork.map(pr => `| #${pr.PR} | ${pr.TITLE.substring(0, 50)}... | ${pr.ACTION_NEEDED} |`).join('\n')}
---
## 💤 Stale PRs
${stale.map(pr => `| #${pr.PR} | ${pr.TITLE.substring(0, 40)}... | ${pr.STALENESS} |`).join('\n')}
---
## 📝 Draft PRs
${drafts.map(pr => `| #${pr.PR} | ${pr.TITLE.substring(0, 50)}... |`).join('\n')}
---
## 🎯 Immediate Actions
1. **Merge:** ${readyToMerge.length} PRs ready for immediate merge
2. **Review:** ${needsReview.length} PRs awaiting maintainer attention
3. **Follow Up:** ${stale.length} stale PRs need author ping
---
## Processing Log
${results.map((r, i) => `${i+1}. #${r.PR}: ${r.RECOMMENDATION} (${r.MERGE_READY === 'YES' ? 'ready' : r.CLOSE_ELIGIBLE === 'YES' ? 'close' : 'needs attention'})`).join('\n')}
```
---
## CRITICAL ANTI-PATTERNS (BLOCKING VIOLATIONS)
| Violation | Why It's Wrong | Severity |
|-----------|----------------|----------|
| **Batch multiple PRs in one task** | Violates 1 PR = 1 task rule | CRITICAL |
| **Use `run_in_background=false`** | No parallelism, slower execution | CRITICAL |
| **Collect all tasks, report at end** | Loses streaming benefit | CRITICAL |
| **No `background_output()` polling** | Can't stream results | CRITICAL |
| No progress updates | User doesn't know if stuck or working | HIGH |
---
## EXECUTION CHECKLIST
- [ ] Created todos before starting
- [ ] Fetched ALL PRs with exhaustive pagination
- [ ] **LAUNCHED**: 1 background task per PR (`run_in_background=true`)
- [ ] **STREAMED**: Results via `background_output()` as each task completes
- [ ] Showed live progress every 5 PRs
- [ ] Real-time categorization visible to user
- [ ] Conservative auto-close with confirmation
- [ ] **FINAL**: Comprehensive summary report at end
- [ ] All todos marked complete
---
## Quick Start
When invoked, immediately:
1. **CREATE TODOS**
2. `gh repo view --json nameWithOwner -q .nameWithOwner`
3. Exhaustive pagination for ALL open PRs
4. **LAUNCH**: For each PR:
- `task(run_in_background=true)` - 1 task per PR
- Store taskId mapped to PR number
5. **STREAM**: Poll `background_output()` for each task:
- As each completes, immediately report result
- Categorize in real-time
- Show progress every 5 completions
6. Auto-close eligible PRs
7. **GENERATE FINAL COMPREHENSIVE REPORT**

View File

@@ -1,373 +0,0 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "typer>=0.12.0",
# "rich>=13.0.0",
# ]
# ///
"""
GitHub Issues/PRs Fetcher with Exhaustive Pagination.
Fetches ALL issues and/or PRs from a GitHub repository using gh CLI.
Implements proper pagination to ensure no items are missed.
Usage:
./gh_fetch.py issues # Fetch all issues
./gh_fetch.py prs # Fetch all PRs
./gh_fetch.py all # Fetch both issues and PRs
./gh_fetch.py issues --hours 48 # Issues from last 48 hours
./gh_fetch.py prs --state open # Only open PRs
./gh_fetch.py all --repo owner/repo # Specify repository
"""
import asyncio
import json
from datetime import UTC, datetime, timedelta
from enum import Enum
from typing import Annotated
import typer
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress, TaskID
from rich.table import Table
app = typer.Typer(
name="gh_fetch",
help="Fetch GitHub issues/PRs with exhaustive pagination.",
no_args_is_help=True,
)
console = Console()
BATCH_SIZE = 500 # Maximum allowed by GitHub API
class ItemState(str, Enum):
ALL = "all"
OPEN = "open"
CLOSED = "closed"
class OutputFormat(str, Enum):
JSON = "json"
TABLE = "table"
COUNT = "count"
async def run_gh_command(args: list[str]) -> tuple[str, str, int]:
"""Run gh CLI command asynchronously."""
proc = await asyncio.create_subprocess_exec(
"gh",
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return stdout.decode(), stderr.decode(), proc.returncode or 0
async def get_current_repo() -> str:
"""Get the current repository from gh CLI."""
stdout, stderr, code = await run_gh_command(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
if code != 0:
console.print(f"[red]Error getting current repo: {stderr}[/red]")
raise typer.Exit(1)
return stdout.strip()
async def fetch_items_page(
repo: str,
item_type: str, # "issue" or "pr"
state: str,
limit: int,
search_filter: str = "",
) -> list[dict]:
"""Fetch a single page of issues or PRs."""
cmd = [
item_type,
"list",
"--repo",
repo,
"--state",
state,
"--limit",
str(limit),
"--json",
"number,title,state,createdAt,updatedAt,labels,author,body",
]
if search_filter:
cmd.extend(["--search", search_filter])
stdout, stderr, code = await run_gh_command(cmd)
if code != 0:
console.print(f"[red]Error fetching {item_type}s: {stderr}[/red]")
return []
try:
return json.loads(stdout) if stdout.strip() else []
except json.JSONDecodeError:
console.print(f"[red]Error parsing {item_type} response[/red]")
return []
async def fetch_all_items(
repo: str,
item_type: str,
state: str,
hours: int | None,
progress: Progress,
task_id: TaskID,
) -> list[dict]:
"""Fetch ALL items with exhaustive pagination."""
all_items: list[dict] = []
page = 1
# First fetch
progress.update(task_id, description=f"[cyan]Fetching {item_type}s page {page}...")
items = await fetch_items_page(repo, item_type, state, BATCH_SIZE)
fetched_count = len(items)
all_items.extend(items)
console.print(f"[dim]Page {page}: fetched {fetched_count} {item_type}s[/dim]")
# Continue pagination if we got exactly BATCH_SIZE (more pages exist)
while fetched_count == BATCH_SIZE:
page += 1
progress.update(task_id, description=f"[cyan]Fetching {item_type}s page {page}...")
# Use created date of last item to paginate
last_created = all_items[-1].get("createdAt", "")
if not last_created:
break
search_filter = f"created:<{last_created}"
items = await fetch_items_page(repo, item_type, state, BATCH_SIZE, search_filter)
fetched_count = len(items)
if fetched_count == 0:
break
# Deduplicate by number
existing_numbers = {item["number"] for item in all_items}
new_items = [item for item in items if item["number"] not in existing_numbers]
all_items.extend(new_items)
console.print(
f"[dim]Page {page}: fetched {fetched_count}, added {len(new_items)} new (total: {len(all_items)})[/dim]"
)
# Safety limit
if page > 20:
console.print("[yellow]Safety limit reached (20 pages)[/yellow]")
break
# Filter by time if specified
if hours is not None:
cutoff = datetime.now(UTC) - timedelta(hours=hours)
cutoff_str = cutoff.isoformat()
original_count = len(all_items)
all_items = [
item
for item in all_items
if item.get("createdAt", "") >= cutoff_str or item.get("updatedAt", "") >= cutoff_str
]
filtered_count = original_count - len(all_items)
if filtered_count > 0:
console.print(f"[dim]Filtered out {filtered_count} items older than {hours} hours[/dim]")
return all_items
def display_table(items: list[dict], item_type: str) -> None:
"""Display items in a Rich table."""
table = Table(title=f"{item_type.upper()}s ({len(items)} total)")
table.add_column("#", style="cyan", width=6)
table.add_column("Title", style="white", max_width=50)
table.add_column("State", style="green", width=8)
table.add_column("Author", style="yellow", width=15)
table.add_column("Labels", style="magenta", max_width=30)
table.add_column("Updated", style="dim", width=12)
for item in items[:50]: # Show first 50
labels = ", ".join(label.get("name", "") for label in item.get("labels", []))
updated = item.get("updatedAt", "")[:10]
author = item.get("author", {}).get("login", "unknown")
table.add_row(
str(item.get("number", "")),
(item.get("title", "")[:47] + "...") if len(item.get("title", "")) > 50 else item.get("title", ""),
item.get("state", ""),
author,
(labels[:27] + "...") if len(labels) > 30 else labels,
updated,
)
console.print(table)
if len(items) > 50:
console.print(f"[dim]... and {len(items) - 50} more items[/dim]")
@app.command()
def issues(
repo: Annotated[str | None, typer.Option("--repo", "-r", help="Repository (owner/repo)")] = None,
state: Annotated[ItemState, typer.Option("--state", "-s", help="Issue state filter")] = ItemState.ALL,
hours: Annotated[
int | None,
typer.Option("--hours", "-h", help="Only issues from last N hours (created or updated)"),
] = None,
output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Output format")] = OutputFormat.TABLE,
) -> None:
"""Fetch all issues with exhaustive pagination."""
async def async_main() -> None:
target_repo = repo or await get_current_repo()
console.print(f"""
[cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/cyan]
[cyan]Repository:[/cyan] {target_repo}
[cyan]State:[/cyan] {state.value}
[cyan]Time filter:[/cyan] {f"Last {hours} hours" if hours else "All time"}
[cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/cyan]
""")
with Progress(console=console) as progress:
task: TaskID = progress.add_task("[cyan]Fetching issues...", total=None)
items = await fetch_all_items(target_repo, "issue", state.value, hours, progress, task)
progress.update(task, description="[green]Complete!", completed=100, total=100)
console.print(
Panel(
f"[green]✓ Found {len(items)} issues[/green]",
title="[green]Pagination Complete[/green]",
border_style="green",
)
)
if output == OutputFormat.JSON:
console.print(json.dumps(items, indent=2, ensure_ascii=False))
elif output == OutputFormat.TABLE:
display_table(items, "issue")
else: # COUNT
console.print(f"Total issues: {len(items)}")
asyncio.run(async_main())
@app.command()
def prs(
repo: Annotated[str | None, typer.Option("--repo", "-r", help="Repository (owner/repo)")] = None,
state: Annotated[ItemState, typer.Option("--state", "-s", help="PR state filter")] = ItemState.OPEN,
hours: Annotated[
int | None,
typer.Option("--hours", "-h", help="Only PRs from last N hours (created or updated)"),
] = None,
output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Output format")] = OutputFormat.TABLE,
) -> None:
"""Fetch all PRs with exhaustive pagination."""
async def async_main() -> None:
target_repo = repo or await get_current_repo()
console.print(f"""
[cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/cyan]
[cyan]Repository:[/cyan] {target_repo}
[cyan]State:[/cyan] {state.value}
[cyan]Time filter:[/cyan] {f"Last {hours} hours" if hours else "All time"}
[cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/cyan]
""")
with Progress(console=console) as progress:
task: TaskID = progress.add_task("[cyan]Fetching PRs...", total=None)
items = await fetch_all_items(target_repo, "pr", state.value, hours, progress, task)
progress.update(task, description="[green]Complete!", completed=100, total=100)
console.print(
Panel(
f"[green]✓ Found {len(items)} PRs[/green]",
title="[green]Pagination Complete[/green]",
border_style="green",
)
)
if output == OutputFormat.JSON:
console.print(json.dumps(items, indent=2, ensure_ascii=False))
elif output == OutputFormat.TABLE:
display_table(items, "pr")
else: # COUNT
console.print(f"Total PRs: {len(items)}")
asyncio.run(async_main())
@app.command(name="all")
def fetch_all(
repo: Annotated[str | None, typer.Option("--repo", "-r", help="Repository (owner/repo)")] = None,
state: Annotated[ItemState, typer.Option("--state", "-s", help="State filter")] = ItemState.ALL,
hours: Annotated[
int | None,
typer.Option("--hours", "-h", help="Only items from last N hours (created or updated)"),
] = None,
output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Output format")] = OutputFormat.TABLE,
) -> None:
"""Fetch all issues AND PRs with exhaustive pagination."""
async def async_main() -> None:
target_repo = repo or await get_current_repo()
console.print(f"""
[cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/cyan]
[cyan]Repository:[/cyan] {target_repo}
[cyan]State:[/cyan] {state.value}
[cyan]Time filter:[/cyan] {f"Last {hours} hours" if hours else "All time"}
[cyan]Fetching:[/cyan] Issues AND PRs
[cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/cyan]
""")
with Progress(console=console) as progress:
issues_task: TaskID = progress.add_task("[cyan]Fetching issues...", total=None)
prs_task: TaskID = progress.add_task("[cyan]Fetching PRs...", total=None)
# Fetch in parallel
issues_items, prs_items = await asyncio.gather(
fetch_all_items(target_repo, "issue", state.value, hours, progress, issues_task),
fetch_all_items(target_repo, "pr", state.value, hours, progress, prs_task),
)
progress.update(
issues_task,
description="[green]Issues complete!",
completed=100,
total=100,
)
progress.update(prs_task, description="[green]PRs complete!", completed=100, total=100)
console.print(
Panel(
f"[green]✓ Found {len(issues_items)} issues and {len(prs_items)} PRs[/green]",
title="[green]Pagination Complete[/green]",
border_style="green",
)
)
if output == OutputFormat.JSON:
result = {"issues": issues_items, "prs": prs_items}
console.print(json.dumps(result, indent=2, ensure_ascii=False))
elif output == OutputFormat.TABLE:
display_table(issues_items, "issue")
console.print("")
display_table(prs_items, "pr")
else: # COUNT
console.print(f"Total issues: {len(issues_items)}")
console.print(f"Total PRs: {len(prs_items)}")
asyncio.run(async_main())
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,482 @@
---
name: github-triage
description: "Unified GitHub triage for issues AND PRs. 1 item = 1 background task (category: free). Issues: answer questions from codebase, analyze bugs. PRs: review bugfixes, merge safe ones. All parallel, all background. Triggers: 'triage', 'triage issues', 'triage PRs', 'github triage'."
---
# GitHub Triage — Unified Issue & PR Processor
<role>
You are a GitHub triage orchestrator. You fetch all open issues and PRs, classify each one, then spawn exactly 1 background subagent per item using `category="free"`. Each subagent analyzes its item, takes action (comment/close/merge/report), and records results via TaskCreate.
</role>
---
## ARCHITECTURE
```
1 issue or PR = 1 TaskCreate = 1 task(category="free", run_in_background=true)
```
| Rule | Value |
|------|-------|
| Category for ALL subagents | `free` |
| Execution mode | `run_in_background=true` |
| Parallelism | ALL items launched simultaneously |
| Result tracking | Each subagent calls `TaskCreate` with its findings |
| Result collection | `background_output()` polling loop |
---
## PHASE 1: FETCH ALL OPEN ITEMS
<fetch>
Run these commands to collect data. Use the bundled script if available, otherwise fall back to gh CLI.
```bash
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
# Issues: all open
gh issue list --repo $REPO --state open --limit 500 \
--json number,title,state,createdAt,updatedAt,labels,author,body,comments
# PRs: all open
gh pr list --repo $REPO --state open --limit 500 \
--json number,title,state,createdAt,updatedAt,labels,author,body,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup
```
If either returns exactly 500 results, paginate using `--search "created:<LAST_CREATED_AT"` until exhausted.
</fetch>
---
## PHASE 2: CLASSIFY EACH ITEM
For each item, determine its type based on title, labels, and body content:
<classification>
### Issues
| Type | Detection | Action Path |
|------|-----------|-------------|
| `ISSUE_QUESTION` | Title contains `[Question]`, `[Discussion]`, `?`, or body is asking "how to" / "why does" / "is it possible" | SUBAGENT_ISSUE_QUESTION |
| `ISSUE_BUG` | Title contains `[Bug]`, `Bug:`, body describes unexpected behavior, error messages, stack traces | SUBAGENT_ISSUE_BUG |
| `ISSUE_FEATURE` | Title contains `[Feature]`, `[RFE]`, `[Enhancement]`, `Feature Request`, `Proposal` | SUBAGENT_ISSUE_FEATURE |
| `ISSUE_OTHER` | Anything else | SUBAGENT_ISSUE_OTHER |
### PRs
| Type | Detection | Action Path |
|------|-----------|-------------|
| `PR_BUGFIX` | Title starts with `fix`, `fix:`, `fix(`, branch contains `fix/`, `bugfix/`, or labels include `bug` | SUBAGENT_PR_BUGFIX |
| `PR_OTHER` | Everything else (feat, refactor, docs, chore, etc.) | SUBAGENT_PR_OTHER |
</classification>
---
## PHASE 3: SPAWN 1 BACKGROUND TASK PER ITEM
For EVERY item, create a TaskCreate entry first, then spawn a background task.
```
For each item:
1. TaskCreate(subject="Triage: #{number} {title}")
2. task(category="free", run_in_background=true, load_skills=[], prompt=SUBAGENT_PROMPT)
3. Store mapping: item_number -> { task_id, background_task_id }
```
---
## SUBAGENT PROMPT TEMPLATES
Each subagent gets an explicit, step-by-step prompt. Free models are limited — leave NOTHING implicit.
---
### SUBAGENT_ISSUE_QUESTION
<issue_question_prompt>
```
You are a GitHub issue responder for the repository {REPO}.
ITEM:
- Issue #{number}: {title}
- Author: {author}
- Body: {body}
- Comments: {comments_summary}
YOUR JOB:
1. Read the issue carefully. Understand what the user is asking.
2. Search the codebase to find the answer. Use Grep and Read tools.
- Search for relevant file names, function names, config keys mentioned in the issue.
- Read the files you find to understand how the feature works.
3. Decide: Can you answer this clearly and accurately from the codebase?
IF YES (you found a clear, accurate answer):
Step A: Write a helpful comment. The comment MUST:
- Start with exactly: [sisyphus-bot]
- Be warm, friendly, and thorough
- Include specific file paths and code references
- Include code snippets or config examples if helpful
- End with "Feel free to reopen if this doesn't resolve your question!"
Step B: Post the comment:
gh issue comment {number} --repo {REPO} --body "YOUR_COMMENT"
Step C: Close the issue:
gh issue close {number} --repo {REPO}
Step D: Report back with this EXACT format:
ACTION: ANSWERED_AND_CLOSED
COMMENT_POSTED: yes
SUMMARY: [1-2 sentence summary of your answer]
IF NO (not enough info in codebase, or answer is uncertain):
Report back with:
ACTION: NEEDS_MANUAL_ATTENTION
REASON: [why you couldn't answer — be specific]
PARTIAL_FINDINGS: [what you DID find, if anything]
RULES:
- NEVER guess. Only answer if the codebase clearly supports your answer.
- NEVER make up file paths or function names.
- The [sisyphus-bot] prefix is MANDATORY on every comment you post.
- Be genuinely helpful — imagine you're a senior maintainer who cares about the community.
```
</issue_question_prompt>
---
### SUBAGENT_ISSUE_BUG
<issue_bug_prompt>
```
You are a GitHub bug analyzer for the repository {REPO}.
ITEM:
- Issue #{number}: {title}
- Author: {author}
- Body: {body}
- Comments: {comments_summary}
YOUR JOB:
1. Read the issue carefully. Understand the reported bug:
- What behavior does the user expect?
- What behavior do they actually see?
- What steps reproduce it?
2. Search the codebase for the relevant code. Use Grep and Read tools.
- Find the files/functions mentioned or related to the bug.
- Read them carefully and trace the logic.
3. Determine one of three outcomes:
OUTCOME A — CONFIRMED BUG (you found the problematic code):
Step 1: Post a comment on the issue. The comment MUST:
- Start with exactly: [sisyphus-bot]
- Apologize sincerely for the inconvenience ("We're sorry you ran into this issue.")
- Briefly acknowledge what the bug is
- Say "We've identified the root cause and will work on a fix."
- Do NOT reveal internal implementation details unnecessarily
Step 2: Post the comment:
gh issue comment {number} --repo {REPO} --body "YOUR_COMMENT"
Step 3: Report back with:
ACTION: CONFIRMED_BUG
ROOT_CAUSE: [which file, which function, what goes wrong]
FIX_APPROACH: [how to fix it — be specific: "In {file}, line ~{N}, change X to Y because Z"]
SEVERITY: [LOW|MEDIUM|HIGH|CRITICAL]
AFFECTED_FILES: [list of files that need changes]
OUTCOME B — NOT A BUG (user misunderstanding, provably correct behavior):
ONLY choose this if you can RIGOROUSLY PROVE the behavior is correct.
Step 1: Post a comment. The comment MUST:
- Start with exactly: [sisyphus-bot]
- Be kind and empathetic — never condescending
- Explain clearly WHY the current behavior is correct
- Include specific code references or documentation links
- Offer a workaround or alternative if possible
- End with "Please let us know if you have further questions!"
Step 2: Post the comment:
gh issue comment {number} --repo {REPO} --body "YOUR_COMMENT"
Step 3: DO NOT close the issue. Let the user or maintainer decide.
Step 4: Report back with:
ACTION: NOT_A_BUG
EXPLANATION: [why this is correct behavior]
PROOF: [specific code reference proving it]
OUTCOME C — UNCLEAR (can't determine from codebase alone):
Report back with:
ACTION: NEEDS_INVESTIGATION
FINDINGS: [what you found so far]
BLOCKERS: [what's preventing you from determining the cause]
SUGGESTED_NEXT_STEPS: [what a human should look at]
RULES:
- NEVER guess at root causes. Only report CONFIRMED_BUG if you found the exact problematic code.
- NEVER close bug issues yourself. Only comment.
- For OUTCOME B (not a bug): you MUST have rigorous proof. If there's ANY doubt, choose OUTCOME C instead.
- The [sisyphus-bot] prefix is MANDATORY on every comment.
- When apologizing, be genuine. The user took time to report this.
```
</issue_bug_prompt>
---
### SUBAGENT_ISSUE_FEATURE
<issue_feature_prompt>
```
You are a GitHub feature request analyzer for the repository {REPO}.
ITEM:
- Issue #{number}: {title}
- Author: {author}
- Body: {body}
- Comments: {comments_summary}
YOUR JOB:
1. Read the feature request.
2. Search the codebase to check if this feature already exists (partially or fully).
3. Assess feasibility and alignment with the project.
Report back with:
ACTION: FEATURE_ASSESSED
ALREADY_EXISTS: [YES_FULLY | YES_PARTIALLY | NO]
IF_EXISTS: [where in the codebase, how to use it]
FEASIBILITY: [EASY | MODERATE | HARD | ARCHITECTURAL_CHANGE]
RELEVANT_FILES: [files that would need changes]
NOTES: [any observations about implementation approach]
If the feature already fully exists:
Post a comment (prefix: [sisyphus-bot]) explaining how to use the existing feature with examples.
gh issue comment {number} --repo {REPO} --body "YOUR_COMMENT"
RULES:
- Do NOT close feature requests.
- The [sisyphus-bot] prefix is MANDATORY on any comment.
```
</issue_feature_prompt>
---
### SUBAGENT_ISSUE_OTHER
<issue_other_prompt>
```
You are a GitHub issue analyzer for the repository {REPO}.
ITEM:
- Issue #{number}: {title}
- Author: {author}
- Body: {body}
- Comments: {comments_summary}
YOUR JOB:
Quickly assess this issue and report:
ACTION: ASSESSED
TYPE_GUESS: [QUESTION | BUG | FEATURE | DISCUSSION | META | STALE]
SUMMARY: [1-2 sentence summary]
NEEDS_ATTENTION: [YES | NO]
SUGGESTED_LABEL: [if any]
Do NOT post comments. Do NOT close. Just analyze and report.
```
</issue_other_prompt>
---
### SUBAGENT_PR_BUGFIX
<pr_bugfix_prompt>
```
You are a GitHub PR reviewer for the repository {REPO}.
ITEM:
- PR #{number}: {title}
- Author: {author}
- Base: {baseRefName}
- Head: {headRefName}
- Draft: {isDraft}
- Mergeable: {mergeable}
- Review Decision: {reviewDecision}
- CI Status: {statusCheckRollup_summary}
- Body: {body}
YOUR JOB:
1. Fetch PR details (DO NOT checkout the branch — read-only analysis):
gh pr view {number} --repo {REPO} --json files,reviews,comments,statusCheckRollup,reviewDecision
2. Read the changed files list. For each changed file, use `gh api repos/{REPO}/pulls/{number}/files` to see the diff.
3. Search the codebase to understand what the PR is fixing and whether the fix is correct.
4. Evaluate merge safety:
MERGE CONDITIONS (ALL must be true for auto-merge):
a. CI status checks: ALL passing (no failures, no pending)
b. Review decision: APPROVED
c. The fix is clearly correct — addresses an obvious, unambiguous bug
d. No risky side effects (no architectural changes, no breaking changes)
e. Not a draft PR
f. Mergeable state is clean (no conflicts)
IF ALL MERGE CONDITIONS MET:
Step 1: Merge the PR:
gh pr merge {number} --repo {REPO} --squash --auto
Step 2: Report back with:
ACTION: MERGED
FIX_SUMMARY: [what bug was fixed and how]
FILES_CHANGED: [list of files]
RISK: NONE
IF ANY CONDITION NOT MET:
Report back with:
ACTION: NEEDS_HUMAN_DECISION
FIX_SUMMARY: [what the PR does]
WHAT_IT_FIXES: [the bug or issue it addresses]
CI_STATUS: [PASS | FAIL | PENDING — list any failures]
REVIEW_STATUS: [APPROVED | CHANGES_REQUESTED | PENDING | NONE]
MISSING: [what's preventing auto-merge — be specific]
RISK_ASSESSMENT: [what could go wrong]
AMBIGUOUS_PARTS: [anything that needs human judgment]
RECOMMENDED_ACTION: [what the maintainer should do]
ABSOLUTE RULES:
- NEVER run `git checkout`, `git fetch`, `git pull`, or `git switch`. READ-ONLY via gh CLI and API.
- NEVER checkout the PR branch. NEVER. Use `gh api` and `gh pr view` only.
- Only merge if you are 100% certain ALL conditions are met. When in doubt, report instead.
- The [sisyphus-bot] prefix is MANDATORY on any comment you post.
```
</pr_bugfix_prompt>
---
### SUBAGENT_PR_OTHER
<pr_other_prompt>
```
You are a GitHub PR reviewer for the repository {REPO}.
ITEM:
- PR #{number}: {title}
- Author: {author}
- Base: {baseRefName}
- Head: {headRefName}
- Draft: {isDraft}
- Mergeable: {mergeable}
- Review Decision: {reviewDecision}
- CI Status: {statusCheckRollup_summary}
- Body: {body}
YOUR JOB:
1. Fetch PR details (READ-ONLY — no checkout):
gh pr view {number} --repo {REPO} --json files,reviews,comments,statusCheckRollup,reviewDecision
2. Read the changed files via `gh api repos/{REPO}/pulls/{number}/files`.
3. Assess the PR and report:
ACTION: PR_ASSESSED
TYPE: [FEATURE | REFACTOR | DOCS | CHORE | TEST | OTHER]
SUMMARY: [what this PR does in 2-3 sentences]
CI_STATUS: [PASS | FAIL | PENDING]
REVIEW_STATUS: [APPROVED | CHANGES_REQUESTED | PENDING | NONE]
FILES_CHANGED: [count and key files]
RISK_LEVEL: [LOW | MEDIUM | HIGH]
ALIGNMENT: [does this fit the project direction? YES | NO | UNCLEAR]
BLOCKERS: [anything preventing merge]
RECOMMENDED_ACTION: [MERGE | REQUEST_CHANGES | NEEDS_REVIEW | CLOSE | WAIT]
NOTES: [any observations for the maintainer]
ABSOLUTE RULES:
- NEVER run `git checkout`, `git fetch`, `git pull`, or `git switch`. READ-ONLY.
- NEVER checkout the PR branch. Use `gh api` and `gh pr view` only.
- Do NOT merge non-bugfix PRs automatically. Report only.
```
</pr_other_prompt>
---
## PHASE 4: COLLECT RESULTS & UPDATE TASKS
<collection>
Poll `background_output()` for each spawned task. As each completes:
1. Parse the subagent's report.
2. Update the corresponding TaskCreate entry:
- `TaskUpdate(id=task_id, status="completed", description=FULL_REPORT_TEXT)`
3. Stream the result to the user immediately — do not wait for all to finish.
Track counters:
- issues_answered (commented + closed)
- bugs_confirmed
- bugs_not_a_bug
- prs_merged
- prs_needs_decision
- features_assessed
</collection>
---
## PHASE 5: FINAL SUMMARY
After all background tasks complete, produce a summary:
```markdown
# GitHub Triage Report — {REPO}
**Date:** {date}
**Items Processed:** {total}
## Issues ({issue_count})
| Action | Count |
|--------|-------|
| Answered & Closed | {issues_answered} |
| Bug Confirmed | {bugs_confirmed} |
| Not A Bug (explained) | {bugs_not_a_bug} |
| Feature Assessed | {features_assessed} |
| Needs Manual Attention | {needs_manual} |
## PRs ({pr_count})
| Action | Count |
|--------|-------|
| Auto-Merged (safe bugfix) | {prs_merged} |
| Needs Human Decision | {prs_needs_decision} |
| Assessed (non-bugfix) | {prs_assessed} |
## Items Requiring Your Attention
[List each item that needs human decision with its report summary]
```
---
## ANTI-PATTERNS
| Violation | Severity |
|-----------|----------|
| Using any category other than `free` | CRITICAL |
| Batching multiple items into one task | CRITICAL |
| Using `run_in_background=false` | CRITICAL |
| Subagent running `git checkout` on a PR branch | CRITICAL |
| Posting comment without `[sisyphus-bot]` prefix | CRITICAL |
| Merging a PR that doesn't meet ALL 6 conditions | CRITICAL |
| Closing a bug issue (only comment, never close bugs) | HIGH |
| Guessing at answers without codebase evidence | HIGH |
| Not recording results via TaskCreate/TaskUpdate | HIGH |
---
## QUICK START
When invoked:
1. `TaskCreate` for the overall triage job
2. Fetch all open issues + PRs via gh CLI (paginate if needed)
3. Classify each item (ISSUE_QUESTION, ISSUE_BUG, ISSUE_FEATURE, PR_BUGFIX, etc.)
4. For EACH item: `TaskCreate` + `task(category="free", run_in_background=true, load_skills=[], prompt=...)`
5. Poll `background_output()` — stream results as they arrive
6. `TaskUpdate` each task with the subagent's findings
7. Produce final summary report

View File

@@ -69,7 +69,9 @@ async def run_gh_command(args: list[str]) -> tuple[str, str, int]:
async def get_current_repo() -> str:
"""Get the current repository from gh CLI."""
stdout, stderr, code = await run_gh_command(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
stdout, stderr, code = await run_gh_command(
["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]
)
if code != 0:
console.print(f"[red]Error getting current repo: {stderr}[/red]")
raise typer.Exit(1)
@@ -123,7 +125,6 @@ async def fetch_all_items(
all_items: list[dict] = []
page = 1
# First fetch
progress.update(task_id, description=f"[cyan]Fetching {item_type}s page {page}...")
items = await fetch_items_page(repo, item_type, state, BATCH_SIZE)
fetched_count = len(items)
@@ -131,24 +132,25 @@ async def fetch_all_items(
console.print(f"[dim]Page {page}: fetched {fetched_count} {item_type}s[/dim]")
# Continue pagination if we got exactly BATCH_SIZE (more pages exist)
while fetched_count == BATCH_SIZE:
page += 1
progress.update(task_id, description=f"[cyan]Fetching {item_type}s page {page}...")
progress.update(
task_id, description=f"[cyan]Fetching {item_type}s page {page}..."
)
# Use created date of last item to paginate
last_created = all_items[-1].get("createdAt", "")
if not last_created:
break
search_filter = f"created:<{last_created}"
items = await fetch_items_page(repo, item_type, state, BATCH_SIZE, search_filter)
items = await fetch_items_page(
repo, item_type, state, BATCH_SIZE, search_filter
)
fetched_count = len(items)
if fetched_count == 0:
break
# Deduplicate by number
existing_numbers = {item["number"] for item in all_items}
new_items = [item for item in items if item["number"] not in existing_numbers]
all_items.extend(new_items)
@@ -157,12 +159,10 @@ async def fetch_all_items(
f"[dim]Page {page}: fetched {fetched_count}, added {len(new_items)} new (total: {len(all_items)})[/dim]"
)
# Safety limit
if page > 20:
console.print("[yellow]Safety limit reached (20 pages)[/yellow]")
break
# Filter by time if specified
if hours is not None:
cutoff = datetime.now(UTC) - timedelta(hours=hours)
cutoff_str = cutoff.isoformat()
@@ -171,11 +171,14 @@ async def fetch_all_items(
all_items = [
item
for item in all_items
if item.get("createdAt", "") >= cutoff_str or item.get("updatedAt", "") >= cutoff_str
if item.get("createdAt", "") >= cutoff_str
or item.get("updatedAt", "") >= cutoff_str
]
filtered_count = original_count - len(all_items)
if filtered_count > 0:
console.print(f"[dim]Filtered out {filtered_count} items older than {hours} hours[/dim]")
console.print(
f"[dim]Filtered out {filtered_count} items older than {hours} hours[/dim]"
)
return all_items
@@ -190,14 +193,16 @@ def display_table(items: list[dict], item_type: str) -> None:
table.add_column("Labels", style="magenta", max_width=30)
table.add_column("Updated", style="dim", width=12)
for item in items[:50]: # Show first 50
for item in items[:50]:
labels = ", ".join(label.get("name", "") for label in item.get("labels", []))
updated = item.get("updatedAt", "")[:10]
author = item.get("author", {}).get("login", "unknown")
table.add_row(
str(item.get("number", "")),
(item.get("title", "")[:47] + "...") if len(item.get("title", "")) > 50 else item.get("title", ""),
(item.get("title", "")[:47] + "...")
if len(item.get("title", "")) > 50
else item.get("title", ""),
item.get("state", ""),
author,
(labels[:27] + "...") if len(labels) > 30 else labels,
@@ -211,13 +216,21 @@ def display_table(items: list[dict], item_type: str) -> None:
@app.command()
def issues(
repo: Annotated[str | None, typer.Option("--repo", "-r", help="Repository (owner/repo)")] = None,
state: Annotated[ItemState, typer.Option("--state", "-s", help="Issue state filter")] = ItemState.ALL,
repo: Annotated[
str | None, typer.Option("--repo", "-r", help="Repository (owner/repo)")
] = None,
state: Annotated[
ItemState, typer.Option("--state", "-s", help="Issue state filter")
] = ItemState.ALL,
hours: Annotated[
int | None,
typer.Option("--hours", "-h", help="Only issues from last N hours (created or updated)"),
typer.Option(
"--hours", "-h", help="Only issues from last N hours (created or updated)"
),
] = None,
output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Output format")] = OutputFormat.TABLE,
output: Annotated[
OutputFormat, typer.Option("--output", "-o", help="Output format")
] = OutputFormat.TABLE,
) -> None:
"""Fetch all issues with exhaustive pagination."""
@@ -225,33 +238,29 @@ def issues(
target_repo = repo or await get_current_repo()
console.print(f"""
[cyan][/cyan]
[cyan]Repository:[/cyan] {target_repo}
[cyan]State:[/cyan] {state.value}
[cyan]Time filter:[/cyan] {f"Last {hours} hours" if hours else "All time"}
[cyan][/cyan]
""")
with Progress(console=console) as progress:
task: TaskID = progress.add_task("[cyan]Fetching issues...", total=None)
items = await fetch_all_items(target_repo, "issue", state.value, hours, progress, task)
progress.update(task, description="[green]Complete!", completed=100, total=100)
items = await fetch_all_items(
target_repo, "issue", state.value, hours, progress, task
)
progress.update(
task, description="[green]Complete!", completed=100, total=100
)
console.print(
Panel(
f"[green]✓ Found {len(items)} issues[/green]",
title="[green]Pagination Complete[/green]",
border_style="green",
)
Panel(f"[green]Found {len(items)} issues[/green]", border_style="green")
)
if output == OutputFormat.JSON:
console.print(json.dumps(items, indent=2, ensure_ascii=False))
elif output == OutputFormat.TABLE:
display_table(items, "issue")
else: # COUNT
else:
console.print(f"Total issues: {len(items)}")
asyncio.run(async_main())
@@ -259,13 +268,21 @@ def issues(
@app.command()
def prs(
repo: Annotated[str | None, typer.Option("--repo", "-r", help="Repository (owner/repo)")] = None,
state: Annotated[ItemState, typer.Option("--state", "-s", help="PR state filter")] = ItemState.OPEN,
repo: Annotated[
str | None, typer.Option("--repo", "-r", help="Repository (owner/repo)")
] = None,
state: Annotated[
ItemState, typer.Option("--state", "-s", help="PR state filter")
] = ItemState.OPEN,
hours: Annotated[
int | None,
typer.Option("--hours", "-h", help="Only PRs from last N hours (created or updated)"),
typer.Option(
"--hours", "-h", help="Only PRs from last N hours (created or updated)"
),
] = None,
output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Output format")] = OutputFormat.TABLE,
output: Annotated[
OutputFormat, typer.Option("--output", "-o", help="Output format")
] = OutputFormat.TABLE,
) -> None:
"""Fetch all PRs with exhaustive pagination."""
@@ -273,33 +290,29 @@ def prs(
target_repo = repo or await get_current_repo()
console.print(f"""
[cyan][/cyan]
[cyan]Repository:[/cyan] {target_repo}
[cyan]State:[/cyan] {state.value}
[cyan]Time filter:[/cyan] {f"Last {hours} hours" if hours else "All time"}
[cyan][/cyan]
""")
with Progress(console=console) as progress:
task: TaskID = progress.add_task("[cyan]Fetching PRs...", total=None)
items = await fetch_all_items(target_repo, "pr", state.value, hours, progress, task)
progress.update(task, description="[green]Complete!", completed=100, total=100)
items = await fetch_all_items(
target_repo, "pr", state.value, hours, progress, task
)
progress.update(
task, description="[green]Complete!", completed=100, total=100
)
console.print(
Panel(
f"[green]✓ Found {len(items)} PRs[/green]",
title="[green]Pagination Complete[/green]",
border_style="green",
)
Panel(f"[green]Found {len(items)} PRs[/green]", border_style="green")
)
if output == OutputFormat.JSON:
console.print(json.dumps(items, indent=2, ensure_ascii=False))
elif output == OutputFormat.TABLE:
display_table(items, "pr")
else: # COUNT
else:
console.print(f"Total PRs: {len(items)}")
asyncio.run(async_main())
@@ -307,13 +320,21 @@ def prs(
@app.command(name="all")
def fetch_all(
repo: Annotated[str | None, typer.Option("--repo", "-r", help="Repository (owner/repo)")] = None,
state: Annotated[ItemState, typer.Option("--state", "-s", help="State filter")] = ItemState.ALL,
repo: Annotated[
str | None, typer.Option("--repo", "-r", help="Repository (owner/repo)")
] = None,
state: Annotated[
ItemState, typer.Option("--state", "-s", help="State filter")
] = ItemState.ALL,
hours: Annotated[
int | None,
typer.Option("--hours", "-h", help="Only items from last N hours (created or updated)"),
typer.Option(
"--hours", "-h", help="Only items from last N hours (created or updated)"
),
] = None,
output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Output format")] = OutputFormat.TABLE,
output: Annotated[
OutputFormat, typer.Option("--output", "-o", help="Output format")
] = OutputFormat.TABLE,
) -> None:
"""Fetch all issues AND PRs with exhaustive pagination."""
@@ -321,22 +342,25 @@ def fetch_all(
target_repo = repo or await get_current_repo()
console.print(f"""
[cyan][/cyan]
[cyan]Repository:[/cyan] {target_repo}
[cyan]State:[/cyan] {state.value}
[cyan]Time filter:[/cyan] {f"Last {hours} hours" if hours else "All time"}
[cyan]Fetching:[/cyan] Issues AND PRs
[cyan][/cyan]
""")
with Progress(console=console) as progress:
issues_task: TaskID = progress.add_task("[cyan]Fetching issues...", total=None)
issues_task: TaskID = progress.add_task(
"[cyan]Fetching issues...", total=None
)
prs_task: TaskID = progress.add_task("[cyan]Fetching PRs...", total=None)
# Fetch in parallel
issues_items, prs_items = await asyncio.gather(
fetch_all_items(target_repo, "issue", state.value, hours, progress, issues_task),
fetch_all_items(target_repo, "pr", state.value, hours, progress, prs_task),
fetch_all_items(
target_repo, "issue", state.value, hours, progress, issues_task
),
fetch_all_items(
target_repo, "pr", state.value, hours, progress, prs_task
),
)
progress.update(
@@ -345,12 +369,13 @@ def fetch_all(
completed=100,
total=100,
)
progress.update(prs_task, description="[green]PRs complete!", completed=100, total=100)
progress.update(
prs_task, description="[green]PRs complete!", completed=100, total=100
)
console.print(
Panel(
f"[green]Found {len(issues_items)} issues and {len(prs_items)} PRs[/green]",
title="[green]Pagination Complete[/green]",
f"[green]Found {len(issues_items)} issues and {len(prs_items)} PRs[/green]",
border_style="green",
)
)
@@ -362,7 +387,7 @@ def fetch_all(
display_table(issues_items, "issue")
console.print("")
display_table(prs_items, "pr")
else: # COUNT
else:
console.print(f"Total issues: {len(issues_items)}")
console.print(f"Total PRs: {len(prs_items)}")

View File

@@ -1,10 +1,10 @@
# oh-my-opencode — OpenCode Plugin
**Generated:** 2026-02-17 | **Commit:** aac79f03 | **Branch:** dev
**Generated:** 2026-02-18 | **Commit:** 04e95d7e | **Branch:** dev
## OVERVIEW
OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 41 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1164 TypeScript files, 133k LOC.
OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 44 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1149 TypeScript files, 132k LOC.
## STRUCTURE
@@ -14,14 +14,14 @@ oh-my-opencode/
│ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface
│ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4)
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
│ ├── hooks/ # 41 hooks across 37 directories + 6 standalone files
│ ├── hooks/ # 44 hooks across 39 directories + 6 standalone files
│ ├── tools/ # 26 tools across 15 directories
│ ├── features/ # 18 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.)
│ ├── features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.)
│ ├── shared/ # 101 utility files in 13 categories
│ ├── config/ # Zod v4 schema system (22 files)
│ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js)
│ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app)
│ ├── plugin/ # 8 OpenCode hook handlers + 41 hook composition
│ ├── plugin/ # 8 OpenCode hook handlers + 44 hook composition
│ └── plugin-handlers/ # 6-phase config loading pipeline
├── packages/ # Monorepo: comment-checker, opencode-sdk
└── local-ignore/ # Dev-only test fixtures
@@ -34,7 +34,7 @@ OhMyOpenCodePlugin(ctx)
├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate
├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools)
├─→ createHooks() # 3-tier: Core(32) + Continuation(7) + Skill(2) = 41 hooks
├─→ createHooks() # 3-tier: Core(35) + Continuation(7) + Skill(2) = 44 hooks
└─→ createPluginInterface() # 8 OpenCode hook handlers → PluginInterface
```
@@ -86,7 +86,7 @@ Fields: agents (14 overridable), categories (8 built-in + custom), disabled_* ar
- **Test pattern**: Vitest, co-located `*.test.ts`, given/when/then style
- **Factory pattern**: `createXXX()` for all tools, hooks, agents
- **Hook tiers**: Session (19) → Tool-Guard (9) → Transform (4) → Continuation (7) → Skill (2)
- **Hook tiers**: Session (22) → Tool-Guard (9) → Transform (4) → Continuation (7) → Skill (2)
- **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all`
- **Model resolution**: 3-step: override → category-default → provider-fallback → system-default
- **Config format**: JSONC with comments, Zod v4 validation, snake_case keys

View File

@@ -87,9 +87,11 @@
"claude-code-hooks",
"auto-slash-command",
"edit-error-recovery",
"json-error-recovery",
"delegate-task-retry",
"prometheus-md-only",
"sisyphus-junior-notepad",
"sisyphus-gpt-hephaestus-reminder",
"start-work",
"atlas",
"unstable-agent-babysitter",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.7.1",
"version": "3.7.3",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -74,13 +74,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.7.1",
"oh-my-opencode-darwin-x64": "3.7.1",
"oh-my-opencode-linux-arm64": "3.7.1",
"oh-my-opencode-linux-arm64-musl": "3.7.1",
"oh-my-opencode-linux-x64": "3.7.1",
"oh-my-opencode-linux-x64-musl": "3.7.1",
"oh-my-opencode-windows-x64": "3.7.1"
"oh-my-opencode-darwin-arm64": "3.7.3",
"oh-my-opencode-darwin-x64": "3.7.3",
"oh-my-opencode-linux-arm64": "3.7.3",
"oh-my-opencode-linux-arm64-musl": "3.7.3",
"oh-my-opencode-linux-x64": "3.7.3",
"oh-my-opencode-linux-x64-musl": "3.7.3",
"oh-my-opencode-windows-x64": "3.7.3"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.7.1",
"version": "3.7.3",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.7.1",
"version": "3.7.3",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "3.7.1",
"version": "3.7.3",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.7.1",
"version": "3.7.3",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.7.1",
"version": "3.7.3",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.7.1",
"version": "3.7.3",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.7.1",
"version": "3.7.3",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -1527,6 +1527,38 @@
"created_at": "2026-02-16T19:01:33Z",
"repoId": 1108837393,
"pullRequestNo": 1906
},
{
"name": "feelsodev",
"id": 59601439,
"comment_id": 3914425492,
"created_at": "2026-02-17T12:24:00Z",
"repoId": 1108837393,
"pullRequestNo": 1917
},
{
"name": "rentiansheng",
"id": 3955934,
"comment_id": 3914953522,
"created_at": "2026-02-17T14:18:29Z",
"repoId": 1108837393,
"pullRequestNo": 1889
},
{
"name": "codeg-dev",
"id": 12405078,
"comment_id": 3915482750,
"created_at": "2026-02-17T15:47:18Z",
"repoId": 1108837393,
"pullRequestNo": 1927
},
{
"name": "codeg-dev",
"id": 12405078,
"comment_id": 3915952929,
"created_at": "2026-02-17T17:11:11Z",
"repoId": 1108837393,
"pullRequestNo": 1927
}
]
}

View File

@@ -1,6 +1,6 @@
# src/ — Plugin Source
**Generated:** 2026-02-17
**Generated:** 2026-02-18
## OVERVIEW
@@ -14,7 +14,7 @@ Root source directory. Entry point `index.ts` orchestrates 4-step initialization
| `plugin-config.ts` | JSONC parse, multi-level merge (user → project → defaults), Zod validation |
| `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler |
| `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry |
| `create-hooks.ts` | 3-tier hook composition: Core(32) + Continuation(7) + Skill(2) |
| `create-hooks.ts` | 3-tier hook composition: Core(35) + Continuation(7) + Skill(2) |
| `plugin-interface.ts` | Assembles 8 OpenCode hook handlers into PluginInterface |
## CONFIG LOADING
@@ -32,8 +32,8 @@ loadPluginConfig(directory, ctx)
```
createHooks()
├─→ createCoreHooks() # 32 hooks
│ ├─ createSessionHooks() # 19: contextWindowMonitor, thinkMode, ralphLoop, sessionRecovery...
├─→ createCoreHooks() # 35 hooks
│ ├─ createSessionHooks() # 22: contextWindowMonitor, thinkMode, ralphLoop, sessionRecovery, jsonErrorRecovery, sisyphusGptHephaestusReminder, taskReminder...
│ ├─ createToolGuardHooks() # 9: commentChecker, rulesInjector, writeExistingFileGuard...
│ └─ createTransformHooks() # 4: claudeCodeHooks, keywordDetector, contextInjector, thinkingBlockValidator
├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard...

View File

@@ -166,40 +166,71 @@ unblocking maximum parallelism in subsequent waves.
**The plan can have 50+ TODOs. That's OK. ONE PLAN.**
### 6.1 SINGLE ATOMIC WRITE (CRITICAL - Prevents Content Loss)
### 6.1 INCREMENTAL WRITE PROTOCOL (CRITICAL - Prevents Output Limit Stalls)
<write_protocol>
**The Write tool OVERWRITES files. It does NOT append.**
**Write OVERWRITES. Never call Write twice on the same file.**
**MANDATORY PROTOCOL:**
1. **Prepare ENTIRE plan content in memory FIRST**
2. **Write ONCE with complete content**
3. **NEVER split into multiple Write calls**
Plans with many tasks will exceed your output token limit if you try to generate everything at once.
Split into: **one Write** (skeleton) + **multiple Edits** (tasks in batches).
**IF plan is too large for single output:**
1. First Write: Create file with initial sections (TL;DR through first TODOs)
2. Subsequent: Use **Edit tool** to APPEND remaining sections
- Target the END of the file
- Edit replaces text, so include last line + new content
**Step 1 — Write skeleton (all sections EXCEPT individual task details):**
**FORBIDDEN (causes content loss):**
\`\`\`
Write(".sisyphus/plans/x.md", "# Part 1...")
❌ Write(".sisyphus/plans/x.md", "# Part 2...") // Part 1 is GONE!
Write(".sisyphus/plans/{name}.md", content=\`
# {Plan Title}
## TL;DR
> ...
## Context
...
## Work Objectives
...
## Verification Strategy
...
## Execution Strategy
...
---
## TODOs
---
## Final Verification Wave
...
## Commit Strategy
...
## Success Criteria
...
\`)
\`\`\`
**CORRECT (preserves content):**
\`\`\`
✅ Write(".sisyphus/plans/x.md", "# Complete plan content...") // Single write
**Step 2 — Edit-append tasks in batches of 2-4:**
// OR if too large:
✅ Write(".sisyphus/plans/x.md", "# Plan\n## TL;DR\n...") // First chunk
✅ Edit(".sisyphus/plans/x.md", oldString="---\n## Success Criteria", newString="---\n## More TODOs\n...\n---\n## Success Criteria") // Append via Edit
Use Edit to insert each batch of tasks before the Final Verification section:
\`\`\`
Edit(".sisyphus/plans/{name}.md",
oldString="---\\n\\n## Final Verification Wave",
newString="- [ ] 1. Task Title\\n\\n **What to do**: ...\\n **QA Scenarios**: ...\\n\\n- [ ] 2. Task Title\\n\\n **What to do**: ...\\n **QA Scenarios**: ...\\n\\n---\\n\\n## Final Verification Wave")
\`\`\`
**SELF-CHECK before Write:**
- [ ] Is this the FIRST write to this file? → Write is OK
- [ ] File already exists with my content? → Use Edit to append, NOT Write
Repeat until all tasks are written. 2-4 tasks per Edit call balances speed and output limits.
**Step 3 — Verify completeness:**
After all Edits, Read the plan file to confirm all tasks are present and no content was lost.
**FORBIDDEN:**
- \`Write()\` twice to the same file — second call erases the first
- Generating ALL tasks in a single Write — hits output limits, causes stalls
</write_protocol>
### 7. DRAFT AS WORKING MEMORY (MANDATORY)

View File

@@ -67,20 +67,19 @@ program
.command("run <message>")
.allowUnknownOption()
.passThroughOptions()
.description("Run opencode with todo/background task completion enforcement")
.description("Run opencode with todo/background task completion enforcement")
.option("-a, --agent <name>", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)")
.option("-d, --directory <path>", "Working directory")
.option("-t, --timeout <ms>", "Timeout in milliseconds (default: 30 minutes)", parseInt)
.option("-p, --port <port>", "Server port (attaches if port already in use)", parseInt)
.option("--attach <url>", "Attach to existing opencode server URL")
.option("--on-complete <command>", "Shell command to run after completion")
.option("--json", "Output structured JSON result to stdout")
.option("--verbose", "Show full event stream (default: messages/tools only)")
.option("--session-id <id>", "Resume existing session instead of creating new one")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode run "Fix the bug in index.ts"
$ bunx oh-my-opencode run --agent Sisyphus "Implement feature X"
$ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task"
$ bunx oh-my-opencode run --port 4321 "Fix the bug"
$ bunx oh-my-opencode run --attach http://127.0.0.1:4321 "Fix the bug"
$ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId
@@ -109,11 +108,11 @@ Unlike 'opencode run', this command waits until:
message,
agent: options.agent,
directory: options.directory,
timeout: options.timeout,
port: options.port,
attach: options.attach,
onComplete: options.onComplete,
json: options.json ?? false,
verbose: options.verbose ?? false,
sessionId: options.sessionId,
}
const exitCode = await run(runOptions)

56
src/cli/run/AGENTS.md Normal file
View File

@@ -0,0 +1,56 @@
# src/cli/run/ — Non-Interactive Session Launcher
**Generated:** 2026-02-18
## OVERVIEW
37 files. Powers the `oh-my-opencode run <message>` command. Connects to OpenCode server, creates/resumes sessions, streams events, and polls for completion.
## EXECUTION FLOW
```
runner.ts
1. opencode-binary-resolver.ts → Find OpenCode binary
2. server-connection.ts → Connect to OpenCode server (start if needed)
3. agent-resolver.ts → Flag → env → config → Sisyphus
4. session-resolver.ts → Create new or resume existing session
5. events.ts → Stream SSE events from session
6. event-handlers.ts → Process each event type
7. poll-for-completion.ts → Wait for todos + background tasks done
8. on-complete-hook.ts → Execute user-defined completion hook
```
## KEY FILES
| File | Purpose |
|------|---------|
| `runner.ts` | Main orchestration — connects, resolves, runs, completes |
| `server-connection.ts` | Start OpenCode server process, create SDK client |
| `agent-resolver.ts` | Resolve agent: `--agent` flag → `OPENCODE_AGENT` env → config → Sisyphus |
| `session-resolver.ts` | Create new session or resume via `--attach` / `--session-id` |
| `events.ts` | SSE event stream subscription |
| `event-handlers.ts` | Route events to handlers (message, tool, error, idle) |
| `event-stream-processor.ts` | Process event stream with filtering and buffering |
| `poll-for-completion.ts` | Poll session until todos complete + no background tasks |
| `completion.ts` | Determine if session is truly done |
| `continuation-state.ts` | Persist state for `run` continuation across invocations |
| `output-renderer.ts` | Format session output for terminal |
| `json-output.ts` | JSON output mode (`--json` flag) |
| `types.ts` | `RunOptions`, `RunResult`, `RunContext`, event payload types |
## AGENT RESOLUTION PRIORITY
```
1. --agent CLI flag
2. OPENCODE_AGENT environment variable
3. default_run_agent config
4. "sisyphus" (default)
```
## COMPLETION DETECTION
Poll-based with two conditions:
1. All todos marked completed (no pending/in_progress)
2. No running background tasks
`on-complete-hook.ts` executes optional user command on completion (e.g., `--on-complete "notify-send done"`).

View File

@@ -0,0 +1,28 @@
import type { OpencodeClient } from "@opencode-ai/sdk"
import { normalizeSDKResponse } from "../../shared"
interface AgentProfile {
name?: string
color?: string
}
export async function loadAgentProfileColors(
client: OpencodeClient,
): Promise<Record<string, string>> {
try {
const agentsRes = await client.app.agents()
const agents = normalizeSDKResponse(agentsRes, [] as AgentProfile[], {
preferResponseOnMissingData: true,
})
const colors: Record<string, string> = {}
for (const agent of agents) {
if (!agent.name || !agent.color) continue
colors[agent.name] = agent.color
}
return colors
} catch {
return {}
}
}

View File

@@ -0,0 +1,138 @@
import { describe, it, expect, mock, spyOn, afterEach } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import type { RunContext } from "./types"
import { writeState as writeRalphLoopState } from "../../hooks/ralph-loop/storage"
const testDirs: string[] = []
afterEach(() => {
while (testDirs.length > 0) {
const dir = testDirs.pop()
if (dir) {
rmSync(dir, { recursive: true, force: true })
}
}
})
function createTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), "omo-run-continuation-"))
testDirs.push(dir)
return dir
}
function createMockContext(directory: string): RunContext {
return {
client: {
session: {
todo: mock(() => Promise.resolve({ data: [] })),
children: mock(() => Promise.resolve({ data: [] })),
status: mock(() => Promise.resolve({ data: {} })),
},
} as unknown as RunContext["client"],
sessionID: "test-session",
directory,
abortController: new AbortController(),
}
}
function writeBoulderStateFile(directory: string, activePlanPath: string, sessionIDs: string[]): void {
const sisyphusDir = join(directory, ".sisyphus")
mkdirSync(sisyphusDir, { recursive: true })
writeFileSync(
join(sisyphusDir, "boulder.json"),
JSON.stringify({
active_plan: activePlanPath,
started_at: new Date().toISOString(),
session_ids: sessionIDs,
plan_name: "test-plan",
agent: "atlas",
}),
"utf-8",
)
}
describe("checkCompletionConditions continuation coverage", () => {
it("returns false when active boulder continuation exists for this session", async () => {
// given
spyOn(console, "log").mockImplementation(() => {})
const directory = createTempDir()
const planPath = join(directory, ".sisyphus", "plans", "active-plan.md")
mkdirSync(join(directory, ".sisyphus", "plans"), { recursive: true })
writeFileSync(planPath, "- [ ] incomplete task\n", "utf-8")
writeBoulderStateFile(directory, planPath, ["test-session"])
const ctx = createMockContext(directory)
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(false)
})
it("returns true when boulder exists but is complete", async () => {
// given
spyOn(console, "log").mockImplementation(() => {})
const directory = createTempDir()
const planPath = join(directory, ".sisyphus", "plans", "done-plan.md")
mkdirSync(join(directory, ".sisyphus", "plans"), { recursive: true })
writeFileSync(planPath, "- [x] completed task\n", "utf-8")
writeBoulderStateFile(directory, planPath, ["test-session"])
const ctx = createMockContext(directory)
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(true)
})
it("returns false when active ralph-loop continuation exists for this session", async () => {
// given
spyOn(console, "log").mockImplementation(() => {})
const directory = createTempDir()
writeRalphLoopState(directory, {
active: true,
iteration: 2,
max_iterations: 10,
completion_promise: "DONE",
started_at: new Date().toISOString(),
prompt: "keep going",
session_id: "test-session",
})
const ctx = createMockContext(directory)
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(false)
})
it("returns true when active ralph-loop is bound to another session", async () => {
// given
spyOn(console, "log").mockImplementation(() => {})
const directory = createTempDir()
writeRalphLoopState(directory, {
active: true,
iteration: 2,
max_iterations: 10,
completion_promise: "DONE",
started_at: new Date().toISOString(),
prompt: "keep going",
session_id: "other-session",
})
const ctx = createMockContext(directory)
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(true)
})
})

View File

@@ -143,6 +143,47 @@ describe("checkCompletionConditions", () => {
expect(result).toBe(false)
})
it("returns true when child status is missing but descendants are idle", async () => {
// given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [],
},
statuses: {},
})
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(true)
})
it("returns false when descendant is busy even if parent status is missing", async () => {
// given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [{ id: "grandchild-1" }],
"grandchild-1": [],
},
statuses: {
"grandchild-1": { type: "busy" },
},
})
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(false)
})
it("returns true when all descendants idle (recursive)", async () => {
// given
spyOn(console, "log").mockImplementation(() => {})

View File

@@ -1,10 +1,22 @@
import pc from "picocolors"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
import { normalizeSDKResponse } from "../../shared"
import {
getContinuationState,
type ContinuationState,
} from "./continuation-state"
export async function checkCompletionConditions(ctx: RunContext): Promise<boolean> {
try {
if (!await areAllTodosComplete(ctx)) {
const continuationState = getContinuationState(ctx.directory, ctx.sessionID)
if (continuationState.hasActiveHookMarker) {
const reason = continuationState.activeHookMarkerReason ?? "continuation hook is active"
console.log(pc.dim(` Waiting: ${reason}`))
return false
}
if (!continuationState.hasTodoHookMarker && !await areAllTodosComplete(ctx)) {
return false
}
@@ -12,6 +24,10 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
return false
}
if (!areContinuationHooksIdle(continuationState)) {
return false
}
return true
} catch (err) {
console.error(pc.red(`[completion] API error: ${err}`))
@@ -19,6 +35,20 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
}
}
function areContinuationHooksIdle(continuationState: ContinuationState): boolean {
if (continuationState.hasActiveBoulder) {
console.log(pc.dim(" Waiting: boulder continuation is active"))
return false
}
if (continuationState.hasActiveRalphLoop) {
console.log(pc.dim(" Waiting: ralph-loop continuation is active"))
return false
}
return true
}
async function areAllTodosComplete(ctx: RunContext): Promise<boolean> {
const todosRes = await ctx.client.session.todo({
path: { id: ctx.sessionID },

View File

@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { setContinuationMarkerSource } from "../../features/run-continuation-state"
import { getContinuationState } from "./continuation-state"
const tempDirs: string[] = []
function createTempDir(): string {
const directory = mkdtempSync(join(tmpdir(), "omo-run-cont-state-"))
tempDirs.push(directory)
return directory
}
afterEach(() => {
while (tempDirs.length > 0) {
const directory = tempDirs.pop()
if (directory) {
rmSync(directory, { recursive: true, force: true })
}
}
})
describe("getContinuationState marker integration", () => {
it("reports active marker state from continuation hooks", () => {
// given
const directory = createTempDir()
const sessionID = "ses_marker_active"
setContinuationMarkerSource(directory, sessionID, "todo", "active", "todos remaining")
// when
const state = getContinuationState(directory, sessionID)
// then
expect(state.hasActiveHookMarker).toBe(true)
expect(state.activeHookMarkerReason).toContain("todos")
})
it("does not report active marker when all sources are idle/stopped", () => {
// given
const directory = createTempDir()
const sessionID = "ses_marker_idle"
setContinuationMarkerSource(directory, sessionID, "todo", "idle")
setContinuationMarkerSource(directory, sessionID, "stop", "stopped")
// when
const state = getContinuationState(directory, sessionID)
// then
expect(state.hasActiveHookMarker).toBe(false)
expect(state.activeHookMarkerReason).toBeNull()
})
})

View File

@@ -0,0 +1,49 @@
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
import {
getActiveContinuationMarkerReason,
isContinuationMarkerActive,
readContinuationMarker,
} from "../../features/run-continuation-state"
import { readState as readRalphLoopState } from "../../hooks/ralph-loop/storage"
export interface ContinuationState {
hasActiveBoulder: boolean
hasActiveRalphLoop: boolean
hasHookMarker: boolean
hasTodoHookMarker: boolean
hasActiveHookMarker: boolean
activeHookMarkerReason: string | null
}
export function getContinuationState(directory: string, sessionID: string): ContinuationState {
const marker = readContinuationMarker(directory, sessionID)
return {
hasActiveBoulder: hasActiveBoulderContinuation(directory, sessionID),
hasActiveRalphLoop: hasActiveRalphLoopContinuation(directory, sessionID),
hasHookMarker: marker !== null,
hasTodoHookMarker: marker?.sources.todo !== undefined,
hasActiveHookMarker: isContinuationMarkerActive(marker),
activeHookMarkerReason: getActiveContinuationMarkerReason(marker),
}
}
function hasActiveBoulderContinuation(directory: string, sessionID: string): boolean {
const boulder = readBoulderState(directory)
if (!boulder) return false
if (!boulder.session_ids.includes(sessionID)) return false
const progress = getPlanProgress(boulder.active_plan)
return !progress.isComplete
}
function hasActiveRalphLoopContinuation(directory: string, sessionID: string): boolean {
const state = readRalphLoopState(directory)
if (!state || !state.active) return false
if (state.session_id && state.session_id !== sessionID) {
return false
}
return true
}

View File

@@ -0,0 +1,7 @@
const isCI = Boolean(process.env.CI || process.env.GITHUB_ACTIONS)
export const displayChars = {
treeEnd: isCI ? "`-" : "└─",
treeIndent: " ",
treeJoin: isCI ? " " : " ",
} as const

View File

@@ -4,6 +4,7 @@ import type {
EventPayload,
MessageUpdatedProps,
MessagePartUpdatedProps,
MessagePartDeltaProps,
ToolExecuteProps,
ToolResultProps,
SessionErrorProps,
@@ -93,6 +94,15 @@ export function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
break
}
case "message.part.delta": {
const deltaProps = props as MessagePartDeltaProps | undefined
const field = deltaProps?.field ?? "unknown"
const delta = deltaProps?.delta ?? ""
const preview = delta.slice(0, 80).replace(/\n/g, "\\n")
console.error(pc.dim(`${sessionTag} message.part.delta (${field}): "${preview}${delta.length > 80 ? "..." : ""}"`))
break
}
case "message.updated": {
const msgProps = props as MessageUpdatedProps | undefined
const role = msgProps?.info?.role ?? "unknown"

View File

@@ -7,12 +7,21 @@ import type {
SessionErrorProps,
MessageUpdatedProps,
MessagePartUpdatedProps,
MessagePartDeltaProps,
ToolExecuteProps,
ToolResultProps,
TuiToastShowProps,
} from "./types"
import type { EventState } from "./event-state"
import { serializeError } from "./event-formatting"
import { formatToolHeader } from "./tool-input-preview"
import { displayChars } from "./display-chars"
import {
closeThinkBlock,
openThinkBlock,
renderAgentHeader,
writePaddedText,
} from "./output-renderer"
function getSessionId(props?: { sessionID?: string; sessionId?: string }): string | undefined {
return props?.sessionID ?? props?.sessionId
@@ -30,6 +39,18 @@ function getPartSessionId(props?: {
return props?.part?.sessionID ?? props?.part?.sessionId
}
function getPartMessageId(props?: {
part?: { messageID?: string }
}): string | undefined {
return props?.part?.messageID
}
function getDeltaMessageId(props?: {
messageID?: string
}): string | undefined {
return props?.messageID
}
export function handleSessionIdle(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "session.idle") return
@@ -74,13 +95,41 @@ export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload,
const infoSid = getInfoSessionId(props)
if ((partSid ?? infoSid) !== ctx.sessionID) return
const role = props?.info?.role
const mappedRole = getPartMessageId(props)
? state.messageRoleById[getPartMessageId(props) ?? ""]
: undefined
if ((role ?? mappedRole) === "user") return
const part = props?.part
if (!part) return
if (part.id && part.type) {
state.partTypesById[part.id] = part.type
}
if (part.type === "reasoning") {
ensureThinkBlockOpen(state)
const reasoningText = part.text ?? ""
const newText = reasoningText.slice(state.lastReasoningText.length)
if (newText) {
const padded = writePaddedText(newText, state.thinkingAtLineStart)
process.stdout.write(pc.dim(padded.output))
state.thinkingAtLineStart = padded.atLineStart
state.hasReceivedMeaningfulWork = true
}
state.lastReasoningText = reasoningText
return
}
closeThinkBlockIfNeeded(state)
if (part.type === "text" && part.text) {
const newText = part.text.slice(state.lastPartText.length)
if (newText) {
process.stdout.write(newText)
const padded = writePaddedText(newText, state.textAtLineStart)
process.stdout.write(padded.output)
state.textAtLineStart = padded.atLineStart
state.hasReceivedMeaningfulWork = true
}
state.lastPartText = part.text
@@ -91,6 +140,44 @@ export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload,
}
}
export function handleMessagePartDelta(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "message.part.delta") return
const props = payload.properties as MessagePartDeltaProps | undefined
const sessionID = props?.sessionID ?? props?.sessionId
if (sessionID !== ctx.sessionID) return
const role = getDeltaMessageId(props)
? state.messageRoleById[getDeltaMessageId(props) ?? ""]
: undefined
if (role === "user") return
if (props?.field !== "text") return
const partType = props?.partID ? state.partTypesById[props.partID] : undefined
const delta = props.delta ?? ""
if (!delta) return
if (partType === "reasoning") {
ensureThinkBlockOpen(state)
const padded = writePaddedText(delta, state.thinkingAtLineStart)
process.stdout.write(pc.dim(padded.output))
state.thinkingAtLineStart = padded.atLineStart
state.lastReasoningText += delta
state.hasReceivedMeaningfulWork = true
return
}
closeThinkBlockIfNeeded(state)
const padded = writePaddedText(delta, state.textAtLineStart)
process.stdout.write(padded.output)
state.textAtLineStart = padded.atLineStart
state.lastPartText += delta
state.hasReceivedMeaningfulWork = true
}
function handleToolPart(
_ctx: RunContext,
part: NonNullable<MessagePartUpdatedProps["part"]>,
@@ -100,34 +187,26 @@ function handleToolPart(
const status = part.state?.status
if (status === "running") {
if (state.currentTool !== null) return
state.currentTool = toolName
let inputPreview = ""
const input = part.state?.input
if (input) {
if (input.command) {
inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}`
} else if (input.pattern) {
inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}`
} else if (input.filePath) {
inputPreview = ` ${pc.dim(String(input.filePath))}`
} else if (input.query) {
inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}`
}
}
const header = formatToolHeader(toolName, part.state?.input ?? {})
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
state.hasReceivedMeaningfulWork = true
process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`)
process.stdout.write(`\n ${pc.cyan(header.icon)} ${pc.bold(header.title)}${suffix} \n`)
}
if (status === "completed" || status === "error") {
if (state.currentTool === null) return
const output = part.state?.output || ""
const maxLen = 200
const preview = output.length > maxLen ? output.slice(0, maxLen) + "..." : output
if (preview.trim()) {
const lines = preview.split("\n").slice(0, 3)
process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`))
if (output.trim()) {
process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
const padded = writePaddedText(output, true)
process.stdout.write(pc.dim(padded.output + (padded.atLineStart ? "" : " ")))
process.stdout.write("\n")
}
state.currentTool = null
state.lastPartText = ""
state.textAtLineStart = true
}
}
@@ -136,11 +215,40 @@ export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, sta
const props = payload.properties as MessageUpdatedProps | undefined
if (getInfoSessionId(props) !== ctx.sessionID) return
state.currentMessageRole = props?.info?.role ?? null
const messageID = props?.info?.id ?? null
const role = props?.info?.role
if (messageID && role) {
state.messageRoleById[messageID] = role
}
if (props?.info?.role !== "assistant") return
state.hasReceivedMeaningfulWork = true
state.messageCount++
state.lastPartText = ""
const isNewMessage = !messageID || messageID !== state.currentMessageId
if (isNewMessage) {
state.currentMessageId = messageID
state.hasReceivedMeaningfulWork = true
state.messageCount++
state.lastPartText = ""
state.lastReasoningText = ""
state.hasPrintedThinkingLine = false
state.lastThinkingSummary = ""
state.textAtLineStart = true
state.thinkingAtLineStart = false
closeThinkBlockIfNeeded(state)
}
const agent = props?.info?.agent ?? null
const model = props?.info?.modelID ?? null
const variant = props?.info?.variant ?? null
if (agent !== state.currentAgent || model !== state.currentModel || variant !== state.currentVariant) {
state.currentAgent = agent
state.currentModel = model
state.currentVariant = variant
renderAgentHeader(agent, model, variant, state.agentColorsByName)
}
}
export function handleToolExecute(ctx: RunContext, payload: EventPayload, state: EventState): void {
@@ -149,25 +257,17 @@ export function handleToolExecute(ctx: RunContext, payload: EventPayload, state:
const props = payload.properties as ToolExecuteProps | undefined
if (getSessionId(props) !== ctx.sessionID) return
closeThinkBlockIfNeeded(state)
if (state.currentTool !== null) return
const toolName = props?.name || "unknown"
state.currentTool = toolName
let inputPreview = ""
if (props?.input) {
const input = props.input
if (input.command) {
inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}`
} else if (input.pattern) {
inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}`
} else if (input.filePath) {
inputPreview = ` ${pc.dim(String(input.filePath))}`
} else if (input.query) {
inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}`
}
}
const header = formatToolHeader(toolName, props?.input ?? {})
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
state.hasReceivedMeaningfulWork = true
process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`)
process.stdout.write(`\n ${pc.cyan(header.icon)} ${pc.bold(header.title)}${suffix} \n`)
}
export function handleToolResult(ctx: RunContext, payload: EventPayload, state: EventState): void {
@@ -176,36 +276,52 @@ export function handleToolResult(ctx: RunContext, payload: EventPayload, state:
const props = payload.properties as ToolResultProps | undefined
if (getSessionId(props) !== ctx.sessionID) return
const output = props?.output || ""
const maxLen = 200
const preview = output.length > maxLen ? output.slice(0, maxLen) + "..." : output
closeThinkBlockIfNeeded(state)
if (preview.trim()) {
const lines = preview.split("\n").slice(0, 3)
process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`))
if (state.currentTool === null) return
const output = props?.output || ""
if (output.trim()) {
process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
const padded = writePaddedText(output, true)
process.stdout.write(pc.dim(padded.output + (padded.atLineStart ? "" : " ")))
process.stdout.write("\n")
}
state.currentTool = null
state.lastPartText = ""
state.textAtLineStart = true
}
export function handleTuiToast(_ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "tui.toast.show") return
const props = payload.properties as TuiToastShowProps | undefined
const title = props?.title ? `${props.title}: ` : ""
const message = props?.message?.trim()
const variant = props?.variant ?? "info"
if (!message) return
if (variant === "error") {
state.mainSessionError = true
state.lastError = `${title}${message}`
console.error(pc.red(`\n[tui.toast.error] ${state.lastError}`))
return
const title = props?.title ? `${props.title}: ` : ""
const message = props?.message?.trim()
if (message) {
state.mainSessionError = true
state.lastError = `${title}${message}`
}
}
const colorize = variant === "warning" ? pc.yellow : pc.dim
console.log(colorize(`[toast:${variant}] ${title}${message}`))
}
function ensureThinkBlockOpen(state: EventState): void {
if (state.inThinkBlock) return
openThinkBlock()
state.inThinkBlock = true
state.hasPrintedThinkingLine = false
state.thinkingAtLineStart = false
}
function closeThinkBlockIfNeeded(state: EventState): void {
if (!state.inThinkBlock) return
closeThinkBlock()
state.inThinkBlock = false
state.lastThinkingLineWidth = 0
state.lastThinkingSummary = ""
state.thinkingAtLineStart = false
}

View File

@@ -9,6 +9,36 @@ export interface EventState {
hasReceivedMeaningfulWork: boolean
/** Count of assistant messages for the main session */
messageCount: number
/** Current agent name from the latest assistant message */
currentAgent: string | null
/** Current model ID from the latest assistant message */
currentModel: string | null
/** Current model variant from the latest assistant message */
currentVariant: string | null
/** Current message role (user/assistant) — used to filter user messages from display */
currentMessageRole: string | null
/** Agent profile colors keyed by display name */
agentColorsByName: Record<string, string>
/** Part type registry keyed by partID (text, reasoning, tool, ...) */
partTypesById: Record<string, string>
/** Whether a THINK block is currently open in output */
inThinkBlock: boolean
/** Tracks streamed reasoning text to avoid duplicates */
lastReasoningText: string
/** Whether compact thinking line already printed for current reasoning block */
hasPrintedThinkingLine: boolean
/** Last rendered thinking line width (for in-place padding updates) */
lastThinkingLineWidth: number
/** Message role lookup by message ID to filter user parts */
messageRoleById: Record<string, string>
/** Last rendered thinking summary (to avoid duplicate re-render) */
lastThinkingSummary: string
/** Whether text stream is currently at line start (for padding) */
textAtLineStart: boolean
/** Whether reasoning stream is currently at line start (for padding) */
thinkingAtLineStart: boolean
/** Current assistant message ID — prevents counter resets on repeated message.updated for same message */
currentMessageId: string | null
}
export function createEventState(): EventState {
@@ -21,5 +51,20 @@ export function createEventState(): EventState {
currentTool: null,
hasReceivedMeaningfulWork: false,
messageCount: 0,
currentAgent: null,
currentModel: null,
currentVariant: null,
currentMessageRole: null,
agentColorsByName: {},
partTypesById: {},
inThinkBlock: false,
lastReasoningText: "",
hasPrintedThinkingLine: false,
lastThinkingLineWidth: 0,
messageRoleById: {},
lastThinkingSummary: "",
textAtLineStart: true,
thinkingAtLineStart: false,
currentMessageId: null,
}
}

View File

@@ -7,6 +7,7 @@ import {
handleSessionIdle,
handleSessionStatus,
handleMessagePartUpdated,
handleMessagePartDelta,
handleMessageUpdated,
handleToolExecute,
handleToolResult,
@@ -24,16 +25,21 @@ export async function processEvents(
try {
const payload = event as EventPayload
if (!payload?.type) {
console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`))
if (ctx.verbose) {
console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`))
}
continue
}
logEventVerbose(ctx, payload)
if (ctx.verbose) {
logEventVerbose(ctx, payload)
}
handleSessionError(ctx, payload, state)
handleSessionIdle(ctx, payload, state)
handleSessionStatus(ctx, payload, state)
handleMessagePartUpdated(ctx, payload, state)
handleMessagePartDelta(ctx, payload, state)
handleMessageUpdated(ctx, payload, state)
handleToolExecute(ctx, payload, state)
handleToolResult(ctx, payload, state)

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from "bun:test"
import { describe, it, expect, spyOn } from "bun:test"
import { createEventState, serializeError, type EventState } from "./events"
import type { RunContext, EventPayload } from "./types"
@@ -87,6 +87,52 @@ describe("createEventState", () => {
})
describe("event handling", () => {
it("does not log verbose event traces by default", async () => {
// given
const ctx = createMockContext("my-session")
const state = createEventState()
const errorSpy = spyOn(console, "error").mockImplementation(() => {})
const payload: EventPayload = {
type: "custom.event",
properties: { sessionID: "my-session" },
}
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// when
await processEvents(ctx, events, state)
// then
expect(errorSpy).not.toHaveBeenCalled()
errorSpy.mockRestore()
})
it("logs full event traces when verbose is enabled", async () => {
// given
const ctx = { ...createMockContext("my-session"), verbose: true }
const state = createEventState()
const errorSpy = spyOn(console, "error").mockImplementation(() => {})
const payload: EventPayload = {
type: "custom.event",
properties: { sessionID: "my-session" },
}
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// when
await processEvents(ctx, events, state)
// then
expect(errorSpy).toHaveBeenCalledTimes(1)
const firstCall = errorSpy.mock.calls[0]
expect(String(firstCall?.[0] ?? "")).toContain("custom.event")
errorSpy.mockRestore()
})
it("session.idle sets mainSessionIdle to true for matching session", async () => {
// given
const ctx = createMockContext("my-session")

View File

@@ -0,0 +1,657 @@
import { describe, expect, it, spyOn } from "bun:test"
import type { EventPayload, RunContext } from "./types"
import { createEventState } from "./events"
import { processEvents } from "./event-stream-processor"
function stripAnsi(str: string): string {
return str.replace(new RegExp("\x1b\\[[0-9;]*m", "g"), "")
}
const createMockContext = (sessionID: string = "test-session"): RunContext => ({
client: {} as RunContext["client"],
sessionID,
directory: "/test",
abortController: new AbortController(),
})
async function* toAsyncIterable<T>(items: T[]): AsyncIterable<T> {
for (const item of items) {
yield item
}
}
describe("message.part.delta handling", () => {
it("prints streaming text incrementally from delta events", async () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
field: "text",
delta: "Hello",
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
field: "text",
delta: " world",
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
expect(state.hasReceivedMeaningfulWork).toBe(true)
expect(state.lastPartText).toBe("Hello world")
expect(stdoutSpy).toHaveBeenCalledTimes(2)
stdoutSpy.mockRestore()
})
it("does not suppress assistant tool/text parts when state role is stale user", () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
state.currentMessageRole = "user"
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const payload: EventPayload = {
type: "message.part.updated",
properties: {
part: {
sessionID: "ses_main",
type: "tool",
tool: "task_create",
state: { status: "running" },
},
},
}
//#when
const { handleMessagePartUpdated } = require("./event-handlers") as {
handleMessagePartUpdated: (ctx: RunContext, payload: EventPayload, state: ReturnType<typeof createEventState>) => void
}
handleMessagePartUpdated(ctx, payload, state)
//#then
expect(state.currentTool).toBe("task_create")
expect(state.hasReceivedMeaningfulWork).toBe(true)
stdoutSpy.mockRestore()
})
it("renders agent header using profile hex color when available", () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
state.agentColorsByName["Sisyphus (Ultraworker)"] = "#00CED1"
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const payload: EventPayload = {
type: "message.updated",
properties: {
info: {
sessionID: "ses_main",
role: "assistant",
agent: "Sisyphus (Ultraworker)",
modelID: "claude-opus-4-6",
variant: "max",
},
},
}
//#when
const { handleMessageUpdated } = require("./event-handlers") as {
handleMessageUpdated: (ctx: RunContext, payload: EventPayload, state: ReturnType<typeof createEventState>) => void
}
handleMessageUpdated(ctx, payload, state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
expect(rendered).toContain("\u001b[38;2;0;206;209m")
expect(rendered).toContain("claude-opus-4-6 (max)")
expect(rendered).toContain("└─")
expect(rendered).toContain("Sisyphus (Ultraworker)")
stdoutSpy.mockRestore()
})
it("separates think block output from normal response output", async () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.updated",
properties: {
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.updated",
properties: {
part: { id: "think-1", sessionID: "ses_main", type: "reasoning", text: "" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
partID: "think-1",
field: "text",
delta: "Composing final summary in Korean with clear concise structure",
},
},
{
type: "message.part.updated",
properties: {
part: { id: "text-1", sessionID: "ses_main", type: "text", text: "" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
partID: "text-1",
field: "text",
delta: "answer",
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
const plain = stripAnsi(rendered)
expect(plain).toContain("Thinking:")
expect(plain).toContain("Composing final summary in Korean")
expect(plain).toContain("answer")
stdoutSpy.mockRestore()
})
it("updates thinking line incrementally on delta updates", async () => {
//#given
const previous = process.env.GITHUB_ACTIONS
delete process.env.GITHUB_ACTIONS
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.updated",
properties: {
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.updated",
properties: {
part: { id: "think-1", sessionID: "ses_main", type: "reasoning", text: "" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
partID: "think-1",
field: "text",
delta: "Composing final summary",
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
partID: "think-1",
field: "text",
delta: " in Korean with specifics.",
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
const plain = stripAnsi(rendered)
expect(plain).toContain("Thinking:")
expect(plain).toContain("Composing final summary")
expect(plain).toContain("in Korean with specifics.")
if (previous !== undefined) process.env.GITHUB_ACTIONS = previous
stdoutSpy.mockRestore()
})
it("does not re-render identical thinking summary repeatedly", async () => {
//#given
const previous = process.env.GITHUB_ACTIONS
delete process.env.GITHUB_ACTIONS
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.updated",
properties: {
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.updated",
properties: {
part: { id: "think-1", messageID: "msg_assistant", sessionID: "ses_main", type: "reasoning", text: "" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_assistant",
partID: "think-1",
field: "text",
delta: "The user wants me",
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_assistant",
partID: "think-1",
field: "text",
delta: " to",
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_assistant",
partID: "think-1",
field: "text",
delta: " ",
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
const plain = stripAnsi(rendered)
const renderCount = plain.split("Thinking:").length - 1
expect(renderCount).toBe(1)
if (previous !== undefined) process.env.GITHUB_ACTIONS = previous
stdoutSpy.mockRestore()
})
it("does not truncate thinking content", async () => {
//#given
const previous = process.env.GITHUB_ACTIONS
delete process.env.GITHUB_ACTIONS
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const longThinking = "This is a very long thinking stream that should never be truncated and must include final tail marker END-OF-THINKING-MARKER"
const events: EventPayload[] = [
{
type: "message.updated",
properties: {
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.updated",
properties: {
part: { id: "think-1", messageID: "msg_assistant", sessionID: "ses_main", type: "reasoning", text: "" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_assistant",
partID: "think-1",
field: "text",
delta: longThinking,
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
expect(rendered).toContain("END-OF-THINKING-MARKER")
if (previous !== undefined) process.env.GITHUB_ACTIONS = previous
stdoutSpy.mockRestore()
})
it("applies left and right padding to assistant text output", async () => {
//#given
const previous = process.env.GITHUB_ACTIONS
delete process.env.GITHUB_ACTIONS
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.updated",
properties: {
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6", variant: "max" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_assistant",
partID: "part_assistant_text",
field: "text",
delta: "hello\nworld",
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
expect(rendered).toContain(" hello \n world")
if (previous !== undefined) process.env.GITHUB_ACTIONS = previous
stdoutSpy.mockRestore()
})
it("does not render user message parts in output stream", async () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.updated",
properties: {
info: { id: "msg_user", sessionID: "ses_main", role: "user", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.updated",
properties: {
part: { id: "part_user_text", messageID: "msg_user", sessionID: "ses_main", type: "text", text: "[search-mode] should not print" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_user",
partID: "part_user_text",
field: "text",
delta: "still should not print",
},
},
{
type: "message.updated",
properties: {
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus (Ultraworker)", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_assistant",
partID: "part_assistant_text",
field: "text",
delta: "assistant output",
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
expect(rendered.includes("[search-mode] should not print")).toBe(false)
expect(rendered.includes("still should not print")).toBe(false)
expect(rendered).toContain("assistant output")
stdoutSpy.mockRestore()
})
it("renders tool header and full tool output without truncation", async () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const longTail = "END-OF-TOOL-OUTPUT-MARKER"
const events: EventPayload[] = [
{
type: "tool.execute",
properties: {
sessionID: "ses_main",
name: "read",
input: { filePath: "src/index.ts", offset: 1, limit: 200 },
},
},
{
type: "tool.result",
properties: {
sessionID: "ses_main",
name: "read",
output: `line1\nline2\n${longTail}`,
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
expect(rendered).toContain("→")
expect(rendered).toContain("Read src/index.ts")
expect(rendered).toContain("END-OF-TOOL-OUTPUT-MARKER")
stdoutSpy.mockRestore()
})
it("renders tool header only once when message.part.updated fires multiple times for same running tool", async () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
sessionID: "ses_main",
type: "tool",
tool: "bash",
state: { status: "running", input: { command: "bun test" } },
},
},
},
{
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
sessionID: "ses_main",
type: "tool",
tool: "bash",
state: { status: "running", input: { command: "bun test" } },
},
},
},
{
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
sessionID: "ses_main",
type: "tool",
tool: "bash",
state: { status: "running", input: { command: "bun test" } },
},
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
const headerCount = rendered.split("bun test").length - 1
expect(headerCount).toBe(1)
stdoutSpy.mockRestore()
})
it("renders tool header only once when both tool.execute and message.part.updated fire", async () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "tool.execute",
properties: {
sessionID: "ses_main",
name: "bash",
input: { command: "bun test" },
},
},
{
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
sessionID: "ses_main",
type: "tool",
tool: "bash",
state: { status: "running", input: { command: "bun test" } },
},
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
const headerCount = rendered.split("bun test").length - 1
expect(headerCount).toBe(1)
stdoutSpy.mockRestore()
})
it("renders tool output only once when both tool.result and message.part.updated(completed) fire", async () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "tool.execute",
properties: {
sessionID: "ses_main",
name: "bash",
input: { command: "bun test" },
},
},
{
type: "tool.result",
properties: {
sessionID: "ses_main",
name: "bash",
output: "UNIQUE-OUTPUT-MARKER",
},
},
{
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
sessionID: "ses_main",
type: "tool",
tool: "bash",
state: { status: "completed", input: { command: "bun test" }, output: "UNIQUE-OUTPUT-MARKER" },
},
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
const outputCount = rendered.split("UNIQUE-OUTPUT-MARKER").length - 1
expect(outputCount).toBe(1)
stdoutSpy.mockRestore()
})
it("does not re-render text when message.updated fires multiple times for same message", async () => {
//#given
const ctx = createMockContext("ses_main")
const state = createEventState()
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true)
const events: EventPayload[] = [
{
type: "message.updated",
properties: {
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_main",
messageID: "msg_1",
field: "text",
delta: "Hello world",
},
},
{
type: "message.updated",
properties: {
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-6" },
},
},
{
type: "message.part.updated",
properties: {
part: { id: "text-1", sessionID: "ses_main", type: "text", text: "Hello world" },
},
},
]
//#when
await processEvents(ctx, toAsyncIterable(events), state)
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
const textCount = rendered.split("Hello world").length - 1
expect(textCount).toBe(1)
stdoutSpy.mockRestore()
})
})

View File

@@ -0,0 +1,90 @@
import pc from "picocolors"
export function renderAgentHeader(
agent: string | null,
model: string | null,
variant: string | null,
agentColorsByName: Record<string, string>,
): void {
if (!agent && !model) return
const agentLabel = agent
? pc.bold(colorizeWithProfileColor(agent, agentColorsByName[agent]))
: ""
const modelBase = model ?? ""
const variantSuffix = variant ? ` (${variant})` : ""
const modelLabel = model ? pc.dim(`${modelBase}${variantSuffix}`) : ""
process.stdout.write("\n")
if (modelLabel) {
process.stdout.write(` ${modelLabel} \n`)
}
if (agentLabel) {
process.stdout.write(` ${pc.dim("└─")} ${agentLabel} \n`)
}
process.stdout.write("\n")
}
export function openThinkBlock(): void {
process.stdout.write(`\n ${pc.dim("┃ Thinking:")} `)
}
export function closeThinkBlock(): void {
process.stdout.write(" \n\n")
}
export function writePaddedText(
text: string,
atLineStart: boolean,
): { output: string; atLineStart: boolean } {
const isGitHubActions = process.env.GITHUB_ACTIONS === "true"
if (isGitHubActions) {
return { output: text, atLineStart: text.endsWith("\n") }
}
let output = ""
let lineStart = atLineStart
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (lineStart) {
output += " "
lineStart = false
}
if (ch === "\n") {
output += " \n"
lineStart = true
continue
}
output += ch
}
return { output, atLineStart: lineStart }
}
function colorizeWithProfileColor(text: string, hexColor?: string): string {
if (!hexColor) return pc.magenta(text)
const rgb = parseHexColor(hexColor)
if (!rgb) return pc.magenta(text)
const [r, g, b] = rgb
return `\u001b[38;2;${r};${g};${b}m${text}\u001b[39m`
}
function parseHexColor(hexColor: string): [number, number, number] | null {
const cleaned = hexColor.trim()
const match = cleaned.match(/^#?([A-Fa-f0-9]{6})$/)
if (!match) return null
const hex = match[1]
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
return [r, g, b]
}

View File

@@ -94,6 +94,7 @@ describe("pollForCompletion", () => {
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
minStabilizationMs: 500,
})
//#then - should be aborted, not completed (tool blocked exit)
@@ -159,6 +160,7 @@ describe("pollForCompletion", () => {
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
minStabilizationMs: 500,
})
//#then
@@ -310,7 +312,7 @@ describe("pollForCompletion", () => {
//#then - returns 1 (not 130/timeout), error message printed
expect(result).toBe(1)
const errorCalls = (console.error as ReturnType<typeof mock>).mock.calls
expect(errorCalls.some((call) => call[0]?.includes("Session ended with error"))).toBe(true)
expect(errorCalls.some((call: unknown[]) => String(call[0] ?? "").includes("Session ended with error"))).toBe(true)
})
it("returns 1 when session errors while tool is active (error not masked by tool gate)", async () => {
@@ -335,4 +337,5 @@ describe("pollForCompletion", () => {
//#then - returns 1
expect(result).toBe(1)
})
})

View File

@@ -5,9 +5,9 @@ import { checkCompletionConditions } from "./completion"
import { normalizeSDKResponse } from "../../shared"
const DEFAULT_POLL_INTERVAL_MS = 500
const DEFAULT_REQUIRED_CONSECUTIVE = 3
const DEFAULT_REQUIRED_CONSECUTIVE = 1
const ERROR_GRACE_CYCLES = 3
const MIN_STABILIZATION_MS = 10_000
const MIN_STABILIZATION_MS = 0
export interface PollOptions {
pollIntervalMs?: number
@@ -34,6 +34,10 @@ export async function pollForCompletion(
while (!abortController.signal.aborted) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
if (abortController.signal.aborted) {
return 130
}
// ERROR CHECK FIRST — errors must not be masked by other gates
if (eventState.mainSessionError) {
errorCycleCount++
@@ -71,6 +75,11 @@ export async function pollForCompletion(
}
if (!eventState.hasReceivedMeaningfulWork) {
if (minStabilizationMs <= 0) {
consecutiveCompleteChecks = 0
continue
}
if (Date.now() - pollStartTimestamp < minStabilizationMs) {
consecutiveCompleteChecks = 0
continue
@@ -91,6 +100,10 @@ export async function pollForCompletion(
const shouldExit = await checkCompletionConditions(ctx)
if (shouldExit) {
if (abortController.signal.aborted) {
return 130
}
consecutiveCompleteChecks++
if (consecutiveCompleteChecks >= requiredConsecutive) {
console.log(pc.green("\n\nAll tasks completed."))

View File

@@ -1,6 +1,6 @@
/// <reference types="bun-types" />
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import { describe, it, expect } from "bun:test"
import type { OhMyOpenCodeConfig } from "../../config"
import { resolveRunAgent, waitForEventProcessorShutdown } from "./runner"
@@ -83,14 +83,6 @@ describe("resolveRunAgent", () => {
})
describe("waitForEventProcessorShutdown", () => {
let consoleLogSpy: ReturnType<typeof spyOn<typeof console, "log">> | null = null
afterEach(() => {
if (consoleLogSpy) {
consoleLogSpy.mockRestore()
consoleLogSpy = null
}
})
it("returns quickly when event processor completes", async () => {
//#given
@@ -99,7 +91,6 @@ describe("waitForEventProcessorShutdown", () => {
resolve()
}, 25)
})
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {})
const start = performance.now()
//#when
@@ -108,29 +99,19 @@ describe("waitForEventProcessorShutdown", () => {
//#then
const elapsed = performance.now() - start
expect(elapsed).toBeLessThan(200)
expect(console.log).not.toHaveBeenCalledWith(
"[run] Event stream did not close within 200ms after abort; continuing shutdown.",
)
})
it("times out and continues when event processor does not complete", async () => {
//#given
const eventProcessor = new Promise<void>(() => {})
const spy = spyOn(console, "log").mockImplementation(() => {})
consoleLogSpy = spy
const timeoutMs = 200
const start = performance.now()
try {
//#when
await waitForEventProcessorShutdown(eventProcessor, timeoutMs)
//#when
await waitForEventProcessorShutdown(eventProcessor, timeoutMs)
//#then
const elapsed = performance.now() - start
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10)
expect(spy.mock.calls.length).toBeGreaterThanOrEqual(1)
} finally {
spy.mockRestore()
}
//#then
const elapsed = performance.now() - start
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10)
})
})

View File

@@ -8,10 +8,10 @@ import { createJsonOutputManager } from "./json-output"
import { executeOnCompleteHook } from "./on-complete-hook"
import { resolveRunAgent } from "./agent-resolver"
import { pollForCompletion } from "./poll-for-completion"
import { loadAgentProfileColors } from "./agent-profile-colors"
export { resolveRunAgent }
const DEFAULT_TIMEOUT_MS = 600_000
const EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS = 2_000
export async function waitForEventProcessorShutdown(
@@ -23,13 +23,7 @@ export async function waitForEventProcessorShutdown(
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
])
if (!completed) {
console.log(
pc.dim(
`[run] Event stream did not close within ${timeoutMs}ms after abort; continuing shutdown.`,
),
)
}
void completed
}
export async function run(options: RunOptions): Promise<number> {
@@ -39,7 +33,6 @@ export async function run(options: RunOptions): Promise<number> {
const {
message,
directory = process.cwd(),
timeout = DEFAULT_TIMEOUT_MS,
} = options
const jsonManager = options.json ? createJsonOutputManager() : null
@@ -48,14 +41,6 @@ export async function run(options: RunOptions): Promise<number> {
const pluginConfig = loadPluginConfig(directory, { command: "run" })
const resolvedAgent = resolveRunAgent(options, pluginConfig)
const abortController = new AbortController()
let timeoutId: ReturnType<typeof setTimeout> | null = null
if (timeout > 0) {
timeoutId = setTimeout(() => {
console.log(pc.yellow("\nTimeout reached. Aborting..."))
abortController.abort()
}, timeout)
}
try {
const { client, cleanup: serverCleanup } = await createServerConnection({
@@ -65,7 +50,6 @@ export async function run(options: RunOptions): Promise<number> {
})
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId)
serverCleanup()
}
@@ -84,14 +68,20 @@ export async function run(options: RunOptions): Promise<number> {
console.log(pc.dim(`Session: ${sessionID}`))
const ctx: RunContext = { client, sessionID, directory, abortController }
const ctx: RunContext = {
client,
sessionID,
directory,
abortController,
verbose: options.verbose ?? false,
}
const events = await client.event.subscribe({ query: { directory } })
const eventState = createEventState()
eventState.agentColorsByName = await loadAgentProfileColors(client)
const eventProcessor = processEvents(ctx, events.stream, eventState).catch(
() => {},
)
console.log(pc.dim("\nSending prompt..."))
await client.session.promptAsync({
path: { id: sessionID },
body: {
@@ -100,8 +90,6 @@ export async function run(options: RunOptions): Promise<number> {
},
query: { directory },
})
console.log(pc.dim("Waiting for completion...\n"))
const exitCode = await pollForCompletion(ctx, eventState, abortController)
// Abort the event stream to stop the processor
@@ -138,7 +126,6 @@ export async function run(options: RunOptions): Promise<number> {
throw err
}
} catch (err) {
if (timeoutId) clearTimeout(timeoutId)
if (jsonManager) jsonManager.restore()
if (err instanceof Error && err.name === "AbortError") {
return 130

View File

@@ -95,6 +95,24 @@ describe("createServerConnection", () => {
expect(mockServerClose).toHaveBeenCalled()
})
it("explicit port attaches when start fails because port became occupied", async () => {
// given
const signal = new AbortController().signal
const port = 8080
mockIsPortAvailable.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
mockCreateOpencode.mockRejectedValueOnce(new Error("Failed to start server on port 8080"))
// when
const result = await createServerConnection({ port, signal })
// then
expect(mockIsPortAvailable).toHaveBeenNthCalledWith(1, 8080, "127.0.0.1")
expect(mockIsPortAvailable).toHaveBeenNthCalledWith(2, 8080, "127.0.0.1")
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://127.0.0.1:8080" })
result.cleanup()
expect(mockServerClose).not.toHaveBeenCalled()
})
it("explicit port attaches when port is occupied", async () => {
// given
const signal = new AbortController().signal
@@ -133,6 +151,32 @@ describe("createServerConnection", () => {
expect(mockServerClose).toHaveBeenCalled()
})
it("auto mode retries on next port when initial start fails", async () => {
// given
const signal = new AbortController().signal
mockGetAvailableServerPort
.mockResolvedValueOnce({ port: 4096, wasAutoSelected: false })
.mockResolvedValueOnce({ port: 4097, wasAutoSelected: true })
mockCreateOpencode
.mockRejectedValueOnce(new Error("Failed to start server on port 4096"))
.mockResolvedValueOnce({
client: { session: {} },
server: { url: "http://127.0.0.1:4097", close: mockServerClose },
})
// when
const result = await createServerConnection({ signal })
// then
expect(mockGetAvailableServerPort).toHaveBeenNthCalledWith(1, 4096, "127.0.0.1")
expect(mockGetAvailableServerPort).toHaveBeenNthCalledWith(2, 4097, "127.0.0.1")
expect(mockCreateOpencode).toHaveBeenNthCalledWith(1, { signal, port: 4096, hostname: "127.0.0.1" })
expect(mockCreateOpencode).toHaveBeenNthCalledWith(2, { signal, port: 4097, hostname: "127.0.0.1" })
result.cleanup()
expect(mockServerClose).toHaveBeenCalledTimes(1)
})
it("invalid port throws error", async () => {
// given
const signal = new AbortController().signal

View File

@@ -5,6 +5,24 @@ import { getAvailableServerPort, isPortAvailable, DEFAULT_SERVER_PORT } from "..
import { withWorkingOpencodePath } from "./opencode-binary-resolver"
import { prependResolvedOpencodeBinToPath } from "./opencode-bin-path"
function isPortStartFailure(error: unknown, port: number): boolean {
if (!(error instanceof Error)) {
return false
}
return error.message.includes(`Failed to start server on port ${port}`)
}
async function startServer(options: { signal: AbortSignal, port: number }): Promise<ServerConnection> {
const { signal, port } = options
const { client, server } = await withWorkingOpencodePath(() =>
createOpencode({ signal, port, hostname: "127.0.0.1" }),
)
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
return { client, cleanup: () => server.close() }
}
export async function createServerConnection(options: {
port?: number
attach?: string
@@ -29,11 +47,22 @@ export async function createServerConnection(options: {
if (available) {
console.log(pc.dim("Starting server on port"), pc.cyan(port.toString()))
const { client, server } = await withWorkingOpencodePath(() =>
createOpencode({ signal, port, hostname: "127.0.0.1" }),
)
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
return { client, cleanup: () => server.close() }
try {
return await startServer({ signal, port })
} catch (error) {
if (!isPortStartFailure(error, port)) {
throw error
}
const stillAvailable = await isPortAvailable(port, "127.0.0.1")
if (stillAvailable) {
throw error
}
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("became occupied, attaching to existing server"))
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
return { client, cleanup: () => {} }
}
}
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("is occupied, attaching to existing server"))
@@ -47,9 +76,16 @@ export async function createServerConnection(options: {
} else {
console.log(pc.dim("Starting server on port"), pc.cyan(selectedPort.toString()))
}
const { client, server } = await withWorkingOpencodePath(() =>
createOpencode({ signal, port: selectedPort, hostname: "127.0.0.1" }),
)
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
return { client, cleanup: () => server.close() }
try {
return await startServer({ signal, port: selectedPort })
} catch (error) {
if (!isPortStartFailure(error, selectedPort)) {
throw error
}
const { port: retryPort } = await getAvailableServerPort(selectedPort + 1, "127.0.0.1")
console.log(pc.dim("Retrying server start on port"), pc.cyan(retryPort.toString()))
return await startServer({ signal, port: retryPort })
}
}

View File

@@ -0,0 +1,144 @@
export interface ToolHeader {
icon: string
title: string
description?: string
}
export function formatToolHeader(toolName: string, input: Record<string, unknown>): ToolHeader {
if (toolName === "glob") {
const pattern = str(input.pattern)
const root = str(input.path)
return {
icon: "✱",
title: pattern ? `Glob "${pattern}"` : "Glob",
description: root ? `in ${root}` : undefined,
}
}
if (toolName === "grep") {
const pattern = str(input.pattern)
const root = str(input.path)
return {
icon: "✱",
title: pattern ? `Grep "${pattern}"` : "Grep",
description: root ? `in ${root}` : undefined,
}
}
if (toolName === "list") {
const path = str(input.path)
return {
icon: "→",
title: path ? `List ${path}` : "List",
}
}
if (toolName === "read") {
const filePath = str(input.filePath)
return {
icon: "→",
title: filePath ? `Read ${filePath}` : "Read",
description: formatKeyValues(input, ["filePath"]),
}
}
if (toolName === "write") {
const filePath = str(input.filePath)
return {
icon: "←",
title: filePath ? `Write ${filePath}` : "Write",
}
}
if (toolName === "edit") {
const filePath = str(input.filePath)
return {
icon: "←",
title: filePath ? `Edit ${filePath}` : "Edit",
description: formatKeyValues(input, ["filePath", "oldString", "newString"]),
}
}
if (toolName === "webfetch") {
const url = str(input.url)
return {
icon: "%",
title: url ? `WebFetch ${url}` : "WebFetch",
description: formatKeyValues(input, ["url"]),
}
}
if (toolName === "websearch_web_search_exa") {
const query = str(input.query)
return {
icon: "◈",
title: query ? `Web Search "${query}"` : "Web Search",
}
}
if (toolName === "grep_app_searchGitHub") {
const query = str(input.query)
return {
icon: "◇",
title: query ? `Code Search "${query}"` : "Code Search",
}
}
if (toolName === "task") {
const desc = str(input.description)
const subagent = str(input.subagent_type)
return {
icon: "#",
title: desc || (subagent ? `${subagent} Task` : "Task"),
description: subagent ? `agent=${subagent}` : undefined,
}
}
if (toolName === "bash") {
const command = str(input.command)
return {
icon: "$",
title: command || "bash",
description: formatKeyValues(input, ["command"]),
}
}
if (toolName === "skill") {
const name = str(input.name)
return {
icon: "→",
title: name ? `Skill "${name}"` : "Skill",
}
}
if (toolName === "todowrite") {
return {
icon: "#",
title: "Todos",
}
}
return {
icon: "⚙",
title: toolName,
description: formatKeyValues(input, []),
}
}
function formatKeyValues(input: Record<string, unknown>, exclude: string[]): string | undefined {
const entries = Object.entries(input).filter(([key, value]) => {
if (exclude.includes(key)) return false
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
})
if (!entries.length) return undefined
return entries
.map(([key, value]) => `${key}=${String(value)}`)
.join(" ")
}
function str(value: unknown): string | undefined {
if (typeof value !== "string") return undefined
const trimmed = value.trim()
return trimmed.length ? trimmed : undefined
}

View File

@@ -4,8 +4,8 @@ export type { OpencodeClient }
export interface RunOptions {
message: string
agent?: string
verbose?: boolean
directory?: string
timeout?: number
port?: number
attach?: string
onComplete?: string
@@ -31,6 +31,7 @@ export interface RunContext {
sessionID: string
directory: string
abortController: AbortController
verbose?: boolean
}
export interface Todo {
@@ -66,12 +67,14 @@ export interface SessionStatusProps {
export interface MessageUpdatedProps {
info?: {
id?: string
sessionID?: string
sessionId?: string
role?: string
modelID?: string
providerID?: string
agent?: string
variant?: string
}
}
@@ -95,6 +98,15 @@ export interface MessagePartUpdatedProps {
}
}
export interface MessagePartDeltaProps {
sessionID?: string
sessionId?: string
messageID?: string
partID?: string
field?: string
delta?: string
}
export interface ToolExecuteProps {
sessionID?: string
sessionId?: string

View File

@@ -33,9 +33,11 @@ export const HookNameSchema = z.enum([
"claude-code-hooks",
"auto-slash-command",
"edit-error-recovery",
"json-error-recovery",
"delegate-task-retry",
"prometheus-md-only",
"sisyphus-junior-notepad",
"sisyphus-gpt-hephaestus-reminder",
"start-work",
"atlas",
"unstable-agent-babysitter",

View File

@@ -1,6 +1,6 @@
# src/features/ — 18 Feature Modules
# src/features/ — 19 Feature Modules
**Generated:** 2026-02-17
**Generated:** 2026-02-18
## OVERVIEW
@@ -27,6 +27,7 @@ Standalone feature modules wired into plugin/ layer. Each is self-contained with
| **claude-code-agent-loader** | 3 | LOW | Load agents from .opencode/agents/ |
| **claude-code-command-loader** | 3 | LOW | Load commands from .opencode/commands/ |
| **claude-code-session-state** | 2 | LOW | Subagent session state tracking |
| **run-continuation-state** | 5 | LOW | Persistent state for `run` command continuation across sessions |
| **tool-metadata-store** | 2 | LOW | Tool execution metadata cache |
## KEY MODULES

View File

@@ -0,0 +1,56 @@
# src/features/background-agent/ — Core Orchestration Engine
**Generated:** 2026-02-18
## OVERVIEW
39 files (~10k LOC). Manages async task lifecycle: launch → queue → run → poll → complete/error. Concurrency limited per model/provider (default 5). Central to multi-agent orchestration.
## TASK LIFECYCLE
```
LaunchInput → pending → [ConcurrencyManager queue] → running → polling → completed/error/cancelled/interrupt
```
## KEY FILES
| File | Purpose |
|------|---------|
| `manager.ts` | `BackgroundManager` — main class: launch, cancel, getTask, listTasks |
| `spawner.ts` | Task spawning: create session → inject prompt → start polling |
| `concurrency.ts` | `ConcurrencyManager` — FIFO queue per concurrency key, slot acquisition/release |
| `task-poller.ts` | 3s interval polling, completion via idle events + stability detection (10s unchanged) |
| `result-handler.ts` | Process completed tasks: extract result, notify parent, cleanup |
| `state.ts` | In-memory task store (Map-based) |
| `types.ts` | `BackgroundTask`, `LaunchInput`, `ResumeInput`, `BackgroundTaskStatus` |
## SPAWNER SUBDIRECTORY (6 files)
| File | Purpose |
|------|---------|
| `spawner-context.ts` | `SpawnerContext` interface composing all spawner deps |
| `background-session-creator.ts` | Create OpenCode session for background task |
| `concurrency-key-from-launch-input.ts` | Derive concurrency key from model/provider |
| `parent-directory-resolver.ts` | Resolve working directory for child session |
| `tmux-callback-invoker.ts` | Notify TmuxSessionManager on session creation |
## COMPLETION DETECTION
Two signals combined:
1. **Session idle event** — OpenCode reports session became idle
2. **Stability detection** — message count unchanged for 10s (3+ stable polls at 3s interval)
Both must agree before marking a task complete. Prevents premature completion on brief pauses.
## CONCURRENCY MODEL
- Key format: `{providerID}/{modelID}` (e.g., `anthropic/claude-opus-4-6`)
- Default limit: 5 concurrent per key (configurable via `background_task` config)
- FIFO queue: tasks wait in order when slots full
- Slot released on: completion, error, cancellation
## NOTIFICATION FLOW
```
task completed → result-handler → parent-session-notifier → inject system message into parent session
```

View File

@@ -1,38 +1,8 @@
import { afterEach, describe, expect, it } from "bun:test"
import { findAvailablePort, startCallbackServer, type CallbackServer } from "./callback-server"
import { startCallbackServer, type CallbackServer } from "./callback-server"
const nativeFetch = Bun.fetch.bind(Bun)
describe("findAvailablePort", () => {
it("returns the start port when it is available", async () => {
// given
const startPort = 19877
// when
const port = await findAvailablePort(startPort)
// then
expect(port).toBeGreaterThanOrEqual(startPort)
expect(port).toBeLessThan(startPort + 20)
})
it("skips busy ports and returns next available", async () => {
// given
const blocker = Bun.serve({
port: 19877,
hostname: "127.0.0.1",
fetch: () => new Response(),
})
// when
const port = await findAvailablePort(19877)
// then
expect(port).toBeGreaterThan(19877)
blocker.stop(true)
})
})
describe("startCallbackServer", () => {
let server: CallbackServer | null = null

View File

@@ -1,5 +1,6 @@
import { findAvailablePort as findAvailablePortShared } from "../../shared/port-utils"
const DEFAULT_PORT = 19877
const MAX_PORT_ATTEMPTS = 20
const TIMEOUT_MS = 5 * 60 * 1000
export type OAuthCallbackResult = {
@@ -33,28 +34,8 @@ const SUCCESS_HTML = `<!DOCTYPE html>
</body>
</html>`
async function isPortAvailable(port: number): Promise<boolean> {
try {
const server = Bun.serve({
port,
hostname: "127.0.0.1",
fetch: () => new Response(),
})
server.stop(true)
return true
} catch {
return false
}
}
export async function findAvailablePort(startPort: number = DEFAULT_PORT): Promise<number> {
for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
const port = startPort + attempt
if (await isPortAvailable(port)) {
return port
}
}
throw new Error(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`)
return findAvailablePortShared(startPort)
}
export async function startCallbackServer(startPort: number = DEFAULT_PORT): Promise<CallbackServer> {

View File

@@ -0,0 +1,59 @@
# src/features/opencode-skill-loader/ — 4-Scope Skill Discovery
**Generated:** 2026-02-18
## OVERVIEW
28 files (~3.2k LOC). Discovers, parses, merges, and resolves SKILL.md files from 4 scopes with priority deduplication.
## 4-SCOPE PRIORITY (highest → lowest)
```
1. Project (.opencode/skills/)
2. OpenCode config (~/.config/opencode/skills/)
3. User (~/.config/opencode/oh-my-opencode/skills/)
4. Global (built-in skills)
```
Same-named skill at higher scope overrides lower.
## KEY FILES
| File | Purpose |
|------|---------|
| `loader.ts` | Main `loadSkills()` — orchestrates discovery → parse → merge |
| `async-loader.ts` | Async variant for non-blocking skill loading |
| `blocking.ts` | Sync variant for initial load |
| `merger.ts` | Priority-based deduplication across scopes |
| `skill-content.ts` | YAML frontmatter parsing from SKILL.md |
| `skill-discovery.ts` | Find SKILL.md files in directory trees |
| `skill-directory-loader.ts` | Load all skills from a single directory |
| `config-source-discovery.ts` | Discover scope directories from config |
| `skill-template-resolver.ts` | Variable substitution in skill templates |
| `skill-mcp-config.ts` | Extract MCP configs from skill YAML |
| `types.ts` | `LoadedSkill`, `SkillScope`, `SkillDiscoveryResult` |
## SKILL FORMAT (SKILL.md)
```markdown
---
name: my-skill
description: What this skill does
tools: [Bash, Read, Write]
mcp:
- name: my-mcp
type: stdio
command: npx
args: [-y, my-mcp-server]
---
Skill content (instructions for the agent)...
```
## MERGER SUBDIRECTORY
Handles complex merge logic when skills from multiple scopes have overlapping names or MCP configs.
## TEMPLATE RESOLUTION
Variables like `{{directory}}`, `{{agent}}` in skill content get resolved at load time based on current context.

View File

@@ -0,0 +1 @@
export const CONTINUATION_MARKER_DIR = ".sisyphus/run-continuation"

View File

@@ -0,0 +1,3 @@
export * from "./types"
export * from "./constants"
export * from "./storage"

View File

@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import {
clearContinuationMarker,
isContinuationMarkerActive,
readContinuationMarker,
setContinuationMarkerSource,
} from "./storage"
const tempDirs: string[] = []
function createTempDir(): string {
const directory = mkdtempSync(join(tmpdir(), "omo-run-marker-"))
tempDirs.push(directory)
return directory
}
afterEach(() => {
while (tempDirs.length > 0) {
const directory = tempDirs.pop()
if (directory) {
rmSync(directory, { recursive: true, force: true })
}
}
})
describe("run-continuation-state storage", () => {
it("stores and reads per-source marker state", () => {
// given
const directory = createTempDir()
const sessionID = "ses_test"
// when
setContinuationMarkerSource(directory, sessionID, "todo", "active", "2 todos remaining")
setContinuationMarkerSource(directory, sessionID, "stop", "stopped", "user requested stop")
const marker = readContinuationMarker(directory, sessionID)
// then
expect(marker).not.toBeNull()
expect(marker?.sessionID).toBe(sessionID)
expect(marker?.sources.todo?.state).toBe("active")
expect(marker?.sources.todo?.reason).toBe("2 todos remaining")
expect(marker?.sources.stop?.state).toBe("stopped")
})
it("treats marker as active when any source is active", () => {
// given
const directory = createTempDir()
const sessionID = "ses_active"
setContinuationMarkerSource(directory, sessionID, "todo", "active", "pending")
setContinuationMarkerSource(directory, sessionID, "stop", "idle")
const marker = readContinuationMarker(directory, sessionID)
// when
const isActive = isContinuationMarkerActive(marker)
// then
expect(isActive).toBe(true)
})
it("returns inactive when no source is active", () => {
// given
const directory = createTempDir()
const sessionID = "ses_idle"
setContinuationMarkerSource(directory, sessionID, "todo", "idle")
setContinuationMarkerSource(directory, sessionID, "stop", "stopped")
const marker = readContinuationMarker(directory, sessionID)
// when
const isActive = isContinuationMarkerActive(marker)
// then
expect(isActive).toBe(false)
})
it("clears marker for a session", () => {
// given
const directory = createTempDir()
const sessionID = "ses_clear"
setContinuationMarkerSource(directory, sessionID, "todo", "active")
// when
clearContinuationMarker(directory, sessionID)
const marker = readContinuationMarker(directory, sessionID)
// then
expect(marker).toBeNull()
})
})

View File

@@ -0,0 +1,80 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { CONTINUATION_MARKER_DIR } from "./constants"
import type {
ContinuationMarker,
ContinuationMarkerSource,
ContinuationMarkerState,
} from "./types"
function getMarkerPath(directory: string, sessionID: string): string {
return join(directory, CONTINUATION_MARKER_DIR, `${sessionID}.json`)
}
export function readContinuationMarker(
directory: string,
sessionID: string,
): ContinuationMarker | null {
const markerPath = getMarkerPath(directory, sessionID)
if (!existsSync(markerPath)) return null
try {
const raw = readFileSync(markerPath, "utf-8")
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null
return parsed as ContinuationMarker
} catch {
return null
}
}
export function setContinuationMarkerSource(
directory: string,
sessionID: string,
source: ContinuationMarkerSource,
state: ContinuationMarkerState,
reason?: string,
): ContinuationMarker {
const now = new Date().toISOString()
const existing = readContinuationMarker(directory, sessionID)
const next: ContinuationMarker = {
sessionID,
updatedAt: now,
sources: {
...(existing?.sources ?? {}),
[source]: {
state,
...(reason ? { reason } : {}),
updatedAt: now,
},
},
}
const markerPath = getMarkerPath(directory, sessionID)
mkdirSync(join(directory, CONTINUATION_MARKER_DIR), { recursive: true })
writeFileSync(markerPath, JSON.stringify(next, null, 2), "utf-8")
return next
}
export function clearContinuationMarker(directory: string, sessionID: string): void {
const markerPath = getMarkerPath(directory, sessionID)
if (!existsSync(markerPath)) return
try {
rmSync(markerPath)
} catch {
}
}
export function isContinuationMarkerActive(marker: ContinuationMarker | null): boolean {
if (!marker) return false
return Object.values(marker.sources).some((entry) => entry?.state === "active")
}
export function getActiveContinuationMarkerReason(marker: ContinuationMarker | null): string | null {
if (!marker) return null
const active = Object.entries(marker.sources).find(([, entry]) => entry?.state === "active")
if (!active || !active[1]) return null
const [source, entry] = active
return entry.reason ?? `${source} continuation is active`
}

View File

@@ -0,0 +1,15 @@
export type ContinuationMarkerSource = "todo" | "stop"
export type ContinuationMarkerState = "idle" | "active" | "stopped"
export interface ContinuationMarkerSourceEntry {
state: ContinuationMarkerState
reason?: string
updatedAt: string
}
export interface ContinuationMarker {
sessionID: string
updatedAt: string
sources: Partial<Record<ContinuationMarkerSource, ContinuationMarkerSourceEntry>>
}

View File

@@ -0,0 +1,52 @@
# src/features/tmux-subagent/ — Tmux Pane Management
**Generated:** 2026-02-18
## OVERVIEW
28 files. State-first tmux integration managing panes for background agent sessions. Handles split decisions, grid planning, polling, and lifecycle events.
## CORE ARCHITECTURE
```
TmuxSessionManager (manager.ts)
├─→ DecisionEngine: Should we spawn/close panes?
├─→ ActionExecutor: Execute spawn/close/replace actions
├─→ PollingManager: Monitor pane health
└─→ EventHandlers: React to session create/delete
```
## KEY FILES
| File | Purpose |
|------|---------|
| `manager.ts` | `TmuxSessionManager` — main class, session tracking, event routing |
| `decision-engine.ts` | Evaluate window state → produce `SpawnDecision` with actions |
| `action-executor.ts` | Execute `PaneAction[]` (close, spawn, replace) |
| `grid-planning.ts` | Calculate pane layout given window dimensions |
| `spawn-action-decider.ts` | Decide spawn vs replace vs skip |
| `spawn-target-finder.ts` | Find best pane to split or replace |
| `polling-manager.ts` | Health polling for tracked sessions |
| `types.ts` | `TrackedSession`, `WindowState`, `PaneAction`, `SpawnDecision` |
## PANE LIFECYCLE
```
session.created → spawn-action-decider → grid-planning → action-executor → track session
session.deleted → cleanup tracked session → close pane if empty
```
## LAYOUT CONSTRAINTS
- `MIN_PANE_WIDTH`: 52 chars
- `MIN_PANE_HEIGHT`: 11 lines
- Main pane preserved (never split below minimum)
- Agent panes split from remaining space
## EVENT HANDLERS
| File | Event |
|------|-------|
| `session-created-handler.ts` | New background session → spawn pane |
| `session-deleted-handler.ts` | Session ended → close pane |
| `session-created-event.ts` | Event type definition |

View File

@@ -1,14 +1,14 @@
# src/hooks/ — 41 Lifecycle Hooks
# src/hooks/ — 44 Lifecycle Hooks
**Generated:** 2026-02-17
**Generated:** 2026-02-18
## OVERVIEW
41 hooks across 37 directories + 6 standalone files. Three-tier composition: Core(33) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
44 hooks across 39 directories + 6 standalone files. Three-tier composition: Core(35) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
## HOOK TIERS
### Tier 1: Session Hooks (20) — `create-session-hooks.ts`
### Tier 1: Session Hooks (22) — `create-session-hooks.ts`
| Hook | Event | Purpose |
|------|-------|---------|
@@ -31,6 +31,9 @@
| questionLabelTruncator | tool.execute.before | Truncate long question labels |
| taskResumeInfo | chat.message | Inject task context on resume |
| anthropicEffort | chat.params | Adjust reasoning effort level |
| jsonErrorRecovery | tool.execute.after | Detect JSON parse errors, inject correction reminder |
| sisyphusGptHephaestusReminder | chat.message | Toast warning when Sisyphus uses GPT model |
| taskReminder | tool.execute.after | Remind about task tools after 10 turns without usage |
### Tier 2: Tool Guard Hooks (9) — `create-tool-guard-hooks.ts`

View File

@@ -0,0 +1,49 @@
# src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery
**Generated:** 2026-02-18
## OVERVIEW
31 files (~2232 LOC). Most complex hook. Recovers from context window limit errors via multiple strategies applied in sequence.
## RECOVERY STRATEGIES (in priority order)
| Strategy | File | Mechanism |
|----------|------|-----------|
| **Empty content recovery** | `empty-content-recovery.ts` | Handle empty/null content blocks in messages |
| **Deduplication** | `deduplication-recovery.ts` | Remove duplicate tool results from context |
| **Target-token truncation** | `target-token-truncation.ts` | Truncate largest tool outputs to fit target ratio |
| **Aggressive truncation** | `aggressive-truncation-strategy.ts` | Last-resort truncation with minimal output preservation |
| **Summarize retry** | `summarize-retry-strategy.ts` | Compaction + summarization then retry |
## KEY FILES
| File | Purpose |
|------|---------|
| `recovery-hook.ts` | Main hook entry — `session.error` handler, strategy orchestration |
| `executor.ts` | Execute recovery strategies in sequence |
| `parser.ts` | Parse Anthropic token limit error messages |
| `state.ts` | `AutoCompactState` — per-session retry/truncation tracking |
| `types.ts` | `ParsedTokenLimitError`, `RetryState`, `TruncateState`, config constants |
| `storage.ts` | Persist tool results for later truncation |
| `tool-result-storage.ts` | Store/retrieve individual tool call results |
| `message-builder.ts` | Build retry messages after recovery |
## RETRY CONFIG
- Max attempts: 2
- Initial delay: 2s, backoff ×2, max 30s
- Max truncation attempts: 20
- Target token ratio: 0.5 (truncate to 50% of limit)
- Chars per token estimate: 4
## PRUNING SYSTEM
`pruning-*.ts` files handle intelligent output pruning:
- `pruning-deduplication.ts` — Remove duplicate content across tool results
- `pruning-tool-output-truncation.ts` — Truncate oversized tool outputs
- `pruning-types.ts` — Pruning-specific type definitions
## SDK VARIANTS
`empty-content-recovery-sdk.ts` and `tool-result-storage-sdk.ts` provide SDK-based implementations for OpenCode client interactions.

View File

@@ -27,8 +27,10 @@ export { createInteractiveBashSessionHook } from "./interactive-bash-session";
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
export { createCategorySkillReminderHook } from "./category-skill-reminder";
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
export { createSisyphusGptHephaestusReminderHook } from "./sisyphus-gpt-hephaestus-reminder";
export { createAutoSlashCommandHook } from "./auto-slash-command";
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
export { createJsonErrorRecoveryHook } from "./json-error-recovery";
export { createPrometheusMdOnlyHook } from "./prometheus-md-only";
export { createSisyphusJuniorNotepadHook } from "./sisyphus-junior-notepad";
export { createTaskResumeInfoHook } from "./task-resume-info";

View File

@@ -0,0 +1,58 @@
import type { PluginInput } from "@opencode-ai/plugin"
export const JSON_ERROR_TOOL_EXCLUDE_LIST = [
"bash",
"read",
"glob",
"grep",
"webfetch",
"look_at",
"grep_app_searchgithub",
"websearch_web_search_exa",
] as const
export const JSON_ERROR_PATTERNS = [
/json parse error/i,
/failed to parse json/i,
/invalid json/i,
/malformed json/i,
/unexpected end of json input/i,
/syntaxerror:\s*unexpected token.*json/i,
/json[^\n]*expected '\}'/i,
/json[^\n]*unexpected eof/i,
] as const
const JSON_ERROR_REMINDER_MARKER = "[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]"
const JSON_ERROR_EXCLUDED_TOOLS = new Set<string>(JSON_ERROR_TOOL_EXCLUDE_LIST)
export const JSON_ERROR_REMINDER = `
[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]
You sent invalid JSON arguments. The system could not parse your tool call.
STOP and do this NOW:
1. LOOK at the error message above to see what was expected vs what you sent.
2. CORRECT your JSON syntax (missing braces, unescaped quotes, trailing commas, etc).
3. RETRY the tool call with valid JSON.
DO NOT repeat the exact same invalid call.
`
export function createJsonErrorRecoveryHook(_ctx: PluginInput) {
return {
"tool.execute.after": async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
) => {
if (JSON_ERROR_EXCLUDED_TOOLS.has(input.tool.toLowerCase())) return
if (typeof output.output !== "string") return
if (output.output.includes(JSON_ERROR_REMINDER_MARKER)) return
const hasJsonError = JSON_ERROR_PATTERNS.some((pattern) => pattern.test(output.output))
if (hasJsonError) {
output.output += `\n${JSON_ERROR_REMINDER}`
}
},
}
}

View File

@@ -0,0 +1,196 @@
import { beforeEach, describe, expect, it } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import {
createJsonErrorRecoveryHook,
JSON_ERROR_PATTERNS,
JSON_ERROR_REMINDER,
JSON_ERROR_TOOL_EXCLUDE_LIST,
} from "./index"
describe("createJsonErrorRecoveryHook", () => {
let hook: ReturnType<typeof createJsonErrorRecoveryHook>
type ToolExecuteAfterHandler = NonNullable<
ReturnType<typeof createJsonErrorRecoveryHook>["tool.execute.after"]
>
type ToolExecuteAfterInput = Parameters<ToolExecuteAfterHandler>[0]
type ToolExecuteAfterOutput = Parameters<ToolExecuteAfterHandler>[1]
const createMockPluginInput = (): PluginInput => {
return {
client: {} as PluginInput["client"],
directory: "/tmp/test",
} as PluginInput
}
beforeEach(() => {
hook = createJsonErrorRecoveryHook(createMockPluginInput())
})
describe("tool.execute.after", () => {
const createInput = (tool = "Edit"): ToolExecuteAfterInput => ({
tool,
sessionID: "test-session",
callID: "test-call-id",
})
const createOutput = (outputText: string): ToolExecuteAfterOutput => ({
title: "Tool Error",
output: outputText,
metadata: {},
})
const createUnknownOutput = (value: unknown): { title: string; output: unknown; metadata: Record<string, unknown> } => ({
title: "Tool Error",
output: value,
metadata: {},
})
it("appends reminder when output includes JSON parse error", async () => {
// given
const input = createInput()
const output = createOutput("JSON parse error: expected '}' in JSON body")
// when
await hook["tool.execute.after"](input, output)
// then
expect(output.output).toContain(JSON_ERROR_REMINDER)
})
it("appends reminder when output includes SyntaxError", async () => {
// given
const input = createInput()
const output = createOutput("SyntaxError: Unexpected token in JSON at position 10")
// when
await hook["tool.execute.after"](input, output)
// then
expect(output.output).toContain(JSON_ERROR_REMINDER)
})
it("does not append reminder for normal output", async () => {
// given
const input = createInput()
const output = createOutput("Task completed successfully")
// when
await hook["tool.execute.after"](input, output)
// then
expect(output.output).toBe("Task completed successfully")
})
it("does not append reminder for empty output", async () => {
// given
const input = createInput()
const output = createOutput("")
// when
await hook["tool.execute.after"](input, output)
// then
expect(output.output).toBe("")
})
it("does not append reminder for false positive non-JSON text", async () => {
// given
const input = createInput()
const output = createOutput("Template failed: expected '}' before newline")
// when
await hook["tool.execute.after"](input, output)
// then
expect(output.output).toBe("Template failed: expected '}' before newline")
})
it("does not append reminder for excluded tools", async () => {
// given
const input = createInput("Read")
const output = createOutput("JSON parse error: unexpected end of JSON input")
// when
await hook["tool.execute.after"](input, output)
// then
expect(output.output).toBe("JSON parse error: unexpected end of JSON input")
})
it("does not append reminder when reminder already exists", async () => {
// given
const input = createInput()
const output = createOutput(`JSON parse error: invalid JSON\n${JSON_ERROR_REMINDER}`)
// when
await hook["tool.execute.after"](input, output)
// then
const reminderCount = output.output.split("[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]").length - 1
expect(reminderCount).toBe(1)
})
it("does not append duplicate reminder on repeated execution", async () => {
// given
const input = createInput()
const output = createOutput("JSON parse error: invalid JSON arguments")
// when
await hook["tool.execute.after"](input, output)
await hook["tool.execute.after"](input, output)
// then
const reminderCount = output.output.split("[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]").length - 1
expect(reminderCount).toBe(1)
})
it("ignores non-string output values", async () => {
// given
const input = createInput()
const values: unknown[] = [42, null, undefined, { error: "invalid json" }]
// when
for (const value of values) {
const output = createUnknownOutput(value)
await hook["tool.execute.after"](input, output as ToolExecuteAfterOutput)
// then
expect(output.output).toBe(value)
}
})
})
describe("JSON_ERROR_PATTERNS", () => {
it("contains known parse error patterns", () => {
// given
const output = "JSON parse error: unexpected end of JSON input"
// when
const isMatched = JSON_ERROR_PATTERNS.some((pattern) => pattern.test(output))
// then
expect(isMatched).toBe(true)
})
})
describe("JSON_ERROR_TOOL_EXCLUDE_LIST", () => {
it("contains content-heavy tools that should be excluded", () => {
// given
const expectedExcludedTools: Array<(typeof JSON_ERROR_TOOL_EXCLUDE_LIST)[number]> = [
"read",
"bash",
"webfetch",
]
// when
const allExpectedToolsIncluded = expectedExcludedTools.every((toolName) =>
JSON_ERROR_TOOL_EXCLUDE_LIST.includes(toolName)
)
// then
expect(allExpectedToolsIncluded).toBe(true)
})
})
})

View File

@@ -0,0 +1,6 @@
export {
createJsonErrorRecoveryHook,
JSON_ERROR_TOOL_EXCLUDE_LIST,
JSON_ERROR_PATTERNS,
JSON_ERROR_REMINDER,
} from "./hook"

View File

@@ -0,0 +1,37 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared"
const TOAST_TITLE = "Use Hephaestus for GPT Models"
const TOAST_MESSAGE = "Sisyphus is using a GPT model. Use Hephaestus and include 'ulw' in your prompt."
export function createSisyphusGptHephaestusReminderHook(ctx: PluginInput) {
return {
"chat.message": async (input: {
sessionID: string
agent?: string
model?: { providerID: string; modelID: string }
}): Promise<void> => {
const agentName = (input.agent ?? getSessionAgent(input.sessionID) ?? "").toLowerCase()
const modelID = input.model?.modelID?.toLowerCase() ?? ""
if (agentName !== "sisyphus" || !modelID.includes("gpt")) {
return
}
await ctx.client.tui.showToast({
body: {
title: TOAST_TITLE,
message: TOAST_MESSAGE,
variant: "error",
duration: 5000,
},
}).catch((error) => {
log("[sisyphus-gpt-hephaestus-reminder] Failed to show toast", {
sessionID: input.sessionID,
error,
})
})
},
}
}

View File

@@ -0,0 +1,78 @@
import { describe, expect, test, spyOn } from "bun:test"
import { createSisyphusGptHephaestusReminderHook } from "./index"
import { _resetForTesting, updateSessionAgent } from "../../features/claude-code-session-state"
describe("sisyphus-gpt-hephaestus-reminder hook", () => {
test("shows error toast when sisyphus uses gpt model", async () => {
// given - sisyphus agent with gpt model
const showToast = spyOn({
fn: async () => ({}),
}, "fn")
const hook = createSisyphusGptHephaestusReminderHook({
client: {
tui: { showToast },
},
} as any)
// when - chat.message runs
await hook["chat.message"]?.({
sessionID: "ses_1",
agent: "sisyphus",
model: { providerID: "openai", modelID: "gpt-5.3-codex" },
})
// then - error toast is shown
expect(showToast).toHaveBeenCalledTimes(1)
expect(showToast.mock.calls[0]?.[0]).toMatchObject({
body: {
title: "Use Hephaestus for GPT Models",
variant: "error",
},
})
})
test("does not show toast for non-gpt model", async () => {
// given - sisyphus agent with non-gpt model
const showToast = spyOn({
fn: async () => ({}),
}, "fn")
const hook = createSisyphusGptHephaestusReminderHook({
client: {
tui: { showToast },
},
} as any)
// when - chat.message runs with claude model
await hook["chat.message"]?.({
sessionID: "ses_2",
agent: "sisyphus",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
})
// then - no toast
expect(showToast).toHaveBeenCalledTimes(0)
})
test("uses session agent fallback when input agent is missing", async () => {
// given - session agent saved as sisyphus
_resetForTesting()
updateSessionAgent("ses_3", "sisyphus")
const showToast = spyOn({
fn: async () => ({}),
}, "fn")
const hook = createSisyphusGptHephaestusReminderHook({
client: {
tui: { showToast },
},
} as any)
// when - chat.message runs without input.agent
await hook["chat.message"]?.({
sessionID: "ses_3",
model: { providerID: "openai", modelID: "gpt-5.2" },
})
// then - toast shown via fallback agent lookup
expect(showToast).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1 @@
export { createSisyphusGptHephaestusReminderHook } from "./hook"

View File

@@ -1,5 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
clearContinuationMarker,
setContinuationMarkerSource,
} from "../../features/run-continuation-state"
import { log } from "../../shared/logger"
const HOOK_NAME = "stop-continuation-guard"
@@ -13,12 +17,13 @@ export interface StopContinuationGuard {
}
export function createStopContinuationGuardHook(
_ctx: PluginInput
ctx: PluginInput
): StopContinuationGuard {
const stoppedSessions = new Set<string>()
const stop = (sessionID: string): void => {
stoppedSessions.add(sessionID)
setContinuationMarkerSource(ctx.directory, sessionID, "stop", "stopped", "continuation stopped")
log(`[${HOOK_NAME}] Continuation stopped for session`, { sessionID })
}
@@ -28,6 +33,7 @@ export function createStopContinuationGuardHook(
const clear = (sessionID: string): void => {
stoppedSessions.delete(sessionID)
setContinuationMarkerSource(ctx.directory, sessionID, "stop", "idle")
log(`[${HOOK_NAME}] Continuation guard cleared for session`, { sessionID })
}
@@ -42,6 +48,7 @@ export function createStopContinuationGuardHook(
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
clear(sessionInfo.id)
clearContinuationMarker(ctx.directory, sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
}
}

View File

@@ -1,7 +1,28 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { readContinuationMarker } from "../../features/run-continuation-state"
import { createStopContinuationGuardHook } from "./index"
describe("stop-continuation-guard", () => {
const tempDirs: string[] = []
function createTempDir(): string {
const directory = mkdtempSync(join(tmpdir(), "omo-stop-guard-"))
tempDirs.push(directory)
return directory
}
afterEach(() => {
while (tempDirs.length > 0) {
const directory = tempDirs.pop()
if (directory) {
rmSync(directory, { recursive: true, force: true })
}
}
})
function createMockPluginInput() {
return {
client: {
@@ -9,13 +30,14 @@ describe("stop-continuation-guard", () => {
showToast: async () => ({}),
},
},
directory: "/tmp/test",
} as never
directory: createTempDir(),
} as any
}
test("should mark session as stopped", () => {
// given - a guard hook with no stopped sessions
const guard = createStopContinuationGuardHook(createMockPluginInput())
const input = createMockPluginInput()
const guard = createStopContinuationGuardHook(input)
const sessionID = "test-session-1"
// when - we stop continuation for the session
@@ -23,6 +45,9 @@ describe("stop-continuation-guard", () => {
// then - session should be marked as stopped
expect(guard.isStopped(sessionID)).toBe(true)
const marker = readContinuationMarker(input.directory, sessionID)
expect(marker?.sources.stop?.state).toBe("stopped")
})
test("should return false for non-stopped sessions", () => {

View File

@@ -1,6 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import {
clearContinuationMarker,
} from "../../features/run-continuation-state"
import { log } from "../../shared/logger"
import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants"
@@ -45,6 +48,7 @@ export function createTodoContinuationHandler(args: {
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
await handleSessionIdle({
ctx,
sessionID,
@@ -56,6 +60,13 @@ export function createTodoContinuationHandler(args: {
return
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
clearContinuationMarker(ctx.directory, sessionInfo.id)
}
}
handleNonIdleEvent({
eventType: event.type,
properties: props,

View File

@@ -23,9 +23,12 @@ type AgentConfigRecord = Record<string, Record<string, unknown> | undefined> & {
plan?: Record<string, unknown>;
};
function hasConfiguredDefaultAgent(config: Record<string, unknown>): boolean {
function getConfiguredDefaultAgent(config: Record<string, unknown>): string | undefined {
const defaultAgent = config.default_agent;
return typeof defaultAgent === "string" && defaultAgent.trim().length > 0;
if (typeof defaultAgent !== "string") return undefined;
const trimmedDefaultAgent = defaultAgent.trim();
return trimmedDefaultAgent.length > 0 ? trimmedDefaultAgent : undefined;
}
export async function applyAgentConfig(params: {
@@ -107,11 +110,15 @@ export async function applyAgentConfig(params: {
const plannerEnabled = params.pluginConfig.sisyphus_agent?.planner_enabled ?? true;
const replacePlan = params.pluginConfig.sisyphus_agent?.replace_plan ?? true;
const shouldDemotePlan = plannerEnabled && replacePlan;
const configuredDefaultAgent = getConfiguredDefaultAgent(params.config);
const configAgent = params.config.agent as AgentConfigRecord | undefined;
if (isSisyphusEnabled && builtinAgents.sisyphus) {
if (!hasConfiguredDefaultAgent(params.config)) {
if (configuredDefaultAgent) {
(params.config as { default_agent?: string }).default_agent =
getAgentDisplayName(configuredDefaultAgent);
} else {
(params.config as { default_agent?: string }).default_agent =
getAgentDisplayName("sisyphus");
}

View File

@@ -350,7 +350,55 @@ describe("Agent permission defaults", () => {
})
describe("default_agent behavior with Sisyphus orchestration", () => {
test("preserves existing default_agent when already set", async () => {
test("canonicalizes configured default_agent with surrounding whitespace", async () => {
// given
const pluginConfig: OhMyOpenCodeConfig = {}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
default_agent: " hephaestus ",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// when
await handler(config)
// then
expect(config.default_agent).toBe(getAgentDisplayName("hephaestus"))
})
test("canonicalizes configured default_agent when key uses mixed case", async () => {
// given
const pluginConfig: OhMyOpenCodeConfig = {}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
default_agent: "HePhAeStUs",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// when
await handler(config)
// then
expect(config.default_agent).toBe(getAgentDisplayName("hephaestus"))
})
test("canonicalizes configured default_agent key to display name", async () => {
// #given
const pluginConfig: OhMyOpenCodeConfig = {}
const config: Record<string, unknown> = {
@@ -371,7 +419,32 @@ describe("default_agent behavior with Sisyphus orchestration", () => {
await handler(config)
// #then
expect(config.default_agent).toBe("hephaestus")
expect(config.default_agent).toBe(getAgentDisplayName("hephaestus"))
})
test("preserves existing display-name default_agent", async () => {
// #given
const pluginConfig: OhMyOpenCodeConfig = {}
const displayName = getAgentDisplayName("hephaestus")
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
default_agent: displayName,
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// #when
await handler(config)
// #then
expect(config.default_agent).toBe(displayName)
})
test("sets default_agent to sisyphus when missing", async () => {
@@ -396,6 +469,82 @@ describe("default_agent behavior with Sisyphus orchestration", () => {
// #then
expect(config.default_agent).toBe(getAgentDisplayName("sisyphus"))
})
test("sets default_agent to sisyphus when configured default_agent is empty after trim", async () => {
// given
const pluginConfig: OhMyOpenCodeConfig = {}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
default_agent: " ",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// when
await handler(config)
// then
expect(config.default_agent).toBe(getAgentDisplayName("sisyphus"))
})
test("preserves custom default_agent names while trimming whitespace", async () => {
// given
const pluginConfig: OhMyOpenCodeConfig = {}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
default_agent: " Custom Agent ",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// when
await handler(config)
// then
expect(config.default_agent).toBe("Custom Agent")
})
test("does not normalize configured default_agent when Sisyphus is disabled", async () => {
// given
const pluginConfig: OhMyOpenCodeConfig = {
sisyphus_agent: {
disabled: true,
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
default_agent: " HePhAeStUs ",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// when
await handler(config)
// then
expect(config.default_agent).toBe(" HePhAeStUs ")
})
})
describe("Prometheus category config resolution", () => {

49
src/plugin/AGENTS.md Normal file
View File

@@ -0,0 +1,49 @@
# src/plugin/ — 8 OpenCode Hook Handlers + Hook Composition
**Generated:** 2026-02-18
## OVERVIEW
Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and composing 44 hooks into the PluginInterface. Every handler file corresponds to one OpenCode hook type.
## HANDLER FILES
| File | OpenCode Hook | Purpose |
|------|---------------|---------|
| `chat-message.ts` | `chat.message` | First-message variant, session setup, keyword detection |
| `chat-params.ts` | `chat.params` | Anthropic effort level, think mode |
| `event.ts` | `event` | Session lifecycle (created, deleted, idle, error) |
| `tool-execute-before.ts` | `tool.execute.before` | Pre-tool guards (file guard, label truncator, rules injector) |
| `tool-execute-after.ts` | `tool.execute.after` | Post-tool hooks (output truncation, comment checker, metadata) |
| `messages-transform.ts` | `experimental.chat.messages.transform` | Context injection, thinking block validation |
| `tool-registry.ts` | `tool` | 26 tools assembled from factories |
| `skill-context.ts` | — | Skill/browser/category context for tool creation |
## HOOK COMPOSITION (hooks/ subdir)
| File | Tier | Count |
|------|------|-------|
| `create-session-hooks.ts` | Session | 22 |
| `create-tool-guard-hooks.ts` | Tool Guard | 9 |
| `create-transform-hooks.ts` | Transform | 4 |
| `create-continuation-hooks.ts` | Continuation | 7 |
| `create-skill-hooks.ts` | Skill | 2 |
| `create-core-hooks.ts` | Aggregator | Session + Guard + Transform = 35 |
## SUPPORT FILES
| File | Purpose |
|------|---------|
| `available-categories.ts` | Build `AvailableCategory[]` for agent prompt injection |
| `session-agent-resolver.ts` | Resolve which agent owns a session |
| `session-status-normalizer.ts` | Normalize session status across OpenCode versions |
| `recent-synthetic-idles.ts` | Dedup rapid idle events |
| `unstable-agent-babysitter.ts` | Track unstable agent behavior across sessions |
| `types.ts` | `PluginContext`, `PluginInterface`, `ToolsRecord`, `TmuxConfig` |
## KEY PATTERNS
- Each handler exports a function receiving `(hookRecord, ctx, pluginConfig, managers)` → returns OpenCode hook function
- Handlers iterate over hook records, calling each hook with `(input, output)` in sequence
- `safeHook()` wrapper in composition files catches errors per-hook without breaking the chain
- Tool registry uses `filterDisabledTools()` before returning

View File

@@ -81,6 +81,7 @@ export function createChatMessageHandler(args: {
await hooks.keywordDetector?.["chat.message"]?.(input, output)
await hooks.claudeCodeHooks?.["chat.message"]?.(input, output)
await hooks.autoSlashCommand?.["chat.message"]?.(input, output)
await hooks.sisyphusGptHephaestusReminder?.["chat.message"]?.(input)
if (hooks.startWork && isStartWorkHookOutput(output)) {
await hooks.startWork["chat.message"]?.(input, output)
}

View File

@@ -14,11 +14,13 @@ import {
createInteractiveBashSessionHook,
createRalphLoopHook,
createEditErrorRecoveryHook,
createJsonErrorRecoveryHook,
createDelegateTaskRetryHook,
createTaskResumeInfoHook,
createStartWorkHook,
createPrometheusMdOnlyHook,
createSisyphusJuniorNotepadHook,
createSisyphusGptHephaestusReminderHook,
createQuestionLabelTruncatorHook,
createPreemptiveCompactionHook,
} from "../../hooks"
@@ -44,10 +46,12 @@ export type SessionHooks = {
interactiveBashSession: ReturnType<typeof createInteractiveBashSessionHook> | null
ralphLoop: ReturnType<typeof createRalphLoopHook> | null
editErrorRecovery: ReturnType<typeof createEditErrorRecoveryHook> | null
jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook> | null
delegateTaskRetry: ReturnType<typeof createDelegateTaskRetryHook> | null
startWork: ReturnType<typeof createStartWorkHook> | null
prometheusMdOnly: ReturnType<typeof createPrometheusMdOnlyHook> | null
sisyphusJuniorNotepad: ReturnType<typeof createSisyphusJuniorNotepadHook> | null
sisyphusGptHephaestusReminder: ReturnType<typeof createSisyphusGptHephaestusReminderHook> | null
questionLabelTruncator: ReturnType<typeof createQuestionLabelTruncatorHook>
taskResumeInfo: ReturnType<typeof createTaskResumeInfoHook>
anthropicEffort: ReturnType<typeof createAnthropicEffortHook> | null
@@ -134,6 +138,10 @@ export function createSessionHooks(args: {
? safeHook("edit-error-recovery", () => createEditErrorRecoveryHook(ctx))
: null
const jsonErrorRecovery = isHookEnabled("json-error-recovery")
? safeHook("json-error-recovery", () => createJsonErrorRecoveryHook(ctx))
: null
const delegateTaskRetry = isHookEnabled("delegate-task-retry")
? safeHook("delegate-task-retry", () => createDelegateTaskRetryHook(ctx))
: null
@@ -150,6 +158,10 @@ export function createSessionHooks(args: {
? safeHook("sisyphus-junior-notepad", () => createSisyphusJuniorNotepadHook(ctx))
: null
const sisyphusGptHephaestusReminder = isHookEnabled("sisyphus-gpt-hephaestus-reminder")
? safeHook("sisyphus-gpt-hephaestus-reminder", () => createSisyphusGptHephaestusReminderHook(ctx))
: null
const questionLabelTruncator = createQuestionLabelTruncatorHook()
const taskResumeInfo = createTaskResumeInfoHook()
@@ -170,10 +182,12 @@ export function createSessionHooks(args: {
interactiveBashSession,
ralphLoop,
editErrorRecovery,
jsonErrorRecovery,
delegateTaskRetry,
startWork,
prometheusMdOnly,
sisyphusJuniorNotepad,
sisyphusGptHephaestusReminder,
questionLabelTruncator,
taskResumeInfo,
anthropicEffort,

View File

@@ -40,6 +40,7 @@ export function createToolExecuteAfterHandler(args: {
await hooks.categorySkillReminder?.["tool.execute.after"]?.(input, output)
await hooks.interactiveBashSession?.["tool.execute.after"]?.(input, output)
await hooks.editErrorRecovery?.["tool.execute.after"]?.(input, output)
await hooks.jsonErrorRecovery?.["tool.execute.after"]?.(input, output)
await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output)
await hooks.atlasHook?.["tool.execute.after"]?.(input, output)
await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output)

View File

@@ -0,0 +1,58 @@
# src/tools/delegate-task/ — Task Delegation Engine
**Generated:** 2026-02-18
## OVERVIEW
41 files. The `task` tool implementation — delegates work to subagents via background or sync sessions. Resolves categories, models, skills, and manages both async and synchronous execution flows.
## TWO EXECUTION MODES
| Mode | Flow | Use Case |
|------|------|----------|
| **Background** (`run_in_background=true`) | Launch → BackgroundManager → poll → notify parent | Explore, librarian, parallel work |
| **Sync** (`run_in_background=false`) | Create session → send prompt → poll until idle → return result | Sequential tasks needing immediate result |
## KEY FILES
| File | Purpose |
|------|---------|
| `tools.ts` | `createDelegateTask()` factory — main entry point |
| `executor.ts` | Route to background or sync execution |
| `types.ts` | `DelegateTaskArgs`, `DelegateTaskToolOptions`, `ToolContextWithMetadata` |
| `category-resolver.ts` | Map category name → model + config |
| `subagent-resolver.ts` | Map subagent_type → agent + model |
| `model-selection.ts` | Model availability checking + fallback |
| `skill-resolver.ts` | Resolve `load_skills[]` → skill content for injection |
| `prompt-builder.ts` | Build system/user prompt with skill content, categories |
## SYNC EXECUTION CHAIN
```
sync-task.ts → sync-session-creator.ts → sync-prompt-sender.ts → sync-session-poller.ts → sync-result-fetcher.ts
```
Each file handles one step. `sync-continuation.ts` handles session continuation (resume with session_id).
## BACKGROUND EXECUTION
```
background-task.ts → BackgroundManager.launch() → (async polling) → background-continuation.ts
```
`background-continuation.ts` handles `session_id` resume for existing background tasks.
## CATEGORY RESOLUTION
1. Check user-defined categories (`pluginConfig.categories`)
2. Fall back to built-in 8 categories
3. Resolve model from category config
4. Check model availability → fallback if unavailable
## MODEL STRING PARSER
`model-string-parser.ts` handles `"model variant"` format (e.g., `"gpt-5.3-codex medium"` → model=`gpt-5.3-codex`, variant=`medium`).
## UNSTABLE AGENT TRACKING
`unstable-agent-task.ts` marks tasks from categories/agents known to be unstable (e.g., free models). Enables `unstableAgentBabysitter` hook monitoring.