@tamsensedev/runner 0.0.0-stage → 0.2.1

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 (82) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +47 -3
  3. package/dist/cli.js +2914 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/recorder.cjs +396 -0
  6. package/dist/recorder.cjs.map +1 -0
  7. package/library/.claude-plugin/plugin.json +5 -0
  8. package/library/THIRD_PARTY_NOTICES.md +123 -0
  9. package/library/agents/code-architect.md +67 -0
  10. package/library/agents/code-explorer.md +61 -0
  11. package/library/agents/code-reviewer.md +73 -0
  12. package/library/agents/code-simplifier.md +62 -0
  13. package/library/agents/design-critic.md +52 -0
  14. package/library/agents/second-opinion.md +64 -0
  15. package/library/agents/security-auditor.md +86 -0
  16. package/library/agents/silent-failure-hunter.md +70 -0
  17. package/library/agents/test-coverage-analyzer.md +78 -0
  18. package/library/agents/test-runner.md +48 -0
  19. package/library/agents/ui-tester.md +49 -0
  20. package/library/licenses/Apache-2.0.txt +202 -0
  21. package/library/skills/accessibility/SKILL.md +144 -0
  22. package/library/skills/accessibility/references/wcag-checks.md +168 -0
  23. package/library/skills/addressing-review-feedback/SKILL.md +109 -0
  24. package/library/skills/app-screens/SKILL.md +96 -0
  25. package/library/skills/app-screens/references/shell-kpis-charts.md +108 -0
  26. package/library/skills/app-screens/references/states-density-phone.md +54 -0
  27. package/library/skills/app-screens/references/tables-forms-settings.md +104 -0
  28. package/library/skills/avoiding-generic-ai-design/SKILL.md +92 -0
  29. package/library/skills/avoiding-generic-ai-design/references/tells.md +112 -0
  30. package/library/skills/clean-commits/SKILL.md +85 -0
  31. package/library/skills/design-review/SKILL.md +107 -0
  32. package/library/skills/design-review/references/critique-passes.md +101 -0
  33. package/library/skills/design-review/references/screenshot-procedure.md +121 -0
  34. package/library/skills/differential-security-review/LICENSE +427 -0
  35. package/library/skills/differential-security-review/SKILL.md +105 -0
  36. package/library/skills/differential-security-review/references/adversarial.md +115 -0
  37. package/library/skills/differential-security-review/references/methodology.md +145 -0
  38. package/library/skills/differential-security-review/references/patterns.md +108 -0
  39. package/library/skills/false-positive-check/LICENSE +427 -0
  40. package/library/skills/false-positive-check/SKILL.md +95 -0
  41. package/library/skills/false-positive-check/references/bug-class-verification.md +128 -0
  42. package/library/skills/false-positive-check/references/deep-verification.md +113 -0
  43. package/library/skills/false-positive-check/references/evidence-templates.md +93 -0
  44. package/library/skills/false-positive-check/references/false-positive-patterns.md +122 -0
  45. package/library/skills/false-positive-check/references/gate-reviews.md +36 -0
  46. package/library/skills/false-positive-check/references/standard-verification.md +80 -0
  47. package/library/skills/focused-implementation/SKILL.md +104 -0
  48. package/library/skills/implementation-plan/SKILL.md +66 -0
  49. package/library/skills/interface-design/SKILL.md +144 -0
  50. package/library/skills/interface-design/references/craft-defaults.md +130 -0
  51. package/library/skills/interface-design/references/new-direction.md +62 -0
  52. package/library/skills/interface-design/references/writing.md +52 -0
  53. package/library/skills/interface-polish/SKILL.md +103 -0
  54. package/library/skills/interface-polish/references/detail-rules.md +154 -0
  55. package/library/skills/interface-polish/references/worst-case-data.md +158 -0
  56. package/library/skills/motion-design/SKILL.md +106 -0
  57. package/library/skills/motion-design/references/recipes.md +243 -0
  58. package/library/skills/postgres-performance/SKILL.md +97 -0
  59. package/library/skills/postgres-performance/references/rules.md +265 -0
  60. package/library/skills/project-orientation/SKILL.md +77 -0
  61. package/library/skills/reviewing-changes/SKILL.md +183 -0
  62. package/library/skills/safe-database-changes/SKILL.md +111 -0
  63. package/library/skills/safe-database-changes/references/backfills.md +75 -0
  64. package/library/skills/safe-database-changes/references/migration-tools.md +69 -0
  65. package/library/skills/safe-database-changes/references/postgres-ddl.md +199 -0
  66. package/library/skills/security-review/SKILL.md +132 -0
  67. package/library/skills/security-review/references/checks-by-area.md +107 -0
  68. package/library/skills/security-review/references/do-not-report.md +86 -0
  69. package/library/skills/spec-writing/SKILL.md +144 -0
  70. package/library/skills/systematic-debugging/SKILL.md +130 -0
  71. package/library/skills/systematic-debugging/references/flaky-tests.md +51 -0
  72. package/library/skills/systematic-debugging/references/root-cause-tracing.md +55 -0
  73. package/library/skills/task-interview/SKILL.md +84 -0
  74. package/library/skills/test-driven-development/SKILL.md +116 -0
  75. package/library/skills/test-driven-development/references/writing-good-tests.md +85 -0
  76. package/library/skills/ui-testing/SKILL.md +102 -0
  77. package/library/skills/ui-testing/references/headless-check.md +280 -0
  78. package/library/skills/ui-testing/references/playwright-patterns.md +134 -0
  79. package/library/skills/ui-testing/references/recording.md +57 -0
  80. package/library/skills/verification-before-completion/SKILL.md +113 -0
  81. package/library/skills/web-interface-guidelines/SKILL.md +202 -0
  82. package/package.json +46 -6
