@oh-my-pi/pi-coding-agent 17.2.10 → 17.2.12

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 (247) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/{CHANGELOG-rhwzpexa.md → CHANGELOG-k9ghy5sn.md} +53 -0
  3. package/dist/cli.js +10775 -10762
  4. package/dist/types/advisor/runtime.d.ts +1 -1
  5. package/dist/types/capability/mcp.d.ts +11 -0
  6. package/dist/types/capability/skill.d.ts +6 -0
  7. package/dist/types/cli/command-help.d.ts +3 -0
  8. package/dist/types/commands/share.d.ts +25 -0
  9. package/dist/types/commit/agentic/index.d.ts +3 -1
  10. package/dist/types/commit/execute.d.ts +32 -0
  11. package/dist/types/commit/index.d.ts +2 -1
  12. package/dist/types/commit/pipeline.d.ts +7 -1
  13. package/dist/types/config/custom-models.d.ts +31 -0
  14. package/dist/types/config/model-config-values.d.ts +19 -0
  15. package/dist/types/config/model-patch.d.ts +93 -0
  16. package/dist/types/config/model-provider-discovery.d.ts +51 -0
  17. package/dist/types/config/model-registry.d.ts +9 -52
  18. package/dist/types/config/settings-schema.d.ts +1 -34
  19. package/dist/types/config/settings.d.ts +5 -3
  20. package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
  21. package/dist/types/discovery/agent-plugin-format.d.ts +136 -0
  22. package/dist/types/discovery/agent-plugins.d.ts +1 -0
  23. package/dist/types/discovery/contained-path.d.ts +33 -0
  24. package/dist/types/discovery/index.d.ts +1 -0
  25. package/dist/types/eval/executor-base.d.ts +8 -2
  26. package/dist/types/eval/kernel-session-registry.d.ts +60 -0
  27. package/dist/types/export/share.d.ts +1 -1
  28. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  29. package/dist/types/extensibility/extensions/loader.d.ts +7 -0
  30. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  31. package/dist/types/extensibility/shared-events.d.ts +3 -2
  32. package/dist/types/extensibility/skills.d.ts +5 -0
  33. package/dist/types/lsp/diagnostics.d.ts +96 -0
  34. package/dist/types/lsp/index.d.ts +7 -128
  35. package/dist/types/lsp/servers.d.ts +72 -0
  36. package/dist/types/lsp/tool.d.ts +42 -0
  37. package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
  38. package/dist/types/lsp/writethrough.d.ts +33 -0
  39. package/dist/types/mcp/transports/header-policy.d.ts +46 -0
  40. package/dist/types/mcp/types.d.ts +15 -0
  41. package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
  42. package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
  43. package/dist/types/modes/components/agent-hub.d.ts +2 -0
  44. package/dist/types/modes/components/agent-transcript-viewer.d.ts +2 -0
  45. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  46. package/dist/types/modes/components/custom-editor.d.ts +1 -2
  47. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  48. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  49. package/dist/types/modes/theme/color.d.ts +19 -0
  50. package/dist/types/modes/theme/loader.d.ts +19 -0
  51. package/dist/types/modes/theme/schema.d.ts +175 -0
  52. package/dist/types/modes/theme/symbols.d.ts +28 -0
  53. package/dist/types/modes/theme/theme-class.d.ts +244 -0
  54. package/dist/types/modes/theme/theme.d.ts +9 -280
  55. package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
  56. package/dist/types/modes/utils/transcript-render-helpers.d.ts +3 -2
  57. package/dist/types/registry/agent-registry.d.ts +1 -3
  58. package/dist/types/secrets/index.d.ts +26 -2
  59. package/dist/types/secrets/message-transform.d.ts +40 -0
  60. package/dist/types/secrets/obfuscator.d.ts +0 -108
  61. package/dist/types/secrets/placeholder-scan.d.ts +95 -0
  62. package/dist/types/secrets/placeholder.d.ts +94 -0
  63. package/dist/types/secrets/replacement.d.ts +82 -0
  64. package/dist/types/session/agent-session-events.d.ts +2 -2
  65. package/dist/types/session/agent-session-types.d.ts +6 -0
  66. package/dist/types/session/agent-session.d.ts +2 -2
  67. package/dist/types/session/messages.d.ts +1 -0
  68. package/dist/types/session/prewalk.d.ts +3 -1
  69. package/dist/types/session/session-handoff.d.ts +3 -3
  70. package/dist/types/session/session-manager.d.ts +32 -0
  71. package/dist/types/session/session-provider-boundary.d.ts +1 -1
  72. package/dist/types/session/session-tools.d.ts +8 -0
  73. package/dist/types/session/turn-recovery.d.ts +9 -4
  74. package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
  75. package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
  76. package/dist/types/slash-commands/builtin-control.d.ts +2 -0
  77. package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
  78. package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
  79. package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
  80. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  81. package/dist/types/slash-commands/builtin-session.d.ts +2 -0
  82. package/dist/types/task/index.d.ts +2 -0
  83. package/dist/types/tools/gh-common.d.ts +69 -0
  84. package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
  85. package/dist/types/tools/gh-pr-diff.d.ts +102 -0
  86. package/dist/types/tools/gh-run-watch.d.ts +78 -0
  87. package/dist/types/tools/gh-search.d.ts +61 -0
  88. package/dist/types/tools/gh-types.d.ts +340 -0
  89. package/dist/types/tools/gh-view.d.ts +88 -0
  90. package/dist/types/tools/gh.d.ts +4 -201
  91. package/dist/types/tools/read-archive.d.ts +16 -0
  92. package/dist/types/tools/read-format.d.ts +104 -0
  93. package/dist/types/tools/read-path-resolution.d.ts +19 -0
  94. package/dist/types/tools/read-pdf-images.d.ts +12 -0
  95. package/dist/types/tools/read-renderer.d.ts +24 -0
  96. package/dist/types/tools/read-selector.d.ts +27 -0
  97. package/dist/types/tools/read-sqlite.d.ts +16 -0
  98. package/dist/types/tools/read-summary.d.ts +19 -0
  99. package/dist/types/tools/read.d.ts +1 -23
  100. package/dist/types/tools/shell-tokenize.d.ts +18 -1
  101. package/dist/types/utils/changelog.d.ts +0 -5
  102. package/package.json +13 -13
  103. package/scripts/legacy-pi-virtual-module.ts +4 -1
  104. package/src/advisor/runtime.ts +12 -7
  105. package/src/capability/mcp.ts +11 -0
  106. package/src/capability/skill.ts +6 -0
  107. package/src/cli/command-help.ts +4 -0
  108. package/src/cli/gallery-cli.ts +1 -1
  109. package/src/cli-commands.ts +5 -0
  110. package/src/commands/commit.ts +16 -3
  111. package/src/commands/share.ts +71 -0
  112. package/src/commit/agentic/index.ts +39 -21
  113. package/src/commit/execute.ts +56 -0
  114. package/src/commit/index.ts +2 -1
  115. package/src/commit/pipeline.ts +20 -7
  116. package/src/config/custom-models.ts +188 -0
  117. package/src/config/model-config-values.ts +128 -0
  118. package/src/config/model-discovery.ts +1 -1
  119. package/src/config/model-patch.ts +252 -0
  120. package/src/config/model-provider-discovery.ts +132 -0
  121. package/src/config/model-registry.ts +84 -704
  122. package/src/config/settings-schema.ts +2 -33
  123. package/src/config/settings.ts +53 -3
  124. package/src/debug/raw-sse-buffer.ts +20 -0
  125. package/src/discovery/agent-plugin-format.ts +551 -0
  126. package/src/discovery/agent-plugins.ts +341 -0
  127. package/src/discovery/claude-plugins.ts +21 -5
  128. package/src/discovery/contained-path.ts +75 -0
  129. package/src/discovery/helpers.ts +23 -9
  130. package/src/discovery/index.ts +1 -0
  131. package/src/discovery/omp-plugins.ts +20 -7
  132. package/src/edit/hashline/diff.ts +5 -1
  133. package/src/eval/executor-base.ts +39 -6
  134. package/src/eval/jl/executor.ts +82 -371
  135. package/src/eval/kernel-session-registry.ts +398 -0
  136. package/src/eval/py/executor.ts +53 -261
  137. package/src/eval/rb/executor.ts +36 -279
  138. package/src/exec/non-interactive-env.ts +1 -0
  139. package/src/export/share.ts +2 -1
  140. package/src/extensibility/custom-tools/types.ts +2 -2
  141. package/src/extensibility/extensions/loader.ts +78 -14
  142. package/src/extensibility/extensions/runner.ts +6 -0
  143. package/src/extensibility/extensions/types.ts +12 -0
  144. package/src/extensibility/plugins/marketplace/manager.ts +2 -1
  145. package/src/extensibility/shared-events.ts +3 -2
  146. package/src/extensibility/skills.ts +9 -0
  147. package/src/internal-urls/skill-protocol.ts +14 -0
  148. package/src/internal-urls/vault-protocol.ts +2 -2
  149. package/src/launch/client.ts +11 -1
  150. package/src/launch/protocol.ts +7 -2
  151. package/src/lsp/diagnostics.ts +516 -0
  152. package/src/lsp/index.ts +20 -2819
  153. package/src/lsp/servers.ts +296 -0
  154. package/src/lsp/tool.ts +1352 -0
  155. package/src/lsp/workspace-diagnostics.ts +170 -0
  156. package/src/lsp/writethrough.ts +561 -0
  157. package/src/main.ts +3 -2
  158. package/src/mcp/config.ts +3 -0
  159. package/src/mcp/manager.ts +12 -9
  160. package/src/mcp/transports/header-policy.ts +95 -0
  161. package/src/mcp/transports/http.ts +35 -52
  162. package/src/mcp/transports/sse.ts +20 -27
  163. package/src/mcp/types.ts +15 -0
  164. package/src/modes/acp/acp-agent.ts +15 -4
  165. package/src/modes/components/agent-dashboard.ts +2 -0
  166. package/src/modes/components/agent-hub-projection.ts +2 -2
  167. package/src/modes/components/agent-hub-renderer.ts +3 -7
  168. package/src/modes/components/agent-hub.ts +5 -0
  169. package/src/modes/components/agent-transcript-viewer.ts +3 -0
  170. package/src/modes/components/chat-transcript-builder.ts +3 -0
  171. package/src/modes/components/custom-editor.ts +0 -9
  172. package/src/modes/components/status-line/component.ts +1 -0
  173. package/src/modes/components/status-line/segments.ts +6 -4
  174. package/src/modes/components/status-line/types.ts +2 -0
  175. package/src/modes/components/tool-execution.ts +13 -16
  176. package/src/modes/components/tree-selector.ts +62 -3
  177. package/src/modes/controllers/command-controller.ts +8 -2
  178. package/src/modes/controllers/event-controller.ts +15 -15
  179. package/src/modes/controllers/input-controller.ts +20 -2
  180. package/src/modes/controllers/selector-controller.ts +1 -0
  181. package/src/modes/theme/color.ts +133 -0
  182. package/src/modes/theme/loader.ts +178 -0
  183. package/src/modes/theme/schema.ts +263 -0
  184. package/src/modes/theme/symbols.ts +1000 -0
  185. package/src/modes/theme/theme-class.ts +611 -0
  186. package/src/modes/theme/theme.ts +27 -2453
  187. package/src/modes/theme/tui-adapters.ts +279 -0
  188. package/src/modes/utils/transcript-render-helpers.ts +4 -2
  189. package/src/modes/utils/ui-helpers.ts +1 -0
  190. package/src/registry/agent-registry.ts +2 -2
  191. package/src/sdk.ts +6 -49
  192. package/src/secrets/index.ts +51 -6
  193. package/src/secrets/message-transform.ts +287 -0
  194. package/src/secrets/obfuscator.ts +39 -1303
  195. package/src/secrets/placeholder-scan.ts +506 -0
  196. package/src/secrets/placeholder.ts +309 -0
  197. package/src/secrets/replacement.ts +216 -0
  198. package/src/session/agent-session-events.ts +2 -2
  199. package/src/session/agent-session-types.ts +6 -0
  200. package/src/session/agent-session.ts +146 -21
  201. package/src/session/indexed-session-storage.ts +7 -2
  202. package/src/session/messages.ts +1 -0
  203. package/src/session/prewalk.ts +57 -17
  204. package/src/session/session-advisors.ts +32 -34
  205. package/src/session/session-context.ts +3 -5
  206. package/src/session/session-handoff.ts +39 -16
  207. package/src/session/session-manager.ts +67 -3
  208. package/src/session/session-provider-boundary.ts +3 -6
  209. package/src/session/session-tools.ts +37 -3
  210. package/src/session/todo-tracker.ts +11 -1
  211. package/src/session/turn-recovery.ts +115 -84
  212. package/src/slash-commands/builtin-collaboration.ts +504 -0
  213. package/src/slash-commands/builtin-completions.ts +291 -0
  214. package/src/slash-commands/builtin-control.ts +78 -0
  215. package/src/slash-commands/builtin-lifecycle.ts +506 -0
  216. package/src/slash-commands/builtin-marketplace.ts +567 -0
  217. package/src/slash-commands/builtin-modes.ts +518 -0
  218. package/src/slash-commands/builtin-registry.ts +21 -2996
  219. package/src/slash-commands/builtin-session.ts +592 -0
  220. package/src/task/executor.ts +17 -14
  221. package/src/task/index.ts +21 -6
  222. package/src/tools/bash-skill-urls.ts +15 -0
  223. package/src/tools/bash.ts +10 -11
  224. package/src/tools/debug.ts +1 -1
  225. package/src/tools/gh-common.ts +288 -0
  226. package/src/tools/gh-pr-checkout.ts +679 -0
  227. package/src/tools/gh-pr-diff.ts +473 -0
  228. package/src/tools/gh-run-watch.ts +1015 -0
  229. package/src/tools/gh-search.ts +500 -0
  230. package/src/tools/gh-types.ts +379 -0
  231. package/src/tools/gh-view.ts +612 -0
  232. package/src/tools/gh.ts +109 -3821
  233. package/src/tools/jtd-to-json-schema.ts +123 -21
  234. package/src/tools/read-archive.ts +209 -0
  235. package/src/tools/read-format.ts +593 -0
  236. package/src/tools/read-path-resolution.ts +36 -0
  237. package/src/tools/read-pdf-images.ts +250 -0
  238. package/src/tools/read-renderer.ts +289 -0
  239. package/src/tools/read-selector.ts +84 -0
  240. package/src/tools/read-sqlite.ts +215 -0
  241. package/src/tools/read-summary.ts +199 -0
  242. package/src/tools/read.ts +82 -1824
  243. package/src/tools/shell-tokenize.ts +99 -1
  244. package/src/utils/changelog.ts +1 -1
  245. package/src/utils/title-generator.ts +3 -1
  246. package/src/web/search/providers/exa.ts +1 -1
  247. package/src/web/search/providers/zai.ts +12 -3
