@app-vitals/agent-runtime 0.0.0-stage → 0.1.0

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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +40 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,52 @@
1
+ /**
2
+ * agent/src/runtime/browser.integration.test.ts
3
+ *
4
+ * Integration test for launchBrowser() — a real I/O boundary (an actual
5
+ * Chromium process launch) with no existing coverage, so this exercises the
6
+ * real dependency rather than a double per docs/testing.md's integration
7
+ * layer definition.
8
+ *
9
+ * Verifies the browser launches under the same --no-sandbox flags the
10
+ * container's restricted securityContext requires (see agent/src/runtime/browser.ts)
11
+ * and that a trivial page can be navigated and read back.
12
+ */
13
+
14
+ import { afterEach, describe, expect, test } from "bun:test";
15
+ import type { Browser } from "playwright";
16
+ import { launchBrowser } from "./browser.ts";
17
+
18
+ describe("launchBrowser", () => {
19
+ let browser: Browser | undefined;
20
+
21
+ afterEach(async () => {
22
+ if (browser) {
23
+ await browser.close();
24
+ browser = undefined;
25
+ }
26
+ });
27
+
28
+ test("launches successfully", async () => {
29
+ browser = await launchBrowser();
30
+ expect(browser.isConnected()).toBe(true);
31
+ });
32
+
33
+ test("navigates a data: URL and reads back page content", async () => {
34
+ browser = await launchBrowser();
35
+ const page = await browser.newPage();
36
+ await page.goto(
37
+ "data:text/html,<title>shipwright-browser-test</title><h1>hello</h1>",
38
+ );
39
+ expect(await page.title()).toBe("shipwright-browser-test");
40
+ expect(await page.content()).toContain("hello");
41
+ await page.close();
42
+ });
43
+
44
+ test("navigates about:blank and reports a ready document", async () => {
45
+ browser = await launchBrowser();
46
+ const page = await browser.newPage();
47
+ await page.goto("about:blank");
48
+ const readyState = await page.evaluate(() => document.readyState);
49
+ expect(readyState).toBe("complete");
50
+ await page.close();
51
+ });
52
+ });
package/browser.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * agent/src/runtime/browser.ts
3
+ *
4
+ * Launches a headless Chromium browser for the agent's browser-driven tools
5
+ * (e.g. the PW-1.x line of work). Runs under the container's restricted
6
+ * securityContext (runAsNonRoot, allowPrivilegeEscalation: false, no sudo) —
7
+ * there is no real Linux user-namespace sandbox available, so Chromium's own
8
+ * OS-level sandbox must be disabled via --no-sandbox / --disable-setuid-sandbox
9
+ * (and Playwright's chromiumSandbox option, which wraps the same behavior).
10
+ *
11
+ * Browser resolution: chromium.launch({ headless: true }) with no explicit
12
+ * `channel` resolves to the chromium-headless-shell executable whenever it's
13
+ * installed (confirmed against playwright-core@1.61.1's actual launch-time
14
+ * executable resolution: BrowserType.getExecutableName() returns
15
+ * "chromium-headless-shell" when options.headless is true and no channel is
16
+ * set — see server/chromium/chromium.ts, bundled into lib/coreBundle.js).
17
+ * This matches Playwright's documented default headless-mode behavior since
18
+ * v1.49 (https://github.com/microsoft/playwright/issues/33566) — no channel
19
+ * string needs to be passed. agent/Dockerfile installs exactly this browser
20
+ * (`playwright install chromium-headless-shell`, PW-1.1), so no other
21
+ * Chromium variant is present in the runtime image for headless launches to
22
+ * fall back to.
23
+ */
24
+
25
+ import type { Browser } from "playwright";
26
+ import { chromium } from "playwright";
27
+
28
+ export async function launchBrowser(): Promise<Browser> {
29
+ return chromium.launch({
30
+ headless: true,
31
+ chromiumSandbox: false,
32
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
33
+ });
34
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * agent/src/runtime/chat-poller.integration.test.ts
3
+ *
4
+ * Integration tests for the chat poll loop's start()/stop() timer lifecycle.
5
+ * Filed as integration (not unit) per docs/test-readiness/test-system.md: these
6
+ * tests drive `createChatPoller`'s real `setInterval`/`clearInterval` across
7
+ * real wall-clock waits (~100ms) to observe timer start/stop behavior, which
8
+ * puts them over the unit-layer <200ms hard cap and outside its "no I/O of any
9
+ * kind" boundary. Client and runner remain fake doubles — no real HTTP.
10
+ */
11
+
12
+ import { describe, expect, it, mock } from "bun:test";
13
+ import { createChatPoller } from "./chat-poller.ts";
14
+ import type { ChatServiceClient } from "./http-chat-service-client.ts";
15
+
16
+ // ─── Helpers ───────────────────────────────────────────────────────────────────
17
+
18
+ function makeThread(id: string) {
19
+ return {
20
+ id,
21
+ agentId: "agent-1",
22
+ memberId: null,
23
+ title: null,
24
+ createdAt: new Date(),
25
+ updatedAt: new Date(),
26
+ };
27
+ }
28
+
29
+ /** Create a fake ChatServiceClient. All methods are overridable per test. */
30
+ function makeFakeClient(
31
+ overrides: Partial<ChatServiceClient> = {},
32
+ ): ChatServiceClient {
33
+ return {
34
+ listThreads: async () => ({ threads: [], total: 0, limit: 50, offset: 0 }),
35
+ claimMessage: async () => null,
36
+ heartbeat: async () => ({ cancelRequested: false }),
37
+ replyToMessage: async () => {
38
+ throw new Error("replyToMessage not stubbed for this test");
39
+ },
40
+ getAttachment: async () => null,
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ // ─── start/stop: timer lifecycle ───────────────────────────────────────────────
46
+
47
+ describe("createChatPoller start/stop", () => {
48
+ it("start() invokes pollOnce on the configured interval, and stop() halts it", async () => {
49
+ const thread = makeThread("thread-timer");
50
+ const claimMessage = mock(async () => null);
51
+ const client = makeFakeClient({
52
+ listThreads: async () => ({
53
+ threads: [thread],
54
+ total: 1,
55
+ limit: 50,
56
+ offset: 0,
57
+ }),
58
+ claimMessage,
59
+ });
60
+ const runner = mock(async () => ({ result: "ok" }));
61
+
62
+ const poller = createChatPoller({ client, runner, intervalMs: 10 });
63
+ poller.start();
64
+
65
+ // Let a couple of intervals elapse.
66
+ await new Promise((resolve) => setTimeout(resolve, 100));
67
+ poller.stop();
68
+ const callsAtStop = claimMessage.mock.calls.length;
69
+ expect(callsAtStop).toBeGreaterThan(0);
70
+
71
+ // No further polling after stop().
72
+ await new Promise((resolve) => setTimeout(resolve, 100));
73
+ expect(claimMessage.mock.calls.length).toBe(callsAtStop);
74
+ });
75
+
76
+ it("start() is a no-op when already running (does not create a second timer)", async () => {
77
+ const thread = makeThread("thread-timer-2");
78
+ const claimMessage = mock(async () => null);
79
+ const client = makeFakeClient({
80
+ listThreads: async () => ({
81
+ threads: [thread],
82
+ total: 1,
83
+ limit: 50,
84
+ offset: 0,
85
+ }),
86
+ claimMessage,
87
+ });
88
+ const runner = mock(async () => ({ result: "ok" }));
89
+
90
+ const poller = createChatPoller({ client, runner, intervalMs: 10 });
91
+ poller.start();
92
+ poller.start(); // second call should be a no-op guard
93
+
94
+ await new Promise((resolve) => setTimeout(resolve, 100));
95
+ poller.stop();
96
+
97
+ // If a second timer had been created, calls would double up per tick.
98
+ // Just assert it ran and stopped cleanly without throwing.
99
+ expect(claimMessage.mock.calls.length).toBeGreaterThan(0);
100
+ });
101
+
102
+ it("stop() is a no-op when the poller was never started", () => {
103
+ const client = makeFakeClient();
104
+ const runner = mock(async () => ({ result: "ok" }));
105
+ const poller = createChatPoller({ client, runner });
106
+
107
+ expect(() => poller.stop()).not.toThrow();
108
+ });
109
+ });
package/chat-poller.ts ADDED
@@ -0,0 +1,401 @@
1
+ /**
2
+ * agent/src/runtime/chat-poller.ts
3
+ *
4
+ * Chat poll loop — polls the chat service for pending user messages, runs them
5
+ * through Claude, and posts replies back.
6
+ *
7
+ * The poll loop:
8
+ * 1. Lists threads for the agent (via ChatServiceClient.listThreads)
9
+ * 2. For each thread, attempts to claim the next unclaimed user message
10
+ * 3. For a claimed message: runs it through the Claude runner with
11
+ * `chat:<threadId>` as the session key, then posts the reply
12
+ *
13
+ * Error isolation: failures on individual threads are caught and logged; other
14
+ * threads continue processing.
15
+ */
16
+
17
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
18
+ import { join } from "node:path";
19
+ import type { ClaudeRunResult, ProgressCallback } from "./claude.ts";
20
+ import type { ChatServiceClient } from "./http-chat-service-client.ts";
21
+ import { parseMarkers } from "./markers.ts";
22
+ import {
23
+ synthesizeSpeech,
24
+ transcribeAudio,
25
+ type VoiceConfig,
26
+ } from "./voice.ts";
27
+
28
+ // ─── Types ────────────────────────────────────────────────────────────────────
29
+
30
+ type ChatRunner = (
31
+ message: string,
32
+ sessionKey?: string,
33
+ onProgress?: ProgressCallback,
34
+ signal?: AbortSignal,
35
+ ) => Promise<ClaudeRunResult>;
36
+
37
+ /** DI seam for STT — defaults to `transcribeAudio` from ./voice.ts. */
38
+ export type TranscribeAudioFn = typeof transcribeAudio;
39
+ /** DI seam for TTS — defaults to `synthesizeSpeech` from ./voice.ts. */
40
+ export type SynthesizeSpeechFn = typeof synthesizeSpeech;
41
+
42
+ /**
43
+ * Audio attachment extensions recognized as voice notes. Detected by
44
+ * filename extension (not mimetype — the chat service doesn't surface one)
45
+ * against the same set of formats Slack's voice-note handling accepts.
46
+ */
47
+ const AUDIO_EXTENSION_REGEX = /\.(webm|ogg|wav|m4a|mp3)$/i;
48
+
49
+ /**
50
+ * Heartbeat cadence while a reply is in flight.
51
+ *
52
+ * Interval-driven, NOT progress-driven. A single long-running tool call (a
53
+ * multi-minute `Bash`, say) emits one stream event and then nothing at all
54
+ * until it returns, so a heartbeat tied to Claude turns goes silent during
55
+ * exactly the long wait it exists to cover. Liveness is a property of the
56
+ * agent process, not of the model's output cadence — so it gets its own
57
+ * timer. 3s keeps proof of life well inside the admin UI's poll interval.
58
+ */
59
+ const HEARTBEAT_INTERVAL_MS = 3_000;
60
+
61
+ interface ChatPollerOptions {
62
+ client: ChatServiceClient;
63
+ runner: ChatRunner;
64
+ /** Poll interval in ms. Default: 5000 */
65
+ intervalMs?: number;
66
+ /** Heartbeat cadence in ms while a reply is in flight. Default: 3000 */
67
+ heartbeatIntervalMs?: number;
68
+ /**
69
+ * Agent workspace directory. When set, attachments on claimed messages are
70
+ * pulled into `<workspaceDir>/uploads/` so Claude can Read them.
71
+ */
72
+ workspaceDir?: string;
73
+ /** Injected for tests so timer behavior is deterministic. */
74
+ setIntervalFn?: typeof setInterval;
75
+ /** Injected for tests so timer behavior is deterministic. */
76
+ clearIntervalFn?: typeof clearInterval;
77
+ /** STT: transcribes an audio attachment. Default: transcribeAudio from ./voice.ts. */
78
+ transcribeAudioFn?: TranscribeAudioFn;
79
+ /** TTS: synthesizes a [speak:] marker's text to audio. Default: synthesizeSpeech from ./voice.ts. */
80
+ synthesizeSpeechFn?: SynthesizeSpeechFn;
81
+ /** Voice provider config (Groq/whisper-svc for STT, ElevenLabs/Piper for TTS). */
82
+ voiceConfig?: VoiceConfig;
83
+ }
84
+
85
+ interface ChatPoller {
86
+ /** Start the poll interval. */
87
+ start(): void;
88
+ /** Stop the poll interval. */
89
+ stop(): void;
90
+ /** Run a single poll iteration. Exposed for testing. */
91
+ pollOnce(): Promise<void>;
92
+ }
93
+
94
+ // ─── createChatPoller ─────────────────────────────────────────────────────────
95
+
96
+ export function createChatPoller(opts: ChatPollerOptions): ChatPoller {
97
+ const {
98
+ client,
99
+ runner,
100
+ intervalMs = 5_000,
101
+ heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS,
102
+ workspaceDir,
103
+ setIntervalFn = setInterval,
104
+ clearIntervalFn = clearInterval,
105
+ transcribeAudioFn = transcribeAudio,
106
+ synthesizeSpeechFn = synthesizeSpeech,
107
+ voiceConfig = {},
108
+ } = opts;
109
+
110
+ let timer: ReturnType<typeof setInterval> | undefined;
111
+ /** Guards against a slow poll overlapping the next interval tick. */
112
+ let pollInFlight = false;
113
+
114
+ async function processThread(threadId: string): Promise<void> {
115
+ const message = await client.claimMessage(threadId);
116
+ if (!message) return; // no unclaimed messages — no-op
117
+
118
+ const sessionKey = `chat:${threadId}`;
119
+
120
+ // Pull an attachment into the workspace so Claude can Read it.
121
+ let runnerMessage = message.body;
122
+ if (message.attachmentFilename && workspaceDir) {
123
+ try {
124
+ const bytes = await client.getAttachment(threadId, message.id);
125
+ if (bytes) {
126
+ const safeFilename = message.attachmentFilename.replace(
127
+ /[^\w.-]+/g,
128
+ "_",
129
+ );
130
+ const uploadsDir = join(workspaceDir, "uploads");
131
+ await mkdir(uploadsDir, { recursive: true });
132
+ const filePath = join(uploadsDir, `${message.id}-${safeFilename}`);
133
+ await writeFile(filePath, bytes);
134
+
135
+ // Voice note: transcribe and replace the runner message entirely
136
+ // (same convention Slack's buildPromptWithFiles uses — the
137
+ // transcript stands in for the message body, it isn't appended to
138
+ // it). Any failure (no fn injected, non-audio extension, a thrown
139
+ // transcribeAudioFn, or a null/blank transcript) falls through to
140
+ // the generic attached-file note below.
141
+ let transcript: string | null = null;
142
+ if (transcribeAudioFn && AUDIO_EXTENSION_REGEX.test(safeFilename)) {
143
+ try {
144
+ transcript = await transcribeAudioFn(filePath, voiceConfig);
145
+ } catch (err) {
146
+ console.error(
147
+ `[chat-poller] transcription failed for thread ${threadId} message ${message.id}:`,
148
+ err instanceof Error ? err.message : String(err),
149
+ );
150
+ transcript = null;
151
+ }
152
+ }
153
+
154
+ runnerMessage =
155
+ transcript && transcript.trim().length > 0
156
+ ? `[voice transcript: ${transcript.trim()}]`
157
+ : `${message.body}\n\n[Attached file: ${safeFilename} saved at ${filePath}]`;
158
+ }
159
+ } catch (err) {
160
+ console.error(
161
+ `[chat-poller] failed to pull attachment for thread ${threadId} message ${message.id}:`,
162
+ err instanceof Error ? err.message : String(err),
163
+ );
164
+ }
165
+ }
166
+
167
+ // Bidirectional heartbeat: the same 3s tick that proves liveness also
168
+ // learns whether a cancel was requested (via cancelRequestedAt on the
169
+ // returned message) and, if so, aborts the in-flight run. This keeps the
170
+ // architecture pull-only — no second polling loop, no inbound HTTP surface
171
+ // on the agent. Failures are swallowed — a missed heartbeat must never
172
+ // abort the reply itself.
173
+ const abortController = new AbortController();
174
+ let cancelRequested = false;
175
+ const sendHeartbeat = () => {
176
+ client
177
+ .heartbeat(threadId, message.id)
178
+ .then((result) => {
179
+ if (result?.cancelRequested && !abortController.signal.aborted) {
180
+ cancelRequested = true;
181
+ abortController.abort();
182
+ }
183
+ })
184
+ .catch((err) => {
185
+ console.error(
186
+ `[chat-poller] heartbeat failed for thread ${threadId} message ${message.id}:`,
187
+ err instanceof Error ? err.message : String(err),
188
+ );
189
+ });
190
+ };
191
+
192
+ let runResult: Awaited<ReturnType<ChatRunner>> | undefined;
193
+ let errorKind: string | undefined;
194
+ const heartbeatTimer = setIntervalFn(sendHeartbeat, heartbeatIntervalMs);
195
+ try {
196
+ runResult = await runner(
197
+ runnerMessage,
198
+ sessionKey,
199
+ undefined,
200
+ abortController.signal,
201
+ );
202
+ } catch (err) {
203
+ // The runner failed. The message must NOT be left claimed forever with
204
+ // no reaper — always fall through to replyToMessage below with an
205
+ // errorKind. A cancel is a deliberate user action, not a stall.
206
+ const name = err instanceof Error ? err.name : "";
207
+ errorKind =
208
+ cancelRequested || name === "ClaudeAbortedError"
209
+ ? "cancelled"
210
+ : "stalled";
211
+ console.error(
212
+ `[chat-poller] runner failed for thread ${threadId} (${errorKind}):`,
213
+ err instanceof Error ? err.message : String(err),
214
+ );
215
+ } finally {
216
+ clearIntervalFn(heartbeatTimer);
217
+ }
218
+
219
+ // Derive the reply body + errorKind. The poller ALWAYS replies — a
220
+ // streamIncomplete run has an empty result string, so posting it verbatim
221
+ // would be an EMPTY reply; a thrown runner would otherwise leave the
222
+ // message claimed forever. Every branch produces a non-empty body.
223
+ const {
224
+ body,
225
+ tokens,
226
+ costUsd,
227
+ errorKind: finalErrorKind,
228
+ } = deriveReply(runResult, errorKind);
229
+
230
+ // TTS: extract a [speak:] marker (if any) and synthesize it to audio.
231
+ // The marker is stripped from the posted body whenever one is found,
232
+ // regardless of whether synthesis itself succeeds — only the attachment
233
+ // is conditional on success (AC2 stripping + AC4 graceful degradation).
234
+ // A marker-only reply that produced no audio falls back to posting the
235
+ // spoken text, so the user never gets an empty bubble (see below).
236
+ let replyBody = body;
237
+ let attachmentFilename: string | undefined;
238
+ let attachmentSize: number | undefined;
239
+ let attachmentBytes: Uint8Array | undefined;
240
+
241
+ const { cleaned, markers } = parseMarkers(body);
242
+ let speakText: string | undefined;
243
+ for (const marker of markers) {
244
+ if (marker.type === "speak") {
245
+ speakText = marker.text;
246
+ break;
247
+ }
248
+ }
249
+
250
+ if (speakText !== undefined) {
251
+ replyBody = cleaned;
252
+ if (synthesizeSpeechFn) {
253
+ try {
254
+ const audioPath = await synthesizeSpeechFn(speakText, voiceConfig);
255
+ if (audioPath) {
256
+ const audioBytes = await readFile(audioPath);
257
+ attachmentBytes = new Uint8Array(audioBytes);
258
+ attachmentFilename = audioPath.split("/").pop() ?? "response.mp3";
259
+ attachmentSize = attachmentBytes.length;
260
+ }
261
+ } catch (err) {
262
+ console.error(
263
+ `[chat-poller] speech synthesis failed for thread ${threadId} message ${message.id}:`,
264
+ err instanceof Error ? err.message : String(err),
265
+ );
266
+ }
267
+ }
268
+
269
+ // Text fallback: a marker-only reply (`[speak:...]` and nothing else)
270
+ // strips down to an empty body, so if no audio was produced the user
271
+ // would get a blank bubble. Post the spoken text instead — the same
272
+ // contract as slack.ts's postSpeakTextFallback. When `cleaned` still has
273
+ // text, that text IS the graceful degradation; leave it alone.
274
+ if (attachmentBytes === undefined && replyBody.trim().length === 0) {
275
+ console.warn(
276
+ `[chat-poller] no audio for [speak:] marker on thread ${threadId} message ${message.id} — falling back to speak text`,
277
+ );
278
+ replyBody = speakText;
279
+ }
280
+ }
281
+
282
+ try {
283
+ await client.replyToMessage(threadId, message.id, {
284
+ body: replyBody,
285
+ tokens,
286
+ costUsd,
287
+ errorKind: finalErrorKind,
288
+ attachmentFilename,
289
+ attachmentSize,
290
+ attachmentBytes,
291
+ });
292
+ } catch (err) {
293
+ console.error(
294
+ `[chat-poller] replyToMessage failed for thread ${threadId} message ${message.id}:`,
295
+ err instanceof Error ? err.message : String(err),
296
+ );
297
+ }
298
+ }
299
+
300
+ async function pollOnce(): Promise<void> {
301
+ let listResult: Awaited<ReturnType<typeof client.listThreads>>;
302
+ try {
303
+ listResult = await client.listThreads({});
304
+ } catch (err) {
305
+ console.error(
306
+ "[chat-poller] listThreads failed:",
307
+ err instanceof Error ? err.message : String(err),
308
+ );
309
+ return;
310
+ }
311
+
312
+ if (listResult.threads.length === 0) return;
313
+
314
+ // Process each thread independently — errors on one must not block others
315
+ await Promise.all(
316
+ listResult.threads.map((thread) =>
317
+ processThread(thread.id).catch((err) => {
318
+ console.error(
319
+ `[chat-poller] error processing thread ${thread.id}:`,
320
+ err instanceof Error ? err.message : String(err),
321
+ );
322
+ }),
323
+ ),
324
+ );
325
+ }
326
+
327
+ return {
328
+ start() {
329
+ if (timer) return; // already running
330
+ // Skip a tick when the previous iteration is still running. A poll that
331
+ // outlives its interval (a long claim + reply cycle always does) would
332
+ // otherwise stack concurrent iterations for as long as it runs.
333
+ timer = setIntervalFn(() => {
334
+ if (pollInFlight) return;
335
+ pollInFlight = true;
336
+ void pollOnce().finally(() => {
337
+ pollInFlight = false;
338
+ });
339
+ }, intervalMs);
340
+ },
341
+ stop() {
342
+ if (timer) {
343
+ clearIntervalFn(timer);
344
+ timer = undefined;
345
+ }
346
+ },
347
+ pollOnce,
348
+ };
349
+ }
350
+
351
+ interface DerivedReply {
352
+ body: string;
353
+ tokens?: unknown;
354
+ costUsd?: number;
355
+ errorKind?: string;
356
+ }
357
+
358
+ /**
359
+ * Turn a run outcome into a reply that is never empty. `errorKind` is the
360
+ * failure-mode already inferred by the caller (`cancelled`/`stalled` for a
361
+ * thrown runner, or undefined for a completed run). Rules:
362
+ * - cancelled: fixed body, keep errorKind.
363
+ * - runner threw (no runResult): keep errorKind, synthesize a body.
364
+ * - streamIncomplete or empty result on a clean finish: errorKind=incomplete
365
+ * with a non-empty fallback body (never post an empty bubble).
366
+ * - otherwise: the real reply body + usage.
367
+ */
368
+ export function deriveReply(
369
+ runResult: ClaudeRunResult | undefined,
370
+ errorKind: string | undefined,
371
+ ): DerivedReply {
372
+ if (errorKind === "cancelled") return { body: "Cancelled.", errorKind };
373
+
374
+ if (!runResult) {
375
+ // Runner threw — errorKind is "stalled" (cancelled handled above).
376
+ return { body: "The run failed unexpectedly.", errorKind };
377
+ }
378
+
379
+ const { usage: tokens, totalCostUsd: costUsd } = runResult;
380
+
381
+ if (runResult.streamIncomplete) {
382
+ return {
383
+ body: runResult.result || "The reply was cut off before it finished.",
384
+ tokens,
385
+ costUsd,
386
+ errorKind: "incomplete",
387
+ };
388
+ }
389
+
390
+ if (runResult.result.trim().length > 0) {
391
+ return { body: runResult.result, tokens, costUsd, errorKind };
392
+ }
393
+
394
+ // Clean finish but empty body — treat as incomplete rather than an empty bubble.
395
+ return {
396
+ body: "The reply was empty.",
397
+ tokens,
398
+ costUsd,
399
+ errorKind: "incomplete",
400
+ };
401
+ }