Merge branch 'code-yeongyu:dev' into fix/oauth-discovery-root-fallback
This commit is contained in:
+4
-4
@@ -14,7 +14,7 @@ Entry point `index.ts` orchestrates 5-step initialization: loadConfig → create
|
||||
| `plugin-config.ts` | JSONC parse, multi-level merge, Zod v4 validation |
|
||||
| `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler |
|
||||
| `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry (26 tools) |
|
||||
| `create-hooks.ts` | 3-tier: Core(37) + Continuation(7) + Skill(2) = 46 hooks |
|
||||
| `create-hooks.ts` | 3-tier: Core(39) + Continuation(7) + Skill(2) = 48 hooks |
|
||||
| `plugin-interface.ts` | 8 OpenCode hook handlers: config, tool, chat.message, chat.params, chat.headers, event, tool.execute.before, tool.execute.after |
|
||||
|
||||
## CONFIG LOADING
|
||||
@@ -32,10 +32,10 @@ loadPluginConfig(directory, ctx)
|
||||
|
||||
```
|
||||
createHooks()
|
||||
├─→ createCoreHooks() # 37 hooks
|
||||
├─→ createCoreHooks() # 39 hooks
|
||||
│ ├─ createSessionHooks() # 23: contextWindowMonitor, thinkMode, ralphLoop, modelFallback, runtimeFallback, noSisyphusGpt, noHephaestusNonGpt, anthropicEffort, intentGate...
|
||||
│ ├─ createToolGuardHooks() # 10: commentChecker, rulesInjector, writeExistingFileGuard, jsonErrorRecovery, hashlineReadEnhancer...
|
||||
│ ├─ createToolGuardHooks() # 12: commentChecker, rulesInjector, writeExistingFileGuard, jsonErrorRecovery, hashlineReadEnhancer...
|
||||
│ └─ createTransformHooks() # 4: claudeCodeHooks, keywordDetector, contextInjector, thinkingBlockValidator
|
||||
├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard, ralphLoopActivator...
|
||||
├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard, compactionContextInjector...
|
||||
└─→ createSkillHooks() # 2: categorySkillReminder, autoSlashCommand
|
||||
```
|
||||
|
||||
@@ -248,8 +248,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "low",
|
||||
"model": "openai/gpt-5.4-mini",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.4",
|
||||
@@ -334,8 +333,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "low",
|
||||
"model": "openai/gpt-5.4-mini",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.4",
|
||||
@@ -533,7 +531,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"model": "openai/gpt-5.4-mini",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.4",
|
||||
@@ -608,7 +606,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"model": "openai/gpt-5.4-mini",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.4",
|
||||
@@ -684,7 +682,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
"model": "opencode/gpt-5.4-mini",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/gpt-5.4",
|
||||
@@ -759,7 +757,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
"model": "opencode/gpt-5.4-mini",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/gpt-5.4",
|
||||
@@ -830,7 +828,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
||||
"variant": "high",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
"model": "github-copilot/gpt-5.4-mini",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||
@@ -900,7 +898,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
||||
"variant": "high",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
"model": "github-copilot/gpt-5.4-mini",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "github-copilot/gemini-3.1-pro-preview",
|
||||
@@ -1092,7 +1090,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"model": "opencode/gpt-5.4-mini",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/gpt-5.4",
|
||||
@@ -1167,7 +1165,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
"model": "openai/gpt-5.4-mini",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.4",
|
||||
@@ -1375,7 +1373,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
"model": "github-copilot/gpt-5.4-mini",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/gpt-5.4",
|
||||
@@ -1453,7 +1451,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"model": "openai/gpt-5.4-mini",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.4",
|
||||
@@ -1531,7 +1529,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"model": "openai/gpt-5.4-mini",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.4",
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("generateModelConfig OpenAI-only model catalog", () => {
|
||||
|
||||
// #then
|
||||
expect(result.categories?.artistry).toEqual({ model: "openai/gpt-5.4", variant: "xhigh" })
|
||||
expect(result.categories?.quick).toEqual({ model: "openai/gpt-5.3-codex", variant: "low" })
|
||||
expect(result.categories?.quick).toEqual({ model: "openai/gpt-5.4-mini" })
|
||||
expect(result.categories?.["visual-engineering"]).toEqual({ model: "openai/gpt-5.4", variant: "high" })
|
||||
expect(result.categories?.writing).toEqual({ model: "openai/gpt-5.4", variant: "medium" })
|
||||
})
|
||||
@@ -55,6 +55,6 @@ describe("generateModelConfig OpenAI-only model catalog", () => {
|
||||
// #then
|
||||
expect(result.agents?.explore).toEqual({ model: "opencode-go/minimax-m2.5" })
|
||||
expect(result.agents?.librarian).toEqual({ model: "opencode-go/minimax-m2.5" })
|
||||
expect(result.categories?.quick).toEqual({ model: "opencode-go/minimax-m2.5" })
|
||||
expect(result.categories?.quick).toEqual({ model: "openai/gpt-5.4-mini" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ const OPENAI_ONLY_AGENT_OVERRIDES: Record<string, AgentConfig> = {
|
||||
|
||||
const OPENAI_ONLY_CATEGORY_OVERRIDES: Record<string, CategoryConfig> = {
|
||||
artistry: { model: "openai/gpt-5.4", variant: "xhigh" },
|
||||
quick: { model: "openai/gpt-5.3-codex", variant: "low" },
|
||||
quick: { model: "openai/gpt-5.4-mini" },
|
||||
"visual-engineering": { model: "openai/gpt-5.4", variant: "high" },
|
||||
writing: { model: "openai/gpt-5.4", variant: "medium" },
|
||||
}
|
||||
|
||||
@@ -115,6 +115,42 @@ describe("waitForEventProcessorShutdown", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("run environment setup", () => {
|
||||
let originalClient: string | undefined
|
||||
let originalRunMode: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalClient = process.env.OPENCODE_CLIENT
|
||||
originalRunMode = process.env.OPENCODE_CLI_RUN_MODE
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClient === undefined) {
|
||||
delete process.env.OPENCODE_CLIENT
|
||||
} else {
|
||||
process.env.OPENCODE_CLIENT = originalClient
|
||||
}
|
||||
if (originalRunMode === undefined) {
|
||||
delete process.env.OPENCODE_CLI_RUN_MODE
|
||||
} else {
|
||||
process.env.OPENCODE_CLI_RUN_MODE = originalRunMode
|
||||
}
|
||||
})
|
||||
|
||||
it("sets OPENCODE_CLIENT to 'run' to exclude question tool from registry", async () => {
|
||||
//#given
|
||||
delete process.env.OPENCODE_CLIENT
|
||||
|
||||
//#when - run() sets env vars synchronously before any async work
|
||||
const { run } = await import(`./runner?env-setup-${Date.now()}`)
|
||||
run({ message: "test" }).catch(() => {})
|
||||
|
||||
//#then
|
||||
expect(String(process.env.OPENCODE_CLIENT)).toBe("run")
|
||||
expect(String(process.env.OPENCODE_CLI_RUN_MODE)).toBe("true")
|
||||
})
|
||||
})
|
||||
|
||||
describe("run with invalid model", () => {
|
||||
it("given invalid --model value, when run, then returns exit code 1 with error message", async () => {
|
||||
// given
|
||||
|
||||
@@ -31,6 +31,7 @@ export async function waitForEventProcessorShutdown(
|
||||
|
||||
export async function run(options: RunOptions): Promise<number> {
|
||||
process.env.OPENCODE_CLI_RUN_MODE = "true"
|
||||
process.env.OPENCODE_CLIENT = "run"
|
||||
|
||||
const startTime = Date.now()
|
||||
const {
|
||||
|
||||
@@ -14,7 +14,7 @@ config/schema/
|
||||
├── agent-names.ts # BuiltinAgentNameSchema (11), OverridableAgentNameSchema (14)
|
||||
├── agent-overrides.ts # AgentOverrideConfigSchema (21 fields per agent)
|
||||
├── categories.ts # 8 built-in + custom categories
|
||||
├── hooks.ts # HookNameSchema (46 hooks)
|
||||
├── hooks.ts # HookNameSchema (48 hooks)
|
||||
├── skills.ts # SkillsConfigSchema (sources, paths, recursive)
|
||||
├── commands.ts # BuiltinCommandNameSchema
|
||||
├── experimental.ts # Feature flags (plugin_load_timeout_ms min 1000)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const HookNameSchema = z.enum([
|
||||
"gpt-permission-continuation",
|
||||
"todo-continuation-enforcer",
|
||||
"context-window-monitor",
|
||||
"session-recovery",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BuiltinCommandNameSchema } from "./commands"
|
||||
import { ExperimentalConfigSchema } from "./experimental"
|
||||
import { GitMasterConfigSchema } from "./git-master"
|
||||
import { NotificationConfigSchema } from "./notification"
|
||||
import { OpenClawConfigSchema } from "./openclaw"
|
||||
import { RalphLoopConfigSchema } from "./ralph-loop"
|
||||
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
|
||||
import { SkillsConfigSchema } from "./skills"
|
||||
@@ -55,6 +56,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
runtime_fallback: z.union([z.boolean(), RuntimeFallbackConfigSchema]).optional(),
|
||||
background_task: BackgroundTaskConfigSchema.optional(),
|
||||
notification: NotificationConfigSchema.optional(),
|
||||
openclaw: OpenClawConfigSchema.optional(),
|
||||
babysitting: BabysittingConfigSchema.optional(),
|
||||
git_master: GitMasterConfigSchema.optional(),
|
||||
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const OpenClawGatewaySchema = z.object({
|
||||
type: z.enum(["http", "command"]).default("http"),
|
||||
// HTTP specific
|
||||
url: z.string().optional(),
|
||||
method: z.string().default("POST"),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
// Command specific
|
||||
command: z.string().optional(),
|
||||
// Shared
|
||||
timeout: z.number().optional(),
|
||||
})
|
||||
|
||||
export const OpenClawHookSchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
gateway: z.string(),
|
||||
instruction: z.string(),
|
||||
})
|
||||
|
||||
export const OpenClawReplyListenerConfigSchema = z.object({
|
||||
discordBotToken: z.string().optional(),
|
||||
discordChannelId: z.string().optional(),
|
||||
discordMention: z.string().optional(), // For allowed_mentions
|
||||
authorizedDiscordUserIds: z.array(z.string()).default([]),
|
||||
|
||||
telegramBotToken: z.string().optional(),
|
||||
telegramChatId: z.string().optional(),
|
||||
|
||||
pollIntervalMs: z.number().default(3000),
|
||||
rateLimitPerMinute: z.number().default(10),
|
||||
maxMessageLength: z.number().default(500),
|
||||
includePrefix: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export const OpenClawConfigSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
|
||||
// Outbound Configuration
|
||||
gateways: z.record(z.string(), OpenClawGatewaySchema).default({}),
|
||||
hooks: z.record(z.string(), OpenClawHookSchema).default({}),
|
||||
|
||||
// Inbound Configuration (Reply Listener)
|
||||
replyListener: OpenClawReplyListenerConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export type OpenClawConfig = z.infer<typeof OpenClawConfigSchema>
|
||||
export type OpenClawGateway = z.infer<typeof OpenClawGatewaySchema>
|
||||
export type OpenClawHook = z.infer<typeof OpenClawHookSchema>
|
||||
export type OpenClawReplyListenerConfig = z.infer<typeof OpenClawReplyListenerConfigSchema>
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./types"
|
||||
export * from "./constants"
|
||||
export * from "./storage"
|
||||
export * from "./top-level-task"
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
getPlanName,
|
||||
createBoulderState,
|
||||
findPrometheusPlans,
|
||||
getTaskSessionState,
|
||||
upsertTaskSessionState,
|
||||
} from "./storage"
|
||||
import type { BoulderState } from "./types"
|
||||
import { readCurrentTopLevelTask } from "./top-level-task"
|
||||
|
||||
describe("boulder-state", () => {
|
||||
const TEST_DIR = join(tmpdir(), "boulder-state-test-" + Date.now())
|
||||
@@ -134,6 +137,24 @@ describe("boulder-state", () => {
|
||||
expect(result?.session_ids).toEqual(["session-1", "session-2"])
|
||||
expect(result?.plan_name).toBe("my-plan")
|
||||
})
|
||||
|
||||
test("should default task_sessions to empty object when missing from JSON", () => {
|
||||
// given - boulder.json without task_sessions field
|
||||
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
|
||||
writeFileSync(boulderFile, JSON.stringify({
|
||||
active_plan: "/path/to/plan.md",
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "plan",
|
||||
}))
|
||||
|
||||
// when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.task_sessions).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeBoulderState", () => {
|
||||
@@ -249,6 +270,115 @@ describe("boulder-state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("task session state", () => {
|
||||
test("should persist and read preferred session for a top-level plan task", () => {
|
||||
// given - existing boulder state
|
||||
const state: BoulderState = {
|
||||
active_plan: "/plan.md",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
// when
|
||||
upsertTaskSessionState(TEST_DIR, {
|
||||
taskKey: "todo:1",
|
||||
taskLabel: "1",
|
||||
taskTitle: "Implement auth flow",
|
||||
sessionId: "ses_task_123",
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
})
|
||||
const result = getTaskSessionState(TEST_DIR, "todo:1")
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.session_id).toBe("ses_task_123")
|
||||
expect(result?.task_title).toBe("Implement auth flow")
|
||||
expect(result?.agent).toBe("sisyphus-junior")
|
||||
expect(result?.category).toBe("deep")
|
||||
})
|
||||
|
||||
test("should overwrite preferred session for the same top-level plan task", () => {
|
||||
// given - existing boulder state with prior preferred session
|
||||
const state: BoulderState = {
|
||||
active_plan: "/plan.md",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "plan",
|
||||
task_sessions: {
|
||||
"todo:1": {
|
||||
task_key: "todo:1",
|
||||
task_label: "1",
|
||||
task_title: "Implement auth flow",
|
||||
session_id: "ses_old",
|
||||
updated_at: "2026-01-02T10:00:00Z",
|
||||
},
|
||||
},
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
// when
|
||||
upsertTaskSessionState(TEST_DIR, {
|
||||
taskKey: "todo:1",
|
||||
taskLabel: "1",
|
||||
taskTitle: "Implement auth flow",
|
||||
sessionId: "ses_new",
|
||||
})
|
||||
const result = getTaskSessionState(TEST_DIR, "todo:1")
|
||||
|
||||
// then
|
||||
expect(result?.session_id).toBe("ses_new")
|
||||
})
|
||||
})
|
||||
|
||||
describe("readCurrentTopLevelTask", () => {
|
||||
test("should return the first unchecked top-level task in TODOs", () => {
|
||||
// given - plan with nested and top-level unchecked tasks
|
||||
const planPath = join(TEST_DIR, "current-task-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Finished task
|
||||
- [ ] nested acceptance checkbox
|
||||
- [ ] 2. Current task
|
||||
|
||||
## Final Verification Wave
|
||||
- [ ] F1. Final review
|
||||
`)
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.key).toBe("todo:2")
|
||||
expect(result?.title).toBe("Current task")
|
||||
})
|
||||
|
||||
test("should fall back to final-wave task when implementation tasks are complete", () => {
|
||||
// given - plan with only final-wave work remaining
|
||||
const planPath = join(TEST_DIR, "final-wave-current-task-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Finished task
|
||||
|
||||
## Final Verification Wave
|
||||
- [ ] F1. Final review
|
||||
`)
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.key).toBe("final-wave:f1")
|
||||
expect(result?.title).toBe("Final review")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPlanProgress", () => {
|
||||
test("should count completed and uncompleted checkboxes", () => {
|
||||
// given - plan file with checkboxes
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"
|
||||
import { dirname, join, basename } from "node:path"
|
||||
import type { BoulderState, PlanProgress } from "./types"
|
||||
import type { BoulderState, PlanProgress, TaskSessionState } from "./types"
|
||||
import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants"
|
||||
|
||||
const RESERVED_KEYS = new Set(["__proto__", "prototype", "constructor"])
|
||||
|
||||
export function getBoulderFilePath(directory: string): string {
|
||||
return join(directory, BOULDER_DIR, BOULDER_FILE)
|
||||
}
|
||||
@@ -29,6 +31,9 @@ export function readBoulderState(directory: string): BoulderState | null {
|
||||
if (!Array.isArray(parsed.session_ids)) {
|
||||
parsed.session_ids = []
|
||||
}
|
||||
if (!parsed.task_sessions || typeof parsed.task_sessions !== "object" || Array.isArray(parsed.task_sessions)) {
|
||||
parsed.task_sessions = {}
|
||||
}
|
||||
return parsed as BoulderState
|
||||
} catch {
|
||||
return null
|
||||
@@ -85,6 +90,54 @@ export function clearBoulderState(directory: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function getTaskSessionState(directory: string, taskKey: string): TaskSessionState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state?.task_sessions) {
|
||||
return null
|
||||
}
|
||||
|
||||
return state.task_sessions[taskKey] ?? null
|
||||
}
|
||||
|
||||
export function upsertTaskSessionState(
|
||||
directory: string,
|
||||
input: {
|
||||
taskKey: string
|
||||
taskLabel: string
|
||||
taskTitle: string
|
||||
sessionId: string
|
||||
agent?: string
|
||||
category?: string
|
||||
},
|
||||
): BoulderState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (RESERVED_KEYS.has(input.taskKey)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const taskSessions = state.task_sessions ?? {}
|
||||
taskSessions[input.taskKey] = {
|
||||
task_key: input.taskKey,
|
||||
task_label: input.taskLabel,
|
||||
task_title: input.taskTitle,
|
||||
session_id: input.sessionId,
|
||||
...(input.agent !== undefined ? { agent: input.agent } : {}),
|
||||
...(input.category !== undefined ? { category: input.category } : {}),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
state.task_sessions = taskSessions
|
||||
if (writeBoulderState(directory, state)) {
|
||||
return state
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Prometheus plan files for this project.
|
||||
* Prometheus stores plans at: {project}/.sisyphus/plans/{name}.md
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
|
||||
import { readCurrentTopLevelTask } from "./top-level-task"
|
||||
|
||||
function writePlanFile(fileName: string, content: string): string {
|
||||
const planPath = join(tmpdir(), fileName)
|
||||
writeFileSync(planPath, content, "utf-8")
|
||||
return planPath
|
||||
}
|
||||
|
||||
describe("readCurrentTopLevelTask", () => {
|
||||
test("returns first unchecked top-level task in TODOs", () => {
|
||||
// given
|
||||
const planPath = writePlanFile(
|
||||
`top-level-task-happy-${Date.now()}.md`,
|
||||
`# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Done task
|
||||
- [ ] 2. Current task
|
||||
|
||||
## Final Verification Wave
|
||||
- [ ] F1. Final review
|
||||
`,
|
||||
)
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
key: "todo:2",
|
||||
section: "todo",
|
||||
label: "2",
|
||||
title: "Current task",
|
||||
})
|
||||
})
|
||||
|
||||
test("returns null when all tasks are checked", () => {
|
||||
// given
|
||||
const planPath = writePlanFile(
|
||||
`top-level-task-all-checked-${Date.now()}.md`,
|
||||
`# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Done task
|
||||
- [x] 2. Another done task
|
||||
|
||||
## Final Verification Wave
|
||||
- [x] F1. Final done review
|
||||
`,
|
||||
)
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for empty plan file", () => {
|
||||
// given
|
||||
const planPath = writePlanFile(`top-level-task-empty-${Date.now()}.md`, "")
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null when plan file does not exist", () => {
|
||||
// given
|
||||
const planPath = join(tmpdir(), `top-level-task-missing-${Date.now()}.md`)
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("skips nested or indented checkboxes", () => {
|
||||
// given
|
||||
const planPath = writePlanFile(
|
||||
`top-level-task-nested-${Date.now()}.md`,
|
||||
`# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Done task
|
||||
- [ ] nested should be ignored
|
||||
- [ ] 2. Top-level pending
|
||||
`,
|
||||
)
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result?.key).toBe("todo:2")
|
||||
})
|
||||
|
||||
test("falls back to Final Verification Wave when TODOs are all checked", () => {
|
||||
// given
|
||||
const planPath = writePlanFile(
|
||||
`top-level-task-fallback-${Date.now()}.md`,
|
||||
`# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Done task
|
||||
- [x] 2. Done task
|
||||
|
||||
## Final Verification Wave
|
||||
- [ ] F1. Final review pending
|
||||
`,
|
||||
)
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
key: "final-wave:f1",
|
||||
section: "final-wave",
|
||||
label: "F1",
|
||||
title: "Final review pending",
|
||||
})
|
||||
})
|
||||
|
||||
test("selects the first unchecked task among mixed checked and unchecked TODOs", () => {
|
||||
// given
|
||||
const planPath = writePlanFile(
|
||||
`top-level-task-mixed-${Date.now()}.md`,
|
||||
`# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Done task
|
||||
- [ ] 2. First unchecked
|
||||
- [ ] 3. Second unchecked
|
||||
`,
|
||||
)
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result?.key).toBe("todo:2")
|
||||
expect(result?.title).toBe("First unchecked")
|
||||
})
|
||||
|
||||
test("ignores malformed labels and continues to next unchecked task", () => {
|
||||
// given
|
||||
const planPath = writePlanFile(
|
||||
`top-level-task-malformed-${Date.now()}.md`,
|
||||
`# Plan
|
||||
|
||||
## TODOs
|
||||
- [ ] no number prefix
|
||||
- [ ] 2. Valid task after malformed label
|
||||
`,
|
||||
)
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
key: "todo:2",
|
||||
section: "todo",
|
||||
label: "2",
|
||||
title: "Valid task after malformed label",
|
||||
})
|
||||
})
|
||||
|
||||
test("supports unchecked tasks with asterisk bullets", () => {
|
||||
// given
|
||||
const planPath = writePlanFile(
|
||||
`top-level-task-asterisk-${Date.now()}.md`,
|
||||
`# Plan
|
||||
|
||||
## TODOs
|
||||
* [ ] 1. Task using asterisk bullet
|
||||
`,
|
||||
)
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result?.key).toBe("todo:1")
|
||||
expect(result?.title).toBe("Task using asterisk bullet")
|
||||
})
|
||||
|
||||
test("returns final-wave task when plan has only Final Verification Wave section", () => {
|
||||
// given
|
||||
const planPath = writePlanFile(
|
||||
`top-level-task-final-only-${Date.now()}.md`,
|
||||
`# Plan
|
||||
|
||||
## Final Verification Wave
|
||||
- [ ] F2. Final-only task
|
||||
`,
|
||||
)
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
key: "final-wave:f2",
|
||||
section: "final-wave",
|
||||
label: "F2",
|
||||
title: "Final-only task",
|
||||
})
|
||||
})
|
||||
|
||||
test("returns the first unchecked task when multiple unchecked tasks exist", () => {
|
||||
// given
|
||||
const planPath = writePlanFile(
|
||||
`top-level-task-multiple-${Date.now()}.md`,
|
||||
`# Plan
|
||||
|
||||
## TODOs
|
||||
- [ ] 1. First unchecked task
|
||||
- [ ] 2. Second unchecked task
|
||||
- [ ] 3. Third unchecked task
|
||||
`,
|
||||
)
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result?.label).toBe("1")
|
||||
expect(result?.title).toBe("First unchecked task")
|
||||
})
|
||||
|
||||
test("ignores unchecked content in non-target sections during section transitions", () => {
|
||||
// given
|
||||
const planPath = writePlanFile(
|
||||
`top-level-task-sections-${Date.now()}.md`,
|
||||
`# Plan
|
||||
|
||||
## Notes
|
||||
- [ ] 99. Should be ignored because section is not tracked
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Done implementation task
|
||||
|
||||
## Decisions
|
||||
- [ ] 100. Should also be ignored
|
||||
|
||||
## Final Verification Wave
|
||||
- [ ] F3. Final verification task
|
||||
`,
|
||||
)
|
||||
|
||||
// when
|
||||
const result = readCurrentTopLevelTask(planPath)
|
||||
|
||||
// then
|
||||
expect(result?.key).toBe("final-wave:f3")
|
||||
expect(result?.section).toBe("final-wave")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
|
||||
import type { TopLevelTaskRef } from "./types"
|
||||
|
||||
const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i
|
||||
const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
|
||||
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
|
||||
const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/
|
||||
const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/
|
||||
const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i
|
||||
|
||||
type PlanSection = "todo" | "final-wave" | "other"
|
||||
|
||||
function buildTaskRef(
|
||||
section: "todo" | "final-wave",
|
||||
taskLabel: string,
|
||||
): TopLevelTaskRef | null {
|
||||
const pattern = section === "todo" ? TODO_TASK_PATTERN : FINAL_WAVE_TASK_PATTERN
|
||||
const match = taskLabel.match(pattern)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawLabel = match[1]
|
||||
const title = match[2].trim()
|
||||
|
||||
return {
|
||||
key: `${section}:${rawLabel.toLowerCase()}`,
|
||||
section,
|
||||
label: rawLabel,
|
||||
title,
|
||||
}
|
||||
}
|
||||
|
||||
export function readCurrentTopLevelTask(planPath: string): TopLevelTaskRef | null {
|
||||
if (!existsSync(planPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(planPath, "utf-8")
|
||||
const lines = content.split(/\r?\n/)
|
||||
let section: PlanSection = "other"
|
||||
|
||||
for (const line of lines) {
|
||||
if (SECOND_LEVEL_HEADING_PATTERN.test(line)) {
|
||||
section = TODO_HEADING_PATTERN.test(line)
|
||||
? "todo"
|
||||
: FINAL_VERIFICATION_HEADING_PATTERN.test(line)
|
||||
? "final-wave"
|
||||
: "other"
|
||||
}
|
||||
|
||||
const uncheckedTaskMatch = line.match(UNCHECKED_CHECKBOX_PATTERN)
|
||||
if (!uncheckedTaskMatch) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (uncheckedTaskMatch[1].length > 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (section !== "todo" && section !== "final-wave") {
|
||||
continue
|
||||
}
|
||||
|
||||
const taskRef = buildTaskRef(section, uncheckedTaskMatch[2].trim())
|
||||
if (taskRef) {
|
||||
return taskRef
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export interface BoulderState {
|
||||
agent?: string
|
||||
/** Absolute path to the git worktree root where work happens */
|
||||
worktree_path?: string
|
||||
/** Preferred reusable subagent sessions keyed by current top-level plan task */
|
||||
task_sessions?: Record<string, TaskSessionState>
|
||||
}
|
||||
|
||||
export interface PlanProgress {
|
||||
@@ -28,3 +30,31 @@ export interface PlanProgress {
|
||||
/** Whether all tasks are done */
|
||||
isComplete: boolean
|
||||
}
|
||||
|
||||
export interface TaskSessionState {
|
||||
/** Stable identifier for the current top-level plan task (e.g. todo:1 / final-wave:F1) */
|
||||
task_key: string
|
||||
/** Original task label from the plan file */
|
||||
task_label: string
|
||||
/** Full task title from the plan file */
|
||||
task_title: string
|
||||
/** Preferred reusable subagent session */
|
||||
session_id: string
|
||||
/** Agent associated with the task session, when known */
|
||||
agent?: string
|
||||
/** Category associated with the task session, when known */
|
||||
category?: string
|
||||
/** Last update timestamp */
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface TopLevelTaskRef {
|
||||
/** Stable identifier for the current top-level plan task */
|
||||
key: string
|
||||
/** Task section in the Prometheus plan */
|
||||
section: "todo" | "final-wave"
|
||||
/** Original label token (e.g. 1 / F1) */
|
||||
label: string
|
||||
/** Full task title extracted from the checkbox line */
|
||||
title: string
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
# src/hooks/ — 46 Lifecycle Hooks
|
||||
# src/hooks/ — 48 Lifecycle Hooks
|
||||
|
||||
**Generated:** 2026-03-06
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
46 hooks across 45 directories + 11 standalone files. Three-tier composition: Core(37) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
|
||||
48 hooks across dedicated modules and standalone files. Three-tier composition: Core(39) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
|
||||
|
||||
## HOOK TIERS
|
||||
|
||||
@@ -85,7 +85,7 @@ hooks/
|
||||
| noHephaestusNonGpt | chat.message | Block Hephaestus from using non-GPT models |
|
||||
| runtimeFallback | event | Auto-switch models on API provider errors |
|
||||
|
||||
### Tier 2: Tool Guard Hooks (10) — `create-tool-guard-hooks.ts`
|
||||
### Tier 2: Tool Guard Hooks (12) — `create-tool-guard-hooks.ts`
|
||||
|
||||
| Hook | Event | Purpose |
|
||||
|------|-------|---------|
|
||||
|
||||
@@ -2,11 +2,12 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createAtlasEventHandler } from "./event-handler"
|
||||
import { createToolExecuteAfterHandler } from "./tool-execute-after"
|
||||
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
|
||||
import type { AtlasHookOptions, SessionState } from "./types"
|
||||
import type { AtlasHookOptions, PendingTaskRef, SessionState } from "./types"
|
||||
|
||||
export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
|
||||
const sessions = new Map<string, SessionState>()
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
const pendingTaskRefs = new Map<string, PendingTaskRef>()
|
||||
const autoCommit = options?.autoCommit ?? true
|
||||
|
||||
function getState(sessionID: string): SessionState {
|
||||
@@ -20,7 +21,7 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
|
||||
|
||||
return {
|
||||
handler: createAtlasEventHandler({ ctx, options, sessions, getState }),
|
||||
"tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths }),
|
||||
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, autoCommit, getState }),
|
||||
"tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }),
|
||||
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export async function injectBoulderContinuation(input: {
|
||||
total: number
|
||||
agent?: string
|
||||
worktreePath?: string
|
||||
preferredTaskSessionId?: string
|
||||
preferredTaskTitle?: string
|
||||
backgroundManager?: BackgroundManager
|
||||
sessionState: SessionState
|
||||
}): Promise<void> {
|
||||
@@ -26,6 +28,8 @@ export async function injectBoulderContinuation(input: {
|
||||
total,
|
||||
agent,
|
||||
worktreePath,
|
||||
preferredTaskSessionId,
|
||||
preferredTaskTitle,
|
||||
backgroundManager,
|
||||
sessionState,
|
||||
} = input
|
||||
@@ -40,9 +44,13 @@ export async function injectBoulderContinuation(input: {
|
||||
}
|
||||
|
||||
const worktreeContext = worktreePath ? `\n\n[Worktree: ${worktreePath}]` : ""
|
||||
const preferredSessionContext = preferredTaskSessionId
|
||||
? `\n\n[Preferred reuse session for current top-level plan task${preferredTaskTitle ? `: ${preferredTaskTitle}` : ""}: ${preferredTaskSessionId}]`
|
||||
: ""
|
||||
const prompt =
|
||||
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
|
||||
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
|
||||
preferredSessionContext +
|
||||
worktreeContext
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk"
|
||||
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
|
||||
@@ -52,6 +52,23 @@ describe("Atlas final-wave approval gate regressions", () => {
|
||||
response: new Response(),
|
||||
}))
|
||||
|
||||
Reflect.set(client.session, "get", async ({ path }: { path: { id: string } }) => {
|
||||
const parentID = path.id === "ses_nested_scope_review"
|
||||
? "atlas-nested-final-wave-session"
|
||||
: path.id.startsWith("ses_parallel_review_")
|
||||
? "atlas-parallel-final-wave-session"
|
||||
: "main-session-123"
|
||||
|
||||
return {
|
||||
data: {
|
||||
id: path.id,
|
||||
parentID,
|
||||
} as Session,
|
||||
request: new Request(`http://localhost/session/${path.id}`),
|
||||
response: new Response(),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
directory: testDirectory,
|
||||
project: {} as AtlasHookContext["project"],
|
||||
|
||||
@@ -60,10 +60,18 @@ describe("Atlas final verification approval gate", () => {
|
||||
}
|
||||
})
|
||||
|
||||
Reflect.set(client.session, "get", async () => {
|
||||
Reflect.set(client.session, "get", async ({ path }: { path: { id: string } }) => {
|
||||
const parentID = path.id === "ses_final_wave_review"
|
||||
? "atlas-final-wave-session"
|
||||
: path.id === "ses_feature_task"
|
||||
? "atlas-non-final-session"
|
||||
: "main-session-123"
|
||||
return {
|
||||
data: { parentID: "main-session-123" } as Session,
|
||||
request: new Request("http://localhost/session/main-session-123"),
|
||||
data: {
|
||||
id: path.id,
|
||||
parentID,
|
||||
} as Session,
|
||||
request: new Request(`http://localhost/session/${path.id}`),
|
||||
response: new Response(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
|
||||
import {
|
||||
getPlanProgress,
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
@@ -8,6 +13,7 @@ import type { AtlasHookOptions, SessionState } from "./types"
|
||||
|
||||
const CONTINUATION_COOLDOWN_MS = 5000
|
||||
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
|
||||
const MAX_CONSECUTIVE_PROMPT_FAILURES = 10
|
||||
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
|
||||
|
||||
function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean {
|
||||
@@ -31,6 +37,14 @@ async function injectContinuation(input: {
|
||||
input.sessionState.lastContinuationInjectedAt = Date.now()
|
||||
|
||||
try {
|
||||
const currentBoulder = readBoulderState(input.ctx.directory)
|
||||
const currentTask = currentBoulder
|
||||
? readCurrentTopLevelTask(currentBoulder.active_plan)
|
||||
: null
|
||||
const preferredTaskSession = currentTask
|
||||
? getTaskSessionState(input.ctx.directory, currentTask.key)
|
||||
: null
|
||||
|
||||
await injectBoulderContinuation({
|
||||
ctx: input.ctx,
|
||||
sessionID: input.sessionID,
|
||||
@@ -39,6 +53,8 @@ async function injectContinuation(input: {
|
||||
total: input.progress.total,
|
||||
agent: input.agent,
|
||||
worktreePath: input.worktreePath,
|
||||
preferredTaskSessionId: preferredTaskSession?.session_id,
|
||||
preferredTaskTitle: preferredTaskSession?.task_title,
|
||||
backgroundManager: input.options?.backgroundManager,
|
||||
sessionState: input.sessionState,
|
||||
})
|
||||
@@ -62,7 +78,7 @@ function scheduleRetry(input: {
|
||||
sessionState.pendingRetryTimer = setTimeout(async () => {
|
||||
sessionState.pendingRetryTimer = undefined
|
||||
|
||||
if (sessionState.promptFailureCount >= 2) return
|
||||
if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) return
|
||||
if (sessionState.waitingForFinalWaveApproval) return
|
||||
|
||||
const currentBoulder = readBoulderState(ctx.directory)
|
||||
@@ -72,7 +88,6 @@ function scheduleRetry(input: {
|
||||
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
||||
if (currentProgress.isComplete) return
|
||||
if (options?.isContinuationStopped?.(sessionID)) return
|
||||
if (options?.shouldSkipContinuation?.(sessionID)) return
|
||||
if (hasRunningBackgroundTasks(sessionID, options)) return
|
||||
|
||||
await injectContinuation({
|
||||
@@ -135,7 +150,7 @@ export async function handleAtlasSessionIdle(input: {
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionState.promptFailureCount >= 2) {
|
||||
if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) {
|
||||
const timeSinceLastFailure =
|
||||
sessionState.lastFailureAt !== undefined ? now - sessionState.lastFailureAt : Number.POSITIVE_INFINITY
|
||||
if (timeSinceLastFailure < FAILURE_BACKOFF_MS) {
|
||||
@@ -161,11 +176,6 @@ export async function handleAtlasSessionIdle(input: {
|
||||
return
|
||||
}
|
||||
|
||||
if (options?.shouldSkipContinuation?.(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
||||
scheduleRetry({ ctx, sessionID, sessionState, options })
|
||||
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
|
||||
|
||||
+525
-100
@@ -10,6 +10,7 @@ import {
|
||||
} from "../../features/boulder-state"
|
||||
import type { BoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import type { PendingTaskRef } from "./types"
|
||||
|
||||
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
|
||||
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
|
||||
@@ -33,25 +34,40 @@ mock.module("../../shared/opencode-storage-detection", () => ({
|
||||
}))
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
const { createToolExecuteAfterHandler } = await import("./tool-execute-after")
|
||||
const { createToolExecuteBeforeHandler } = await import("./tool-execute-before")
|
||||
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
|
||||
|
||||
describe("atlas hook", () => {
|
||||
let TEST_DIR: string
|
||||
let SISYPHUS_DIR: string
|
||||
|
||||
function createMockPluginInput(overrides?: { promptMock?: ReturnType<typeof mock> }) {
|
||||
function createMockPluginInput(overrides?: {
|
||||
promptMock?: ReturnType<typeof mock>
|
||||
sessionGetMock?: ReturnType<typeof mock>
|
||||
}) {
|
||||
const promptMock = overrides?.promptMock ?? mock(() => Promise.resolve())
|
||||
const sessionGetMock = overrides?.sessionGetMock ?? mock(async ({ path }: { path: { id: string } }) => ({
|
||||
data: {
|
||||
id: path.id,
|
||||
parentID: path.id.startsWith("ses_") ? "session-1" : "main-session-123",
|
||||
},
|
||||
}))
|
||||
return {
|
||||
directory: TEST_DIR,
|
||||
client: {
|
||||
session: {
|
||||
get: async () => ({ data: { parentID: "main-session-123" } }),
|
||||
get: sessionGetMock,
|
||||
prompt: promptMock,
|
||||
promptAsync: promptMock,
|
||||
},
|
||||
},
|
||||
_promptMock: promptMock,
|
||||
} as unknown as Parameters<typeof createAtlasHook>[0] & { _promptMock: ReturnType<typeof mock> }
|
||||
_sessionGetMock: sessionGetMock,
|
||||
} as unknown as Parameters<typeof createAtlasHook>[0] & {
|
||||
_promptMock: ReturnType<typeof mock>
|
||||
_sessionGetMock: ReturnType<typeof mock>
|
||||
}
|
||||
}
|
||||
|
||||
function setupMessageStorage(sessionID: string, agent: string): void {
|
||||
@@ -404,12 +420,417 @@ describe("atlas hook", () => {
|
||||
|
||||
// then - should include verification instructions
|
||||
expect(output.output).toContain("LYING")
|
||||
expect(output.output).toContain("PHASE 1")
|
||||
expect(output.output).toContain("PHASE 2")
|
||||
expect(output.output).toContain("PHASE 1")
|
||||
expect(output.output).toContain("PHASE 2")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
test("should clean pending task refs when a task returns background launch output", async () => {
|
||||
// given - direct handlers with shared pending maps
|
||||
const sessionID = "session-bg-launch-cleanup-test"
|
||||
setupMessageStorage(sessionID, "atlas")
|
||||
|
||||
const planPath = join(TEST_DIR, "background-cleanup-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
- [ ] 1. Implement auth flow
|
||||
`)
|
||||
writeBoulderState(TEST_DIR, {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "background-cleanup-plan",
|
||||
})
|
||||
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
const pendingTaskRefs = new Map<string, PendingTaskRef>()
|
||||
const beforeHandler = createToolExecuteBeforeHandler({
|
||||
ctx: createMockPluginInput(),
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
})
|
||||
const afterHandler = createToolExecuteAfterHandler({
|
||||
ctx: createMockPluginInput(),
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
autoCommit: true,
|
||||
getState: () => ({ promptFailureCount: 0 }),
|
||||
})
|
||||
|
||||
// when - the task is captured before execution
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID, callID: "call-bg-launch" },
|
||||
{ args: { prompt: "Implement auth flow" } }
|
||||
)
|
||||
expect(pendingTaskRefs.size).toBe(1)
|
||||
|
||||
// and the task returns a background launch result
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID, callID: "call-bg-launch" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Background task launched.\n\nSession ID: ses_bg_12345",
|
||||
metadata: {},
|
||||
}
|
||||
)
|
||||
|
||||
// then - the pending task ref is still cleaned up
|
||||
expect(pendingTaskRefs.size).toBe(0)
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
test("should persist preferred subagent session for the current top-level task", async () => {
|
||||
// given - boulder state with a current top-level task, Atlas caller
|
||||
const sessionID = "session-task-session-track-test"
|
||||
setupMessageStorage(sessionID, "atlas")
|
||||
|
||||
const planPath = join(TEST_DIR, "task-session-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
- [ ] 1. Implement auth flow
|
||||
- [ ] nested acceptance checkbox
|
||||
`)
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "task-session-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: `Task completed successfully
|
||||
|
||||
<task_metadata>
|
||||
session_id: ses_auth_flow_123
|
||||
</task_metadata>`,
|
||||
metadata: {
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "task", sessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// then
|
||||
const updatedState = readBoulderState(TEST_DIR)
|
||||
expect(updatedState?.task_sessions?.["todo:1"]?.session_id).toBe("ses_auth_flow_123")
|
||||
expect(updatedState?.task_sessions?.["todo:1"]?.task_title).toBe("Implement auth flow")
|
||||
expect(updatedState?.task_sessions?.["todo:1"]?.agent).toBe("sisyphus-junior")
|
||||
expect(updatedState?.task_sessions?.["todo:1"]?.category).toBe("deep")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
test("should preserve the delegated task key even after the plan advances to the next task", async () => {
|
||||
// given - Atlas caller starts task 1, then the plan advances before task output is processed
|
||||
const sessionID = "session-stable-task-key-test"
|
||||
setupMessageStorage(sessionID, "atlas")
|
||||
|
||||
const planPath = join(TEST_DIR, "stable-task-key-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
- [ ] 1. Implement auth flow
|
||||
- [ ] 2. Add API validation
|
||||
`)
|
||||
|
||||
writeBoulderState(TEST_DIR, {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "stable-task-key-plan",
|
||||
})
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
|
||||
// when - Atlas delegates task 1
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "task", sessionID, callID: "call-task-1" },
|
||||
{ args: { prompt: "Implement auth flow" } }
|
||||
)
|
||||
|
||||
// and the plan is advanced before the task output is processed
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Implement auth flow
|
||||
- [ ] 2. Add API validation
|
||||
`)
|
||||
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "task", sessionID, callID: "call-task-1" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: `Task completed successfully
|
||||
|
||||
<task_metadata>
|
||||
session_id: ses_auth_flow_123
|
||||
</task_metadata>`,
|
||||
metadata: {
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// then - the completed task session is still recorded against task 1, not task 2
|
||||
const updatedState = readBoulderState(TEST_DIR)
|
||||
expect(updatedState?.task_sessions?.["todo:1"]?.session_id).toBe("ses_auth_flow_123")
|
||||
expect(updatedState?.task_sessions?.["todo:2"]).toBeUndefined()
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
test("should not overwrite the current task mapping when task() explicitly resumes an older session", async () => {
|
||||
// given - current plan is on task 2, but Atlas explicitly resumes an older session for a previous task
|
||||
const sessionID = "session-cross-task-resume-test"
|
||||
setupMessageStorage(sessionID, "atlas")
|
||||
|
||||
const planPath = join(TEST_DIR, "cross-task-resume-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Implement auth flow
|
||||
- [ ] 2. Add API validation
|
||||
`)
|
||||
|
||||
writeBoulderState(TEST_DIR, {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "cross-task-resume-plan",
|
||||
})
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
|
||||
// when - Atlas resumes an explicit prior session
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "task", sessionID, callID: "call-resume-old-task" },
|
||||
{ args: { prompt: "Follow up on previous task", session_id: "ses_old_task_111" } }
|
||||
)
|
||||
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: `Task continued successfully
|
||||
|
||||
<task_metadata>
|
||||
session_id: ses_old_task_111
|
||||
</task_metadata>`,
|
||||
metadata: {
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
}
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "task", sessionID, callID: "call-resume-old-task" },
|
||||
output
|
||||
)
|
||||
|
||||
// then - Atlas does not poison task 2's preferred session mapping
|
||||
const updatedState = readBoulderState(TEST_DIR)
|
||||
expect(updatedState?.task_sessions?.["todo:2"]).toBeUndefined()
|
||||
expect(output.output).not.toContain('task(session_id="ses_old_task_111"')
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
test("should not reuse an explicitly resumed session id in completion reminders", async () => {
|
||||
// given - current plan is on task 2 with an existing tracked session
|
||||
const sessionID = "session-explicit-resume-reminder-test"
|
||||
setupMessageStorage(sessionID, "atlas")
|
||||
|
||||
const planPath = join(TEST_DIR, "explicit-resume-reminder-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
- [x] 1. Implement auth flow
|
||||
- [ ] 2. Add API validation
|
||||
`)
|
||||
|
||||
writeBoulderState(TEST_DIR, {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "explicit-resume-reminder-plan",
|
||||
task_sessions: {
|
||||
"todo:2": {
|
||||
task_key: "todo:2",
|
||||
task_label: "2",
|
||||
task_title: "Add API validation",
|
||||
session_id: "ses_tracked_current_task",
|
||||
updated_at: "2026-01-02T10:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: `Task continued successfully
|
||||
|
||||
<task_metadata>
|
||||
session_id: ses_old_task_111
|
||||
</task_metadata>`,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "task", sessionID, callID: "call-explicit-resume-reminder" },
|
||||
{ args: { prompt: "Follow up on previous task", session_id: "ses_old_task_111" } }
|
||||
)
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "task", sessionID, callID: "call-explicit-resume-reminder" },
|
||||
output
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.output).not.toContain('task(session_id="ses_old_task_111"')
|
||||
expect(output.output).toContain("ses_tracked_current_task")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
test("should skip persistence when multiple in-flight task calls claim the same top-level task", async () => {
|
||||
// given
|
||||
const sessionID = "session-parallel-task-collision-test"
|
||||
setupMessageStorage(sessionID, "atlas")
|
||||
|
||||
const planPath = join(TEST_DIR, "parallel-task-collision-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
- [ ] 1. Implement auth flow
|
||||
- [ ] 2. Add API validation
|
||||
`)
|
||||
|
||||
writeBoulderState(TEST_DIR, {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "parallel-task-collision-plan",
|
||||
})
|
||||
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
const pendingTaskRefs = new Map<string, PendingTaskRef>()
|
||||
const beforeHandler = createToolExecuteBeforeHandler({
|
||||
ctx: createMockPluginInput(),
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
})
|
||||
const afterHandler = createToolExecuteAfterHandler({
|
||||
ctx: createMockPluginInput(),
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
autoCommit: true,
|
||||
getState: () => ({ promptFailureCount: 0 }),
|
||||
})
|
||||
|
||||
// when - two task() calls start before either one completes
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID, callID: "call-task-first" },
|
||||
{ args: { prompt: "Implement auth flow part 1" } }
|
||||
)
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID, callID: "call-task-second" },
|
||||
{ args: { prompt: "Implement auth flow part 2" } }
|
||||
)
|
||||
|
||||
const secondPendingTaskRef = pendingTaskRefs.get("call-task-second")
|
||||
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID, callID: "call-task-second" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: `Task completed successfully
|
||||
|
||||
<task_metadata>
|
||||
session_id: ses_parallel_collision_222
|
||||
</task_metadata>`,
|
||||
metadata: {},
|
||||
}
|
||||
)
|
||||
|
||||
// then
|
||||
expect(secondPendingTaskRef).toEqual({
|
||||
kind: "skip",
|
||||
reason: "ambiguous_task_key",
|
||||
task: {
|
||||
key: "todo:1",
|
||||
label: "1",
|
||||
title: "Implement auth flow",
|
||||
},
|
||||
})
|
||||
const updatedState = readBoulderState(TEST_DIR)
|
||||
expect(updatedState?.task_sessions?.["todo:1"]).toBeUndefined()
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
test("should ignore extracted session ids that are outside the active boulder lineage", async () => {
|
||||
// given
|
||||
const sessionID = "session-untrusted-session-id-test"
|
||||
setupMessageStorage(sessionID, "atlas")
|
||||
|
||||
const planPath = join(TEST_DIR, "untrusted-session-id-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
- [ ] 1. Implement auth flow
|
||||
`)
|
||||
|
||||
writeBoulderState(TEST_DIR, {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "untrusted-session-id-plan",
|
||||
})
|
||||
|
||||
const hook = createAtlasHook(createMockPluginInput({
|
||||
sessionGetMock: mock(async ({ path }: { path: { id: string } }) => ({
|
||||
data: {
|
||||
id: path.id,
|
||||
parentID: path.id === "ses_untrusted_999" ? "session-outside-lineage" : "main-session-123",
|
||||
},
|
||||
})),
|
||||
}))
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: `Task completed successfully
|
||||
|
||||
<task_metadata>
|
||||
session_id: ses_untrusted_999
|
||||
</task_metadata>`,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "task", sessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// then
|
||||
const updatedState = readBoulderState(TEST_DIR)
|
||||
expect(updatedState?.task_sessions?.["todo:1"]).toBeUndefined()
|
||||
expect(output.output).not.toContain('task(session_id="ses_untrusted_999"')
|
||||
expect(output.output).toContain('task(session_id="<session_id>"')
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
describe("completion gate output ordering", () => {
|
||||
const COMPLETION_GATE_SESSION = "completion-gate-order-test"
|
||||
|
||||
@@ -1043,37 +1464,6 @@ describe("atlas hook", () => {
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should skip when another continuation hook already injected", async () => {
|
||||
// given - boulder state with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput, {
|
||||
directory: TEST_DIR,
|
||||
shouldSkipContinuation: (sessionID: string) => sessionID === MAIN_SESSION_ID,
|
||||
})
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then - should not call prompt because another continuation already handled it
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should clear abort state on message.updated", async () => {
|
||||
// given - boulder with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
@@ -1147,6 +1537,48 @@ describe("atlas hook", () => {
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
test("should include preferred reuse session in continuation prompt for current top-level task", async () => {
|
||||
// given - boulder state with tracked preferred session
|
||||
const planPath = join(TEST_DIR, "preferred-session-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
|
||||
## TODOs
|
||||
- [ ] 1. Implement auth flow
|
||||
`)
|
||||
|
||||
writeBoulderState(TEST_DIR, {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "preferred-session-plan",
|
||||
task_sessions: {
|
||||
"todo:1": {
|
||||
task_key: "todo:1",
|
||||
task_label: "1",
|
||||
task_title: "Implement auth flow",
|
||||
session_id: "ses_auth_flow_123",
|
||||
updated_at: "2026-01-02T10:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.body.parts[0].text).toContain("Preferred reuse session for current top-level plan task")
|
||||
expect(callArgs.body.parts[0].text).toContain("ses_auth_flow_123")
|
||||
})
|
||||
|
||||
test("should inject when last agent is sisyphus and boulder targets atlas explicitly", async () => {
|
||||
// given - boulder explicitly set to atlas, but last agent is sisyphus (initial state after /start-work)
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
@@ -1283,7 +1715,7 @@ describe("atlas hook", () => {
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test("should stop continuation after 2 consecutive prompt failures (issue #1355)", async () => {
|
||||
test("should stop continuation after 10 consecutive prompt failures (issue #1355)", async () => {
|
||||
//#given - boulder state with incomplete plan and prompt always fails
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
@@ -1296,7 +1728,7 @@ describe("atlas hook", () => {
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
|
||||
const promptMock = mock((): Promise<void> => Promise.reject(new Error("Bad Request")))
|
||||
const mockInput = createMockPluginInput({ promptMock })
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
@@ -1306,25 +1738,23 @@ describe("atlas hook", () => {
|
||||
|
||||
try {
|
||||
//#when - idle fires repeatedly, past cooldown each time
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
}
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
|
||||
//#then - should attempt only twice, then disable continuation
|
||||
expect(promptMock).toHaveBeenCalledTimes(2)
|
||||
//#then - should attempt only 10 times, then disable continuation
|
||||
expect(promptMock).toHaveBeenCalledTimes(10)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
})
|
||||
|
||||
test("should reset prompt failure counter on success and only stop after 2 consecutive failures", async () => {
|
||||
test("should reset prompt failure counter on success and only stop after 10 consecutive failures", async () => {
|
||||
//#given - boulder state with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
@@ -1337,11 +1767,9 @@ describe("atlas hook", () => {
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const promptMock = mock(() => Promise.resolve())
|
||||
const promptMock = mock((): Promise<void> => Promise.reject(new Error("Bad Request")))
|
||||
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
|
||||
promptMock.mockImplementationOnce(() => Promise.resolve())
|
||||
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
|
||||
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
|
||||
|
||||
const mockInput = createMockPluginInput({ promptMock })
|
||||
const hook = createAtlasHook(mockInput)
|
||||
@@ -1351,21 +1779,21 @@ describe("atlas hook", () => {
|
||||
Date.now = () => now
|
||||
|
||||
try {
|
||||
//#when - fail, succeed (reset), then fail twice (disable), then attempt again
|
||||
for (let i = 0; i < 5; i++) {
|
||||
//#when - fail, succeed (reset), then fail 10 times (disable), then attempt again
|
||||
for (let i = 0; i < 13; i++) {
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
}
|
||||
|
||||
//#then - 4 prompt attempts; 5th idle is skipped after 2 consecutive failures
|
||||
expect(promptMock).toHaveBeenCalledTimes(4)
|
||||
//#then - 12 prompt attempts; 13th idle is skipped after 10 consecutive failures
|
||||
expect(promptMock).toHaveBeenCalledTimes(12)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
})
|
||||
|
||||
test("should keep skipping continuation during 5-minute backoff after 2 consecutive failures", async () => {
|
||||
test("should keep skipping continuation during 5-minute backoff after 10 consecutive failures", async () => {
|
||||
//#given - boulder state with incomplete plan and prompt always fails
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
@@ -1387,26 +1815,26 @@ describe("atlas hook", () => {
|
||||
Date.now = () => now
|
||||
|
||||
try {
|
||||
//#when - third idle occurs inside 5-minute backoff window
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
//#when - 11th idle occurs inside 5-minute backoff window
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
}
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 60000
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
|
||||
//#then - third attempt should still be skipped
|
||||
expect(promptMock).toHaveBeenCalledTimes(2)
|
||||
//#then - 11th attempt should still be skipped
|
||||
expect(promptMock).toHaveBeenCalledTimes(10)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
})
|
||||
|
||||
test("should retry continuation after 5-minute backoff expires following 2 consecutive failures", async () => {
|
||||
test("should retry continuation after 5-minute backoff expires following 10 consecutive failures", async () => {
|
||||
//#given - boulder state with incomplete plan and prompt always fails
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
@@ -1428,20 +1856,20 @@ describe("atlas hook", () => {
|
||||
Date.now = () => now
|
||||
|
||||
try {
|
||||
//#when - third idle occurs after 5+ minutes
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
//#when - 11th idle occurs after 5+ minutes
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
}
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 300000
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
|
||||
//#then - third attempt should run after backoff expiration
|
||||
expect(promptMock).toHaveBeenCalledTimes(3)
|
||||
//#then - 11th attempt should run after backoff expiration
|
||||
expect(promptMock).toHaveBeenCalledTimes(11)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
@@ -1461,8 +1889,9 @@ describe("atlas hook", () => {
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const promptMock = mock((): Promise<void> => Promise.reject(new Error("Bad Request")))
|
||||
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
|
||||
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
|
||||
}
|
||||
promptMock.mockImplementationOnce(() => Promise.resolve(undefined))
|
||||
const mockInput = createMockPluginInput({ promptMock })
|
||||
const hook = createAtlasHook(mockInput)
|
||||
@@ -1472,32 +1901,30 @@ describe("atlas hook", () => {
|
||||
Date.now = () => now
|
||||
|
||||
try {
|
||||
//#when - fail twice, recover after backoff with success, then fail twice again
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
//#when - fail 10 times, recover after backoff with success, then fail 10 times again
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
}
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 300000
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
}
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
|
||||
//#then - success retry resets counter, so two additional failures are allowed before skip
|
||||
expect(promptMock).toHaveBeenCalledTimes(5)
|
||||
//#then - success retry resets counter, so 10 additional failures are allowed before skip
|
||||
expect(promptMock).toHaveBeenCalledTimes(21)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
@@ -1525,14 +1952,12 @@ describe("atlas hook", () => {
|
||||
Date.now = () => now
|
||||
|
||||
try {
|
||||
//#when - two failures disables continuation, then compaction resets it
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
//#when - 10 failures disable continuation, then compaction resets it
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
}
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
@@ -1543,8 +1968,8 @@ describe("atlas hook", () => {
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
|
||||
//#then - 2 attempts + 1 after compaction (3 total)
|
||||
expect(promptMock).toHaveBeenCalledTimes(3)
|
||||
//#then - 10 attempts + 1 after compaction (11 total)
|
||||
expect(promptMock).toHaveBeenCalledTimes(11)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { extractSessionIdFromOutput } from "./subagent-session-id"
|
||||
|
||||
describe("extractSessionIdFromOutput", () => {
|
||||
test("extracts Session ID blocks from background output", () => {
|
||||
// given
|
||||
const output = `Background task launched.\n\nSession ID: ses_bg_12345`
|
||||
|
||||
// when
|
||||
const result = extractSessionIdFromOutput(output)
|
||||
|
||||
// then
|
||||
expect(result).toBe("ses_bg_12345")
|
||||
})
|
||||
|
||||
test("extracts session_id from task metadata blocks", () => {
|
||||
// given
|
||||
const output = `Task completed.\n\n<task_metadata>\nsession_id: ses_sync_12345\n</task_metadata>`
|
||||
|
||||
// when
|
||||
const result = extractSessionIdFromOutput(output)
|
||||
|
||||
// then
|
||||
expect(result).toBe("ses_sync_12345")
|
||||
})
|
||||
|
||||
test("extracts hyphenated session IDs from task metadata blocks", () => {
|
||||
// given
|
||||
const output = `Task completed.\n\n<task_metadata>\nsession_id: ses_auth-flow-123\n</task_metadata>`
|
||||
|
||||
// when
|
||||
const result = extractSessionIdFromOutput(output)
|
||||
|
||||
// then
|
||||
expect(result).toBe("ses_auth-flow-123")
|
||||
})
|
||||
|
||||
test("returns undefined when no session id is present", () => {
|
||||
// given
|
||||
const output = "Task completed without metadata"
|
||||
|
||||
// when
|
||||
const result = extractSessionIdFromOutput(output)
|
||||
|
||||
// then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("prefers the session id inside the trailing task_metadata block", () => {
|
||||
// given
|
||||
const output = `The previous attempt mentioned session_id: ses_wrong_body_123 but that was only context.
|
||||
|
||||
<task_metadata>
|
||||
session_id: ses_real_metadata_456
|
||||
</task_metadata>`
|
||||
|
||||
// when
|
||||
const result = extractSessionIdFromOutput(output)
|
||||
|
||||
// then
|
||||
expect(result).toBe("ses_real_metadata_456")
|
||||
})
|
||||
|
||||
test("does not let task_metadata parsing bleed into incidental body text after the closing tag", () => {
|
||||
// given
|
||||
const output = `<task_metadata>
|
||||
session_id: ses_real_metadata_456
|
||||
</task_metadata>
|
||||
|
||||
debug log: session_id: ses_wrong_body_789`
|
||||
|
||||
// when
|
||||
const result = extractSessionIdFromOutput(output)
|
||||
|
||||
// then
|
||||
expect(result).toBe("ses_real_metadata_456")
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,44 @@
|
||||
export function extractSessionIdFromOutput(output: string): string {
|
||||
const match = output.match(/Session ID:\s*(ses_[a-zA-Z0-9]+)/)
|
||||
return match?.[1] ?? "<session_id>"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
|
||||
export function extractSessionIdFromOutput(output: string): string | undefined {
|
||||
const taskMetadataBlocks = [...output.matchAll(/<task_metadata>([\s\S]*?)<\/task_metadata>/gi)]
|
||||
const lastTaskMetadataBlock = taskMetadataBlocks.at(-1)?.[1]
|
||||
if (lastTaskMetadataBlock) {
|
||||
const taskMetadataSessionMatch = lastTaskMetadataBlock.match(/session_id:\s*(ses_[a-zA-Z0-9_-]+)/i)
|
||||
if (taskMetadataSessionMatch) {
|
||||
return taskMetadataSessionMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
const explicitSessionMatches = [...output.matchAll(/Session ID:\s*(ses_[a-zA-Z0-9_-]+)/g)]
|
||||
return explicitSessionMatches.at(-1)?.[1]
|
||||
}
|
||||
|
||||
export async function validateSubagentSessionId(input: {
|
||||
client: PluginInput["client"]
|
||||
sessionID?: string
|
||||
lineageSessionIDs: string[]
|
||||
}): Promise<string | undefined> {
|
||||
if (!input.sessionID || input.lineageSessionIDs.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const belongsToLineage = await isSessionInBoulderLineage({
|
||||
client: input.client,
|
||||
sessionID: input.sessionID,
|
||||
boulderSessionIDs: input.lineageSessionIDs,
|
||||
})
|
||||
|
||||
if (!belongsToLineage) {
|
||||
log(`[${HOOK_NAME}] Ignoring extracted session id outside active lineage`, {
|
||||
sessionID: input.sessionID,
|
||||
lineageSessionIDs: input.lineageSessionIDs,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
return input.sessionID
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { appendSessionId, getPlanProgress, readBoulderState } from "../../features/boulder-state"
|
||||
import {
|
||||
appendSessionId,
|
||||
getPlanProgress,
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
upsertTaskSessionState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { isCallerOrchestrator } from "../../shared/session-utils"
|
||||
import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktree"
|
||||
@@ -7,7 +14,7 @@ import { shouldPauseForFinalWaveApproval } from "./final-wave-approval-gate"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { DIRECT_WORK_REMINDER } from "./system-reminder-templates"
|
||||
import { isSisyphusPath } from "./sisyphus-path"
|
||||
import { extractSessionIdFromOutput } from "./subagent-session-id"
|
||||
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
|
||||
import {
|
||||
buildCompletionGate,
|
||||
buildFinalWaveApprovalReminder,
|
||||
@@ -15,16 +22,60 @@ import {
|
||||
buildStandaloneVerificationReminder,
|
||||
} from "./verification-reminders"
|
||||
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
|
||||
import type { SessionState } from "./types"
|
||||
import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types"
|
||||
import type { PendingTaskRef, SessionState } from "./types"
|
||||
import type { ToolExecuteAfterInput, ToolExecuteAfterOutput, TrackedTopLevelTaskRef } from "./types"
|
||||
|
||||
function resolvePreferredSessionId(currentSessionId?: string, trackedSessionId?: string): string {
|
||||
return currentSessionId ?? trackedSessionId ?? "<session_id>"
|
||||
}
|
||||
|
||||
function resolveTaskContext(
|
||||
pendingTaskRef: PendingTaskRef | undefined,
|
||||
planPath: string,
|
||||
): {
|
||||
currentTask: TrackedTopLevelTaskRef | null
|
||||
shouldSkipTaskSessionUpdate: boolean
|
||||
shouldIgnoreCurrentSessionId: boolean
|
||||
} {
|
||||
if (!pendingTaskRef) {
|
||||
return {
|
||||
currentTask: readCurrentTopLevelTask(planPath),
|
||||
shouldSkipTaskSessionUpdate: false,
|
||||
shouldIgnoreCurrentSessionId: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingTaskRef.kind === "track") {
|
||||
return {
|
||||
currentTask: pendingTaskRef.task,
|
||||
shouldSkipTaskSessionUpdate: false,
|
||||
shouldIgnoreCurrentSessionId: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingTaskRef.reason === "explicit_resume") {
|
||||
return {
|
||||
currentTask: readCurrentTopLevelTask(planPath),
|
||||
shouldSkipTaskSessionUpdate: true,
|
||||
shouldIgnoreCurrentSessionId: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentTask: pendingTaskRef.task,
|
||||
shouldSkipTaskSessionUpdate: true,
|
||||
shouldIgnoreCurrentSessionId: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function createToolExecuteAfterHandler(input: {
|
||||
ctx: PluginInput
|
||||
pendingFilePaths: Map<string, string>
|
||||
pendingTaskRefs: Map<string, PendingTaskRef>
|
||||
autoCommit: boolean
|
||||
getState: (sessionID: string) => SessionState
|
||||
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise<void> {
|
||||
const { ctx, pendingFilePaths, autoCommit, getState } = input
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
|
||||
return async (toolInput, toolOutput): Promise<void> => {
|
||||
// Guard against undefined output (e.g., from /review command - see issue #1035)
|
||||
if (!toolOutput) {
|
||||
@@ -59,6 +110,10 @@ export function createToolExecuteAfterHandler(input: {
|
||||
}
|
||||
|
||||
const outputStr = toolOutput.output && typeof toolOutput.output === "string" ? toolOutput.output : ""
|
||||
const pendingTaskRef = toolInput.callID ? pendingTaskRefs.get(toolInput.callID) : undefined
|
||||
if (toolInput.callID) {
|
||||
pendingTaskRefs.delete(toolInput.callID)
|
||||
}
|
||||
const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task continued")
|
||||
if (isBackgroundLaunch) {
|
||||
return
|
||||
@@ -67,11 +122,19 @@ export function createToolExecuteAfterHandler(input: {
|
||||
if (toolOutput.output && typeof toolOutput.output === "string") {
|
||||
const gitStats = collectGitDiffStats(ctx.directory)
|
||||
const fileChanges = formatFileChanges(gitStats)
|
||||
const subagentSessionId = extractSessionIdFromOutput(toolOutput.output)
|
||||
const extractedSessionId = extractSessionIdFromOutput(toolOutput.output)
|
||||
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
if (boulderState) {
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
const {
|
||||
currentTask,
|
||||
shouldSkipTaskSessionUpdate,
|
||||
shouldIgnoreCurrentSessionId,
|
||||
} = resolveTaskContext(pendingTaskRef, boulderState.active_plan)
|
||||
const trackedTaskSession = currentTask
|
||||
? getTaskSessionState(ctx.directory, currentTask.key)
|
||||
: null
|
||||
const sessionState = toolInput.sessionID ? getState(toolInput.sessionID) : undefined
|
||||
|
||||
if (toolInput.sessionID && !boulderState.session_ids?.includes(toolInput.sessionID)) {
|
||||
@@ -82,6 +145,31 @@ export function createToolExecuteAfterHandler(input: {
|
||||
})
|
||||
}
|
||||
|
||||
const lineageSessionIDs = toolInput.sessionID && !boulderState.session_ids.includes(toolInput.sessionID)
|
||||
? [...boulderState.session_ids, toolInput.sessionID]
|
||||
: boulderState.session_ids
|
||||
const subagentSessionId = await validateSubagentSessionId({
|
||||
client: ctx.client,
|
||||
sessionID: extractedSessionId,
|
||||
lineageSessionIDs,
|
||||
})
|
||||
|
||||
if (currentTask && subagentSessionId && !shouldSkipTaskSessionUpdate) {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: subagentSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const preferredSessionId = resolvePreferredSessionId(
|
||||
shouldIgnoreCurrentSessionId ? undefined : subagentSessionId,
|
||||
trackedTaskSession?.session_id,
|
||||
)
|
||||
|
||||
// Preserve original subagent response - critical for debugging failed tasks
|
||||
const originalResponse = toolOutput.output
|
||||
const shouldPauseForApproval = sessionState
|
||||
@@ -102,11 +190,11 @@ export function createToolExecuteAfterHandler(input: {
|
||||
}
|
||||
|
||||
const leadReminder = shouldPauseForApproval
|
||||
? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, subagentSessionId)
|
||||
: buildCompletionGate(boulderState.plan_name, subagentSessionId)
|
||||
? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, preferredSessionId)
|
||||
: buildCompletionGate(boulderState.plan_name, preferredSessionId)
|
||||
const followupReminder = shouldPauseForApproval
|
||||
? null
|
||||
: buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId, autoCommit, false)
|
||||
: buildOrchestratorReminder(boulderState.plan_name, progress, preferredSessionId, autoCommit, false)
|
||||
|
||||
toolOutput.output = `
|
||||
<system-reminder>
|
||||
@@ -132,10 +220,22 @@ ${
|
||||
plan: boulderState.plan_name,
|
||||
progress: `${progress.completed}/${progress.total}`,
|
||||
fileCount: gitStats.length,
|
||||
preferredSessionId,
|
||||
waitingForFinalWaveApproval: shouldPauseForApproval,
|
||||
})
|
||||
} else {
|
||||
toolOutput.output += `\n<system-reminder>\n${buildStandaloneVerificationReminder(subagentSessionId)}\n</system-reminder>`
|
||||
const lineageSessionIDs = toolInput.sessionID ? [toolInput.sessionID] : []
|
||||
const subagentSessionId = await validateSubagentSessionId({
|
||||
client: ctx.client,
|
||||
sessionID: extractedSessionId,
|
||||
lineageSessionIDs,
|
||||
})
|
||||
const preferredSessionId = pendingTaskRef?.kind === "skip"
|
||||
? undefined
|
||||
: subagentSessionId
|
||||
toolOutput.output += `\n<system-reminder>\n${buildStandaloneVerificationReminder(
|
||||
resolvePreferredSessionId(preferredSessionId),
|
||||
)}\n</system-reminder>`
|
||||
|
||||
log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
|
||||
@@ -2,19 +2,26 @@ import { log } from "../../shared/logger"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||
import { isCallerOrchestrator } from "../../shared/session-utils"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { readBoulderState, readCurrentTopLevelTask } from "../../features/boulder-state"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
|
||||
import { isSisyphusPath } from "./sisyphus-path"
|
||||
import type { PendingTaskRef, TrackedTopLevelTaskRef } from "./types"
|
||||
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
|
||||
|
||||
export function createToolExecuteBeforeHandler(input: {
|
||||
ctx: PluginInput
|
||||
pendingFilePaths: Map<string, string>
|
||||
pendingTaskRefs: Map<string, PendingTaskRef>
|
||||
}): (
|
||||
toolInput: { tool: string; sessionID?: string; callID?: string },
|
||||
toolOutput: { args: Record<string, unknown>; message?: string }
|
||||
) => Promise<void> {
|
||||
const { ctx, pendingFilePaths } = input
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs } = input
|
||||
|
||||
function trackTask(callID: string, task: TrackedTopLevelTaskRef): void {
|
||||
pendingTaskRefs.set(callID, { kind: "track", task })
|
||||
}
|
||||
|
||||
return async (toolInput, toolOutput): Promise<void> => {
|
||||
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
|
||||
@@ -43,6 +50,46 @@ export function createToolExecuteBeforeHandler(input: {
|
||||
|
||||
// Check task - inject single-task directive
|
||||
if (toolInput.tool === "task") {
|
||||
if (toolInput.callID) {
|
||||
const requestedSessionId = toolOutput.args.session_id as string | undefined
|
||||
if (requestedSessionId) {
|
||||
pendingTaskRefs.set(toolInput.callID, {
|
||||
kind: "skip",
|
||||
reason: "explicit_resume",
|
||||
})
|
||||
} else {
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
const currentTask = boulderState
|
||||
? readCurrentTopLevelTask(boulderState.active_plan)
|
||||
: null
|
||||
if (currentTask) {
|
||||
const task = {
|
||||
key: currentTask.key,
|
||||
label: currentTask.label,
|
||||
title: currentTask.title,
|
||||
}
|
||||
const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => (
|
||||
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === task.key
|
||||
))
|
||||
|
||||
if (hasExistingClaim) {
|
||||
pendingTaskRefs.set(toolInput.callID, {
|
||||
kind: "skip",
|
||||
reason: "ambiguous_task_key",
|
||||
task,
|
||||
})
|
||||
log(`[${HOOK_NAME}] Skipping task session persistence for ambiguous task key`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
callID: toolInput.callID,
|
||||
taskKey: task.key,
|
||||
})
|
||||
} else {
|
||||
trackTask(toolInput.callID, task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = toolOutput.args.prompt as string | undefined
|
||||
if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) {
|
||||
toolOutput.args.prompt = `<system-reminder>${SINGLE_TASK_DIRECTIVE}</system-reminder>\n` + prompt
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentOverrides } from "../../config"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { TopLevelTaskRef } from "../../features/boulder-state"
|
||||
|
||||
export type ModelInfo = { providerID: string; modelID: string }
|
||||
|
||||
@@ -7,7 +8,6 @@ export interface AtlasHookOptions {
|
||||
directory: string
|
||||
backgroundManager?: BackgroundManager
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
shouldSkipContinuation?: (sessionID: string) => boolean
|
||||
agentOverrides?: AgentOverrides
|
||||
/** Enable auto-commit after each atomic task completion (default: true) */
|
||||
autoCommit?: boolean
|
||||
@@ -25,6 +25,13 @@ export interface ToolExecuteAfterOutput {
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type TrackedTopLevelTaskRef = Pick<TopLevelTaskRef, "key" | "label" | "title">
|
||||
|
||||
export type PendingTaskRef =
|
||||
| { kind: "track"; task: TrackedTopLevelTaskRef }
|
||||
| { kind: "skip"; reason: "explicit_resume" }
|
||||
| { kind: "skip"; reason: "ambiguous_task_key"; task: TrackedTopLevelTaskRef }
|
||||
|
||||
export interface SessionState {
|
||||
lastEventWasAbortError?: boolean
|
||||
lastContinuationInjectedAt?: number
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { VERIFICATION_REMINDER } from "./system-reminder-templates"
|
||||
|
||||
function buildReuseHint(sessionId: string): string {
|
||||
return `
|
||||
**PREFERRED REUSE SESSION FOR THE CURRENT TOP-LEVEL PLAN TASK**
|
||||
|
||||
- Reuse \`${sessionId}\` first if verification fails or the result needs follow-up.
|
||||
- Start a fresh subagent session only when reuse is unavailable or would cross task boundaries.
|
||||
`
|
||||
}
|
||||
|
||||
export function buildCompletionGate(planName: string, sessionId: string): string {
|
||||
return `
|
||||
**COMPLETION GATE — DO NOT PROCEED UNTIL THIS IS DONE**
|
||||
@@ -25,7 +34,8 @@ task(session_id="${sessionId}", prompt="fix: checkbox not recorded correctly")
|
||||
|
||||
**Your completion is NOT tracked until the checkbox is marked in the plan file.**
|
||||
|
||||
**VERIFICATION_REMINDER**`
|
||||
**VERIFICATION_REMINDER**
|
||||
${buildReuseHint(sessionId)}`
|
||||
}
|
||||
|
||||
function buildVerificationReminder(sessionId: string): string {
|
||||
@@ -38,7 +48,9 @@ ${VERIFICATION_REMINDER}
|
||||
**If ANY verification fails, use this immediately:**
|
||||
\`\`\`
|
||||
task(session_id="${sessionId}", prompt="fix: [describe the specific failure]")
|
||||
\`\`\``
|
||||
\`\`\`
|
||||
|
||||
${buildReuseHint(sessionId)}`
|
||||
}
|
||||
|
||||
export function buildOrchestratorReminder(
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
type TextPart = {
|
||||
type?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
type MessageInfo = {
|
||||
id?: string
|
||||
role?: string
|
||||
error?: unknown
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
export type SessionMessage = {
|
||||
info?: MessageInfo
|
||||
parts?: TextPart[]
|
||||
}
|
||||
|
||||
export function getLastAssistantMessage(messages: SessionMessage[]): SessionMessage | null {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
if (messages[index].info?.role === "assistant") {
|
||||
return messages[index]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractAssistantText(message: SessionMessage): string {
|
||||
return (message.parts ?? [])
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export function isGptAssistantMessage(message: SessionMessage): boolean {
|
||||
const modelID = message.info?.model?.modelID ?? message.info?.modelID
|
||||
return typeof modelID === "string" && modelID.toLowerCase().includes("gpt")
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export const HOOK_NAME = "gpt-permission-continuation"
|
||||
export const CONTINUATION_PROMPT = "continue"
|
||||
export const MAX_CONSECUTIVE_AUTO_CONTINUES = 3
|
||||
|
||||
export const DEFAULT_STALL_PATTERNS = [
|
||||
"if you want",
|
||||
"would you like",
|
||||
"shall i",
|
||||
"do you want me to",
|
||||
"let me know if",
|
||||
] as const
|
||||
@@ -1,23 +0,0 @@
|
||||
import { DEFAULT_STALL_PATTERNS } from "./constants"
|
||||
|
||||
function getTrailingSegment(text: string): string {
|
||||
const normalized = text.trim().replace(/\s+/g, " ")
|
||||
if (!normalized) return ""
|
||||
|
||||
const sentenceParts = normalized.split(/(?<=[.!?])\s+/)
|
||||
return sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? ""
|
||||
}
|
||||
|
||||
export function detectStallPattern(
|
||||
text: string,
|
||||
patterns: readonly string[] = DEFAULT_STALL_PATTERNS,
|
||||
): boolean {
|
||||
if (!text.trim()) return false
|
||||
|
||||
const tail = text.slice(-800)
|
||||
const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean)
|
||||
const hotZone = lines.slice(-3).join(" ")
|
||||
const trailingSegment = getTrailingSegment(hotZone)
|
||||
|
||||
return patterns.some((pattern) => trailingSegment.startsWith(pattern.toLowerCase()))
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { describe, expect, it as test } from "bun:test"
|
||||
|
||||
import { createGptPermissionContinuationHook } from "."
|
||||
|
||||
type SessionMessage = {
|
||||
info: {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
modelID?: string
|
||||
}
|
||||
parts?: Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
type GptPermissionContext = Parameters<typeof createGptPermissionContinuationHook>[0]
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function extractPromptText(input: unknown): string {
|
||||
if (!isRecord(input)) return ""
|
||||
|
||||
const body = input.body
|
||||
if (!isRecord(body)) return ""
|
||||
|
||||
const parts = body.parts
|
||||
if (!Array.isArray(parts)) return ""
|
||||
|
||||
const firstPart = parts[0]
|
||||
if (!isRecord(firstPart)) return ""
|
||||
|
||||
return typeof firstPart.text === "string" ? firstPart.text : ""
|
||||
}
|
||||
|
||||
function createMockPluginInput(messages: SessionMessage[]): {
|
||||
ctx: GptPermissionContext
|
||||
promptCalls: string[]
|
||||
} {
|
||||
const promptCalls: string[] = []
|
||||
const client = createOpencodeClient({ directory: "/tmp/test" })
|
||||
const shell = Object.assign(
|
||||
() => {
|
||||
throw new Error("$ is not used in this test")
|
||||
},
|
||||
{
|
||||
braces: () => [],
|
||||
escape: (input: string) => input,
|
||||
env() {
|
||||
return shell
|
||||
},
|
||||
cwd() {
|
||||
return shell
|
||||
},
|
||||
nothrow() {
|
||||
return shell
|
||||
},
|
||||
throws() {
|
||||
return shell
|
||||
},
|
||||
},
|
||||
)
|
||||
const request = new Request("http://localhost")
|
||||
const response = new Response()
|
||||
|
||||
Reflect.set(client.session, "messages", async () => ({ data: messages, error: undefined, request, response }))
|
||||
Reflect.set(client.session, "prompt", async (input: unknown) => {
|
||||
promptCalls.push(extractPromptText(input))
|
||||
return { data: undefined, error: undefined, request, response }
|
||||
})
|
||||
Reflect.set(client.session, "promptAsync", async (input: unknown) => {
|
||||
promptCalls.push(extractPromptText(input))
|
||||
return { data: undefined, error: undefined, request, response }
|
||||
})
|
||||
|
||||
const ctx: GptPermissionContext = {
|
||||
client,
|
||||
project: {
|
||||
id: "test-project",
|
||||
worktree: "/tmp/test",
|
||||
time: { created: Date.now() },
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
worktree: "/tmp/test",
|
||||
serverUrl: new URL("http://localhost"),
|
||||
$: shell,
|
||||
}
|
||||
|
||||
return { ctx, promptCalls }
|
||||
}
|
||||
|
||||
function createAssistantMessage(id: string, text: string): SessionMessage {
|
||||
return {
|
||||
info: { id, role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text }],
|
||||
}
|
||||
}
|
||||
|
||||
function createUserMessage(id: string, text: string): SessionMessage {
|
||||
return {
|
||||
info: { id, role: "user" },
|
||||
parts: [{ type: "text", text }],
|
||||
}
|
||||
}
|
||||
|
||||
describe("gpt-permission-continuation", () => {
|
||||
test("injects continue when the last GPT assistant reply asks for permission", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "I finished the analysis. If you want, I can apply the changes next." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual(["continue"])
|
||||
})
|
||||
|
||||
test("does not inject when the last assistant model is not GPT", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: {
|
||||
id: "msg-1",
|
||||
role: "assistant",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
},
|
||||
parts: [{ type: "text", text: "If you want, I can keep going." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not inject when the last assistant reply is not a stall pattern", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "I completed the refactor and all tests pass." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not inject when a permission phrase appears before the final sentence", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "If you want, I can keep going. The current work is complete." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not inject when continuation is stopped for the session", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "If you want, I can continue with the fix." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx, {
|
||||
isContinuationStopped: (sessionID) => sessionID === "ses-1",
|
||||
})
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not inject twice for the same assistant message", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "Would you like me to continue with the fix?" }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual(["continue"])
|
||||
})
|
||||
|
||||
describe("#given repeated GPT permission tails in the same session", () => {
|
||||
describe("#when the permission phrases keep changing", () => {
|
||||
test("stops injecting after three consecutive auto-continues", async () => {
|
||||
// given
|
||||
const messages: SessionMessage[] = [
|
||||
createUserMessage("msg-0", "Please continue the fix."),
|
||||
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
|
||||
]
|
||||
const { ctx, promptCalls } = createMockPluginInput(messages)
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
messages.push(createUserMessage("msg-2", "continue"))
|
||||
messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?"))
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
messages.push(createUserMessage("msg-4", "continue"))
|
||||
messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?"))
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
messages.push(createUserMessage("msg-6", "continue"))
|
||||
messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?"))
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual(["continue", "continue", "continue"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when a real user message arrives between auto-continues", () => {
|
||||
test("resets the consecutive auto-continue counter", async () => {
|
||||
// given
|
||||
const messages: SessionMessage[] = [
|
||||
createUserMessage("msg-0", "Please continue the fix."),
|
||||
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
|
||||
]
|
||||
const { ctx, promptCalls } = createMockPluginInput(messages)
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
messages.push(createUserMessage("msg-2", "continue"))
|
||||
messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?"))
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
messages.push(createUserMessage("msg-4", "Please keep going and finish the cleanup."))
|
||||
messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?"))
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
messages.push(createUserMessage("msg-6", "continue"))
|
||||
messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?"))
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
messages.push(createUserMessage("msg-8", "continue"))
|
||||
messages.push(createAssistantMessage("msg-9", "If you want, I can apply the final polish."))
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
messages.push(createUserMessage("msg-10", "continue"))
|
||||
messages.push(createAssistantMessage("msg-11", "Would you like me to ship the final verification?"))
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual(["continue", "continue", "continue", "continue", "continue"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when the same permission phrase repeats after an auto-continue", () => {
|
||||
test("stops immediately on stagnation", async () => {
|
||||
// given
|
||||
const messages: SessionMessage[] = [
|
||||
createUserMessage("msg-0", "Please continue the fix."),
|
||||
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
|
||||
]
|
||||
const { ctx, promptCalls } = createMockPluginInput(messages)
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
messages.push(createUserMessage("msg-2", "continue"))
|
||||
messages.push(createAssistantMessage("msg-3", "If you want, I can apply the patch next."))
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual(["continue"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when a user manually types continue after the cap is reached", () => {
|
||||
test("resets the cap and allows another auto-continue", async () => {
|
||||
// given
|
||||
const messages: SessionMessage[] = [
|
||||
createUserMessage("msg-0", "Please continue the fix."),
|
||||
createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
|
||||
]
|
||||
const { ctx, promptCalls } = createMockPluginInput(messages)
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
messages.push(createUserMessage("msg-2", "continue"))
|
||||
messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?"))
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
messages.push(createUserMessage("msg-4", "continue"))
|
||||
messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?"))
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
messages.push(createUserMessage("msg-6", "continue"))
|
||||
messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?"))
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
messages.push(createUserMessage("msg-8", "continue"))
|
||||
messages.push(createAssistantMessage("msg-9", "If you want, I can apply the final polish."))
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual(["continue", "continue", "continue", "continue"])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,200 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
import {
|
||||
extractAssistantText,
|
||||
getLastAssistantMessage,
|
||||
isGptAssistantMessage,
|
||||
type SessionMessage,
|
||||
} from "./assistant-message"
|
||||
import {
|
||||
CONTINUATION_PROMPT,
|
||||
HOOK_NAME,
|
||||
MAX_CONSECUTIVE_AUTO_CONTINUES,
|
||||
} from "./constants"
|
||||
import { detectStallPattern } from "./detector"
|
||||
import type { SessionStateStore } from "./session-state"
|
||||
|
||||
type SessionState = ReturnType<SessionStateStore["getState"]>
|
||||
|
||||
async function promptContinuation(
|
||||
ctx: PluginInput,
|
||||
sessionID: string,
|
||||
): Promise<void> {
|
||||
const payload = {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
parts: [{ type: "text" as const, text: CONTINUATION_PROMPT }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
}
|
||||
|
||||
if (typeof ctx.client.session.promptAsync === "function") {
|
||||
await ctx.client.session.promptAsync(payload)
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.client.session.prompt(payload)
|
||||
}
|
||||
|
||||
function getLastUserMessageBefore(
|
||||
messages: SessionMessage[],
|
||||
lastAssistantIndex: number,
|
||||
): SessionMessage | null {
|
||||
for (let index = lastAssistantIndex - 1; index >= 0; index--) {
|
||||
if (messages[index].info?.role === "user") {
|
||||
return messages[index]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isAutoContinuationUserMessage(message: SessionMessage): boolean {
|
||||
return extractAssistantText(message).trim().toLowerCase() === CONTINUATION_PROMPT
|
||||
}
|
||||
|
||||
function extractPermissionPhrase(text: string): string | null {
|
||||
const tail = text.slice(-800)
|
||||
const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean)
|
||||
const hotZone = lines.slice(-3).join(" ")
|
||||
const sentenceParts = hotZone.trim().replace(/\s+/g, " ").split(/(?<=[.!?])\s+/)
|
||||
const trailingSegment = sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? ""
|
||||
return trailingSegment || null
|
||||
}
|
||||
|
||||
function resetAutoContinuationState(state: SessionState): void {
|
||||
state.consecutiveAutoContinueCount = 0
|
||||
state.awaitingAutoContinuationResponse = false
|
||||
state.lastAutoContinuePermissionPhrase = undefined
|
||||
}
|
||||
|
||||
export function createGptPermissionContinuationHandler(args: {
|
||||
ctx: PluginInput
|
||||
sessionStateStore: SessionStateStore
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
||||
const { ctx, sessionStateStore, isContinuationStopped } = args
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
const properties = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = (properties?.info as { id?: string } | undefined)?.id
|
||||
if (sessionID) {
|
||||
sessionStateStore.cleanup(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type !== "session.idle") return
|
||||
|
||||
const sessionID = properties?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (isContinuationStopped?.(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
if (state.inFlight) {
|
||||
log(`[${HOOK_NAME}] Skipped: prompt already in flight`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const messagesResponse = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const messages = normalizeSDKResponse(messagesResponse, [] as SessionMessage[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
const lastAssistantMessage = getLastAssistantMessage(messages)
|
||||
if (!lastAssistantMessage) return
|
||||
|
||||
const lastAssistantIndex = messages.lastIndexOf(lastAssistantMessage)
|
||||
const previousUserMessage = getLastUserMessageBefore(messages, lastAssistantIndex)
|
||||
const previousUserMessageWasAutoContinuation =
|
||||
previousUserMessage !== null
|
||||
&& state.awaitingAutoContinuationResponse
|
||||
&& isAutoContinuationUserMessage(previousUserMessage)
|
||||
|
||||
if (previousUserMessageWasAutoContinuation) {
|
||||
state.awaitingAutoContinuationResponse = false
|
||||
} else if (previousUserMessage) {
|
||||
resetAutoContinuationState(state)
|
||||
} else {
|
||||
state.awaitingAutoContinuationResponse = false
|
||||
}
|
||||
|
||||
const messageID = lastAssistantMessage.info?.id
|
||||
if (messageID && state.lastHandledMessageID === messageID) {
|
||||
log(`[${HOOK_NAME}] Skipped: already handled assistant message`, { sessionID, messageID })
|
||||
return
|
||||
}
|
||||
|
||||
if (lastAssistantMessage.info?.error) {
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant message has error`, { sessionID, messageID })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isGptAssistantMessage(lastAssistantMessage)) {
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant model is not GPT`, { sessionID, messageID })
|
||||
return
|
||||
}
|
||||
|
||||
const assistantText = extractAssistantText(lastAssistantMessage)
|
||||
if (!detectStallPattern(assistantText)) {
|
||||
return
|
||||
}
|
||||
|
||||
const permissionPhrase = extractPermissionPhrase(assistantText)
|
||||
if (!permissionPhrase) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.consecutiveAutoContinueCount >= MAX_CONSECUTIVE_AUTO_CONTINUES) {
|
||||
state.lastHandledMessageID = messageID
|
||||
log(`[${HOOK_NAME}] Skipped: reached max consecutive auto-continues`, {
|
||||
sessionID,
|
||||
messageID,
|
||||
consecutiveAutoContinueCount: state.consecutiveAutoContinueCount,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
state.consecutiveAutoContinueCount >= 1
|
||||
&& state.lastAutoContinuePermissionPhrase === permissionPhrase
|
||||
) {
|
||||
state.lastHandledMessageID = messageID
|
||||
log(`[${HOOK_NAME}] Skipped: repeated permission phrase after auto-continue`, {
|
||||
sessionID,
|
||||
messageID,
|
||||
permissionPhrase,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
state.inFlight = true
|
||||
await promptContinuation(ctx, sessionID)
|
||||
state.lastHandledMessageID = messageID
|
||||
state.consecutiveAutoContinueCount += 1
|
||||
state.awaitingAutoContinuationResponse = true
|
||||
state.lastAutoContinuePermissionPhrase = permissionPhrase
|
||||
state.lastInjectedAt = Date.now()
|
||||
log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID })
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Failed to inject continuation prompt`, {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
} finally {
|
||||
state.inFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { createGptPermissionContinuationHandler } from "./handler"
|
||||
import { createSessionStateStore } from "./session-state"
|
||||
|
||||
export type GptPermissionContinuationHook = {
|
||||
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
wasRecentlyInjected: (sessionID: string) => boolean
|
||||
}
|
||||
|
||||
export function createGptPermissionContinuationHook(
|
||||
ctx: PluginInput,
|
||||
options?: {
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
},
|
||||
): GptPermissionContinuationHook {
|
||||
const sessionStateStore = createSessionStateStore()
|
||||
|
||||
return {
|
||||
handler: createGptPermissionContinuationHandler({
|
||||
ctx,
|
||||
sessionStateStore,
|
||||
isContinuationStopped: options?.isContinuationStopped,
|
||||
}),
|
||||
wasRecentlyInjected(sessionID: string): boolean {
|
||||
return sessionStateStore.wasRecentlyInjected(sessionID, 5_000)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
type SessionState = {
|
||||
inFlight: boolean
|
||||
consecutiveAutoContinueCount: number
|
||||
awaitingAutoContinuationResponse: boolean
|
||||
lastHandledMessageID?: string
|
||||
lastAutoContinuePermissionPhrase?: string
|
||||
lastInjectedAt?: number
|
||||
}
|
||||
|
||||
export type SessionStateStore = ReturnType<typeof createSessionStateStore>
|
||||
|
||||
export function createSessionStateStore() {
|
||||
const states = new Map<string, SessionState>()
|
||||
|
||||
const getState = (sessionID: string): SessionState => {
|
||||
const existing = states.get(sessionID)
|
||||
if (existing) return existing
|
||||
|
||||
const created: SessionState = {
|
||||
inFlight: false,
|
||||
consecutiveAutoContinueCount: 0,
|
||||
awaitingAutoContinuationResponse: false,
|
||||
}
|
||||
states.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
|
||||
return {
|
||||
getState,
|
||||
wasRecentlyInjected(sessionID: string, windowMs: number): boolean {
|
||||
const state = states.get(sessionID)
|
||||
if (!state?.lastInjectedAt) return false
|
||||
return Date.now() - state.lastInjectedAt <= windowMs
|
||||
},
|
||||
cleanup(sessionID: string): void {
|
||||
states.delete(sessionID)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { createTodoContinuationEnforcer } from "../todo-continuation-enforcer"
|
||||
import { createGptPermissionContinuationHook } from "."
|
||||
|
||||
describe("gpt-permission-continuation coordination", () => {
|
||||
test("injects only once when GPT permission continuation and todo continuation are both eligible", async () => {
|
||||
// given
|
||||
const promptCalls: string[] = []
|
||||
const toastCalls: string[] = []
|
||||
const sessionID = "ses-dual-continuation"
|
||||
const ctx = {
|
||||
directory: "/tmp/test",
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "If you want, I can implement the fix next." }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
todo: async () => ({
|
||||
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
|
||||
}),
|
||||
prompt: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
||||
return {}
|
||||
},
|
||||
promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
||||
return {}
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async (input: { body: { title: string } }) => {
|
||||
toastCalls.push(input.body.title)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const gptPermissionContinuation = createGptPermissionContinuationHook(ctx)
|
||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx, {
|
||||
shouldSkipContinuation: (id) => gptPermissionContinuation.wasRecentlyInjected(id),
|
||||
})
|
||||
|
||||
// when
|
||||
await gptPermissionContinuation.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await todoContinuationEnforcer.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual(["continue"])
|
||||
expect(toastCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -30,7 +30,6 @@ export { createCategorySkillReminderHook } from "./category-skill-reminder";
|
||||
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
||||
export { createNoSisyphusGptHook } from "./no-sisyphus-gpt";
|
||||
export { createNoHephaestusNonGptHook } from "./no-hephaestus-non-gpt";
|
||||
export { createGptPermissionContinuationHook, type GptPermissionContinuationHook } from "./gpt-permission-continuation"
|
||||
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
||||
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
const wakeOpenClawMock = mock(async () => null)
|
||||
|
||||
mock.module("../openclaw", () => ({
|
||||
wakeOpenClaw: wakeOpenClawMock,
|
||||
}))
|
||||
|
||||
describe("createOpenClawHook", () => {
|
||||
beforeEach(() => {
|
||||
wakeOpenClawMock.mockClear()
|
||||
})
|
||||
|
||||
test("maps session.created to session-start", async () => {
|
||||
const { createOpenClawHook } = await import("./openclaw")
|
||||
const hook = createOpenClawHook(
|
||||
{ directory: "/tmp/project" } as any,
|
||||
{ openclaw: { enabled: true } } as any,
|
||||
)
|
||||
|
||||
await hook?.event?.({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { sessionID: "session-1" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(wakeOpenClawMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"session-start",
|
||||
expect.objectContaining({
|
||||
projectPath: "/tmp/project",
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("uses tool.execute.before for question tools", async () => {
|
||||
const { createOpenClawHook } = await import("./openclaw")
|
||||
const hook = createOpenClawHook(
|
||||
{ directory: "/tmp/project" } as any,
|
||||
{ openclaw: { enabled: true } } as any,
|
||||
)
|
||||
|
||||
await hook?.["tool.execute.before"]?.(
|
||||
{ tool: "ask_user_question", sessionID: "session-2" },
|
||||
{ args: { questions: [{ question: "Need approval?", options: [{ label: "Yes" }] }] } },
|
||||
)
|
||||
|
||||
expect(wakeOpenClawMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"ask-user-question",
|
||||
expect.objectContaining({
|
||||
projectPath: "/tmp/project",
|
||||
question: "Need approval?",
|
||||
sessionId: "session-2",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back to args.question string when questions array absent", async () => {
|
||||
const { createOpenClawHook } = await import("./openclaw")
|
||||
const hook = createOpenClawHook(
|
||||
{ directory: "/tmp/project" } as any,
|
||||
{ openclaw: { enabled: true } } as any,
|
||||
)
|
||||
|
||||
await hook?.["tool.execute.before"]?.(
|
||||
{ tool: "question", sessionID: "session-3" },
|
||||
{ args: { question: "Fallback?" } },
|
||||
)
|
||||
|
||||
expect(wakeOpenClawMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"ask-user-question",
|
||||
expect.objectContaining({
|
||||
question: "Fallback?",
|
||||
sessionId: "session-3",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { PluginContext } from "../plugin/types"
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
import { wakeOpenClaw } from "../openclaw"
|
||||
import type { OpenClawContext } from "../openclaw/types"
|
||||
|
||||
export function createOpenClawHook(
|
||||
ctx: PluginContext,
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
) {
|
||||
const config = pluginConfig.openclaw
|
||||
if (!config?.enabled) return null
|
||||
|
||||
const handleWake = async (event: string, context: OpenClawContext) => {
|
||||
await wakeOpenClaw(config, event, context)
|
||||
}
|
||||
|
||||
return {
|
||||
event: async (input: any) => {
|
||||
const { event } = input
|
||||
const props = event.properties || {}
|
||||
const sessionID = props.sessionID || props.info?.id
|
||||
|
||||
const context: OpenClawContext = {
|
||||
sessionId: sessionID,
|
||||
projectPath: ctx.directory,
|
||||
}
|
||||
|
||||
if (event.type === "session.created") {
|
||||
await handleWake("session-start", context)
|
||||
} else if (event.type === "session.deleted") {
|
||||
await handleWake("session-end", context)
|
||||
} else if (event.type === "session.idle") {
|
||||
// Check if we are waiting for user input (ask-user-question)
|
||||
// This is heuristic. If the last message was from assistant and ended with a question?
|
||||
// Or if the system is idle.
|
||||
await handleWake("session-idle", context)
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string },
|
||||
output: { args: Record<string, unknown> },
|
||||
) => {
|
||||
const normalizedToolName = input.tool.toLowerCase()
|
||||
if (
|
||||
normalizedToolName !== "question"
|
||||
&& normalizedToolName !== "ask_user_question"
|
||||
&& normalizedToolName !== "askuserquestion"
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// question tool uses args.questions array, not args.question
|
||||
const questions = Array.isArray(output.args.questions) ? output.args.questions : []
|
||||
const question = questions.length > 0 && typeof questions[0]?.question === "string"
|
||||
? questions[0].question
|
||||
: typeof output.args.question === "string" ? output.args.question : undefined
|
||||
const context: OpenClawContext = {
|
||||
sessionId: input.sessionID,
|
||||
projectPath: ctx.directory,
|
||||
question,
|
||||
}
|
||||
await handleWake("ask-user-question", context)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -108,4 +108,80 @@ describe("detectCompletionInSessionMessages", () => {
|
||||
expect(detected).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given promise appears in tool_result part (not text part)", () => {
|
||||
test("#when Oracle returns VERIFIED via task() tool_result #then should detect completion", async () => {
|
||||
const messages: SessionMessage[] = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [
|
||||
{ type: "text", text: "Consulting Oracle for verification." },
|
||||
{ type: "tool_use", text: '{"subagent_type":"oracle"}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [
|
||||
{ type: "tool_result", text: 'Task completed.\n\nAgent: oracle\n\n<promise>VERIFIED</promise>\n\n<task_metadata>\nsession_id: ses_abc123\n</task_metadata>' },
|
||||
{ type: "text", text: "Oracle verified the task." },
|
||||
],
|
||||
},
|
||||
]
|
||||
const ctx = createPluginInput(messages)
|
||||
|
||||
const detected = await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID: "session-123",
|
||||
promise: "VERIFIED",
|
||||
apiTimeoutMs: 1000,
|
||||
directory: "/tmp",
|
||||
sinceMessageIndex: 0,
|
||||
})
|
||||
|
||||
expect(detected).toBe(true)
|
||||
})
|
||||
|
||||
test("#when DONE appears only in tool_result part #then should detect completion", async () => {
|
||||
const messages: SessionMessage[] = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [
|
||||
{ type: "tool_result", text: 'Background task output <promise>DONE</promise>' },
|
||||
{ type: "text", text: "Task completed successfully." },
|
||||
],
|
||||
},
|
||||
]
|
||||
const ctx = createPluginInput(messages)
|
||||
|
||||
const detected = await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID: "session-123",
|
||||
promise: "DONE",
|
||||
apiTimeoutMs: 1000,
|
||||
directory: "/tmp",
|
||||
})
|
||||
|
||||
expect(detected).toBe(true)
|
||||
})
|
||||
|
||||
test("#when promise appears in tool_use part (not tool_result) #then should NOT detect completion", async () => {
|
||||
const messages: SessionMessage[] = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [
|
||||
{ type: "tool_use", text: 'prompt containing <promise>VERIFIED</promise> as instruction' },
|
||||
{ type: "text", text: "Calling Oracle." },
|
||||
],
|
||||
},
|
||||
]
|
||||
const ctx = createPluginInput(messages)
|
||||
|
||||
const detected = await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID: "session-123",
|
||||
promise: "VERIFIED",
|
||||
apiTimeoutMs: 1000,
|
||||
directory: "/tmp",
|
||||
})
|
||||
|
||||
expect(detected).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function detectCompletionInSessionMessages(
|
||||
|
||||
let responseText = ""
|
||||
for (const part of assistant.parts) {
|
||||
if (part.type !== "text") continue
|
||||
if (part.type !== "text" && part.type !== "tool_result") continue
|
||||
responseText += `${responseText ? "\n" : ""}${part.text ?? ""}`
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ You already emitted <promise>{{INITIAL_PROMISE}}</promise>. This does NOT finish
|
||||
REQUIRED NOW:
|
||||
- Call Oracle using task(subagent_type="oracle", load_skills=[], run_in_background=false, ...)
|
||||
- Ask Oracle to verify whether the original task is actually complete
|
||||
- Include the original task in the Oracle request
|
||||
- Explicitly tell Oracle to review skeptically and critically, and to look for reasons the task may still be incomplete or wrong
|
||||
- The system will inspect the Oracle session directly for the verification result
|
||||
- If Oracle does not verify, continue fixing the task and do not consider it complete
|
||||
|
||||
@@ -40,6 +42,7 @@ REQUIRED NOW:
|
||||
- Oracle does not lie. Treat the verification result as ground truth
|
||||
- Do not claim completion early or argue with the failed verification
|
||||
- After fixing the remaining issues, request Oracle review again using task(subagent_type="oracle", load_skills=[], run_in_background=false, ...)
|
||||
- Include the original task in the Oracle request and tell Oracle to review skeptically and critically
|
||||
- Only when the work is ready for review again, output: <promise>{{PROMISE}}</promise>
|
||||
|
||||
Original task:
|
||||
|
||||
@@ -25,7 +25,7 @@ function collectAssistantText(message: OpenCodeSessionMessage): string {
|
||||
|
||||
let text = ""
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "text") {
|
||||
if (part.type !== "text" && part.type !== "tool_result") {
|
||||
continue
|
||||
}
|
||||
text += `${text ? "\n" : ""}${part.text ?? ""}`
|
||||
|
||||
@@ -87,7 +87,7 @@ export function createRalphLoopEventHandler(
|
||||
return
|
||||
}
|
||||
|
||||
const completionSessionID = verificationSessionID ?? (state.verification_pending ? undefined : sessionID)
|
||||
const completionSessionID = verificationSessionID ?? sessionID
|
||||
const transcriptPath = completionSessionID ? options.getTranscriptPath(completionSessionID) : undefined
|
||||
const completionViaTranscript = completionSessionID
|
||||
? detectCompletionInTranscript(
|
||||
@@ -107,7 +107,13 @@ export function createRalphLoopEventHandler(
|
||||
sinceMessageIndex: undefined,
|
||||
})
|
||||
: state.verification_pending
|
||||
? false
|
||||
? await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID,
|
||||
promise: state.completion_promise,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
directory: options.directory,
|
||||
sinceMessageIndex: state.message_count_at_start,
|
||||
})
|
||||
: await detectCompletionInSessionMessages(ctx, {
|
||||
sessionID,
|
||||
promise: state.completion_promise,
|
||||
|
||||
@@ -366,7 +366,7 @@ describe("ulw-loop verification", () => {
|
||||
expect(toastCalls.some((toast) => toast.title === "ULTRAWORK LOOP COMPLETE!")).toBe(false)
|
||||
})
|
||||
|
||||
test("#given parent session emits VERIFIED #when oracle session is not tracked #then ulw loop continues instead of completing", async () => {
|
||||
test("#given parent session emits VERIFIED #when oracle session is not tracked #then ulw loop completes from parent session evidence", async () => {
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: (sessionID) => sessionID === "ses-oracle" ? oracleTranscriptPath : parentTranscriptPath,
|
||||
})
|
||||
@@ -379,17 +379,13 @@ describe("ulw-loop verification", () => {
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||
writeFileSync(
|
||||
parentTranscriptPath,
|
||||
`${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "done <promise>DONE</promise>" } })}\n${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: `bad parent leak <promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>` } })}\n`,
|
||||
`${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "done <promise>DONE</promise>" } })}\n${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: `verified <promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>` } })}\n`,
|
||||
)
|
||||
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||
|
||||
expect(hook.getState()).not.toBeNull()
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
expect(hook.getState()?.completion_promise).toBe("DONE")
|
||||
expect(hook.getState()?.verification_pending).toBeUndefined()
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
expect(promptCalls[1]?.text).toContain("Verification failed")
|
||||
expect(hook.getState()).toBeNull()
|
||||
expect(toastCalls.some((toast) => toast.title === "ULTRAWORK LOOP COMPLETE!")).toBe(true)
|
||||
})
|
||||
|
||||
test("#given oracle verification fails #when loop restarts #then old oracle session is aborted", async () => {
|
||||
|
||||
@@ -10,7 +10,8 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
|
||||
|
||||
- Proceed without asking for permission
|
||||
- Mark each task complete when finished
|
||||
- Do not stop until all tasks are done`
|
||||
- Do not stop until all tasks are done
|
||||
- If you believe all work is already complete, the system is questioning your completion claim. Critically re-examine each todo item from a skeptical perspective, verify the work was actually done correctly, and update the todo list accordingly.`
|
||||
|
||||
export const COUNTDOWN_SECONDS = 2
|
||||
export const TOAST_DURATION_MS = 900
|
||||
|
||||
@@ -17,7 +17,6 @@ export function createTodoContinuationHandler(args: {
|
||||
backgroundManager?: BackgroundManager
|
||||
skipAgents?: string[]
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
shouldSkipContinuation?: (sessionID: string) => boolean
|
||||
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
||||
const {
|
||||
ctx,
|
||||
@@ -25,7 +24,6 @@ export function createTodoContinuationHandler(args: {
|
||||
backgroundManager,
|
||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||
isContinuationStopped,
|
||||
shouldSkipContinuation,
|
||||
} = args
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
@@ -58,7 +56,6 @@ export function createTodoContinuationHandler(args: {
|
||||
backgroundManager,
|
||||
skipAgents,
|
||||
isContinuationStopped,
|
||||
shouldSkipContinuation,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ export async function handleSessionIdle(args: {
|
||||
backgroundManager?: BackgroundManager
|
||||
skipAgents?: string[]
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
shouldSkipContinuation?: (sessionID: string) => boolean
|
||||
}): Promise<void> {
|
||||
const {
|
||||
ctx,
|
||||
@@ -39,7 +38,6 @@ export async function handleSessionIdle(args: {
|
||||
backgroundManager,
|
||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||
isContinuationStopped,
|
||||
shouldSkipContinuation,
|
||||
} = args
|
||||
|
||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||
@@ -174,11 +172,6 @@ export async function handleSessionIdle(args: {
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldSkipContinuation?.(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount, todos)
|
||||
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
|
||||
return
|
||||
|
||||
@@ -17,7 +17,6 @@ export function createTodoContinuationEnforcer(
|
||||
backgroundManager,
|
||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||
isContinuationStopped,
|
||||
shouldSkipContinuation,
|
||||
} = options
|
||||
|
||||
const sessionStateStore = createSessionStateStore()
|
||||
@@ -43,7 +42,6 @@ export function createTodoContinuationEnforcer(
|
||||
backgroundManager,
|
||||
skipAgents,
|
||||
isContinuationStopped,
|
||||
shouldSkipContinuation,
|
||||
})
|
||||
|
||||
const cancelAllCountdowns = (): void => {
|
||||
|
||||
@@ -1706,27 +1706,6 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should not inject when shouldSkipContinuation returns true", async () => {
|
||||
// given - session already handled by another continuation hook
|
||||
const sessionID = "main-skip-other-continuation"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||
shouldSkipContinuation: (id) => id === sessionID,
|
||||
})
|
||||
|
||||
// when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// then - no countdown toast or continuation injection
|
||||
expect(toastCalls).toHaveLength(0)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should not inject when isContinuationStopped becomes true during countdown", async () => {
|
||||
// given - session where continuation is not stopped at idle time but stops during countdown
|
||||
const sessionID = "main-race-condition"
|
||||
|
||||
@@ -5,7 +5,6 @@ export interface TodoContinuationEnforcerOptions {
|
||||
backgroundManager?: BackgroundManager
|
||||
skipAgents?: string[]
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
shouldSkipContinuation?: (sessionID: string) => boolean
|
||||
}
|
||||
|
||||
export interface TodoContinuationEnforcer {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveGateway, validateGatewayUrl, normalizeReplyListenerConfig } from "../config"
|
||||
import type { OpenClawConfig } from "../types"
|
||||
import { OpenClawConfigSchema } from "../../config/schema/openclaw"
|
||||
|
||||
describe("OpenClaw Config", () => {
|
||||
test("resolveGateway resolves HTTP gateway", () => {
|
||||
const config: OpenClawConfig = {
|
||||
enabled: true,
|
||||
gateways: {
|
||||
discord: {
|
||||
type: "http",
|
||||
url: "https://discord.com/api/webhooks/123",
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
"session-start": {
|
||||
enabled: true,
|
||||
gateway: "discord",
|
||||
instruction: "Started session {{sessionId}}",
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const resolved = resolveGateway(config, "session-start")
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved?.gatewayName).toBe("discord")
|
||||
expect(resolved?.gateway.url).toBe("https://discord.com/api/webhooks/123")
|
||||
expect(resolved?.instruction).toBe("Started session {{sessionId}}")
|
||||
})
|
||||
|
||||
test("resolveGateway returns null for disabled config", () => {
|
||||
const config: OpenClawConfig = {
|
||||
enabled: false,
|
||||
gateways: {},
|
||||
hooks: {},
|
||||
} as any
|
||||
expect(resolveGateway(config, "session-start")).toBeNull()
|
||||
})
|
||||
|
||||
test("resolveGateway returns null for unknown hook", () => {
|
||||
const config: OpenClawConfig = {
|
||||
enabled: true,
|
||||
gateways: {},
|
||||
hooks: {},
|
||||
} as any
|
||||
expect(resolveGateway(config, "unknown")).toBeNull()
|
||||
})
|
||||
|
||||
test("resolveGateway returns null for disabled hook", () => {
|
||||
const config: OpenClawConfig = {
|
||||
enabled: true,
|
||||
gateways: { g: { type: "http", url: "https://example.com" } },
|
||||
hooks: {
|
||||
event: { enabled: false, gateway: "g", instruction: "i" },
|
||||
},
|
||||
} as any
|
||||
expect(resolveGateway(config, "event")).toBeNull()
|
||||
})
|
||||
|
||||
test("validateGatewayUrl allows HTTPS", () => {
|
||||
expect(validateGatewayUrl("https://example.com")).toBe(true)
|
||||
})
|
||||
|
||||
test("validateGatewayUrl rejects HTTP remote", () => {
|
||||
expect(validateGatewayUrl("http://example.com")).toBe(false)
|
||||
})
|
||||
|
||||
test("validateGatewayUrl allows HTTP localhost", () => {
|
||||
expect(validateGatewayUrl("http://localhost:3000")).toBe(true)
|
||||
expect(validateGatewayUrl("http://127.0.0.1:3000")).toBe(true)
|
||||
})
|
||||
|
||||
test("normalizeReplyListenerConfig normalizes nested reply listener fields", () => {
|
||||
const config = normalizeReplyListenerConfig({
|
||||
enabled: true,
|
||||
gateways: {},
|
||||
hooks: {},
|
||||
replyListener: {
|
||||
discordBotToken: "discord-token",
|
||||
discordChannelId: "channel-id",
|
||||
authorizedDiscordUserIds: ["user-1", "", "user-2"],
|
||||
pollIntervalMs: 100,
|
||||
rateLimitPerMinute: 0,
|
||||
maxMessageLength: 9000,
|
||||
includePrefix: false,
|
||||
},
|
||||
} as OpenClawConfig)
|
||||
|
||||
expect(config.replyListener).toEqual({
|
||||
discordBotToken: "discord-token",
|
||||
discordChannelId: "channel-id",
|
||||
authorizedDiscordUserIds: ["user-1", "user-2"],
|
||||
pollIntervalMs: 500,
|
||||
rateLimitPerMinute: 1,
|
||||
maxMessageLength: 4000,
|
||||
includePrefix: false,
|
||||
})
|
||||
})
|
||||
|
||||
test("gateway timeout remains optional so env fallback can apply", () => {
|
||||
const parsed = OpenClawConfigSchema.parse({
|
||||
enabled: true,
|
||||
gateways: {
|
||||
command: {
|
||||
type: "command",
|
||||
command: "echo hi",
|
||||
},
|
||||
},
|
||||
hooks: {},
|
||||
})
|
||||
|
||||
expect(parsed.gateways.command.timeout).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test, mock, spyOn } from "bun:test"
|
||||
import {
|
||||
interpolateInstruction,
|
||||
resolveCommandTimeoutMs,
|
||||
shellEscapeArg,
|
||||
wakeGateway,
|
||||
wakeCommandGateway,
|
||||
} from "../dispatcher"
|
||||
|
||||
describe("OpenClaw Dispatcher", () => {
|
||||
test("interpolateInstruction replaces variables", () => {
|
||||
const template = "Hello {{name}}, welcome to {{place}}!"
|
||||
const variables = { name: "World", place: "Bun" }
|
||||
expect(interpolateInstruction(template, variables)).toBe(
|
||||
"Hello World, welcome to Bun!",
|
||||
)
|
||||
})
|
||||
|
||||
test("interpolateInstruction handles missing variables", () => {
|
||||
const template = "Hello {{name}}!"
|
||||
const variables = {}
|
||||
expect(interpolateInstruction(template, variables)).toBe("Hello !")
|
||||
})
|
||||
|
||||
test("shellEscapeArg escapes single quotes", () => {
|
||||
expect(shellEscapeArg("foo'bar")).toBe("'foo'\\''bar'")
|
||||
expect(shellEscapeArg("simple")).toBe("'simple'")
|
||||
})
|
||||
|
||||
test("wakeGateway sends POST request", async () => {
|
||||
const fetchSpy = spyOn(global, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
try {
|
||||
const result = await wakeGateway(
|
||||
"test",
|
||||
{ url: "https://example.com", method: "POST", timeout: 1000, type: "http" },
|
||||
{ foo: "bar" },
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(fetchSpy).toHaveBeenCalled()
|
||||
const call = fetchSpy.mock.calls.find(c => c[0] === "https://example.com")
|
||||
expect(call[0]).toBe("https://example.com")
|
||||
expect(call[1]?.method).toBe("POST")
|
||||
expect(call[1]?.body).toBe('{"foo":"bar"}')
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("wakeGateway fails on invalid URL", async () => {
|
||||
const result = await wakeGateway("test", { url: "http://example.com", method: "POST", timeout: 1000, type: "http" }, {})
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Invalid URL")
|
||||
})
|
||||
|
||||
test("resolveCommandTimeoutMs reads OMO env fallback", () => {
|
||||
const original = process.env.OMO_OPENCLAW_COMMAND_TIMEOUT_MS
|
||||
process.env.OMO_OPENCLAW_COMMAND_TIMEOUT_MS = "4321"
|
||||
|
||||
try {
|
||||
// Call without explicit envTimeoutRaw so the function reads from process.env itself
|
||||
expect(resolveCommandTimeoutMs(undefined)).toBe(4321)
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.OMO_OPENCLAW_COMMAND_TIMEOUT_MS
|
||||
else process.env.OMO_OPENCLAW_COMMAND_TIMEOUT_MS = original
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { analyzePaneContent } from "../tmux"
|
||||
|
||||
describe("openclaw tmux helpers", () => {
|
||||
test("analyzePaneContent recognizes the opencode welcome prompt", () => {
|
||||
const content = "opencode\nAsk anything...\nRun /help"
|
||||
expect(analyzePaneContent(content).confidence).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("analyzePaneContent returns zero confidence for empty content", () => {
|
||||
expect(analyzePaneContent(null).confidence).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
OpenClawConfig,
|
||||
OpenClawGateway,
|
||||
OpenClawReplyListenerConfig,
|
||||
} from "./types"
|
||||
|
||||
const DEFAULT_REPLY_POLL_INTERVAL_MS = 3000
|
||||
const MIN_REPLY_POLL_INTERVAL_MS = 500
|
||||
const MAX_REPLY_POLL_INTERVAL_MS = 60000
|
||||
const DEFAULT_REPLY_RATE_LIMIT_PER_MINUTE = 10
|
||||
const MIN_REPLY_RATE_LIMIT_PER_MINUTE = 1
|
||||
const DEFAULT_REPLY_MAX_MESSAGE_LENGTH = 500
|
||||
const MIN_REPLY_MAX_MESSAGE_LENGTH = 1
|
||||
const MAX_REPLY_MAX_MESSAGE_LENGTH = 4000
|
||||
|
||||
function normalizeInteger(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max?: number,
|
||||
): number {
|
||||
const numeric =
|
||||
typeof value === "number"
|
||||
? Math.trunc(value)
|
||||
: typeof value === "string" && value.trim()
|
||||
? Number.parseInt(value, 10)
|
||||
: Number.NaN
|
||||
|
||||
if (!Number.isFinite(numeric)) return fallback
|
||||
if (numeric < min) return min
|
||||
if (max !== undefined && numeric > max) return max
|
||||
return numeric
|
||||
}
|
||||
|
||||
export function normalizeReplyListenerConfig(config: OpenClawConfig): OpenClawConfig {
|
||||
const replyListener = config.replyListener
|
||||
if (!replyListener) return config
|
||||
|
||||
const normalizedReplyListener: OpenClawReplyListenerConfig = {
|
||||
...replyListener,
|
||||
discordBotToken: replyListener.discordBotToken,
|
||||
discordChannelId: replyListener.discordChannelId,
|
||||
telegramBotToken: replyListener.telegramBotToken,
|
||||
telegramChatId: replyListener.telegramChatId,
|
||||
pollIntervalMs: normalizeInteger(
|
||||
replyListener.pollIntervalMs,
|
||||
DEFAULT_REPLY_POLL_INTERVAL_MS,
|
||||
MIN_REPLY_POLL_INTERVAL_MS,
|
||||
MAX_REPLY_POLL_INTERVAL_MS,
|
||||
),
|
||||
rateLimitPerMinute: normalizeInteger(
|
||||
replyListener.rateLimitPerMinute,
|
||||
DEFAULT_REPLY_RATE_LIMIT_PER_MINUTE,
|
||||
MIN_REPLY_RATE_LIMIT_PER_MINUTE,
|
||||
),
|
||||
maxMessageLength: normalizeInteger(
|
||||
replyListener.maxMessageLength,
|
||||
DEFAULT_REPLY_MAX_MESSAGE_LENGTH,
|
||||
MIN_REPLY_MAX_MESSAGE_LENGTH,
|
||||
MAX_REPLY_MAX_MESSAGE_LENGTH,
|
||||
),
|
||||
includePrefix: replyListener.includePrefix !== false,
|
||||
authorizedDiscordUserIds: Array.isArray(replyListener.authorizedDiscordUserIds)
|
||||
? replyListener.authorizedDiscordUserIds.filter(
|
||||
(id) => typeof id === "string" && id.trim() !== "",
|
||||
)
|
||||
: [],
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
replyListener: normalizedReplyListener,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGateway(
|
||||
config: OpenClawConfig,
|
||||
event: string,
|
||||
): { gatewayName: string; gateway: OpenClawGateway; instruction: string } | null {
|
||||
if (!config.enabled) return null
|
||||
|
||||
const mapping = config.hooks[event]
|
||||
if (!mapping || !mapping.enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
const gateway = config.gateways[mapping.gateway]
|
||||
if (!gateway) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate based on gateway type
|
||||
if (gateway.type === "command") {
|
||||
if (!gateway.command) return null
|
||||
} else {
|
||||
// HTTP gateway
|
||||
if (!gateway.url) return null
|
||||
}
|
||||
|
||||
return { gatewayName: mapping.gateway, gateway, instruction: mapping.instruction }
|
||||
}
|
||||
|
||||
export function validateGatewayUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol === "https:") return true
|
||||
if (
|
||||
parsed.protocol === "http:" &&
|
||||
(parsed.hostname === "localhost" ||
|
||||
parsed.hostname === "127.0.0.1" ||
|
||||
parsed.hostname === "::1" ||
|
||||
parsed.hostname === "[::1]")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { pollLoop, logReplyListenerMessage } from "./reply-listener"
|
||||
|
||||
pollLoop().catch((err) => {
|
||||
logReplyListenerMessage(
|
||||
`FATAL: reply listener daemon crashed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`,
|
||||
)
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { spawn } from "bun"
|
||||
import type { OpenClawGateway } from "./types"
|
||||
|
||||
const DEFAULT_HTTP_TIMEOUT_MS = 10_000
|
||||
const DEFAULT_COMMAND_TIMEOUT_MS = 5_000
|
||||
const MIN_COMMAND_TIMEOUT_MS = 100
|
||||
const MAX_COMMAND_TIMEOUT_MS = 300_000
|
||||
const SHELL_METACHAR_RE = /[|&;><`$()]/
|
||||
|
||||
export function validateGatewayUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol === "https:") return true
|
||||
if (
|
||||
parsed.protocol === "http:" &&
|
||||
(parsed.hostname === "localhost" ||
|
||||
parsed.hostname === "127.0.0.1" ||
|
||||
parsed.hostname === "::1" ||
|
||||
parsed.hostname === "[::1]")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function interpolateInstruction(
|
||||
template: string,
|
||||
variables: Record<string, string | undefined>,
|
||||
): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
|
||||
return variables[key] ?? ""
|
||||
})
|
||||
}
|
||||
|
||||
export function shellEscapeArg(value: string): string {
|
||||
return "'" + value.replace(/'/g, "'\\''") + "'"
|
||||
}
|
||||
|
||||
export function resolveCommandTimeoutMs(
|
||||
gatewayTimeout?: number,
|
||||
envTimeoutRaw =
|
||||
process.env.OMO_OPENCLAW_COMMAND_TIMEOUT_MS
|
||||
?? process.env.OMX_OPENCLAW_COMMAND_TIMEOUT_MS,
|
||||
): number {
|
||||
const parseFinite = (value: unknown): number | undefined => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined
|
||||
return value
|
||||
}
|
||||
const parseEnv = (value?: string): number | undefined => {
|
||||
if (!value) return undefined
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
const rawTimeout =
|
||||
parseFinite(gatewayTimeout) ??
|
||||
parseEnv(envTimeoutRaw) ??
|
||||
DEFAULT_COMMAND_TIMEOUT_MS
|
||||
|
||||
return Math.min(
|
||||
MAX_COMMAND_TIMEOUT_MS,
|
||||
Math.max(MIN_COMMAND_TIMEOUT_MS, Math.trunc(rawTimeout)),
|
||||
)
|
||||
}
|
||||
|
||||
export async function wakeGateway(
|
||||
gatewayName: string,
|
||||
gatewayConfig: OpenClawGateway,
|
||||
payload: unknown,
|
||||
): Promise<{ gateway: string; success: boolean; error?: string; statusCode?: number }> {
|
||||
if (!gatewayConfig.url || !validateGatewayUrl(gatewayConfig.url)) {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: "Invalid URL (HTTPS required)",
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...gatewayConfig.headers,
|
||||
}
|
||||
|
||||
const timeout = gatewayConfig.timeout ?? DEFAULT_HTTP_TIMEOUT_MS
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
const response = await fetch(gatewayConfig.url, {
|
||||
method: gatewayConfig.method || "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
clearTimeout(timeoutId)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: `HTTP ${response.status}`,
|
||||
statusCode: response.status,
|
||||
}
|
||||
}
|
||||
|
||||
return { gateway: gatewayName, success: true, statusCode: response.status }
|
||||
} catch (error) {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function wakeCommandGateway(
|
||||
gatewayName: string,
|
||||
gatewayConfig: OpenClawGateway,
|
||||
variables: Record<string, string | undefined>,
|
||||
): Promise<{ gateway: string; success: boolean; error?: string }> {
|
||||
if (!gatewayConfig.command) {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: "No command configured",
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const timeout = resolveCommandTimeoutMs(gatewayConfig.timeout)
|
||||
|
||||
// Interpolate variables with shell escaping
|
||||
const interpolated = gatewayConfig.command.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
|
||||
const value = variables[key]
|
||||
if (value === undefined) return _match
|
||||
return shellEscapeArg(value)
|
||||
})
|
||||
|
||||
// Always use sh -c to handle the shell command string correctly
|
||||
const proc = spawn(["sh", "-c", interpolated], {
|
||||
env: { ...process.env },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
|
||||
// Handle timeout manually
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error("Command timed out"))
|
||||
}, timeout)
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.race([proc.exited, timeoutPromise])
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
if (proc.exitCode !== 0) {
|
||||
throw new Error(`Command exited with code ${proc.exitCode}`)
|
||||
}
|
||||
|
||||
return { gateway: gatewayName, success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
gateway: gatewayName,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { basename } from "path"
|
||||
import { resolveGateway } from "./config"
|
||||
import {
|
||||
wakeGateway,
|
||||
wakeCommandGateway,
|
||||
interpolateInstruction,
|
||||
} from "./dispatcher"
|
||||
import { getCurrentTmuxSession, captureTmuxPane } from "./tmux"
|
||||
import { startReplyListener, stopReplyListener } from "./reply-listener"
|
||||
import type { OpenClawConfig, OpenClawContext, OpenClawPayload, WakeResult } from "./types"
|
||||
|
||||
const DEBUG =
|
||||
process.env.OMO_OPENCLAW_DEBUG === "1"
|
||||
|| process.env.OMX_OPENCLAW_DEBUG === "1"
|
||||
|
||||
function buildWhitelistedContext(context: OpenClawContext): OpenClawContext {
|
||||
const result: OpenClawContext = {}
|
||||
if (context.sessionId !== undefined) result.sessionId = context.sessionId
|
||||
if (context.projectPath !== undefined) result.projectPath = context.projectPath
|
||||
if (context.tmuxSession !== undefined) result.tmuxSession = context.tmuxSession
|
||||
if (context.prompt !== undefined) result.prompt = context.prompt
|
||||
if (context.contextSummary !== undefined) result.contextSummary = context.contextSummary
|
||||
if (context.reasoning !== undefined) result.reasoning = context.reasoning
|
||||
if (context.question !== undefined) result.question = context.question
|
||||
if (context.tmuxTail !== undefined) result.tmuxTail = context.tmuxTail
|
||||
if (context.replyChannel !== undefined) result.replyChannel = context.replyChannel
|
||||
if (context.replyTarget !== undefined) result.replyTarget = context.replyTarget
|
||||
if (context.replyThread !== undefined) result.replyThread = context.replyThread
|
||||
return result
|
||||
}
|
||||
|
||||
export async function wakeOpenClaw(
|
||||
config: OpenClawConfig,
|
||||
event: string,
|
||||
context: OpenClawContext,
|
||||
): Promise<WakeResult | null> {
|
||||
try {
|
||||
if (!config.enabled) return null
|
||||
|
||||
const resolved = resolveGateway(config, event)
|
||||
if (!resolved) return null
|
||||
|
||||
const { gatewayName, gateway, instruction } = resolved
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const replyChannel = context.replyChannel ?? process.env.OPENCLAW_REPLY_CHANNEL
|
||||
const replyTarget = context.replyTarget ?? process.env.OPENCLAW_REPLY_TARGET
|
||||
const replyThread = context.replyThread ?? process.env.OPENCLAW_REPLY_THREAD
|
||||
|
||||
const enrichedContext: OpenClawContext = {
|
||||
...context,
|
||||
...(replyChannel !== undefined && { replyChannel }),
|
||||
...(replyTarget !== undefined && { replyTarget }),
|
||||
...(replyThread !== undefined && { replyThread }),
|
||||
}
|
||||
|
||||
const tmuxSession = enrichedContext.tmuxSession ?? getCurrentTmuxSession() ?? undefined
|
||||
|
||||
let tmuxTail = enrichedContext.tmuxTail
|
||||
if (!tmuxTail && (event === "stop" || event === "session-end") && process.env.TMUX) {
|
||||
try {
|
||||
const paneId = process.env.TMUX_PANE
|
||||
if (paneId) {
|
||||
tmuxTail = (await captureTmuxPane(paneId, 15)) ?? undefined
|
||||
}
|
||||
} catch (error) {
|
||||
if (DEBUG) {
|
||||
console.error(
|
||||
"[openclaw] failed to capture tmux tail:",
|
||||
error instanceof Error ? error.message : error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const variables: Record<string, string | undefined> = {
|
||||
sessionId: enrichedContext.sessionId,
|
||||
projectPath: enrichedContext.projectPath,
|
||||
projectName: enrichedContext.projectPath ? basename(enrichedContext.projectPath) : undefined,
|
||||
tmuxSession,
|
||||
prompt: enrichedContext.prompt,
|
||||
contextSummary: enrichedContext.contextSummary,
|
||||
reasoning: enrichedContext.reasoning,
|
||||
question: enrichedContext.question,
|
||||
tmuxTail,
|
||||
event,
|
||||
timestamp: now,
|
||||
replyChannel,
|
||||
replyTarget,
|
||||
replyThread,
|
||||
}
|
||||
|
||||
const interpolatedInstruction = interpolateInstruction(instruction, variables)
|
||||
variables.instruction = interpolatedInstruction
|
||||
|
||||
let result: WakeResult
|
||||
|
||||
if (gateway.type === "command") {
|
||||
result = await wakeCommandGateway(gatewayName, gateway, variables)
|
||||
} else {
|
||||
const payload: OpenClawPayload = {
|
||||
event,
|
||||
instruction: interpolatedInstruction,
|
||||
text: interpolatedInstruction,
|
||||
timestamp: now,
|
||||
sessionId: enrichedContext.sessionId,
|
||||
projectPath: enrichedContext.projectPath,
|
||||
projectName: enrichedContext.projectPath ? basename(enrichedContext.projectPath) : undefined,
|
||||
tmuxSession,
|
||||
tmuxTail,
|
||||
...(replyChannel !== undefined && { channel: replyChannel }),
|
||||
...(replyTarget !== undefined && { to: replyTarget }),
|
||||
...(replyThread !== undefined && { threadId: replyThread }),
|
||||
context: buildWhitelistedContext(enrichedContext),
|
||||
}
|
||||
|
||||
result = await wakeGateway(gatewayName, gateway, payload)
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
console.error(`[openclaw] wake ${event} -> ${gatewayName}: ${result.success ? "ok" : result.error}`)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (DEBUG) {
|
||||
console.error(`[openclaw] wakeOpenClaw error:`, error instanceof Error ? error.message : error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeOpenClaw(config: OpenClawConfig): Promise<void> {
|
||||
const replyListener = config.replyListener
|
||||
if (config.enabled && (replyListener?.discordBotToken || replyListener?.telegramBotToken)) {
|
||||
await startReplyListener(config)
|
||||
}
|
||||
}
|
||||
|
||||
export { startReplyListener, stopReplyListener }
|
||||
@@ -0,0 +1,717 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
chmodSync,
|
||||
statSync,
|
||||
appendFileSync,
|
||||
renameSync,
|
||||
} from "fs"
|
||||
import { join, dirname } from "path"
|
||||
import { homedir } from "os"
|
||||
import { spawn } from "bun" // Use bun spawn
|
||||
import { captureTmuxPane, analyzePaneContent, sendToPane, isTmuxAvailable } from "./tmux"
|
||||
import { lookupByMessageId, removeMessagesByPane, pruneStale } from "./session-registry"
|
||||
import type { OpenClawConfig } from "./types"
|
||||
import { normalizeReplyListenerConfig } from "./config"
|
||||
|
||||
const SECURE_FILE_MODE = 0o600
|
||||
const MAX_LOG_SIZE_BYTES = 1 * 1024 * 1024
|
||||
const DAEMON_ENV_ALLOWLIST = [
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"USER",
|
||||
"USERNAME",
|
||||
"LOGNAME",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TERM",
|
||||
"TMUX",
|
||||
"TMUX_PANE",
|
||||
"TMPDIR",
|
||||
"TMP",
|
||||
"TEMP",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"SHELL",
|
||||
"NODE_ENV",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
"SystemRoot",
|
||||
"SYSTEMROOT",
|
||||
"windir",
|
||||
"COMSPEC",
|
||||
]
|
||||
|
||||
const DEFAULT_STATE_DIR = join(homedir(), ".omx", "state")
|
||||
const PID_FILE_PATH = join(DEFAULT_STATE_DIR, "reply-listener.pid")
|
||||
const STATE_FILE_PATH = join(DEFAULT_STATE_DIR, "reply-listener-state.json")
|
||||
const CONFIG_FILE_PATH = join(DEFAULT_STATE_DIR, "reply-listener-config.json")
|
||||
const LOG_FILE_PATH = join(DEFAULT_STATE_DIR, "reply-listener.log")
|
||||
|
||||
export const DAEMON_IDENTITY_MARKER = "--openclaw-reply-listener-daemon"
|
||||
|
||||
function createMinimalDaemonEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
for (const key of DAEMON_ENV_ALLOWLIST) {
|
||||
if (process.env[key] !== undefined) {
|
||||
env[key] = process.env[key] as string
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function ensureStateDir(): void {
|
||||
if (!existsSync(DEFAULT_STATE_DIR)) {
|
||||
mkdirSync(DEFAULT_STATE_DIR, { recursive: true, mode: 0o700 })
|
||||
}
|
||||
}
|
||||
|
||||
function writeSecureFile(filePath: string, content: string): void {
|
||||
ensureStateDir()
|
||||
writeFileSync(filePath, content, { mode: SECURE_FILE_MODE })
|
||||
try {
|
||||
chmodSync(filePath, SECURE_FILE_MODE)
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function rotateLogIfNeeded(logPath: string): void {
|
||||
try {
|
||||
if (!existsSync(logPath)) return
|
||||
const stats = statSync(logPath)
|
||||
if (stats.size > MAX_LOG_SIZE_BYTES) {
|
||||
const backupPath = `${logPath}.old`
|
||||
if (existsSync(backupPath)) {
|
||||
unlinkSync(backupPath)
|
||||
}
|
||||
renameSync(logPath, backupPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function log(message: string): void {
|
||||
try {
|
||||
ensureStateDir()
|
||||
rotateLogIfNeeded(LOG_FILE_PATH)
|
||||
const timestamp = new Date().toISOString()
|
||||
const logLine = `[${timestamp}] ${message}\n`
|
||||
appendFileSync(LOG_FILE_PATH, logLine, { mode: SECURE_FILE_MODE })
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function logReplyListenerMessage(message: string): void {
|
||||
log(message)
|
||||
}
|
||||
|
||||
interface DaemonState {
|
||||
isRunning: boolean
|
||||
pid: number | null
|
||||
startedAt: string
|
||||
lastPollAt: string | null
|
||||
telegramLastUpdateId: number | null
|
||||
discordLastMessageId: string | null
|
||||
messagesInjected: number
|
||||
errors: number
|
||||
lastError?: string
|
||||
}
|
||||
|
||||
function readDaemonState(): DaemonState | null {
|
||||
try {
|
||||
if (!existsSync(STATE_FILE_PATH)) return null
|
||||
const content = readFileSync(STATE_FILE_PATH, "utf-8")
|
||||
return JSON.parse(content)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeDaemonState(state: DaemonState): void {
|
||||
writeSecureFile(STATE_FILE_PATH, JSON.stringify(state, null, 2))
|
||||
}
|
||||
|
||||
function readDaemonConfig(): OpenClawConfig | null {
|
||||
try {
|
||||
if (!existsSync(CONFIG_FILE_PATH)) return null
|
||||
const content = readFileSync(CONFIG_FILE_PATH, "utf-8")
|
||||
return JSON.parse(content)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeDaemonConfig(config: OpenClawConfig): void {
|
||||
writeSecureFile(CONFIG_FILE_PATH, JSON.stringify(config, null, 2))
|
||||
}
|
||||
|
||||
function readPidFile(): number | null {
|
||||
try {
|
||||
if (!existsSync(PID_FILE_PATH)) return null
|
||||
const content = readFileSync(PID_FILE_PATH, "utf-8")
|
||||
const pid = parseInt(content.trim(), 10)
|
||||
if (Number.isNaN(pid)) return null
|
||||
return pid
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writePidFile(pid: number): void {
|
||||
writeSecureFile(PID_FILE_PATH, String(pid))
|
||||
}
|
||||
|
||||
function removePidFile(): void {
|
||||
if (existsSync(PID_FILE_PATH)) {
|
||||
unlinkSync(PID_FILE_PATH)
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function isReplyListenerProcess(pid: number): Promise<boolean> {
|
||||
try {
|
||||
if (process.platform === "linux") {
|
||||
const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf-8")
|
||||
return cmdline.includes(DAEMON_IDENTITY_MARKER)
|
||||
}
|
||||
// macOS
|
||||
const proc = spawn(["ps", "-p", String(pid), "-o", "args="], {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
if (proc.exitCode !== 0) return false
|
||||
return stdout.includes(DAEMON_IDENTITY_MARKER)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function isDaemonRunning(): Promise<boolean> {
|
||||
const pid = readPidFile()
|
||||
if (pid === null) return false
|
||||
if (!isProcessRunning(pid)) {
|
||||
removePidFile()
|
||||
return false
|
||||
}
|
||||
if (!(await isReplyListenerProcess(pid))) {
|
||||
removePidFile()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Input Sanitization
|
||||
export function sanitizeReplyInput(text: string): string {
|
||||
return text
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
|
||||
.replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "")
|
||||
.replace(/\r?\n/g, " ")
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/`/g, "\\`")
|
||||
.replace(/\$\(/g, "\\$(")
|
||||
.replace(/\$\{/g, "\\${")
|
||||
.trim()
|
||||
}
|
||||
|
||||
class RateLimiter {
|
||||
maxPerMinute: number
|
||||
timestamps: number[] = []
|
||||
windowMs = 60 * 1000
|
||||
|
||||
constructor(maxPerMinute: number) {
|
||||
this.maxPerMinute = maxPerMinute
|
||||
}
|
||||
|
||||
canProceed(): boolean {
|
||||
const now = Date.now()
|
||||
this.timestamps = this.timestamps.filter((t) => now - t < this.windowMs)
|
||||
if (this.timestamps.length >= this.maxPerMinute) return false
|
||||
this.timestamps.push(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function injectReply(
|
||||
paneId: string,
|
||||
text: string,
|
||||
platform: string,
|
||||
config: OpenClawConfig,
|
||||
): Promise<boolean> {
|
||||
const replyListener = config.replyListener
|
||||
const content = await captureTmuxPane(paneId, 15)
|
||||
const analysis = analyzePaneContent(content)
|
||||
|
||||
if (analysis.confidence < 0.3) { // Lower threshold for simple check
|
||||
log(
|
||||
`WARN: Pane ${paneId} does not appear to be running OpenCode CLI (confidence: ${analysis.confidence}). Skipping injection, removing stale mapping.`,
|
||||
)
|
||||
removeMessagesByPane(paneId)
|
||||
return false
|
||||
}
|
||||
|
||||
const prefix = replyListener?.includePrefix === false ? "" : `[reply:${platform}] `
|
||||
const sanitized = sanitizeReplyInput(prefix + text)
|
||||
const truncated = sanitized.slice(0, replyListener?.maxMessageLength ?? 500)
|
||||
const success = await sendToPane(paneId, truncated, true)
|
||||
|
||||
if (success) {
|
||||
log(
|
||||
`Injected reply from ${platform} into pane ${paneId}: "${truncated.slice(0, 50)}${truncated.length > 50 ? "..." : ""}"`,
|
||||
)
|
||||
} else {
|
||||
log(`ERROR: Failed to inject reply into pane ${paneId}`)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
let discordBackoffUntil = 0
|
||||
|
||||
async function pollDiscord(
|
||||
config: OpenClawConfig,
|
||||
state: DaemonState,
|
||||
rateLimiter: RateLimiter,
|
||||
): Promise<void> {
|
||||
const replyListener = config.replyListener
|
||||
if (!replyListener?.discordBotToken || !replyListener.discordChannelId) return
|
||||
if (
|
||||
!replyListener.authorizedDiscordUserIds
|
||||
|| replyListener.authorizedDiscordUserIds.length === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (Date.now() < discordBackoffUntil) return
|
||||
|
||||
try {
|
||||
const after = state.discordLastMessageId
|
||||
? `?after=${state.discordLastMessageId}&limit=10`
|
||||
: "?limit=10"
|
||||
const url = `https://discord.com/api/v10/channels/${replyListener.discordChannelId}/messages${after}`
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bot ${replyListener.discordBotToken}` },
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
const remaining = response.headers.get("x-ratelimit-remaining")
|
||||
const reset = response.headers.get("x-ratelimit-reset")
|
||||
|
||||
if (remaining !== null && parseInt(remaining, 10) < 2) {
|
||||
const parsed = reset ? parseFloat(reset) : Number.NaN
|
||||
const resetTime = Number.isFinite(parsed) ? parsed * 1000 : Date.now() + 10000
|
||||
discordBackoffUntil = resetTime
|
||||
log(
|
||||
`WARN: Discord rate limit low (remaining: ${remaining}), backing off until ${new Date(resetTime).toISOString()}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
log(`Discord API error: HTTP ${response.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
const messages = await response.json()
|
||||
if (!Array.isArray(messages) || messages.length === 0) return
|
||||
|
||||
const sorted = [...messages].reverse()
|
||||
|
||||
for (const msg of sorted) {
|
||||
if (!msg.message_reference?.message_id) {
|
||||
state.discordLastMessageId = msg.id
|
||||
writeDaemonState(state)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!replyListener.authorizedDiscordUserIds.includes(msg.author.id)) {
|
||||
state.discordLastMessageId = msg.id
|
||||
writeDaemonState(state)
|
||||
continue
|
||||
}
|
||||
|
||||
const mapping = lookupByMessageId("discord-bot", msg.message_reference.message_id)
|
||||
if (!mapping) {
|
||||
state.discordLastMessageId = msg.id
|
||||
writeDaemonState(state)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!rateLimiter.canProceed()) {
|
||||
log(`WARN: Rate limit exceeded, dropping Discord message ${msg.id}`)
|
||||
state.discordLastMessageId = msg.id
|
||||
writeDaemonState(state)
|
||||
state.errors++
|
||||
continue
|
||||
}
|
||||
|
||||
state.discordLastMessageId = msg.id
|
||||
writeDaemonState(state)
|
||||
|
||||
const success = await injectReply(mapping.tmuxPaneId, msg.content, "discord", config)
|
||||
|
||||
if (success) {
|
||||
state.messagesInjected++
|
||||
// Add reaction
|
||||
try {
|
||||
await fetch(
|
||||
`https://discord.com/api/v10/channels/${replyListener.discordChannelId}/messages/${msg.id}/reactions/%E2%9C%85/@me`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bot ${replyListener.discordBotToken}` },
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} else {
|
||||
state.errors++
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
state.errors++
|
||||
state.lastError = error instanceof Error ? error.message : String(error)
|
||||
log(`Discord polling error: ${state.lastError}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTelegram(
|
||||
config: OpenClawConfig,
|
||||
state: DaemonState,
|
||||
rateLimiter: RateLimiter,
|
||||
): Promise<void> {
|
||||
const replyListener = config.replyListener
|
||||
if (!replyListener?.telegramBotToken || !replyListener.telegramChatId) return
|
||||
|
||||
try {
|
||||
const offset = state.telegramLastUpdateId ? state.telegramLastUpdateId + 1 : 0
|
||||
const url = `https://api.telegram.org/bot${replyListener.telegramBotToken}/getUpdates?offset=${offset}&timeout=0`
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (!response.ok) {
|
||||
log(`Telegram API error: HTTP ${response.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
const body = await response.json() as any
|
||||
const updates = body.result || []
|
||||
|
||||
for (const update of updates) {
|
||||
const msg = update.message
|
||||
if (!msg) {
|
||||
state.telegramLastUpdateId = update.update_id
|
||||
writeDaemonState(state)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!msg.reply_to_message?.message_id) {
|
||||
state.telegramLastUpdateId = update.update_id
|
||||
writeDaemonState(state)
|
||||
continue
|
||||
}
|
||||
|
||||
if (String(msg.chat.id) !== replyListener.telegramChatId) {
|
||||
state.telegramLastUpdateId = update.update_id
|
||||
writeDaemonState(state)
|
||||
continue
|
||||
}
|
||||
|
||||
const mapping = lookupByMessageId("telegram", String(msg.reply_to_message.message_id))
|
||||
if (!mapping) {
|
||||
state.telegramLastUpdateId = update.update_id
|
||||
writeDaemonState(state)
|
||||
continue
|
||||
}
|
||||
|
||||
const text = msg.text || ""
|
||||
if (!text) {
|
||||
state.telegramLastUpdateId = update.update_id
|
||||
writeDaemonState(state)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!rateLimiter.canProceed()) {
|
||||
log(`WARN: Rate limit exceeded, dropping Telegram message ${msg.message_id}`)
|
||||
state.telegramLastUpdateId = update.update_id
|
||||
writeDaemonState(state)
|
||||
state.errors++
|
||||
continue
|
||||
}
|
||||
|
||||
state.telegramLastUpdateId = update.update_id
|
||||
writeDaemonState(state)
|
||||
|
||||
const success = await injectReply(mapping.tmuxPaneId, text, "telegram", config)
|
||||
|
||||
if (success) {
|
||||
state.messagesInjected++
|
||||
try {
|
||||
await fetch(
|
||||
`https://api.telegram.org/bot${replyListener.telegramBotToken}/sendMessage`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: replyListener.telegramChatId,
|
||||
text: "Injected into Codex CLI session.",
|
||||
reply_to_message_id: msg.message_id,
|
||||
}),
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} else {
|
||||
state.errors++
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
state.errors++
|
||||
state.lastError = error instanceof Error ? error.message : String(error)
|
||||
log(`Telegram polling error: ${state.lastError}`)
|
||||
}
|
||||
}
|
||||
|
||||
const PRUNE_INTERVAL_MS = 60 * 60 * 1000
|
||||
|
||||
export async function pollLoop(): Promise<void> {
|
||||
log("Reply listener daemon starting poll loop")
|
||||
const config = readDaemonConfig()
|
||||
if (!config) {
|
||||
log("ERROR: No daemon config found, exiting")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const state = readDaemonState() || {
|
||||
isRunning: true,
|
||||
pid: process.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastPollAt: null,
|
||||
telegramLastUpdateId: null,
|
||||
discordLastMessageId: null,
|
||||
messagesInjected: 0,
|
||||
errors: 0,
|
||||
}
|
||||
|
||||
state.isRunning = true
|
||||
state.pid = process.pid
|
||||
|
||||
const rateLimiter = new RateLimiter(config.replyListener?.rateLimitPerMinute || 10)
|
||||
let lastPruneAt = Date.now()
|
||||
|
||||
const shutdown = (): void => {
|
||||
log("Shutdown signal received")
|
||||
state.isRunning = false
|
||||
writeDaemonState(state)
|
||||
removePidFile()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGTERM", shutdown)
|
||||
process.on("SIGINT", shutdown)
|
||||
|
||||
try {
|
||||
pruneStale()
|
||||
log("Pruned stale registry entries")
|
||||
} catch (e) {
|
||||
log(`WARN: Failed to prune stale entries: ${e}`)
|
||||
}
|
||||
|
||||
while (state.isRunning) {
|
||||
try {
|
||||
state.lastPollAt = new Date().toISOString()
|
||||
await pollDiscord(config, state, rateLimiter)
|
||||
await pollTelegram(config, state, rateLimiter)
|
||||
|
||||
if (Date.now() - lastPruneAt > PRUNE_INTERVAL_MS) {
|
||||
try {
|
||||
pruneStale()
|
||||
lastPruneAt = Date.now()
|
||||
log("Pruned stale registry entries")
|
||||
} catch (e) {
|
||||
log(`WARN: Prune failed: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
writeDaemonState(state)
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, config.replyListener?.pollIntervalMs || 3000),
|
||||
)
|
||||
} catch (error) {
|
||||
state.errors++
|
||||
state.lastError = error instanceof Error ? error.message : String(error)
|
||||
log(`Poll error: ${state.lastError}`)
|
||||
writeDaemonState(state)
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, (config.replyListener?.pollIntervalMs || 3000) * 2),
|
||||
)
|
||||
}
|
||||
}
|
||||
log("Poll loop ended")
|
||||
}
|
||||
|
||||
export async function startReplyListener(config: OpenClawConfig): Promise<{ success: boolean; message: string; state?: DaemonState; error?: string }> {
|
||||
if (await isDaemonRunning()) {
|
||||
const state = readDaemonState()
|
||||
return {
|
||||
success: true,
|
||||
message: "Reply listener daemon is already running",
|
||||
state: state || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await isTmuxAvailable())) {
|
||||
return {
|
||||
success: false,
|
||||
message: "tmux not available - reply injection requires tmux",
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedConfig = normalizeReplyListenerConfig(config)
|
||||
const replyListener = normalizedConfig.replyListener
|
||||
if (!replyListener?.discordBotToken && !replyListener?.telegramBotToken) {
|
||||
// Only warn if no platforms enabled, but user might just want outbound
|
||||
// Actually, instructions say: "Fire-and-forget for outbound, daemon process for inbound"
|
||||
// So if no inbound config, we shouldn't start daemon.
|
||||
return {
|
||||
success: false,
|
||||
message: "No enabled reply listener platforms configured (missing bot tokens/channels)",
|
||||
}
|
||||
}
|
||||
|
||||
writeDaemonConfig(normalizedConfig)
|
||||
ensureStateDir()
|
||||
|
||||
const currentFile = import.meta.url
|
||||
const isTs = currentFile.endsWith(".ts")
|
||||
const daemonScript = isTs
|
||||
? join(dirname(new URL(currentFile).pathname), "daemon.ts")
|
||||
: join(dirname(new URL(currentFile).pathname), "daemon.js")
|
||||
|
||||
try {
|
||||
const proc = spawn(["bun", "run", daemonScript, DAEMON_IDENTITY_MARKER], {
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
cwd: process.cwd(),
|
||||
env: createMinimalDaemonEnv(),
|
||||
})
|
||||
|
||||
proc.unref()
|
||||
const pid = proc.pid
|
||||
|
||||
if (pid) {
|
||||
writePidFile(pid)
|
||||
const state: DaemonState = {
|
||||
isRunning: true,
|
||||
pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastPollAt: null,
|
||||
telegramLastUpdateId: null,
|
||||
discordLastMessageId: null,
|
||||
messagesInjected: 0,
|
||||
errors: 0,
|
||||
}
|
||||
writeDaemonState(state)
|
||||
log(`Reply listener daemon started with PID ${pid}`)
|
||||
return {
|
||||
success: true,
|
||||
message: `Reply listener daemon started with PID ${pid}`,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to start daemon process",
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to start daemon",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopReplyListener(): Promise<{ success: boolean; message: string; state?: DaemonState; error?: string }> {
|
||||
const pid = readPidFile()
|
||||
if (pid === null) {
|
||||
return {
|
||||
success: true,
|
||||
message: "Reply listener daemon is not running",
|
||||
}
|
||||
}
|
||||
|
||||
if (!isProcessRunning(pid)) {
|
||||
removePidFile()
|
||||
return {
|
||||
success: true,
|
||||
message: "Reply listener daemon was not running (cleaned up stale PID file)",
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await isReplyListenerProcess(pid))) {
|
||||
removePidFile()
|
||||
return {
|
||||
success: false,
|
||||
message: `Refusing to kill PID ${pid}: process identity does not match the reply listener daemon (stale or reused PID - removed PID file)`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, "SIGTERM")
|
||||
removePidFile()
|
||||
const state = readDaemonState()
|
||||
if (state) {
|
||||
state.isRunning = false
|
||||
state.pid = null
|
||||
writeDaemonState(state)
|
||||
}
|
||||
log(`Reply listener daemon stopped (PID ${pid})`)
|
||||
return {
|
||||
success: true,
|
||||
message: `Reply listener daemon stopped (PID ${pid})`,
|
||||
state: state || undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to stop daemon",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
openSync,
|
||||
closeSync,
|
||||
writeSync,
|
||||
unlinkSync,
|
||||
statSync,
|
||||
constants,
|
||||
} from "fs"
|
||||
import { join, dirname } from "path"
|
||||
import { randomUUID } from "crypto"
|
||||
import { getOpenCodeStorageDir } from "../shared/data-path"
|
||||
|
||||
const OPENCLAW_STORAGE_DIR = join(getOpenCodeStorageDir(), "openclaw")
|
||||
const REGISTRY_PATH = join(OPENCLAW_STORAGE_DIR, "reply-session-registry.jsonl")
|
||||
const REGISTRY_LOCK_PATH = join(OPENCLAW_STORAGE_DIR, "reply-session-registry.lock")
|
||||
const SECURE_FILE_MODE = 0o600
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||
const LOCK_TIMEOUT_MS = 2000
|
||||
const LOCK_WAIT_TIMEOUT_MS = 4000
|
||||
const LOCK_RETRY_MS = 20
|
||||
const LOCK_STALE_MS = 10000
|
||||
|
||||
export interface SessionMapping {
|
||||
sessionId: string
|
||||
tmuxSession: string
|
||||
tmuxPaneId: string
|
||||
projectPath: string
|
||||
platform: string
|
||||
messageId: string
|
||||
channelId?: string
|
||||
threadId?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function ensureRegistryDir(): void {
|
||||
const registryDir = dirname(REGISTRY_PATH)
|
||||
if (!existsSync(registryDir)) {
|
||||
mkdirSync(registryDir, { recursive: true, mode: 0o700 })
|
||||
}
|
||||
}
|
||||
|
||||
function sleepMs(ms: number): void {
|
||||
// Use Atomics.wait for synchronous sleep
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
||||
}
|
||||
|
||||
function isPidAlive(pid: number): boolean {
|
||||
if (!Number.isFinite(pid) || pid <= 0) return false
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM"
|
||||
}
|
||||
}
|
||||
|
||||
interface LockSnapshot {
|
||||
raw: string
|
||||
pid: number | null
|
||||
token: string | null
|
||||
}
|
||||
|
||||
function readLockSnapshot(): LockSnapshot | null {
|
||||
try {
|
||||
if (!existsSync(REGISTRY_LOCK_PATH)) return null
|
||||
const raw = readFileSync(REGISTRY_LOCK_PATH, "utf-8")
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return { raw, pid: null, token: null }
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
const pid =
|
||||
typeof parsed.pid === "number" && Number.isFinite(parsed.pid) ? parsed.pid : null
|
||||
const token =
|
||||
typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null
|
||||
return { raw, pid, token }
|
||||
} catch {
|
||||
// Legacy format or plain PID
|
||||
const [pidStr] = trimmed.split(":")
|
||||
const parsedPid = Number.parseInt(pidStr ?? "", 10)
|
||||
return {
|
||||
raw,
|
||||
pid: Number.isFinite(parsedPid) && parsedPid > 0 ? parsedPid : null,
|
||||
token: null,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function removeLockIfUnchanged(snapshot: LockSnapshot): boolean {
|
||||
try {
|
||||
if (!existsSync(REGISTRY_LOCK_PATH)) return false
|
||||
const currentRaw = readFileSync(REGISTRY_LOCK_PATH, "utf-8")
|
||||
if (currentRaw !== snapshot.raw) return false
|
||||
unlinkSync(REGISTRY_LOCK_PATH)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface LockHandle {
|
||||
fd: number
|
||||
token: string
|
||||
}
|
||||
|
||||
function acquireRegistryLock(): LockHandle | null {
|
||||
ensureRegistryDir()
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < LOCK_TIMEOUT_MS) {
|
||||
try {
|
||||
const token = randomUUID()
|
||||
const fd = openSync(
|
||||
REGISTRY_LOCK_PATH,
|
||||
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
|
||||
SECURE_FILE_MODE,
|
||||
)
|
||||
try {
|
||||
const lockPayload = JSON.stringify({
|
||||
pid: process.pid,
|
||||
acquiredAt: Date.now(),
|
||||
token,
|
||||
})
|
||||
writeSync(fd, lockPayload)
|
||||
} catch (writeError) {
|
||||
try {
|
||||
closeSync(fd)
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
try {
|
||||
unlinkSync(REGISTRY_LOCK_PATH)
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
throw writeError
|
||||
}
|
||||
return { fd, token }
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err.code !== "EEXIST") throw error
|
||||
|
||||
try {
|
||||
const stats = statSync(REGISTRY_LOCK_PATH)
|
||||
const lockAgeMs = Date.now() - stats.mtimeMs
|
||||
if (lockAgeMs > LOCK_STALE_MS) {
|
||||
const snapshot = readLockSnapshot()
|
||||
if (!snapshot) {
|
||||
sleepMs(LOCK_RETRY_MS)
|
||||
continue
|
||||
}
|
||||
if (snapshot.pid !== null && isPidAlive(snapshot.pid)) {
|
||||
sleepMs(LOCK_RETRY_MS)
|
||||
continue
|
||||
}
|
||||
if (removeLockIfUnchanged(snapshot)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
sleepMs(LOCK_RETRY_MS)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function acquireRegistryLockOrWait(maxWaitMs = LOCK_WAIT_TIMEOUT_MS): LockHandle | null {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < maxWaitMs) {
|
||||
const lock = acquireRegistryLock()
|
||||
if (lock !== null) return lock
|
||||
if (Date.now() - started < maxWaitMs) {
|
||||
sleepMs(LOCK_RETRY_MS)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function releaseRegistryLock(lock: LockHandle): void {
|
||||
try {
|
||||
closeSync(lock.fd)
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const snapshot = readLockSnapshot()
|
||||
if (!snapshot || snapshot.token !== lock.token) return
|
||||
removeLockIfUnchanged(snapshot)
|
||||
}
|
||||
|
||||
function withRegistryLockOrWait<T>(
|
||||
onLocked: () => T,
|
||||
onLockUnavailable: () => T,
|
||||
): T {
|
||||
const lock = acquireRegistryLockOrWait()
|
||||
if (lock === null) return onLockUnavailable()
|
||||
try {
|
||||
return onLocked()
|
||||
} finally {
|
||||
releaseRegistryLock(lock)
|
||||
}
|
||||
}
|
||||
|
||||
function withRegistryLock(onLocked: () => void, onLockUnavailable: () => void): void {
|
||||
const lock = acquireRegistryLock()
|
||||
if (lock === null) {
|
||||
onLockUnavailable()
|
||||
return
|
||||
}
|
||||
try {
|
||||
onLocked()
|
||||
} finally {
|
||||
releaseRegistryLock(lock)
|
||||
}
|
||||
}
|
||||
|
||||
function readAllMappingsUnsafe(): SessionMapping[] {
|
||||
if (!existsSync(REGISTRY_PATH)) return []
|
||||
try {
|
||||
const content = readFileSync(REGISTRY_PATH, "utf-8")
|
||||
return content
|
||||
.split("\n")
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as SessionMapping
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((m): m is SessionMapping => m !== null)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteRegistryUnsafe(mappings: SessionMapping[]): void {
|
||||
ensureRegistryDir()
|
||||
if (mappings.length === 0) {
|
||||
writeFileSync(REGISTRY_PATH, "", { mode: SECURE_FILE_MODE })
|
||||
return
|
||||
}
|
||||
const content = mappings.map((m) => JSON.stringify(m)).join("\n") + "\n"
|
||||
writeFileSync(REGISTRY_PATH, content, { mode: SECURE_FILE_MODE })
|
||||
}
|
||||
|
||||
export function registerMessage(mapping: SessionMapping): boolean {
|
||||
return withRegistryLockOrWait(
|
||||
() => {
|
||||
ensureRegistryDir()
|
||||
const line = JSON.stringify(mapping) + "\n"
|
||||
const fd = openSync(
|
||||
REGISTRY_PATH,
|
||||
constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT,
|
||||
SECURE_FILE_MODE,
|
||||
)
|
||||
try {
|
||||
writeSync(fd, line)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
return true
|
||||
},
|
||||
() => {
|
||||
console.warn(
|
||||
"[notifications] session registry lock unavailable; skipping reply correlation write",
|
||||
)
|
||||
return false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function loadAllMappings(): SessionMapping[] {
|
||||
return withRegistryLockOrWait(
|
||||
() => readAllMappingsUnsafe(),
|
||||
() => [],
|
||||
)
|
||||
}
|
||||
|
||||
export function lookupByMessageId(platform: string, messageId: string): SessionMapping | null {
|
||||
const mappings = loadAllMappings()
|
||||
return mappings.find((m) => m.platform === platform && m.messageId === messageId) || null
|
||||
}
|
||||
|
||||
export function removeSession(sessionId: string): void {
|
||||
withRegistryLock(
|
||||
() => {
|
||||
const mappings = readAllMappingsUnsafe()
|
||||
const filtered = mappings.filter((m) => m.sessionId !== sessionId)
|
||||
if (filtered.length === mappings.length) return
|
||||
rewriteRegistryUnsafe(filtered)
|
||||
},
|
||||
() => {
|
||||
// Best-effort
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function removeMessagesByPane(paneId: string): void {
|
||||
withRegistryLock(
|
||||
() => {
|
||||
const mappings = readAllMappingsUnsafe()
|
||||
const filtered = mappings.filter((m) => m.tmuxPaneId !== paneId)
|
||||
if (filtered.length === mappings.length) return
|
||||
rewriteRegistryUnsafe(filtered)
|
||||
},
|
||||
() => {
|
||||
// Best-effort
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function pruneStale(): void {
|
||||
withRegistryLock(
|
||||
() => {
|
||||
const now = Date.now()
|
||||
const mappings = readAllMappingsUnsafe()
|
||||
const filtered = mappings.filter((m) => {
|
||||
try {
|
||||
const age = now - new Date(m.createdAt).getTime()
|
||||
return age < MAX_AGE_MS
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
if (filtered.length === mappings.length) return
|
||||
rewriteRegistryUnsafe(filtered)
|
||||
},
|
||||
() => {
|
||||
// Best-effort
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { spawn } from "bun"
|
||||
|
||||
export function getCurrentTmuxSession(): string | null {
|
||||
const env = process.env.TMUX
|
||||
if (!env) return null
|
||||
const match = env.match(/(\d+)$/)
|
||||
return match ? `session-${match[1]}` : null // Wait, TMUX env is /tmp/tmux-501/default,1234,0
|
||||
// Reference tmux.js gets session name via `tmux display-message -p '#S'`
|
||||
}
|
||||
|
||||
export async function getTmuxSessionName(): Promise<string | null> {
|
||||
try {
|
||||
const proc = spawn(["tmux", "display-message", "-p", "#S"], {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const outputPromise = new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
const output = await outputPromise
|
||||
// Await proc.exited ensures exitCode is set; avoid race condition
|
||||
if (proc.exitCode !== 0) return null
|
||||
return output.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureTmuxPane(paneId: string, lines = 15): Promise<string | null> {
|
||||
try {
|
||||
const proc = spawn(
|
||||
["tmux", "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const outputPromise = new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
const output = await outputPromise
|
||||
if (proc.exitCode !== 0) return null
|
||||
return output.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendToPane(paneId: string, text: string, confirm = true): Promise<boolean> {
|
||||
try {
|
||||
const literalProc = spawn(["tmux", "send-keys", "-t", paneId, "-l", "--", text], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
await literalProc.exited
|
||||
if (literalProc.exitCode !== 0) return false
|
||||
|
||||
if (!confirm) return true
|
||||
|
||||
const enterProc = spawn(["tmux", "send-keys", "-t", paneId, "Enter"], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
await enterProc.exited
|
||||
return enterProc.exitCode === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function isTmuxAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const proc = spawn(["tmux", "-V"], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
await proc.exited
|
||||
return proc.exitCode === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function analyzePaneContent(content: string | null): { confidence: number } {
|
||||
if (!content) return { confidence: 0 }
|
||||
|
||||
let confidence = 0
|
||||
if (content.includes("opencode")) confidence += 0.3
|
||||
if (content.includes("Ask anything...")) confidence += 0.5
|
||||
if (content.includes("Run /help")) confidence += 0.2
|
||||
|
||||
return { confidence: Math.min(1, confidence) }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
OpenClawConfig,
|
||||
OpenClawGateway,
|
||||
OpenClawHook,
|
||||
OpenClawReplyListenerConfig,
|
||||
} from "../config/schema/openclaw"
|
||||
|
||||
export type {
|
||||
OpenClawConfig,
|
||||
OpenClawGateway,
|
||||
OpenClawHook,
|
||||
OpenClawReplyListenerConfig,
|
||||
}
|
||||
|
||||
export interface OpenClawContext {
|
||||
sessionId?: string
|
||||
projectPath?: string
|
||||
projectName?: string
|
||||
tmuxSession?: string
|
||||
prompt?: string
|
||||
contextSummary?: string
|
||||
reasoning?: string
|
||||
question?: string
|
||||
tmuxTail?: string
|
||||
replyChannel?: string
|
||||
replyTarget?: string
|
||||
replyThread?: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
|
||||
export interface OpenClawPayload {
|
||||
event: string
|
||||
instruction: string
|
||||
text: string
|
||||
timestamp: string
|
||||
sessionId?: string
|
||||
projectPath?: string
|
||||
projectName?: string
|
||||
tmuxSession?: string
|
||||
tmuxTail?: string
|
||||
channel?: string
|
||||
to?: string
|
||||
threadId?: string
|
||||
context: OpenClawContext
|
||||
}
|
||||
|
||||
export interface WakeResult {
|
||||
gateway: string
|
||||
success: boolean
|
||||
error?: string
|
||||
statusCode?: number
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and composing 46 hooks into the PluginInterface. Every handler file corresponds to one OpenCode hook type.
|
||||
Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and composing 48 hooks into the PluginInterface. Every handler file corresponds to one OpenCode hook type.
|
||||
|
||||
## HANDLER FILES
|
||||
|
||||
@@ -25,9 +25,9 @@ Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and com
|
||||
| File | Tier | Count |
|
||||
|------|------|-------|
|
||||
| `create-session-hooks.ts` | Session | 23 |
|
||||
| `create-tool-guard-hooks.ts` | Tool Guard | 10 |
|
||||
| `create-tool-guard-hooks.ts` | Tool Guard | 12 |
|
||||
| `create-skill-hooks.ts` | Skill | 2 |
|
||||
| `create-core-hooks.ts` | Aggregator | Session + Guard + Transform = 37 |
|
||||
| `create-core-hooks.ts` | Aggregator | Session + Guard + Transform = 39 |
|
||||
|
||||
## SUPPORT FILES
|
||||
|
||||
|
||||
@@ -195,7 +195,6 @@ export function createEventHandler(args: {
|
||||
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
|
||||
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));
|
||||
await Promise.resolve(hooks.sessionNotification?.(input));
|
||||
await Promise.resolve(hooks.gptPermissionContinuation?.handler?.(input));
|
||||
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input));
|
||||
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input));
|
||||
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input));
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { PluginContext } from "../types"
|
||||
|
||||
import {
|
||||
createGptPermissionContinuationHook,
|
||||
createTodoContinuationEnforcer,
|
||||
createBackgroundNotificationHook,
|
||||
createStopContinuationGuardHook,
|
||||
@@ -15,7 +14,6 @@ import { safeCreateHook } from "../../shared/safe-create-hook"
|
||||
import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter"
|
||||
|
||||
export type ContinuationHooks = {
|
||||
gptPermissionContinuation: ReturnType<typeof createGptPermissionContinuationHook> | null
|
||||
stopContinuationGuard: ReturnType<typeof createStopContinuationGuardHook> | null
|
||||
compactionContextInjector: ReturnType<typeof createCompactionContextInjector> | null
|
||||
compactionTodoPreserver: ReturnType<typeof createCompactionTodoPreserverHook> | null
|
||||
@@ -57,13 +55,6 @@ export function createContinuationHooks(args: {
|
||||
}))
|
||||
: null
|
||||
|
||||
const gptPermissionContinuation = isHookEnabled("gpt-permission-continuation")
|
||||
? safeHook("gpt-permission-continuation", () =>
|
||||
createGptPermissionContinuationHook(ctx, {
|
||||
isContinuationStopped: stopContinuationGuard?.isStopped,
|
||||
}))
|
||||
: null
|
||||
|
||||
const compactionContextInjector = isHookEnabled("compaction-context-injector")
|
||||
? safeHook("compaction-context-injector", () =>
|
||||
createCompactionContextInjector({ ctx, backgroundManager }))
|
||||
@@ -78,8 +69,6 @@ export function createContinuationHooks(args: {
|
||||
createTodoContinuationEnforcer(ctx, {
|
||||
backgroundManager,
|
||||
isContinuationStopped: stopContinuationGuard?.isStopped,
|
||||
shouldSkipContinuation: (sessionID: string) =>
|
||||
gptPermissionContinuation?.wasRecentlyInjected(sessionID) ?? false,
|
||||
}))
|
||||
: null
|
||||
|
||||
@@ -122,15 +111,12 @@ export function createContinuationHooks(args: {
|
||||
backgroundManager,
|
||||
isContinuationStopped: (sessionID: string) =>
|
||||
stopContinuationGuard?.isStopped(sessionID) ?? false,
|
||||
shouldSkipContinuation: (sessionID: string) =>
|
||||
gptPermissionContinuation?.wasRecentlyInjected(sessionID) ?? false,
|
||||
agentOverrides: pluginConfig.agents,
|
||||
autoCommit: pluginConfig.start_work?.auto_commit,
|
||||
}))
|
||||
: null
|
||||
|
||||
return {
|
||||
gptPermissionContinuation,
|
||||
stopContinuationGuard,
|
||||
compactionContextInjector,
|
||||
compactionTodoPreserver,
|
||||
|
||||
@@ -6,6 +6,17 @@ import { readState, writeState } from "../hooks/ralph-loop/storage"
|
||||
|
||||
const VERIFICATION_ATTEMPT_PATTERN = /<ulw_verification_attempt_id>(.*?)<\/ulw_verification_attempt_id>/i
|
||||
|
||||
function getMetadataString(metadata: Record<string, unknown> | undefined, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = metadata?.[key]
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getPluginDirectory(ctx: PluginContext): string | null {
|
||||
if (typeof ctx === "object" && ctx !== null && "directory" in ctx && typeof ctx.directory === "string") {
|
||||
return ctx.directory
|
||||
@@ -43,9 +54,9 @@ export function createToolExecuteAfterHandler(args: {
|
||||
|
||||
if (input.tool === "task") {
|
||||
const directory = getPluginDirectory(ctx)
|
||||
const sessionId = typeof output.metadata?.sessionId === "string" ? output.metadata.sessionId : undefined
|
||||
const agent = typeof output.metadata?.agent === "string" ? output.metadata.agent : undefined
|
||||
const prompt = typeof output.metadata?.prompt === "string" ? output.metadata.prompt : undefined
|
||||
const sessionId = getMetadataString(output.metadata, ["sessionId", "sessionID", "session_id"])
|
||||
const agent = getMetadataString(output.metadata, ["agent"])
|
||||
const prompt = getMetadataString(output.metadata, ["prompt"])
|
||||
const verificationAttemptId = prompt?.match(VERIFICATION_ATTEMPT_PATTERN)?.[1]?.trim()
|
||||
const loopState = directory ? readState(directory) : null
|
||||
const isVerificationContext =
|
||||
|
||||
@@ -20,6 +20,26 @@ export function createToolExecuteBeforeHandler(args: {
|
||||
) => Promise<void> {
|
||||
const { ctx, hooks } = args
|
||||
|
||||
function buildUltraworkOracleVerificationPrompt(prompt: string, originalTask: string, verificationAttemptId: string): string {
|
||||
const verificationPrompt = [
|
||||
"You are verifying the active ULTRAWORK loop result for this session.",
|
||||
"",
|
||||
"Original task:",
|
||||
originalTask,
|
||||
"",
|
||||
"Review the work skeptically and critically.",
|
||||
"Assume it may be incomplete, misleading, or subtly broken until the evidence proves otherwise.",
|
||||
"Look for missing scope, weak verification, process violations, hidden regressions, and any reason the task should NOT be considered complete.",
|
||||
"",
|
||||
`If the work is fully complete, end your response with <promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>.`,
|
||||
"If the work is not complete, explain the blocking issues clearly and DO NOT emit that promise.",
|
||||
"",
|
||||
`<ulw_verification_attempt_id>${verificationAttemptId}</ulw_verification_attempt_id>`,
|
||||
].join("\n")
|
||||
|
||||
return `${prompt ? `${prompt}\n\n` : ""}${verificationPrompt}`
|
||||
}
|
||||
|
||||
return async (input, output): Promise<void> => {
|
||||
await hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output)
|
||||
await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output)
|
||||
@@ -91,7 +111,11 @@ export function createToolExecuteBeforeHandler(args: {
|
||||
verification_session_id: undefined,
|
||||
})
|
||||
argsObject.run_in_background = false
|
||||
argsObject.prompt = `${prompt ? `${prompt}\n\n` : ""}You are verifying the active ULTRAWORK loop result for this session. Review whether the original task is truly complete: ${loopState.prompt}\n\nIf the work is fully complete, end your response with <promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>. If the work is not complete, explain the blocking issues clearly and DO NOT emit that promise.\n\n<ulw_verification_attempt_id>${verificationAttemptId}</ulw_verification_attempt_id>`
|
||||
argsObject.prompt = buildUltraworkOracleVerificationPrompt(
|
||||
prompt,
|
||||
loopState.prompt,
|
||||
verificationAttemptId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,9 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
|
||||
expect(readState(directory)?.verification_attempt_id).toBeTruthy()
|
||||
expect(output.args.run_in_background).toBe(false)
|
||||
expect(output.args.prompt).toContain("Original task:")
|
||||
expect(output.args.prompt).toContain("Ship feature")
|
||||
expect(output.args.prompt).toContain("Review the work skeptically and critically")
|
||||
expect(output.args.prompt).toContain(`<promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>`)
|
||||
|
||||
clearState(directory)
|
||||
@@ -171,6 +173,45 @@ describe("tool.execute.before ultrawork oracle verification", () => {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("#given ulw loop is awaiting verification #when oracle metadata uses sessionID #then oracle session id is stored", async () => {
|
||||
const directory = join(tmpdir(), `tool-after-ulw-sessionid-${Date.now()}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeState(directory, {
|
||||
active: true,
|
||||
iteration: 3,
|
||||
completion_promise: ULTRAWORK_VERIFICATION_PROMISE,
|
||||
initial_completion_promise: "DONE",
|
||||
started_at: new Date().toISOString(),
|
||||
prompt: "Ship feature",
|
||||
session_id: "ses-main",
|
||||
ultrawork: true,
|
||||
verification_pending: true,
|
||||
})
|
||||
|
||||
const handler = createToolExecuteAfterHandler({
|
||||
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteAfterHandler>[0]["ctx"],
|
||||
hooks: {} as Parameters<typeof createToolExecuteAfterHandler>[0]["hooks"],
|
||||
})
|
||||
|
||||
await handler(
|
||||
{ tool: "task", sessionID: "ses-main", callID: "call-1" },
|
||||
{
|
||||
title: "oracle task",
|
||||
output: "done",
|
||||
metadata: {
|
||||
agent: "oracle",
|
||||
sessionID: "ses-oracle-alt",
|
||||
sync: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(readState(directory)?.verification_session_id).toBe("ses-oracle-alt")
|
||||
|
||||
clearState(directory)
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("#given newer oracle attempt exists #when older oracle task finishes #then old session does not overwrite active verification", async () => {
|
||||
const directory = join(tmpdir(), `tool-race-ulw-${Date.now()}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
|
||||
@@ -289,6 +289,19 @@ describe("migrateHookNames", () => {
|
||||
expect(removed).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("removes gpt-permission-continuation from disabled hooks", () => {
|
||||
// given: Config with removed GPT permission continuation hook
|
||||
const hooks = ["gpt-permission-continuation", "comment-checker"]
|
||||
|
||||
// when: Migrate hook names
|
||||
const { migrated, changed, removed } = migrateHookNames(hooks)
|
||||
|
||||
// then: Removed hook should be filtered out
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated).toEqual(["comment-checker"])
|
||||
expect(removed).toEqual(["gpt-permission-continuation"])
|
||||
})
|
||||
|
||||
test("handles mixed migration and removal", () => {
|
||||
// given: Config with both legacy rename and removed hooks
|
||||
const hooks = ["anthropic-auto-compact", "preemptive-compaction", "sisyphus-orchestrator"]
|
||||
@@ -413,6 +426,20 @@ describe("migrateConfigFile", () => {
|
||||
expect(rawConfig.disabled_hooks).toEqual(["comment-checker"])
|
||||
})
|
||||
|
||||
test("removes gpt-permission-continuation from disabled_hooks", () => {
|
||||
// given: Config with removed GPT permission continuation hook
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
disabled_hooks: ["gpt-permission-continuation", "comment-checker"],
|
||||
}
|
||||
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// then: Removed hook should be filtered out
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig.disabled_hooks).toEqual(["comment-checker"])
|
||||
})
|
||||
|
||||
test("does not write if no migration needed", () => {
|
||||
// given: Config with current names
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
|
||||
@@ -10,6 +10,7 @@ export const HOOK_NAME_MAP: Record<string, string | null> = {
|
||||
// Removed hooks (v3.0.0) - will be filtered out and user warned
|
||||
"empty-message-sanitizer": null,
|
||||
"delegate-task-english-directive": null,
|
||||
"gpt-permission-continuation": null,
|
||||
}
|
||||
|
||||
export function migrateHookNames(
|
||||
|
||||
@@ -361,19 +361,23 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
expect(fifth.model).toBe("k2p5")
|
||||
})
|
||||
|
||||
test("quick has valid fallbackChain with claude-haiku-4-5 as primary", () => {
|
||||
test("quick has valid fallbackChain with gpt-5.4-mini as primary and claude-haiku-4-5 as secondary", () => {
|
||||
// given - quick category requirement
|
||||
const quick = CATEGORY_MODEL_REQUIREMENTS["quick"]
|
||||
|
||||
// when - accessing quick requirement
|
||||
// then - fallbackChain exists with claude-haiku-4-5 as first entry
|
||||
// then - fallbackChain exists with gpt-5.4-mini as first entry, haiku as second
|
||||
expect(quick).toBeDefined()
|
||||
expect(quick.fallbackChain).toBeArray()
|
||||
expect(quick.fallbackChain.length).toBeGreaterThan(0)
|
||||
expect(quick.fallbackChain.length).toBeGreaterThan(1)
|
||||
|
||||
const primary = quick.fallbackChain[0]
|
||||
expect(primary.model).toBe("claude-haiku-4-5")
|
||||
expect(primary.providers[0]).toBe("anthropic")
|
||||
expect(primary.model).toBe("gpt-5.4-mini")
|
||||
expect(primary.providers).toContain("openai")
|
||||
|
||||
const secondary = quick.fallbackChain[1]
|
||||
expect(secondary.model).toBe("claude-haiku-4-5")
|
||||
expect(secondary.providers).toContain("anthropic")
|
||||
})
|
||||
|
||||
test("unspecified-low has valid fallbackChain with claude-sonnet-4-6 as primary", () => {
|
||||
|
||||
@@ -251,6 +251,10 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
},
|
||||
quick: {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["openai", "github-copilot", "opencode"],
|
||||
model: "gpt-5.4-mini",
|
||||
},
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-haiku-4-5",
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@
|
||||
| ultrabrain | gpt-5.4 xhigh | Hard logic |
|
||||
| deep | gpt-5.3-codex medium | Autonomous problem-solving |
|
||||
| artistry | gemini-3.1-pro high | Creative approaches |
|
||||
| quick | claude-haiku-4-5 | Trivial tasks |
|
||||
| quick | gpt-5.4-mini | Trivial tasks |
|
||||
| unspecified-low | claude-sonnet-4-6 | Moderate effort |
|
||||
| unspecified-high | claude-opus-4-6 max | High effort |
|
||||
| writing | kimi-k2p5 | Documentation |
|
||||
|
||||
@@ -149,9 +149,9 @@ Approach:
|
||||
</Category_Context>
|
||||
|
||||
<Caller_Warning>
|
||||
THIS CATEGORY USES A LESS CAPABLE MODEL (claude-haiku-4-5).
|
||||
THIS CATEGORY USES A SMALLER/FASTER MODEL (gpt-5.4-mini).
|
||||
|
||||
The model executing this task has LIMITED reasoning capacity. Your prompt MUST be:
|
||||
The model executing this task is optimized for speed over depth. Your prompt MUST be:
|
||||
|
||||
**EXHAUSTIVELY EXPLICIT** - Leave NOTHING to interpretation:
|
||||
1. MUST DO: List every required action as atomic, numbered steps
|
||||
@@ -159,10 +159,9 @@ The model executing this task has LIMITED reasoning capacity. Your prompt MUST b
|
||||
3. EXPECTED OUTPUT: Describe exact success criteria with concrete examples
|
||||
|
||||
**WHY THIS MATTERS:**
|
||||
- Less capable models WILL deviate without explicit guardrails
|
||||
- Vague instructions → unpredictable results
|
||||
- Implicit expectations → missed requirements
|
||||
|
||||
- Smaller models benefit from explicit guardrails
|
||||
- Vague instructions may lead to unpredictable results
|
||||
- Implicit expectations may be missed
|
||||
**PROMPT STRUCTURE (MANDATORY):**
|
||||
\`\`\`
|
||||
TASK: [One-sentence goal]
|
||||
@@ -287,7 +286,7 @@ export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
|
||||
ultrabrain: { model: "openai/gpt-5.4", variant: "xhigh" },
|
||||
deep: { model: "openai/gpt-5.3-codex", variant: "medium" },
|
||||
artistry: { model: "google/gemini-3.1-pro", variant: "high" },
|
||||
quick: { model: "anthropic/claude-haiku-4-5" },
|
||||
quick: { model: "openai/gpt-5.4-mini" },
|
||||
"unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
|
||||
"unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },
|
||||
writing: { model: "kimi-for-coding/k2p5" },
|
||||
|
||||
Reference in New Issue
Block a user