@sayknow-cli/coding-agent 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/dist/types/append-only-mode.d.ts +31 -0
  2. package/dist/types/capability/rule.d.ts +8 -0
  3. package/dist/types/cli/completion-cli.d.ts +41 -0
  4. package/dist/types/cli/config-cli.d.ts +1 -0
  5. package/dist/types/cli/fixture-report.d.ts +20 -0
  6. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  7. package/dist/types/cli/nofile-limit.d.ts +11 -0
  8. package/dist/types/cli/notify-cli.d.ts +7 -0
  9. package/dist/types/cli/update-cli.d.ts +22 -0
  10. package/dist/types/cli.d.ts +124 -1
  11. package/dist/types/commands/completion.d.ts +29 -0
  12. package/dist/types/commands/config.d.ts +3 -0
  13. package/dist/types/commands/deep-interview.d.ts +3 -0
  14. package/dist/types/config/file-lock.d.ts +7 -6
  15. package/dist/types/config/keybindings.d.ts +2 -2
  16. package/dist/types/config/model-profile-activation.d.ts +6 -0
  17. package/dist/types/config/model-profiles.d.ts +1 -0
  18. package/dist/types/config/settings-schema.d.ts +119 -1
  19. package/dist/types/config/settings.d.ts +8 -0
  20. package/dist/types/discovery/index.d.ts +1 -0
  21. package/dist/types/export/html/template.generated.d.ts +1 -1
  22. package/dist/types/export/ttsr.d.ts +14 -3
  23. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  24. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  25. package/dist/types/goals/runtime.d.ts +1 -0
  26. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  27. package/dist/types/main.d.ts +4 -1
  28. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  29. package/dist/types/modes/components/model-selector.d.ts +4 -0
  30. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  31. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  32. package/dist/types/modes/components/status-line.d.ts +9 -0
  33. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  34. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  35. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  36. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  37. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  38. package/dist/types/modes/interactive-mode.d.ts +4 -0
  39. package/dist/types/modes/print-mode.d.ts +13 -1
  40. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  41. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  42. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  43. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  44. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  45. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  46. package/dist/types/modes/types.d.ts +3 -0
  47. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  48. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  49. package/dist/types/notifications/config-commands.d.ts +9 -0
  50. package/dist/types/notifications/config.d.ts +30 -1
  51. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  52. package/dist/types/notifications/index.d.ts +8 -3
  53. package/dist/types/notifications/lifecycle-commands.d.ts +12 -3
  54. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  55. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  56. package/dist/types/notifications/rich-draft.d.ts +68 -0
  57. package/dist/types/notifications/rich-render.d.ts +90 -0
  58. package/dist/types/notifications/telegram-daemon.d.ts +45 -3
  59. package/dist/types/notifications/telegram-reference.d.ts +7 -5
  60. package/dist/types/notifications/threaded-render.d.ts +14 -0
  61. package/dist/types/runtime-api-key.d.ts +7 -0
  62. package/dist/types/sdk.d.ts +3 -0
  63. package/dist/types/session/agent-session.d.ts +61 -6
  64. package/dist/types/session/agent-storage.d.ts +1 -0
  65. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  66. package/dist/types/session/messages.d.ts +4 -0
  67. package/dist/types/session/session-manager.d.ts +17 -1
  68. package/dist/types/skc-runtime/deep-interview-ambiguity.d.ts +39 -0
  69. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +10 -1
  70. package/dist/types/skc-runtime/deep-interview-state.d.ts +17 -2
  71. package/dist/types/skc-runtime/session-state-sidecar.d.ts +7 -0
  72. package/dist/types/skc-runtime/state-writer.d.ts +3 -0
  73. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  74. package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  75. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +120 -13
  76. package/dist/types/skill-state/active-state.d.ts +18 -1
  77. package/dist/types/system-prompt.d.ts +18 -2
  78. package/dist/types/task/executor.d.ts +3 -0
  79. package/dist/types/task/fork-context-budget.d.ts +7 -0
  80. package/dist/types/task/token-log.d.ts +30 -0
  81. package/dist/types/task/token-log.test.d.ts +1 -0
  82. package/dist/types/tools/bisect.d.ts +81 -0
  83. package/dist/types/tools/computer.d.ts +2 -2
  84. package/dist/types/tools/index.d.ts +1 -0
  85. package/dist/types/tools/search.d.ts +2 -2
  86. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  87. package/dist/types/utils/file-mentions.d.ts +14 -0
  88. package/dist/types/utils/git.d.ts +32 -0
  89. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  90. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  91. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  92. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  93. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  94. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  95. package/package.json +8 -8
  96. package/src/append-only-mode.ts +58 -0
  97. package/src/autoresearch/prompt.md +1 -1
  98. package/src/autoresearch/tools/log-experiment.ts +1 -1
  99. package/src/capability/rule.ts +9 -0
  100. package/src/cli/completion-cli.ts +234 -0
  101. package/src/cli/config-cli.ts +61 -9
  102. package/src/cli/fast-help.ts +1 -0
  103. package/src/cli/fixture-report.test.ts +230 -0
  104. package/src/cli/fixture-report.ts +171 -0
  105. package/src/cli/nofile-limit.ts +48 -0
  106. package/src/cli/notify-cli.ts +103 -17
  107. package/src/cli/telegram-cli.ts +1 -1
  108. package/src/cli/update-cli.ts +148 -33
  109. package/src/cli.ts +29 -1
  110. package/src/commands/completion.ts +84 -0
  111. package/src/commands/config.ts +2 -0
  112. package/src/commands/contribution-prep.ts +1 -1
  113. package/src/commands/deep-interview.ts +2 -1
  114. package/src/config/file-lock.ts +73 -34
  115. package/src/config/keybindings.ts +3 -3
  116. package/src/config/model-profile-activation.ts +65 -0
  117. package/src/config/model-profiles.ts +31 -2
  118. package/src/config/settings-schema.ts +76 -1
  119. package/src/config/settings.ts +22 -0
  120. package/src/defaults/skc/skills/team/SKILL.md +17 -14
  121. package/src/defaults/skc/skills/ultragoal/SKILL.md +59 -4
  122. package/src/defaults/skc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  123. package/src/defaults/skc-defaults.ts +9 -0
  124. package/src/discovery/helpers.ts +10 -0
  125. package/src/discovery/index.ts +1 -0
  126. package/src/export/html/template.generated.ts +1 -1
  127. package/src/export/html/template.js +60 -2
  128. package/src/export/ttsr.ts +96 -18
  129. package/src/extensibility/extensions/runner.ts +183 -219
  130. package/src/extensibility/extensions/types.ts +9 -0
  131. package/src/extensibility/skc-plugins/runtime-adapters.ts +156 -44
  132. package/src/goals/runtime.ts +10 -2
  133. package/src/hindsight/backend.ts +1 -3
  134. package/src/hooks/native-skill-hook.ts +124 -4
  135. package/src/internal-urls/docs-index.generated.ts +17 -8
  136. package/src/lsp/client.ts +1 -0
  137. package/src/main.ts +95 -43
  138. package/src/memories/index.ts +12 -1
  139. package/src/modes/components/assistant-message.ts +241 -82
  140. package/src/modes/components/custom-editor.ts +1 -0
  141. package/src/modes/components/footer.ts +7 -2
  142. package/src/modes/components/model-selector.ts +54 -11
  143. package/src/modes/components/queued-message-selector.ts +92 -0
  144. package/src/modes/components/settings-selector.ts +4 -0
  145. package/src/modes/components/status-line.ts +167 -54
  146. package/src/modes/components/thinking-selector.ts +10 -8
  147. package/src/modes/components/tool-execution.ts +44 -19
  148. package/src/modes/components/welcome.ts +181 -44
  149. package/src/modes/controllers/command-controller.ts +40 -4
  150. package/src/modes/controllers/event-controller.ts +77 -49
  151. package/src/modes/controllers/extension-ui-controller.ts +12 -11
  152. package/src/modes/controllers/input-controller.ts +192 -35
  153. package/src/modes/controllers/selector-controller.ts +168 -24
  154. package/src/modes/interactive-mode.ts +56 -16
  155. package/src/modes/print-mode.ts +43 -4
  156. package/src/modes/prompt-action-autocomplete.ts +21 -20
  157. package/src/modes/rpc/rpc-mode.ts +32 -12
  158. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  159. package/src/modes/rpc/rpc-types.ts +9 -0
  160. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  161. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  162. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  163. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  164. package/src/modes/types.ts +3 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  166. package/src/modes/utils/injected-user-submission.ts +94 -0
  167. package/src/modes/utils/ui-helpers.ts +20 -7
  168. package/src/notifications/config-commands.ts +20 -0
  169. package/src/notifications/config.ts +52 -5
  170. package/src/notifications/daemon-paths.ts +2 -0
  171. package/src/notifications/index.ts +144 -18
  172. package/src/notifications/lifecycle-commands.ts +74 -23
  173. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  174. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  175. package/src/notifications/reply-sent-store.ts +134 -0
  176. package/src/notifications/rich-draft.ts +107 -0
  177. package/src/notifications/rich-render.ts +142 -0
  178. package/src/notifications/telegram-daemon-control.ts +5 -3
  179. package/src/notifications/telegram-daemon.ts +606 -98
  180. package/src/notifications/telegram-reference.ts +17 -16
  181. package/src/notifications/threaded-render.ts +45 -2
  182. package/src/prompts/agents/architect.md +19 -10
  183. package/src/prompts/agents/critic.md +16 -18
  184. package/src/prompts/agents/executor.md +14 -11
  185. package/src/prompts/agents/planner.md +18 -13
  186. package/src/prompts/ci-green-request.md +3 -8
  187. package/src/prompts/goals/goal-continuation.md +0 -5
  188. package/src/prompts/goals/goal-mode-active.md +0 -5
  189. package/src/prompts/system/agent-creation-architect.md +2 -2
  190. package/src/prompts/system/plan-mode-active.md +1 -1
  191. package/src/prompts/system/plan-mode-subagent.md +3 -3
  192. package/src/prompts/system/project-prompt.md +0 -13
  193. package/src/prompts/system/subagent-system-prompt.md +3 -2
  194. package/src/prompts/system/system-prompt.md +29 -17
  195. package/src/prompts/system/volatile-project-context.md +10 -0
  196. package/src/prompts/system/web-search.md +4 -7
  197. package/src/prompts/tools/apply-patch.md +6 -0
  198. package/src/prompts/tools/ast-edit.md +1 -0
  199. package/src/prompts/tools/bash.md +1 -1
  200. package/src/prompts/tools/bisect.md +22 -0
  201. package/src/prompts/tools/browser.md +1 -1
  202. package/src/prompts/tools/computer.md +2 -2
  203. package/src/prompts/tools/cron.md +0 -2
  204. package/src/prompts/tools/eval.md +2 -2
  205. package/src/prompts/tools/find.md +1 -1
  206. package/src/prompts/tools/github.md +6 -6
  207. package/src/prompts/tools/goal.md +1 -3
  208. package/src/prompts/tools/image-gen.md +12 -5
  209. package/src/prompts/tools/irc.md +5 -4
  210. package/src/prompts/tools/job.md +2 -2
  211. package/src/prompts/tools/lsp.md +1 -1
  212. package/src/prompts/tools/monitor.md +2 -2
  213. package/src/prompts/tools/patch.md +2 -2
  214. package/src/prompts/tools/replace.md +2 -21
  215. package/src/prompts/tools/rewind.md +1 -1
  216. package/src/prompts/tools/search-tool-bm25.md +6 -11
  217. package/src/prompts/tools/search.md +1 -1
  218. package/src/prompts/tools/skill.md +3 -2
  219. package/src/prompts/tools/ssh.md +2 -1
  220. package/src/prompts/tools/subagent.md +2 -2
  221. package/src/prompts/tools/task.md +4 -5
  222. package/src/prompts/tools/todo-write.md +1 -1
  223. package/src/prompts/tools/vim.md +1 -0
  224. package/src/prompts/tools/web-search.md +2 -0
  225. package/src/prompts/tools/write.md +8 -2
  226. package/src/runtime-api-key.ts +16 -0
  227. package/src/sdk.ts +88 -36
  228. package/src/session/agent-session.ts +1013 -141
  229. package/src/session/agent-storage.ts +4 -0
  230. package/src/session/file-mention-pruning.ts +64 -0
  231. package/src/session/messages.ts +4 -0
  232. package/src/session/session-manager.ts +103 -4
  233. package/src/skc-runtime/deep-interview-ambiguity.ts +206 -0
  234. package/src/skc-runtime/deep-interview-recorder.ts +62 -8
  235. package/src/skc-runtime/deep-interview-state.ts +44 -2
  236. package/src/skc-runtime/session-state-sidecar.ts +263 -20
  237. package/src/skc-runtime/state-runtime.ts +12 -2
  238. package/src/skc-runtime/state-writer.ts +15 -0
  239. package/src/skc-runtime/tmux-common.ts +1 -0
  240. package/src/skc-runtime/ultragoal-guard.ts +55 -66
  241. package/src/skc-runtime/ultragoal-receipt-freshness.ts +315 -0
  242. package/src/skc-runtime/ultragoal-runtime.ts +1672 -159
  243. package/src/skc-runtime/workflow-manifest.generated.json +113 -2
  244. package/src/skc-runtime/workflow-manifest.ts +74 -1
  245. package/src/skill-state/active-state.ts +141 -5
  246. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  247. package/src/slash-commands/builtin-registry.ts +296 -72
  248. package/src/system-prompt.ts +57 -16
  249. package/src/task/executor.ts +132 -24
  250. package/src/task/fork-context-advisory.ts +2 -7
  251. package/src/task/fork-context-budget.ts +9 -0
  252. package/src/task/index.ts +13 -6
  253. package/src/task/output-manager.ts +20 -8
  254. package/src/task/receipt.ts +26 -7
  255. package/src/task/token-log.test.ts +188 -0
  256. package/src/task/token-log.ts +179 -0
  257. package/src/tools/bisect.ts +367 -0
  258. package/src/tools/computer.ts +246 -43
  259. package/src/tools/index.ts +3 -0
  260. package/src/tools/output-meta.ts +116 -4
  261. package/src/tools/search.ts +8 -2
  262. package/src/tools/skill.ts +15 -0
  263. package/src/tools/telegram-send.ts +6 -0
  264. package/src/tools/ultragoal-ask-guard.ts +39 -5
  265. package/src/utils/file-mentions.ts +39 -7
  266. package/src/utils/git.ts +73 -0
  267. package/src/utils/pasted-image-path.ts +170 -0
  268. package/src/web/scrapers/youtube.ts +24 -12
  269. package/src/workflow/workflow-intent-diff.ts +29 -6
  270. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  271. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  272. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -0,0 +1,179 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import type { ChatUsageSnapshot, CostEstimate } from "@sayknow-cli/agent-core";
