Compare commits

..

2 Commits

Author SHA1 Message Date
YeonGyu-Kim
8b71559113 refactor(tmux): use dedicated pane state parser
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:09:23 +09:00
YeonGyu-Kim
26f7738397 fix(tmux): parse pane state output without trailing title
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:09:18 +09:00
26 changed files with 278 additions and 498 deletions

View File

@@ -193,9 +193,10 @@ jobs:
if-no-files-found: error
# =============================================================================
# Job 2: Publish all platforms (oh-my-opencode + oh-my-openagent)
# Job 2: Publish all platforms using OIDC/Provenance
# - Runs on ubuntu-latest for ALL platforms (just downloading artifacts)
# - Uses NODE_AUTH_TOKEN for auth + OIDC for provenance attestation
# - Uses npm Trusted Publishing (OIDC) - no NODE_AUTH_TOKEN needed
# - Fresh OIDC token at publish time avoids timeout issues
# =============================================================================
publish:
needs: build
@@ -207,7 +208,7 @@ jobs:
matrix:
platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline]
steps:
- name: Check if oh-my-opencode already published
- name: Check if already published
id: check
run: |
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
@@ -221,23 +222,9 @@ jobs:
echo "→ ${PKG_NAME}@${VERSION} will be published"
fi
- name: Check if oh-my-openagent already published
id: check-openagent
run: |
PKG_NAME="oh-my-openagent-${{ matrix.platform }}"
VERSION="${{ inputs.version }}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "✓ ${PKG_NAME}@${VERSION} already published, skipping"
else
echo "skip=false" >> $GITHUB_OUTPUT
echo "→ ${PKG_NAME}@${VERSION} will be published"
fi
- name: Download artifact
id: download
if: steps.check.outputs.skip != 'true' || steps.check-openagent.outputs.skip != 'true'
if: steps.check.outputs.skip != 'true'
continue-on-error: true
uses: actions/download-artifact@v4
with:
@@ -245,7 +232,7 @@ jobs:
path: .
- name: Extract artifact
if: (steps.check.outputs.skip != 'true' || steps.check-openagent.outputs.skip != 'true') && steps.download.outcome == 'success'
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
run: |
PLATFORM="${{ matrix.platform }}"
mkdir -p packages/${PLATFORM}
@@ -261,7 +248,7 @@ jobs:
ls -la packages/${PLATFORM}/bin/
- uses: actions/setup-node@v4
if: (steps.check.outputs.skip != 'true' || steps.check-openagent.outputs.skip != 'true') && steps.download.outcome == 'success'
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
@@ -281,25 +268,3 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
timeout-minutes: 15
- name: Publish oh-my-openagent-${{ matrix.platform }}
if: steps.check-openagent.outputs.skip != 'true' && steps.download.outcome == 'success'
run: |
cd packages/${{ matrix.platform }}
# Rename package for oh-my-openagent
jq --arg name "oh-my-openagent-${{ matrix.platform }}" \
--arg desc "Platform-specific binary for oh-my-openagent (${{ matrix.platform }})" \
'.name = $name | .description = $desc | .bin = {"oh-my-openagent": (.bin | to_entries | .[0].value)}' \
package.json > tmp.json && mv tmp.json package.json
TAG_ARG=""
if [ -n "${{ inputs.dist_tag }}" ]; then
TAG_ARG="--tag ${{ inputs.dist_tag }}"
fi
npm publish --access public --provenance $TAG_ARG
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
timeout-minutes: 15

View File