package/dist/cli.js ADDED
@@ -0,0 +1,2914 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { existsSync as existsSync4 } from "fs";
5
+ import { homedir as homedir3 } from "os";
6
+ import process5 from "process";
7
+
8
+ // src/api.ts
9
+ var DEFAULT_SERVER = "https://my.tamsense.com";
10
+ var ApiError = class extends Error {
11
+ status;
12
+ code;
13
+ constructor(status, code, message) {
14
+ super(message);
15
+ this.name = "ApiError";
16
+ this.status = status;
17
+ this.code = code;
18
+ }
19
+ };
20
+ function serverOrigin(address) {
21
+ let url;
22
+ try {
23
+ url = new URL(address);
24
+ } catch {
25
+ throw new Error(`"${address}" is not a valid address. Use a full address like ${DEFAULT_SERVER}`);
26
+ }
27
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
28
+ throw new Error(`"${address}" is not a web address. Use a full address like ${DEFAULT_SERVER}`);
29
+ }
30
+ return url.origin;
31
+ }
32
+ function textOf(value) {
33
+ return typeof value === "string" && value.trim() ? value : null;
34
+ }
35
+ async function errorOf(response) {
36
+ const reply2 = await response.json().catch(() => ({}));
37
+ const code = textOf(reply2.data?.code);
38
+ const message = textOf(reply2.message) ?? textOf(reply2.statusMessage) ?? `TAMsense answered with status ${response.status}`;
39
+ return new ApiError(response.status, code, message);
40
+ }
41
+ function createApi(server, token = null, fetchImpl = fetch) {
42
+ async function send(method, path, headers, body) {
43
+ const all = { accept: "application/json", ...headers };
44
+ if (token) {
45
+ all.authorization = `Bearer ${token}`;
46
+ }
47
+ let response;
48
+ try {
49
+ response = await fetchImpl(new URL(path, server), { method, headers: all, body, ...body instanceof ReadableStream ? { duplex: "half" } : {} });
50
+ } catch (error) {
51
+ throw new Error(`Could not reach ${server}. Check your internet connection.`, { cause: error });
52
+ }
53
+ if (!response.ok) {
54
+ throw await errorOf(response);
55
+ }
56
+ return await response.json();
57
+ }
58
+ return {
59
+ server,
60
+ request: (method, path, body) => send(method, path, body ? { "content-type": "application/json" } : {}, body && JSON.stringify(body)),
61
+ upload: (method, path, body, contentType) => send(method, path, { "content-type": contentType }, body)
62
+ };
63
+ }
64
+
65
+ // src/browser.ts
66
+ import { spawn } from "child_process";
67
+ import { existsSync } from "fs";
68
+ import { mkdir, readFile, rm, writeFile } from "fs/promises";
69
+ import { delimiter, dirname, join } from "path";
70
+ import { createInterface } from "readline";
71
+
72
+ // src/commands.ts
73
+ import { parseArgs } from "util";
74
+ var LOGIN_COMMAND = "npx @tamsensedev/runner@latest login";
75
+ var START_COMMAND = "npx @tamsensedev/runner@latest start";
76
+ var BROWSER_COMMAND = "npx @tamsensedev/runner@latest browser";
77
+ var DEFAULT_PARALLEL = 2;
78
+ var MAX_PARALLEL = 4;
79
+ var HELP_TEXT = `Connects this computer to TAMsense so it can work on your tasks.
80
+
81
+ Usage:
82
+ ${LOGIN_COMMAND} connect this computer
83
+ ${START_COMMAND} take your tasks and work on them until you press Ctrl+C
84
+ ${BROWSER_COMMAND} install the headless browser for interface checks, or show it
85
+ ${BROWSER_COMMAND} remove delete that browser
86
+ npx @tamsensedev/runner@latest status show the connection
87
+ npx @tamsensedev/runner@latest logout disconnect this computer
88
+
89
+ Login options:
90
+ --name <name> how this computer is called in TAMsense (default: its network name)
91
+ --server <url> TAMsense address (default: ${DEFAULT_SERVER})
92
+
93
+ Start options:
94
+ --parallel <n> how many tasks to work on at once, 1 to ${MAX_PARALLEL} (default: ${DEFAULT_PARALLEL}, or TAMSENSE_PARALLEL)`;
95
+ function parallelFor(flag, env) {
96
+ const [source, value] = flag !== void 0 ? ["--parallel", flag] : ["TAMSENSE_PARALLEL", env?.trim() || void 0];
97
+ if (value === void 0) {
98
+ return DEFAULT_PARALLEL;
99
+ }
100
+ const count = Number(value);
101
+ if (!/^\d+$/.test(value) || count < 1 || count > MAX_PARALLEL) {
102
+ throw new Error(`${source} must be a whole number from 1 to ${MAX_PARALLEL}, got "${value}".`);
103
+ }
104
+ return count;
105
+ }
106
+ function parseCommand(argv, env = {}) {
107
+ const { positionals, values } = parseArgs({
108
+ args: argv,
109
+ allowPositionals: true,
110
+ strict: true,
111
+ options: {
112
+ server: { type: "string" },
113
+ name: { type: "string" },
114
+ parallel: { type: "string" },
115
+ help: { type: "boolean" }
116
+ }
117
+ });
118
+ const [name, detail] = positionals;
119
+ if (values.help) {
120
+ return { name: "help" };
121
+ }
122
+ if (name === "browser" && (detail === void 0 || detail === "remove") && positionals.length <= 2) {
123
+ return { name: "browser", remove: detail === "remove" };
124
+ }
125
+ if (positionals.length !== 1) {
126
+ return { name: "help" };
127
+ }
128
+ if (name === "login") {
129
+ return { name: "login", server: values.server ?? null, deviceName: values.name ?? null };
130
+ }
131
+ if (name === "start") {
132
+ return { name: "start", parallel: parallelFor(values.parallel, env.TAMSENSE_PARALLEL) };
133
+ }
134
+ if (name === "status" || name === "logout") {
135
+ return { name };
136
+ }
137
+ return { name: "help" };
138
+ }
139
+
140
+ // src/browser.ts
141
+ var PLAYWRIGHT_VERSION = "1.64.1";
142
+ var BROWSER_NAMES = ["chromium-headless-shell", "chromium"];
143
+ var MARKER_FILE = "tamsense-browser.json";
144
+ var OUTPUT_TAIL_LINES = 40;
145
+ var ERROR_TAIL_LINES = 3;
146
+ var ANSI_PATTERN = /\u001B\[[\d;]*m/g;
147
+ var OFFLINE_PATTERN = /ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNREFUSED|ECONNRESET|ENETUNREACH|getaddrinfo/i;
148
+ var BROWSER_QUESTION = "Interface checks need a headless browser (about 150 MB, installed once into ~/.tamsense). Install it now? [Y/n] ";
149
+ var BROWSER_HINT = `Interface checks can use a headless browser. To install it (about 150 MB), run ${BROWSER_COMMAND}`;
150
+ var OFFLINE_TEXT = `No connection to download it. Check your internet connection and run ${BROWSER_COMMAND} again.`;
151
+ function browserFolders(home) {
152
+ const prefix = join(home, ".tamsense", "playwright");
153
+ return {
154
+ prefix,
155
+ packageDir: join(prefix, "node_modules", "playwright-core"),
156
+ browsers: join(home, ".tamsense", "browsers"),
157
+ marker: join(prefix, MARKER_FILE)
158
+ };
159
+ }
160
+ async function installedBrowser(home) {
161
+ const folders = browserFolders(home);
162
+ let saved;
163
+ try {
164
+ saved = JSON.parse(await readFile(folders.marker, "utf8"));
165
+ } catch {
166
+ return null;
167
+ }
168
+ const complete = saved.version === PLAYWRIGHT_VERSION && typeof saved.browser === "string" && existsSync(join(folders.packageDir, "package.json")) && existsSync(folders.browsers);
169
+ return complete ? { packageDir: folders.packageDir, browsers: folders.browsers, version: PLAYWRIGHT_VERSION, browser: String(saved.browser) } : null;
170
+ }
171
+ function browserEnv(browser) {
172
+ return { TAMSENSE_PLAYWRIGHT: browser.packageDir, TAMSENSE_PLAYWRIGHT_BROWSERS: browser.browsers };
173
+ }
174
+ function browserStatusText(browser) {
175
+ return `The headless browser is installed: ${browser.browser} with playwright-core ${browser.version}, in ${dirname(browser.browsers)}. To remove it, run ${BROWSER_COMMAND} remove`;
176
+ }
177
+ function findNpm(nodePath, env, exists) {
178
+ const pathKey = Object.keys(env).find((name) => name.toUpperCase() === "PATH") ?? "PATH";
179
+ const folders = [dirname(nodePath), ...(env[pathKey] ?? "").split(delimiter).filter(Boolean)];
180
+ for (const folder of folders) {
181
+ for (const cli of [join(folder, "node_modules", "npm", "bin", "npm-cli.js"), join(folder, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js")]) {
182
+ if (exists(cli)) {
183
+ return cli;
184
+ }
185
+ }
186
+ }
187
+ return null;
188
+ }
189
+ var runProcess = (command, args, options) => new Promise((resolve, reject) => {
190
+ const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
191
+ const tail = [];
192
+ for (const stream of [child.stdout, child.stderr]) {
193
+ createInterface({ input: stream }).on("line", (line) => {
194
+ const text2 = line.replace(ANSI_PATTERN, "").trim();
195
+ if (text2) {
196
+ tail.push(text2);
197
+ tail.splice(0, Math.max(0, tail.length - OUTPUT_TAIL_LINES));
198
+ options.onLine(text2);
199
+ }
200
+ });
201
+ }
202
+ child.on("error", reject);
203
+ child.on("close", (code) => resolve({ code: code ?? 1, output: tail.join("\n") }));
204
+ });
205
+ function failureText(what, result) {
206
+ if (OFFLINE_PATTERN.test(result.output)) {
207
+ return `${what}: ${OFFLINE_TEXT}`;
208
+ }
209
+ const lastLines = result.output.split("\n").slice(-ERROR_TAIL_LINES).join(" ");
210
+ return `${what} (exit code ${result.code})${lastLines ? `: ${lastLines}` : "."}`;
211
+ }
212
+ async function installBrowser(tools) {
213
+ const folders = browserFolders(tools.home);
214
+ const npmCli = findNpm(tools.nodePath, tools.env, tools.exists);
215
+ if (!npmCli) {
216
+ throw new Error(`npm was not found next to ${tools.nodePath} or on PATH. Install Node.js with npm and run ${BROWSER_COMMAND} again.`);
217
+ }
218
+ const showLine = (line) => tools.print(` ${line}`);
219
+ await rm(folders.marker, { force: true });
220
+ await mkdir(folders.prefix, { recursive: true });
221
+ tools.print(`Step 1 of 2: downloading playwright-core ${PLAYWRIGHT_VERSION}\u2026`);
222
+ const npmArgs = [npmCli, "install", "--no-save", "--no-audit", "--no-fund", "--prefix", folders.prefix, `playwright-core@${PLAYWRIGHT_VERSION}`];
223
+ const npm = await tools.run(tools.nodePath, npmArgs, { cwd: folders.prefix, env: tools.env, onLine: showLine });
224
+ if (npm.code !== 0) {
225
+ throw new Error(failureText("npm could not download playwright-core", npm));
226
+ }
227
+ tools.print("Step 2 of 2: downloading the browser, about 150 MB\u2026");
228
+ const env = { ...tools.env, PLAYWRIGHT_BROWSERS_PATH: folders.browsers };
229
+ let result = { code: 1, output: "" };
230
+ for (const browser of BROWSER_NAMES) {
231
+ result = await tools.run(tools.nodePath, [join(folders.packageDir, "cli.js"), "install", browser], { cwd: folders.prefix, env, onLine: showLine });
232
+ if (result.code === 0) {
233
+ await writeFile(folders.marker, `${JSON.stringify({ version: PLAYWRIGHT_VERSION, browser }, null, 4)}
234
+ `);
235
+ tools.print(`Installed ${browser} into ${folders.browsers}.`);
236
+ return { packageDir: folders.packageDir, browsers: folders.browsers, version: PLAYWRIGHT_VERSION, browser };
237
+ }
238
+ if (OFFLINE_PATTERN.test(result.output)) {
239
+ break;
240
+ }
241
+ }
242
+ throw new Error(failureText("Could not download the browser", result));
243
+ }
244
+ async function removeBrowser(home) {
245
+ const folders = browserFolders(home);
246
+ const present2 = existsSync(folders.prefix) || existsSync(folders.browsers);
247
+ await rm(folders.prefix, { recursive: true, force: true });
248
+ await rm(folders.browsers, { recursive: true, force: true });
249
+ return present2;
250
+ }
251
+ async function offerBrowser(tools) {
252
+ if (tools.declined) {
253
+ return null;
254
+ }
255
+ if (!tools.interactive) {
256
+ tools.print(BROWSER_HINT);
257
+ return null;
258
+ }
259
+ if (!await tools.ask(BROWSER_QUESTION)) {
260
+ await tools.rememberDecline();
261
+ tools.print(`Not installed. To install it later, run ${BROWSER_COMMAND}`);
262
+ return null;
263
+ }
264
+ try {
265
+ return await tools.install();
266
+ } catch (error) {
267
+ tools.print(`Could not install the browser: ${error instanceof Error ? error.message : String(error)}`);
268
+ tools.print("Tasks still run without it.");
269
+ return null;
270
+ }
271
+ }
272
+
273
+ // src/capabilities.ts
274
+ import { homedir } from "os";
275
+ import { query } from "@anthropic-ai/claude-agent-sdk";
276
+ var PROBE_TIMEOUT_MS = 6e4;
277
+ var NO_ANSWER_TEXT = "Claude Code did not start in time.";
278
+ var STOPPED_TEXT = "Stopped before Claude Code answered.";
279
+ var API_KEY_SOURCES = /* @__PURE__ */ new Set(["ANTHROPIC_API_KEY", "apiKeyHelper", "/login managed key", "user", "project", "org", "temporary"]);
280
+ var LOGIN_SOURCES = /* @__PURE__ */ new Set(["none", "oauth"]);
281
+ var ACCOUNT_WORDS = {
282
+ subscription: "on your subscription",
283
+ api_key: "on an API key"
284
+ };
285
+ function accountOfKeySource(source) {
286
+ if (source && API_KEY_SOURCES.has(source)) {
287
+ return "api_key";
288
+ }
289
+ return source && LOGIN_SOURCES.has(source) ? "subscription" : null;
290
+ }
291
+ function accountOfInfo(info) {
292
+ if (info.apiProvider && info.apiProvider !== "firstParty") {
293
+ return null;
294
+ }
295
+ if (info.apiKeySource && API_KEY_SOURCES.has(info.apiKeySource)) {
296
+ return "api_key";
297
+ }
298
+ return info.subscriptionType ? "subscription" : null;
299
+ }
300
+ function sessionOf(init) {
301
+ return {
302
+ model: init.model || null,
303
+ account: accountOfKeySource(init.apiKeySource),
304
+ claudeVersion: init.claude_code_version || null
305
+ };
306
+ }
307
+ function capabilitiesOf(init) {
308
+ return {
309
+ account: accountOfInfo(init.account),
310
+ claudeVersion: init.claude_code_version || null,
311
+ models: init.models.map((model) => ({ value: model.value, displayName: model.displayName, description: model.description }))
312
+ };
313
+ }
314
+ function isAlias(value) {
315
+ return value !== "default" && !value.startsWith("claude-");
316
+ }
317
+ function capabilitiesLine(capabilities) {
318
+ const version = capabilities.claudeVersion ? `Claude Code ${capabilities.claudeVersion}` : "Claude Code";
319
+ const account = capabilities.account ? ` ${ACCOUNT_WORDS[capabilities.account]}` : "";
320
+ const names = capabilities.models.filter((model) => isAlias(model.value)).map((model) => model.displayName);
321
+ return names.length > 0 ? `${version}${account}: ${names.join(", ")}` : `${version}${account}`;
322
+ }
323
+ async function probeCapabilities(stop) {
324
+ stop?.throwIfAborted();
325
+ let release = () => {
326
+ };
327
+ const released = new Promise((resolve) => {
328
+ release = resolve;
329
+ });
330
+ const nothing = {
331
+ [Symbol.asyncIterator]: () => ({
332
+ next: async () => {
333
+ await released;
334
+ return { done: true, value: void 0 };
335
+ }
336
+ })
337
+ };
338
+ const session = query({ prompt: nothing, options: { cwd: homedir(), settingSources: ["user"], persistSession: false, strictMcpConfig: true } });
339
+ const timeout = AbortSignal.timeout(PROBE_TIMEOUT_MS);
340
+ const giveUp = stop ? AbortSignal.any([stop, timeout]) : timeout;
341
+ const gaveUp = new Promise((_, reject) => {
342
+ giveUp.addEventListener("abort", () => reject(new Error(stop?.aborted ? STOPPED_TEXT : NO_ANSWER_TEXT)), { once: true });
343
+ });
344
+ try {
345
+ return capabilitiesOf(await Promise.race([session.initializationResult(), gaveUp]));
346
+ } finally {
347
+ release();
348
+ session.close();
349
+ }
350
+ }
351
+
352
+ // src/claude.ts
353
+ import { fileURLToPath } from "url";
354
+ import { createSdkMcpServer, query as query2, tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
355
+ import { z as z2 } from "zod";
356
+
357
+ // src/claudeResult.ts
358
+ var MAX_ACTIVITY_LENGTH = 120;
359
+ var FILE_TOOLS = /* @__PURE__ */ new Set(["Read", "Edit", "Write", "MultiEdit", "NotebookEdit"]);
360
+ var HELPER_TOOLS = /* @__PURE__ */ new Set(["Task", "Agent"]);
361
+ function shorten(text2) {
362
+ const line = text2.replace(/\s+/g, " ").trim();
363
+ return line.length > MAX_ACTIVITY_LENGTH ? `${line.slice(0, MAX_ACTIVITY_LENGTH - 1)}\u2026` : line;
364
+ }
365
+ function activityLine(tool3, input) {
366
+ const fields = typeof input === "object" && input !== null ? input : {};
367
+ if (tool3 === "Bash" && typeof fields.command === "string") {
368
+ return `$ ${shorten(fields.command)}`;
369
+ }
370
+ if (FILE_TOOLS.has(tool3) && typeof fields.file_path === "string") {
371
+ return `${tool3} ${shorten(fields.file_path)}`;
372
+ }
373
+ if (tool3 === "Skill" && typeof fields.skill === "string") {
374
+ return `Skill ${shorten(fields.skill)}`;
375
+ }
376
+ if (HELPER_TOOLS.has(tool3) && typeof fields.subagent_type === "string") {
377
+ return `Agent ${shorten(fields.subagent_type)}`;
378
+ }
379
+ return tool3;
380
+ }
381
+ function usageOf(modelUsage) {
382
+ const entries = Object.values(modelUsage);
383
+ if (entries.length === 0) {
384
+ return null;
385
+ }
386
+ const sum = (pick) => entries.reduce((total, usage) => total + pick(usage), 0);
387
+ const inputTokens = sum((usage) => usage.inputTokens + usage.cacheReadInputTokens + usage.cacheCreationInputTokens);
388
+ const outputTokens = sum((usage) => usage.outputTokens);
389
+ return { inputTokens, cachedInputTokens: sum((usage) => usage.cacheReadInputTokens), outputTokens, totalTokens: inputTokens + outputTokens };
390
+ }
391
+ function mainModel(modelUsage) {
392
+ const ranked = Object.entries(modelUsage).sort(([, first], [, second]) => second.outputTokens - first.outputTokens);
393
+ return ranked[0]?.[0] ?? null;
394
+ }
395
+ function outcomeOf(result, lastText) {
396
+ const measured = { model: mainModel(result.modelUsage), usage: usageOf(result.modelUsage), verdict: null };
397
+ if (result.subtype === "success") {
398
+ return { completed: !result.is_error, report: result.result || lastText, ...measured };
399
+ }
400
+ return { completed: false, report: result.errors.join("\n") || `Claude stopped early: ${result.subtype}`, ...measured };
401
+ }
402
+
403
+ // src/feed.ts
404
+ var MAX_TEXT_LENGTH = 8e3;
405
+ var MAX_RESULT_LENGTH = 2e3;
406
+ var MAX_SUMMARY_LENGTH = 500;
407
+ var HELPER_TOOLS2 = /* @__PURE__ */ new Set(["Task", "Agent"]);
408
+ var MCP_PREFIX = /^mcp__[^_]+(?:_[^_]+)*__/;
409
+ var ATTACH_LABELS = { attach_screenshot: "Screenshot", attach_replay: "Replay", attach_video: "Video" };
410
+ function clip(text2, max) {
411
+ const trimmed = text2.trim();
412
+ return trimmed.length > max ? `${trimmed.slice(0, max - 1)}\u2026` : trimmed;
413
+ }
414
+ function fieldsOf(input) {
415
+ return typeof input === "object" && input !== null ? input : {};
416
+ }
417
+ function textField(fields, name) {
418
+ const value = fields[name];
419
+ return typeof value === "string" && value.trim() ? value : null;
420
+ }
421
+ function toolDraft(block, parent) {
422
+ const tool3 = (block.name ?? "tool").replace(MCP_PREFIX, "");
423
+ const fields = fieldsOf(block.input);
424
+ const base = { tool: tool3, ref: block.id ?? null, parent, failed: false };
425
+ if (tool3 === "Skill" && textField(fields, "skill")) {
426
+ return { ...base, kind: "skill", text: clip(textField(fields, "skill"), MAX_SUMMARY_LENGTH) };
427
+ }
428
+ if (HELPER_TOOLS2.has(tool3)) {
429
+ const who = textField(fields, "subagent_type") ?? "general-purpose";
430
+ const what = textField(fields, "description");
431
+ return { ...base, kind: "agent", text: clip(what ? `${who}: ${what}` : who, MAX_SUMMARY_LENGTH) };
432
+ }
433
+ if (tool3 === "submit_verdict") {
434
+ const verdict = fields.passed === true ? "Passed" : "Did not pass";
435
+ return { ...base, kind: "tool", text: clip(`${verdict}. ${textField(fields, "report") ?? ""}`, MAX_SUMMARY_LENGTH) };
436
+ }
437
+ if (tool3 === "hand_off") {
438
+ return { ...base, kind: "tool", text: clip(`Hand off to ${textField(fields, "to") ?? "?"}: ${textField(fields, "note") ?? ""}`, MAX_SUMMARY_LENGTH) };
439
+ }
440
+ if (tool3 in ATTACH_LABELS) {
441
+ return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[tool3]}: ${textField(fields, "title") ?? textField(fields, "path") ?? ""}`, MAX_SUMMARY_LENGTH) };
442
+ }
443
+ const command = textField(fields, "command");
444
+ const summary = tool3 === "Bash" && command ? `$ ${command}` : activityLine(tool3, block.input);
445
+ return { ...base, kind: "tool", text: clip(summary, MAX_SUMMARY_LENGTH) };
446
+ }
447
+ function resultText(content) {
448
+ if (typeof content === "string") {
449
+ return content;
450
+ }
451
+ if (!Array.isArray(content)) {
452
+ return "";
453
+ }
454
+ return content.map((part) => part.type === "text" ? part.text ?? "" : part.type === "image" ? "[image]" : "").filter(Boolean).join("\n");
455
+ }
456
+ function feedOf(message) {
457
+ if (message.type !== "assistant" && message.type !== "user") {
458
+ return [];
459
+ }
460
+ const parent = message.parent_tool_use_id ?? null;
461
+ const content = message.message.content;
462
+ if (!Array.isArray(content)) {
463
+ return [];
464
+ }
465
+ const drafts = [];
466
+ for (const block of content) {
467
+ if (message.type === "assistant" && block.type === "thinking" && block.thinking?.trim()) {
468
+ drafts.push({ kind: "thinking", text: clip(block.thinking, MAX_TEXT_LENGTH), tool: null, ref: null, parent, failed: false });
469
+ } else if (message.type === "assistant" && block.type === "text" && block.text?.trim()) {
470
+ drafts.push({ kind: "text", text: clip(block.text, MAX_TEXT_LENGTH), tool: null, ref: null, parent, failed: false });
471
+ } else if (message.type === "assistant" && block.type === "tool_use") {
472
+ drafts.push(toolDraft(block, parent));
473
+ } else if (message.type === "user" && block.type === "tool_result") {
474
+ drafts.push({ kind: "result", text: clip(resultText(block.content), MAX_RESULT_LENGTH), tool: null, ref: block.tool_use_id ?? null, parent, failed: block.is_error === true });
475
+ }
476
+ }
477
+ return drafts;
478
+ }
479
+ var CONSOLE_LABELS = { tool: "", skill: "Skill ", agent: "Agent " };
480
+ var MAX_CONSOLE_LENGTH = 120;
481
+ function consoleLine(draft) {
482
+ const label = CONSOLE_LABELS[draft.kind];
483
+ return label === void 0 ? null : clip(`${label}${draft.text}`.replace(/\s+/g, " "), MAX_CONSOLE_LENGTH);
484
+ }
485
+ var HIDDEN_TOOLS = /* @__PURE__ */ new Set(["ToolSearch", "SubagentHandback"]);
486
+ function hiddenStepsFilter() {
487
+ const hidden = /* @__PURE__ */ new Set();
488
+ return (draft) => {
489
+ if (draft.kind === "tool" && draft.tool && HIDDEN_TOOLS.has(draft.tool)) {
490
+ if (draft.ref) {
491
+ hidden.add(draft.ref);
492
+ }
493
+ return false;
494
+ }
495
+ return !(draft.kind === "result" && draft.ref && hidden.has(draft.ref));
496
+ };
497
+ }
498
+
499
+ // src/handoff.ts
500
+ var HANDOFF_OUTCOMES = ["pass", "problems", "ask"];
501
+ function handoffTargetLine(target) {
502
+ return target.label ? `${target.name} (${target.outcome}: ${target.label})` : `${target.name} (${target.outcome})`;
503
+ }
504
+ function validTargets(targets) {
505
+ return `Valid targets: ${targets.map(handoffTargetLine).join(", ")}.`;
506
+ }
507
+ function chooseHandoff(targets, request) {
508
+ const to = request.to.trim();
509
+ const note = request.note.trim();
510
+ if (!note) {
511
+ return { error: "Write a note for the next agent and the owner: what was done, what to check and what is open." };
512
+ }
513
+ const matching = targets.filter((target) => target.name.toLowerCase() === to.toLowerCase() || String(target.columnId) === to);
514
+ if (matching.length === 0) {
515
+ return { error: `"${to}" is not a step this task can be handed to. ${validTargets(targets)}` };
516
+ }
517
+ const edge = matching.find((target) => target.outcome === request.outcome);
518
+ if (!edge) {
519
+ return { error: `${matching[0].name} does not take the outcome "${request.outcome}". ${validTargets(targets)}` };
520
+ }
521
+ return { handoff: { toColumnId: edge.columnId, outcome: edge.outcome, note } };
522
+ }
523
+
524
+ // src/input.ts
525
+ var DEFAULT_MODEL = "default";
526
+ var QUIET_MS = 1e3;
527
+ var IDLE_LIMIT_MS = 3e4;
528
+ function userMessage(text2) {
529
+ return { type: "user", message: { role: "user", content: text2 }, parent_tool_use_id: null };
530
+ }
531
+ function humanText(message) {
532
+ return `Message from ${message.authorName}: ${message.text}`;
533
+ }
534
+ function humanMessage(message) {
535
+ return { ...userMessage(humanText(message)), priority: message.mode === "steer" ? "now" : "next", client_composed: true };
536
+ }
537
+ function switchLine(change) {
538
+ const model = change.model === DEFAULT_MODEL ? "the default model" : change.model;
539
+ const effort = change.effort && `${change.effort} effort`;
540
+ return `Switched to ${[model, effort].filter(Boolean).join(", ")}`;
541
+ }
542
+ function isActivity(message) {
543
+ return message.type === "assistant" || message.type === "user" || message.type === "system" && message.subtype === "init";
544
+ }
545
+ function inputChannel(options = {}) {
546
+ const queue = [];
547
+ const seen = /* @__PURE__ */ new Set();
548
+ let delivered = [];
549
+ let started = false;
550
+ let opening = "";
551
+ let generation = 0;
552
+ let closed = false;
553
+ let idle = false;
554
+ let active = false;
555
+ let interrupted = 0;
556
+ let drained = false;
557
+ let quiet = false;
558
+ let idleEpoch = 0;
559
+ let inFlight = 0;
560
+ let statesSeen = false;
561
+ let wake = null;
562
+ let timers = [];
563
+ let applySwitch2 = null;
564
+ let waitingSwitch = null;
565
+ function wakeUp() {
566
+ wake?.();
567
+ wake = null;
568
+ }
569
+ function clearTimers() {
570
+ timers.forEach(clearTimeout);
571
+ timers = [];
572
+ }
573
+ function close() {
574
+ if (closed) {
575
+ return;
576
+ }
577
+ closed = true;
578
+ clearTimers();
579
+ waitingSwitch?.resolve(false);
580
+ waitingSwitch = null;
581
+ wakeUp();
582
+ }
583
+ function maybeClose() {
584
+ if (started && idle && drained && quiet && queue.length === 0 && inFlight === 0) {
585
+ close();
586
+ }
587
+ }
588
+ function turnStarted() {
589
+ idle = false;
590
+ clearTimers();
591
+ }
592
+ function turnEnded() {
593
+ if (closed || idle) {
594
+ return;
595
+ }
596
+ idle = true;
597
+ idleEpoch += 1;
598
+ drained = !options.onIdle;
599
+ quiet = false;
600
+ timers = [
601
+ setTimeout(() => {
602
+ quiet = true;
603
+ maybeClose();
604
+ }, options.quietMs ?? QUIET_MS),
605
+ setTimeout(close, options.idleLimitMs ?? IDLE_LIMIT_MS)
606
+ ];
607
+ options.onIdle?.();
608
+ }
609
+ async function* stream(own) {
610
+ for (; ; ) {
611
+ if (closed || own !== generation) {
612
+ return;
613
+ }
614
+ const next = queue.shift();
615
+ if (!next) {
616
+ await new Promise((resolve) => {
617
+ wake = resolve;
618
+ });
619
+ continue;
620
+ }
621
+ if (active && !idle && next.message.priority === "now") {
622
+ interrupted += 1;
623
+ }
624
+ turnStarted();
625
+ delivered.push(...next.ids);
626
+ yield next.message;
627
+ }
628
+ }
629
+ return {
630
+ get closed() {
631
+ return closed;
632
+ },
633
+ // Сообщения, пришедшие, пока готовился код, идут вместе с задачей.
634
+ // Повторный start для нового Claude Code (прошлый сеанс не открылся) шлёт то же первое сообщение снова, а прежний поток кончается
635
+ start(prompt) {
636
+ if (started) {
637
+ queue.unshift({ ids: [], message: userMessage(opening) });
638
+ } else {
639
+ started = true;
640
+ const early = queue.splice(0);
641
+ opening = [prompt, ...early.map((item) => item.message.message.content)].join("\n\n");
642
+ queue.push({ ids: early.flatMap((item) => item.ids), message: userMessage(opening) });
643
+ }
644
+ generation += 1;
645
+ wakeUp();
646
+ return stream(generation);
647
+ },
648
+ // Если Claude Code сообщает состояние сессии, верим только ему: idle приходит после фоновых задач
649
+ observe(message) {
650
+ if (message.type === "system" && message.subtype === "session_state_changed") {
651
+ statesSeen = true;
652
+ if (message.state === "idle") {
653
+ turnEnded();
654
+ } else {
655
+ turnStarted();
656
+ }
657
+ return;
658
+ }
659
+ if (statesSeen) {
660
+ return;
661
+ }
662
+ if (message.type === "result") {
663
+ active = false;
664
+ if (interrupted > 0) {
665
+ interrupted -= 1;
666
+ return;
667
+ }
668
+ turnEnded();
669
+ } else if (isActivity(message)) {
670
+ active = true;
671
+ turnStarted();
672
+ }
673
+ },
674
+ onSwitch(apply) {
675
+ applySwitch2 = apply;
676
+ if (waitingSwitch) {
677
+ const { change, resolve } = waitingSwitch;
678
+ waitingSwitch = null;
679
+ void apply(change).then(() => resolve(true), () => resolve(false));
680
+ }
681
+ },
682
+ close,
683
+ push(message) {
684
+ if (closed || seen.has(message.id)) {
685
+ return false;
686
+ }
687
+ seen.add(message.id);
688
+ queue.push({ ids: [message.id], message: humanMessage(message) });
689
+ wakeUp();
690
+ return true;
691
+ },
692
+ async checking(work2) {
693
+ const epoch = idle ? idleEpoch : null;
694
+ inFlight += 1;
695
+ try {
696
+ await work2();
697
+ } finally {
698
+ inFlight -= 1;
699
+ if (idle && epoch === idleEpoch) {
700
+ drained = true;
701
+ }
702
+ maybeClose();
703
+ }
704
+ },
705
+ takeDelivered() {
706
+ const ids = delivered;
707
+ delivered = [];
708
+ return ids;
709
+ },
710
+ restoreDelivered(ids) {
711
+ delivered = [...ids, ...delivered];
712
+ },
713
+ hasDelivered: () => delivered.length > 0,
714
+ // До запуска Claude смена ждёт его; после конца работы менять уже нечего
715
+ async switchModel(change) {
716
+ if (closed) {
717
+ return false;
718
+ }
719
+ if (applySwitch2) {
720
+ await applySwitch2(change);
721
+ return true;
722
+ }
723
+ const earlier = waitingSwitch;
724
+ earlier?.resolve(false);
725
+ const merged = { model: change.model ?? earlier?.change.model ?? null, effort: change.effort ?? earlier?.change.effort ?? null };
726
+ return new Promise((resolve) => {
727
+ waitingSwitch = { change: merged, resolve };
728
+ });
729
+ }
730
+ };
731
+ }
732
+
733
+ // src/specText.ts
734
+ function isRecord(value) {
735
+ return typeof value === "object" && value !== null && !Array.isArray(value);
736
+ }
737
+ function text(value) {
738
+ return typeof value === "string" ? value.trim() : "";
739
+ }
740
+ function texts(value) {
741
+ return Array.isArray(value) ? value.map(text).filter(Boolean) : [];
742
+ }
743
+ function records(value) {
744
+ return Array.isArray(value) ? value.filter(isRecord) : [];
745
+ }
746
+ function section(title, lines) {
747
+ return lines.length > 0 ? `${title}
748
+ ${lines.join("\n")}` : null;
749
+ }
750
+ function criterionLine(value) {
751
+ if (typeof value === "string") {
752
+ return value.trim() ? `- ${value.trim()}` : null;
753
+ }
754
+ if (!isRecord(value) || !text(value.text)) {
755
+ return null;
756
+ }
757
+ const kind = ["main", "edge", "keep"].includes(value.kind) ? `(${value.kind})` : "";
758
+ const head = [text(value.id), kind].filter(Boolean).join(" ");
759
+ return `- ${head ? `${head}: ` : ""}${text(value.text)}`;
760
+ }
761
+ function assumptionLine(value) {
762
+ if (typeof value === "string") {
763
+ return value.trim() ? `- ${value.trim()}` : null;
764
+ }
765
+ if (!isRecord(value) || !text(value.text)) {
766
+ return null;
767
+ }
768
+ return `- ${text(value.text)}${text(value.reason) ? ` (${text(value.reason)})` : ""}`;
769
+ }
770
+ function changeLines(change, index) {
771
+ const action = ["create", "modify", "delete"].includes(change.action) ? ` (${change.action})` : "";
772
+ const [produces, consumes, acceptance] = [texts(change.produces), texts(change.consumes), texts(change.acceptance)];
773
+ return [
774
+ `${index + 1}. ${text(change.repo) ? `${text(change.repo)} ` : ""}${text(change.file)}${action}: ${text(change.description)}`,
775
+ ...produces.length > 0 ? [` Produces: ${produces.join("; ")}`] : [],
776
+ ...consumes.length > 0 ? [` Consumes: ${consumes.join("; ")}`] : [],
777
+ ...acceptance.length > 0 ? [` Makes true: ${acceptance.join(", ")}`] : []
778
+ ];
779
+ }
780
+ var CODE_USE_TEXT = { reuse: "reuse", change: "change", keep: "do not touch" };
781
+ var RISK_LEVELS = ["low", "medium", "high"];
782
+ function present(lines) {
783
+ return lines.filter((line) => line !== null);
784
+ }
785
+ function specPromptText(spec) {
786
+ if (!isRecord(spec)) {
787
+ return null;
788
+ }
789
+ const structured = Array.isArray(spec.outOfScope) || isRecord(spec.testPlan);
790
+ const plan = isRecord(spec.testPlan) ? spec.testPlan : {};
791
+ const risks = records(spec.risks).filter((item) => text(item.risk));
792
+ const riskLevel = RISK_LEVELS.includes(spec.riskLevel) ? spec.riskLevel : null;
793
+ return [
794
+ text(spec.goal) && `Goal: ${text(spec.goal)}`,
795
+ text(spec.context) && `${structured ? "Summary" : "Context"}: ${text(spec.context)}`,
796
+ section("In scope:", texts(spec.inScope).map((item) => `- ${item}`)),
797
+ section("Out of scope (do not build):", texts(spec.outOfScope).map((item) => `- ${item}`)),
798
+ section("Decisions:", records(spec.decisions).filter((item) => text(item.question) && text(item.answer)).map((item) => `- ${text(item.question)} \u2192 ${text(item.answer)} (${item.by === "agent" ? "agent's call" : "owner"})`)),
799
+ section("Code map:", records(spec.codeMap).filter((item) => text(item.file)).map((item) => `- ${text(item.repo) ? `${text(item.repo)} ` : ""}${text(item.file)}: ${text(item.role)}${CODE_USE_TEXT[item.use] ? ` [${CODE_USE_TEXT[item.use]}]` : ""}`)),
800
+ section("Changes, in order:", records(spec.changes).filter((item) => text(item.file)).flatMap(changeLines)),
801
+ section("Acceptance criteria:", present(Array.isArray(spec.acceptance) ? spec.acceptance.map(criterionLine) : [])),
802
+ section("Test plan:", [
803
+ ...records(plan.automated).filter((item) => text(item.command)).map((item) => `- Run in ${text(item.repo) || "the repository"}: ${text(item.command)}${text(item.expect) ? `. Passing: ${text(item.expect)}` : ""}`),
804
+ ...records(plan.newTests).filter((item) => text(item.seam)).map((item) => `- New test at ${text(item.seam)}${texts(item.covers).length > 0 ? `, covers ${texts(item.covers).join(", ")}` : ""}`),
805
+ ...records(plan.manual).filter((item) => text(item.steps)).map((item) => `- By hand${text(item.page) ? ` on ${text(item.page)}` : ""}: ${text(item.steps)}`)
806
+ ]),
807
+ risks.length > 0 ? section(`Risks${riskLevel ? ` (risk level ${riskLevel})` : ""}:`, risks.map((item) => `- ${text(item.risk)}${text(item.mitigation) ? `. Mitigation: ${text(item.mitigation)}` : ""}`)) : riskLevel && `Risk level: ${riskLevel}`,
808
+ section("Assumptions:", present(Array.isArray(spec.assumptions) ? spec.assumptions.map(assumptionLine) : [])),
809
+ section("Open questions (not answered yet):", records(spec.openQuestions).filter((item) => text(item.question)).map((item) => `- ${text(item.question)}`))
810
+ ].filter(Boolean).join("\n\n");
811
+ }
812
+
813
+ // src/prompt.ts
814
+ var VERDICT_TOOL_NAME = "submit_verdict";
815
+ var SCREENSHOT_TOOL_NAME = "attach_screenshot";
816
+ var REPLAY_TOOL_NAME = "attach_replay";
817
+ var VIDEO_TOOL_NAME = "attach_video";
818
+ var HANDOFF_TOOL_NAME = "hand_off";
819
+ var SUBMIT_QUESTIONS_TOOL_NAME = "submit_questions";
820
+ var SUBMIT_SPEC_TOOL_NAME = "submit_spec";
821
+ var UNATTENDED_RULE = `You are working on a TAMsense task on the user's own computer. People on the task may send you messages during this run, each starting with "Message from <name>:". Follow them: they take priority over the original task where the two disagree. Nobody answers questions or approves actions during the run, so never stop to wait for a reply: decide yourself, keep working, and explain your decisions in your report.`;
822
+ var MACHINE_RULE = "This is the person's own computer, and their other programs may be running on it. Stop only the processes you started yourself, by their process id, and stop them before you finish. Never kill processes by name or pattern (taskkill /IM, pkill, killall, Stop-Process -Name) and never touch programs you did not start.";
823
+ var CODE_RULES = [
824
+ "Make the changes the task asks for, and keep them focused on it.",
825
+ "When there is an approved spec, follow it: make the changes in its order with the names and data shapes it gives, build nothing from its out of scope list, and add the tests and run the commands of its test plan.",
826
+ "Check your work with the project's own tools: run the tests, type checks and linters that exist, and fix what you broke.",
827
+ "Commit your work in every repository you changed, on the current branch. Do not push, do not switch or create branches, and do not rewrite history: TAMsense pushes the branch and opens the pull request."
828
+ ];
829
+ var REWORK_RULE = "This is a rework. The branch already has the earlier work and maybe the reviewer's own commits: treat the current branch as the source of truth, keep the reviewer's changes unless a comment asks otherwise, and address every comment.";
830
+ var CODE_REPORT_RULE = "End with a short report for the reviewer: what you changed, how you checked it, and anything left open.";
831
+ var CHECK_RULES = [
832
+ "The work to check is on the current branch. See it with git diff against the base branch named for each repository below, and read the code around it.",
833
+ "Do not change, commit or push product code. You may run anything and create throwaway files outside the repositories.",
834
+ "Judge against the task, the spec and its acceptance criteria, checking each criterion by its id (AC-1 and so on) when the spec has ids. Be specific: every problem gets a file, a line and what to do about it.",
835
+ `Finish by calling the ${VERDICT_TOOL_NAME} tool exactly once: passed is true only if nothing must be fixed; report says what you checked and, if it failed, exactly what to fix.`
836
+ ];
837
+ var SCREENSHOT_RULE = `If you render screens (screenshots of the interface), attach the ones that show the result with the ${SCREENSHOT_TOOL_NAME} tool. People see them on the task without opening the code.`;
838
+ var NAMING_RULE = 'Name everything you attach (screenshots, replays, videos) by what it shows and why it was made: a short title such as "Design option 1", "Feature testing" or "Feature testing, attempt 2", and a one-line purpose such as "Comparing two layouts for the empty orders state". Number design variants, name test runs by their goal and attempt, and never use generic names like "screenshot1".';
839
+ var RECORDING_RULE = `Every browser you open with Playwright from Node is recorded for the task as a replay people can watch, and requests from the tested site to the TAMsense tracker are blocked, so your runs never reach analytics. In every browser script, load the recorder from the TAMSENSE_RECORDER environment variable (require(process.env.TAMSENSE_RECORDER), or createRequire(import.meta.url)(process.env.TAMSENSE_RECORDER) in an .mjs script), open each context with await recorder.newRecordedContext(browser, { title, purpose, context: { viewport } }), call await recorder.mark(page, 'what just happened') at every meaningful step, and end with await recorder.closeRecorded(context). Add video: true when the page has a canvas, a map, a video player or iframes from another site (a replay cannot show them), or when someone asks for a video. Recordings are attached to the task when the run ends; ${REPLAY_TOOL_NAME} and ${VIDEO_TOOL_NAME} attach one earlier with a better title.`;
840
+ var BROWSER_RULE = "A headless browser is installed for interface checks. If the project has no Playwright of its own, load playwright-core from the folder in the TAMSENSE_PLAYWRIGHT environment variable, with PLAYWRIGHT_BROWSERS_PATH set to the folder in TAMSENSE_PLAYWRIGHT_BROWSERS. Never install browsers or packages yourself.";
841
+ var STAGE_GOALS = {
842
+ code: "Implement",
843
+ review: "Review the code for",
844
+ test: "Test"
845
+ };
846
+ var SPEC_UNATTENDED_RULE = `You are preparing a TAMsense task on the user's own computer. Nobody answers you during this run: the owner answers your questions and reviews the spec on the task page after it. So never stop to wait for a reply and never ask in your messages: hand in what this stage asks for with the TAMsense tool, then finish. People on the task may send you messages during this run, each starting with "Message from <name>:". Follow them.`;
847
+ var READ_ONLY_RULE = "Read the code to research the task. Do not change, commit or push anything: nothing from this run is pushed, and the code step starts from a clean branch.";
848
+ var RESEARCH_RULE = "Start by loading the tamsense:task-interview skill with the Skill tool and follow it: it holds the question test, the corner sweep and the rules for rounds. Research before asking: read the code in the repositories here and the evidence of the linked action, if there is one. Finding facts is your job; the decisions about intent are the owner's.";
849
+ var BRIEF_RULE = "Write the brief as you go: findings (what the code does today that matters, each with owner/repo path:line), dataFacts (numbers and facts from the linked action, each with its source; empty when none), approach (2 to 4 sentences), codeMap (the files the change will touch or must reuse, each with its role), assumptions (each corner you settled with a default, with the reason) and pendingDecisions (decisions that depend on an answer to this round; empty when none).";
850
+ var QUESTION_SHAPE_RULE = 'Each question has a snake_case id, one plain sentence the owner can answer without reading code, a whyItMatters line, and 2 to 4 concrete options grounded in what you saw, each saying what happens if it is chosen. Put the option you recommend first and set recommended on it (in a multiSelect question, on every option you recommend). multiSelect is true only when the options are additive. Never add an "Other" option: the page adds a free-text field. An unanswered question takes your recommended option, so recommend the one you would defend.';
851
+ var QUESTIONS_REPLY_RULE = "Then reply in one or two plain sentences: what you understood from the code and why you ask these questions, or that no questions are needed. Do not repeat the questions: the page shows them.";
852
+ var SPEC_RULES = [
853
+ "Start by loading the tamsense:spec-writing skill with the Skill tool and follow it: it holds the shape of the spec, the rules for acceptance criteria, the size budget, the lint list and the builder check.",
854
+ "Start from the brief and the conversation below. Verify in the code what the spec depends on and research only what is missing; without a brief, research first as the tamsense task-interview skill describes.",
855
+ 'Apply the answers: an answer the owner gave is a decision with by: owner; an answer marked "Recommended option applied, not answered" is an assumption with the reason "not answered, the recommended option applies". If an answer contradicts the code or another answer, check the code before following it and say so in your reply.',
856
+ "Where the task, the answers and the project context are silent and a safe default exists, choose it and record it as an assumption with its reason. A point with no safe default that changes the scope or the behaviour becomes an open question; keep them to 3 at most.",
857
+ "Before you hand the spec in, run the builder check of the skill once. It is required, also for a small task: start one fresh general-purpose subagent with the Agent tool, give it only the spec as text inside the builder check prompt of the skill, and settle each point it reports as the skill says.",
858
+ `Hand in the FULL spec with ${SUBMIT_SPEC_TOOL_NAME}. TAMsense lints it and checks the files, and sends it back once if no fresh reader has checked it; if it returns feedback, the spec was not saved: fix it and call ${SUBMIT_SPEC_TOOL_NAME} again. After two rounds of lint feedback the spec is saved as it is.`,
859
+ "When the task already has a saved spec, the conversation holds the owner's feedback or answers to its open questions: apply them and hand in the full updated spec. An answered open question becomes a decision; a corrected or confirmed assumption becomes a decision and leaves the assumptions.",
860
+ "Then reply with a short plain summary: the approach, the risk level and the open questions, if any. Do not paste the spec: the page shows it."
861
+ ];
862
+ var SPEC_WORK_MISSING = "TAMsense sent a spec step without its round and brief.";
863
+ var SPEC_STYLE_RULE = "Write the brief, the questions, the spec and your reply in the language the task is written in. Replies are plain text, no Markdown. Use real file paths, function and table names; if you could not verify something, say so. No em dashes and no en dashes; use commas, colons or a plain hyphen.";
864
+ function specText(spec) {
865
+ const text2 = specPromptText(spec);
866
+ return text2 ? `Approved spec:
867
+ ${text2}` : null;
868
+ }
869
+ function contextText(context) {
870
+ return context.trim() ? `Project context from the team (follow it like the project's own rules):
871
+ ${context.trim()}` : null;
872
+ }
873
+ function bulletList(lines) {
874
+ return lines.map((line) => `- ${line}`).join("\n");
875
+ }
876
+ function repositoryLines(job, workspace) {
877
+ const sourceOf = new Map(job.repositories.map((repository) => [repository.fullName, repository]));
878
+ return workspace.repositories.map((repository) => {
879
+ const source = sourceOf.get(repository.fullName);
880
+ const pull = source?.pullRequestUrl ? `, pull request ${source.pullRequestUrl}` : "";
881
+ return `${repository.folder} (${repository.fullName}), base branch origin/${source?.defaultBranch ?? "main"}${pull}`;
882
+ });
883
+ }
884
+ function stepLine(stage) {
885
+ return `This step of the board is "${stage.name}"${stage.helper ? `, and you work as the ${stage.helper}` : ""}.`;
886
+ }
887
+ function teamMessagesText(messages) {
888
+ return messages.length > 0 ? `Messages from the team before this run:
889
+ ${messages.map((message) => `${message.authorName}: ${message.text}`).join("\n")}` : null;
890
+ }
891
+ function handoffText(targets, isCheck) {
892
+ if (targets.length === 0) {
893
+ return null;
894
+ }
895
+ return [
896
+ `When you finish, hand the task on with the ${HANDOFF_TOOL_NAME} tool. Write a clear note for the next agent and the task owner: what was done, what to check and what is still open. The steps this task can go to from here:`,
897
+ bulletList(targets.map(handoffTargetLine)),
898
+ `If none of them fits, just finish without handing off.${isCheck ? ` Hand in your verdict with ${VERDICT_TOOL_NAME} as well.` : ""}`
899
+ ].join("\n");
900
+ }
901
+ function threadText(thread) {
902
+ if (thread.length === 0) {
903
+ return null;
904
+ }
905
+ const lines = thread.map((message) => `${message.role === "user" ? "Owner" : "You, earlier"}: ${message.text}`);
906
+ return `The conversation on the task so far (answers to the questions and the owner's feedback):
907
+ ${lines.join("\n\n")}`;
908
+ }
909
+ function questionRules(work2) {
910
+ const handIn = `Call ${SUBMIT_QUESTIONS_TOOL_NAME} once with the brief and the questions, or an empty list when none are needed. If it returns problems, fix them and call it again.`;
911
+ if (work2.round === 1) {
912
+ return [
913
+ RESEARCH_RULE,
914
+ BRIEF_RULE,
915
+ `Ask 0 to ${work2.maxQuestions} questions, ideally 2 or 3, the most important first, and only the ones that pass the three tests of the skill. Zero questions is a good result: the spec is then written right away. Ask only questions that do not depend on another question of this round; put a dependent decision into pendingDecisions.`,
916
+ QUESTION_SHAPE_RULE,
917
+ handIn,
918
+ QUESTIONS_REPLY_RULE
919
+ ];
920
+ }
921
+ const taken = work2.takenIds.length > 0 ? ` These ids are taken by round 1: ${work2.takenIds.join(", ")}.` : "";
922
+ return [
923
+ `This is the follow-up round ${work2.round} of 2, and there is no round after it. Follow the tamsense:task-interview skill (load it with the Skill tool if this conversation has not loaded it yet). Read the answers in the conversation below. Do not research again what your brief covers; check only what the answers changed.`,
924
+ `Ask at most ${work2.maxQuestions} more questions, and only when an answer is vague ("not sure", "depends", "maybe"), contradicts another answer or the code, or unlocked a decision from pendingDecisions that passes the tests of the tamsense task-interview skill. Otherwise hand in no questions: whatever is still open becomes an assumption.${taken}`,
925
+ QUESTION_SHAPE_RULE,
926
+ "Update the brief with what the answers changed and leave pendingDecisions empty.",
927
+ handIn,
928
+ QUESTIONS_REPLY_RULE
929
+ ];
930
+ }
931
+ function specGoal(job, work2) {
932
+ if (work2.round === null) {
933
+ return `Write the spec for TAMsense task #${job.taskId}: ${job.title}`;
934
+ }
935
+ return work2.round > 1 ? `Follow-up questions for TAMsense task #${job.taskId}: ${job.title}` : `Prepare TAMsense task #${job.taskId} for its spec: ${job.title}`;
936
+ }
937
+ function buildSpecPrompt(job, work2, workspace) {
938
+ const prompt = [
939
+ specGoal(job, work2),
940
+ job.description,
941
+ work2.linkedAction,
942
+ work2.brief ? `Brief from the clarification phase (research already done, verify only what you rely on):
943
+ ${JSON.stringify(work2.brief)}` : null,
944
+ work2.round === null && job.spec ? `Current saved spec (hand in the full updated version):
945
+ ${JSON.stringify(job.spec)}` : null,
946
+ job.handoffNote && `Note from another step: ${job.handoffNote}`,
947
+ threadText(work2.thread),
948
+ teamMessagesText(job.messages)
949
+ ].filter(Boolean).join("\n\n");
950
+ const instructions = [
951
+ SPEC_UNATTENDED_RULE,
952
+ MACHINE_RULE,
953
+ stepLine(job.stage),
954
+ job.stage.instructions,
955
+ contextText(job.projectContext),
956
+ `The repositories for this task are checked out in the current folder, one subfolder each:
957
+ ${bulletList(repositoryLines(job, workspace))}`,
958
+ READ_ONLY_RULE,
959
+ `How to work:
960
+ ${bulletList(work2.round === null ? SPEC_RULES : questionRules(work2))}`,
961
+ SPEC_STYLE_RULE
962
+ ].filter(Boolean).join("\n\n");
963
+ return { prompt, instructions };
964
+ }
965
+ function buildTaskPrompt(job, workspace, options = {}) {
966
+ const { kind } = job.stage;
967
+ if (kind === "interview" || kind === "spec") {
968
+ if (!job.specWork) {
969
+ throw new Error(SPEC_WORK_MISSING);
970
+ }
971
+ return buildSpecPrompt(job, job.specWork, workspace);
972
+ }
973
+ const isCheck = kind !== "code";
974
+ const prompt = [
975
+ `${STAGE_GOALS[kind]} TAMsense task #${job.taskId}: ${job.title}`,
976
+ job.description,
977
+ specText(job.spec),
978
+ job.handoffNote && `Note from the previous step: ${job.handoffNote}`,
979
+ job.review && `Review comments to address:
980
+ ${job.review}`,
981
+ teamMessagesText(job.messages)
982
+ ].filter(Boolean).join("\n\n");
983
+ const evidence = [SCREENSHOT_RULE, ...options.recorder ? [RECORDING_RULE] : [], NAMING_RULE];
984
+ const rules = isCheck ? [...CHECK_RULES, ...evidence] : [...CODE_RULES, ...job.review ? [REWORK_RULE] : [], ...evidence, CODE_REPORT_RULE];
985
+ const instructions = [
986
+ UNATTENDED_RULE,
987
+ MACHINE_RULE,
988
+ stepLine(job.stage),
989
+ job.stage.instructions,
990
+ contextText(job.projectContext),
991
+ `The repositories for this task are checked out in the current folder, one subfolder each, on branch ${job.branch}:
992
+ ${bulletList(repositoryLines(job, workspace))}`,
993
+ `How to work:
994
+ ${bulletList(rules)}`,
995
+ handoffText(job.allowedHandoffs, isCheck),
996
+ options.browser && BROWSER_RULE
997
+ ].filter(Boolean).join("\n\n");
998
+ return { prompt, instructions };
999
+ }
1000
+
1001
+ // src/specTools.ts
1002
+ import { tool } from "@anthropic-ai/claude-agent-sdk";
1003
+ import { z } from "zod";
1004
+ var MAX_FIRST_ROUND = 4;
1005
+ var questionSchema = z.object({
1006
+ id: z.string().describe('Stable snake_case id, unique in the task, for example "empty_state"'),
1007
+ question: z.string().describe("One plain sentence the owner can answer without reading code"),
1008
+ whyItMatters: z.string().describe("One line: what changes in the result depending on the answer"),
1009
+ options: z.array(z.object({
1010
+ label: z.string().describe('Short, concrete answer (1-6 words), without "(Recommended)": use the recommended flag'),
1011
+ description: z.string().describe("One sentence: what happens if this option is chosen"),
1012
+ recommended: z.boolean().describe("true on the option you recommend (in a multiSelect question, on every option you recommend). Recommended options come first")
1013
+ })).min(2).max(4).describe('2-4 mutually exclusive options grounded in the code and data. The UI always adds a free-text "Other" field, so never add "Other" yourself'),
1014
+ multiSelect: z.boolean().describe("true only when the options are additive and several can apply together")
1015
+ });
1016
+ var briefSchema = z.object({
1017
+ findings: z.array(z.object({
1018
+ fact: z.string().describe("What the code does today that matters for this task"),
1019
+ where: z.string().describe("Where it is: owner/repo path:line")
1020
+ })).describe("Findings from the code"),
1021
+ dataFacts: z.array(z.string()).describe("Numbers and facts from the events or the business data, each with its source. Empty when the task needs no data"),
1022
+ approach: z.string().describe("The approach you would take, in 2-4 sentences"),
1023
+ codeMap: z.array(z.object({
1024
+ repo: z.string().describe("Repository full name (owner/name)"),
1025
+ file: z.string().describe("Path relative to the repository root"),
1026
+ role: z.string().describe("Why it matters: what to change or reuse there")
1027
+ })).describe("The files the change will touch or must reuse"),
1028
+ assumptions: z.array(z.object({
1029
+ text: z.string().describe("The default you chose"),
1030
+ reason: z.string().describe("Why it is safe or why it was chosen")
1031
+ })).describe("Corners settled with a default instead of a question"),
1032
+ pendingDecisions: z.array(z.string()).describe("Decisions that depend on an answer to this round and cannot be asked yet. Empty when none")
1033
+ });
1034
+ var questionsPayloadSchema = z.object({
1035
+ brief: briefSchema,
1036
+ questions: z.array(questionSchema).max(MAX_FIRST_ROUND).describe(`0-${MAX_FIRST_ROUND} questions that pass the question test, most important first. An empty list when research settled everything`)
1037
+ });
1038
+ var specSchema = z.object({
1039
+ goal: z.string().describe("The problem solved, for whom, and the outcome a user or the business will notice"),
1040
+ context: z.string().describe("Summary: the problem in 1-2 sentences, then the chosen approach in 1-2 sentences"),
1041
+ inScope: z.array(z.string()).describe("What this task delivers, one item each"),
1042
+ outOfScope: z.array(z.string()).describe("What this task does not do: nearby additions a coder might be tempted to make and approaches that are ruled out. Never empty"),
1043
+ decisions: z.array(z.object({
1044
+ question: z.string(),
1045
+ answer: z.string(),
1046
+ by: z.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
1047
+ })).describe("Every answered question with by: owner, and your own calls that matter with by: agent"),
1048
+ codeMap: z.array(z.object({
1049
+ repo: z.string().describe("Repository full name (owner/name)"),
1050
+ file: z.string().describe("Path relative to the repository root"),
1051
+ role: z.string().describe("What the coder needs to know about this file"),
1052
+ use: z.enum(["reuse", "change", "keep"]).describe("reuse: build on it; change: it is in changes; keep: do not touch")
1053
+ })).describe("The files a coder must read before starting"),
1054
+ changes: z.array(z.object({
1055
+ repo: z.string().describe("Repository full name (owner/name) where the change happens"),
1056
+ file: z.string().describe("Path to the file, relative to the repository root"),
1057
+ action: z.enum(["create", "modify", "delete"]),
1058
+ description: z.string().describe("The behaviour the file has after the change and which existing functions or patterns to extend. No code snippets or diffs"),
1059
+ produces: z.array(z.string()).describe("Names with signatures or data shapes this change creates or changes for other changes. Empty when none"),
1060
+ consumes: z.array(z.string()).describe("Names with signatures or data shapes this change uses from earlier changes. Empty when none"),
1061
+ acceptance: z.array(z.string()).describe("Ids of the criteria this change makes true, like AC-1")
1062
+ })).min(1).describe("File changes in dependency order: each uses only what earlier changes or the code already provide"),
1063
+ acceptance: z.array(z.object({
1064
+ id: z.string().describe("AC-1, AC-2 and so on, in order"),
1065
+ kind: z.enum(["main", "edge", "keep"]).describe("main: the main path; edge: an error, empty or limit case; keep: existing behaviour that must keep working"),
1066
+ text: z.string().describe('"Given <state>, when <action>, then <observable result>", saying where to look. No file paths or function names')
1067
+ })).min(1).describe("Acceptance criteria: main path first, then edge cases, then what must keep working"),
1068
+ testPlan: z.object({
1069
+ automated: z.array(z.object({
1070
+ repo: z.string(),
1071
+ command: z.string().describe("The exact command to run"),
1072
+ expect: z.string().describe("What passing looks like")
1073
+ })).describe("At least one command"),
1074
+ newTests: z.array(z.object({
1075
+ seam: z.string().describe("Where the test goes: the highest seam that covers the behaviour (an endpoint, a page, a public function)"),
1076
+ covers: z.array(z.string()).describe("Ids of the criteria it covers")
1077
+ })),
1078
+ manual: z.array(z.object({
1079
+ page: z.string().describe("The page or screen"),
1080
+ steps: z.string().describe("What a person does and sees")
1081
+ }))
1082
+ }),
1083
+ risks: z.array(z.object({
1084
+ risk: z.string(),
1085
+ mitigation: z.string()
1086
+ })).describe("What could go wrong: migrations, existing data, access, performance, compatibility"),
1087
+ riskLevel: z.enum(["low", "medium", "high"]).describe("How closely a person should review the result"),
1088
+ assumptions: z.array(z.object({
1089
+ text: z.string().describe("The default you chose"),
1090
+ reason: z.string().describe("Why, so the owner can confirm or correct it quickly")
1091
+ })).describe("Defaults chosen where the task was silent. Empty when nothing was assumed"),
1092
+ openQuestions: z.array(questionSchema).describe("Questions only the owner can settle; the spec cannot be approved until each is answered or dismissed. Empty when none"),
1093
+ split: z.array(z.string()).describe("When the task holds more than one shippable goal or more than about 8 files: the separate tasks you propose, one line each. Empty otherwise")
1094
+ });
1095
+ function reply(text2, isError = false) {
1096
+ return { content: [{ type: "text", text: text2 }], ...isError ? { isError: true } : {} };
1097
+ }
1098
+ function unreachable(error, toolName) {
1099
+ const message = error instanceof Error ? error.message : String(error);
1100
+ return reply(`Could not reach TAMsense to check this: ${message}. Call ${toolName} again in a moment.`, true);
1101
+ }
1102
+ function questionsTool(review, onSubmit) {
1103
+ return tool(
1104
+ SUBMIT_QUESTIONS_TOOL_NAME,
1105
+ "Hand in the brief and the clarifying questions of this round. Call it once, after the research. Pass an empty questions list when research settled everything: the spec is then written next. If it returns problems, the questions were not saved: fix them and call it again.",
1106
+ questionsPayloadSchema.shape,
1107
+ async (args) => {
1108
+ let verdict;
1109
+ try {
1110
+ verdict = await review({ questions: args });
1111
+ } catch (error) {
1112
+ return unreachable(error, SUBMIT_QUESTIONS_TOOL_NAME);
1113
+ }
1114
+ if (!verdict.accepted) {
1115
+ return reply(verdict.feedback, true);
1116
+ }
1117
+ onSubmit(args);
1118
+ return reply(args.questions.length > 0 ? "Questions saved. The owner answers them on the task page after this run. Finish now with a reply of one or two sentences." : "Saved with no questions: the spec is written next. Finish now with a reply of one or two sentences.");
1119
+ }
1120
+ );
1121
+ }
1122
+ var BUILDER_CHECK_FIRST = "The spec passed the lint but was not saved yet: the builder check of the tamsense:spec-writing skill has not run in this session. Start one fresh general-purpose subagent with the Agent tool, give it only the spec as text inside the builder check prompt of the skill, settle each point it reports as the skill says, then call submit_spec again with the full spec.";
1123
+ function specTool(review, onSubmit, builderCheckRan) {
1124
+ let repairs = 0;
1125
+ let reminded = false;
1126
+ return tool(
1127
+ SUBMIT_SPEC_TOOL_NAME,
1128
+ "Hand in the implementation spec. It replaces the previous version entirely, so always send the FULL spec. TAMsense lints it and checks the files first; if it returns feedback, the spec was not saved: fix it and call submit_spec again.",
1129
+ specSchema.shape,
1130
+ async (args) => {
1131
+ let verdict;
1132
+ try {
1133
+ verdict = await review({ spec: args, repairs });
1134
+ } catch (error) {
1135
+ return unreachable(error, SUBMIT_SPEC_TOOL_NAME);
1136
+ }
1137
+ if (!verdict.accepted) {
1138
+ repairs += 1;
1139
+ return reply(verdict.feedback, true);
1140
+ }
1141
+ if (!builderCheckRan() && !reminded) {
1142
+ reminded = true;
1143
+ return reply(BUILDER_CHECK_FIRST, true);
1144
+ }
1145
+ onSubmit({ spec: args, problems: verdict.problems });
1146
+ return reply(verdict.problems.length > 0 ? `Spec saved with these problems left: ${verdict.problems.join("; ")}. Mention them in your reply.` : "Spec saved.");
1147
+ }
1148
+ );
1149
+ }
1150
+
1151
+ // src/usageLimit.ts
1152
+ var USAGE_LIMIT_REPORT = "Claude hit its usage limit";
1153
+ var DEFAULT_PAUSE_MS = 30 * 6e4;
1154
+ var DAY_MS = 24 * 60 * 6e4;
1155
+ var SECONDS_BELOW = 1e11;
1156
+ var LIMIT_TEXT = /You['’]ve (?:hit|reached) your|You['’]re out of (?:usage credits|extra usage)|usage limit/i;
1157
+ var EPOCH_RESET = /\|(\d{10,13})\b/;
1158
+ var CLOCK_RESET = /resets?\s+(?:at\s+)?(?:([a-z]{3})[a-z]*\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*([ap]m)?/i;
1159
+ var MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
1160
+ function fromEpoch(value) {
1161
+ return new Date(value < SECONDS_BELOW ? value * 1e3 : value);
1162
+ }
1163
+ function hourOf(hour, meridiem) {
1164
+ if (!meridiem) {
1165
+ return hour < 24 ? hour : null;
1166
+ }
1167
+ if (hour < 1 || hour > 12) {
1168
+ return null;
1169
+ }
1170
+ return hour % 12 + (meridiem.toLowerCase() === "pm" ? 12 : 0);
1171
+ }
1172
+ function resetTimeOf(text2, now) {
1173
+ const epoch = EPOCH_RESET.exec(text2);
1174
+ if (epoch) {
1175
+ const reset2 = fromEpoch(Number(epoch[1]));
1176
+ return reset2 > now ? reset2 : null;
1177
+ }
1178
+ const clock = CLOCK_RESET.exec(text2);
1179
+ if (!clock) {
1180
+ return null;
1181
+ }
1182
+ const [, monthName, day, hourText, minuteText, meridiem] = clock;
1183
+ const hour = hourOf(Number(hourText), meridiem);
1184
+ const minute = Number(minuteText ?? 0);
1185
+ const month = monthName ? MONTHS.indexOf(monthName.toLowerCase()) : now.getMonth();
1186
+ if (hour === null || minute > 59 || month < 0) {
1187
+ return null;
1188
+ }
1189
+ const reset = new Date(now.getFullYear(), month, day ? Number(day) : now.getDate(), hour, minute);
1190
+ if (reset > now) {
1191
+ return reset;
1192
+ }
1193
+ if (!monthName) {
1194
+ return new Date(reset.getTime() + DAY_MS);
1195
+ }
1196
+ const nextYear = new Date(now.getFullYear() + 1, month, Number(day), hour, minute);
1197
+ return nextYear.getTime() - now.getTime() < 31 * DAY_MS ? nextYear : null;
1198
+ }
1199
+ function assistantText(message) {
1200
+ return message.message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("\n");
1201
+ }
1202
+ function usageLimitWatch() {
1203
+ let limitedByText = false;
1204
+ let limitedByEvent = false;
1205
+ let eventReset = null;
1206
+ let texts2 = [];
1207
+ function hitText(text2) {
1208
+ limitedByText = true;
1209
+ texts2.push(text2);
1210
+ }
1211
+ return {
1212
+ observe(message) {
1213
+ if (message.type === "rate_limit_event") {
1214
+ const info = message.rate_limit_info;
1215
+ limitedByEvent = info.status === "rejected" && !info.isUsingOverage;
1216
+ eventReset = limitedByEvent && info.resetsAt ? fromEpoch(info.resetsAt) : null;
1217
+ } else if (message.type === "assistant" && message.error) {
1218
+ const text2 = assistantText(message);
1219
+ if (message.error === "rate_limit" || LIMIT_TEXT.test(text2)) {
1220
+ hitText(text2);
1221
+ }
1222
+ } else if (message.type === "result") {
1223
+ if (message.subtype === "success" && !message.is_error) {
1224
+ limitedByText = false;
1225
+ texts2 = [];
1226
+ return;
1227
+ }
1228
+ const text2 = message.subtype === "success" ? message.result : message.errors.join("\n");
1229
+ if (LIMIT_TEXT.test(text2)) {
1230
+ hitText(text2);
1231
+ }
1232
+ }
1233
+ },
1234
+ pausedUntil(completed, now = /* @__PURE__ */ new Date()) {
1235
+ if (completed || !(limitedByText || limitedByEvent)) {
1236
+ return null;
1237
+ }
1238
+ const fromText = texts2.map((text2) => resetTimeOf(text2, now)).find((reset2) => reset2 !== null);
1239
+ const reset = eventReset && eventReset > now ? eventReset : fromText ?? new Date(now.getTime() + DEFAULT_PAUSE_MS);
1240
+ return reset.toISOString();
1241
+ }
1242
+ };
1243
+ }
1244
+
1245
+ // src/claude.ts
1246
+ var NOBODY_TO_ASK = "Nobody is at the computer to approve this during a TAMsense run. Do it another way, or explain in your report why it is needed.";
1247
+ var NO_RESULT_TEXT = "Claude finished without a result.";
1248
+ var SERVER_NAME = "tamsense";
1249
+ var LIBRARY_PATH = fileURLToPath(new URL("../library", import.meta.url));
1250
+ var denyWhenAsked = async () => ({ behavior: "deny", message: NOBODY_TO_ASK });
1251
+ var CAPTION_FIELDS = {
1252
+ title: z2.string().describe('short name of what it shows, at most 60 characters: "Design option 1", "Feature testing", "Feature testing, attempt 2". Never generic names like "screenshot1"'),
1253
+ purpose: z2.string().describe('one line, at most 160 characters, on why it was made: "Comparing two layouts for the empty orders state"')
1254
+ };
1255
+ var ATTACH_TOOLS = {
1256
+ screenshot: {
1257
+ name: SCREENSHOT_TOOL_NAME,
1258
+ description: "Attach a screenshot to the TAMsense task so people can see the result without opening the code. Attach the final look of each changed screen and anything a reviewer must see. PNG, JPEG or WebP up to 5 MB, at most 20 per run.",
1259
+ path: "absolute path of the image file"
1260
+ },
1261
+ replay: {
1262
+ name: REPLAY_TOOL_NAME,
1263
+ description: "Attach a browser replay (the .rrweb.ndjson file the TAMsense recorder wrote) to the task now, with your own title and purpose. Every recording is attached at the end of the run anyway; use this to attach one earlier or to name it better. Close the browser context first so the file is complete.",
1264
+ path: "absolute path of the .rrweb.ndjson replay file"
1265
+ },
1266
+ video: {
1267
+ name: VIDEO_TOOL_NAME,
1268
+ description: "Attach a browser video (.webm or .mp4, up to 200 MB) to the task now, with your own title and purpose. Videos recorded with video: true are attached at the end of the run anyway. Close the browser context first so the file is complete.",
1269
+ path: "absolute path of the video file"
1270
+ }
1271
+ };
1272
+ function attachTool(kind, attach) {
1273
+ const spec = ATTACH_TOOLS[kind];
1274
+ return tool2(
1275
+ spec.name,
1276
+ spec.description,
1277
+ { path: z2.string().describe(spec.path), ...CAPTION_FIELDS },
1278
+ async (args) => ({ content: [{ type: "text", text: await attach({ kind, ...args }) }] })
1279
+ );
1280
+ }
1281
+ function verdictTool(onVerdict) {
1282
+ return tool2(
1283
+ VERDICT_TOOL_NAME,
1284
+ "Hand in the result of this check. Call it exactly once, at the very end.",
1285
+ {
1286
+ passed: z2.boolean().describe("true only if nothing must be fixed"),
1287
+ report: z2.string().describe("what you checked and, if it failed, exactly what to fix with file and line")
1288
+ },
1289
+ async (args) => {
1290
+ onVerdict({ passed: args.passed, report: args.report });
1291
+ return { content: [{ type: "text", text: "Verdict received. You can stop now." }] };
1292
+ }
1293
+ );
1294
+ }
1295
+ function handoffTool(targets, onHandoff) {
1296
+ return tool2(
1297
+ HANDOFF_TOOL_NAME,
1298
+ "Hand the task to the next step of the TAMsense board when you finish. The next agent and the task owner read your note. Only one handoff counts: calling it again replaces the earlier one.",
1299
+ {
1300
+ to: z2.string().describe("name or id of the step to hand the task to"),
1301
+ outcome: z2.enum(HANDOFF_OUTCOMES).describe("pass: the work is done; problems: something must be fixed; ask: a question for that step"),
1302
+ note: z2.string().describe("for the next agent and the owner: what was done, what to check, what is open")
1303
+ },
1304
+ async (args) => {
1305
+ const choice = chooseHandoff(targets, args);
1306
+ if ("error" in choice) {
1307
+ return { content: [{ type: "text", text: choice.error }], isError: true };
1308
+ }
1309
+ onHandoff(choice.handoff);
1310
+ return { content: [{ type: "text", text: "Handoff recorded. It happens when you finish." }] };
1311
+ }
1312
+ );
1313
+ }
1314
+ function specWorkTools(specWork, submitted) {
1315
+ if (!specWork) {
1316
+ return [];
1317
+ }
1318
+ return specWork.stage === "interview" ? [questionsTool(specWork.review, (questions) => {
1319
+ submitted.questions = questions;
1320
+ })] : [specTool(specWork.review, (spec) => {
1321
+ submitted.spec = spec;
1322
+ }, () => submitted.helperStarted)];
1323
+ }
1324
+ function tamsenseServer(request, submitted) {
1325
+ const tools = [
1326
+ attachTool("screenshot", request.attach),
1327
+ attachTool("replay", request.attach),
1328
+ attachTool("video", request.attach),
1329
+ ...request.wantsVerdict ? [verdictTool((verdict) => {
1330
+ submitted.verdict = verdict;
1331
+ })] : [],
1332
+ ...specWorkTools(request.specWork, submitted),
1333
+ ...request.handoffs.length > 0 ? [handoffTool(request.handoffs, (handoff) => {
1334
+ submitted.handoff = handoff;
1335
+ })] : []
1336
+ ];
1337
+ return {
1338
+ server: createSdkMcpServer({ name: SERVER_NAME, version: "1.0.0", tools }),
1339
+ allowed: tools.map((item) => `mcp__${SERVER_NAME}__${item.name}`)
1340
+ };
1341
+ }
1342
+ function modelOptions(request) {
1343
+ return {
1344
+ ...request.model === null ? {} : { model: request.model },
1345
+ ...request.effort === null ? {} : { effort: request.effort }
1346
+ };
1347
+ }
1348
+ async function applySwitch(target, change) {
1349
+ if (change.model !== null) {
1350
+ await target.setModel(change.model === DEFAULT_MODEL ? void 0 : change.model);
1351
+ }
1352
+ if (change.effort !== null) {
1353
+ await target.applyFlagSettings({ effortLevel: change.effort });
1354
+ }
1355
+ }
1356
+ async function attempt(request, context, resume) {
1357
+ let lastText = "";
1358
+ let sessionId = null;
1359
+ let outcome = { completed: false, report: NO_RESULT_TEXT, model: null, usage: null, verdict: null };
1360
+ const tamsense = tamsenseServer(request, context.submitted);
1361
+ const shown = hiddenStepsFilter();
1362
+ const messages = context.startQuery({
1363
+ prompt: request.input.start(request.prompt.prompt),
1364
+ options: {
1365
+ cwd: request.cwd,
1366
+ systemPrompt: { type: "preset", preset: "claude_code", append: request.prompt.instructions },
1367
+ settingSources: ["user", "project", "local"],
1368
+ permissionMode: "auto",
1369
+ // краткий пересказ размышлений Claude идёт в журнал задачи, как в Paseo
1370
+ thinking: { type: "adaptive", display: "summarized" },
1371
+ canUseTool: denyWhenAsked,
1372
+ plugins: [{ type: "local", path: LIBRARY_PATH }],
1373
+ mcpServers: { [SERVER_NAME]: tamsense.server },
1374
+ allowedTools: tamsense.allowed,
1375
+ abortController: context.abortController,
1376
+ env: request.env,
1377
+ ...modelOptions(request),
1378
+ ...resume === null ? {} : { resume }
1379
+ }
1380
+ });
1381
+ request.input.onSwitch((change) => applySwitch(messages, change));
1382
+ const failedToResume = () => resume !== null && sessionId === null && !request.signal.aborted;
1383
+ try {
1384
+ for await (const message of messages) {
1385
+ if (message.type === "system" && message.subtype === "init") {
1386
+ sessionId = message.session_id;
1387
+ request.onSession(sessionOf(message));
1388
+ }
1389
+ if (message.type === "result" && (message.is_error || message.subtype !== "success") && failedToResume()) {
1390
+ messages.close();
1391
+ return { outcome, sessionId, resumeFailed: true };
1392
+ }
1393
+ for (const draft of feedOf(message).filter(shown)) {
1394
+ if (draft.kind === "agent" && draft.parent === null) {
1395
+ context.submitted.helperStarted = true;
1396
+ }
1397
+ request.onFeed(draft);
1398
+ if (draft.kind === "text" && draft.parent === null) {
1399
+ lastText = draft.text;
1400
+ }
1401
+ }
1402
+ if (message.type === "result") {
1403
+ outcome = outcomeOf(message, lastText);
1404
+ }
1405
+ context.limit.observe(message);
1406
+ request.input.observe(message);
1407
+ }
1408
+ } catch (error) {
1409
+ if (failedToResume()) {
1410
+ return { outcome, sessionId, resumeFailed: true };
1411
+ }
1412
+ throw error;
1413
+ }
1414
+ return { outcome, sessionId, resumeFailed: false };
1415
+ }
1416
+ function claudeRunner(startQuery) {
1417
+ return async (request) => {
1418
+ request.signal.throwIfAborted();
1419
+ const abortController = new AbortController();
1420
+ request.signal.addEventListener("abort", () => {
1421
+ request.input.close();
1422
+ abortController.abort();
1423
+ }, { once: true });
1424
+ const submitted = { verdict: null, handoff: null, questions: null, spec: null, helperStarted: false };
1425
+ const limit = usageLimitWatch();
1426
+ const context = { startQuery, abortController, submitted, limit };
1427
+ try {
1428
+ let result = await attempt(request, context, request.resumeSessionId);
1429
+ if (result.resumeFailed) {
1430
+ result = await attempt(request, context, null);
1431
+ }
1432
+ const pausedUntil = limit.pausedUntil(result.outcome.completed);
1433
+ const outcome = pausedUntil ? { ...result.outcome, completed: false, report: USAGE_LIMIT_REPORT } : result.outcome;
1434
+ return { ...outcome, verdict: submitted.verdict, handoff: submitted.handoff, questions: submitted.questions, spec: submitted.spec, sessionId: result.sessionId, pausedUntil };
1435
+ } finally {
1436
+ request.input.close();
1437
+ }
1438
+ };
1439
+ }
1440
+ var runClaude = claudeRunner(query2);
1441
+
1442
+ // src/config.ts
1443
+ import { mkdir as mkdir2, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1444
+ import { homedir as homedir2 } from "os";
1445
+ import { dirname as dirname2, join as join2 } from "path";
1446
+ var OWNER_ONLY_FILE = 384;
1447
+ var OWNER_ONLY_FOLDER = 448;
1448
+ function configPath(home = homedir2()) {
1449
+ return join2(home, ".tamsense", "runner.json");
1450
+ }
1451
+ function isRecord2(value) {
1452
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1453
+ }
1454
+ function parseConfig(value) {
1455
+ if (!isRecord2(value)) {
1456
+ return null;
1457
+ }
1458
+ const { server, token, deviceId, deviceName, browserDeclined } = value;
1459
+ const isValid = typeof server === "string" && typeof token === "string" && typeof deviceId === "number" && typeof deviceName === "string";
1460
+ return isValid ? { server, token, deviceId, deviceName, ...browserDeclined === true ? { browserDeclined } : {} } : null;
1461
+ }
1462
+ function isMissingFile(error) {
1463
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
1464
+ }
1465
+ async function readConfig(path = configPath()) {
1466
+ try {
1467
+ return parseConfig(JSON.parse(await readFile2(path, "utf8")));
1468
+ } catch (error) {
1469
+ if (isMissingFile(error) || error instanceof SyntaxError) {
1470
+ return null;
1471
+ }
1472
+ throw error;
1473
+ }
1474
+ }
1475
+ async function writeConfig(config, path = configPath()) {
1476
+ await mkdir2(dirname2(path), { recursive: true, mode: OWNER_ONLY_FOLDER });
1477
+ await writeFile2(path, `${JSON.stringify(config, null, 4)}
1478
+ `, { mode: OWNER_ONLY_FILE });
1479
+ }
1480
+ async function removeConfig(path = configPath()) {
1481
+ await rm2(path, { force: true });
1482
+ }
1483
+
1484
+ // src/protocol.ts
1485
+ var MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
1486
+ var MAX_ATTACHMENTS_PER_RUN = 20;
1487
+ var MAX_VIDEO_BYTES = 200 * 1024 * 1024;
1488
+ var PAIRINGS_PATH = "/api/runner/pairings";
1489
+ var PAIRING_TOKEN_PATH = "/api/runner/pairings/token";
1490
+ var IDENTITY_PATH = "/api/runner/me";
1491
+ var NEXT_RUN_PATH = "/api/runner/runs/next";
1492
+ var CAPABILITIES_PATH = "/api/runner/me/capabilities";
1493
+ function runPath(runId, action) {
1494
+ return `/api/runner/runs/${runId}/${action}`;
1495
+ }
1496
+ function recordingUploadPath(runId, upload, part) {
1497
+ return `/api/runner/runs/${runId}/recordings/${upload}/${part}`;
1498
+ }
1499
+
1500
+ // src/login.ts
1501
+ var MS_PER_SECOND = 1e3;
1502
+ var EXPIRED_TEXT = `The code expired before it was confirmed. Run ${LOGIN_COMMAND} again.`;
1503
+ var DENIED_TEXT = "The connection was cancelled in the browser. This computer was not connected.";
1504
+ async function login(tools, request) {
1505
+ const pairing = await tools.api.request("POST", PAIRINGS_PATH, request);
1506
+ const pageUrl = new URL(pairing.verificationPath, tools.api.server).toString();
1507
+ const expiresAt = Date.parse(pairing.expiresAt);
1508
+ tools.print(`Your code: ${pairing.userCode}`);
1509
+ tools.print(`Confirm it in the browser: ${pageUrl}`);
1510
+ tools.print("Waiting for confirmation\u2026");
1511
+ tools.openBrowser(pageUrl);
1512
+ while (tools.now() < expiresAt) {
1513
+ await tools.sleep(pairing.intervalSeconds * MS_PER_SECOND);
1514
+ const reply2 = await tools.api.request("POST", PAIRING_TOKEN_PATH, { deviceCode: pairing.deviceCode });
1515
+ if (reply2.status === "connected") {
1516
+ return { token: reply2.token, device: reply2.device };
1517
+ }
1518
+ if (reply2.status === "denied") {
1519
+ throw new Error(DENIED_TEXT);
1520
+ }
1521
+ if (reply2.status === "expired") {
1522
+ break;
1523
+ }
1524
+ }
1525
+ throw new Error(EXPIRED_TEXT);
1526
+ }
1527
+
1528
+ // src/recorderEnv.ts
1529
+ import { existsSync as existsSync2 } from "fs";
1530
+ import { join as join4 } from "path";
1531
+ import { fileURLToPath as fileURLToPath2 } from "url";
1532
+
1533
+ // src/recorder.ts
1534
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
1535
+ import Module, { createRequire } from "module";
1536
+ import { basename, dirname as dirname3, join as join3 } from "path";
1537
+ import process2 from "process";
1538
+ var RECORDINGS_DIR_ENV = "TAMSENSE_RECORDINGS_DIR";
1539
+ var SERVER_ENV = "TAMSENSE_SERVER";
1540
+ var DEFAULT_TITLE_ENV = "TAMSENSE_RECORDING_TITLE";
1541
+ var DEFAULT_PURPOSE_ENV = "TAMSENSE_RECORDING_PURPOSE";
1542
+ var MARK_TAG = "tamsense.mark";
1543
+ var REPLAY_SUFFIX = ".rrweb.ndjson";
1544
+ var META_SUFFIX = ".json";
1545
+ var MAX_TITLE_LENGTH = 60;
1546
+ var MAX_PURPOSE_LENGTH = 160;
1547
+ var DEFAULT_TRACKER_ORIGINS = ["https://my.tamsense.com"];
1548
+ var BINDING = "__tamsenseRecord";
1549
+ var STATE_KEY = /* @__PURE__ */ Symbol.for("tamsense.recorder");
1550
+ var FLUSH_MS = 100;
1551
+ var MAX_SESSION_BYTES = 300 * 1024 * 1024;
1552
+ var GAP_CHECK_MS = 1500;
1553
+ var RRWEB_META = 4;
1554
+ function state() {
1555
+ const holder = globalThis;
1556
+ holder[STATE_KEY] ??= { contexts: /* @__PURE__ */ new WeakMap(), pages: /* @__PURE__ */ new WeakMap(), instrumented: /* @__PURE__ */ new WeakSet(), script: null, hooked: false };
1557
+ return holder[STATE_KEY];
1558
+ }
1559
+ function recordingsDir() {
1560
+ const dir = process2.env[RECORDINGS_DIR_ENV];
1561
+ if (!dir) {
1562
+ throw new Error(`${RECORDINGS_DIR_ENV} is not set: recordings work only inside a TAMsense run`);
1563
+ }
1564
+ return dir;
1565
+ }
1566
+ function warn(text2) {
1567
+ process2.stderr.write(`[tamsense recorder] ${text2}
1568
+ `);
1569
+ }
1570
+ function isTrackerIngest(url, tamsenseOrigins) {
1571
+ if (!/\/api\/events\/?$/.test(url.pathname)) {
1572
+ return false;
1573
+ }
1574
+ return url.searchParams.has("key") || tamsenseOrigins.includes(url.origin);
1575
+ }
1576
+ function trackerOrigins(env = process2.env) {
1577
+ const origins = new Set(DEFAULT_TRACKER_ORIGINS);
1578
+ try {
1579
+ if (env[SERVER_ENV]) {
1580
+ origins.add(new URL(env[SERVER_ENV]).origin);
1581
+ }
1582
+ } catch {
1583
+ }
1584
+ return [...origins];
1585
+ }
1586
+ function checkCaption(caption) {
1587
+ const title = typeof caption?.title === "string" ? caption.title.trim() : "";
1588
+ const purpose = typeof caption?.purpose === "string" ? caption.purpose.trim() : "";
1589
+ if (!title || !purpose) {
1590
+ throw new Error('Give it a title (what it shows, for example "Design option 1" or "Feature testing, attempt 2") and a purpose (one line on why it was made).');
1591
+ }
1592
+ if (title.length > MAX_TITLE_LENGTH || purpose.length > MAX_PURPOSE_LENGTH) {
1593
+ throw new Error(`Keep the title within ${MAX_TITLE_LENGTH} characters and the purpose within ${MAX_PURPOSE_LENGTH}.`);
1594
+ }
1595
+ return { title, purpose };
1596
+ }
1597
+ function clip2(text2, max) {
1598
+ return text2.length > max ? `${text2.slice(0, max - 1).trimEnd()}\u2026` : text2;
1599
+ }
1600
+ function automaticCaption(env = process2.env, script2 = process2.argv[1]) {
1601
+ const from = script2 ? `, opened by ${basename(script2)}` : "";
1602
+ return {
1603
+ title: clip2(env[DEFAULT_TITLE_ENV]?.trim() || "Browser session", MAX_TITLE_LENGTH),
1604
+ purpose: clip2(`${env[DEFAULT_PURPOSE_ENV]?.trim() || "Recorded automatically while the agent used a browser"}${from}`, MAX_PURPOSE_LENGTH)
1605
+ };
1606
+ }
1607
+ function rrwebSource() {
1608
+ const entry = createRequire(import.meta.url).resolve("rrweb");
1609
+ return readFileSync(join3(dirname3(entry), "rrweb.umd.min.cjs"), "utf8");
1610
+ }
1611
+ function pageScript(rrweb) {
1612
+ return `(() => {
1613
+ if (window.top !== window || window.__tamsenseRecording) return;
1614
+ window.__tamsenseRecording = true;
1615
+ const rrweb = (() => { const module = { exports: {} }; const exports = module.exports; ${rrweb}
1616
+ ; return module.exports; })();
1617
+ let buffer = [];
1618
+ let timer = null;
1619
+ const send = (extra) => {
1620
+ clearTimeout(timer);
1621
+ timer = null;
1622
+ if (!buffer.length && !extra) return Promise.resolve();
1623
+ const events = buffer;
1624
+ buffer = [];
1625
+ return window.${BINDING}(JSON.stringify({ events, ...extra })).catch(() => {});
1626
+ };
1627
+ rrweb.record({
1628
+ emit(event) {
1629
+ buffer.push(event);
1630
+ if (!timer) timer = setTimeout(send, ${FLUSH_MS});
1631
+ },
1632
+ maskInputOptions: { password: true },
1633
+ });
1634
+ const crossOrigin = (frame) => { try { return new URL(frame.src, location.href).origin !== location.origin; } catch { return false; } };
1635
+ const checkGaps = () => {
1636
+ const gaps = [];
1637
+ if (document.querySelector('canvas')) gaps.push('canvas');
1638
+ if (document.querySelector('video')) gaps.push('video');
1639
+ if ([...document.querySelectorAll('iframe')].some(crossOrigin)) gaps.push('cross-origin iframe');
1640
+ if (gaps.length) send({ gaps });
1641
+ };
1642
+ window.__tamsenseFlush = () => send();
1643
+ window.__tamsenseMark = (label) => { rrweb.record.addCustomEvent('${MARK_TAG}', { label }); return send(); };
1644
+ addEventListener('pagehide', () => { send(); });
1645
+ addEventListener('load', () => setTimeout(checkGaps, ${GAP_CHECK_MS}));
1646
+ })();`;
1647
+ }
1648
+ function script() {
1649
+ const current = state();
1650
+ current.script ??= pageScript(rrwebSource());
1651
+ return current.script;
1652
+ }
1653
+ var Session = class {
1654
+ file;
1655
+ metaFile;
1656
+ meta;
1657
+ bytes = 0;
1658
+ constructor(dir, recording, url) {
1659
+ const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
1660
+ this.file = join3(dir, `${id}${REPLAY_SUFFIX}`);
1661
+ this.metaFile = join3(dir, `${id}${META_SUFFIX}`);
1662
+ this.meta = { version: 1, id, ...recording.caption, startedAt: Date.now(), endedAt: null, url, marks: [], video: null, gaps: [], blockedTrackerRequests: 0, truncated: false, automatic: recording.automatic };
1663
+ writeFileSync(this.file, "");
1664
+ this.save();
1665
+ }
1666
+ save() {
1667
+ writeFileSync(this.metaFile, `${JSON.stringify(this.meta, null, 2)}
1668
+ `);
1669
+ }
1670
+ receive(payload) {
1671
+ let batch;
1672
+ try {
1673
+ batch = JSON.parse(payload);
1674
+ } catch {
1675
+ return;
1676
+ }
1677
+ if (batch.gaps?.length) {
1678
+ this.meta.gaps = [.../* @__PURE__ */ new Set([...this.meta.gaps, ...batch.gaps])];
1679
+ this.save();
1680
+ }
1681
+ const page = batch.events?.find((event) => event.type === RRWEB_META && typeof event.data?.href === "string" && event.data.href !== "about:blank");
1682
+ if (page && this.meta.url === "about:blank") {
1683
+ this.meta.url = String(page.data.href);
1684
+ this.save();
1685
+ }
1686
+ const lines = (batch.events ?? []).map((event) => JSON.stringify(event)).join("\n");
1687
+ if (!lines || this.meta.truncated) {
1688
+ return;
1689
+ }
1690
+ if (this.bytes + lines.length > MAX_SESSION_BYTES) {
1691
+ this.meta.truncated = true;
1692
+ this.save();
1693
+ return;
1694
+ }
1695
+ this.bytes += lines.length + 1;
1696
+ appendFileSync(this.file, `${lines}
1697
+ `);
1698
+ }
1699
+ end() {
1700
+ if (this.meta.endedAt === null) {
1701
+ this.meta.endedAt = Date.now();
1702
+ this.save();
1703
+ }
1704
+ }
1705
+ summary() {
1706
+ const { title, purpose, video, marks, gaps, blockedTrackerRequests } = this.meta;
1707
+ return { title, purpose, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests };
1708
+ }
1709
+ };
1710
+ async function startSession(recording, page, injectNow) {
1711
+ const current = state();
1712
+ if (current.pages.has(page)) {
1713
+ return;
1714
+ }
1715
+ const session = new Session(recordingsDir(), recording, page.url());
1716
+ current.pages.set(page, session);
1717
+ recording.sessions.push(session);
1718
+ page.on("close", () => session.end());
1719
+ const video = page.video();
1720
+ if (video) {
1721
+ session.meta.video = await video.path().catch(() => null);
1722
+ session.save();
1723
+ }
1724
+ if (injectNow) {
1725
+ await page.evaluate(script()).catch(() => {
1726
+ });
1727
+ }
1728
+ }
1729
+ async function flush(context) {
1730
+ await Promise.all(context.pages().filter((page) => !page.isClosed()).map(
1731
+ (page) => page.evaluate(() => window.__tamsenseFlush?.(), void 0).catch(() => {
1732
+ })
1733
+ ));
1734
+ }
1735
+ async function recordContext(context, caption, automatic = false) {
1736
+ const checked = checkCaption(caption);
1737
+ const current = state();
1738
+ const known = current.contexts.get(context);
1739
+ if (known) {
1740
+ Object.assign(known, { caption: checked, automatic });
1741
+ for (const session of known.sessions) {
1742
+ Object.assign(session.meta, checked, { automatic });
1743
+ session.save();
1744
+ }
1745
+ return;
1746
+ }
1747
+ mkdirSync(recordingsDir(), { recursive: true });
1748
+ const recording = { caption: checked, automatic, sessions: [] };
1749
+ current.contexts.set(context, recording);
1750
+ const origins = trackerOrigins();
1751
+ await context.route((url) => isTrackerIngest(url, origins), async (route) => {
1752
+ try {
1753
+ const session = current.pages.get(route.request().frame().page());
1754
+ if (session) {
1755
+ session.meta.blockedTrackerRequests += 1;
1756
+ session.save();
1757
+ }
1758
+ } catch {
1759
+ }
1760
+ await route.fulfill({ status: 200, contentType: "application/json", body: '{"ok":true}' });
1761
+ });
1762
+ await context.exposeBinding(BINDING, ({ page }, payload) => current.pages.get(page)?.receive(payload));
1763
+ await context.addInitScript({ content: script() });
1764
+ context.on("page", (page) => void startSession(recording, page, false));
1765
+ context.on("close", () => recording.sessions.forEach((session) => session.end()));
1766
+ for (const page of context.pages()) {
1767
+ await startSession(recording, page, true);
1768
+ }
1769
+ const close = context.close.bind(context);
1770
+ context.close = async (options) => {
1771
+ await flush(context);
1772
+ return close(options);
1773
+ };
1774
+ }
1775
+ function instrument(exports) {
1776
+ const chromium = exports?.chromium;
1777
+ const instrumentation = exports?._instrumentation ?? chromium?._instrumentation;
1778
+ const current = state();
1779
+ if (typeof chromium?.launch !== "function" || typeof instrumentation?.addListener !== "function" || current.instrumented.has(instrumentation)) {
1780
+ return;
1781
+ }
1782
+ current.instrumented.add(instrumentation);
1783
+ instrumentation.addListener({
1784
+ runAfterCreateBrowserContext: async (context) => {
1785
+ try {
1786
+ if (!current.contexts.has(context)) {
1787
+ await recordContext(context, automaticCaption(), true);
1788
+ }
1789
+ } catch (error) {
1790
+ warn(`could not record this browser: ${error instanceof Error ? error.message : String(error)}`);
1791
+ }
1792
+ }
1793
+ });
1794
+ }
1795
+ function installAutoRecording() {
1796
+ const current = state();
1797
+ if (current.hooked) {
1798
+ return;
1799
+ }
1800
+ current.hooked = true;
1801
+ const loader = Module;
1802
+ const load = loader._load;
1803
+ loader._load = function(...args) {
1804
+ const exports = load.apply(this, args);
1805
+ try {
1806
+ instrument(exports);
1807
+ } catch {
1808
+ }
1809
+ return exports;
1810
+ };
1811
+ }
1812
+ if (process2.env[RECORDINGS_DIR_ENV]) {
1813
+ installAutoRecording();
1814
+ }
1815
+
1816
+ // src/recorderEnv.ts
1817
+ var RECORDER_ENV = "TAMSENSE_RECORDER";
1818
+ function bundledRecorder() {
1819
+ const path = fileURLToPath2(new URL("./recorder.cjs", import.meta.url));
1820
+ return existsSync2(path) ? path : null;
1821
+ }
1822
+ function recordingsFolder(home, runId) {
1823
+ return join4(home, ".tamsense", "recordings", `run-${runId}`);
1824
+ }
1825
+ function nodeOptionsWithRecorder(recorder, nodeOptions) {
1826
+ return [nodeOptions?.trim(), `--require "${recorder.replaceAll("\\", "/")}"`].filter(Boolean).join(" ");
1827
+ }
1828
+ function recorderEnv(input) {
1829
+ const { job } = input;
1830
+ return {
1831
+ [RECORDER_ENV]: input.recorder,
1832
+ [RECORDINGS_DIR_ENV]: input.folder,
1833
+ [SERVER_ENV]: input.server,
1834
+ [DEFAULT_TITLE_ENV]: `${job.stage.name} browser session`.slice(0, MAX_TITLE_LENGTH),
1835
+ [DEFAULT_PURPOSE_ENV]: `Recorded automatically in the ${job.stage.name} step of task #${job.taskId}`.slice(0, MAX_PURPOSE_LENGTH),
1836
+ NODE_OPTIONS: nodeOptionsWithRecorder(input.recorder, input.nodeOptions)
1837
+ };
1838
+ }
1839
+
1840
+ // src/scope.ts
1841
+ var TEAM_KEY_MISSING_TEXT = "This is a team computer: Claude must run on the Anthropic API key of your company, not on a personal Claude subscription. Set ANTHROPIC_API_KEY on this computer and start again.";
1842
+ function teamKeyProblem(isTeam, env) {
1843
+ return isTeam && !env.ANTHROPIC_API_KEY?.trim() ? TEAM_KEY_MISSING_TEXT : null;
1844
+ }
1845
+ function describeDevice(identity) {
1846
+ return identity.device.team ? `a team computer of ${identity.projectName}` : `the personal computer of ${identity.email ?? "its owner"}`;
1847
+ }
1848
+
1849
+ // src/system.ts
1850
+ import { spawn as spawn2 } from "child_process";
1851
+ import { hostname } from "os";
1852
+ import { delimiter as delimiter2 } from "path";
1853
+ import { createInterface as createInterface2 } from "readline/promises";
1854
+ import { setTimeout as wait } from "timers/promises";
1855
+ var PLATFORM_LABELS = {
1856
+ win32: "Windows",
1857
+ darwin: "macOS",
1858
+ linux: "Linux"
1859
+ };
1860
+ var MAX_NAME_LENGTH = 80;
1861
+ function platformLabel(platform = process.platform) {
1862
+ return PLATFORM_LABELS[platform] ?? platform;
1863
+ }
1864
+ function defaultDeviceName() {
1865
+ return hostname().slice(0, MAX_NAME_LENGTH) || "My computer";
1866
+ }
1867
+ function browserCommand(url, platform) {
1868
+ if (platform === "win32") {
1869
+ return ["rundll32", ["url.dll,FileProtocolHandler", url]];
1870
+ }
1871
+ return [platform === "darwin" ? "open" : "xdg-open", [url]];
1872
+ }
1873
+ function openBrowser(url, platform = process.platform) {
1874
+ const [command, args] = browserCommand(url, platform);
1875
+ const child = spawn2(command, args, { detached: true, stdio: "ignore" });
1876
+ child.on("error", () => {
1877
+ });
1878
+ child.unref();
1879
+ }
1880
+ function withNodeOnPath(env, nodeDir, separator = delimiter2) {
1881
+ const key = Object.keys(env).find((name) => name.toUpperCase() === "PATH") ?? "PATH";
1882
+ const entries = (env[key] ?? "").split(separator).filter(Boolean);
1883
+ const normalize = (dir) => dir.replace(/[\\/]+$/, "").toLowerCase();
1884
+ if (entries.some((entry) => normalize(entry) === normalize(nodeDir))) {
1885
+ return env;
1886
+ }
1887
+ return { ...env, [key]: [...entries, nodeDir].join(separator) };
1888
+ }
1889
+ async function askYesNo(question) {
1890
+ const prompt = createInterface2({ input: process.stdin, output: process.stdout });
1891
+ try {
1892
+ for (; ; ) {
1893
+ const answer = (await prompt.question(question)).trim().toLowerCase();
1894
+ if (answer === "" || answer === "y" || answer === "yes") {
1895
+ return true;
1896
+ }
1897
+ if (answer === "n" || answer === "no") {
1898
+ return false;
1899
+ }
1900
+ }
1901
+ } finally {
1902
+ prompt.close();
1903
+ }
1904
+ }
1905
+ async function sleep(ms, signal) {
1906
+ await wait(ms, void 0, { signal }).catch(() => {
1907
+ });
1908
+ }
1909
+
1910
+ // src/update.ts
1911
+ import { spawn as spawn3 } from "child_process";
1912
+ import { readFileSync as readFileSync2 } from "fs";
1913
+ import process3 from "process";
1914
+ var PACKAGE_NAME = "@tamsensedev/runner";
1915
+ var RELAUNCHED_ENV = "TAMSENSE_RELAUNCHED";
1916
+ var NO_UPDATE_ENV = "TAMSENSE_NO_UPDATE";
1917
+ var REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME.replace("/", "%2f")}/latest`;
1918
+ var REGISTRY_TIMEOUT_MS = 3e3;
1919
+ var RELEASE_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
1920
+ function releaseParts(version) {
1921
+ const match = RELEASE_PATTERN.exec(version.trim());
1922
+ return match ? match.slice(1).map(Number) : null;
1923
+ }
1924
+ function isNewer(latest, current) {
1925
+ const next = releaseParts(latest);
1926
+ const now = releaseParts(current);
1927
+ if (!next || !now) {
1928
+ return false;
1929
+ }
1930
+ const index = next.findIndex((part, position) => part !== now[position]);
1931
+ return index !== -1 && next[index] > now[index];
1932
+ }
1933
+ function currentVersion() {
1934
+ try {
1935
+ const manifest = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
1936
+ return manifest.name === PACKAGE_NAME && typeof manifest.version === "string" ? manifest.version : null;
1937
+ } catch {
1938
+ return null;
1939
+ }
1940
+ }
1941
+ async function fetchLatest(url, signal) {
1942
+ const response = await fetch(url, { signal, headers: { accept: "application/json" } });
1943
+ return response.ok ? await response.json() : null;
1944
+ }
1945
+ async function latestVersion(fetchJson = fetchLatest, timeoutMs = REGISTRY_TIMEOUT_MS) {
1946
+ try {
1947
+ const body = await fetchJson(REGISTRY_URL, AbortSignal.timeout(timeoutMs));
1948
+ const version = body && typeof body === "object" ? body.version : null;
1949
+ return typeof version === "string" ? version : null;
1950
+ } catch {
1951
+ return null;
1952
+ }
1953
+ }
1954
+ async function updateToRun(env, current, latest) {
1955
+ if (!current || env[RELAUNCHED_ENV] || env[NO_UPDATE_ENV]) {
1956
+ return null;
1957
+ }
1958
+ const found = await latest();
1959
+ return found && isNewer(found, current) ? { current, latest: found } : null;
1960
+ }
1961
+ function relaunchArgs(argv) {
1962
+ return ["--yes", `${PACKAGE_NAME}@latest`, ...argv];
1963
+ }
1964
+ function relaunch(argv, env) {
1965
+ const isWindows = process3.platform === "win32";
1966
+ return new Promise((resolve, reject) => {
1967
+ const child = spawn3(isWindows ? "npx.cmd" : "npx", relaunchArgs(argv), { stdio: "inherit", shell: isWindows, env: { ...env, [RELAUNCHED_ENV]: "1" } });
1968
+ const ignoreInterrupt = () => {
1969
+ };
1970
+ process3.on("SIGINT", ignoreInterrupt);
1971
+ child.on("error", (error) => {
1972
+ process3.off("SIGINT", ignoreInterrupt);
1973
+ reject(error);
1974
+ });
1975
+ child.on("close", (code) => {
1976
+ process3.off("SIGINT", ignoreInterrupt);
1977
+ resolve(code ?? 1);
1978
+ });
1979
+ });
1980
+ }
1981
+
1982
+ // src/worker.ts
1983
+ import { rm as rm4 } from "fs/promises";
1984
+ import { dirname as dirname5 } from "path";
1985
+ import process4 from "process";
1986
+
1987
+ // src/feedQueue.ts
1988
+ var MAX_BATCH = 200;
1989
+ var MAX_WAITING = 2e3;
1990
+ function feedQueue(send) {
1991
+ let seq = 0;
1992
+ let waiting = [];
1993
+ let sending = Promise.resolve();
1994
+ async function drain() {
1995
+ while (waiting.length > 0) {
1996
+ const batch = waiting.slice(0, MAX_BATCH);
1997
+ try {
1998
+ await send(batch);
1999
+ } catch {
2000
+ return;
2001
+ }
2002
+ const lastSent = batch.at(-1).seq;
2003
+ waiting = waiting.filter((entry) => entry.seq > lastSent);
2004
+ }
2005
+ }
2006
+ return {
2007
+ push(draft) {
2008
+ seq += 1;
2009
+ const entry = { ...draft, seq };
2010
+ waiting.push(entry);
2011
+ if (waiting.length > MAX_WAITING) {
2012
+ waiting = waiting.slice(-MAX_WAITING);
2013
+ }
2014
+ return entry;
2015
+ },
2016
+ flush() {
2017
+ sending = sending.then(drain);
2018
+ return sending;
2019
+ }
2020
+ };
2021
+ }
2022
+
2023
+ // src/lock.ts
2024
+ function keyedLock() {
2025
+ const tails = /* @__PURE__ */ new Map();
2026
+ return {
2027
+ async run(key, work2) {
2028
+ const previous = tails.get(key) ?? Promise.resolve();
2029
+ let release = () => {
2030
+ };
2031
+ const done = new Promise((resolve) => {
2032
+ release = resolve;
2033
+ });
2034
+ const tail = previous.then(() => done);
2035
+ tails.set(key, tail);
2036
+ await previous;
2037
+ try {
2038
+ return await work2();
2039
+ } finally {
2040
+ release();
2041
+ if (tails.get(key) === tail) {
2042
+ tails.delete(key);
2043
+ }
2044
+ }
2045
+ }
2046
+ };
2047
+ }
2048
+
2049
+ // src/recordings.ts
2050
+ import { randomUUID } from "crypto";
2051
+ import { createReadStream } from "fs";
2052
+ import { readdir, readFile as readFile3, stat, writeFile as writeFile3 } from "fs/promises";
2053
+ import { basename as basename2, dirname as dirname4, extname, isAbsolute, join as join5 } from "path";
2054
+ import { createInterface as createInterface3 } from "readline";
2055
+ import { Readable } from "stream";
2056
+ import { promisify } from "util";
2057
+ import { gzip } from "zlib";
2058
+ var gzipAsync = promisify(gzip);
2059
+ var CHUNK_TARGET_BYTES = 2 * 1024 * 1024;
2060
+ var MAX_LINE_BYTES = 30 * 1024 * 1024;
2061
+ var LEDGER_FILE = "uploaded.json";
2062
+ var RRWEB_FULL_SNAPSHOT = 2;
2063
+ var RRWEB_META2 = 4;
2064
+ var RRWEB_CUSTOM = 5;
2065
+ var VIDEO_TYPES_BY_EXTENSION = {
2066
+ ".webm": "video/webm",
2067
+ ".mp4": "video/mp4"
2068
+ };
2069
+ function eventOf(line) {
2070
+ try {
2071
+ const event = JSON.parse(line);
2072
+ return typeof event?.type === "number" && typeof event.timestamp === "number" ? event : null;
2073
+ } catch {
2074
+ return null;
2075
+ }
2076
+ }
2077
+ async function* replayChunks(lines, scan, targetBytes = CHUNK_TARGET_BYTES) {
2078
+ let chunk = [];
2079
+ let bytes = 0;
2080
+ let previous = null;
2081
+ for await (const line of lines) {
2082
+ const event = line.trim() ? eventOf(line) : null;
2083
+ if (!event) {
2084
+ continue;
2085
+ }
2086
+ if (line.length > MAX_LINE_BYTES) {
2087
+ throw new Error("One event of this replay is larger than 30 MB, so it cannot be uploaded.");
2088
+ }
2089
+ const startsGroup = event.type === RRWEB_META2 || event.type === RRWEB_FULL_SNAPSHOT && previous?.type !== RRWEB_META2;
2090
+ if (chunk.length > 0 && (startsGroup || bytes + line.length > targetBytes)) {
2091
+ yield `${chunk.join("\n")}
2092
+ `;
2093
+ chunk = [];
2094
+ bytes = 0;
2095
+ }
2096
+ chunk.push(line);
2097
+ bytes += line.length + 1;
2098
+ previous = event;
2099
+ scan.startTs = Math.min(scan.startTs, event.timestamp);
2100
+ scan.snapshot ||= event.type === RRWEB_FULL_SNAPSHOT;
2101
+ if (event.type === RRWEB_CUSTOM && event.data?.tag === MARK_TAG && typeof event.data.payload?.label === "string") {
2102
+ scan.marks.push({ ts: event.timestamp, label: event.data.payload.label });
2103
+ }
2104
+ }
2105
+ if (chunk.length > 0) {
2106
+ yield `${chunk.join("\n")}
2107
+ `;
2108
+ }
2109
+ }
2110
+ function marksFrom(marks, startTs) {
2111
+ return marks.map((mark) => ({ at: Math.max(0, mark.ts - startTs), label: mark.label })).sort((a, b) => a.at - b.at);
2112
+ }
2113
+ function sessionIdOf(file) {
2114
+ return basename2(file).replace(REPLAY_SUFFIX, "");
2115
+ }
2116
+ async function readMeta(file) {
2117
+ try {
2118
+ return JSON.parse(await readFile3(file, "utf8"));
2119
+ } catch {
2120
+ return null;
2121
+ }
2122
+ }
2123
+ async function metaFor(kind, file) {
2124
+ if (kind === "replay") {
2125
+ return readMeta(join5(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
2126
+ }
2127
+ const folder = basename2(dirname4(file)) === "video" ? dirname4(dirname4(file)) : dirname4(file);
2128
+ for (const name of await readdir(folder).catch(() => [])) {
2129
+ if (name.endsWith(META_SUFFIX) && name !== LEDGER_FILE) {
2130
+ const meta = await readMeta(join5(folder, name));
2131
+ if (meta?.video === file) {
2132
+ return meta;
2133
+ }
2134
+ }
2135
+ }
2136
+ return null;
2137
+ }
2138
+ async function ledger(dir) {
2139
+ try {
2140
+ return new Set(JSON.parse(await readFile3(join5(dir, LEDGER_FILE), "utf8")));
2141
+ } catch {
2142
+ return /* @__PURE__ */ new Set();
2143
+ }
2144
+ }
2145
+ async function remember(dir, file) {
2146
+ const done = await ledger(dir);
2147
+ done.add(file);
2148
+ await writeFile3(join5(dir, LEDGER_FILE), JSON.stringify([...done]));
2149
+ }
2150
+ async function nonEmpty(file) {
2151
+ return file !== null && ((await stat(file).catch(() => null))?.size ?? 0) > 0;
2152
+ }
2153
+ async function pendingRecordings(dir) {
2154
+ const done = await ledger(dir);
2155
+ const names = (await readdir(dir).catch(() => [])).sort();
2156
+ const pending = [];
2157
+ for (const name of names) {
2158
+ if (!name.endsWith(META_SUFFIX) || name === LEDGER_FILE) {
2159
+ continue;
2160
+ }
2161
+ const meta = await readMeta(join5(dir, name));
2162
+ const replay = join5(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
2163
+ if (!done.has(replay) && await nonEmpty(replay)) {
2164
+ pending.push({ kind: "replay", file: replay, meta });
2165
+ }
2166
+ if (meta?.video && !done.has(meta.video) && await nonEmpty(meta.video)) {
2167
+ pending.push({ kind: "video", file: meta.video, meta });
2168
+ }
2169
+ }
2170
+ return pending;
2171
+ }
2172
+ async function uploadReplay(upload, file, caption) {
2173
+ const id = randomUUID();
2174
+ const scan = { startTs: Number.POSITIVE_INFINITY, snapshot: false, marks: [] };
2175
+ const lines = createInterface3({ input: createReadStream(file, "utf8"), crlfDelay: Infinity });
2176
+ for await (const chunk of replayChunks(lines, scan)) {
2177
+ await upload.api.upload("POST", recordingUploadPath(upload.runId, id, "chunks"), new Uint8Array(await gzipAsync(chunk)), "application/gzip");
2178
+ }
2179
+ if (!scan.snapshot) {
2180
+ throw new Error("This replay has no page snapshot, so it cannot be played. Was the page ever opened?");
2181
+ }
2182
+ return finish(upload, { kind: "replay", upload: id, name: basename2(file), ...caption, contentType: "application/x-ndjson", durationMs: null, marks: marksFrom(scan.marks, scan.startTs) });
2183
+ }
2184
+ async function uploadVideo(upload, file, caption, meta) {
2185
+ const contentType = VIDEO_TYPES_BY_EXTENSION[extname(file).toLowerCase()];
2186
+ const info = await stat(file);
2187
+ if (info.size > MAX_VIDEO_BYTES) {
2188
+ throw new Error(`The video is larger than ${MAX_VIDEO_BYTES / 1024 / 1024} MB. Record a shorter one.`);
2189
+ }
2190
+ const id = randomUUID();
2191
+ const body = Readable.toWeb(createReadStream(file));
2192
+ await upload.api.upload("PUT", recordingUploadPath(upload.runId, id, "video"), body, contentType);
2193
+ const startedAt = meta?.startedAt ?? info.birthtimeMs;
2194
+ const endedAt = meta?.endedAt ?? info.mtimeMs;
2195
+ const durationMs = Math.max(0, Math.round(endedAt - startedAt));
2196
+ const marks = marksFrom(meta?.marks ?? [], startedAt).filter((mark) => mark.at <= durationMs);
2197
+ return finish(upload, { kind: "video", upload: id, name: basename2(file), ...caption, contentType, durationMs, marks });
2198
+ }
2199
+ async function finish(upload, body) {
2200
+ return (await upload.api.request("POST", runPath(upload.runId, "recordings"), body)).id;
2201
+ }
2202
+ async function attachRecording(api, runId, recordingsDir2, request) {
2203
+ if (!isAbsolute(request.path)) {
2204
+ return `Give the absolute path of the ${request.kind === "replay" ? "replay (.rrweb.ndjson)" : "video (.webm or .mp4)"} file.`;
2205
+ }
2206
+ const fitsKind = request.kind === "replay" ? request.path.endsWith(REPLAY_SUFFIX) : extname(request.path).toLowerCase() in VIDEO_TYPES_BY_EXTENSION;
2207
+ if (!fitsKind) {
2208
+ return request.kind === "replay" ? `A replay is the ${REPLAY_SUFFIX} file the TAMsense recorder wrote.` : "Only WebM and MP4 videos can be attached.";
2209
+ }
2210
+ if (!await nonEmpty(request.path)) {
2211
+ return `There is no recording at ${request.path}. Close the browser context first (closeRecorded) so the file is complete.`;
2212
+ }
2213
+ let caption;
2214
+ try {
2215
+ caption = checkCaption(request);
2216
+ } catch (error) {
2217
+ return error instanceof Error ? error.message : String(error);
2218
+ }
2219
+ try {
2220
+ const meta = await metaFor(request.kind, request.path);
2221
+ const upload = { api, runId };
2222
+ await (request.kind === "replay" ? uploadReplay(upload, request.path, caption) : uploadVideo(upload, request.path, caption, meta));
2223
+ await remember(recordingsDir2, request.path);
2224
+ } catch (error) {
2225
+ return `Could not attach the ${request.kind}: ${error instanceof Error ? error.message : String(error)}`;
2226
+ }
2227
+ return "Attached to the task.";
2228
+ }
2229
+ function numbered(key, title, used) {
2230
+ const count = (used.get(key) ?? 0) + 1;
2231
+ used.set(key, count);
2232
+ const suffix = count > 1 ? ` (${count})` : "";
2233
+ return `${title.slice(0, MAX_TITLE_LENGTH - suffix.length)}${suffix}`;
2234
+ }
2235
+ function overLimit(error) {
2236
+ return error instanceof ApiError && error.status === 422 && /at most/i.test(error.message);
2237
+ }
2238
+ async function uploadPendingRecordings(api, runId, dir) {
2239
+ const report = { uploaded: 0, failed: [], skipped: 0 };
2240
+ const used = /* @__PURE__ */ new Map();
2241
+ const full = /* @__PURE__ */ new Set();
2242
+ const upload = { api, runId };
2243
+ const pending = (await pendingRecordings(dir)).sort((a, b) => Number(a.meta?.automatic ?? true) - Number(b.meta?.automatic ?? true));
2244
+ for (const recording of pending) {
2245
+ if (full.has(recording.kind)) {
2246
+ report.skipped += 1;
2247
+ continue;
2248
+ }
2249
+ const title = recording.meta?.title || "Browser session";
2250
+ const caption = checkCaption({
2251
+ title: numbered(`${recording.kind}:${title}`, title, used),
2252
+ purpose: recording.meta?.purpose || "Recorded automatically while the agent used a browser"
2253
+ });
2254
+ try {
2255
+ await (recording.kind === "replay" ? uploadReplay(upload, recording.file, caption) : uploadVideo(upload, recording.file, caption, recording.meta));
2256
+ await remember(dir, recording.file);
2257
+ report.uploaded += 1;
2258
+ } catch (error) {
2259
+ if (overLimit(error)) {
2260
+ full.add(recording.kind);
2261
+ report.skipped += 1;
2262
+ continue;
2263
+ }
2264
+ report.failed.push(`${caption.title} (${recording.kind}): ${error instanceof Error ? error.message : String(error)}`);
2265
+ }
2266
+ }
2267
+ return report;
2268
+ }
2269
+
2270
+ // src/screenshot.ts
2271
+ import { readFile as readFile4, stat as stat2 } from "fs/promises";
2272
+ import { basename as basename3, extname as extname2, isAbsolute as isAbsolute2 } from "path";
2273
+ var TYPES_BY_EXTENSION = {
2274
+ ".png": "image/png",
2275
+ ".jpg": "image/jpeg",
2276
+ ".jpeg": "image/jpeg",
2277
+ ".webp": "image/webp"
2278
+ };
2279
+ async function readScreenshot(request) {
2280
+ if (!isAbsolute2(request.path)) {
2281
+ return "Give the absolute path of the image file.";
2282
+ }
2283
+ const contentType = TYPES_BY_EXTENSION[extname2(request.path).toLowerCase()];
2284
+ if (!contentType) {
2285
+ return "Only PNG, JPEG and WebP images can be attached.";
2286
+ }
2287
+ let caption;
2288
+ try {
2289
+ caption = checkCaption(request);
2290
+ } catch (error) {
2291
+ return error instanceof Error ? error.message : String(error);
2292
+ }
2293
+ const info = await stat2(request.path).catch(() => null);
2294
+ if (!info?.isFile()) {
2295
+ return `There is no file at ${request.path}.`;
2296
+ }
2297
+ if (info.size > MAX_ATTACHMENT_BYTES) {
2298
+ return "The image is larger than 5 MB. Take a smaller screenshot (viewport instead of full page, or JPEG).";
2299
+ }
2300
+ const data = (await readFile4(request.path)).toString("base64");
2301
+ return { name: basename3(request.path), ...caption, contentType, data };
2302
+ }
2303
+
2304
+ // src/workspace.ts
2305
+ import { existsSync as existsSync3 } from "fs";
2306
+ import { rm as rm3 } from "fs/promises";
2307
+ import { join as join6 } from "path";
2308
+
2309
+ // src/git.ts
2310
+ import { Buffer } from "buffer";
2311
+ import { spawn as spawn4 } from "child_process";
2312
+ var GITHUB_ORIGIN = "https://github.com/";
2313
+ function githubAuthEnv(token) {
2314
+ const credentials = Buffer.from(`x-access-token:${token}`).toString("base64");
2315
+ return {
2316
+ GIT_CONFIG_COUNT: "1",
2317
+ GIT_CONFIG_KEY_0: `http.${GITHUB_ORIGIN}.extraheader`,
2318
+ GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
2319
+ GIT_TERMINAL_PROMPT: "0"
2320
+ };
2321
+ }
2322
+ function runGit(args, options) {
2323
+ const env = { ...process.env, ...options.env, ...options.token ? githubAuthEnv(options.token) : {} };
2324
+ return new Promise((resolve, reject) => {
2325
+ const child = spawn4("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"] });
2326
+ let stdout = "";
2327
+ let stderr = "";
2328
+ child.stdout.on("data", (chunk) => {
2329
+ stdout += chunk.toString();
2330
+ });
2331
+ child.stderr.on("data", (chunk) => {
2332
+ stderr += chunk.toString();
2333
+ });
2334
+ child.on("error", reject);
2335
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }));
2336
+ });
2337
+ }
2338
+ async function git(args, options) {
2339
+ const result = await runGit(args, options);
2340
+ if (result.code !== 0) {
2341
+ throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`);
2342
+ }
2343
+ return result.stdout;
2344
+ }
2345
+
2346
+ // src/workspace.ts
2347
+ var FALLBACK_IDENTITY = { name: "TAMsense", email: "noreply@tamsense.com" };
2348
+ var cacheLock = keyedLock();
2349
+ function githubRemoteUrl(fullName) {
2350
+ return `https://github.com/${fullName}.git`;
2351
+ }
2352
+ function tamsenseFolder(home, ...parts) {
2353
+ return join6(home, ".tamsense", ...parts);
2354
+ }
2355
+ function folderNames(repositories) {
2356
+ const shortName = (fullName) => fullName.split("/").pop() ?? fullName;
2357
+ const counts = /* @__PURE__ */ new Map();
2358
+ for (const repository of repositories) {
2359
+ counts.set(shortName(repository.fullName), (counts.get(shortName(repository.fullName)) ?? 0) + 1);
2360
+ }
2361
+ return repositories.map((repository) => counts.get(shortName(repository.fullName)) === 1 ? shortName(repository.fullName) : repository.fullName.replace("/", "__"));
2362
+ }
2363
+ async function refreshCache(cache, repository, token, tools) {
2364
+ if (existsSync3(join6(cache, ".git"))) {
2365
+ await git(["fetch", "--prune", "origin"], { cwd: cache, token });
2366
+ return;
2367
+ }
2368
+ await git(["clone", "--no-checkout", tools.remoteUrl(repository.fullName), cache], { cwd: tools.home, token });
2369
+ }
2370
+ async function removeWorktree(cache, path) {
2371
+ await runGit(["worktree", "remove", "--force", path], { cwd: cache });
2372
+ await rm3(path, { recursive: true, force: true });
2373
+ await git(["worktree", "prune"], { cwd: cache });
2374
+ }
2375
+ async function baseRef(cache, job, repository) {
2376
+ const pushed = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${job.branch}`], { cwd: cache });
2377
+ return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
2378
+ }
2379
+ async function prepareWorkspace(job, token, tools) {
2380
+ const root = tamsenseFolder(tools.home, "work", `task-${job.taskId}`);
2381
+ const folders = folderNames(job.repositories);
2382
+ const repositories = [];
2383
+ for (const [index, repository] of job.repositories.entries()) {
2384
+ const cache = tamsenseFolder(tools.home, "repos", repository.fullName.replace("/", "__"));
2385
+ const folder = folders[index] ?? repository.fullName;
2386
+ const path = join6(root, folder);
2387
+ const baseSha = await cacheLock.run(repository.fullName, async () => {
2388
+ await refreshCache(cache, repository, token, tools);
2389
+ await removeWorktree(cache, path);
2390
+ await git(["worktree", "add", "--force", "-B", job.branch, path, await baseRef(cache, job, repository)], { cwd: cache });
2391
+ return git(["rev-parse", "HEAD"], { cwd: path });
2392
+ });
2393
+ repositories.push({ fullName: repository.fullName, folder, path, baseSha });
2394
+ }
2395
+ return { root, repositories };
2396
+ }
2397
+ async function commitIdentityEnv(home) {
2398
+ const configured = await runGit(["config", "--get", "user.email"], { cwd: home });
2399
+ if (configured.code === 0 && configured.stdout) {
2400
+ return {};
2401
+ }
2402
+ return {
2403
+ GIT_AUTHOR_NAME: FALLBACK_IDENTITY.name,
2404
+ GIT_AUTHOR_EMAIL: FALLBACK_IDENTITY.email,
2405
+ GIT_COMMITTER_NAME: FALLBACK_IDENTITY.name,
2406
+ GIT_COMMITTER_EMAIL: FALLBACK_IDENTITY.email
2407
+ };
2408
+ }
2409
+ async function publishWorkspace(workspace, job, token, identity) {
2410
+ const pushed = [];
2411
+ for (const repository of workspace.repositories) {
2412
+ const options = { cwd: repository.path, env: identity };
2413
+ const sha = await cacheLock.run(repository.fullName, async () => {
2414
+ await git(["add", "--all"], options);
2415
+ const hasStaged = (await runGit(["diff", "--cached", "--quiet"], options)).code === 1;
2416
+ if (hasStaged) {
2417
+ await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
2418
+ }
2419
+ const newCommits = Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options));
2420
+ if (newCommits === 0) {
2421
+ return null;
2422
+ }
2423
+ await git(["push", "origin", `HEAD:refs/heads/${job.branch}`], { ...options, token });
2424
+ return git(["rev-parse", "HEAD"], options);
2425
+ });
2426
+ if (sha) {
2427
+ pushed.push({ repository: repository.fullName, branch: job.branch, sha });
2428
+ }
2429
+ }
2430
+ return pushed;
2431
+ }
2432
+
2433
+ // src/worker.ts
2434
+ var POLL_INTERVAL_MS = 5e3;
2435
+ var RETRY_AFTER_ERROR_MS = 15e3;
2436
+ var INBOX_INTERVAL_MS = 2e3;
2437
+ var MAX_MESSAGE_LINE = 200;
2438
+ var CAPABILITIES_INTERVAL_MS = 30 * 6e4;
2439
+ var FEED_INTERVAL_MS = 1500;
2440
+ var FATAL_STATUSES = /* @__PURE__ */ new Set([401, 403]);
2441
+ var STOPPED_HERE = "stopped_here";
2442
+ var CLOSED_ON_SITE = "closed_on_site";
2443
+ function messageOf(error) {
2444
+ return error instanceof Error ? error.message : String(error);
2445
+ }
2446
+ function isFatal(error) {
2447
+ return error instanceof ApiError && FATAL_STATUSES.has(error.status);
2448
+ }
2449
+ async function gitToken(api, job) {
2450
+ return (await api.request("POST", runPath(job.runId, "git-token"))).token;
2451
+ }
2452
+ async function attachScreenshot(api, job, screenshot, attached) {
2453
+ if (attached.count >= MAX_ATTACHMENTS_PER_RUN) {
2454
+ return `This run already attached ${MAX_ATTACHMENTS_PER_RUN} screenshots. Mention the rest in your report.`;
2455
+ }
2456
+ const body = await readScreenshot(screenshot);
2457
+ if (typeof body === "string") {
2458
+ return body;
2459
+ }
2460
+ try {
2461
+ await api.request("POST", runPath(job.runId, "attachments"), body);
2462
+ } catch (error) {
2463
+ return `Could not attach the screenshot: ${messageOf(error)}`;
2464
+ }
2465
+ attached.count += 1;
2466
+ return "Attached to the task.";
2467
+ }
2468
+ function sessionReporter(api, job, print2) {
2469
+ let sending = null;
2470
+ return {
2471
+ report: (session) => {
2472
+ sending ??= api.request("POST", runPath(job.runId, "session"), session).then(() => {
2473
+ }, (error) => print2(`Could not tell TAMsense which Claude took the task: ${messageOf(error)}`));
2474
+ },
2475
+ sent: async () => {
2476
+ await sending;
2477
+ }
2478
+ };
2479
+ }
2480
+ function journal(feed, print2) {
2481
+ return (draft) => {
2482
+ feed.push(draft);
2483
+ const line = consoleLine(draft);
2484
+ if (line) {
2485
+ print2(` ${line}`);
2486
+ }
2487
+ };
2488
+ }
2489
+ function messageLine(message) {
2490
+ const line = `Message from ${message.authorName}: ${message.text}`.replace(/\s+/g, " ").trim();
2491
+ return line.length > MAX_MESSAGE_LINE ? `${line.slice(0, MAX_MESSAGE_LINE - 1)}\u2026` : line;
2492
+ }
2493
+ function inbox(job, tools, record, closedOnSite) {
2494
+ const input = inputChannel({ onIdle: () => void poll() });
2495
+ let running = null;
2496
+ let again = false;
2497
+ let failing = false;
2498
+ async function check() {
2499
+ const ack = input.takeDelivered();
2500
+ const body = { ack };
2501
+ let reply2;
2502
+ try {
2503
+ reply2 = await tools.api.request("POST", runPath(job.runId, "inbox"), body);
2504
+ } catch (error) {
2505
+ input.restoreDelivered(ack);
2506
+ if (!failing) {
2507
+ tools.print(`Could not check for new messages on TAMsense, trying again: ${messageOf(error)}`);
2508
+ }
2509
+ failing = true;
2510
+ return;
2511
+ }
2512
+ failing = false;
2513
+ if (!reply2.keepGoing) {
2514
+ closedOnSite();
2515
+ return;
2516
+ }
2517
+ for (const message of reply2.messages) {
2518
+ if (input.push(message)) {
2519
+ tools.print(` ${messageLine(message)}`);
2520
+ }
2521
+ }
2522
+ if (reply2.model !== null || reply2.effort !== null) {
2523
+ const change = { model: reply2.model, effort: reply2.effort };
2524
+ void input.switchModel(change).then(
2525
+ (applied) => {
2526
+ if (applied) {
2527
+ record({ kind: "tool", text: switchLine(change), tool: "model", ref: null, parent: null, failed: false });
2528
+ }
2529
+ },
2530
+ (error) => tools.print(`Could not switch the model: ${messageOf(error)}`)
2531
+ );
2532
+ }
2533
+ }
2534
+ function poll() {
2535
+ if (running) {
2536
+ again = true;
2537
+ return running;
2538
+ }
2539
+ running = input.checking(check).finally(() => {
2540
+ running = null;
2541
+ if (again) {
2542
+ again = false;
2543
+ void poll();
2544
+ }
2545
+ });
2546
+ return running;
2547
+ }
2548
+ return {
2549
+ input,
2550
+ poll,
2551
+ settled: async () => {
2552
+ for (let current = running; current; current = running) {
2553
+ await current;
2554
+ }
2555
+ }
2556
+ };
2557
+ }
2558
+ function specWorkTools2(job, api) {
2559
+ const { kind } = job.stage;
2560
+ if (kind !== "interview" && kind !== "spec") {
2561
+ return null;
2562
+ }
2563
+ return { stage: kind, review: (body) => api.request("POST", runPath(job.runId, "review"), body) };
2564
+ }
2565
+ async function executeJob(job, tools, signal, input, record) {
2566
+ const isCheck = job.stage.kind === "review" || job.stage.kind === "test";
2567
+ tools.print("Preparing the code\u2026");
2568
+ const workspace = await prepareWorkspace(job, await gitToken(tools.api, job), tools);
2569
+ const identity = await commitIdentityEnv(tools.home);
2570
+ signal.throwIfAborted();
2571
+ tools.print("Claude is working\u2026");
2572
+ const attached = { count: 0 };
2573
+ const session = sessionReporter(tools.api, job, tools.print);
2574
+ const recordings = recordingsFolder(tools.home, job.runId);
2575
+ const baseEnv = withNodeOnPath(process4.env, dirname5(process4.execPath));
2576
+ const recording = tools.recorder ? recorderEnv({ recorder: tools.recorder, folder: recordings, server: tools.api.server, job, nodeOptions: baseEnv.NODE_OPTIONS }) : {};
2577
+ const outcome = await tools.runAgent({
2578
+ cwd: workspace.root,
2579
+ prompt: buildTaskPrompt(job, workspace, { browser: Boolean(tools.browser), recorder: Boolean(tools.recorder) }),
2580
+ input,
2581
+ env: { ...baseEnv, ...tools.browser ? browserEnv(tools.browser) : {}, ...recording, ...identity },
2582
+ signal,
2583
+ wantsVerdict: isCheck,
2584
+ specWork: specWorkTools2(job, tools.api),
2585
+ handoffs: job.allowedHandoffs,
2586
+ resumeSessionId: job.resumeSessionId,
2587
+ model: job.model,
2588
+ effort: job.effort,
2589
+ onFeed: record,
2590
+ onSession: session.report,
2591
+ attach: (request) => request.kind === "screenshot" ? attachScreenshot(tools.api, job, request, attached) : attachRecording(tools.api, job.runId, recordings, request)
2592
+ });
2593
+ await session.sent();
2594
+ signal.throwIfAborted();
2595
+ const shouldPublish = job.stage.kind === "code" && outcome.completed;
2596
+ const pushed = shouldPublish ? await publishWorkspace(workspace, job, await gitToken(tools.api, job), identity) : [];
2597
+ return {
2598
+ outcome: outcome.completed ? "succeeded" : "failed",
2599
+ report: outcome.report,
2600
+ pushed,
2601
+ model: outcome.model,
2602
+ usage: outcome.usage,
2603
+ verdict: outcome.verdict,
2604
+ handoff: outcome.handoff ?? null,
2605
+ sessionId: outcome.sessionId ?? null,
2606
+ pausedUntil: outcome.pausedUntil ?? null,
2607
+ questions: outcome.questions ?? null,
2608
+ spec: outcome.spec ?? null
2609
+ };
2610
+ }
2611
+ function clockOf(iso) {
2612
+ const time = new Date(iso);
2613
+ return `${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
2614
+ }
2615
+ function resultLine(finish2) {
2616
+ if (finish2.pausedUntil) {
2617
+ return `Paused: ${USAGE_LIMIT_REPORT}, resumes at ${clockOf(finish2.pausedUntil)}`;
2618
+ }
2619
+ if (finish2.verdict) {
2620
+ return finish2.verdict.passed ? "Check passed." : `Check did not pass: ${finish2.verdict.report}`;
2621
+ }
2622
+ if (finish2.questions) {
2623
+ return finish2.questions.questions.length > 0 ? `Done. ${finish2.questions.questions.length} question(s) wait for the owner on TAMsense.` : "Done. No questions needed, the spec is written next.";
2624
+ }
2625
+ if (finish2.spec) {
2626
+ return "Done. The spec waits for the owner on TAMsense.";
2627
+ }
2628
+ return finish2.outcome === "succeeded" ? `Done. Pushed ${finish2.pushed.length} branch(es), the pull requests open on TAMsense.` : `Failed: ${finish2.report}`;
2629
+ }
2630
+ async function sendRecordings(job, tools) {
2631
+ const folder = recordingsFolder(tools.home, job.runId);
2632
+ try {
2633
+ const report = await uploadPendingRecordings(tools.api, job.runId, folder);
2634
+ if (report.uploaded > 0) {
2635
+ tools.print(`Attached ${report.uploaded} browser recording(s) to the task.`);
2636
+ }
2637
+ if (report.skipped > 0) {
2638
+ tools.print(`${report.skipped} more recording(s) were not attached: the run reached its limit.`);
2639
+ }
2640
+ for (const line of report.failed) {
2641
+ tools.print(`Could not attach a recording: ${line}`);
2642
+ }
2643
+ } finally {
2644
+ await rm4(folder, { recursive: true, force: true });
2645
+ }
2646
+ }
2647
+ function failureReport(error, signal) {
2648
+ return signal.reason === STOPPED_HERE ? "The run was stopped on the computer." : `The run stopped on the computer: ${messageOf(error)}`;
2649
+ }
2650
+ async function runJob(job, tools, stop) {
2651
+ tools.print(`Task #${job.taskId}: ${job.title} (${job.stage.name})`);
2652
+ const run2 = new AbortController();
2653
+ const stopRun = () => run2.abort(STOPPED_HERE);
2654
+ if (stop.aborted) {
2655
+ stopRun();
2656
+ }
2657
+ stop.addEventListener("abort", stopRun, { once: true });
2658
+ const feed = feedQueue((entries) => tools.api.request("POST", runPath(job.runId, "feed"), { entries }));
2659
+ const feeding = setInterval(() => void feed.flush(), FEED_INTERVAL_MS);
2660
+ const record = journal(feed, tools.print);
2661
+ const messages = inbox(job, tools, record, () => run2.abort(CLOSED_ON_SITE));
2662
+ const { input } = messages;
2663
+ run2.signal.addEventListener("abort", input.close, { once: true });
2664
+ void messages.poll();
2665
+ const polling = setInterval(() => void messages.poll(), INBOX_INTERVAL_MS);
2666
+ let finish2;
2667
+ try {
2668
+ finish2 = await executeJob(job, tools, run2.signal, input, record);
2669
+ } catch (error) {
2670
+ finish2 = { outcome: "failed", report: failureReport(error, run2.signal), pushed: [], model: null, usage: null, verdict: null, handoff: null, sessionId: null, pausedUntil: null, questions: null, spec: null };
2671
+ } finally {
2672
+ clearInterval(polling);
2673
+ clearInterval(feeding);
2674
+ stop.removeEventListener("abort", stopRun);
2675
+ input.close();
2676
+ }
2677
+ await messages.settled();
2678
+ if (input.hasDelivered()) {
2679
+ await messages.poll();
2680
+ }
2681
+ await feed.flush();
2682
+ if (run2.signal.reason === CLOSED_ON_SITE) {
2683
+ await rm4(recordingsFolder(tools.home, job.runId), { recursive: true, force: true });
2684
+ tools.print("TAMsense closed this run while it was in progress. The result was not sent.");
2685
+ return;
2686
+ }
2687
+ await sendRecordings(job, tools).catch((error) => tools.print(`Could not attach the browser recordings: ${messageOf(error)}`));
2688
+ try {
2689
+ await tools.api.request("POST", runPath(job.runId, "finish"), finish2);
2690
+ tools.print(resultLine(finish2));
2691
+ } catch (error) {
2692
+ tools.print(`Could not send the result to TAMsense: ${messageOf(error)}`);
2693
+ }
2694
+ }
2695
+ async function reportCapabilities(tools, stop) {
2696
+ if (!tools.capabilities) {
2697
+ return;
2698
+ }
2699
+ let printed = false;
2700
+ let complained = false;
2701
+ while (!stop.aborted) {
2702
+ try {
2703
+ const capabilities = await tools.capabilities(stop);
2704
+ await tools.api.request("PUT", CAPABILITIES_PATH, capabilities);
2705
+ if (!printed) {
2706
+ tools.print(capabilitiesLine(capabilities));
2707
+ printed = true;
2708
+ }
2709
+ } catch (error) {
2710
+ if (!complained && !stop.aborted) {
2711
+ tools.print(`Could not tell TAMsense which Claude models this computer has: ${messageOf(error)}`);
2712
+ complained = true;
2713
+ }
2714
+ }
2715
+ await tools.sleep(CAPABILITIES_INTERVAL_MS, stop);
2716
+ }
2717
+ }
2718
+ function jobTools(job, tools, parallel) {
2719
+ return parallel > 1 ? { ...tools, print: (line) => tools.print(`[#${job.taskId}] ${line}`) } : tools;
2720
+ }
2721
+ async function work(tools, stop, parallel = 1) {
2722
+ tools.print(parallel > 1 ? `Waiting for tasks, up to ${parallel} at once. Press Ctrl+C to stop.` : "Waiting for tasks. Press Ctrl+C to stop.");
2723
+ const halt = new AbortController();
2724
+ const stopAll = AbortSignal.any([stop, halt.signal]);
2725
+ const running = /* @__PURE__ */ new Set();
2726
+ const taskLock = keyedLock();
2727
+ const reporting = reportCapabilities(tools, stopAll);
2728
+ const start = (job) => {
2729
+ const runTools = jobTools(job, tools, parallel);
2730
+ const task = taskLock.run(String(job.taskId), () => runJob(job, runTools, stopAll)).catch((error) => runTools.print(`The run stopped: ${messageOf(error)}`)).finally(() => running.delete(task));
2731
+ running.add(task);
2732
+ };
2733
+ try {
2734
+ while (!stopAll.aborted) {
2735
+ if (running.size >= parallel) {
2736
+ await Promise.race(running);
2737
+ continue;
2738
+ }
2739
+ let reply2;
2740
+ try {
2741
+ reply2 = await tools.api.request("POST", NEXT_RUN_PATH);
2742
+ } catch (error) {
2743
+ if (isFatal(error)) {
2744
+ halt.abort();
2745
+ throw error;
2746
+ }
2747
+ tools.print(`Could not reach TAMsense: ${messageOf(error)}. Trying again soon.`);
2748
+ await tools.sleep(RETRY_AFTER_ERROR_MS, stopAll);
2749
+ continue;
2750
+ }
2751
+ if (reply2.job) {
2752
+ start(reply2.job);
2753
+ } else {
2754
+ await tools.sleep(POLL_INTERVAL_MS, stopAll);
2755
+ }
2756
+ }
2757
+ } finally {
2758
+ await Promise.allSettled([...running, reporting]);
2759
+ }
2760
+ }
2761
+
2762
+ // src/cli.ts
2763
+ var NOT_CONNECTED_TEXT = `This computer is not connected. Run ${LOGIN_COMMAND}`;
2764
+ var DISCONNECTED_TEXT = `This computer was disconnected on the website. Run ${LOGIN_COMMAND}`;
2765
+ var NO_TASKS_ACCESS_TEXT = "Your account no longer has access to Tasks. Ask the project owner.";
2766
+ var ACCESS_PROBLEMS = { 401: DISCONNECTED_TEXT, 403: NO_TASKS_ACCESS_TEXT };
2767
+ var INTERRUPTED_EXIT_CODE = 130;
2768
+ function print(line) {
2769
+ process5.stdout.write(`${line}
2770
+ `);
2771
+ }
2772
+ function serverFor(flag, saved) {
2773
+ return serverOrigin(flag ?? process5.env.TAMSENSE_URL ?? saved?.server ?? DEFAULT_SERVER);
2774
+ }
2775
+ function accessProblem(error) {
2776
+ return error instanceof ApiError ? ACCESS_PROBLEMS[error.status] ?? null : null;
2777
+ }
2778
+ async function forgetDevice(config) {
2779
+ await createApi(config.server, config.token).request("DELETE", IDENTITY_PATH).catch(() => {
2780
+ });
2781
+ await removeConfig();
2782
+ }
2783
+ async function runLogin(server, deviceName) {
2784
+ const saved = await readConfig();
2785
+ const api = createApi(serverFor(server, saved));
2786
+ if (saved) {
2787
+ await forgetDevice(saved);
2788
+ }
2789
+ const name = deviceName?.trim() || defaultDeviceName();
2790
+ const result = await login(
2791
+ { api, openBrowser, print, sleep, now: Date.now },
2792
+ { name, platform: platformLabel() }
2793
+ );
2794
+ const browserDeclined = saved?.browserDeclined ? { browserDeclined: true } : {};
2795
+ await writeConfig({ server: api.server, token: result.token, deviceId: result.device.id, deviceName: result.device.name, ...browserDeclined });
2796
+ print(`Connected: ${result.device.name}. To take your tasks, run ${START_COMMAND}`);
2797
+ }
2798
+ function stopOnInterrupt() {
2799
+ const stop = new AbortController();
2800
+ process5.once("SIGINT", () => {
2801
+ print("Stopping\u2026 Press Ctrl+C again to quit at once.");
2802
+ stop.abort();
2803
+ process5.once("SIGINT", () => process5.exit(INTERRUPTED_EXIT_CODE));
2804
+ });
2805
+ return stop.signal;
2806
+ }
2807
+ function installTools(home) {
2808
+ return { home, nodePath: process5.execPath, env: process5.env, print, run: runProcess, exists: existsSync4 };
2809
+ }
2810
+ async function browserForStart(home, saved) {
2811
+ return await installedBrowser(home) ?? await offerBrowser({
2812
+ interactive: Boolean(process5.stdin.isTTY && process5.stdout.isTTY),
2813
+ declined: saved.browserDeclined === true,
2814
+ print,
2815
+ ask: askYesNo,
2816
+ install: () => installBrowser(installTools(home)),
2817
+ rememberDecline: async () => writeConfig({ ...saved, browserDeclined: true })
2818
+ });
2819
+ }
2820
+ async function startedNewVersion() {
2821
+ const update = await updateToRun(process5.env, currentVersion(), () => latestVersion());
2822
+ if (!update) {
2823
+ return false;
2824
+ }
2825
+ print(`TAMsense runner ${update.latest} is out, this is ${update.current}. Starting the new version...`);
2826
+ try {
2827
+ process5.exitCode = await relaunch(process5.argv.slice(2), process5.env);
2828
+ return true;
2829
+ } catch {
2830
+ print("Could not start the new version. Going on with this one.");
2831
+ return false;
2832
+ }
2833
+ }
2834
+ async function runStart(parallel) {
2835
+ if (await startedNewVersion()) {
2836
+ return;
2837
+ }
2838
+ const saved = await readConfig();
2839
+ if (!saved) {
2840
+ print(NOT_CONNECTED_TEXT);
2841
+ return;
2842
+ }
2843
+ const api = createApi(saved.server, saved.token);
2844
+ const identity = await api.request("GET", IDENTITY_PATH);
2845
+ const problem = teamKeyProblem(identity.device.team, process5.env);
2846
+ if (problem) {
2847
+ throw new Error(problem);
2848
+ }
2849
+ print(`${identity.device.name} is connected to ${saved.server} as ${describeDevice(identity)}.`);
2850
+ const home = homedir3();
2851
+ const browser = await browserForStart(home, saved);
2852
+ await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder() }, stopOnInterrupt(), parallel);
2853
+ }
2854
+ async function runBrowser(remove) {
2855
+ const home = homedir3();
2856
+ if (remove) {
2857
+ print(await removeBrowser(home) ? "Removed the headless browser." : "No headless browser is installed.");
2858
+ const saved = await readConfig();
2859
+ if (saved) {
2860
+ await writeConfig({ ...saved, browserDeclined: true });
2861
+ }
2862
+ return;
2863
+ }
2864
+ const installed = await installedBrowser(home);
2865
+ if (installed) {
2866
+ print(browserStatusText(installed));
2867
+ return;
2868
+ }
2869
+ await installBrowser(installTools(home));
2870
+ print("Claude uses it for interface checks from the next start.");
2871
+ }
2872
+ async function runStatus() {
2873
+ const saved = await readConfig();
2874
+ if (!saved) {
2875
+ print(NOT_CONNECTED_TEXT);
2876
+ return;
2877
+ }
2878
+ const identity = await createApi(saved.server, saved.token).request("GET", IDENTITY_PATH);
2879
+ print(`Connected: ${identity.device.name}, ${describeDevice(identity)}`);
2880
+ print(`Account: ${identity.email ?? "unknown"}, project ${identity.projectName}`);
2881
+ print(`Server: ${saved.server}`);
2882
+ }
2883
+ async function runLogout() {
2884
+ const saved = await readConfig();
2885
+ if (!saved) {
2886
+ print(NOT_CONNECTED_TEXT);
2887
+ return;
2888
+ }
2889
+ await forgetDevice(saved);
2890
+ print("Disconnected. This computer no longer takes your tasks.");
2891
+ }
2892
+ async function run(command) {
2893
+ if (command.name === "login") {
2894
+ await runLogin(command.server, command.deviceName);
2895
+ } else if (command.name === "start") {
2896
+ await runStart(command.parallel);
2897
+ } else if (command.name === "browser") {
2898
+ await runBrowser(command.remove);
2899
+ } else if (command.name === "status") {
2900
+ await runStatus();
2901
+ } else if (command.name === "logout") {
2902
+ await runLogout();
2903
+ } else {
2904
+ print(HELP_TEXT);
2905
+ }
2906
+ }
2907
+ try {
2908
+ await run(parseCommand(process5.argv.slice(2), process5.env));
2909
+ } catch (error) {
2910
+ process5.stderr.write(`${accessProblem(error) ?? (error instanceof Error ? error.message : String(error))}
2911
+ `);
2912
+ process5.exitCode = 1;
2913
+ }
2914
+ //# sourceMappingURL=cli.js.map