4
+ import { sessionRoot } from "../skc-runtime/session-layout";
5
+ import { resolveSkcSessionForRead, SessionResolutionError } from "../skc-runtime/session-resolution";
6
+ import type { TaskTokenLog, TaskTokenMetrics } from "./types";
7
+
8
+ const TOKEN_LOG_FILE = "token-log.jsonl";
9
+
10
+ export interface TaskTokenLogChatBuckets {
11
+ readonly inputTokens: number;
12
+ readonly outputTokens: number;
13
+ readonly cachedInputTokens: number;
14
+ readonly cacheWriteTokens: number;
15
+ readonly costUsd?: number;
16
+ }
17
+
18
+ export interface TaskTokenLogSessionManager {
19
+ getSessionId(): string;
20
+ }
21
+
22
+ export interface TaskTokenLogContext {
23
+ readonly subagentId: string;
24
+ readonly agent?: string;
25
+ readonly turn: number;
26
+ readonly at: string;
27
+ readonly model?: string;
28
+ }
29
+
30
+ export function taskTokenLogFromChat(chat: TaskTokenLogChatBuckets, ctx: TaskTokenLogContext): TaskTokenLog {
31
+ const input = chat.inputTokens;
32
+ const output = chat.outputTokens;
33
+ const cacheRead = chat.cachedInputTokens;
34
+ const cacheWrite = chat.cacheWriteTokens;
35
+ return {
36
+ subagentId: ctx.subagentId,
37
+ ...(ctx.agent ? { agent: ctx.agent } : {}),
38
+ turn: ctx.turn,
39
+ at: ctx.at,
40
+ input,
41
+ output,
42
+ cacheRead,
43
+ cacheWrite,
44
+ totalTokens: input + output + cacheRead + cacheWrite,
45
+ ...(chat.costUsd !== undefined ? { cost: chat.costUsd } : {}),
46
+ ...(ctx.model ? { model: ctx.model } : {}),
47
+ };
48
+ }
49
+
50
+ export function taskTokenLogFromUsage(
51
+ usage: ChatUsageSnapshot,
52
+ ctx: TaskTokenLogContext & { readonly cost?: CostEstimate },
53
+ ): TaskTokenLog {
54
+ const costUsd = ctx.cost && "usd" in ctx.cost ? ctx.cost.usd : undefined;
55
+ const cacheRead = usage.cachedInputTokens ?? 0;
56
+ const cacheWrite = usage.cacheWriteTokens ?? 0;
57
+ // `ChatUsageSnapshot.inputTokens` is the aggregate input-class bucket
58
+ // (raw input + cacheRead + cacheWrite; see buildUsageSnapshot in the agent
59
+ // telemetry layer). `TaskTokenLog.input` must be the cost-bearing bucket that
60
+ // EXCLUDES cache, so subtract the cache buckets back out (clamped at 0).
61
+ const input = Math.max(0, usage.inputTokens - cacheRead - cacheWrite);
62
+ return taskTokenLogFromChat(
63
+ {
64
+ inputTokens: input,
65
+ outputTokens: usage.outputTokens,
66
+ cachedInputTokens: cacheRead,
67
+ cacheWriteTokens: cacheWrite,
68
+ ...(costUsd !== undefined ? { costUsd } : {}),
69
+ },
70
+ ctx,
71
+ );
72
+ }
73
+
74
+ export async function resolveTaskTokenLogDir(
75
+ cwd: string,
76
+ sessionManager: TaskTokenLogSessionManager | undefined,
77
+ envSessionId: string | undefined = process.env.SKC_SESSION_ID,
78
+ ): Promise<string | undefined> {
79
+ // Prefer the canonical SessionManager id so root turns land in the SAME
80
+ // `<session>/token-logs` dir the task executor uses for subagent turns and
81
+ // that `skc --fixture <id>` reads from. Fall back to the env/latest-active
82
+ // session only when no manager id is available (e.g. lifecycle launches where
83
+ // the SDK adopts a pre-allocated id internally). Never let a best-effort
84
+ // telemetry side channel crash startup — swallow every SessionResolutionError.
85
+ const managerId = sessionManager?.getSessionId();
86
+ if (managerId) return path.join(sessionRoot(cwd, managerId), "token-logs");
87
+ try {
88
+ const session = await resolveSkcSessionForRead(cwd, { envSessionId });
89
+ return path.join(session.sessionRoot, "token-logs");
90
+ } catch (error) {
91
+ if (error instanceof SessionResolutionError) return undefined;
92
+ throw error;
93
+ }
94
+ }
95
+ export async function persistTaskTokenLog(entry: TaskTokenLog, opts: { dir: string }): Promise<void> {
96
+ await fs.mkdir(opts.dir, { recursive: true });
97
+ await fs.appendFile(path.join(opts.dir, TOKEN_LOG_FILE), `${JSON.stringify(entry)}\n`, "utf-8");
98
+ }
99
+
100
+ export async function readTaskTokenLogs(dir: string): Promise<TaskTokenLog[]> {
101
+ let raw: string;
102
+ try {
103
+ raw = await fs.readFile(path.join(dir, TOKEN_LOG_FILE), "utf-8");
104
+ } catch (error) {
105
+ if (isNodeError(error) && error.code === "ENOENT") return [];
106
+ throw error;
107
+ }
108
+ const logs: TaskTokenLog[] = [];
109
+ let skipped = 0;
110
+ for (const line of raw.split("\n")) {
111
+ if (line.trim().length === 0) continue;
112
+ let parsed: unknown;
113
+ try {
114
+ parsed = JSON.parse(line);
115
+ } catch {
116
+ // Skip a torn/partially-flushed tail line rather than aborting the
117
+ // entire read; concurrent appends can leave an incomplete final line.
118
+ skipped += 1;
119
+ continue;
120
+ }
121
+ if (isTaskTokenLog(parsed)) logs.push(parsed);
122
+ else skipped += 1;
123
+ }
124
+ // A torn tail line alongside valid entries is tolerated, but a non-empty file
125
+ // that yields zero valid entries is corrupt — surface it loudly rather than
126
+ // returning [] (which a caller would render as a misleading all-zero report).
127
+ if (logs.length === 0 && skipped > 0) {
128
+ throw new Error(`corrupt token-log: ${skipped} unparseable line(s) and no valid entries in ${dir}`);
129
+ }
130
+ return logs;
131
+ }
132
+
133
+ export function computeTaskTokenMetrics(logs: readonly TaskTokenLog[]): TaskTokenMetrics {
134
+ let inputTokens = 0;
135
+ let outputTokens = 0;
136
+ let cacheReadTokens = 0;
137
+ let cacheWriteTokens = 0;
138
+ let totalTokens = 0;
139
+ for (const log of logs) {
140
+ inputTokens += log.input;
141
+ outputTokens += log.output;
142
+ cacheReadTokens += log.cacheRead;
143
+ cacheWriteTokens += log.cacheWrite;
144
+ totalTokens += log.totalTokens;
145
+ }
146
+ return {
147
+ turns: logs.length,
148
+ inputTokens,
149
+ outputTokens,
150
+ cacheReadTokens,
151
+ cacheWriteTokens,
152
+ totalTokens,
153
+ cacheHitRate: computeCacheHitRate(inputTokens, cacheReadTokens),
154
+ };
155
+ }
156
+
157
+ export function computeCacheHitRate(input: number, cacheRead: number): number {
158
+ const denominator = input + cacheRead;
159
+ return denominator <= 0 ? 0 : cacheRead / denominator;
160
+ }
161
+
162
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
163
+ return error instanceof Error && "code" in error;
164
+ }
165
+
166
+ function isTaskTokenLog(value: unknown): value is TaskTokenLog {
167
+ if (value === null || typeof value !== "object") return false;
168
+ const record = value as Record<string, unknown>;
169
+ return (
170
+ typeof record.subagentId === "string" &&
171
+ typeof record.turn === "number" &&
172
+ typeof record.at === "string" &&
173
+ typeof record.input === "number" &&
174
+ typeof record.output === "number" &&
175
+ typeof record.cacheRead === "number" &&
176
+ typeof record.cacheWrite === "number" &&
177
+ typeof record.totalTokens === "number"
178
+ );
179
+ }
@@ -0,0 +1,367 @@
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@sayknow-cli/agent-core";
2
+ import { prompt } from "@sayknow-cli/utils";
3
+ import * as z from "zod/v4";
4
+ import bisectDescription from "../prompts/tools/bisect.md" with { type: "text" };
5
+ import * as git from "../utils/git";
6
+ import type { ToolSession } from ".";
7
+ import type { OutputMeta } from "./output-meta";
8
+ import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
9
+ import { toolResult } from "./tool-result";
10
+
11
+ const DEFAULT_MAX_STEPS = 40;
12
+ const DEFAULT_STEP_TIMEOUT_MS = 10 * 60 * 1000;
13
+ /** git bisect run convention: a predicate exiting 125 means "cannot test this revision". */
14
+ const SKIP_EXIT_CODE = 125;
15
+
16
+ const bisectSchema = z.object({
17
+ good: z
18
+ .string()
19
+ .min(1)
20
+ .describe("A known-good commit-ish (must be an ancestor of `bad`) where the predicate passes."),
21
+ bad: z
22
+ .string()
23
+ .min(1)
24
+ .default("HEAD")
25
+ .describe("A known-bad commit-ish (defaults to HEAD) where the predicate fails."),
26
+ run: z
27
+ .string()
28
+ .min(1)
29
+ .describe("Shell command evaluated at each revision. Exit 0 = good, 125 = skip, any other non-zero = bad."),
30
+ invert: z
31
+ .boolean()
32
+ .default(false)
33
+ .describe("Find the commit that FIXED the behavior instead of the one that broke it (exit 0 is treated as bad)."),
34
+ maxSteps: z
35
+ .number()
36
+ .int()
37
+ .positive()
38
+ .max(1000)
39
+ .default(DEFAULT_MAX_STEPS)
40
+ .describe("Maximum bisection steps before giving up."),
41
+ stepTimeoutMs: z
42
+ .number()
43
+ .int()
44
+ .positive()
45
+ .default(DEFAULT_STEP_TIMEOUT_MS)
46
+ .describe("Per-step timeout in milliseconds; a timed-out step is treated as a skip."),
47
+ });
48
+
49
+ type BisectParams = z.infer<typeof bisectSchema>;
50
+
51
+ export type BisectVerdict = "good" | "bad" | "skip";
52
+
53
+ export interface BisectStep {
54
+ rev: string;
55
+ verdict: BisectVerdict;
56
+ }
57
+
58
+ export interface BisectMarkResult {
59
+ exitCode: number;
60
+ output: string;
61
+ }
62
+
63
+ export interface BisectControllerDeps {
64
+ maxSteps: number;
65
+ /** SHA of the revision git currently has checked out. */
66
+ currentRev: () => Promise<string>;
67
+ /** Evaluate the predicate at the checked-out revision. */
68
+ evaluate: (rev: string) => Promise<BisectVerdict>;
69
+ /** Apply the verdict via `git bisect good|bad|skip` and return the raw result. */
70
+ mark: (verdict: BisectVerdict) => Promise<BisectMarkResult>;
71
+ }
72
+
73
+ export interface BisectOutcome {
74
+ culprit: string | null;
75
+ steps: BisectStep[];
76
+ concluded: boolean;
77
+ reason?: string;
78
+ }
79
+
80
+ export interface BisectToolDetails {
81
+ meta?: OutputMeta;
82
+ culprit: string | null;
83
+ invert: boolean;
84
+ concluded: boolean;
85
+ steps: BisectStep[];
86
+ author?: string;
87
+ date?: string;
88
+ subject?: string;
89
+ reason?: string;
90
+ }
91
+
92
+ const FIRST_BAD_RE = /^([0-9a-f]{7,40}) is the first bad commit$/m;
93
+ const ONLY_SKIPPED_RE = /only '?skip'?ped commits left to test/i;
94
+
95
+ /** Parse the culprit SHA from `git bisect good|bad` output, or null when not yet converged. */
96
+ export function parseFirstBadCommit(output: string): string | null {
97
+ const match = FIRST_BAD_RE.exec(output);
98
+ return match ? match[1]! : null;
99
+ }
100
+
101
+ /**
102
+ * Map a predicate exit code to a bisect verdict. `invert` swaps the good/bad
103
+ * mapping (to hunt for a fixing commit) but never reinterprets a skip.
104
+ */
105
+ export function classifyExit(exitCode: number, invert: boolean): BisectVerdict {
106
+ if (exitCode === SKIP_EXIT_CODE) return "skip";
107
+ const verdict: BisectVerdict = exitCode === 0 ? "good" : "bad";
108
+ if (!invert) return verdict;
109
+ return verdict === "good" ? "bad" : "good";
110
+ }
111
+
112
+ /**
113
+ * Drive the bisect loop. Pure orchestration — every git/predicate effect is
114
+ * injected — so it can be exercised deterministically without a real repo.
115
+ */
116
+ export async function runBisectController(deps: BisectControllerDeps, signal?: AbortSignal): Promise<BisectOutcome> {
117
+ const steps: BisectStep[] = [];
118
+ for (let step = 0; step < deps.maxSteps; step += 1) {
119
+ throwIfAborted(signal);
120
+ const rev = await deps.currentRev();
121
+ const verdict = await deps.evaluate(rev);
122
+ steps.push({ rev, verdict });
123
+ const marked = await deps.mark(verdict);
124
+ if (marked.exitCode !== 0) {
125
+ return {
126
+ culprit: null,
127
+ steps,
128
+ concluded: false,
129
+ reason: marked.output.trim() || `git bisect ${verdict} failed with exit code ${marked.exitCode}`,
130
+ };
131
+ }
132
+ const culprit = parseFirstBadCommit(marked.output);
133
+ if (culprit) return { culprit, steps, concluded: true };
134
+ if (ONLY_SKIPPED_RE.test(marked.output)) {
135
+ return {
136
+ culprit: null,
137
+ steps,
138
+ concluded: false,
139
+ reason: "only skipped commits are left to test — the result is undetermined",
140
+ };
141
+ }
142
+ }
143
+ return {
144
+ culprit: null,
145
+ steps,
146
+ concluded: false,
147
+ reason: `reached the ${deps.maxSteps}-step limit without converging`,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Run the predicate as `sh -c <run>` in `cwd` and return its exit code. A step
153
+ * that exceeds `timeoutMs` is killed and reported as a skip (125). Honors the
154
+ * outer abort signal by propagating a {@link ToolAbortError}.
155
+ */
156
+ async function evaluatePredicate(
157
+ run: string,
158
+ cwd: string,
159
+ timeoutMs: number,
160
+ signal: AbortSignal | undefined,
161
+ ): Promise<number> {
162
+ const controller = new AbortController();
163
+ let timedOut = false;
164
+ const timer = setTimeout(() => {
165
+ timedOut = true;
166
+ controller.abort();
167
+ }, timeoutMs);
168
+ const onExternalAbort = () => controller.abort();
169
+ signal?.addEventListener("abort", onExternalAbort, { once: true });
170
+ try {
171
+ const child = Bun.spawn(["sh", "-c", run], {
172
+ cwd,
173
+ stdin: "ignore",
174
+ stdout: "ignore",
175
+ stderr: "ignore",
176
+ signal: controller.signal,
177
+ });
178
+ const exitCode = await child.exited;
179
+ if (timedOut) return SKIP_EXIT_CODE;
180
+ if (signal?.aborted) throw new ToolAbortError();
181
+ return exitCode ?? 1;
182
+ } catch (error) {
183
+ if (timedOut) return SKIP_EXIT_CODE;
184
+ if (signal?.aborted) throw new ToolAbortError();
185
+ throw new ToolError(`Failed to run bisect predicate: ${error instanceof Error ? error.message : String(error)}`);
186
+ } finally {
187
+ clearTimeout(timer);
188
+ signal?.removeEventListener("abort", onExternalAbort);
189
+ }
190
+ }
191
+
192
+ function formatSteps(steps: BisectStep[]): string {
193
+ if (steps.length === 0) return " (seeded range converged immediately)";
194
+ return steps.map((step, index) => ` ${index + 1}. ${step.rev.slice(0, 12)} → ${step.verdict}`).join("\n");
195
+ }
196
+
197
+ /**
198
+ * Report the post-teardown worktree state honestly. `git bisect reset` plus a
199
+ * `git reset --hard` restores every *tracked* file to its pre-bisect state, so
200
+ * the common "predicate wrote to a tracked file" case is fully cleaned. A
201
+ * predicate that created *untracked* files is intentionally left untouched (the
202
+ * tool never deletes files it did not create), so those are surfaced as a note
203
+ * rather than hidden behind a false "restored" claim. Any residual tracked
204
+ * change is reported as a warning so the tool never overstates the cleanup.
205
+ */
206
+ async function describeRestore(cwd: string): Promise<string> {
207
+ const tracked = (await git.status(cwd, { porcelainV1: true, untrackedFiles: "no" }).catch(() => "")).trim();
208
+ if (tracked) {
209
+ return `WARNING: working tree not fully restored — tracked changes remain after teardown:\n${tracked}`;
210
+ }
211
+ const all = (await git.status(cwd, { porcelainV1: true, untrackedFiles: "all" }).catch(() => "")).trim();
212
+ if (all) {
213
+ return "Working tree restored: tracked files reset to their pre-bisect state (untracked files the predicate created are left in place).";
214
+ }
215
+ return "Working tree restored to its pre-bisect state.";
216
+ }
217
+
218
+ export class BisectTool implements AgentTool<typeof bisectSchema, BisectToolDetails> {
219
+ readonly name = "bisect";
220
+ readonly label = "Bisect";
221
+ readonly summary = "Find the commit that introduced a regression by driving git bisect with a shell predicate";
222
+ readonly description: string;
223
+ readonly parameters = bisectSchema;
224
+ readonly strict = true;
225
+ readonly loadMode = "discoverable";
226
+ readonly intent = (args: Partial<BisectParams>): string =>
227
+ args.run ? `bisecting: ${args.run}` : "bisecting regression";
228
+
229
+ constructor(private readonly session: ToolSession) {
230
+ this.description = prompt.render(bisectDescription);
231
+ }
232
+
233
+ async execute(
234
+ _toolCallId: string,
235
+ params: BisectParams,
236
+ signal?: AbortSignal,
237
+ _onUpdate?: AgentToolUpdateCallback<BisectToolDetails>,
238
+ _context?: AgentToolContext,
239
+ ): Promise<AgentToolResult<BisectToolDetails>> {
240
+ // Resolve the worktree top level once and run every git, predicate, and
241
+ // teardown operation from it — never from the raw session cwd. The session
242
+ // cwd may be a subdirectory that a candidate commit deletes mid-bisect
243
+ // (leaving later commands with a missing working directory), and `git
244
+ // bisect` itself must be run from the top level of the working tree.
245
+ const cwd = await git.repo.root(this.session.cwd, signal);
246
+ if (!cwd) {
247
+ throw new ToolError("bisect requires a git repository; the current directory is not inside a git worktree.");
248
+ }
249
+ const dirty = (await git.status(cwd, { porcelainV1: true, untrackedFiles: "no" })).trim();
250
+ if (dirty) {
251
+ throw new ToolError(
252
+ "Working tree has uncommitted changes. Commit or stash them before bisecting — git bisect checks out historical commits and would clobber uncommitted work.",
253
+ );
254
+ }
255
+
256
+ const goodSha = await git.ref.resolve(cwd, params.good, signal);
257
+ if (!goodSha) throw new ToolError(`Could not resolve the good ref: ${params.good}`);
258
+ const badSha = await git.ref.resolve(cwd, params.bad, signal);
259
+ if (!badSha) throw new ToolError(`Could not resolve the bad ref: ${params.bad}`);
260
+ if (goodSha === badSha) {
261
+ throw new ToolError("good and bad resolve to the same commit; there is nothing to bisect.");
262
+ }
263
+ if (!(await git.bisect.isAncestor(cwd, goodSha, badSha, signal))) {
264
+ throw new ToolError(
265
+ `good (${params.good}) must be an ancestor of bad (${params.bad}). Swap them or pick a correct known-good commit.`,
266
+ );
267
+ }
268
+
269
+ let outcome: BisectOutcome;
270
+ // Defensive reset in case a previous aborted bisect left state behind; a
271
+ // no-op when not bisecting (reset never throws).
272
+ await git.bisect.reset(cwd, signal);
273
+ try {
274
+ await git.bisect.start(cwd, signal);
275
+ const badMark = await git.bisect.bad(cwd, badSha, signal);
276
+ if (badMark.exitCode !== 0) {
277
+ throw new ToolError(`git bisect bad failed: ${badMark.stderr.trim() || badMark.stdout.trim()}`);
278
+ }
279
+ const goodMark = await git.bisect.good(cwd, goodSha, signal);
280
+ if (goodMark.exitCode !== 0) {
281
+ throw new ToolError(`git bisect good failed: ${goodMark.stderr.trim() || goodMark.stdout.trim()}`);
282
+ }
283
+ // A tiny good..bad range can converge on the seeding marks themselves.
284
+ const seededCulprit = parseFirstBadCommit(`${goodMark.stdout}\n${goodMark.stderr}`);
285
+ if (seededCulprit) {
286
+ outcome = { culprit: seededCulprit, steps: [], concluded: true };
287
+ } else {
288
+ outcome = await runBisectController(
289
+ {
290
+ maxSteps: params.maxSteps,
291
+ currentRev: async () => (await git.head.sha(cwd, signal)) ?? "HEAD",
292
+ evaluate: async () =>
293
+ classifyExit(
294
+ await evaluatePredicate(params.run, cwd, params.stepTimeoutMs, signal),
295
+ params.invert,
296
+ ),
297
+ mark: async verdict => {
298
+ const result =
299
+ verdict === "good"
300
+ ? await git.bisect.good(cwd, undefined, signal)
301
+ : verdict === "bad"
302
+ ? await git.bisect.bad(cwd, undefined, signal)
303
+ : await git.bisect.skip(cwd, signal);
304
+ return { exitCode: result.exitCode, output: `${result.stdout}\n${result.stderr}` };
305
+ },
306
+ },
307
+ signal,
308
+ );
309
+ }
310
+ } finally {
311
+ // Always restore the working tree, even on error/abort. `git bisect
312
+ // reset` returns HEAD to the original branch/commit but leaves behind
313
+ // any tracked-file edits the `sh -c` predicate made mid-run. The tracked
314
+ // tree was verified clean before we started (precondition above), so
315
+ // discarding tracked modifications (reset --hard) only reverts the
316
+ // predicate's side effects, never user work; then bisect reset restores
317
+ // the original HEAD. Both are best-effort and must not throw here.
318
+ await git.reset(cwd, { hard: true }).catch(() => {});
319
+ await git.bisect.reset(cwd);
320
+ }
321
+
322
+ // Never claim a clean restore we did not actually achieve.
323
+ const restoreLine = await describeRestore(cwd);
324
+
325
+ if (outcome.concluded && outcome.culprit) {
326
+ const info = await git.bisect.describe(cwd, outcome.culprit).catch(() => null);
327
+ const lines = [
328
+ params.invert ? `First fixing commit: ${outcome.culprit}` : `First bad commit: ${outcome.culprit}`,
329
+ ];
330
+ if (info) {
331
+ lines.push(`Author: ${info.author}`, `Date: ${info.date}`, `Subject: ${info.subject}`);
332
+ if (info.stat) lines.push("", "Files changed:", info.stat);
333
+ }
334
+ lines.push("", `Tested ${outcome.steps.length} revision(s):`, formatSteps(outcome.steps));
335
+ lines.push("", restoreLine);
336
+ return toolResult<BisectToolDetails>({
337
+ culprit: outcome.culprit,
338
+ invert: params.invert,
339
+ concluded: true,
340
+ steps: outcome.steps,
341
+ author: info?.author,
342
+ date: info?.date,
343
+ subject: info?.subject,
344
+ })
345
+ .text(lines.join("\n"))
346
+ .done();
347
+ }
348
+
349
+ const lines = [
350
+ `Bisect did not converge: ${outcome.reason ?? "unknown reason"}.`,
351
+ "",
352
+ `Tested ${outcome.steps.length} revision(s):`,
353
+ formatSteps(outcome.steps),
354
+ "",
355
+ restoreLine,
356
+ ];
357
+ return toolResult<BisectToolDetails>({
358
+ culprit: null,
359
+ invert: params.invert,
360
+ concluded: false,
361
+ steps: outcome.steps,
362
+ reason: outcome.reason,
363
+ })
364
+ .text(lines.join("\n"))
365
+ .done();
366
+ }
367
+ }