@agent-native/core 0.114.9 → 0.114.11
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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +19 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/engine/ai-sdk-engine.ts +31 -3
- package/corpus/core/src/agent/engine/anthropic-engine.ts +20 -3
- package/corpus/core/src/agent/engine/builder-engine.ts +56 -9
- package/corpus/core/src/agent/engine/first-event-timeout.ts +64 -0
- package/corpus/core/src/cli/pr-visual-recap-workflow.ts +2 -3
- package/corpus/core/src/client/AssistantChat.tsx +7 -3
- package/corpus/core/src/client/resources/ResourcesPanel.tsx +1 -1
- package/corpus/core/src/extensions/actions.ts +219 -0
- package/corpus/core/src/integrations/computer-supervision-store.ts +4 -4
- package/corpus/core/src/integrations/pending-tasks-store.ts +1 -1
- package/corpus/core/src/integrations/remote-commands-store.ts +5 -5
- package/corpus/core/src/integrations/remote-devices-store.ts +3 -3
- package/corpus/core/src/integrations/remote-push-store.ts +3 -3
- package/corpus/core/src/observability/traces.ts +38 -1
- package/corpus/core/src/server/action-routes.ts +23 -1
- package/corpus/core/src/server/http-response-telemetry.ts +6 -1
- package/corpus/core/src/templates/workspace-core/.agents/skills/extensions/SKILL.md +19 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +1 -1
- package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
- package/corpus/templates/analytics/actions/account-deep-dive.ts +41 -6
- package/corpus/templates/analytics/changelog/2026-07-20-account-deep-dives-finish-with-partial-data-when-hubspot-lo.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-20-dashboard-email-reports-now-include-an-image-when-a-single-p.md +6 -0
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +11 -3
- package/corpus/templates/calendar/actions/create-event.ts +5 -0
- package/corpus/templates/calendar/app/components/ThemeToggle.tsx +4 -1
- package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +0 -1
- package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +309 -421
- package/corpus/templates/calendar/app/components/calendar/InlineEventPickers.tsx +349 -0
- package/corpus/templates/calendar/app/i18n-data.ts +110 -0
- package/corpus/templates/calendar/app/lib/event-form-utils.ts +6 -0
- package/corpus/templates/calendar/app/pages/CalendarView.tsx +3 -0
- package/corpus/templates/calendar/app/root.tsx +4 -2
- package/corpus/templates/calendar/changelog/2026-07-20-events-can-be-updated-inline-with-date-time-timezone-and-rep.md +6 -0
- package/corpus/templates/calendar/changelog/2026-07-20-public-booking-pages-no-longer-hydrate-with-a-theme-mismatch.md +6 -0
- package/corpus/templates/calendar/shared/api.ts +1 -0
- package/corpus/templates/clips/AGENTS.md +7 -0
- package/corpus/templates/clips/actions/create-recording.ts +51 -10
- package/corpus/templates/clips/actions/get-recording-player-data.ts +34 -5
- package/corpus/templates/clips/actions/lib/transcript-preview.ts +48 -0
- package/corpus/templates/clips/actions/list-recordings.ts +15 -4
- package/corpus/templates/clips/actions/regenerate-summary.ts +32 -0
- package/corpus/templates/clips/actions/search-recordings.ts +28 -6
- package/corpus/templates/clips/actions/view-screen.ts +54 -47
- package/corpus/templates/clips/app/components/library/library-grid.tsx +1 -5
- package/corpus/templates/clips/app/components/library/recording-card.tsx +29 -32
- package/corpus/templates/clips/app/components/meetings/quick-ask-sidebar.tsx +13 -0
- package/corpus/templates/clips/app/components/player/video-player.tsx +48 -0
- package/corpus/templates/clips/app/lib/fmp4.ts +26 -4
- package/corpus/templates/clips/app/lib/mse-video-loader.ts +13 -2
- package/corpus/templates/clips/changelog/2026-07-20-agent-summaries-use-bounded-transcript-context.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-clip-summaries-now-finish-without-sending-a-long-transcript-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-clips-agents-only-discover-recordings-you-own-or-have-alread.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-hosted-recordings-no-longer-start-with-an-impossible-buffered-upload.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-meeting-recordings-now-offer-a-cleaner-transcript-view-with-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-rewind-capture-status.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-shared-clips-can-be-shared-with-limited-access-and-cards-sho.md +6 -0
- package/corpus/templates/clips/desktop/src/app.tsx +81 -71
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +12 -8
- package/corpus/templates/clips/desktop/src/lib/rewind-status.ts +103 -0
- package/corpus/templates/clips/desktop/src/overlays/pill-logo.tsx +2 -15
- package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +107 -340
- package/corpus/templates/clips/desktop/src/styles.css +37 -145
- package/corpus/templates/clips/desktop/src-tauri/src/recording_indicator.rs +6 -7
- package/corpus/templates/clips/server/lib/agent-recording-access.ts +73 -0
- package/corpus/templates/clips/server/plugins/agent-chat.ts +5 -2
- package/corpus/templates/plan/app/lib/plan-local-bridge.ts +18 -0
- package/corpus/templates/plan/app/pages/PlansPage.tsx +10 -11
- package/corpus/templates/plan/changelog/2026-07-20-bridged-local-plans-now-load-comments-from-the-local-workspace.md +6 -0
- package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
- package/dist/agent/engine/ai-sdk-engine.js +27 -4
- package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
- package/dist/agent/engine/anthropic-engine.d.ts.map +1 -1
- package/dist/agent/engine/anthropic-engine.js +19 -4
- package/dist/agent/engine/anthropic-engine.js.map +1 -1
- package/dist/agent/engine/builder-engine.d.ts.map +1 -1
- package/dist/agent/engine/builder-engine.js +40 -7
- package/dist/agent/engine/builder-engine.js.map +1 -1
- package/dist/agent/engine/first-event-timeout.d.ts +25 -0
- package/dist/agent/engine/first-event-timeout.d.ts.map +1 -0
- package/dist/agent/engine/first-event-timeout.js +49 -0
- package/dist/agent/engine/first-event-timeout.js.map +1 -0
- package/dist/cli/pr-visual-recap-workflow.d.ts +2 -2
- package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.js +2 -2
- package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +7 -3
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/resources/ResourcesPanel.js +1 -1
- package/dist/client/resources/ResourcesPanel.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/extensions/actions.d.ts.map +1 -1
- package/dist/extensions/actions.js +193 -1
- package/dist/extensions/actions.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/integrations/computer-supervision-store.js +4 -4
- package/dist/integrations/computer-supervision-store.js.map +1 -1
- package/dist/integrations/pending-tasks-store.js +1 -1
- package/dist/integrations/pending-tasks-store.js.map +1 -1
- package/dist/integrations/remote-commands-store.js +5 -5
- package/dist/integrations/remote-commands-store.js.map +1 -1
- package/dist/integrations/remote-devices-store.js +3 -3
- package/dist/integrations/remote-devices-store.js.map +1 -1
- package/dist/integrations/remote-push-store.js +3 -3
- package/dist/integrations/remote-push-store.js.map +1 -1
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +3 -3
- package/dist/observability/traces.d.ts.map +1 -1
- package/dist/observability/traces.js +23 -1
- package/dist/observability/traces.js.map +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/action-routes.d.ts.map +1 -1
- package/dist/server/action-routes.js +23 -1
- package/dist/server/action-routes.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/http-response-telemetry.d.ts +2 -0
- package/dist/server/http-response-telemetry.d.ts.map +1 -1
- package/dist/server/http-response-telemetry.js +5 -1
- package/dist/server/http-response-telemetry.js.map +1 -1
- package/dist/templates/workspace-core/.agents/skills/extensions/SKILL.md +19 -0
- package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +1 -1
- package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
- package/package.json +2 -2
- package/src/agent/engine/ai-sdk-engine.ts +31 -3
- package/src/agent/engine/anthropic-engine.ts +20 -3
- package/src/agent/engine/builder-engine.ts +56 -9
- package/src/agent/engine/first-event-timeout.ts +64 -0
- package/src/cli/pr-visual-recap-workflow.ts +2 -3
- package/src/client/AssistantChat.tsx +7 -3
- package/src/client/resources/ResourcesPanel.tsx +1 -1
- package/src/extensions/actions.ts +219 -0
- package/src/integrations/computer-supervision-store.ts +4 -4
- package/src/integrations/pending-tasks-store.ts +1 -1
- package/src/integrations/remote-commands-store.ts +5 -5
- package/src/integrations/remote-devices-store.ts +3 -3
- package/src/integrations/remote-push-store.ts +3 -3
- package/src/observability/traces.ts +38 -1
- package/src/server/action-routes.ts +23 -1
- package/src/server/http-response-telemetry.ts +6 -1
- package/src/templates/workspace-core/.agents/skills/extensions/SKILL.md +19 -0
- package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +1 -1
- package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.114.11
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b47a1b3: Add extension-data-set and extension-data-get agent actions for reading and writing extensionData from the agent side, with auto-refresh of mounted extension iframes after writes.
|
|
8
|
+
|
|
9
|
+
## 0.114.10
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 687fefc: Capture unexpected HTTP action failures with request-correlated server diagnostics.
|
|
14
|
+
- 687fefc: Capture uncategorized HTTP action failures through the configured server error monitor and correlate them with request telemetry.
|
|
15
|
+
- 687fefc: Abort and retry model requests that produce no stream events within two minutes instead of hanging until run watchdogs fire.
|
|
16
|
+
- 687fefc: Fix nullable organization scope parameters for remote integration stores.
|
|
17
|
+
- 687fefc: Capture truncated, secret-scrubbed tool error text on generation telemetry when `captureToolResults` is enabled.
|
|
18
|
+
- 687fefc: Distinguish ended agent-chat streams from true reconnect stalls in error monitoring and recovery messaging.
|
|
19
|
+
- Updated dependencies [687fefc]
|
|
20
|
+
- @agent-native/recap-cli@0.4.7
|
|
21
|
+
|
|
3
22
|
## 0.114.9
|
|
4
23
|
|
|
5
24
|
### Patch Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.114.
|
|
3
|
+
"version": "0.114.11",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
supportsClaudeAdaptiveThinking,
|
|
24
24
|
} from "../../shared/reasoning-effort.js";
|
|
25
25
|
import { AI_SDK_MODEL_CONFIG, type AISDKProvider } from "../model-config.js";
|
|
26
|
+
import {
|
|
27
|
+
createFirstEventAbortController,
|
|
28
|
+
FIRST_STREAM_EVENT_TIMEOUT_MS,
|
|
29
|
+
} from "./first-event-timeout.js";
|
|
26
30
|
import {
|
|
27
31
|
clampThinkingBudgetTokens,
|
|
28
32
|
resolveMaxOutputTokensForEngine,
|
|
@@ -379,6 +383,7 @@ class AISDKEngine implements AgentEngine {
|
|
|
379
383
|
}
|
|
380
384
|
|
|
381
385
|
let assistantContent: EngineContentPart[] = [];
|
|
386
|
+
const firstEventAbort = createFirstEventAbortController(opts.abortSignal);
|
|
382
387
|
|
|
383
388
|
try {
|
|
384
389
|
const result = streamText({
|
|
@@ -390,7 +395,7 @@ class AISDKEngine implements AgentEngine {
|
|
|
390
395
|
...(opts.temperature !== undefined
|
|
391
396
|
? { temperature: opts.temperature }
|
|
392
397
|
: {}),
|
|
393
|
-
abortSignal:
|
|
398
|
+
abortSignal: firstEventAbort.signal,
|
|
394
399
|
onStepFinish: (step: any) => {
|
|
395
400
|
assistantContent = aiSdkStepToAssistantContent(step);
|
|
396
401
|
},
|
|
@@ -402,8 +407,18 @@ class AISDKEngine implements AgentEngine {
|
|
|
402
407
|
// Buffer the terminal stop so assistant-content can be emitted just
|
|
403
408
|
// before it, regardless of where `finish` arrives in the stream.
|
|
404
409
|
let bufferedStop: EngineEvent | undefined;
|
|
410
|
+
let sawFirstEvent = false;
|
|
405
411
|
|
|
406
412
|
for await (const part of result.fullStream) {
|
|
413
|
+
// "start" is a synthetic lifecycle marker the AI SDK enqueues
|
|
414
|
+
// synchronously when the stream begins — before any provider bytes
|
|
415
|
+
// arrive — so it does not count as real progress. Every other part
|
|
416
|
+
// (including "start-step", only enqueued on the step's first real
|
|
417
|
+
// chunk) proves the provider is actually responding.
|
|
418
|
+
if (!sawFirstEvent && part?.type !== "start") {
|
|
419
|
+
sawFirstEvent = true;
|
|
420
|
+
firstEventAbort.markFirstEvent();
|
|
421
|
+
}
|
|
407
422
|
for (const event of aiSdkPartToEngineEvents(part)) {
|
|
408
423
|
if (event.type === "stop") {
|
|
409
424
|
bufferedStop = event;
|
|
@@ -413,6 +428,15 @@ class AISDKEngine implements AgentEngine {
|
|
|
413
428
|
}
|
|
414
429
|
}
|
|
415
430
|
|
|
431
|
+
// AI SDK surfaces an aborted stream as a graceful `{type: "abort"}`
|
|
432
|
+
// part rather than a thrown error, so a first-event timeout would
|
|
433
|
+
// otherwise fall through to the normal end_turn completion below.
|
|
434
|
+
if (!sawFirstEvent && firstEventAbort.didTimeout()) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`Model request produced no stream events within ${FIRST_STREAM_EVENT_TIMEOUT_MS / 1000}s; the connection appears wedged.`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
416
440
|
yield { type: "assistant-content", parts: assistantContent };
|
|
417
441
|
await clearProviderCredentialAuthFailure({
|
|
418
442
|
key: PROVIDER_ENV_VARS[this.provider][0],
|
|
@@ -420,6 +444,7 @@ class AISDKEngine implements AgentEngine {
|
|
|
420
444
|
});
|
|
421
445
|
yield bufferedStop ?? { type: "stop", reason: "end_turn" };
|
|
422
446
|
} catch (err: any) {
|
|
447
|
+
const timedOut = firstEventAbort.didTimeout();
|
|
423
448
|
// Surface structured fields from AI SDK's APICallError so
|
|
424
449
|
// isRetryableError can check statusCode/providerRetryable directly
|
|
425
450
|
// rather than keyword-matching the message string.
|
|
@@ -427,12 +452,13 @@ class AISDKEngine implements AgentEngine {
|
|
|
427
452
|
typeof err?.statusCode === "number" ? err.statusCode : undefined;
|
|
428
453
|
const errorMessage = err?.message ?? String(err);
|
|
429
454
|
const isConnectionError =
|
|
455
|
+
!timedOut &&
|
|
430
456
|
statusCode === undefined &&
|
|
431
457
|
String(errorMessage).trim().toLowerCase() === "connection error.";
|
|
432
458
|
const providerRetryable: boolean | undefined =
|
|
433
459
|
typeof err?.isRetryable === "boolean"
|
|
434
460
|
? err.isRetryable
|
|
435
|
-
: isConnectionError
|
|
461
|
+
: isConnectionError || timedOut
|
|
436
462
|
? true
|
|
437
463
|
: undefined;
|
|
438
464
|
if (statusCode === 401) {
|
|
@@ -455,12 +481,14 @@ class AISDKEngine implements AgentEngine {
|
|
|
455
481
|
// auto-resume too — matching the Builder gateway path.
|
|
456
482
|
...(statusCode !== undefined
|
|
457
483
|
? { errorCode: `http_${statusCode}`, statusCode }
|
|
458
|
-
: isConnectionError
|
|
484
|
+
: isConnectionError || timedOut
|
|
459
485
|
? { errorCode: "provider_network_error" }
|
|
460
486
|
: {}),
|
|
461
487
|
...(providerRetryable !== undefined ? { providerRetryable } : {}),
|
|
462
488
|
};
|
|
463
489
|
throw err;
|
|
490
|
+
} finally {
|
|
491
|
+
firstEventAbort.cleanup();
|
|
464
492
|
}
|
|
465
493
|
}
|
|
466
494
|
|
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
LLM_MISSING_CREDENTIALS_ERROR_CODE,
|
|
24
24
|
LLM_MISSING_CREDENTIALS_MESSAGE,
|
|
25
25
|
} from "./credential-errors.js";
|
|
26
|
+
import {
|
|
27
|
+
createFirstEventAbortController,
|
|
28
|
+
FIRST_STREAM_EVENT_TIMEOUT_MS,
|
|
29
|
+
} from "./first-event-timeout.js";
|
|
26
30
|
import {
|
|
27
31
|
clampThinkingBudgetTokens,
|
|
28
32
|
resolveMaxOutputTokensForEngine,
|
|
@@ -186,8 +190,9 @@ class AnthropicEngine implements AgentEngine {
|
|
|
186
190
|
// Remove undefined tools to avoid Anthropic API validation errors
|
|
187
191
|
if (!requestParams.tools) delete requestParams.tools;
|
|
188
192
|
|
|
193
|
+
const firstEventAbort = createFirstEventAbortController(opts.abortSignal);
|
|
189
194
|
const apiStream = client.messages.stream(requestParams, {
|
|
190
|
-
signal:
|
|
195
|
+
signal: firstEventAbort.signal,
|
|
191
196
|
});
|
|
192
197
|
|
|
193
198
|
// Per-stream state lets the translator carry each tool-call's id/name from
|
|
@@ -198,6 +203,9 @@ class AnthropicEngine implements AgentEngine {
|
|
|
198
203
|
|
|
199
204
|
try {
|
|
200
205
|
for await (const chunk of apiStream) {
|
|
206
|
+
// The SDK's SSE parsing already drops `ping` keepalives before they
|
|
207
|
+
// reach this loop, so any chunk here is real provider progress.
|
|
208
|
+
firstEventAbort.markFirstEvent();
|
|
201
209
|
const events = anthropicChunkToEngineEvents(chunk, chunkState);
|
|
202
210
|
for (const event of events) {
|
|
203
211
|
yield event;
|
|
@@ -238,17 +246,24 @@ class AnthropicEngine implements AgentEngine {
|
|
|
238
246
|
: "end_turn",
|
|
239
247
|
};
|
|
240
248
|
} catch (err: any) {
|
|
249
|
+
const timedOut = firstEventAbort.didTimeout();
|
|
241
250
|
const statusCode: number | undefined =
|
|
242
251
|
typeof err?.status === "number"
|
|
243
252
|
? err.status
|
|
244
253
|
: typeof err?.statusCode === "number"
|
|
245
254
|
? err.statusCode
|
|
246
255
|
: undefined;
|
|
247
|
-
|
|
256
|
+
// A first-event abort surfaces from the SDK as a generic
|
|
257
|
+
// APIUserAbortError ("Request was aborted.") — replace it with a
|
|
258
|
+
// message that actually explains what happened.
|
|
259
|
+
const errorMessage = timedOut
|
|
260
|
+
? `Model request produced no stream events within ${FIRST_STREAM_EVENT_TIMEOUT_MS / 1000}s; the connection appears wedged.`
|
|
261
|
+
: (err?.message ?? String(err));
|
|
248
262
|
// Anthropic SDK APIConnectionError defaults to "Connection error." with
|
|
249
263
|
// no HTTP status. Tag it so in-run retries and run-level resume treat
|
|
250
264
|
// the failure as a transient network interruption.
|
|
251
265
|
const isConnectionError =
|
|
266
|
+
!timedOut &&
|
|
252
267
|
statusCode === undefined &&
|
|
253
268
|
String(errorMessage).trim().toLowerCase() === "connection error.";
|
|
254
269
|
if (statusCode === 401) {
|
|
@@ -274,7 +289,7 @@ class AnthropicEngine implements AgentEngine {
|
|
|
274
289
|
// continuation logic auto-resume a rate-limited turn.
|
|
275
290
|
...(statusCode !== undefined
|
|
276
291
|
? { errorCode: `http_${statusCode}`, statusCode }
|
|
277
|
-
: isConnectionError
|
|
292
|
+
: isConnectionError || timedOut
|
|
278
293
|
? {
|
|
279
294
|
errorCode: "provider_network_error",
|
|
280
295
|
providerRetryable: true,
|
|
@@ -282,6 +297,8 @@ class AnthropicEngine implements AgentEngine {
|
|
|
282
297
|
: {}),
|
|
283
298
|
};
|
|
284
299
|
throw err;
|
|
300
|
+
} finally {
|
|
301
|
+
firstEventAbort.cleanup();
|
|
285
302
|
}
|
|
286
303
|
}
|
|
287
304
|
}
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
LLM_MISSING_CREDENTIALS_ERROR_CODE,
|
|
32
32
|
LLM_MISSING_CREDENTIALS_MESSAGE,
|
|
33
33
|
} from "./credential-errors.js";
|
|
34
|
+
import { FIRST_STREAM_EVENT_TIMEOUT_MS } from "./first-event-timeout.js";
|
|
34
35
|
import { resolveMaxOutputTokensForEngine } from "./output-tokens.js";
|
|
35
36
|
import {
|
|
36
37
|
engineMessagesToBuilderGatewayAnthropic,
|
|
@@ -272,12 +273,16 @@ class BuilderEngine implements AgentEngine {
|
|
|
272
273
|
phase: "request",
|
|
273
274
|
model: opts.model,
|
|
274
275
|
gatewayUrl,
|
|
275
|
-
timeoutMs:
|
|
276
|
+
timeoutMs: gatewayAbort.effectiveTimeoutMs(),
|
|
276
277
|
timedOut,
|
|
277
278
|
elapsedMs: Date.now() - tStart,
|
|
278
279
|
});
|
|
279
280
|
}
|
|
280
|
-
yield createBuilderGatewayTimeoutStop(
|
|
281
|
+
yield createBuilderGatewayTimeoutStop(
|
|
282
|
+
err,
|
|
283
|
+
timedOut,
|
|
284
|
+
gatewayAbort.effectiveTimeoutMs(),
|
|
285
|
+
);
|
|
281
286
|
return;
|
|
282
287
|
}
|
|
283
288
|
|
|
@@ -331,7 +336,8 @@ class BuilderEngine implements AgentEngine {
|
|
|
331
336
|
yield* parseJsonlStream(reader, opts.model, {
|
|
332
337
|
abortSignal: gatewayAbort.signal,
|
|
333
338
|
didGatewayTimeout: gatewayAbort.didTimeout,
|
|
334
|
-
|
|
339
|
+
getGatewayTimeoutMs: gatewayAbort.effectiveTimeoutMs,
|
|
340
|
+
onFirstEvent: gatewayAbort.markFirstEvent,
|
|
335
341
|
gatewayUrl,
|
|
336
342
|
requestStartedAt: tStart,
|
|
337
343
|
});
|
|
@@ -475,13 +481,12 @@ async function* parseJsonlStream(
|
|
|
475
481
|
captureContext: {
|
|
476
482
|
abortSignal?: AbortSignal;
|
|
477
483
|
didGatewayTimeout?: () => boolean;
|
|
478
|
-
|
|
484
|
+
getGatewayTimeoutMs?: () => number;
|
|
485
|
+
onFirstEvent?: () => void;
|
|
479
486
|
gatewayUrl?: URL;
|
|
480
487
|
requestStartedAt?: number;
|
|
481
488
|
} = {},
|
|
482
489
|
): AsyncIterable<EngineEvent> {
|
|
483
|
-
const gatewayTimeoutMs =
|
|
484
|
-
captureContext.gatewayTimeoutMs ?? DEFAULT_BUILDER_GATEWAY_TIMEOUT_MS;
|
|
485
490
|
const parts: EngineContentPart[] = [];
|
|
486
491
|
let pendingText = "";
|
|
487
492
|
let pendingThinking: { text: string; signature?: string } | null = null;
|
|
@@ -533,6 +538,12 @@ async function* parseJsonlStream(
|
|
|
533
538
|
return;
|
|
534
539
|
}
|
|
535
540
|
|
|
541
|
+
// Heartbeats are transport-level keepalives, not proof the model is
|
|
542
|
+
// producing output — every other parsed event counts as first progress.
|
|
543
|
+
if (event?.type !== "heartbeat") {
|
|
544
|
+
captureContext.onFirstEvent?.();
|
|
545
|
+
}
|
|
546
|
+
|
|
536
547
|
switch (event.type) {
|
|
537
548
|
case "text-delta": {
|
|
538
549
|
const text = event.text ?? "";
|
|
@@ -735,6 +746,9 @@ async function* parseJsonlStream(
|
|
|
735
746
|
};
|
|
736
747
|
} catch (err) {
|
|
737
748
|
const timedOut = captureContext.didGatewayTimeout?.() ?? false;
|
|
749
|
+
const gatewayTimeoutMs =
|
|
750
|
+
captureContext.getGatewayTimeoutMs?.() ??
|
|
751
|
+
DEFAULT_BUILDER_GATEWAY_TIMEOUT_MS;
|
|
738
752
|
if (timedOut || isBuilderGatewayNetworkError(err)) {
|
|
739
753
|
captureBuilderGatewayTransportError(err, {
|
|
740
754
|
phase: "stream",
|
|
@@ -880,16 +894,28 @@ function getBuilderGatewayTimeoutMs(): number {
|
|
|
880
894
|
return Math.min(parsed, maxMs);
|
|
881
895
|
}
|
|
882
896
|
|
|
897
|
+
/**
|
|
898
|
+
* Two-stage abort deadline: until the first real stream event arrives, the
|
|
899
|
+
* effective deadline is min(totalTimeoutMs, FIRST_STREAM_EVENT_TIMEOUT_MS) —
|
|
900
|
+
* a wedged gateway that never streams anything gets cut off in ~2 minutes
|
|
901
|
+
* instead of riding the full flat timeout. Once `markFirstEvent()` fires, the
|
|
902
|
+
* timer reschedules for whatever remains of the original total deadline, so
|
|
903
|
+
* a request that starts streaming still gets the full budget it always did.
|
|
904
|
+
*/
|
|
883
905
|
function createGatewayAbortSignal(
|
|
884
906
|
parentSignal: AbortSignal,
|
|
885
|
-
|
|
907
|
+
totalTimeoutMs: number,
|
|
886
908
|
): {
|
|
887
909
|
signal: AbortSignal;
|
|
888
910
|
didTimeout: () => boolean;
|
|
911
|
+
effectiveTimeoutMs: () => number;
|
|
912
|
+
markFirstEvent: () => void;
|
|
889
913
|
cleanup: () => void;
|
|
890
914
|
} {
|
|
891
915
|
const controller = new AbortController();
|
|
892
916
|
let timedOut = false;
|
|
917
|
+
let firstEventSeen = false;
|
|
918
|
+
const startedAt = Date.now();
|
|
893
919
|
|
|
894
920
|
const abortFromParent = () => {
|
|
895
921
|
if (!controller.signal.aborted) {
|
|
@@ -897,12 +923,18 @@ function createGatewayAbortSignal(
|
|
|
897
923
|
}
|
|
898
924
|
};
|
|
899
925
|
|
|
900
|
-
const
|
|
926
|
+
const fireTimeout = () => {
|
|
901
927
|
timedOut = true;
|
|
902
928
|
if (!controller.signal.aborted) {
|
|
903
929
|
controller.abort(new Error("Builder gateway request timed out"));
|
|
904
930
|
}
|
|
905
|
-
}
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
const firstEventDeadlineMs = Math.min(
|
|
934
|
+
totalTimeoutMs,
|
|
935
|
+
FIRST_STREAM_EVENT_TIMEOUT_MS,
|
|
936
|
+
);
|
|
937
|
+
let timeout = setTimeout(fireTimeout, firstEventDeadlineMs);
|
|
906
938
|
|
|
907
939
|
if (parentSignal.aborted) abortFromParent();
|
|
908
940
|
parentSignal.addEventListener("abort", abortFromParent, { once: true });
|
|
@@ -910,6 +942,21 @@ function createGatewayAbortSignal(
|
|
|
910
942
|
return {
|
|
911
943
|
signal: controller.signal,
|
|
912
944
|
didTimeout: () => timedOut,
|
|
945
|
+
effectiveTimeoutMs: () =>
|
|
946
|
+
firstEventSeen ? totalTimeoutMs : firstEventDeadlineMs,
|
|
947
|
+
markFirstEvent: () => {
|
|
948
|
+
if (firstEventSeen || timedOut) return;
|
|
949
|
+
firstEventSeen = true;
|
|
950
|
+
// The first-event window was already the binding constraint (total
|
|
951
|
+
// timeout <= it) — nothing to reschedule.
|
|
952
|
+
if (firstEventDeadlineMs >= totalTimeoutMs) return;
|
|
953
|
+
clearTimeout(timeout);
|
|
954
|
+
const remainingMs = Math.max(
|
|
955
|
+
0,
|
|
956
|
+
totalTimeoutMs - (Date.now() - startedAt),
|
|
957
|
+
);
|
|
958
|
+
timeout = setTimeout(fireTimeout, remainingMs);
|
|
959
|
+
},
|
|
913
960
|
cleanup: () => {
|
|
914
961
|
clearTimeout(timeout);
|
|
915
962
|
parentSignal.removeEventListener("abort", abortFromParent);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "first stream event" deadline for model-request engines.
|
|
3
|
+
*
|
|
4
|
+
* A request that connects successfully but then streams zero events means the
|
|
5
|
+
* transport or gateway is wedged, not slow — real models (including deep
|
|
6
|
+
* thinking ones) emit their first event within seconds. Bounding this window
|
|
7
|
+
* separately from any total-request deadline turns a silent multi-minute hang
|
|
8
|
+
* into a fast abort-and-retry.
|
|
9
|
+
*/
|
|
10
|
+
export const FIRST_STREAM_EVENT_TIMEOUT_MS = 120_000;
|
|
11
|
+
|
|
12
|
+
export interface FirstEventAbortController {
|
|
13
|
+
readonly signal: AbortSignal;
|
|
14
|
+
/** Idempotent. Call once the first real (non-keepalive) stream event arrives. */
|
|
15
|
+
markFirstEvent: () => void;
|
|
16
|
+
didTimeout: () => boolean;
|
|
17
|
+
cleanup: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Layer a first-event deadline on top of a caller's AbortSignal. Aborts if
|
|
22
|
+
* `markFirstEvent()` is not called within `FIRST_STREAM_EVENT_TIMEOUT_MS`.
|
|
23
|
+
* Has no opinion on a total-request deadline — callers that need one (e.g.
|
|
24
|
+
* builder-engine's flat gateway timeout) compose their own on top.
|
|
25
|
+
*/
|
|
26
|
+
export function createFirstEventAbortController(
|
|
27
|
+
parentSignal: AbortSignal,
|
|
28
|
+
): FirstEventAbortController {
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
let timedOut = false;
|
|
31
|
+
let firstEventSeen = false;
|
|
32
|
+
|
|
33
|
+
const abortFromParent = () => {
|
|
34
|
+
if (!controller.signal.aborted) controller.abort(parentSignal.reason);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const timeout = setTimeout(() => {
|
|
38
|
+
timedOut = true;
|
|
39
|
+
if (!controller.signal.aborted) {
|
|
40
|
+
controller.abort(
|
|
41
|
+
new Error(
|
|
42
|
+
`Model request produced no stream events within ${FIRST_STREAM_EVENT_TIMEOUT_MS / 1000}s`,
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}, FIRST_STREAM_EVENT_TIMEOUT_MS);
|
|
47
|
+
|
|
48
|
+
if (parentSignal.aborted) abortFromParent();
|
|
49
|
+
parentSignal.addEventListener("abort", abortFromParent, { once: true });
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
markFirstEvent: () => {
|
|
54
|
+
if (firstEventSeen) return;
|
|
55
|
+
firstEventSeen = true;
|
|
56
|
+
clearTimeout(timeout);
|
|
57
|
+
},
|
|
58
|
+
didTimeout: () => timedOut,
|
|
59
|
+
cleanup: () => {
|
|
60
|
+
clearTimeout(timeout);
|
|
61
|
+
parentSignal.removeEventListener("abort", abortFromParent);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export
|
|
3
|
-
'name: PR Visual Recap\n\n# Visual code review: a coding agent runs the repo\'s visual-recap skill over the\n# PR diff, publishes a plan, and upserts one sticky comment with a screenshot.\n# Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.\n\non:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review, closed]\n\npermissions:\n contents: read\n\nconcurrency:\n group: pr-visual-recap-${{ github.event.pull_request.number }}\n cancel-in-progress: true\n\nenv:\n VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || \'claude\' }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \'\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n\njobs:\n gate:\n name: Gate\n # A custom plain-label runner is allowed only for trusted same-repo authors.\n # Fork and untrusted PRs are forced onto GitHub-hosted ubuntu-latest before\n # any step starts. The only fromJSON input is this static association list.\n runs-on: ${{ github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON(\'["OWNER","MEMBER","COLLABORATOR"]\'), github.event.pull_request.author_association) && (vars.VISUAL_RECAP_GATE_RUNS_ON || \'ubuntu-latest\') || \'ubuntu-latest\' }}\n timeout-minutes: 10\n permissions:\n contents: read\n issues: write\n pull-requests: write\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n runs_on: ${{ steps.decide.outputs.runs_on }}\n steps:\n - id: decide\n uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\n env:\n # Presence-only signals — never expose secret VALUES to the gate.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \'\' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \'\' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \'\' }}\n HAS_COMPATIBLE: ${{ secrets.VISUAL_RECAP_API_KEY != \'\' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n VISUAL_RECAP_BASE_URL: ${{ env.VISUAL_RECAP_BASE_URL }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_RUNS_ON: ${{ vars.VISUAL_RECAP_RUNS_ON || \'"ubuntu-latest"\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n with:\n script: |\n const pr = context.payload.pull_request;\n const reasons = [];\n\n if (!pr) reasons.push(\'no pull_request payload\');\n if (pr && pr.draft) reasons.push(\'draft PR\');\n if (pr && context.payload.action === \'closed\' && !pr.merged) {\n reasons.push(\'closed without merge\');\n }\n\n // Fork PRs only receive repo secrets when the org/repo opts into\n // GitHub\'s "Send secrets to workflows from pull requests" setting\n // (common in private orgs that use forks heavily). Gate on secret\n // availability, not fork-ness: run on forks that have the token,\n // and skip — with an actionable hint — those that don\'t.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);\n const isPrivate = !!(context.payload.repository && context.payload.repository.private);\n const association = (pr && pr.author_association || \'\').toUpperCase();\n const trustedAssociations = [\'OWNER\', \'MEMBER\', \'COLLABORATOR\'];\n const isTrustedAuthor = trustedAssociations.includes(association);\n let configuredRunner = \'ubuntu-latest\';\n let usesSelfHostedRunner = false;\n try {\n const candidate = JSON.parse(process.env.VISUAL_RECAP_RUNS_ON || \'"ubuntu-latest"\');\n const hosted = typeof candidate === \'string\' && /^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(candidate);\n const selfHosted = Array.isArray(candidate) && candidate.length >= 1 && candidate.length <= 20 && candidate.includes(\'self-hosted\') && candidate.every((label) => typeof label === \'string\' && label.length >= 1 && label.length <= 100 && !/[\\u0000-\\u001f\\u007f]/.test(label)) && new Set(candidate).size === candidate.length;\n if (!hosted && !selfHosted) throw new Error(\'unsupported runner value\');\n configuredRunner = candidate;\n usesSelfHostedRunner = selfHosted;\n } catch {\n reasons.push(\'invalid VISUAL_RECAP_RUNS_ON JSON\');\n }\n if (usesSelfHostedRunner && (isFork || !isTrustedAuthor)) {\n reasons.push(\'self-hosted runner mode requires a trusted same-repository PR author\');\n }\n if (isFork && process.env.HAS_PLAN !== \'true\') {\n reasons.push(`fork PR (${headRepo}) without secret access — enable "Send secrets to workflows from pull requests" (and write tokens) in the repo/org Actions settings to run recaps on forks`);\n }\n\n const login = (pr && pr.user && pr.user.login || \'\').toLowerCase();\n const botAuthors = [\'dependabot[bot]\', \'dependabot\', \'renovate[bot]\', \'renovate\'];\n if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);\n if (pr && pr.user && pr.user.type === \'Bot\') reasons.push(\'bot author (type=Bot)\');\n\n if (!isFork && process.env.HAS_PLAN !== \'true\') reasons.push(\'PLAN_RECAP_TOKEN not configured\');\n\n // Normalize + validate the agent so a mis-cased value can\'t pass the\n // gate and then match neither agent step below.\n const rawAgent = (process.env.AGENT || \'claude\').toLowerCase();\n const agent = [\'deepseek\', \'kimi\', \'moonshot\', \'custom\'].includes(rawAgent) ? \'openai-compatible\' : rawAgent;\n if (![\'claude\', \'codex\', \'openai-compatible\'].includes(agent)) {\n reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude", "codex", or "openai-compatible")`);\n } else if (agent === \'codex\') {\n if (process.env.HAS_OPENAI !== \'true\') reasons.push(\'OPENAI_API_KEY not configured (codex backend)\');\n } else if (agent === \'claude\') {\n if (process.env.HAS_ANTHROPIC !== \'true\') reasons.push(\'ANTHROPIC_API_KEY not configured (claude backend)\');\n } else {\n if (process.env.HAS_COMPATIBLE !== \'true\') reasons.push(\'VISUAL_RECAP_API_KEY not configured (openai-compatible backend)\');\n if (!(process.env.VISUAL_RECAP_MODEL || \'\').trim()) reasons.push(\'VISUAL_RECAP_MODEL is required (openai-compatible backend)\');\n const baseUrl = process.env.VISUAL_RECAP_BASE_URL || \'\';\n try {\n const parsed = new URL(baseUrl);\n if (![\'http:\', \'https:\'].includes(parsed.protocol) || parsed.username || parsed.password) {\n reasons.push(\'VISUAL_RECAP_BASE_URL must be an http(s) URL without credentials\');\n }\n } catch {\n reasons.push(\'VISUAL_RECAP_BASE_URL must be a valid http(s) URL\');\n }\n }\n\n // Validate the model before it reaches the agent CLI.\n const model = process.env.VISUAL_RECAP_MODEL || \'\';\n if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {\n reasons.push(`invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})`);\n }\n\n const skillSource = (process.env.VISUAL_RECAP_SKILL_SOURCE || \'auto\').toLowerCase();\n if (![\'auto\', \'latest\', \'repo\'].includes(skillSource)) {\n reasons.push(\'invalid VISUAL_RECAP_SKILL_SOURCE value (expected "auto", "latest", or "repo")\');\n }\n const usesRepoSkill = skillSource === \'repo\';\n\n // Self-modifying guard, evaluated in the trusted gate (runs NO\n // PR-checked-out code): skip the ENTIRE job if the PR touches the\n // repo-pinned skill instructions or any agent config the runner\n // loads, so a PR can\'t rewrite what the agent loads and exfiltrate\n // secrets. With the default bundled skill source, visual skill and\n // recap workflow files are reviewed content, not instructions loaded\n // by the runner.\n // Keep this guard for untrusted forks and untrusted public-repo PRs.\n // Trusted write actors may edit recap-control files as normal\n // reviewable content; running the recap is useful signal for those\n // changes.\n if (pr && !isTrustedAuthor && (isFork || !isPrivate)) {\n try {\n const files = await github.paginate(github.rest.pulls.listFiles, {\n owner: context.repo.owner,\n repo: context.repo.repo,\n pull_number: pr.number,\n per_page: 100,\n });\n const isSensitive = (p) =>\n (usesRepoSkill && /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p)) ||\n p.startsWith(\'.claude/\') ||\n p === \'CLAUDE.md\' ||\n p === \'AGENTS.md\' ||\n p === \'.mcp.json\';\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\', \')}${hits.length > 3 ? \', …\' : \'\'}) — skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n // Fail closed: if the file list can\'t be read, skip.\n reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);\n }\n }\n\n const run = reasons.length === 0;\n core.setOutput(\'run\', run ? \'true\' : \'false\');\n core.setOutput(\'agent\', agent);\n core.setOutput(\'runs_on\', JSON.stringify(configuredRunner));\n if (run) {\n core.info(`Visual recap will run (${agent}).`);\n } else {\n // Surface the skip reason as a run-summary annotation, not just a\n // buried info log, so it\'s clear in the Actions UI why we skipped.\n core.notice(`Visual recap skipped: ${reasons.join(\'; \')}`);\n }\n\n // When skipping, upsert a sticky recap comment with a short skip\n // line so the PR always explains why the recap job did not run.\n if (!run && pr) {\n try {\n const MARKER = \'<!-- pr-visual-recap -->\';\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n per_page: 100,\n });\n const existing = comments.find(\n (c) => c.user && c.user.type === \'Bot\' && c.body && c.body.includes(MARKER)\n );\n const headShort = (process.env.HEAD_SHA || \'\').slice(0, 7);\n const shaRef = headShort ? `\\`${headShort}\\`` : \'latest push\';\n const primaryReason = reasons.filter(\n (r) => !r.startsWith(\'could not list PR files for the self-modifying guard\')\n )[0] || reasons[0] || \'skipped\';\n const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;\n const baseBody = `${MARKER}\\n### Visual recap — skipped\\n\\nThe visual recap job did not run for this pull request. This is informational only and does **not** block the PR.`;\n const planIdMatch = (existing && existing.body ? existing.body : \'\').match(/<!--\\s*plan-id:\\s*([A-Za-z0-9_-]{1,64})\\s*-->/);\n const planIdMarker = planIdMatch ? `\\n\\n<!-- plan-id: ${planIdMatch[1]} -->` : \'\';\n const updatedBody = `${baseBody}${planIdMarker}\\n\\n${skipLine}`;\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body: updatedBody,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n body: updatedBody,\n });\n }\n } catch (e) {\n core.warning(`Could not update recap skip comment: ${e.message}`);\n }\n }\n\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == \'true\'\n runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }}\n timeout-minutes: 30\n defaults:\n run:\n shell: bash\n permissions:\n actions: write\n checks: write\n contents: read\n issues: write\n pull-requests: write\n env:\n PLAN_RECAP_APP_URL: ${{ secrets.PLAN_RECAP_APP_URL || \'https://plan.agent-native.com\' }}\n PLAN_RECAP_TOKEN: ${{ secrets.PLAN_RECAP_TOKEN }}\n GH_TOKEN: ${{ github.token }}\n PR_NUMBER: ${{ github.event.pull_request.number }}\n PR_STATE: ${{ github.event.pull_request.state }}\n PR_MERGED: ${{ github.event.pull_request.merged }}\n PR_MERGED_AT: ${{ github.event.pull_request.merged_at }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \'\' }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n steps:\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n with:\n fetch-depth: 0\n # This job runs an agent over untrusted PR diff; don\'t leave the token\n # in .git/config (it uses GH_TOKEN for gh API calls, never git push).\n persist-credentials: false\n\n # Dogfood trusted base-branch source inside this monorepo, else install the\n # published package once. Never execute PR-head recap CLI code.\n - name: Resolve recap CLI\n id: cli\n env:\n # Optional: pin the consumer CLI version (e.g. "1.2.3"). Defaults to\n # "latest" when unset. Set via repository variable RECAP_CLI_VERSION.\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n if [ "$GITHUB_REPOSITORY" = "BuilderIO/agent-native" ] && [ -f packages/core/src/cli/index.ts ]; then\n echo "local=true" >> "$GITHUB_OUTPUT"\n else\n echo "local=false" >> "$GITHUB_OUTPUT"\n fi\n\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n if: steps.cli.outputs.local == \'true\'\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: .recap-cli-source\n fetch-depth: 1\n persist-credentials: false\n\n - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8\n if: steps.cli.outputs.local == \'true\'\n\n - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0\n with:\n node-version: "22"\n cache: ${{ steps.cli.outputs.local == \'true\' && \'pnpm\' || \'\' }}\n\n - name: Install trusted workspace recap CLI\n if: steps.cli.outputs.local == \'true\'\n working-directory: .recap-cli-source\n run: |\n set -euo pipefail\n pnpm install --frozen-lockfile --ignore-scripts\n pnpm --filter @agent-native/recap-cli build\n echo "RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "CODE_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install published recap CLI\n if: steps.cli.outputs.local != \'true\'\n env:\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n VERSION="$RECAP_CLI_VERSION"\n if [ "$VERSION" = "latest" ]; then\n VERSION="$(npm view @agent-native/recap-cli@latest version)"\n fi\n for attempt in 1 2 3; do\n if npm install --prefix "$RUNNER_TEMP/recap-cli" --no-audit --no-fund --ignore-scripts "@agent-native/recap-cli@$VERSION"; then\n break\n fi\n if [ "$attempt" = "3" ]; then exit 1; fi\n sleep $((attempt * 10))\n done\n echo "RECAP_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"\n echo "RECAP_CLI=$RUNNER_TEMP/recap-cli/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$RUNNER_TEMP/recap-cli/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install OpenAI-compatible provider runtime\n if: needs.gate.outputs.agent == \'openai-compatible\' && steps.cli.outputs.local != \'true\'\n env:\n CORE_CLI_VERSION: ${{ vars.CORE_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n CORE_VERSION="$CORE_CLI_VERSION"\n if [ "$CORE_VERSION" = "latest" ]; then\n CORE_VERSION="$(npm view @agent-native/core@latest version)"\n fi\n npm install --prefix "$RUNNER_TEMP/recap-code" --no-audit --no-fund --ignore-scripts ai @ai-sdk/openai "@agent-native/core@$CORE_VERSION"\n echo "CORE_CLI_VERSION=$CORE_VERSION" >> "$GITHUB_ENV"\n echo "CODE_CLI=$RUNNER_TEMP/recap-code/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n\n - name: Start visual recap check\n id: recap_check\n continue-on-error: true\n run: |\n set -uo pipefail\n $RECAP_CLI recap check start --sha "$HEAD_SHA" --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n\n - name: Fetch pull request head\n env:\n PR_NUMBER_ENV: ${{ github.event.pull_request.number }}\n run: |\n set -euo pipefail\n if git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then\n git update-ref refs/recap/pr-head "$HEAD_SHA"\n else\n AUTH_B64="$(printf \'x-access-token:%s\' "$GH_TOKEN" | base64 | tr -d \'\\n\')"\n git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_B64" fetch origin "pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head"\n fi\n FETCHED_SHA="$(git rev-parse refs/recap/pr-head)"\n if [ "$FETCHED_SHA" != "$HEAD_SHA" ]; then\n echo "FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA — aborting to avoid recapping the wrong commit"\n exit 1\n fi\n\n - name: Collect bounded diff\n id: diff\n env:\n BASE_SHA: ${{ github.event.pull_request.base.sha }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap collect-diff --base "$BASE_SHA" --head refs/recap/pr-head --out recap.diff --stat recap.stat\n\n - name: Probe plan-app auth\n id: auth_probe\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Hit the plan app\'s action surface with the publish token. A 401 means\n # the token is expired/revoked; surface it in the sticky comment so the\n # repo owner knows to re-mint it instead of seeing a generic failure.\n HTTP_STATUS=$(node -e \'\n const https = require("https");\n const url = new URL("/_agent-native/actions/record-recap-usage", process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const req = https.request(url, { method: "POST", headers: { "authorization": "Bearer " + process.env.PLAN_RECAP_TOKEN, "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on("error", () => process.stdout.write("0"));\n req.end(JSON.stringify({ planId: "__probe__" }));\n \' 2>/dev/null || echo "0")\n if [ "$HTTP_STATUS" = "401" ]; then\n echo "auth_failed=true" >> "$GITHUB_OUTPUT"\n else\n echo "auth_failed=false" >> "$GITHUB_OUTPUT"\n fi\n\n - name: Probe plan-app route health\n id: route_health\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Pre-publish health gate: confirm the plan app\'s recap action routes\n # are actually deployed BEFORE the agent runs. A 404 from\n # create-visual-recap (POST) or get-plan-blocks (GET) means the\n # plan-app deploy has not propagated yet (the client is ahead of the\n # deployed server). Say that plainly here instead of letting the agent\n # run and then fail confusingly at publish time. A 401 or 200 is\n # healthy — the route exists, it just rejected/accepted the probe.\n probe_status() {\n ROUTE="$1" METHOD="$2" node -e \'\n const https = require("https");\n const base = process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com";\n const url = new URL(process.env.ROUTE, base);\n if (process.env.METHOD === "GET") url.searchParams.set("format", "reference");\n const req = https.request(url, { method: process.env.METHOD, headers: { "authorization": "Bearer " + (process.env.PLAN_RECAP_TOKEN || ""), "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on("error", () => process.stdout.write("0"));\n req.on("timeout", () => { process.stdout.write("0"); req.destroy(); });\n if (process.env.METHOD === "POST") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }\n \' 2>/dev/null || echo "0"\n }\n CREATE_STATUS="$(probe_status /_agent-native/actions/create-visual-recap POST)"\n BLOCKS_STATUS="$(probe_status /_agent-native/actions/get-plan-blocks GET)"\n REASON=""\n if [ "$CREATE_STATUS" = "404" ] || [ "$BLOCKS_STATUS" = "404" ]; then\n REASON="Plan app routes return 404 — deploy not yet propagated (create-visual-recap: $CREATE_STATUS, get-plan-blocks: $BLOCKS_STATUS). The plan-app client is ahead of the deployed server; re-run once the deploy finishes propagating."\n echo "::error::$REASON"\n echo "unhealthy=true" >> "$GITHUB_OUTPUT"\n else\n echo "unhealthy=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_ROUTE_HEALTH_EOF__\'\n echo "$REASON"\n echo \'__RECAP_ROUTE_HEALTH_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n run: |\n set -uo pipefail\n # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a\n # credential-bearing diff is never handed to the agent / plan service.\n if ! SCAN_JSON="$($RECAP_CLI recap scan --diff recap.diff --mode "$VISUAL_RECAP_SECRET_SCAN")"; then\n SCAN_JSON=\'{"suppressed":true,"reason":"secret scan failed to run; failing closed"}\'\n fi\n {\n echo \'json<<__RECAP_SCAN_EOF__\'\n echo "$SCAN_JSON"\n echo \'__RECAP_SCAN_EOF__\'\n } >> "$GITHUB_OUTPUT"\n SUPPRESSED=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?"true":"false")}catch{process.stdout.write("true")}\' "$SCAN_JSON")\n echo "suppressed=$SUPPRESSED" >> "$GITHUB_OUTPUT"\n\n - name: Read previous plan id\n id: prev\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n continue-on-error: true\n run: |\n set -euo pipefail\n PLAN_ID="$($RECAP_CLI recap comment find-plan-id --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN")"\n echo "plan_id=$PLAN_ID" >> "$GITHUB_OUTPUT"\n\n - name: Fetch plan block reference\n id: block_reference\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n if $RECAP_CLI recap block-reference --app-url "$PLAN_RECAP_APP_URL" --out recap-blocks.md; then\n echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "ok=false" >> "$GITHUB_OUTPUT"\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_EOF__\'\n echo "Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the hosted Plan action will validate the final MDX."\n echo \'__RECAP_BLOCK_REFERENCE_EOF__\'\n } >> "$GITHUB_OUTPUT"\n cat > recap-blocks.md <<\'EOF\'\n Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.\n EOF\n fi\n\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n env:\n # Pass step outputs via env, NOT ${{ }} interpolation into the run body:\n # the prev plan id is parsed from a PR comment and could inject shell.\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}\n run: |\n set -euo pipefail\n ARGS=(--diff recap.diff --stat recap.stat --block-reference recap-blocks.md --pr "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --head "$HEAD_SHA" --app-url "$PLAN_RECAP_APP_URL" --skill-source "$VISUAL_RECAP_SKILL_SOURCE" --out recap-prompt.md)\n if [ "${DIFF_HUGE:-}" = "true" ]; then ARGS+=(--huge); fi\n if [ "${IS_FORK:-}" = "true" ]; then ARGS+=(--fork-pr true); fi\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n $RECAP_CLI recap build-prompt "${ARGS[@]}"\n\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == \'claude\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS="Read,Write,Bash(git diff:*)"\n CLAUDE_ARGS=(-p "$(cat recap-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)\n CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}")\n rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log\n run_claude() {\n set +e\n npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-result.json 2> claude-stderr.log\n CLAUDE_STATUS="$?"\n set -e\n echo "$CLAUDE_STATUS" > claude-exit-code.txt\n }\n run_claude\n # A clean agent exit WITHOUT recap-source.json is the strongest\n # "retry me" signal — the deterministic publisher needs that file, and\n # the agent occasionally finishes a turn without writing it. Retry once.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' claude-result.json claude-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_claude\n fi\n fi\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == \'codex\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on\n # the gpt-5.5 wss transport); stdin keeps the key out of process args.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # The runner is itself an ephemeral sandbox; bypass Codex\'s own sandbox\n # (bubblewrap can\'t init here) and approval gate (cancels the MCP write).\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n # Validate reasoning against the enum before embedding it in the TOML override.\n case "${VISUAL_RECAP_REASONING:-}" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c "model_reasoning_effort=\\"$VISUAL_RECAP_REASONING\\"") ;;\n "") ;;\n *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;\n esac\n rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log\n run_codex() {\n set +e\n npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-prompt.md)" 2> codex-stderr.log | tee codex-events.jsonl\n CODEX_STATUS="${PIPESTATUS[0]}"\n set -e\n echo "$CODEX_STATUS" > codex-exit-code.txt\n }\n run_codex\n # Retry once if the agent exited without writing recap-source.json\n # (see the Claude step) — the publisher needs that file.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' codex-events.jsonl codex-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_codex\n fi\n fi\n\n - name: Run agent (OpenAI-compatible)\n id: openai_compatible\n if: needs.gate.outputs.agent == \'openai-compatible\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\n AGENT_ENGINE: ai-sdk:openai\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-usage.json\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\n run: |\n set -uo pipefail\n rm -f recap-source.json recap-url.txt recap-url-reason.txt openai-compatible-result.txt openai-compatible-usage.json openai-compatible-stderr.log\n run_openai_compatible() {\n set +e\n $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-prompt.md)" > openai-compatible-result.txt 2> openai-compatible-stderr.log\n OPENAI_COMPATIBLE_STATUS="$?"\n set -e\n echo "$OPENAI_COMPATIBLE_STATUS" > openai-compatible-exit-code.txt\n }\n run_openai_compatible\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_openai_compatible\n fi\n fi\n\n - name: Publish recap source\n id: publish\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n ARGS=(--source recap-source.json --out recap-url.txt --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN")\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")\n if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n "${PR_STATE:-}" ]; then\n ARGS+=(--source-pr-state "$PR_STATE")\n fi\n if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi\n $RECAP_CLI recap publish "${ARGS[@]}"\n\n - name: Build one-shot recap repair prompt\n id: repair_prompt\n if: steps.publish.outputs.repairable == \'true\'\n run: |\n set -euo pipefail\n cp recap-source.json recap-source.initial.json\n $RECAP_CLI recap repair-prompt --source recap-source.json --reason-file recap-url-reason.txt --out recap-repair-prompt.md\n\n - name: Repair recap source (Claude Code)\n id: claude_repair\n if: steps.publish.outputs.repairable == \'true\' && needs.gate.outputs.agent == \'claude\'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS="Read,Write"\n CLAUDE_ARGS=(-p "$(cat recap-repair-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)\n CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}")\n rm -f claude-repair-result.json claude-repair-stderr.log\n set +e\n npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-repair-result.json 2> claude-repair-stderr.log\n CLAUDE_REPAIR_STATUS="$?"\n set -e\n echo "$CLAUDE_REPAIR_STATUS" > claude-repair-exit-code.txt\n if [ "$CLAUDE_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Repair recap source (Codex)\n id: codex_repair\n if: steps.publish.outputs.repairable == \'true\' && needs.gate.outputs.agent == \'codex\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n case "${VISUAL_RECAP_REASONING:-}" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c "model_reasoning_effort=\\"$VISUAL_RECAP_REASONING\\"") ;;\n "") ;;\n *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;\n esac\n rm -f codex-repair-events.jsonl codex-repair-stderr.log\n set +e\n npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-repair-prompt.md)" 2> codex-repair-stderr.log | tee codex-repair-events.jsonl\n CODEX_REPAIR_STATUS="${PIPESTATUS[0]}"\n set -e\n echo "$CODEX_REPAIR_STATUS" > codex-repair-exit-code.txt\n if [ "$CODEX_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Repair recap source (OpenAI-compatible)\n id: openai_compatible_repair\n if: steps.publish.outputs.repairable == \'true\' && needs.gate.outputs.agent == \'openai-compatible\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\n AGENT_ENGINE: ai-sdk:openai\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-repair-usage.json\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\n run: |\n set -uo pipefail\n rm -f openai-compatible-repair-result.txt openai-compatible-repair-usage.json openai-compatible-repair-stderr.log\n set +e\n $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-repair-prompt.md)" > openai-compatible-repair-result.txt 2> openai-compatible-repair-stderr.log\n OPENAI_COMPATIBLE_REPAIR_STATUS="$?"\n set -e\n echo "$OPENAI_COMPATIBLE_REPAIR_STATUS" > openai-compatible-repair-exit-code.txt\n if [ "$OPENAI_COMPATIBLE_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Validate repaired recap source\n id: repaired_source\n if: steps.publish.outputs.repairable == \'true\'\n env:\n REPAIR_AGENT_OK: ${{ steps.claude_repair.outputs.ok || steps.codex_repair.outputs.ok || steps.openai_compatible_repair.outputs.ok }}\n run: |\n set -uo pipefail\n REPAIR_REASON=""\n if [ "${REPAIR_AGENT_OK:-false}" != "true" ]; then\n echo "ok=false" >> "$GITHUB_OUTPUT"\n REPAIR_REASON="Repair agent exited unsuccessfully; repaired source was not published."\n else\n VALIDATION_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap validate-repair --original recap-source.initial.json --source recap-source.json --reason-file recap-url-reason.txt || true)"\n REPAIR_OK="$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}\' "$VALIDATION_JSON")"\n REPAIR_REASON="$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("Repair validation returned invalid output.")}\' "$VALIDATION_JSON")"\n echo "ok=$REPAIR_OK" >> "$GITHUB_OUTPUT"\n fi\n if [ -n "$REPAIR_REASON" ]; then\n echo "$REPAIR_REASON" > recap-url-reason.txt\n fi\n {\n echo \'reason<<__RECAP_REPAIR_REASON_EOF__\'\n echo "$REPAIR_REASON"\n echo \'__RECAP_REPAIR_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Publish repaired recap source\n id: publish_repair\n if: steps.repaired_source.outputs.ok == \'true\'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n ARGS=(--source recap-source.json --out recap-url.txt --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN")\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")\n if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n "${PR_STATE:-}" ]; then\n ARGS+=(--source-pr-state "$PR_STATE")\n fi\n if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi\n $RECAP_CLI recap publish "${ARGS[@]}"\n\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n run: |\n set -uo pipefail\n PLAN_URL=""\n URL_REASON=""\n if [ -f recap-url.txt ]; then\n PLAN_URL="$(tr -d \'\\r\\n\' < recap-url.txt | tr -d \' \')"\n elif [ -f recap-url-reason.txt ]; then\n URL_REASON="$(cat recap-url-reason.txt)"\n else\n URL_REASON="recap-url.txt was not created."\n fi\n # recap-url.txt is agent-written -> untrusted. Rebuild a canonical\n # recap URL from the trusted app base and a strictly validated plan id,\n # preserving path-prefixed self-hosted mounts.\n if [ -z "$URL_REASON" ]; then\n URL_RESULT=$(PLAN_URL="$PLAN_URL" node <<\'NODE\'\n const emit = (value) => process.stdout.write(JSON.stringify(value));\n try {\n const raw = process.env.PLAN_URL || "";\n if (!raw) {\n emit({ url: "", reason: "recap-url.txt was empty" });\n process.exit(0);\n }\n const trusted = new URL(process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const parsed = /^https?:\\/\\//i.test(raw)\n ? new URL(raw)\n : new URL(raw, trusted);\n if (parsed.origin !== trusted.origin) {\n emit({ url: "", reason: `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}` });\n process.exit(0);\n }\n\n const base = trusted.pathname.replace(/\\/$/, "");\n const paths = [parsed.pathname];\n if (base && parsed.pathname.startsWith(`${base}/`)) {\n paths.push(parsed.pathname.slice(base.length) || "/");\n }\n\n for (const path of paths) {\n const match = path.match(/^\\/(?:plans|recaps)\\/([A-Za-z0-9_-]+)\\/?$/);\n if (match) {\n emit({ url: `${trusted.origin}${base}/recaps/${match[1]}`, reason: "" });\n process.exit(0);\n }\n }\n emit({ url: "", reason: "recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app" });\n } catch {\n emit({ url: "", reason: "recap-url.txt was not a valid URL or recap path" });\n }\n NODE\n )\n CANONICAL_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).url||"")}catch{process.stdout.write("")}\' "$URL_RESULT")\n URL_REASON=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("recap-url.txt URL validation failed")}\' "$URL_RESULT")\n else\n CANONICAL_URL=""\n fi\n if [ -n "$CANONICAL_URL" ]; then\n echo "plan_url=$CANONICAL_URL" >> "$GITHUB_OUTPUT"; echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "plan_url=" >> "$GITHUB_OUTPUT"; echo "ok=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_URL_REASON_EOF__\'\n echo "$URL_REASON"\n echo \'__RECAP_URL_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Summarize agent failure\n id: agent_summary\n if: steps.url.outputs.ok != \'true\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_REPAIR_ATTEMPTED: ${{ steps.repair_prompt.outcome == \'success\' }}\n RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }}\n RECAP_PUBLISH_REASON: ${{ steps.repaired_source.outputs.reason || steps.publish_repair.outputs.reason || steps.publish.outputs.reason }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n STDERR=claude-stderr.log\n EXIT_CODE=claude-exit-code.txt\n if [ "$RECAP_AGENT" = "codex" ]; then\n RESULT=codex-events.jsonl\n STDERR=codex-stderr.log\n EXIT_CODE=codex-exit-code.txt\n elif [ "$RECAP_AGENT" = "openai-compatible" ]; then\n RESULT=openai-compatible-result.txt\n STDERR=openai-compatible-stderr.log\n EXIT_CODE=openai-compatible-exit-code.txt\n fi\n if [ "$RECAP_REPAIR_ATTEMPTED" = "true" ]; then\n RESULT=claude-repair-result.json\n STDERR=claude-repair-stderr.log\n EXIT_CODE=claude-repair-exit-code.txt\n if [ "$RECAP_AGENT" = "codex" ]; then\n RESULT=codex-repair-events.jsonl\n STDERR=codex-repair-stderr.log\n EXIT_CODE=codex-repair-exit-code.txt\n elif [ "$RECAP_AGENT" = "openai-compatible" ]; then\n RESULT=openai-compatible-repair-result.txt\n STDERR=openai-compatible-repair-stderr.log\n EXIT_CODE=openai-compatible-repair-exit-code.txt\n fi\n fi\n SUMMARY_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap agent-summary --agent "$RECAP_AGENT" --result-file "$RESULT" --stderr-file "$STDERR" --exit-code-file "$EXIT_CODE" || echo \'{}\')"\n SUMMARY="$(node -e \'try { const value = JSON.parse(process.argv[1]).summary; process.stdout.write(typeof value === "string" ? value : ""); } catch {}\' "$SUMMARY_JSON")"\n if [ -n "$SUMMARY" ]; then\n {\n echo \'summary<<__RECAP_AGENT_SUMMARY_EOF__\'\n echo "$SUMMARY"\n echo \'__RECAP_AGENT_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n elif [ -n "${RECAP_BLOCK_REFERENCE_SUMMARY:-}" ]; then\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n echo "$RECAP_BLOCK_REFERENCE_SUMMARY"\n echo \'__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n elif [ -n "${RECAP_PUBLISH_REASON:-}" ]; then\n {\n echo \'summary<<__RECAP_PUBLISH_SUMMARY_EOF__\'\n echo "$RECAP_PUBLISH_REASON"\n echo \'__RECAP_PUBLISH_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n fi\n\n - name: Attach usage\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n # Use the gate-normalized agent so "Codex" still selects the right file.\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_REPAIR_SUCCEEDED: ${{ steps.publish_repair.outcome == \'success\' }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; fi\n if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-usage.json; fi\n if [ "$RECAP_REPAIR_SUCCEEDED" = "true" ]; then\n RESULT=claude-repair-result.json\n if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-repair-events.jsonl; fi\n if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-repair-usage.json; fi\n fi\n if [ -f "$RESULT" ]; then $RECAP_CLI recap usage --plan-url "$PLAN_URL" --agent "$RECAP_AGENT" --result-file "$RESULT" --model "${VISUAL_RECAP_MODEL:-}" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN" || true; fi\n\n - name: Cache Playwright browsers\n if: steps.url.outputs.ok == \'true\'\n uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3\n with:\n path: ~/.cache/ms-playwright\n key: playwright-1-${{ runner.os }}\n\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n # recap-url.txt is untrusted agent output; pass via env, never ${{ }}.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n if [ -n "${RECAP_PLAYWRIGHT:-}" ] && [ -x "$RECAP_PLAYWRIGHT" ]; then\n "$RECAP_PLAYWRIGHT" install --with-deps chromium || true\n elif command -v pnpm >/dev/null 2>&1; then\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n else\n npx -y playwright@1 install --with-deps chromium || true\n fi\n IMAGE_CACHE_KEY="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"\n LIGHT_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap.png --theme light --image-cache-key "$IMAGE_CACHE_KEY" || echo \'{}\')"\n DARK_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap-dark.png --theme dark --image-cache-key "$IMAGE_CACHE_KEY" || echo \'{}\')"\n for SHOT_LABEL in light dark; do\n if [ "$SHOT_LABEL" = "light" ]; then SHOT_JSON="$LIGHT_SHOT_JSON"; else SHOT_JSON="$DARK_SHOT_JSON"; fi\n SHOT_LABEL="$SHOT_LABEL" SHOT_JSON="$SHOT_JSON" node -e \'const label = process.env.SHOT_LABEL || "shot"; let parsed = {}; try { parsed = JSON.parse(process.env.SHOT_JSON || "{}"); } catch { parsed = { ok: false, reason: "invalid shot JSON" }; } const summary = { ok: parsed.ok === true, imageUrl: parsed.imageUrl ? "[present]" : "", out: typeof parsed.out === "string" ? parsed.out : "", reason: typeof parsed.reason === "string" ? parsed.reason.slice(0, 500) : "" }; console.log(`[recap shot] ${label}: ${JSON.stringify(summary)}`);\'\n done\n IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$LIGHT_SHOT_JSON")\n DARK_IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$DARK_SHOT_JSON")\n SHOT_STATUS=$(LIGHT_SHOT_JSON="$LIGHT_SHOT_JSON" DARK_SHOT_JSON="$DARK_SHOT_JSON" node <<\'NODE\'\n const parse = (raw) => { try { return JSON.parse(raw || "{}"); } catch { return { ok: false, reason: "invalid shot JSON" }; } };\n const shots = [["light", parse(process.env.LIGHT_SHOT_JSON)], ["dark", parse(process.env.DARK_SHOT_JSON)]];\n const hasImage = shots.some(([, shot]) => typeof shot.imageUrl === "string" && shot.imageUrl.trim());\n const reasons = shots.flatMap(([label, shot]) => {\n if (typeof shot.reason === "string" && shot.reason.trim()) return [`${label}: ${shot.reason.trim()}`];\n if (!(typeof shot.imageUrl === "string" && shot.imageUrl.trim())) return [`${label}: no imageUrl returned`];\n return [];\n });\n process.stdout.write(JSON.stringify({ ok: hasImage, reason: hasImage ? "" : reasons.join("; ").slice(0, 1000) }));\n NODE\n )\n SHOT_OK=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}\' "$SHOT_STATUS")\n SHOT_REASON=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("invalid shot status JSON")}\' "$SHOT_STATUS")\n if [ "$SHOT_OK" != "true" ]; then\n echo "::warning::Visual recap screenshot unavailable; posting screenshot-failed recap comment. $SHOT_REASON"\n fi\n echo "image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "light_image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "dark_image_url=$DARK_IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "shot_ok=$SHOT_OK" >> "$GITHUB_OUTPUT"\n {\n echo \'shot_reason<<__RECAP_SHOT_REASON_EOF__\'\n echo "$SHOT_REASON"\n echo \'__RECAP_SHOT_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n if [ -f recap.png ] || [ -f recap-dark.png ]; then echo "captured=true" >> "$GITHUB_OUTPUT"; else echo "captured=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Upload recap screenshot artifact\n if: steps.shot.outputs.captured == \'true\'\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: |\n recap.png\n recap-dark.png\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upload recap source artifact\n if: always() && !cancelled()\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n # recap-source.json + the agent transcript (claude-result.json /\n # codex-events.jsonl + stderr) are the only window into WHAT the agent\n # did when a publish fails (no plan URL) — INCLUDING the case where it\n # finished without writing recap-source.json at all. The sticky comment\n # only shows the screenshot, so without these a failed recap is\n # undebuggable. Uploaded on success + failure; tolerant when absent.\n name: pr-visual-recap-source-${{ github.event.pull_request.number }}\n path: |\n recap-source.json\n recap-source.initial.json\n recap-url-reason.txt\n recap-repair-prompt.md\n claude-result.json\n claude-stderr.log\n claude-repair-result.json\n claude-repair-stderr.log\n claude-repair-exit-code.txt\n codex-events.jsonl\n codex-stderr.log\n codex-repair-events.jsonl\n codex-repair-stderr.log\n codex-repair-exit-code.txt\n openai-compatible-result.txt\n openai-compatible-usage.json\n openai-compatible-stderr.log\n openai-compatible-repair-result.txt\n openai-compatible-repair-usage.json\n openai-compatible-repair-stderr.log\n openai-compatible-repair-exit-code.txt\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upsert sticky comment\n if: always() && !cancelled()\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n RECAP_IMAGE_URL: ${{ steps.shot.outputs.image_url }}\n RECAP_LIGHT_IMAGE_URL: ${{ steps.shot.outputs.light_image_url }}\n RECAP_DARK_IMAGE_URL: ${{ steps.shot.outputs.dark_image_url }}\n RECAP_SHOT_OK: ${{ steps.shot.outputs.shot_ok }}\n RECAP_SHOT_REASON: ${{ steps.shot.outputs.shot_reason }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n RECAP_AUTH_FAILED: ${{ steps.auth_probe.outputs.auth_failed }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n # Prefer the route-health diagnostic when the plan app routes are not\n # yet deployed so the comment explains the 404 instead of a generic\n # "recap-url.txt was not created" message.\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap comment upsert --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN" --head-sha "$HEAD_SHA"\n\n - name: Complete visual recap check\n if: always() && !cancelled() && steps.recap_check.outputs.check_run_id != \'\'\n continue-on-error: true\n env:\n # Untrusted/step values via env (NOT ${{ }}-interpolated into the run\n # body): the agent-written plan URL and the scan JSON could inject shell.\n CHECK_RUN_ID: ${{ steps.recap_check.outputs.check_run_id }}\n PLAN_OK: ${{ steps.url.outputs.ok }}\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -uo pipefail\n $RECAP_CLI recap check complete \\\n --check-run-id "$CHECK_RUN_ID" \\\n --plan-ok "$PLAN_OK" \\\n --plan-url "$PLAN_URL" \\\n --suppressed "$SUPPRESSED" \\\n --suppressed-json "$SUPPRESSED_JSON" \\\n --huge "$DIFF_HUGE" \\\n --tiny "$DIFF_TINY" \\\n --failure-summary "$RECAP_AGENT_SUMMARY" \\\n --url-reason "$RECAP_URL_REASON" \\\n --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n';
|
|
1
|
+
/** Compatibility re-export for the workflow bundled by the recap CLI package. */
|
|
2
|
+
export { PR_VISUAL_RECAP_WORKFLOW_YML } from "@agent-native/recap-cli";
|
|
@@ -3237,7 +3237,9 @@ const AssistantChatInner = forwardRef<
|
|
|
3237
3237
|
const reconnectErrorCode =
|
|
3238
3238
|
reconnectTerminalReason === "run_timeout"
|
|
3239
3239
|
? "run_timeout"
|
|
3240
|
-
: "
|
|
3240
|
+
: reconnectTerminalReason === "stream_ended"
|
|
3241
|
+
? "reconnect_stream_ended"
|
|
3242
|
+
: "reconnect_no_progress";
|
|
3241
3243
|
captureError(new Error(`agent-chat:${reconnectErrorCode}`), {
|
|
3242
3244
|
tags: {
|
|
3243
3245
|
context: "agent-native-chat",
|
|
@@ -3309,9 +3311,11 @@ const AssistantChatInner = forwardRef<
|
|
|
3309
3311
|
}
|
|
3310
3312
|
setRunErrorInfo({
|
|
3311
3313
|
message:
|
|
3312
|
-
|
|
3314
|
+
reconnectErrorCode === "run_timeout"
|
|
3313
3315
|
? "The previous background agent run reached its time limit before finishing. The partial work was preserved; continue or retry to pick up from here."
|
|
3314
|
-
:
|
|
3316
|
+
: reconnectErrorCode === "reconnect_stream_ended"
|
|
3317
|
+
? "The previous agent stream ended while the run was recovering. Continue or retry to reconnect to the run."
|
|
3318
|
+
: "The previous agent run stopped producing visible progress during recovery, so it was stopped before it could keep looping.",
|
|
3315
3319
|
errorCode: reconnectErrorCode,
|
|
3316
3320
|
recoverable: true,
|
|
3317
3321
|
runId,
|
|
@@ -613,7 +613,7 @@ The result should be a reusable agent profile, not a one-off task response.`,
|
|
|
613
613
|
icon: <IconBulb className="h-3.5 w-3.5" />,
|
|
614
614
|
label: "Create Skill",
|
|
615
615
|
desc: "Teach the agent a new ability",
|
|
616
|
-
action: openSkillFlyout,
|
|
616
|
+
action: () => openSkillFlyout(),
|
|
617
617
|
hoverAction: openSkillFlyout,
|
|
618
618
|
},
|
|
619
619
|
{
|