fix(delegate-task): Wave 1 - fix polling timeout, resource cleanup, tool restrictions, idle dedup, auth-plugins JSONC, CLI runner hang

- fix(delegate-task): return error on poll timeout instead of silent null
- fix(delegate-task): ensure toast and session cleanup on all error paths with try/finally
- fix(delegate-task): apply agent tool restrictions in sync-prompt-sender
- fix(plugin): add symmetric idle dedup to prevent double hook triggers
- fix(cli): replace regex-based JSONC editing with jsonc-parser in auth-plugins
- fix(cli): abort event stream after completion and restore no-timeout default

All changes verified with tests and typecheck.
This commit is contained in:
YeonGyu-Kim
2026-02-10 19:09:22 +09:00
parent 5759e28eba
commit 47595f21a6
17 changed files with 1397 additions and 163 deletions
+385
View File
@@ -0,0 +1,385 @@
import { describe, it, expect } from "bun:test"
import { createEventHandler } from "./event"
type EventInput = { event: { type: string; properties?: Record<string, unknown> } }
describe("createEventHandler - idle deduplication", () => {
it("Order A (status→idle): synthetic idle deduped - real idle not dispatched again", async () => {
//#given
const dispatchCalls: EventInput[] = []
const mockDispatchToHooks = async (input: EventInput) => {
if (input.event.type === "session.idle") {
dispatchCalls.push(input)
}
}
const eventHandler = createEventHandler({
ctx: {} as any,
pluginConfig: {} as any,
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
},
managers: {
tmuxSessionManager: {
onSessionCreated: async () => {},
onSessionDeleted: async () => {},
},
} as any,
hooks: {
autoUpdateChecker: { event: mockDispatchToHooks as any },
claudeCodeHooks: { event: async () => {} },
backgroundNotificationHook: { event: async () => {} },
sessionNotification: async () => {},
todoContinuationEnforcer: { handler: async () => {} },
unstableAgentBabysitter: { event: async () => {} },
contextWindowMonitor: { event: async () => {} },
directoryAgentsInjector: { event: async () => {} },
directoryReadmeInjector: { event: async () => {} },
rulesInjector: { event: async () => {} },
thinkMode: { event: async () => {} },
anthropicContextWindowLimitRecovery: { event: async () => {} },
agentUsageReminder: { event: async () => {} },
categorySkillReminder: { event: async () => {} },
interactiveBashSession: { event: async () => {} },
ralphLoop: { event: async () => {} },
stopContinuationGuard: { event: async () => {} },
compactionTodoPreserver: { event: async () => {} },
atlasHook: { handler: async () => {} },
} as any,
})
const sessionId = "ses_test123"
//#when - session.status with idle (generates synthetic idle first)
await eventHandler({
event: {
type: "session.status",
properties: {
sessionID: sessionId,
status: { type: "idle" },
},
},
})
//#then - synthetic idle dispatched once
expect(dispatchCalls.length).toBe(1)
expect(dispatchCalls[0].event.type).toBe("session.idle")
expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId)
//#when - real session.idle arrives
await eventHandler({
event: {
type: "session.idle",
properties: {
sessionID: sessionId,
},
},
})
//#then - real idle deduped, no additional dispatch
expect(dispatchCalls.length).toBe(1)
})
it("Order B (idle→status): real idle deduped - synthetic idle not dispatched", async () => {
//#given
const dispatchCalls: EventInput[] = []
const mockDispatchToHooks = async (input: EventInput) => {
if (input.event.type === "session.idle") {
dispatchCalls.push(input)
}
}
const eventHandler = createEventHandler({
ctx: {} as any,
pluginConfig: {} as any,
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
},
managers: {
tmuxSessionManager: {
onSessionCreated: async () => {},
onSessionDeleted: async () => {},
},
} as any,
hooks: {
autoUpdateChecker: { event: mockDispatchToHooks as any },
claudeCodeHooks: { event: async () => {} },
backgroundNotificationHook: { event: async () => {} },
sessionNotification: async () => {},
todoContinuationEnforcer: { handler: async () => {} },
unstableAgentBabysitter: { event: async () => {} },
contextWindowMonitor: { event: async () => {} },
directoryAgentsInjector: { event: async () => {} },
directoryReadmeInjector: { event: async () => {} },
rulesInjector: { event: async () => {} },
thinkMode: { event: async () => {} },
anthropicContextWindowLimitRecovery: { event: async () => {} },
agentUsageReminder: { event: async () => {} },
categorySkillReminder: { event: async () => {} },
interactiveBashSession: { event: async () => {} },
ralphLoop: { event: async () => {} },
stopContinuationGuard: { event: async () => {} },
compactionTodoPreserver: { event: async () => {} },
atlasHook: { handler: async () => {} },
} as any,
})
const sessionId = "ses_test456"
//#when - real session.idle arrives first
await eventHandler({
event: {
type: "session.idle",
properties: {
sessionID: sessionId,
},
},
})
//#then - real idle dispatched once
expect(dispatchCalls.length).toBe(1)
expect(dispatchCalls[0].event.type).toBe("session.idle")
expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId)
//#when - session.status with idle (generates synthetic idle)
await eventHandler({
event: {
type: "session.status",
properties: {
sessionID: sessionId,
status: { type: "idle" },
},
},
})
//#then - synthetic idle deduped, no additional dispatch
expect(dispatchCalls.length).toBe(1)
})
it("both maps pruned on every event", async () => {
//#given
const eventHandler = createEventHandler({
ctx: {} as any,
pluginConfig: {} as any,
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
},
managers: {
tmuxSessionManager: {
onSessionCreated: async () => {},
onSessionDeleted: async () => {},
},
} as any,
hooks: {
autoUpdateChecker: { event: async () => {} },
claudeCodeHooks: { event: async () => {} },
backgroundNotificationHook: { event: async () => {} },
sessionNotification: async () => {},
todoContinuationEnforcer: { handler: async () => {} },
unstableAgentBabysitter: { event: async () => {} },
contextWindowMonitor: { event: async () => {} },
directoryAgentsInjector: { event: async () => {} },
directoryReadmeInjector: { event: async () => {} },
rulesInjector: { event: async () => {} },
thinkMode: { event: async () => {} },
anthropicContextWindowLimitRecovery: { event: async () => {} },
agentUsageReminder: { event: async () => {} },
categorySkillReminder: { event: async () => {} },
interactiveBashSession: { event: async () => {} },
ralphLoop: { event: async () => {} },
stopContinuationGuard: { event: async () => {} },
compactionTodoPreserver: { event: async () => {} },
atlasHook: { handler: async () => {} },
} as any,
})
// Trigger some synthetic idles
await eventHandler({
event: {
type: "session.status",
properties: {
sessionID: "ses_stale_1",
status: { type: "idle" },
},
},
})
await eventHandler({
event: {
type: "session.status",
properties: {
sessionID: "ses_stale_2",
status: { type: "idle" },
},
},
})
// Trigger some real idles
await eventHandler({
event: {
type: "session.idle",
properties: {
sessionID: "ses_stale_3",
},
},
})
await eventHandler({
event: {
type: "session.idle",
properties: {
sessionID: "ses_stale_4",
},
},
})
//#when - wait for dedup window to expire (600ms > 500ms)
await new Promise((resolve) => setTimeout(resolve, 600))
// Trigger any event to trigger pruning
await eventHandler({
event: {
type: "message.updated",
},
})
//#then - both maps should be pruned (no dedup should occur for new events)
// We verify by checking that a new idle event for same session is dispatched
const dispatchCalls: EventInput[] = []
const eventHandlerWithMock = createEventHandler({
ctx: {} as any,
pluginConfig: {} as any,
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
},
managers: {
tmuxSessionManager: {
onSessionCreated: async () => {},
onSessionDeleted: async () => {},
},
} as any,
hooks: {
autoUpdateChecker: {
event: async (input: EventInput) => {
dispatchCalls.push(input)
},
},
claudeCodeHooks: { event: async () => {} },
backgroundNotificationHook: { event: async () => {} },
sessionNotification: async () => {},
todoContinuationEnforcer: { handler: async () => {} },
unstableAgentBabysitter: { event: async () => {} },
contextWindowMonitor: { event: async () => {} },
directoryAgentsInjector: { event: async () => {} },
directoryReadmeInjector: { event: async () => {} },
rulesInjector: { event: async () => {} },
thinkMode: { event: async () => {} },
anthropicContextWindowLimitRecovery: { event: async () => {} },
agentUsageReminder: { event: async () => {} },
categorySkillReminder: { event: async () => {} },
interactiveBashSession: { event: async () => {} },
ralphLoop: { event: async () => {} },
stopContinuationGuard: { event: async () => {} },
compactionTodoPreserver: { event: async () => {} },
atlasHook: { handler: async () => {} },
},
})
await eventHandlerWithMock({
event: {
type: "session.idle",
properties: {
sessionID: "ses_stale_1",
},
},
})
expect(dispatchCalls.length).toBe(1)
expect(dispatchCalls[0].event.type).toBe("session.idle")
})
it("dedup only applies within window - outside window both dispatch", async () => {
//#given
const dispatchCalls: EventInput[] = []
const eventHandler = createEventHandler({
ctx: {} as any,
pluginConfig: {} as any,
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
},
managers: {
tmuxSessionManager: {
onSessionCreated: async () => {},
onSessionDeleted: async () => {},
},
} as any,
hooks: {
autoUpdateChecker: {
event: async (input: EventInput) => {
if (input.event.type === "session.idle") {
dispatchCalls.push(input)
}
},
},
claudeCodeHooks: { event: async () => {} },
backgroundNotificationHook: { event: async () => {} },
sessionNotification: async () => {},
todoContinuationEnforcer: { handler: async () => {} },
unstableAgentBabysitter: { event: async () => {} },
contextWindowMonitor: { event: async () => {} },
directoryAgentsInjector: { event: async () => {} },
directoryReadmeInjector: { event: async () => {} },
rulesInjector: { event: async () => {} },
thinkMode: { event: async () => {} },
anthropicContextWindowLimitRecovery: { event: async () => {} },
agentUsageReminder: { event: async () => {} },
categorySkillReminder: { event: async () => {} },
interactiveBashSession: { event: async () => {} },
ralphLoop: { event: async () => {} },
stopContinuationGuard: { event: async () => {} },
compactionTodoPreserver: { event: async () => {} },
atlasHook: { handler: async () => {} },
} as any,
})
const sessionId = "ses_outside_window"
//#when - synthetic idle first
await eventHandler({
event: {
type: "session.status",
properties: {
sessionID: sessionId,
status: { type: "idle" },
},
},
})
//#then - synthetic dispatched
expect(dispatchCalls.length).toBe(1)
//#when - wait for dedup window to expire (600ms > 500ms)
await new Promise((resolve) => setTimeout(resolve, 600))
//#when - real idle arrives outside window
await eventHandler({
event: {
type: "session.idle",
properties: {
sessionID: sessionId,
},
},
})
//#then - real idle dispatched (outside dedup window)
expect(dispatchCalls.length).toBe(2)
expect(dispatchCalls[0].event.type).toBe("session.idle")
expect(dispatchCalls[1].event.type).toBe("session.idle")
})
})
+8
View File
@@ -52,11 +52,13 @@ export function createEventHandler(args: {
}
const recentSyntheticIdles = new Map<string, number>()
const recentRealIdles = new Map<string, number>()
const DEDUP_WINDOW_MS = 500
return async (input): Promise<void> => {
pruneRecentSyntheticIdles({
recentSyntheticIdles,
recentRealIdles,
now: Date.now(),
dedupWindowMs: DEDUP_WINDOW_MS,
})
@@ -69,6 +71,7 @@ export function createEventHandler(args: {
recentSyntheticIdles.delete(sessionID)
return
}
recentRealIdles.set(sessionID, Date.now())
}
}
@@ -77,6 +80,11 @@ export function createEventHandler(args: {
const syntheticIdle = normalizeSessionStatusToIdle(input)
if (syntheticIdle) {
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string
const emittedAt = recentRealIdles.get(sessionID)
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
recentRealIdles.delete(sessionID)
return
}
recentSyntheticIdles.set(sessionID, Date.now())
await dispatchToHooks(syntheticIdle)
}
+40
View File
@@ -9,10 +9,12 @@ describe("pruneRecentSyntheticIdles", () => {
["ses_old", 1000],
["ses_new", 1600],
])
const recentRealIdles = new Map<string, number>()
//#when
pruneRecentSyntheticIdles({
recentSyntheticIdles,
recentRealIdles,
now: 2000,
dedupWindowMs: 500,
})
@@ -28,10 +30,12 @@ describe("pruneRecentSyntheticIdles", () => {
["ses_fresh_1", 1950],
["ses_fresh_2", 1980],
])
const recentRealIdles = new Map<string, number>()
//#when
pruneRecentSyntheticIdles({
recentSyntheticIdles,
recentRealIdles,
now: 2000,
dedupWindowMs: 100,
})
@@ -45,10 +49,12 @@ describe("pruneRecentSyntheticIdles", () => {
it("handles empty Map without crashing (no-op on empty)", () => {
//#given
const recentSyntheticIdles = new Map<string, number>()
const recentRealIdles = new Map<string, number>()
//#when
pruneRecentSyntheticIdles({
recentSyntheticIdles,
recentRealIdles,
now: 2000,
dedupWindowMs: 500,
})
@@ -65,10 +71,12 @@ describe("pruneRecentSyntheticIdles", () => {
["ses_stale_2", 1200],
["ses_fresh_2", 1980],
])
const recentRealIdles = new Map<string, number>()
//#when
pruneRecentSyntheticIdles({
recentSyntheticIdles,
recentRealIdles,
now: 2000,
dedupWindowMs: 500,
})
@@ -88,10 +96,12 @@ describe("pruneRecentSyntheticIdles", () => {
["ses_old_2", 800],
["ses_old_3", 1200],
])
const recentRealIdles = new Map<string, number>()
//#when
pruneRecentSyntheticIdles({
recentSyntheticIdles,
recentRealIdles,
now: 2000,
dedupWindowMs: 500,
})
@@ -111,10 +121,12 @@ describe("pruneRecentSyntheticIdles", () => {
for (let i = 0; i < 60; i++) {
recentSyntheticIdles.set(`ses_fresh_${i}`, 1950 + i)
}
const recentRealIdles = new Map<string, number>()
//#when
pruneRecentSyntheticIdles({
recentSyntheticIdles,
recentRealIdles,
now: 2000,
dedupWindowMs: 500,
})
@@ -130,4 +142,32 @@ describe("pruneRecentSyntheticIdles", () => {
expect(recentSyntheticIdles.has(`ses_fresh_${i}`)).toBe(true)
}
})
it("prunes both synthetic and real idle maps (dual map pruning)", () => {
//#given
const recentSyntheticIdles = new Map<string, number>([
["synthetic_old", 1000],
["synthetic_new", 1600],
])
const recentRealIdles = new Map<string, number>([
["real_old", 1000],
["real_new", 1600],
])
//#when
pruneRecentSyntheticIdles({
recentSyntheticIdles,
recentRealIdles,
now: 2000,
dedupWindowMs: 500,
})
//#then - both maps pruned
expect(recentSyntheticIdles.has("synthetic_old")).toBe(false)
expect(recentSyntheticIdles.has("synthetic_new")).toBe(true)
expect(recentRealIdles.has("real_old")).toBe(false)
expect(recentRealIdles.has("real_new")).toBe(true)
expect(recentSyntheticIdles.size).toBe(1)
expect(recentRealIdles.size).toBe(1)
})
})
+8 -1
View File
@@ -1,13 +1,20 @@
export function pruneRecentSyntheticIdles(args: {
recentSyntheticIdles: Map<string, number>
recentRealIdles: Map<string, number>
now: number
dedupWindowMs: number
}): void {
const { recentSyntheticIdles, now, dedupWindowMs } = args
const { recentSyntheticIdles, recentRealIdles, now, dedupWindowMs } = args
for (const [sessionID, emittedAt] of recentSyntheticIdles) {
if (now - emittedAt >= dedupWindowMs) {
recentSyntheticIdles.delete(sessionID)
}
}
for (const [sessionID, emittedAt] of recentRealIdles) {
if (now - emittedAt >= dedupWindowMs) {
recentRealIdles.delete(sessionID)
}
}
}