@@ -121,7 +121,7 @@ jobs:
publish-main:
runs-on: ubuntu-latest
needs: [test, typecheck]
if: github.repository == 'code-yeongyu/oh-my-openagent'
if: github.repository == 'code-yeongyu/oh-my-opencode'
outputs:
version: ${{ steps.version.outputs.version }}
dist_tag: ${{ steps.version.outputs.dist_tag }}
@@ -204,7 +204,7 @@ jobs:
bunx tsc --emitDeclarationOnly
bun run build:schema
- name: Publish oh-my-opencode
- name: Publish main package
if: steps.check.outputs.skip != 'true'
run: |
TAG_ARG=""
@@ -213,42 +213,20 @@ jobs:
fi
npm publish --access public --provenance $TAG_ARG
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
- name: Publish oh-my-openagent
- name: Git commit and tag
if: steps.check.outputs.skip != 'true'
run: |
# Update package name to oh-my-openagent
jq '.name = "oh-my-openagent"' package.json > tmp.json && mv tmp.json package.json
# Update optionalDependencies to use oh-my-openagent naming
jq '.optionalDependencies = {
"oh-my-openagent-darwin-arm64": "${{ steps.version.outputs.version }}",
"oh-my-openagent-darwin-x64": "${{ steps.version.outputs.version }}",
"oh-my-openagent-darwin-x64-baseline": "${{ steps.version.outputs.version }}",
"oh-my-openagent-linux-arm64": "${{ steps.version.outputs.version }}",
"oh-my-openagent-linux-arm64-musl": "${{ steps.version.outputs.version }}",
"oh-my-openagent-linux-x64": "${{ steps.version.outputs.version }}",
"oh-my-openagent-linux-x64-baseline": "${{ steps.version.outputs.version }}",
"oh-my-openagent-linux-x64-musl": "${{ steps.version.outputs.version }}",
"oh-my-openagent-linux-x64-musl-baseline": "${{ steps.version.outputs.version }}",
"oh-my-openagent-windows-x64": "${{ steps.version.outputs.version }}",
"oh-my-openagent-windows-x64-baseline": "${{ steps.version.outputs.version }}"
}' package.json > tmp.json && mv tmp.json package.json
TAG_ARG=""
if [ -n "${{ steps.version.outputs.dist_tag }}" ]; then
TAG_ARG="--tag ${{ steps.version.outputs.dist_tag }}"
fi
npm publish --access public --provenance $TAG_ARG || echo "oh-my-openagent publish may have failed (package may already exist)"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add package.json assets/oh-my-opencode.schema.json packages/*/package.json || true
git diff --cached --quiet || git commit -m "release: v${{ steps.version.outputs.version }}"
git tag -f "v${{ steps.version.outputs.version }}"
git push origin --tags --force
git push origin HEAD || echo "Branch push failed (non-critical)"
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
- name: Restore package.json
if: steps.check.outputs.skip != 'true'
run: |
# Restore original package name
jq '.name = "oh-my-opencode"' package.json > tmp.json && mv tmp.json package.json
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
trigger-platform:
runs-on: ubuntu-latest

View File

@@ -45,12 +45,12 @@
"license": "SUL-1.0",
"repository": {
"type": "git",
"url": "git+https://github.com/code-yeongyu/oh-my-openagent.git"
"url": "git+https://github.com/code-yeongyu/oh-my-opencode.git"
},
"bugs": {
"url": "https://github.com/code-yeongyu/oh-my-openagent/issues"
"url": "https://github.com/code-yeongyu/oh-my-opencode/issues"
},
"homepage": "https://github.com/code-yeongyu/oh-my-openagent#readme",
"homepage": "https://github.com/code-yeongyu/oh-my-opencode#readme",
"dependencies": {
"@ast-grep/cli": "^0.40.0",
"@ast-grep/napi": "^0.40.0",

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"darwin"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"darwin"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"darwin"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"win32"

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-openagent"
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"win32"

View File

@@ -2015,22 +2015,6 @@
"created_at": "2026-03-07T13:53:56Z",
"repoId": 1108837393,
"pullRequestNo": 2360
},
{
"name": "crazyrabbit0",
"id": 5244848,
"comment_id": 3936744393,
"created_at": "2026-02-20T19:40:05Z",
"repoId": 1108837393,
"pullRequestNo": 2012
},
{
"name": "vaur94",
"id": 100377859,
"comment_id": 4019104338,
"created_at": "2026-03-08T14:01:19Z",
"repoId": 1108837393,
"pullRequestNo": 2385
}
]
}

View File

@@ -2,7 +2,6 @@ import { describe, test, expect } from "bun:test"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { BackgroundManager } from "./manager"
import type { BackgroundTask } from "./types"
function createManagerWithStatus(statusImpl: () => Promise<{ data: Record<string, { type: string }> }>): BackgroundManager {
const client = {
@@ -52,105 +51,3 @@ describe("BackgroundManager polling overlap", () => {
expect(statusCallCount).toBe(1)
})
})
function createRunningTask(sessionID: string): BackgroundTask {
return {
id: `bg_test_${sessionID}`,
sessionID,
parentSessionID: "parent-session",
parentMessageID: "parent-msg",
description: "test task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(),
progress: { toolCalls: 0, lastUpdate: new Date() },
}
}
function injectTask(manager: BackgroundManager, task: BackgroundTask): void {
const tasks = (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
tasks.set(task.id, task)
}
function createManagerWithClient(clientOverrides: Record<string, unknown> = {}): BackgroundManager {
const client = {
session: {
status: async () => ({ data: {} }),
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
todo: async () => ({ data: [] }),
messages: async () => ({
data: [{
info: { role: "assistant", finish: "end_turn", id: "msg-2" },
parts: [{ type: "text", text: "done" }],
}, {
info: { role: "user", id: "msg-1" },
parts: [{ type: "text", text: "go" }],
}],
}),
...clientOverrides,
},
}
return new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
}
describe("BackgroundManager pollRunningTasks", () => {
describe("#given a running task whose session is no longer in status response", () => {
test("#when pollRunningTasks runs #then completes the task instead of leaving it running", async () => {
//#given
const manager = createManagerWithClient()
const task = createRunningTask("ses-gone")
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
await poll.call(manager)
manager.shutdown()
//#then
expect(task.status).toBe("completed")
expect(task.completedAt).toBeDefined()
})
})
describe("#given a running task whose session status is idle", () => {
test("#when pollRunningTasks runs #then completes the task", async () => {
//#given
const manager = createManagerWithClient({
status: async () => ({ data: { "ses-idle": { type: "idle" } } }),
})
const task = createRunningTask("ses-idle")
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
await poll.call(manager)
manager.shutdown()
//#then
expect(task.status).toBe("completed")
})
})
describe("#given a running task whose session status is busy", () => {
test("#when pollRunningTasks runs #then keeps the task running", async () => {
//#given
const manager = createManagerWithClient({
status: async () => ({ data: { "ses-busy": { type: "busy" } } }),
})
const task = createRunningTask("ses-busy")
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
await poll.call(manager)
manager.shutdown()
//#then
expect(task.status).toBe("running")
})
})
})

View File

@@ -1501,7 +1501,32 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
try {
const sessionStatus = allStatuses[sessionID]
// Handle retry before checking running state
if (sessionStatus?.type === "idle") {
// Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
if (!hasValidOutput) {
log("[background-agent] Polling idle but no valid output yet, waiting:", task.id)
continue
}
// Re-check status after async operation
if (task.status !== "running") continue
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
continue
}
await this.tryCompleteTask(task, "polling (idle status)")
continue
}
// Session is still actively running (not idle).
// Progress is already tracked via handleEvent(message.part.updated),
// so we skip the expensive session.messages() fetch here.
// Completion will be detected when session transitions to idle.
if (sessionStatus?.type === "retry") {
const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
? (sessionStatus as { message?: string }).message
@@ -1512,40 +1537,12 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
// Match sync-session-poller pattern: only skip completion check when
// status EXISTS and is not idle (i.e., session is actively running).
// When sessionStatus is undefined, the session has completed and dropped
// from the status response — fall through to completion detection.
if (sessionStatus && sessionStatus.type !== "idle") {
log("[background-agent] Session still running, relying on event-based progress:", {
taskId: task.id,
sessionID,
sessionStatus: sessionStatus.type,
toolCalls: task.progress?.toolCalls ?? 0,
})
continue
}
// Session is idle or no longer in status response (completed/disappeared)
const completionSource = sessionStatus?.type === "idle"
? "polling (idle status)"
: "polling (session gone from status)"
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
if (!hasValidOutput) {
log("[background-agent] Polling idle/gone but no valid output yet, waiting:", task.id)
continue
}
// Re-check status after async operation
if (task.status !== "running") continue
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
continue
}
await this.tryCompleteTask(task, completionSource)
log("[background-agent] Session still running, relying on event-based progress:", {
taskId: task.id,
sessionID,
sessionStatus: sessionStatus?.type ?? "not_in_status",
toolCalls: task.progress?.toolCalls ?? 0,
})
} catch (error) {
log("[background-agent] Poll error for task:", { taskId: task.id, error })
}

View File

@@ -0,0 +1,125 @@
import type { TmuxPaneInfo } from "./types"
const MANDATORY_PANE_FIELD_COUNT = 8
type ParsedPaneState = {
windowWidth: number
windowHeight: number
panes: TmuxPaneInfo[]
}
type ParsedPaneLine = {
pane: TmuxPaneInfo
windowWidth: number
windowHeight: number
}
type MandatoryPaneFields = [
paneId: string,
widthString: string,
heightString: string,
leftString: string,
topString: string,
activeString: string,
windowWidthString: string,
windowHeightString: string,
]
export function parsePaneStateOutput(stdout: string): ParsedPaneState | null {
const lines = stdout
.split("\n")
.map((line) => line.replace(/\r$/, ""))
.filter((line) => line.length > 0)
if (lines.length === 0) return null
const parsedPaneLines = lines
.map(parsePaneLine)
.filter((parsedPaneLine): parsedPaneLine is ParsedPaneLine => parsedPaneLine !== null)
if (parsedPaneLines.length === 0) return null
const latestPaneLine = parsedPaneLines[parsedPaneLines.length - 1]
if (!latestPaneLine) return null
return {
windowWidth: latestPaneLine.windowWidth,
windowHeight: latestPaneLine.windowHeight,
panes: parsedPaneLines.map(({ pane }) => pane),
}
}
function parsePaneLine(line: string): ParsedPaneLine | null {
const fields = line.split("\t")
const mandatoryFields = getMandatoryPaneFields(fields)
if (!mandatoryFields) return null
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = mandatoryFields
const width = parseInteger(widthString)
const height = parseInteger(heightString)
const left = parseInteger(leftString)
const top = parseInteger(topString)
const windowWidth = parseInteger(windowWidthString)
const windowHeight = parseInteger(windowHeightString)
if (
width === null ||
height === null ||
left === null ||
top === null ||
windowWidth === null ||
windowHeight === null
) {
return null
}
return {
pane: {
paneId,
width,
height,
left,
top,
title: fields.slice(MANDATORY_PANE_FIELD_COUNT).join("\t"),
isActive: activeString === "1",
},
windowWidth,
windowHeight,
}
}
function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null {
if (fields.length < MANDATORY_PANE_FIELD_COUNT) return null
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = fields
if (
paneId === undefined ||
widthString === undefined ||
heightString === undefined ||
leftString === undefined ||
topString === undefined ||
activeString === undefined ||
windowWidthString === undefined ||
windowHeightString === undefined
) {
return null
}
return [
paneId,
widthString,
heightString,
leftString,
topString,
activeString,
windowWidthString,
windowHeightString,
]
}
function parseInteger(value: string): number | null {
const parsedValue = Number.parseInt(value, 10)
return Number.isNaN(parsedValue) ? null : parsedValue
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { parsePaneStateOutput } from "./pane-state-parser"
describe("parsePaneStateOutput", () => {
test("accepts a single pane when tmux omits the empty trailing title field", () => {
// given
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).not.toBeNull()
expect(result).toEqual({
windowWidth: 120,
windowHeight: 40,
panes: [
{
paneId: "%0",
width: 120,
height: 40,
left: 0,
top: 0,
title: "",
isActive: true,
},
],
})
})
test("handles CRLF line endings without dropping panes", () => {
// given
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\r\n%1\t60\t40\t60\t0\t0\t120\t40\tagent\r\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).not.toBeNull()
expect(result?.panes).toEqual([
{
paneId: "%0",
width: 120,
height: 40,
left: 0,
top: 0,
title: "",
isActive: true,
},
{
paneId: "%1",
width: 60,
height: 40,
left: 60,
top: 0,
title: "agent",
isActive: false,
},
])
})
test("preserves tabs inside pane titles", () => {
// given
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\ttitle\twith\ttabs\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).not.toBeNull()
expect(result?.panes[0]?.title).toBe("title\twith\ttabs")
})
})

View File

@@ -1,5 +1,6 @@
import { spawn } from "bun"
import type { WindowState, TmuxPaneInfo } from "./types"
import { parsePaneStateOutput } from "./pane-state-parser"
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
import { log } from "../../shared"
@@ -27,31 +28,12 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
return null
}
const lines = stdout.trim().split("\n").filter(Boolean)
if (lines.length === 0) return null
const parsedPaneState = parsePaneStateOutput(stdout)
if (!parsedPaneState) return null
let windowWidth = 0
let windowHeight = 0
const panes: TmuxPaneInfo[] = []
for (const line of lines) {
const fields = line.split("\t")
if (fields.length < 9) continue
const [paneId, widthStr, heightStr, leftStr, topStr, activeStr, windowWidthStr, windowHeightStr] = fields
const title = fields.slice(8).join("\t")
const width = parseInt(widthStr, 10)
const height = parseInt(heightStr, 10)
const left = parseInt(leftStr, 10)
const top = parseInt(topStr, 10)
const isActive = activeStr === "1"
windowWidth = parseInt(windowWidthStr, 10)
windowHeight = parseInt(windowHeightStr, 10)
if (!isNaN(width) && !isNaN(left) && !isNaN(height) && !isNaN(top)) {
panes.push({ paneId, width, height, left, top, title, isActive })
}
}
const { panes } = parsedPaneState
const windowWidth = parsedPaneState.windowWidth
const windowHeight = parsedPaneState.windowHeight
panes.sort((a, b) => a.left - b.left || a.top - b.top)

View File

@@ -1,6 +1,6 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { CACHE_DIR, PACKAGE_NAME, USER_CONFIG_DIR } from "./constants"
import { PACKAGE_NAME, USER_CONFIG_DIR } from "./constants"
import { log } from "../../shared/logger"
interface BunLockfile {
@@ -48,22 +48,17 @@ function removeFromBunLock(packageName: string): boolean {
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
try {
const pkgDirs = [
path.join(USER_CONFIG_DIR, "node_modules", packageName),
path.join(CACHE_DIR, "node_modules", packageName),
]
const pkgDir = path.join(USER_CONFIG_DIR, "node_modules", packageName)
const pkgJsonPath = path.join(USER_CONFIG_DIR, "package.json")
let packageRemoved = false
let dependencyRemoved = false
let lockRemoved = false
for (const pkgDir of pkgDirs) {
if (fs.existsSync(pkgDir)) {
fs.rmSync(pkgDir, { recursive: true, force: true })
log(`[auto-update-checker] Package removed: ${pkgDir}`)
packageRemoved = true
}
if (fs.existsSync(pkgDir)) {
fs.rmSync(pkgDir, { recursive: true, force: true })
log(`[auto-update-checker] Package removed: ${pkgDir}`)
packageRemoved = true
}
if (fs.existsSync(pkgJsonPath)) {

View File

@@ -9,8 +9,6 @@ type SessionNotificationConfig = {
idleConfirmationDelay: number
skipIfIncompleteTodos: boolean
maxTrackedSessions: number
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
activityGracePeriodMs?: number
}
export function createIdleNotificationScheduler(options: {
@@ -26,9 +24,6 @@ export function createIdleNotificationScheduler(options: {
const sessionActivitySinceIdle = new Set<string>()
const notificationVersions = new Map<string, number>()
const executingNotifications = new Set<string>()
const scheduledAt = new Map<string, number>()
const activityGracePeriodMs = options.config.activityGracePeriodMs ?? 100
function cleanupOldSessions(): void {
const maxSessions = options.config.maxTrackedSessions
@@ -48,10 +43,6 @@ export function createIdleNotificationScheduler(options: {
const sessionsToRemove = Array.from(executingNotifications).slice(0, executingNotifications.size - maxSessions)
sessionsToRemove.forEach((id) => executingNotifications.delete(id))
}
if (scheduledAt.size > maxSessions) {
const sessionsToRemove = Array.from(scheduledAt.keys()).slice(0, scheduledAt.size - maxSessions)
sessionsToRemove.forEach((id) => scheduledAt.delete(id))
}
}
function cancelPendingNotification(sessionID: string): void {
@@ -60,17 +51,11 @@ export function createIdleNotificationScheduler(options: {
clearTimeout(timer)
pendingTimers.delete(sessionID)
}
scheduledAt.delete(sessionID)
sessionActivitySinceIdle.add(sessionID)
notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1)
}
function markSessionActivity(sessionID: string): void {
const scheduledTime = scheduledAt.get(sessionID)
if (scheduledTime && Date.now() - scheduledTime < activityGracePeriodMs) {
return
}
cancelPendingNotification(sessionID)
if (!executingNotifications.has(sessionID)) {
notifiedSessions.delete(sessionID)
@@ -80,26 +65,22 @@ export function createIdleNotificationScheduler(options: {
async function executeNotification(sessionID: string, version: number): Promise<void> {
if (executingNotifications.has(sessionID)) {
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
if (notificationVersions.get(sessionID) !== version) {
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
if (sessionActivitySinceIdle.has(sessionID)) {
sessionActivitySinceIdle.delete(sessionID)
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
if (notifiedSessions.has(sessionID)) {
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
@@ -132,7 +113,6 @@ export function createIdleNotificationScheduler(options: {
} finally {
executingNotifications.delete(sessionID)
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
if (sessionActivitySinceIdle.has(sessionID)) {
notifiedSessions.delete(sessionID)
sessionActivitySinceIdle.delete(sessionID)
@@ -146,7 +126,6 @@ export function createIdleNotificationScheduler(options: {
if (executingNotifications.has(sessionID)) return
sessionActivitySinceIdle.delete(sessionID)
scheduledAt.set(sessionID, Date.now())
const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1
notificationVersions.set(sessionID, currentVersion)
@@ -165,7 +144,6 @@ export function createIdleNotificationScheduler(options: {
sessionActivitySinceIdle.delete(sessionID)
notificationVersions.delete(sessionID)
executingNotifications.delete(sessionID)
scheduledAt.delete(sessionID)
}
return {

View File

@@ -195,9 +195,8 @@ describe("session-notification", () => {
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 100,
idleConfirmationDelay: 100, // Long delay
skipIfIncompleteTodos: false,
activityGracePeriodMs: 0,
})
// when - session goes idle
@@ -273,7 +272,6 @@ describe("session-notification", () => {
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 0,
})
// when - session goes idle, then message.updated fires
@@ -308,7 +306,6 @@ describe("session-notification", () => {
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 0,
})
// when - session goes idle, then tool.execute.before fires
@@ -512,75 +509,4 @@ describe("session-notification", () => {
}
}
})
test("should ignore activity events within grace period", async () => {
// given - main session is set
const mainSessionID = "main-grace"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 100,
})
// when - session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
// when - activity happens immediately (within grace period)
await hook({
event: {
type: "tool.execute.before",
properties: { sessionID: mainSessionID },
},
})
// Wait for idle delay to pass
await new Promise((resolve) => setTimeout(resolve, 100))
// then - notification SHOULD be sent (activity was within grace period, ignored)
expect(notificationCalls.length).toBeGreaterThanOrEqual(1)
})
test("should cancel notification for activity after grace period", async () => {
// given - main session is set
const mainSessionID = "main-grace-cancel"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 200,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 50,
})
// when - session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
// when - wait for grace period to pass
await new Promise((resolve) => setTimeout(resolve, 60))
// when - activity happens after grace period
await hook({
event: {
type: "tool.execute.before",
properties: { sessionID: mainSessionID },
},
})
// Wait for original delay to pass
await new Promise((resolve) => setTimeout(resolve, 200))
// then - notification should NOT be sent (activity cancelled it after grace period)
expect(notificationCalls).toHaveLength(0)
})
})

View File

@@ -24,8 +24,6 @@ interface SessionNotificationConfig {
/** Maximum number of sessions to track before cleanup (default: 100) */
maxTrackedSessions?: number
enforceMainSessionFilter?: boolean
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
activityGracePeriodMs?: number
}
export function createSessionNotification(
ctx: PluginInput,

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { describe, it, expect } from "bun:test"
import { applyToolConfig } from "./tool-config-handler"
import type { OhMyOpenCodeConfig } from "../config"
@@ -56,109 +56,6 @@ describe("applyToolConfig", () => {
})
})
describe("#given OPENCODE_CONFIG_CONTENT has question set to deny", () => {
let originalConfigContent: string | undefined
let originalCliRunMode: string | undefined
beforeEach(() => {
originalConfigContent = process.env.OPENCODE_CONFIG_CONTENT
originalCliRunMode = process.env.OPENCODE_CLI_RUN_MODE
})
afterEach(() => {
if (originalConfigContent === undefined) {
delete process.env.OPENCODE_CONFIG_CONTENT
} else {
process.env.OPENCODE_CONFIG_CONTENT = originalConfigContent
}
if (originalCliRunMode === undefined) {
delete process.env.OPENCODE_CLI_RUN_MODE
} else {
process.env.OPENCODE_CLI_RUN_MODE = originalCliRunMode
}
})
describe("#when config explicitly denies question permission", () => {
it.each(["sisyphus", "hephaestus", "prometheus"])(
"#then should deny question for %s even without CLI_RUN_MODE",
(agentName) => {
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
permission: { question: "deny" },
})
delete process.env.OPENCODE_CLI_RUN_MODE
const params = createParams({ agents: [agentName] })
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.question).toBe("deny")
},
)
})
describe("#when config does not deny question permission", () => {
it.each(["sisyphus", "hephaestus", "prometheus"])(
"#then should allow question for %s in interactive mode",
(agentName) => {
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
permission: { question: "allow" },
})
delete process.env.OPENCODE_CLI_RUN_MODE
const params = createParams({ agents: [agentName] })
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.question).toBe("allow")
},
)
})
describe("#when CLI_RUN_MODE is true and config does not deny", () => {
it.each(["sisyphus", "hephaestus", "prometheus"])(
"#then should deny question for %s via CLI_RUN_MODE",
(agentName) => {
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
permission: {},
})
process.env.OPENCODE_CLI_RUN_MODE = "true"
const params = createParams({ agents: [agentName] })
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.question).toBe("deny")
},
)
})
describe("#when config deny overrides CLI_RUN_MODE allow", () => {
it.each(["sisyphus", "hephaestus", "prometheus"])(
"#then should deny question for %s when config says deny regardless of CLI_RUN_MODE",
(agentName) => {
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
permission: { question: "deny" },
})
process.env.OPENCODE_CLI_RUN_MODE = "false"
const params = createParams({ agents: [agentName] })
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.question).toBe("deny")
},
)
})
})
describe("#given task_system is disabled", () => {
describe("#when applying tool config", () => {
it.each([

View File

@@ -3,17 +3,6 @@ import { getAgentDisplayName } from "../shared/agent-display-names";
type AgentWithPermission = { permission?: Record<string, unknown> };
function getConfigQuestionPermission(): string | null {
const configContent = process.env.OPENCODE_CONFIG_CONTENT;
if (!configContent) return null;
try {
const parsed = JSON.parse(configContent);
return parsed?.permission?.question ?? null;
} catch {
return null;
}
}
function agentByKey(agentResult: Record<string, unknown>, key: string): AgentWithPermission | undefined {
return (agentResult[key] ?? agentResult[getAgentDisplayName(key)]) as
| AgentWithPermission
@@ -43,11 +32,7 @@ export function applyToolConfig(params: {
};
const isCliRunMode = process.env.OPENCODE_CLI_RUN_MODE === "true";
const configQuestionPermission = getConfigQuestionPermission();
const questionPermission =
configQuestionPermission === "deny" ? "deny" :
isCliRunMode ? "deny" :
"allow";
const questionPermission = isCliRunMode ? "deny" : "allow";
const librarian = agentByKey(params.agentResult, "librarian");
if (librarian) {