@@ -0,0 +1,1015 @@
1
+ import { scheduler } from "node:timers/promises";
2
+ import type { AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
+ import * as git from "../utils/git";
4
+ import type { ToolSession } from ".";
5
+ import type {
6
+ GhRunWatchFailedLogDetails,
7
+ GhRunWatchJobDetails,
8
+ GhRunWatchRunDetails,
9
+ GhRunWatchViewDetails,
10
+ GhToolDetails,
11
+ } from "./gh";
12
+ import {
13
+ buildTextResult,
14
+ githubRepoSlugEquals,
15
+ normalizeBlock,
16
+ normalizeOptionalString,
17
+ pushLine,
18
+ requireCurrentGitBranch,
19
+ requireCurrentGitHead,
20
+ requireNonEmpty,
21
+ resolveGitHubRepo,
22
+ saveArtifactText,
23
+ tryResolveCurrentRepoFresh,
24
+ } from "./gh-common";
25
+ import { formatShortSha } from "./gh-format";
26
+ import type {
27
+ GhActionsJobApi,
28
+ GhActionsJobsResponse,
29
+ GhActionsRunApi,
30
+ GhActionsRunListResponse,
31
+ GhBranchApiResponse,
32
+ GhFailedJobLog,
33
+ GhRunJobSnapshot,
34
+ GhRunReference,
35
+ GhRunSnapshot,
36
+ GithubInput,
37
+ } from "./gh-types";
38
+ import { ToolError, throwIfAborted } from "./tool-errors";
39
+
40
+ export const RUN_WATCH_INTERVAL_DEFAULT = 3;
41
+ export const RUN_WATCH_INTERVAL_SLOW = 15;
42
+ export const RUN_WATCH_FAST_WINDOW_MS = 60_000;
43
+ export const RUN_WATCH_NO_RUNS_GIVE_UP_MS = 90_000;
44
+ export const RUN_WATCH_MAX_POLL_FAILURES = 5;
45
+ export const RUN_WATCH_GRACE_DEFAULT = 5;
46
+ export const RUN_WATCH_TAIL_DEFAULT = 15;
47
+ export const RUN_WATCH_TAIL_MAX = 200;
48
+
49
+ export const RUN_JOBS_PAGE_SIZE = 100;
50
+
51
+ export function resolveTailLimit(value: number | undefined): number {
52
+ if (value === undefined) {
53
+ return RUN_WATCH_TAIL_DEFAULT;
54
+ }
55
+
56
+ if (!Number.isFinite(value) || value <= 0) {
57
+ throw new ToolError("tail must be a positive number");
58
+ }
59
+
60
+ return Math.min(Math.floor(value), RUN_WATCH_TAIL_MAX);
61
+ }
62
+
63
+ export const RUN_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/(\d+)(?:\/.*)?$/;
64
+ export const RUN_SUCCESS_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
65
+ export const RUN_FAILURE_CONCLUSIONS = new Set([
66
+ "failure",
67
+ "timed_out",
68
+ "cancelled",
69
+ "action_required",
70
+ "startup_failure",
71
+ ]);
72
+ export const JOB_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required"]);
73
+
74
+ export function parseRunReference(value: string | undefined): GhRunReference {
75
+ const run = normalizeOptionalString(value);
76
+ if (!run) {
77
+ return {};
78
+ }
79
+
80
+ if (/^\d+$/.test(run)) {
81
+ return { runId: Number(run) };
82
+ }
83
+
84
+ const match = run.match(RUN_URL_PATTERN);
85
+ if (!match) {
86
+ throw new ToolError("run must be a numeric workflow run ID or a full GitHub Actions run URL");
87
+ }
88
+
89
+ return {
90
+ repo: match[1],
91
+ runId: Number(match[2]),
92
+ };
93
+ }
94
+
95
+ export function normalizeRunJob(job: GhActionsJobApi): GhRunJobSnapshot | null {
96
+ if (typeof job.id !== "number") {
97
+ return null;
98
+ }
99
+
100
+ return {
101
+ id: job.id,
102
+ name: normalizeOptionalString(job.name) ?? `job-${job.id}`,
103
+ status: normalizeOptionalString(job.status),
104
+ conclusion: normalizeOptionalString(job.conclusion),
105
+ startedAt: normalizeOptionalString(job.started_at),
106
+ completedAt: normalizeOptionalString(job.completed_at),
107
+ url: normalizeOptionalString(job.html_url),
108
+ };
109
+ }
110
+
111
+ export function normalizeRunSnapshot(run: GhActionsRunApi, jobs: GhRunJobSnapshot[]): GhRunSnapshot {
112
+ if (typeof run.id !== "number") {
113
+ throw new ToolError("GitHub Actions run response did not include a run ID.");
114
+ }
115
+
116
+ return {
117
+ id: run.id,
118
+ workflowName: normalizeOptionalString(run.name),
119
+ displayTitle: normalizeOptionalString(run.display_title),
120
+ status: normalizeOptionalString(run.status),
121
+ conclusion: normalizeOptionalString(run.conclusion),
122
+ branch: normalizeOptionalString(run.head_branch),
123
+ headSha: normalizeOptionalString(run.head_sha),
124
+ createdAt: normalizeOptionalString(run.created_at),
125
+ updatedAt: normalizeOptionalString(run.updated_at),
126
+ url: normalizeOptionalString(run.html_url),
127
+ jobs,
128
+ };
129
+ }
130
+
131
+ export function getRunOutcome(value: string | undefined): "success" | "failure" | "pending" {
132
+ if (!value) {
133
+ return "pending";
134
+ }
135
+
136
+ if (RUN_SUCCESS_CONCLUSIONS.has(value)) {
137
+ return "success";
138
+ }
139
+
140
+ if (RUN_FAILURE_CONCLUSIONS.has(value)) {
141
+ return "failure";
142
+ }
143
+
144
+ return "pending";
145
+ }
146
+
147
+ export function getRunSnapshotOutcome(run: GhRunSnapshot): "success" | "failure" | "pending" {
148
+ if (run.status !== "completed") {
149
+ return "pending";
150
+ }
151
+
152
+ return getRunOutcome(run.conclusion);
153
+ }
154
+
155
+ export function getRunCollectionOutcome(runs: GhRunSnapshot[]): "success" | "failure" | "pending" {
156
+ if (runs.length === 0) {
157
+ return "pending";
158
+ }
159
+
160
+ let pending = false;
161
+ for (const run of runs) {
162
+ if (run.jobs.some(isFailedJob)) {
163
+ return "failure";
164
+ }
165
+
166
+ const outcome = getRunSnapshotOutcome(run);
167
+ if (outcome === "failure") {
168
+ return "failure";
169
+ }
170
+ if (outcome === "pending") {
171
+ pending = true;
172
+ }
173
+ }
174
+
175
+ return pending ? "pending" : "success";
176
+ }
177
+
178
+ export function getRunCollectionSignature(runs: GhRunSnapshot[]): string {
179
+ return runs
180
+ .map(run => run.id)
181
+ .sort((left, right) => left - right)
182
+ .join(",");
183
+ }
184
+
185
+ export function isFailedJob(job: GhRunJobSnapshot): boolean {
186
+ return job.conclusion !== undefined && JOB_FAILURE_CONCLUSIONS.has(job.conclusion);
187
+ }
188
+
189
+ export const GH_RATE_LIMIT_ERROR_PATTERN = /rate limit|HTTP 429|abuse detection/i;
190
+
191
+ /**
192
+ * Rate-limit / secondary-limit gh failures are transient; the run_watch poll
193
+ * loops back off and retry them instead of discarding the whole watch.
194
+ */
195
+ export function isRateLimitedGhError(err: unknown): boolean {
196
+ return err instanceof ToolError && GH_RATE_LIMIT_ERROR_PATTERN.test(err.message);
197
+ }
198
+
199
+ export function formatJobState(job: GhRunJobSnapshot): string {
200
+ return job.conclusion ?? job.status ?? "unknown";
201
+ }
202
+
203
+ export function parseTimestampMs(value: string | undefined): number | undefined {
204
+ if (!value) {
205
+ return undefined;
206
+ }
207
+
208
+ const timestamp = Date.parse(value);
209
+ return Number.isNaN(timestamp) ? undefined : timestamp;
210
+ }
211
+
212
+ export function getJobDurationSeconds(job: GhRunJobSnapshot, observedAtMs: number): number | undefined {
213
+ const startedAtMs = parseTimestampMs(job.startedAt);
214
+ if (startedAtMs === undefined) {
215
+ return undefined;
216
+ }
217
+
218
+ const completedAtMs = parseTimestampMs(job.completedAt) ?? observedAtMs;
219
+ return Math.max(0, Math.floor((completedAtMs - startedAtMs) / 1000));
220
+ }
221
+
222
+ export function buildRunWatchJobDetails(job: GhRunJobSnapshot, observedAtMs: number): GhRunWatchJobDetails {
223
+ return {
224
+ id: job.id,
225
+ name: job.name,
226
+ status: job.status,
227
+ conclusion: job.conclusion,
228
+ durationSeconds: getJobDurationSeconds(job, observedAtMs),
229
+ url: job.url,
230
+ };
231
+ }
232
+
233
+ export function buildRunWatchRunDetails(run: GhRunSnapshot, observedAtMs: number): GhRunWatchRunDetails {
234
+ return {
235
+ id: run.id,
236
+ workflowName: run.workflowName,
237
+ displayTitle: run.displayTitle,
238
+ status: run.status,
239
+ conclusion: run.conclusion,
240
+ branch: run.branch,
241
+ headSha: run.headSha,
242
+ url: run.url,
243
+ jobs: run.jobs.map(job => buildRunWatchJobDetails(job, observedAtMs)),
244
+ };
245
+ }
246
+
247
+ export function buildFailedLogDetails(failedJobLogs: GhFailedJobLog[]): GhRunWatchFailedLogDetails[] {
248
+ return failedJobLogs.map(entry => ({
249
+ runId: entry.run.id,
250
+ workflowName: entry.run.workflowName,
251
+ jobName: entry.job.name,
252
+ conclusion: entry.job.conclusion,
253
+ tail: entry.tail,
254
+ available: entry.available,
255
+ }));
256
+ }
257
+
258
+ export function renderJobsSection(jobs: GhRunJobSnapshot[]): string[] {
259
+ if (jobs.length === 0) {
260
+ return ["## Jobs", "", "No jobs reported yet."];
261
+ }
262
+
263
+ const lines: string[] = [`## Jobs (${jobs.length})`, ""];
264
+ for (const job of jobs) {
265
+ lines.push(`- [${formatJobState(job)}] ${job.name}`);
266
+ if (job.startedAt) {
267
+ pushLine(lines, " Started", job.startedAt);
268
+ }
269
+ if (job.completedAt) {
270
+ pushLine(lines, " Completed", job.completedAt);
271
+ }
272
+ if (job.url) {
273
+ pushLine(lines, " URL", job.url);
274
+ }
275
+ }
276
+
277
+ return lines;
278
+ }
279
+
280
+ export function renderFailedJobLogs(
281
+ failedJobLogs: GhFailedJobLog[],
282
+ options: { mode: "tail"; tail: number } | { mode: "full" },
283
+ ): string[] {
284
+ if (failedJobLogs.length === 0) {
285
+ return [];
286
+ }
287
+
288
+ const lines: string[] = ["## Failed Jobs", ""];
289
+ for (const entry of failedJobLogs) {
290
+ lines.push(`### ${entry.job.name} [${entry.job.conclusion ?? "failed"}]`);
291
+ pushLine(lines, "Run", `#${entry.run.id}`);
292
+ pushLine(lines, "Workflow", entry.run.workflowName ?? undefined);
293
+ if (entry.job.startedAt) {
294
+ pushLine(lines, "Started", entry.job.startedAt);
295
+ }
296
+ if (entry.job.completedAt) {
297
+ pushLine(lines, "Completed", entry.job.completedAt);
298
+ }
299
+ if (entry.job.url) {
300
+ pushLine(lines, "URL", entry.job.url);
301
+ }
302
+ lines.push("");
303
+ const logText = options.mode === "full" ? entry.full : entry.tail;
304
+ if (entry.available && logText) {
305
+ lines.push(options.mode === "full" ? "Full log:" : `Last ${options.tail} log lines:`);
306
+ lines.push("```text");
307
+ lines.push(logText);
308
+ lines.push("```");
309
+ } else {
310
+ lines.push(options.mode === "full" ? "Full log unavailable." : "Log tail unavailable.");
311
+ }
312
+ lines.push("");
313
+ }
314
+
315
+ return lines;
316
+ }
317
+
318
+ export function renderRunSection(run: GhRunSnapshot): string[] {
319
+ const label = run.workflowName ? `### Run #${run.id} - ${run.workflowName}` : `### Run #${run.id}`;
320
+ const lines: string[] = [label, ""];
321
+ pushLine(lines, "Title", run.displayTitle ?? undefined);
322
+ pushLine(lines, "Branch", run.branch ?? undefined);
323
+ pushLine(lines, "Commit", formatShortSha(run.headSha));
324
+ pushLine(lines, "Status", run.status);
325
+ pushLine(lines, "Conclusion", run.conclusion ?? undefined);
326
+ pushLine(lines, "Created", run.createdAt);
327
+ pushLine(lines, "Updated", run.updatedAt);
328
+ pushLine(lines, "URL", run.url);
329
+ lines.push("");
330
+ lines.push(...renderJobsSection(run.jobs));
331
+ return lines;
332
+ }
333
+
334
+ export function formatRunWatchSnapshot(
335
+ repo: string,
336
+ run: GhRunSnapshot,
337
+ pollCount: number,
338
+ note?: string,
339
+ includeOutcome: boolean = false,
340
+ ): string {
341
+ const failedJobs = run.jobs.filter(isFailedJob);
342
+ const lines: string[] = [`# Watching GitHub Actions Run #${run.id}`, ""];
343
+ pushLine(lines, "Repository", repo);
344
+ pushLine(lines, "Workflow", run.workflowName ?? undefined);
345
+ pushLine(lines, "Title", run.displayTitle ?? undefined);
346
+ pushLine(lines, "Branch", run.branch ?? undefined);
347
+ pushLine(lines, "Status", run.status);
348
+ pushLine(lines, "Conclusion", run.conclusion ?? undefined);
349
+ pushLine(lines, "Created", run.createdAt);
350
+ pushLine(lines, "Updated", run.updatedAt);
351
+ pushLine(lines, "URL", run.url);
352
+ pushLine(lines, "Poll", pollCount);
353
+ pushLine(lines, "Failed jobs", failedJobs.length || undefined);
354
+
355
+ if (note) {
356
+ lines.push("");
357
+ lines.push(`Note: ${note}`);
358
+ }
359
+
360
+ lines.push("");
361
+ lines.push(...renderJobsSection(run.jobs));
362
+
363
+ if (includeOutcome) {
364
+ lines.push("");
365
+ lines.push(failedJobs.length > 0 ? "Failures detected." : "All jobs passed.");
366
+ }
367
+
368
+ return lines.join("\n").trim();
369
+ }
370
+
371
+ export function formatRunWatchResult(
372
+ repo: string,
373
+ run: GhRunSnapshot,
374
+ failedJobLogs: GhFailedJobLog[],
375
+ tail: number,
376
+ options?: { mode?: "tail" | "full" },
377
+ ): string {
378
+ const failedJobs = run.jobs.filter(isFailedJob);
379
+ const lines: string[] = [`# GitHub Actions Run #${run.id}`, ""];
380
+ pushLine(lines, "Repository", repo);
381
+ pushLine(lines, "Workflow", run.workflowName ?? undefined);
382
+ pushLine(lines, "Title", run.displayTitle ?? undefined);
383
+ pushLine(lines, "Branch", run.branch ?? undefined);
384
+ pushLine(lines, "Status", run.status);
385
+ pushLine(lines, "Conclusion", run.conclusion ?? undefined);
386
+ pushLine(lines, "Created", run.createdAt);
387
+ pushLine(lines, "Updated", run.updatedAt);
388
+ pushLine(lines, "URL", run.url);
389
+ lines.push("");
390
+ lines.push(...renderJobsSection(run.jobs));
391
+
392
+ if (failedJobs.length > 0) {
393
+ lines.push("");
394
+ lines.push(
395
+ ...renderFailedJobLogs(failedJobLogs, options?.mode === "full" ? { mode: "full" } : { mode: "tail", tail }),
396
+ );
397
+ lines.push("Run failed.");
398
+ } else if (getRunOutcome(run.conclusion) === "success") {
399
+ lines.push("");
400
+ lines.push("All jobs passed.");
401
+ } else {
402
+ lines.push("");
403
+ lines.push("Run completed without successful jobs, but no failed job logs were available.");
404
+ }
405
+
406
+ return lines.join("\n").trim();
407
+ }
408
+
409
+ export function formatCommitRunWatchSnapshot(
410
+ repo: string,
411
+ headSha: string,
412
+ branch: string | undefined,
413
+ runs: GhRunSnapshot[],
414
+ pollCount: number,
415
+ note?: string,
416
+ ): string {
417
+ const failedJobs = runs.flatMap(run => run.jobs.filter(isFailedJob));
418
+ const completedRuns = runs.filter(run => run.status === "completed").length;
419
+ const lines: string[] = [`# Watching GitHub Actions for ${formatShortSha(headSha) ?? headSha}`, ""];
420
+ pushLine(lines, "Repository", repo);
421
+ pushLine(lines, "Branch", branch);
422
+ pushLine(lines, "Commit", headSha);
423
+ pushLine(lines, "Poll", pollCount);
424
+ pushLine(lines, "Runs", runs.length);
425
+ pushLine(lines, "Completed runs", `${completedRuns}/${runs.length}`);
426
+ pushLine(lines, "Failed jobs", failedJobs.length || undefined);
427
+
428
+ if (note) {
429
+ lines.push("");
430
+ lines.push(`Note: ${note}`);
431
+ }
432
+
433
+ if (runs.length === 0) {
434
+ lines.push("");
435
+ lines.push("Waiting for workflow runs for this commit.");
436
+ return lines.join("\n").trim();
437
+ }
438
+
439
+ for (const run of runs) {
440
+ lines.push("");
441
+ lines.push(...renderRunSection(run));
442
+ }
443
+
444
+ return lines.join("\n").trim();
445
+ }
446
+
447
+ export function formatCommitRunWatchResult(
448
+ repo: string,
449
+ headSha: string,
450
+ branch: string | undefined,
451
+ runs: GhRunSnapshot[],
452
+ failedJobLogs: GhFailedJobLog[],
453
+ tail: number,
454
+ options?: { mode?: "tail" | "full" },
455
+ ): string {
456
+ const outcome = getRunCollectionOutcome(runs);
457
+ const lines: string[] = [`# GitHub Actions for ${formatShortSha(headSha) ?? headSha}`, ""];
458
+ pushLine(lines, "Repository", repo);
459
+ pushLine(lines, "Branch", branch);
460
+ pushLine(lines, "Commit", headSha);
461
+ pushLine(lines, "Runs", runs.length);
462
+
463
+ for (const run of runs) {
464
+ lines.push("");
465
+ lines.push(...renderRunSection(run));
466
+ }
467
+
468
+ if (failedJobLogs.length > 0) {
469
+ lines.push("");
470
+ lines.push(
471
+ ...renderFailedJobLogs(failedJobLogs, options?.mode === "full" ? { mode: "full" } : { mode: "tail", tail }),
472
+ );
473
+ lines.push("Workflow runs for this commit failed.");
474
+ } else if (outcome === "success") {
475
+ lines.push("");
476
+ lines.push("All workflow runs for this commit passed.");
477
+ } else {
478
+ lines.push("");
479
+ lines.push("Workflow runs for this commit did not complete successfully.");
480
+ }
481
+
482
+ return lines.join("\n").trim();
483
+ }
484
+
485
+ export function buildGhDetails(repo: string, run: GhRunSnapshot): GhToolDetails {
486
+ return {
487
+ repo,
488
+ branch: run.branch,
489
+ headSha: run.headSha,
490
+ runId: run.id,
491
+ runIds: [run.id],
492
+ status: run.status,
493
+ conclusion: run.conclusion,
494
+ failedJobs: run.jobs.filter(isFailedJob).map(job => job.name),
495
+ };
496
+ }
497
+
498
+ export function buildRunWatchDetails(
499
+ repo: string,
500
+ run: GhRunSnapshot,
501
+ options?: {
502
+ state?: GhRunWatchViewDetails["state"];
503
+ pollCount?: number;
504
+ note?: string;
505
+ failedJobLogs?: GhFailedJobLog[];
506
+ },
507
+ ): GhToolDetails {
508
+ const observedAtMs = Date.now();
509
+ return {
510
+ ...buildGhDetails(repo, run),
511
+ watch: {
512
+ mode: "run",
513
+ state: options?.state ?? "completed",
514
+ repo,
515
+ branch: run.branch,
516
+ headSha: run.headSha,
517
+ pollCount: options?.pollCount,
518
+ note: options?.note,
519
+ run: buildRunWatchRunDetails(run, observedAtMs),
520
+ failedLogs: buildFailedLogDetails(options?.failedJobLogs ?? []),
521
+ },
522
+ };
523
+ }
524
+
525
+ export function buildGhRunCollectionDetails(
526
+ repo: string,
527
+ headSha: string,
528
+ branch: string | undefined,
529
+ runs: GhRunSnapshot[],
530
+ ): GhToolDetails {
531
+ const outcome = getRunCollectionOutcome(runs);
532
+ return {
533
+ repo,
534
+ branch,
535
+ headSha,
536
+ runIds: runs.map(run => run.id),
537
+ status: runs.length > 0 && runs.every(run => run.status === "completed") ? "completed" : "in_progress",
538
+ conclusion: outcome,
539
+ failedJobs: runs.flatMap(run =>
540
+ run.jobs.filter(isFailedJob).map(job => `${run.workflowName ?? `run ${run.id}`}: ${job.name}`),
541
+ ),
542
+ };
543
+ }
544
+
545
+ export function buildCommitRunWatchDetails(
546
+ repo: string,
547
+ headSha: string,
548
+ branch: string | undefined,
549
+ runs: GhRunSnapshot[],
550
+ options?: {
551
+ state?: GhRunWatchViewDetails["state"];
552
+ pollCount?: number;
553
+ note?: string;
554
+ failedJobLogs?: GhFailedJobLog[];
555
+ },
556
+ ): GhToolDetails {
557
+ const observedAtMs = Date.now();
558
+ return {
559
+ ...buildGhRunCollectionDetails(repo, headSha, branch, runs),
560
+ watch: {
561
+ mode: "commit",
562
+ state: options?.state ?? "completed",
563
+ repo,
564
+ branch,
565
+ headSha,
566
+ pollCount: options?.pollCount,
567
+ note: options?.note,
568
+ runs: runs.map(run => buildRunWatchRunDetails(run, observedAtMs)),
569
+ failedLogs: buildFailedLogDetails(options?.failedJobLogs ?? []),
570
+ },
571
+ };
572
+ }
573
+
574
+ export async function resolveGitHubBranchHead(
575
+ cwd: string,
576
+ repo: string,
577
+ branch: string,
578
+ signal?: AbortSignal,
579
+ ): Promise<string> {
580
+ const response = await git.github.json<GhBranchApiResponse>(
581
+ cwd,
582
+ ["api", "--method", "GET", `/repos/${repo}/branches/${encodeURIComponent(branch)}`],
583
+ signal,
584
+ { repoProvided: true },
585
+ );
586
+ return requireNonEmpty(response.commit?.sha, `head SHA for branch ${branch}`);
587
+ }
588
+
589
+ export async function fetchRunsForCommit(
590
+ cwd: string,
591
+ repo: string,
592
+ headSha: string,
593
+ signal?: AbortSignal,
594
+ completedRunJobsCache?: Map<number, GhRunJobSnapshot[]>,
595
+ ): Promise<GhRunSnapshot[]> {
596
+ // Filter only by `head_sha`. The SHA uniquely identifies the commit, so
597
+ // adding the GitHub `branch=` filter would wrongly exclude workflow runs
598
+ // whose `head_branch` is not the local checkout — e.g. tag-push triggered
599
+ // release workflows (`head_branch=v1.2.3`) or PR-triggered runs
600
+ // (`head_branch=<pr head>`). See coding-agent issue tracker for details.
601
+ const response = await git.github.json<GhActionsRunListResponse>(
602
+ cwd,
603
+ [
604
+ "api",
605
+ "--method",
606
+ "GET",
607
+ `/repos/${repo}/actions/runs`,
608
+ "-F",
609
+ `head_sha=${headSha}`,
610
+ "-F",
611
+ `per_page=${RUN_JOBS_PAGE_SIZE}`,
612
+ ],
613
+ signal,
614
+ { repoProvided: true },
615
+ );
616
+
617
+ return Promise.all(
618
+ (response.workflow_runs ?? [])
619
+ .filter((run): run is GhActionsRunApi & { id: number } => typeof run.id === "number")
620
+ .map(async run => {
621
+ // Completed runs' job lists are stable until a re-run flips
622
+ // `status` off "completed"; reuse them across watch polls so a
623
+ // long watch does not refetch every finished run's jobs. A run
624
+ // observed non-completed evicts its entry — when the re-run
625
+ // completes, `status` flips back to "completed" and a stale
626
+ // entry would serve the FIRST attempt's jobs and logs forever.
627
+ const completed = run.status === "completed";
628
+ if (!completed) completedRunJobsCache?.delete(run.id);
629
+ let jobs = completed ? completedRunJobsCache?.get(run.id) : undefined;
630
+ if (!jobs) {
631
+ jobs = await fetchRunJobs(cwd, repo, run.id, signal);
632
+ if (completed) completedRunJobsCache?.set(run.id, jobs);
633
+ }
634
+ return normalizeRunSnapshot(run, jobs);
635
+ }),
636
+ );
637
+ }
638
+
639
+ export async function fetchRunJobs(
640
+ cwd: string,
641
+ repo: string,
642
+ runId: number,
643
+ signal?: AbortSignal,
644
+ ): Promise<GhRunJobSnapshot[]> {
645
+ const jobs: GhRunJobSnapshot[] = [];
646
+ let page = 1;
647
+
648
+ while (true) {
649
+ const response = await git.github.json<GhActionsJobsResponse>(
650
+ cwd,
651
+ [
652
+ "api",
653
+ "--method",
654
+ "GET",
655
+ `/repos/${repo}/actions/runs/${runId}/jobs`,
656
+ "-F",
657
+ `per_page=${RUN_JOBS_PAGE_SIZE}`,
658
+ "-F",
659
+ `page=${page}`,
660
+ ],
661
+ signal,
662
+ { repoProvided: true },
663
+ );
664
+ const rawPage = response.jobs ?? [];
665
+ const pageJobs = rawPage.map(job => normalizeRunJob(job)).filter((job): job is GhRunJobSnapshot => job !== null);
666
+ jobs.push(...pageJobs);
667
+
668
+ // Compare the raw page length: normalizeRunJob drops malformed items,
669
+ // and a post-filter short page must not end pagination early.
670
+ if (rawPage.length < RUN_JOBS_PAGE_SIZE) {
671
+ break;
672
+ }
673
+
674
+ if ((response.total_count ?? 0) <= jobs.length) {
675
+ break;
676
+ }
677
+
678
+ page += 1;
679
+ }
680
+
681
+ return jobs;
682
+ }
683
+
684
+ export async function fetchRunSnapshot(
685
+ cwd: string,
686
+ repo: string,
687
+ runId: number,
688
+ signal?: AbortSignal,
689
+ ): Promise<GhRunSnapshot> {
690
+ const [run, jobs] = await Promise.all([
691
+ git.github.json<GhActionsRunApi>(
692
+ cwd,
693
+ ["api", "--method", "GET", `/repos/${repo}/actions/runs/${runId}`],
694
+ signal,
695
+ {
696
+ repoProvided: true,
697
+ },
698
+ ),
699
+ fetchRunJobs(cwd, repo, runId, signal),
700
+ ]);
701
+
702
+ return normalizeRunSnapshot(run, jobs);
703
+ }
704
+
705
+ export function tailLogLines(log: string, tail: number): string | undefined {
706
+ const normalized = normalizeBlock(log);
707
+ if (!normalized) {
708
+ return undefined;
709
+ }
710
+
711
+ const lines = normalized.split("\n");
712
+ return lines.slice(-tail).join("\n").trimEnd();
713
+ }
714
+
715
+ export async function fetchFailedJobLogs(
716
+ cwd: string,
717
+ repo: string,
718
+ failedJobs: Array<{ run: GhRunSnapshot; job: GhRunJobSnapshot }>,
719
+ tail: number,
720
+ signal?: AbortSignal,
721
+ ): Promise<GhFailedJobLog[]> {
722
+ return Promise.all(
723
+ failedJobs.map(async entry => {
724
+ const result = await git.github.run(cwd, ["api", `/repos/${repo}/actions/jobs/${entry.job.id}/logs`], signal);
725
+ const fullLog = result.exitCode === 0 ? normalizeBlock(result.stdout) : undefined;
726
+ const logTail = fullLog ? tailLogLines(fullLog, tail) : undefined;
727
+ return {
728
+ run: entry.run,
729
+ job: entry.job,
730
+ full: fullLog,
731
+ tail: logTail,
732
+ available: Boolean(fullLog),
733
+ };
734
+ }),
735
+ );
736
+ }
737
+
738
+ export async function executeRunWatch(
739
+ session: ToolSession,
740
+ toolName: string,
741
+ params: GithubInput,
742
+ signal: AbortSignal | undefined,
743
+ onUpdate: AgentToolUpdateCallback<GhToolDetails> | undefined,
744
+ ): Promise<AgentToolResult<GhToolDetails>> {
745
+ const branchInput = normalizeOptionalString(params.branch);
746
+ const explicitRepo = normalizeOptionalString(params.repo);
747
+ const runReference = parseRunReference(params.run);
748
+ const repo = await resolveGitHubRepo(session.cwd, explicitRepo, runReference.repo, signal);
749
+ const graceSeconds = RUN_WATCH_GRACE_DEFAULT;
750
+ const tail = resolveTailLimit(params.tail);
751
+ const watchStartMs = Date.now();
752
+ // Fast polls for the first minute for snappy feedback, then back off:
753
+ // every commit-watch poll is one runs-list call plus one jobs call per
754
+ // non-completed run, and long builds must not burn the shared
755
+ // authenticated REST quota.
756
+ const currentIntervalSeconds = () =>
757
+ Date.now() - watchStartMs < RUN_WATCH_FAST_WINDOW_MS ? RUN_WATCH_INTERVAL_DEFAULT : RUN_WATCH_INTERVAL_SLOW;
758
+ let consecutivePollFailures = 0;
759
+ const handlePollError = async (err: unknown): Promise<void> => {
760
+ if (signal?.aborted) throw err;
761
+ consecutivePollFailures += 1;
762
+ if (!isRateLimitedGhError(err) || consecutivePollFailures > RUN_WATCH_MAX_POLL_FAILURES) throw err;
763
+ // Rate-limited: back off with the slow interval and retry instead of
764
+ // discarding the whole watch (and its accumulated context).
765
+ await scheduler.wait(RUN_WATCH_INTERVAL_SLOW * 1000, { signal });
766
+ };
767
+ if (runReference.runId !== undefined) {
768
+ const runId = runReference.runId;
769
+ let pollCount = 0;
770
+
771
+ while (true) {
772
+ throwIfAborted(signal);
773
+ pollCount += 1;
774
+
775
+ let run: GhRunSnapshot;
776
+ try {
777
+ run = await fetchRunSnapshot(session.cwd, repo, runId, signal);
778
+ } catch (err) {
779
+ await handlePollError(err);
780
+ continue;
781
+ }
782
+ consecutivePollFailures = 0;
783
+ const details = buildRunWatchDetails(repo, run, {
784
+ state: "watching",
785
+ pollCount,
786
+ });
787
+ onUpdate?.({
788
+ content: [{ type: "text", text: formatRunWatchSnapshot(repo, run, pollCount) }],
789
+ details,
790
+ });
791
+
792
+ let failedJobs = run.jobs.filter(isFailedJob);
793
+ const runCompleted = run.status === "completed";
794
+
795
+ if (failedJobs.length > 0) {
796
+ if (!runCompleted && graceSeconds > 0) {
797
+ const note = `Failure detected. Waiting ${graceSeconds}s to capture concurrent failures before fetching logs.`;
798
+ onUpdate?.({
799
+ content: [
800
+ {
801
+ type: "text",
802
+ text: formatRunWatchSnapshot(repo, run, pollCount, note),
803
+ },
804
+ ],
805
+ details: buildRunWatchDetails(repo, run, {
806
+ state: "watching",
807
+ pollCount,
808
+ note,
809
+ }),
810
+ });
811
+ await scheduler.wait(graceSeconds * 1000, { signal });
812
+ try {
813
+ const refetched = await fetchRunSnapshot(session.cwd, repo, runId, signal);
814
+ const refetchedFailed = refetched.jobs.filter(isFailedJob);
815
+ // An auto-retry can reset job conclusions between
816
+ // detection and refetch; keep the originally-detected
817
+ // failure list (and its snapshot) when the refetch no
818
+ // longer shows any failures so the watch never ends
819
+ // with a failure result and zero logs.
820
+ if (refetchedFailed.length > 0) {
821
+ run = refetched;
822
+ failedJobs = refetchedFailed;
823
+ }
824
+ } catch (err) {
825
+ if (signal?.aborted) throw err;
826
+ // Refetch failure: report from the original snapshot.
827
+ }
828
+ }
829
+
830
+ const failedJobLogs = await fetchFailedJobLogs(
831
+ session.cwd,
832
+ repo,
833
+ failedJobs.map(job => ({ run, job })),
834
+ tail,
835
+ signal,
836
+ );
837
+ const finalDetails = buildRunWatchDetails(repo, run, {
838
+ state: "completed",
839
+ failedJobLogs,
840
+ });
841
+ const artifactId = await saveArtifactText(
842
+ session,
843
+ toolName,
844
+ formatRunWatchResult(repo, run, failedJobLogs, tail, { mode: "full" }),
845
+ );
846
+ return buildTextResult(
847
+ formatRunWatchResult(repo, run, failedJobLogs, tail),
848
+ run.url,
849
+ { ...finalDetails, artifactId },
850
+ { artifactId, artifactLabel: "Full failed-job logs" },
851
+ );
852
+ }
853
+
854
+ if (runCompleted) {
855
+ const finalDetails = buildRunWatchDetails(repo, run, {
856
+ state: "completed",
857
+ });
858
+ return buildTextResult(formatRunWatchResult(repo, run, [], tail), run.url, finalDetails);
859
+ }
860
+
861
+ await scheduler.wait(currentIntervalSeconds() * 1000, { signal });
862
+ }
863
+ }
864
+
865
+ let branch: string;
866
+ let headSha: string;
867
+ if (branchInput) {
868
+ branch = branchInput;
869
+ headSha = await resolveGitHubBranchHead(session.cwd, repo, branch, signal);
870
+ } else {
871
+ // No branch/run selector — derive the commit from the current checkout,
872
+ // but only when cwd actually points at `repo`. Otherwise we'd watch an
873
+ // unrelated commit SHA against the explicit repo and silently stream a
874
+ // confident wrong-repo status (issue #1949). GitHub `owner/repo` slugs
875
+ // are case-insensitive — `gh repo view` returns the canonical casing
876
+ // while callers may pass any casing — so the equality check normalizes
877
+ // both sides before deciding the cwd is a different repo (PR #1951).
878
+ const cwdRepo = await tryResolveCurrentRepoFresh(session.cwd, signal);
879
+ if (!githubRepoSlugEquals(cwdRepo, repo)) {
880
+ throw new ToolError(
881
+ `Cannot infer the watched commit for ${repo}: current checkout is ${cwdRepo ?? "not a GitHub repository"}. Pass \`branch\` or \`run\` to scope the watch.`,
882
+ );
883
+ }
884
+ branch = await requireCurrentGitBranch(session.cwd, signal);
885
+ headSha = await requireCurrentGitHead(session.cwd, signal);
886
+ }
887
+ let pollCount = 0;
888
+ let settledSuccessSignature: string | undefined;
889
+ let everSawRuns = false;
890
+ const completedRunJobsCache = new Map<number, GhRunJobSnapshot[]>();
891
+
892
+ while (true) {
893
+ throwIfAborted(signal);
894
+ pollCount += 1;
895
+
896
+ let runs: GhRunSnapshot[];
897
+ try {
898
+ runs = await fetchRunsForCommit(session.cwd, repo, headSha, signal, completedRunJobsCache);
899
+ } catch (err) {
900
+ await handlePollError(err);
901
+ continue;
902
+ }
903
+ consecutivePollFailures = 0;
904
+ if (runs.length > 0) everSawRuns = true;
905
+ const details = buildCommitRunWatchDetails(repo, headSha, branch, runs, {
906
+ state: "watching",
907
+ pollCount,
908
+ });
909
+ onUpdate?.({
910
+ content: [{ type: "text", text: formatCommitRunWatchSnapshot(repo, headSha, branch, runs, pollCount) }],
911
+ details,
912
+ });
913
+
914
+ const outcome = getRunCollectionOutcome(runs);
915
+ if (outcome === "failure") {
916
+ let failedPairs = runs.flatMap(run => run.jobs.filter(isFailedJob).map(job => ({ run, job })));
917
+ if (graceSeconds > 0) {
918
+ const note = `Failure detected. Waiting ${graceSeconds}s to capture concurrent failures before fetching logs.`;
919
+ onUpdate?.({
920
+ content: [
921
+ {
922
+ type: "text",
923
+ text: formatCommitRunWatchSnapshot(repo, headSha, branch, runs, pollCount, note),
924
+ },
925
+ ],
926
+ details: buildCommitRunWatchDetails(repo, headSha, branch, runs, {
927
+ state: "watching",
928
+ pollCount,
929
+ note,
930
+ }),
931
+ });
932
+ await scheduler.wait(graceSeconds * 1000, { signal });
933
+ try {
934
+ const refetched = await fetchRunsForCommit(session.cwd, repo, headSha, signal, completedRunJobsCache);
935
+ const refetchedPairs = refetched.flatMap(run => run.jobs.filter(isFailedJob).map(job => ({ run, job })));
936
+ // Keep the originally-detected failure list when an
937
+ // auto-retry reset the conclusions during the grace window
938
+ // (see the run-id branch above).
939
+ if (refetchedPairs.length > 0) {
940
+ runs = refetched;
941
+ failedPairs = refetchedPairs;
942
+ }
943
+ } catch (err) {
944
+ if (signal?.aborted) throw err;
945
+ // Refetch failure: report from the original snapshots.
946
+ }
947
+ }
948
+
949
+ const failedJobLogs = await fetchFailedJobLogs(session.cwd, repo, failedPairs, tail, signal);
950
+ const finalDetails = buildCommitRunWatchDetails(repo, headSha, branch, runs, {
951
+ state: "completed",
952
+ failedJobLogs,
953
+ });
954
+ const artifactId = await saveArtifactText(
955
+ session,
956
+ toolName,
957
+ formatCommitRunWatchResult(repo, headSha, branch, runs, failedJobLogs, tail, { mode: "full" }),
958
+ );
959
+ return buildTextResult(
960
+ formatCommitRunWatchResult(repo, headSha, branch, runs, failedJobLogs, tail),
961
+ undefined,
962
+ { ...finalDetails, artifactId },
963
+ { artifactId, artifactLabel: "Full failed-job logs" },
964
+ );
965
+ }
966
+
967
+ if (outcome === "success") {
968
+ const signature = getRunCollectionSignature(runs);
969
+ if (signature === settledSuccessSignature) {
970
+ const finalDetails = buildCommitRunWatchDetails(repo, headSha, branch, runs, {
971
+ state: "completed",
972
+ });
973
+ return buildTextResult(
974
+ formatCommitRunWatchResult(repo, headSha, branch, runs, [], tail),
975
+ undefined,
976
+ finalDetails,
977
+ );
978
+ }
979
+
980
+ settledSuccessSignature = signature;
981
+ const confirmWaitSeconds = currentIntervalSeconds();
982
+ const note = `All known workflow runs completed successfully. Waiting ${confirmWaitSeconds}s to ensure no additional runs appear for this commit.`;
983
+ onUpdate?.({
984
+ content: [
985
+ {
986
+ type: "text",
987
+ text: formatCommitRunWatchSnapshot(repo, headSha, branch, runs, pollCount, note),
988
+ },
989
+ ],
990
+ details: buildCommitRunWatchDetails(repo, headSha, branch, runs, {
991
+ state: "watching",
992
+ pollCount,
993
+ note,
994
+ }),
995
+ });
996
+ await scheduler.wait(confirmWaitSeconds * 1000, { signal });
997
+ continue;
998
+ }
999
+
1000
+ settledSuccessSignature = undefined;
1001
+ if (!everSawRuns && Date.now() - watchStartMs >= RUN_WATCH_NO_RUNS_GIVE_UP_MS) {
1002
+ // A repo with no Actions configured (or Actions disabled) never
1003
+ // produces a run for this commit; give up with a clear message
1004
+ // instead of polling forever.
1005
+ const elapsedSec = Math.round((Date.now() - watchStartMs) / 1000);
1006
+ return buildTextResult(
1007
+ `No workflow runs found for ${repo}@${formatShortSha(headSha) ?? headSha} after ${elapsedSec}s (${pollCount} polls). The commit may not trigger any GitHub Actions workflows, or Actions may be disabled for this repository. Pass \`run\` to watch a specific run.`,
1008
+ undefined,
1009
+ buildCommitRunWatchDetails(repo, headSha, branch, runs, { state: "completed", pollCount }),
1010
+ { useless: true },
1011
+ );
1012
+ }
1013
+ await scheduler.wait(currentIntervalSeconds() * 1000, { signal });
1014
+ }
1015
+ }