index.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. import type { PluginInput } from '@opencode-ai/plugin';
  2. import { tool } from '@opencode-ai/plugin/tool';
  3. import { createInternalAgentTextPart, log } from '../../utils';
  4. const HOOK_NAME = 'todo-continuation';
  5. const COMMAND_NAME = 'auto-continue';
  6. const CONTINUATION_PROMPT =
  7. '[Auto-continue: enabled - there are incomplete todos remaining. Continue with the next uncompleted item. Press Esc to cancel. If you need user input or review for the next item, ask instead of proceeding.]';
  8. // Suppress window after user abort (Esc/Ctrl+C) to avoid immediately
  9. // re-continuing something the user explicitly stopped
  10. const SUPPRESS_AFTER_ABORT_MS = 5_000;
  11. const NOTIFICATION_BUSY_GRACE_MS = 250;
  12. const QUESTION_PHRASES = [
  13. 'would you like',
  14. 'should i',
  15. 'do you want',
  16. 'please review',
  17. 'let me know',
  18. 'what do you think',
  19. 'can you confirm',
  20. 'would you prefer',
  21. 'shall i',
  22. 'any thoughts',
  23. ];
  24. // Statuses that indicate a todo is terminal (won't be worked on further).
  25. // Uses denylist approach: any status not listed here is considered incomplete.
  26. const TERMINAL_TODO_STATUSES = ['completed', 'cancelled'];
  27. interface ContinuationState {
  28. enabled: boolean;
  29. consecutiveContinuations: number;
  30. pendingTimer: ReturnType<typeof setTimeout> | null;
  31. pendingTimerSessionId: string | null;
  32. suppressUntil: number;
  33. orchestratorSessionIds: Set<string>;
  34. sawChatMessage: boolean;
  35. // True while our auto-injection prompt is in flight — prevents counter reset
  36. // on session.status→busy and blocks duplicate injections
  37. isAutoInjecting: boolean;
  38. // session IDs with an in-flight noReply countdown notification.
  39. notifyingSessionIds: Set<string>;
  40. // sessionID → timestamp until which just-completed noReply countdown
  41. // notification busy transitions are ignored, covering HTTP/SSE reordering.
  42. notificationBusyUntilBySession: Map<string, number>;
  43. }
  44. function isQuestion(text: string): boolean {
  45. const lowerText = text.toLowerCase().trim();
  46. // Match trailing '?' with optional whitespace after it
  47. if (/\?\s*$/.test(lowerText)) {
  48. return true;
  49. }
  50. return QUESTION_PHRASES.some((phrase) => lowerText.includes(phrase));
  51. }
  52. interface TodoItem {
  53. id: string;
  54. content: string;
  55. status: string;
  56. priority: string;
  57. }
  58. interface MessageInfo {
  59. role?: string;
  60. [key: string]: unknown;
  61. }
  62. interface MessagePart {
  63. type?: string;
  64. text?: string;
  65. [key: string]: unknown;
  66. }
  67. interface Message {
  68. info?: MessageInfo;
  69. parts?: MessagePart[];
  70. }
  71. function cancelPendingTimer(state: ContinuationState): void {
  72. if (state.pendingTimer) {
  73. clearTimeout(state.pendingTimer);
  74. state.pendingTimer = null;
  75. }
  76. state.pendingTimerSessionId = null;
  77. }
  78. function resetState(state: ContinuationState): void {
  79. cancelPendingTimer(state);
  80. state.consecutiveContinuations = 0;
  81. state.suppressUntil = 0;
  82. state.isAutoInjecting = false;
  83. state.notifyingSessionIds.clear();
  84. state.notificationBusyUntilBySession.clear();
  85. }
  86. export function createTodoContinuationHook(
  87. ctx: PluginInput,
  88. config?: {
  89. maxContinuations?: number;
  90. cooldownMs?: number;
  91. autoEnable?: boolean;
  92. autoEnableThreshold?: number;
  93. },
  94. ): {
  95. tool: Record<string, unknown>;
  96. handleEvent: (input: {
  97. event: { type: string; properties?: Record<string, unknown> };
  98. }) => Promise<void>;
  99. handleChatMessage: (input: { sessionID: string; agent?: string }) => void;
  100. handleCommandExecuteBefore: (
  101. input: {
  102. command: string;
  103. sessionID: string;
  104. arguments: string;
  105. },
  106. output: { parts: Array<{ type: string; text?: string }> },
  107. ) => Promise<void>;
  108. } {
  109. const maxContinuations = config?.maxContinuations ?? 5;
  110. const cooldownMs = config?.cooldownMs ?? 3000;
  111. const autoEnable = config?.autoEnable ?? false;
  112. const autoEnableThreshold = config?.autoEnableThreshold ?? 4;
  113. const state: ContinuationState = {
  114. enabled: false,
  115. consecutiveContinuations: 0,
  116. pendingTimer: null,
  117. pendingTimerSessionId: null,
  118. suppressUntil: 0,
  119. orchestratorSessionIds: new Set<string>(),
  120. sawChatMessage: false,
  121. isAutoInjecting: false,
  122. notifyingSessionIds: new Set<string>(),
  123. notificationBusyUntilBySession: new Map<string, number>(),
  124. };
  125. function markNotificationStarted(sessionID: string): void {
  126. state.notifyingSessionIds.add(sessionID);
  127. }
  128. function markNotificationFinished(sessionID: string): void {
  129. state.notifyingSessionIds.delete(sessionID);
  130. state.notificationBusyUntilBySession.set(
  131. sessionID,
  132. Date.now() + NOTIFICATION_BUSY_GRACE_MS,
  133. );
  134. }
  135. function clearNotificationState(sessionID: string): void {
  136. state.notifyingSessionIds.delete(sessionID);
  137. state.notificationBusyUntilBySession.delete(sessionID);
  138. }
  139. function isNotificationBusy(sessionID: string): boolean {
  140. if (state.notifyingSessionIds.has(sessionID)) {
  141. return true;
  142. }
  143. const until = state.notificationBusyUntilBySession.get(sessionID) ?? 0;
  144. if (until <= Date.now()) {
  145. state.notificationBusyUntilBySession.delete(sessionID);
  146. return false;
  147. }
  148. return true;
  149. }
  150. function isOrchestratorSession(sessionID: string): boolean {
  151. return state.orchestratorSessionIds.has(sessionID);
  152. }
  153. function registerOrchestratorSession(sessionID: string): void {
  154. state.orchestratorSessionIds.add(sessionID);
  155. }
  156. function handleChatMessage(input: {
  157. sessionID: string;
  158. agent?: string;
  159. }): void {
  160. if (!input.agent) {
  161. return;
  162. }
  163. state.sawChatMessage = true;
  164. if (input.agent === 'orchestrator') {
  165. registerOrchestratorSession(input.sessionID);
  166. }
  167. }
  168. const autoContinue = tool({
  169. description:
  170. 'Toggle auto-continuation for incomplete todos. When enabled, the orchestrator will automatically continue working through its todo list when it stops with incomplete items.',
  171. args: { enabled: tool.schema.boolean() },
  172. execute: async (args) => {
  173. const enabled = args.enabled;
  174. state.enabled = enabled;
  175. state.consecutiveContinuations = 0;
  176. if (enabled) {
  177. state.suppressUntil = 0;
  178. log(`[${HOOK_NAME}] Auto-continue enabled`, { maxContinuations });
  179. return `Auto-continue enabled. Will auto-continue for up to ${maxContinuations} consecutive injections.`;
  180. }
  181. // Cancel any pending timer on disable
  182. cancelPendingTimer(state);
  183. log(`[${HOOK_NAME}] Auto-continue disabled`);
  184. return 'Auto-continue disabled.';
  185. },
  186. });
  187. async function handleEvent(input: {
  188. event: { type: string; properties?: Record<string, unknown> };
  189. }): Promise<void> {
  190. const { event } = input;
  191. const properties = event.properties ?? {};
  192. if (
  193. event.type === 'session.idle' ||
  194. (event.type === 'session.status' &&
  195. (properties.status as { type?: string } | undefined)?.type === 'idle')
  196. ) {
  197. const sessionID = properties.sessionID as string;
  198. if (!sessionID) {
  199. return;
  200. }
  201. log(`[${HOOK_NAME}] Session idle`, { sessionID });
  202. // Backward compatibility: if no chat.message has identified the
  203. // orchestrator yet, fall back to the first idle session.
  204. if (!state.sawChatMessage && state.orchestratorSessionIds.size === 0) {
  205. registerOrchestratorSession(sessionID);
  206. log(`[${HOOK_NAME}] Tracked orchestrator session`, {
  207. sessionID,
  208. });
  209. }
  210. // Gate: session is orchestrator (needed before auto-enable check)
  211. if (!isOrchestratorSession(sessionID)) {
  212. log(`[${HOOK_NAME}] Skipped: not orchestrator session`, {
  213. sessionID,
  214. });
  215. return;
  216. }
  217. // Auto-enable check: if configured, not yet enabled, and enough
  218. // todos exist, automatically enable auto-continue.
  219. if (autoEnable && !state.enabled) {
  220. try {
  221. const todosResult = await ctx.client.session.todo({
  222. path: { id: sessionID },
  223. });
  224. const todos = todosResult.data as TodoItem[];
  225. const incompleteCount = todos.filter(
  226. (t) => !TERMINAL_TODO_STATUSES.includes(t.status),
  227. ).length;
  228. if (incompleteCount >= autoEnableThreshold) {
  229. state.enabled = true;
  230. state.consecutiveContinuations = 0;
  231. state.suppressUntil = 0;
  232. log(
  233. `[${HOOK_NAME}] Auto-enabled: ${incompleteCount} incomplete todos >= threshold ${autoEnableThreshold}`,
  234. { sessionID },
  235. );
  236. } else {
  237. log(
  238. `[${HOOK_NAME}] Auto-enable skipped: ${incompleteCount} incomplete todos < threshold ${autoEnableThreshold}`,
  239. { sessionID },
  240. );
  241. }
  242. } catch (error) {
  243. log(
  244. `[${HOOK_NAME}] Warning: failed to fetch todos for auto-enable check`,
  245. {
  246. sessionID,
  247. error: error instanceof Error ? error.message : String(error),
  248. },
  249. );
  250. }
  251. }
  252. // Safety gate 1: enabled
  253. if (!state.enabled) {
  254. log(`[${HOOK_NAME}] Skipped: auto-continue not enabled`, {
  255. sessionID,
  256. });
  257. return;
  258. }
  259. // Safety gate 2: incomplete todos exist
  260. let hasIncompleteTodos = false;
  261. let incompleteCount = 0;
  262. try {
  263. const todosResult = await ctx.client.session.todo({
  264. path: { id: sessionID },
  265. });
  266. const todos = todosResult.data as TodoItem[];
  267. incompleteCount = todos.filter(
  268. (t) => !TERMINAL_TODO_STATUSES.includes(t.status),
  269. ).length;
  270. hasIncompleteTodos = incompleteCount > 0;
  271. log(`[${HOOK_NAME}] Fetched todos`, {
  272. sessionID,
  273. hasIncompleteTodos,
  274. total: todos.length,
  275. });
  276. } catch (error) {
  277. log(`[${HOOK_NAME}] Warning: failed to fetch todos`, {
  278. sessionID,
  279. error: error instanceof Error ? error.message : String(error),
  280. });
  281. return;
  282. }
  283. if (!hasIncompleteTodos) {
  284. log(`[${HOOK_NAME}] Skipped: no incomplete todos`, { sessionID });
  285. return;
  286. }
  287. // Safety gate 3: last assistant message is not a question
  288. let lastAssistantIsQuestion = false;
  289. try {
  290. const messagesResult = await ctx.client.session.messages({
  291. path: { id: sessionID },
  292. });
  293. const messages = messagesResult.data as Message[];
  294. const lastAssistantMessage = messages
  295. .slice()
  296. .reverse()
  297. .find((m) => m.info?.role === 'assistant');
  298. if (lastAssistantMessage?.parts) {
  299. const lastText = lastAssistantMessage.parts
  300. .map((p) => p.text ?? '')
  301. .join(' ');
  302. lastAssistantIsQuestion = isQuestion(lastText);
  303. }
  304. log(`[${HOOK_NAME}] Fetched messages`, {
  305. sessionID,
  306. lastAssistantIsQuestion,
  307. });
  308. } catch (error) {
  309. log(`[${HOOK_NAME}] Warning: failed to fetch messages`, {
  310. sessionID,
  311. error: error instanceof Error ? error.message : String(error),
  312. });
  313. return;
  314. }
  315. if (lastAssistantIsQuestion) {
  316. log(`[${HOOK_NAME}] Skipped: last message is question`, {
  317. sessionID,
  318. });
  319. return;
  320. }
  321. // Safety gate 4: below max continuations
  322. if (state.consecutiveContinuations >= maxContinuations) {
  323. log(`[${HOOK_NAME}] Skipped: max continuations reached`, {
  324. sessionID,
  325. consecutive: state.consecutiveContinuations,
  326. max: maxContinuations,
  327. });
  328. return;
  329. }
  330. // Safety gate 5: not in suppress window
  331. const now = Date.now();
  332. if (now < state.suppressUntil) {
  333. log(`[${HOOK_NAME}] Skipped: in suppress window`, {
  334. sessionID,
  335. suppressUntil: state.suppressUntil,
  336. });
  337. return;
  338. }
  339. // Safety gate 6: no pending timer AND no injection in flight
  340. if (state.pendingTimer !== null || state.isAutoInjecting) {
  341. log(`[${HOOK_NAME}] Skipped: timer pending or injection in flight`, {
  342. sessionID,
  343. });
  344. return;
  345. }
  346. // Schedule continuation
  347. log(`[${HOOK_NAME}] Scheduling continuation`, {
  348. sessionID,
  349. delayMs: cooldownMs,
  350. });
  351. // Show countdown notification (noReply = agent doesn't respond)
  352. markNotificationStarted(sessionID);
  353. ctx.client.session
  354. .prompt({
  355. path: { id: sessionID },
  356. body: {
  357. noReply: true,
  358. parts: [
  359. {
  360. type: 'text',
  361. text: [
  362. `⎔ Auto-continue: ${incompleteCount} incomplete todos remaining — resuming in ${cooldownMs / 1000}s — Esc×2 to cancel`,
  363. '',
  364. '[system status: continue without acknowledging this notification]',
  365. ].join('\n'),
  366. },
  367. ],
  368. },
  369. })
  370. .catch(() => {
  371. /* best-effort notification */
  372. })
  373. .finally(() => {
  374. markNotificationFinished(sessionID);
  375. });
  376. state.pendingTimerSessionId = sessionID;
  377. state.pendingTimer = setTimeout(async () => {
  378. state.pendingTimer = null;
  379. state.pendingTimerSessionId = null;
  380. clearNotificationState(sessionID);
  381. // Guard: may have been disabled during cooldown
  382. if (!state.enabled) {
  383. log(`[${HOOK_NAME}] Cancelled: disabled during cooldown`, {
  384. sessionID,
  385. });
  386. return;
  387. }
  388. state.isAutoInjecting = true;
  389. try {
  390. await ctx.client.session.prompt({
  391. path: { id: sessionID },
  392. body: {
  393. parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)],
  394. },
  395. });
  396. state.consecutiveContinuations++;
  397. log(`[${HOOK_NAME}] Continuation injected`, {
  398. sessionID,
  399. consecutive: state.consecutiveContinuations,
  400. });
  401. } catch (error) {
  402. log(`[${HOOK_NAME}] Error: failed to inject continuation`, {
  403. sessionID,
  404. error: error instanceof Error ? error.message : String(error),
  405. });
  406. } finally {
  407. state.isAutoInjecting = false;
  408. }
  409. }, cooldownMs);
  410. } else if (event.type === 'session.status') {
  411. const status = properties.status as { type: string };
  412. const sessionID = properties.sessionID as string;
  413. if (status?.type === 'busy') {
  414. const isOrchestrator = isOrchestratorSession(sessionID);
  415. const isNotification = isNotificationBusy(sessionID);
  416. // Only cancel timer for orchestrator session — sub-agents going
  417. // busy must not silently kill the orchestrator's continuation.
  418. if (
  419. isOrchestrator &&
  420. !isNotification &&
  421. state.pendingTimerSessionId === sessionID
  422. ) {
  423. cancelPendingTimer(state);
  424. }
  425. // Only reset consecutive counter for user-initiated activity,
  426. // not for our own auto-injection prompt. Scope to orchestrator only.
  427. if (
  428. !state.isAutoInjecting &&
  429. !isNotification &&
  430. isOrchestrator &&
  431. state.consecutiveContinuations > 0
  432. ) {
  433. state.consecutiveContinuations = 0;
  434. log(`[${HOOK_NAME}] Reset consecutive count on user activity`, {
  435. sessionID,
  436. });
  437. }
  438. }
  439. } else if (event.type === 'session.error') {
  440. const error = properties.error as { name?: string };
  441. const sessionID = properties.sessionID as string;
  442. const errorName = error?.name;
  443. const isOrchestrator = isOrchestratorSession(sessionID);
  444. if (
  445. isOrchestrator &&
  446. (errorName === 'MessageAbortedError' || errorName === 'AbortError')
  447. ) {
  448. state.suppressUntil = Date.now() + SUPPRESS_AFTER_ABORT_MS;
  449. log(`[${HOOK_NAME}] Suppressed continuation after abort`, {
  450. sessionID,
  451. errorName,
  452. });
  453. }
  454. if (isOrchestrator) {
  455. cancelPendingTimer(state);
  456. log(`[${HOOK_NAME}] Cancelled pending timer on error`, {
  457. sessionID,
  458. });
  459. }
  460. } else if (event.type === 'session.deleted') {
  461. // OpenCode sends sessionID in two shapes:
  462. // properties.info.id (from session store) or properties.sessionID (from event)
  463. const deletedSessionId =
  464. (properties.info as { id?: string })?.id ??
  465. (properties.sessionID as string);
  466. if (deletedSessionId && isOrchestratorSession(deletedSessionId)) {
  467. if (state.pendingTimerSessionId === deletedSessionId) {
  468. cancelPendingTimer(state);
  469. log(`[${HOOK_NAME}] Cancelled pending timer on orchestrator delete`, {
  470. sessionID: deletedSessionId,
  471. });
  472. }
  473. state.orchestratorSessionIds.delete(deletedSessionId);
  474. clearNotificationState(deletedSessionId);
  475. if (state.orchestratorSessionIds.size === 0) {
  476. resetState(state);
  477. state.sawChatMessage = false;
  478. }
  479. log(`[${HOOK_NAME}] Reset orchestrator session on delete`, {
  480. sessionID: deletedSessionId,
  481. });
  482. }
  483. }
  484. }
  485. async function handleCommandExecuteBefore(
  486. input: {
  487. command: string;
  488. sessionID: string;
  489. arguments: string;
  490. },
  491. output: { parts: Array<{ type: string; text?: string }> },
  492. ): Promise<void> {
  493. if (input.command !== COMMAND_NAME) {
  494. return;
  495. }
  496. // Seed orchestrator session from slash command (more reliable than
  497. // first-idle heuristic — slash commands only fire in main chat)
  498. registerOrchestratorSession(input.sessionID);
  499. // Clear template text — hook handles everything directly
  500. output.parts.length = 0;
  501. // Accept explicit on/off argument, toggle only when no arg
  502. const arg = input.arguments.trim().toLowerCase();
  503. let newEnabled: boolean;
  504. if (arg === 'on') {
  505. newEnabled = true;
  506. } else if (arg === 'off') {
  507. newEnabled = false;
  508. } else {
  509. newEnabled = !state.enabled;
  510. }
  511. state.enabled = newEnabled;
  512. state.consecutiveContinuations = 0;
  513. if (!newEnabled) {
  514. // Cancel any pending timer on disable
  515. cancelPendingTimer(state);
  516. output.parts.push(
  517. createInternalAgentTextPart(
  518. '[Auto-continue: disabled by user command.]',
  519. ),
  520. );
  521. log(`[${HOOK_NAME}] Disabled via /${COMMAND_NAME} command`);
  522. return;
  523. }
  524. // Clear suppress window on explicit re-enable
  525. state.suppressUntil = 0;
  526. log(`[${HOOK_NAME}] Enabled via /${COMMAND_NAME} command`, {
  527. maxContinuations,
  528. });
  529. // Check for incomplete todos to decide on immediate continuation
  530. let hasIncompleteTodos = false;
  531. try {
  532. const todosResult = await ctx.client.session.todo({
  533. path: { id: input.sessionID },
  534. });
  535. const todos = todosResult.data as TodoItem[];
  536. hasIncompleteTodos = todos.some(
  537. (t) => !TERMINAL_TODO_STATUSES.includes(t.status),
  538. );
  539. } catch (error) {
  540. log(`[${HOOK_NAME}] Warning: failed to fetch todos in command hook`, {
  541. sessionID: input.sessionID,
  542. error: error instanceof Error ? error.message : String(error),
  543. });
  544. }
  545. if (hasIncompleteTodos) {
  546. output.parts.push(
  547. createInternalAgentTextPart(
  548. `${CONTINUATION_PROMPT} [Auto-continue enabled: up to ${maxContinuations} continuations.]`,
  549. ),
  550. );
  551. } else {
  552. output.parts.push(
  553. createInternalAgentTextPart(
  554. `[Auto-continue: enabled for up to ${maxContinuations} continuations. No incomplete todos right now.]`,
  555. ),
  556. );
  557. }
  558. }
  559. return {
  560. tool: { auto_continue: autoContinue },
  561. handleEvent,
  562. handleChatMessage,
  563. handleCommandExecuteBefore,
  564. };
  565. }