@tamsensedev/runner 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { existsSync as existsSync3 } from "fs";
4
+ import { existsSync as existsSync4 } from "fs";
5
5
  import { homedir as homedir3 } from "os";
6
- import process3 from "process";
6
+ import process5 from "process";
7
7
 
8
8
  // src/api.ts
9
9
  var DEFAULT_SERVER = "https://my.tamsense.com";
@@ -39,27 +39,26 @@ async function errorOf(response) {
39
39
  return new ApiError(response.status, code, message);
40
40
  }
41
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
+ }
42
58
  return {
43
59
  server,
44
- async request(method, path, body) {
45
- const headers = { accept: "application/json" };
46
- if (body) {
47
- headers["content-type"] = "application/json";
48
- }
49
- if (token) {
50
- headers.authorization = `Bearer ${token}`;
51
- }
52
- let response;
53
- try {
54
- response = await fetchImpl(new URL(path, server), { method, headers, body: body && JSON.stringify(body) });
55
- } catch (error) {
56
- throw new Error(`Could not reach ${server}. Check your internet connection.`, { cause: error });
57
- }
58
- if (!response.ok) {
59
- throw await errorOf(response);
60
- }
61
- return await response.json();
62
- }
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)
63
62
  };
64
63
  }
65
64
 
@@ -72,9 +71,9 @@ import { createInterface } from "readline";
72
71
 
73
72
  // src/commands.ts
74
73
  import { parseArgs } from "util";
75
- var LOGIN_COMMAND = "npx @tamsensedev/runner login";
76
- var START_COMMAND = "npx @tamsensedev/runner start";
77
- var BROWSER_COMMAND = "npx @tamsensedev/runner browser";
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";
78
77
  var DEFAULT_PARALLEL = 2;
79
78
  var MAX_PARALLEL = 4;
80
79
  var HELP_TEXT = `Connects this computer to TAMsense so it can work on your tasks.
@@ -82,17 +81,18 @@ var HELP_TEXT = `Connects this computer to TAMsense so it can work on your tasks
82
81
  Usage:
83
82
  ${LOGIN_COMMAND} connect this computer
84
83
  ${START_COMMAND} take your tasks and work on them until you press Ctrl+C
85
- ${BROWSER_COMMAND} install the headless browser for interface checks, or show it
84
+ ${BROWSER_COMMAND} install the headless browser for interface checks now, or show it
86
85
  ${BROWSER_COMMAND} remove delete that browser
87
- npx @tamsensedev/runner status show the connection
88
- npx @tamsensedev/runner logout disconnect this computer
86
+ npx @tamsensedev/runner@latest status show the connection
87
+ npx @tamsensedev/runner@latest logout disconnect this computer
89
88
 
90
89
  Login options:
91
90
  --name <name> how this computer is called in TAMsense (default: its network name)
92
91
  --server <url> TAMsense address (default: ${DEFAULT_SERVER})
93
92
 
94
93
  Start options:
95
- --parallel <n> how many tasks to work on at once, 1 to ${MAX_PARALLEL} (default: ${DEFAULT_PARALLEL}, or TAMSENSE_PARALLEL)`;
94
+ --parallel <n> how many tasks to work on at once, 1 to ${MAX_PARALLEL} (default: ${DEFAULT_PARALLEL}, or TAMSENSE_PARALLEL)
95
+ --no-browser do not install the headless browser for interface checks (start installs it when it is missing)`;
96
96
  function parallelFor(flag, env) {
97
97
  const [source, value] = flag !== void 0 ? ["--parallel", flag] : ["TAMSENSE_PARALLEL", env?.trim() || void 0];
98
98
  if (value === void 0) {
@@ -110,10 +110,11 @@ function parseCommand(argv, env = {}) {
110
110
  allowPositionals: true,
111
111
  strict: true,
112
112
  options: {
113
- server: { type: "string" },
114
- name: { type: "string" },
115
- parallel: { type: "string" },
116
- help: { type: "boolean" }
113
+ "server": { type: "string" },
114
+ "name": { type: "string" },
115
+ "parallel": { type: "string" },
116
+ "no-browser": { type: "boolean" },
117
+ "help": { type: "boolean" }
117
118
  }
118
119
  });
119
120
  const [name, detail] = positionals;
@@ -130,7 +131,7 @@ function parseCommand(argv, env = {}) {
130
131
  return { name: "login", server: values.server ?? null, deviceName: values.name ?? null };
131
132
  }
132
133
  if (name === "start") {
133
- return { name: "start", parallel: parallelFor(values.parallel, env.TAMSENSE_PARALLEL) };
134
+ return { name: "start", parallel: parallelFor(values.parallel, env.TAMSENSE_PARALLEL), browser: !values["no-browser"] };
134
135
  }
135
136
  if (name === "status" || name === "logout") {
136
137
  return { name };
@@ -146,8 +147,7 @@ var OUTPUT_TAIL_LINES = 40;
146
147
  var ERROR_TAIL_LINES = 3;
147
148
  var ANSI_PATTERN = /\u001B\[[\d;]*m/g;
148
149
  var OFFLINE_PATTERN = /ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNREFUSED|ECONNRESET|ENETUNREACH|getaddrinfo/i;
149
- var BROWSER_QUESTION = "Interface checks need a headless browser (about 150 MB, installed once into ~/.tamsense). Install it now? [Y/n] ";
150
- var BROWSER_HINT = `Interface checks can use a headless browser. To install it (about 150 MB), run ${BROWSER_COMMAND}`;
150
+ var INSTALLING_TEXT = "Interface checks need a headless browser. Installing it once into ~/.tamsense, about 150 MB. To start without it, add --no-browser.";
151
151
  var OFFLINE_TEXT = `No connection to download it. Check your internet connection and run ${BROWSER_COMMAND} again.`;
152
152
  function browserFolders(home) {
153
153
  const prefix = join(home, ".tamsense", "playwright");
@@ -249,24 +249,13 @@ async function removeBrowser(home) {
249
249
  await rm(folders.browsers, { recursive: true, force: true });
250
250
  return present2;
251
251
  }
252
- async function offerBrowser(tools) {
253
- if (tools.declined) {
254
- return null;
255
- }
256
- if (!tools.interactive) {
257
- tools.print(BROWSER_HINT);
258
- return null;
259
- }
260
- if (!await tools.ask(BROWSER_QUESTION)) {
261
- await tools.rememberDecline();
262
- tools.print(`Not installed. To install it later, run ${BROWSER_COMMAND}`);
263
- return null;
264
- }
252
+ async function ensureBrowser(tools) {
253
+ tools.print(INSTALLING_TEXT);
265
254
  try {
266
255
  return await tools.install();
267
256
  } catch (error) {
268
257
  tools.print(`Could not install the browser: ${error instanceof Error ? error.message : String(error)}`);
269
- tools.print("Tasks still run without it.");
258
+ tools.print(`Tasks still run without it. To try again, run ${BROWSER_COMMAND}`);
270
259
  return null;
271
260
  }
272
261
  }
@@ -355,6 +344,56 @@ import { fileURLToPath } from "url";
355
344
  import { createSdkMcpServer, query as query2, tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
356
345
  import { z as z2 } from "zod";
357
346
 
347
+ // src/askPerson.ts
348
+ var MAX_ASK_TRIES = 5;
349
+ var MAX_ASK_OPTIONS = 4;
350
+ function personLine(person) {
351
+ const roles = person.roles.length > 0 ? `, ${person.roles.join(", ")}` : "";
352
+ return `${person.id}: ${person.name} (${person.relation}, ${person.access} access${roles})`;
353
+ }
354
+ function findPerson(people, to) {
355
+ const key = String(to).trim().toLowerCase();
356
+ return people.find((person) => String(person.id) === key || person.name.toLowerCase() === key) ?? null;
357
+ }
358
+ function cleanTry(item) {
359
+ return { did: item.did.trim(), error: item.error?.trim() || null, result: item.result.trim() };
360
+ }
361
+ function checkAsk(people, request) {
362
+ const question = request.question.trim();
363
+ const why = request.why.trim();
364
+ const assumption = request.assumption?.trim() ?? "";
365
+ if (!question) {
366
+ return { error: "Write the question: one plain sentence the person can answer without reading the code." };
367
+ }
368
+ const person = findPerson(people, request.to);
369
+ if (!person) {
370
+ return { error: `"${request.to}" is not a person on this task. Ask one of them by id:
371
+ ${people.map(personLine).join("\n")}` };
372
+ }
373
+ if (!why) {
374
+ return { error: `Say in one line why ${person.name} is the right person to ask.` };
375
+ }
376
+ if (!request.blocking && !assumption) {
377
+ return { error: "A question you do not wait for needs the assumption you continue with until the answer comes." };
378
+ }
379
+ const tried = (request.tried ?? []).map(cleanTry).filter((item) => item.did);
380
+ const options = (request.options ?? []).map((option) => ({ label: option.label.trim(), detail: option.detail.trim() })).filter((option) => option.label);
381
+ if (tried.length > MAX_ASK_TRIES || options.length > MAX_ASK_OPTIONS) {
382
+ return { error: `Keep it short: at most ${MAX_ASK_TRIES} things you tried and at most ${MAX_ASK_OPTIONS} suggested answers.` };
383
+ }
384
+ return {
385
+ person,
386
+ body: { to: person.id, why, question, context: request.context?.trim() ?? "", tried, options, blocking: request.blocking, assumption: request.blocking ? null : assumption }
387
+ };
388
+ }
389
+ function askedText(name, body) {
390
+ if (body.blocking) {
391
+ return `Sent to ${name}. This run now waits for the answer and frees the computer. Write one short line saying what you wait for, then end your turn without doing more work. When ${name} answers, this conversation continues here with the answer.`;
392
+ }
393
+ return `Sent to ${name}. Keep working on your assumption: ${body.assumption}. The answer comes as a message from ${name} while you work, or with the next run if this one has ended. If it differs, change what you did.`;
394
+ }
395
+ var ALREADY_WAITING_TEXT = "You already asked a question this run waits for. End your turn now: the conversation continues when the answer comes.";
396
+
358
397
  // src/claudeResult.ts
359
398
  var MAX_ACTIVITY_LENGTH = 120;
360
399
  var FILE_TOOLS = /* @__PURE__ */ new Set(["Read", "Edit", "Write", "MultiEdit", "NotebookEdit"]);
@@ -407,6 +446,7 @@ var MAX_RESULT_LENGTH = 2e3;
407
446
  var MAX_SUMMARY_LENGTH = 500;
408
447
  var HELPER_TOOLS2 = /* @__PURE__ */ new Set(["Task", "Agent"]);
409
448
  var MCP_PREFIX = /^mcp__[^_]+(?:_[^_]+)*__/;
449
+ var ATTACH_LABELS = { attach_screenshot: "Screenshot", attach_replay: "Replay", attach_video: "Video" };
410
450
  function clip(text2, max) {
411
451
  const trimmed = text2.trim();
412
452
  return trimmed.length > max ? `${trimmed.slice(0, max - 1)}\u2026` : trimmed;
@@ -431,14 +471,14 @@ function toolDraft(block, parent) {
431
471
  return { ...base, kind: "agent", text: clip(what ? `${who}: ${what}` : who, MAX_SUMMARY_LENGTH) };
432
472
  }
433
473
  if (tool3 === "submit_verdict") {
434
- const verdict = fields.passed === true ? "Passed" : "Did not pass";
474
+ const verdict = fields.couldNotRun === true ? "Could not run" : fields.passed === true ? "Passed" : "Did not pass";
435
475
  return { ...base, kind: "tool", text: clip(`${verdict}. ${textField(fields, "report") ?? ""}`, MAX_SUMMARY_LENGTH) };
436
476
  }
437
477
  if (tool3 === "hand_off") {
438
478
  return { ...base, kind: "tool", text: clip(`Hand off to ${textField(fields, "to") ?? "?"}: ${textField(fields, "note") ?? ""}`, MAX_SUMMARY_LENGTH) };
439
479
  }
440
- if (tool3 === "attach_screenshot") {
441
- return { ...base, kind: "tool", text: clip(`Screenshot: ${textField(fields, "caption") ?? textField(fields, "path") ?? ""}`, MAX_SUMMARY_LENGTH) };
480
+ if (tool3 in ATTACH_LABELS) {
481
+ return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[tool3]}: ${textField(fields, "title") ?? textField(fields, "path") ?? ""}`, MAX_SUMMARY_LENGTH) };
442
482
  }
443
483
  const command = textField(fields, "command");
444
484
  const summary = tool3 === "Bash" && command ? `$ ${command}` : activityLine(tool3, block.input);
@@ -813,10 +853,15 @@ function specPromptText(spec) {
813
853
  // src/prompt.ts
814
854
  var VERDICT_TOOL_NAME = "submit_verdict";
815
855
  var SCREENSHOT_TOOL_NAME = "attach_screenshot";
856
+ var REPLAY_TOOL_NAME = "attach_replay";
857
+ var VIDEO_TOOL_NAME = "attach_video";
816
858
  var HANDOFF_TOOL_NAME = "hand_off";
817
859
  var SUBMIT_QUESTIONS_TOOL_NAME = "submit_questions";
818
860
  var SUBMIT_SPEC_TOOL_NAME = "submit_spec";
819
- 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.`;
861
+ var ASK_PERSON_TOOL_NAME = "ask_person";
862
+ var MESSAGES_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.`;
863
+ var UNATTENDED_RULE = `${MESSAGES_RULE} 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.`;
864
+ var ASKING_RULE = `${MESSAGES_RULE} Decide yourself when you reasonably can, keep working, and explain your decisions in your report. When you are stuck, or the decision belongs to a person (intent, design, access, how the team runs something locally), ask one of the people on the task with the ${ASK_PERSON_TOOL_NAME} tool instead of guessing, and say what you tried. Never ask what the code or the project knowledge already answers. Nobody watches your messages, so ask only with the tool.`;
820
865
  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.";
821
866
  var CODE_RULES = [
822
867
  "Make the changes the task asks for, and keep them focused on it.",
@@ -832,8 +877,24 @@ var CHECK_RULES = [
832
877
  "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.",
833
878
  `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.`
834
879
  ];
835
- var SCREENSHOT_RULE = `If you render screens (screenshots of the interface), attach the ones that show the result with the ${SCREENSHOT_TOOL_NAME} tool, with a caption saying what each one shows. People see them on the task without opening the code.`;
836
- 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.";
880
+ 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.`;
881
+ 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".';
882
+ var RECORDING_RULE = `Every browser you open with Playwright from Node is recorded for the task as a replay people can watch, requests from the tested site to the TAMsense tracker are blocked, so your runs never reach analytics, and requests other than GET, HEAD and OPTIONS to addresses outside this computer are aborted. 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.`;
883
+ 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.";
884
+ var DEPENDENCIES_RULE = "The checkout may have no dependencies installed. To run the app or its tests, install the project's own dependencies from its lockfile with its package manager (pnpm install --frozen-lockfile, npm ci, yarn install --immutable and similar). Never add packages, change a lockfile or download browsers.";
885
+ function localAppRule(taskId, isCheck, canAsk) {
886
+ const project = `tamsense-task-${taskId}`;
887
+ const cannotRun = isCheck ? `call ${VERDICT_TOOL_NAME} with passed false and couldNotRun true, write the exact reason and what a person should do in the report, and do not hand off: the task waits on this step for a person.` : "say so in your report with the exact reason and what a person should do.";
888
+ return [
889
+ "When you need the running app, start a local copy from this task folder only.",
890
+ "Find how the project runs locally yourself: the README, compose files named local or dev, .env.example and local env files, package scripts.",
891
+ canAsk ? `If you still cannot tell how the team prepares it, for example how the local database gets its tables, ask with ${ASK_PERSON_TOOL_NAME} instead of guessing.` : null,
892
+ "Use only settings meant for a local run that are in this checkout. Never read or copy .env files from elsewhere on this computer, including the person's own checkout, and never use staging, shared dev or production addresses.",
893
+ "Before starting, check every address in the settings you use: databases, caches, queues and APIs must point at localhost or at containers you started, and keys of outside services stay empty.",
894
+ `Start containers as a separate compose project named ${project} on free host ports (an override file in a temp folder), never stop or change containers you did not start, and remove yours with their volumes at the end (docker compose -p ${project} down -v).`,
895
+ `If Docker is not running, there is no local setup, or the settings point outside this computer, do not start anything: ${cannotRun}`
896
+ ].filter(Boolean).join(" ");
897
+ }
837
898
  var STAGE_GOALS = {
838
899
  code: "Implement",
839
900
  review: "Review the code for",
@@ -884,6 +945,16 @@ function teamMessagesText(messages) {
884
945
  return messages.length > 0 ? `Messages from the team before this run:
885
946
  ${messages.map((message) => `${message.authorName}: ${message.text}`).join("\n")}` : null;
886
947
  }
948
+ function peopleText(people) {
949
+ if (people.length === 0) {
950
+ return null;
951
+ }
952
+ return [
953
+ `People on this task you can ask with ${ASK_PERSON_TOOL_NAME} (id: name, relation, access):`,
954
+ bulletList(people.map(personLine)),
955
+ "Ask the assignee first; a question on a topic goes to the person whose role fits it. Set blocking only when you cannot go on without the answer: the run then pauses and the computer is freed for other tasks. Otherwise give the assumption you continue with."
956
+ ].join("\n");
957
+ }
887
958
  function handoffText(targets, isCheck) {
888
959
  if (targets.length === 0) {
889
960
  return null;
@@ -967,6 +1038,8 @@ function buildTaskPrompt(job, workspace, options = {}) {
967
1038
  return buildSpecPrompt(job, job.specWork, workspace);
968
1039
  }
969
1040
  const isCheck = kind !== "code";
1041
+ const people = job.people ?? [];
1042
+ const canAsk = people.length > 0;
970
1043
  const prompt = [
971
1044
  `${STAGE_GOALS[kind]} TAMsense task #${job.taskId}: ${job.title}`,
972
1045
  job.description,
@@ -976,9 +1049,11 @@ function buildTaskPrompt(job, workspace, options = {}) {
976
1049
  ${job.review}`,
977
1050
  teamMessagesText(job.messages)
978
1051
  ].filter(Boolean).join("\n\n");
979
- const rules = isCheck ? [...CHECK_RULES, SCREENSHOT_RULE] : [...CODE_RULES, ...job.review ? [REWORK_RULE] : [], SCREENSHOT_RULE, CODE_REPORT_RULE];
1052
+ const evidence = [SCREENSHOT_RULE, ...options.recorder ? [RECORDING_RULE] : [], NAMING_RULE];
1053
+ const localApp = localAppRule(job.taskId, isCheck, canAsk);
1054
+ const rules = isCheck ? [...CHECK_RULES, DEPENDENCIES_RULE, localApp, ...evidence] : [...CODE_RULES, ...job.review ? [REWORK_RULE] : [], DEPENDENCIES_RULE, localApp, ...evidence, CODE_REPORT_RULE];
980
1055
  const instructions = [
981
- UNATTENDED_RULE,
1056
+ canAsk ? ASKING_RULE : UNATTENDED_RULE,
982
1057
  MACHINE_RULE,
983
1058
  stepLine(job.stage),
984
1059
  job.stage.instructions,
@@ -987,6 +1062,7 @@ ${job.review}`,
987
1062
  ${bulletList(repositoryLines(job, workspace))}`,
988
1063
  `How to work:
989
1064
  ${bulletList(rules)}`,
1065
+ peopleText(people),
990
1066
  handoffText(job.allowedHandoffs, isCheck),
991
1067
  options.browser && BROWSER_RULE
992
1068
  ].filter(Boolean).join("\n\n");
@@ -1243,15 +1319,34 @@ var NO_RESULT_TEXT = "Claude finished without a result.";
1243
1319
  var SERVER_NAME = "tamsense";
1244
1320
  var LIBRARY_PATH = fileURLToPath(new URL("../library", import.meta.url));
1245
1321
  var denyWhenAsked = async () => ({ behavior: "deny", message: NOBODY_TO_ASK });
1246
- function screenshotTool(attach) {
1322
+ var CAPTION_FIELDS = {
1323
+ 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"'),
1324
+ purpose: z2.string().describe('one line, at most 160 characters, on why it was made: "Comparing two layouts for the empty orders state"')
1325
+ };
1326
+ var ATTACH_TOOLS = {
1327
+ screenshot: {
1328
+ name: SCREENSHOT_TOOL_NAME,
1329
+ 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.",
1330
+ path: "absolute path of the image file"
1331
+ },
1332
+ replay: {
1333
+ name: REPLAY_TOOL_NAME,
1334
+ 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.",
1335
+ path: "absolute path of the .rrweb.ndjson replay file"
1336
+ },
1337
+ video: {
1338
+ name: VIDEO_TOOL_NAME,
1339
+ 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.",
1340
+ path: "absolute path of the video file"
1341
+ }
1342
+ };
1343
+ function attachTool(kind, attach) {
1344
+ const spec = ATTACH_TOOLS[kind];
1247
1345
  return tool2(
1248
- SCREENSHOT_TOOL_NAME,
1249
- "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.",
1250
- {
1251
- path: z2.string().describe("absolute path of the image file"),
1252
- caption: z2.string().describe('what it shows, for example "Orders page, phone width, empty state"')
1253
- },
1254
- async (args) => ({ content: [{ type: "text", text: await attach(args) }] })
1346
+ spec.name,
1347
+ spec.description,
1348
+ { path: z2.string().describe(spec.path), ...CAPTION_FIELDS },
1349
+ async (args) => ({ content: [{ type: "text", text: await attach({ kind, ...args }) }] })
1255
1350
  );
1256
1351
  }
1257
1352
  function verdictTool(onVerdict) {
@@ -1260,10 +1355,11 @@ function verdictTool(onVerdict) {
1260
1355
  "Hand in the result of this check. Call it exactly once, at the very end.",
1261
1356
  {
1262
1357
  passed: z2.boolean().describe("true only if nothing must be fixed"),
1263
- report: z2.string().describe("what you checked and, if it failed, exactly what to fix with file and line")
1358
+ report: z2.string().describe("what you checked and, if it failed, exactly what to fix with file and line"),
1359
+ couldNotRun: z2.boolean().optional().describe("true when the check could not run at all because of this computer: Docker not running, no local setup, or settings that point outside this computer; passed must be false and the report gives the exact reason and what a person should do")
1264
1360
  },
1265
1361
  async (args) => {
1266
- onVerdict({ passed: args.passed, report: args.report });
1362
+ onVerdict(args.couldNotRun ? { passed: false, report: args.report, couldNotRun: true } : { passed: args.passed, report: args.report });
1267
1363
  return { content: [{ type: "text", text: "Verdict received. You can stop now." }] };
1268
1364
  }
1269
1365
  );
@@ -1287,6 +1383,48 @@ function handoffTool(targets, onHandoff) {
1287
1383
  }
1288
1384
  );
1289
1385
  }
1386
+ function askPersonTool(asking, waiting) {
1387
+ return tool2(
1388
+ ASK_PERSON_TOOL_NAME,
1389
+ "Ask a person on this TAMsense task when you are stuck or the decision belongs to a person: intent, design, access, or how the team runs something locally. Never ask what the code or the project knowledge already answers. With blocking true the run pauses and frees the computer until the answer comes; with blocking false you keep working on your assumption.",
1390
+ {
1391
+ to: z2.union([z2.number(), z2.string()]).describe("id of the person from the people list in your instructions: the assignee first; a topic question goes to the person whose role fits"),
1392
+ why: z2.string().describe('one line on why this person: "assignee, and the run is on her computer", "Designer, owns empty states"'),
1393
+ question: z2.string().describe("one plain question the person can answer without reading the code"),
1394
+ context: z2.string().optional().describe("one line on what you saw that led to the question"),
1395
+ tried: z2.array(z2.object({
1396
+ did: z2.string().describe("the command or step"),
1397
+ error: z2.string().optional().describe("the exact error line, if there was one"),
1398
+ result: z2.string().describe("what it showed, one line")
1399
+ })).max(MAX_ASK_TRIES).optional().describe("what you already tried, the most telling first"),
1400
+ options: z2.array(z2.object({
1401
+ label: z2.string().describe("a short answer the person can pick"),
1402
+ detail: z2.string().describe("one line on what happens if it is picked")
1403
+ })).max(MAX_ASK_OPTIONS).optional().describe("2 to 4 answers you suggest, the one you would pick first"),
1404
+ blocking: z2.boolean().describe("true when you cannot go on without the answer"),
1405
+ assumption: z2.string().optional().describe("when blocking is false: what you continue with until the answer comes")
1406
+ },
1407
+ async (args) => {
1408
+ if (waiting.waitingFor !== null) {
1409
+ return { content: [{ type: "text", text: ALREADY_WAITING_TEXT }], isError: true };
1410
+ }
1411
+ const choice = checkAsk(asking.people, args);
1412
+ if ("error" in choice) {
1413
+ return { content: [{ type: "text", text: choice.error }], isError: true };
1414
+ }
1415
+ let reply2;
1416
+ try {
1417
+ reply2 = await asking.send(choice.body);
1418
+ } catch (error) {
1419
+ return { content: [{ type: "text", text: `Could not send the question: ${error instanceof Error ? error.message : String(error)}. Decide yourself and say so in your report.` }], isError: true };
1420
+ }
1421
+ if (choice.body.blocking) {
1422
+ waiting.waitingFor = reply2.id;
1423
+ }
1424
+ return { content: [{ type: "text", text: askedText(reply2.name, choice.body) }] };
1425
+ }
1426
+ );
1427
+ }
1290
1428
  function specWorkTools(specWork, submitted) {
1291
1429
  if (!specWork) {
1292
1430
  return [];
@@ -1299,11 +1437,14 @@ function specWorkTools(specWork, submitted) {
1299
1437
  }
1300
1438
  function tamsenseServer(request, submitted) {
1301
1439
  const tools = [
1302
- screenshotTool(request.attach),
1440
+ attachTool("screenshot", request.attach),
1441
+ attachTool("replay", request.attach),
1442
+ attachTool("video", request.attach),
1303
1443
  ...request.wantsVerdict ? [verdictTool((verdict) => {
1304
1444
  submitted.verdict = verdict;
1305
1445
  })] : [],
1306
1446
  ...specWorkTools(request.specWork, submitted),
1447
+ ...request.asking ? [askPersonTool(request.asking, submitted)] : [],
1307
1448
  ...request.handoffs.length > 0 ? [handoffTool(request.handoffs, (handoff) => {
1308
1449
  submitted.handoff = handoff;
1309
1450
  })] : []
@@ -1395,7 +1536,7 @@ function claudeRunner(startQuery) {
1395
1536
  request.input.close();
1396
1537
  abortController.abort();
1397
1538
  }, { once: true });
1398
- const submitted = { verdict: null, handoff: null, questions: null, spec: null, helperStarted: false };
1539
+ const submitted = { verdict: null, handoff: null, questions: null, spec: null, helperStarted: false, waitingFor: null };
1399
1540
  const limit = usageLimitWatch();
1400
1541
  const context = { startQuery, abortController, submitted, limit };
1401
1542
  try {
@@ -1405,7 +1546,7 @@ function claudeRunner(startQuery) {
1405
1546
  }
1406
1547
  const pausedUntil = limit.pausedUntil(result.outcome.completed);
1407
1548
  const outcome = pausedUntil ? { ...result.outcome, completed: false, report: USAGE_LIMIT_REPORT } : result.outcome;
1408
- return { ...outcome, verdict: submitted.verdict, handoff: submitted.handoff, questions: submitted.questions, spec: submitted.spec, sessionId: result.sessionId, pausedUntil };
1549
+ return { ...outcome, verdict: submitted.verdict, handoff: submitted.handoff, questions: submitted.questions, spec: submitted.spec, sessionId: result.sessionId, pausedUntil, waitingFor: submitted.waitingFor };
1409
1550
  } finally {
1410
1551
  request.input.close();
1411
1552
  }
@@ -1429,9 +1570,9 @@ function parseConfig(value) {
1429
1570
  if (!isRecord2(value)) {
1430
1571
  return null;
1431
1572
  }
1432
- const { server, token, deviceId, deviceName, browserDeclined } = value;
1573
+ const { server, token, deviceId, deviceName } = value;
1433
1574
  const isValid = typeof server === "string" && typeof token === "string" && typeof deviceId === "number" && typeof deviceName === "string";
1434
- return isValid ? { server, token, deviceId, deviceName, ...browserDeclined === true ? { browserDeclined } : {} } : null;
1575
+ return isValid ? { server, token, deviceId, deviceName } : null;
1435
1576
  }
1436
1577
  function isMissingFile(error) {
1437
1578
  return error instanceof Error && "code" in error && error.code === "ENOENT";
@@ -1458,6 +1599,7 @@ async function removeConfig(path = configPath()) {
1458
1599
  // src/protocol.ts
1459
1600
  var MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
1460
1601
  var MAX_ATTACHMENTS_PER_RUN = 20;
1602
+ var MAX_VIDEO_BYTES = 200 * 1024 * 1024;
1461
1603
  var PAIRINGS_PATH = "/api/runner/pairings";
1462
1604
  var PAIRING_TOKEN_PATH = "/api/runner/pairings/token";
1463
1605
  var IDENTITY_PATH = "/api/runner/me";
@@ -1466,6 +1608,9 @@ var CAPABILITIES_PATH = "/api/runner/me/capabilities";
1466
1608
  function runPath(runId, action) {
1467
1609
  return `/api/runner/runs/${runId}/${action}`;
1468
1610
  }
1611
+ function recordingUploadPath(runId, upload, part) {
1612
+ return `/api/runner/runs/${runId}/recordings/${upload}/${part}`;
1613
+ }
1469
1614
 
1470
1615
  // src/login.ts
1471
1616
  var MS_PER_SECOND = 1e3;
@@ -1495,6 +1640,340 @@ async function login(tools, request) {
1495
1640
  throw new Error(EXPIRED_TEXT);
1496
1641
  }
1497
1642
 
1643
+ // src/recorderEnv.ts
1644
+ import { existsSync as existsSync2 } from "fs";
1645
+ import { join as join4 } from "path";
1646
+ import { fileURLToPath as fileURLToPath2 } from "url";
1647
+
1648
+ // src/recorder.ts
1649
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
1650
+ import Module, { createRequire } from "module";
1651
+ import { basename, dirname as dirname3, join as join3 } from "path";
1652
+ import process2 from "process";
1653
+ var RECORDINGS_DIR_ENV = "TAMSENSE_RECORDINGS_DIR";
1654
+ var SERVER_ENV = "TAMSENSE_SERVER";
1655
+ var DEFAULT_TITLE_ENV = "TAMSENSE_RECORDING_TITLE";
1656
+ var DEFAULT_PURPOSE_ENV = "TAMSENSE_RECORDING_PURPOSE";
1657
+ var MARK_TAG = "tamsense.mark";
1658
+ var REPLAY_SUFFIX = ".rrweb.ndjson";
1659
+ var META_SUFFIX = ".json";
1660
+ var MAX_TITLE_LENGTH = 60;
1661
+ var MAX_PURPOSE_LENGTH = 160;
1662
+ var DEFAULT_TRACKER_ORIGINS = ["https://my.tamsense.com"];
1663
+ var READ_METHODS = ["GET", "HEAD", "OPTIONS"];
1664
+ var BINDING = "__tamsenseRecord";
1665
+ var STATE_KEY = /* @__PURE__ */ Symbol.for("tamsense.recorder");
1666
+ var FLUSH_MS = 100;
1667
+ var MAX_SESSION_BYTES = 300 * 1024 * 1024;
1668
+ var GAP_CHECK_MS = 1500;
1669
+ var RRWEB_META = 4;
1670
+ function state() {
1671
+ const holder = globalThis;
1672
+ holder[STATE_KEY] ??= { contexts: /* @__PURE__ */ new WeakMap(), pages: /* @__PURE__ */ new WeakMap(), instrumented: /* @__PURE__ */ new WeakSet(), script: null, hooked: false };
1673
+ return holder[STATE_KEY];
1674
+ }
1675
+ function recordingsDir() {
1676
+ const dir = process2.env[RECORDINGS_DIR_ENV];
1677
+ if (!dir) {
1678
+ throw new Error(`${RECORDINGS_DIR_ENV} is not set: recordings work only inside a TAMsense run`);
1679
+ }
1680
+ return dir;
1681
+ }
1682
+ function warn(text2) {
1683
+ process2.stderr.write(`[tamsense recorder] ${text2}
1684
+ `);
1685
+ }
1686
+ function isTrackerIngest(url, tamsenseOrigins) {
1687
+ if (!/\/api\/events\/?$/.test(url.pathname)) {
1688
+ return false;
1689
+ }
1690
+ return url.searchParams.has("key") || tamsenseOrigins.includes(url.origin);
1691
+ }
1692
+ function isLocalHost(hostname2) {
1693
+ const host = hostname2.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
1694
+ return host === "localhost" || host.endsWith(".localhost") || host === "::1" || host === "0.0.0.0" || /^127(?:\.\d{1,3}){3}$/.test(host);
1695
+ }
1696
+ function isExternalWrite(method, url) {
1697
+ return !READ_METHODS.includes(method.toUpperCase()) && !isLocalHost(url.hostname);
1698
+ }
1699
+ function trackerOrigins(env = process2.env) {
1700
+ const origins = new Set(DEFAULT_TRACKER_ORIGINS);
1701
+ try {
1702
+ if (env[SERVER_ENV]) {
1703
+ origins.add(new URL(env[SERVER_ENV]).origin);
1704
+ }
1705
+ } catch {
1706
+ }
1707
+ return [...origins];
1708
+ }
1709
+ function checkCaption(caption) {
1710
+ const title = typeof caption?.title === "string" ? caption.title.trim() : "";
1711
+ const purpose = typeof caption?.purpose === "string" ? caption.purpose.trim() : "";
1712
+ if (!title || !purpose) {
1713
+ 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).');
1714
+ }
1715
+ if (title.length > MAX_TITLE_LENGTH || purpose.length > MAX_PURPOSE_LENGTH) {
1716
+ throw new Error(`Keep the title within ${MAX_TITLE_LENGTH} characters and the purpose within ${MAX_PURPOSE_LENGTH}.`);
1717
+ }
1718
+ return { title, purpose };
1719
+ }
1720
+ function clip2(text2, max) {
1721
+ return text2.length > max ? `${text2.slice(0, max - 1).trimEnd()}\u2026` : text2;
1722
+ }
1723
+ function automaticCaption(env = process2.env, script2 = process2.argv[1]) {
1724
+ const from = script2 ? `, opened by ${basename(script2)}` : "";
1725
+ return {
1726
+ title: clip2(env[DEFAULT_TITLE_ENV]?.trim() || "Browser session", MAX_TITLE_LENGTH),
1727
+ purpose: clip2(`${env[DEFAULT_PURPOSE_ENV]?.trim() || "Recorded automatically while the agent used a browser"}${from}`, MAX_PURPOSE_LENGTH)
1728
+ };
1729
+ }
1730
+ function rrwebSource() {
1731
+ const entry = createRequire(import.meta.url).resolve("rrweb");
1732
+ return readFileSync(join3(dirname3(entry), "rrweb.umd.min.cjs"), "utf8");
1733
+ }
1734
+ function pageScript(rrweb) {
1735
+ return `(() => {
1736
+ if (window.top !== window || window.__tamsenseRecording) return;
1737
+ window.__tamsenseRecording = true;
1738
+ const rrweb = (() => { const module = { exports: {} }; const exports = module.exports; ${rrweb}
1739
+ ; return module.exports; })();
1740
+ let buffer = [];
1741
+ let timer = null;
1742
+ const send = (extra) => {
1743
+ clearTimeout(timer);
1744
+ timer = null;
1745
+ if (!buffer.length && !extra) return Promise.resolve();
1746
+ const events = buffer;
1747
+ buffer = [];
1748
+ return window.${BINDING}(JSON.stringify({ events, ...extra })).catch(() => {});
1749
+ };
1750
+ rrweb.record({
1751
+ emit(event) {
1752
+ buffer.push(event);
1753
+ if (!timer) timer = setTimeout(send, ${FLUSH_MS});
1754
+ },
1755
+ maskInputOptions: { password: true },
1756
+ });
1757
+ const crossOrigin = (frame) => { try { return new URL(frame.src, location.href).origin !== location.origin; } catch { return false; } };
1758
+ const checkGaps = () => {
1759
+ const gaps = [];
1760
+ if (document.querySelector('canvas')) gaps.push('canvas');
1761
+ if (document.querySelector('video')) gaps.push('video');
1762
+ if ([...document.querySelectorAll('iframe')].some(crossOrigin)) gaps.push('cross-origin iframe');
1763
+ if (gaps.length) send({ gaps });
1764
+ };
1765
+ window.__tamsenseFlush = () => send();
1766
+ window.__tamsenseMark = (label) => { rrweb.record.addCustomEvent('${MARK_TAG}', { label }); return send(); };
1767
+ addEventListener('pagehide', () => { send(); });
1768
+ addEventListener('load', () => setTimeout(checkGaps, ${GAP_CHECK_MS}));
1769
+ })();`;
1770
+ }
1771
+ function script() {
1772
+ const current = state();
1773
+ current.script ??= pageScript(rrwebSource());
1774
+ return current.script;
1775
+ }
1776
+ var Session = class {
1777
+ file;
1778
+ metaFile;
1779
+ meta;
1780
+ bytes = 0;
1781
+ constructor(dir, recording, url) {
1782
+ const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
1783
+ this.file = join3(dir, `${id}${REPLAY_SUFFIX}`);
1784
+ this.metaFile = join3(dir, `${id}${META_SUFFIX}`);
1785
+ this.meta = { version: 1, id, ...recording.caption, startedAt: Date.now(), endedAt: null, url, marks: [], video: null, gaps: [], blockedTrackerRequests: 0, blockedExternalWrites: 0, truncated: false, automatic: recording.automatic };
1786
+ writeFileSync(this.file, "");
1787
+ this.save();
1788
+ }
1789
+ save() {
1790
+ writeFileSync(this.metaFile, `${JSON.stringify(this.meta, null, 2)}
1791
+ `);
1792
+ }
1793
+ receive(payload) {
1794
+ let batch;
1795
+ try {
1796
+ batch = JSON.parse(payload);
1797
+ } catch {
1798
+ return;
1799
+ }
1800
+ if (batch.gaps?.length) {
1801
+ this.meta.gaps = [.../* @__PURE__ */ new Set([...this.meta.gaps, ...batch.gaps])];
1802
+ this.save();
1803
+ }
1804
+ const page = batch.events?.find((event) => event.type === RRWEB_META && typeof event.data?.href === "string" && event.data.href !== "about:blank");
1805
+ if (page && this.meta.url === "about:blank") {
1806
+ this.meta.url = String(page.data.href);
1807
+ this.save();
1808
+ }
1809
+ const lines = (batch.events ?? []).map((event) => JSON.stringify(event)).join("\n");
1810
+ if (!lines || this.meta.truncated) {
1811
+ return;
1812
+ }
1813
+ if (this.bytes + lines.length > MAX_SESSION_BYTES) {
1814
+ this.meta.truncated = true;
1815
+ this.save();
1816
+ return;
1817
+ }
1818
+ this.bytes += lines.length + 1;
1819
+ appendFileSync(this.file, `${lines}
1820
+ `);
1821
+ }
1822
+ end() {
1823
+ if (this.meta.endedAt === null) {
1824
+ this.meta.endedAt = Date.now();
1825
+ this.save();
1826
+ }
1827
+ }
1828
+ summary() {
1829
+ const { title, purpose, video, marks, gaps, blockedTrackerRequests, blockedExternalWrites } = this.meta;
1830
+ return { title, purpose, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests, blockedExternalWrites };
1831
+ }
1832
+ };
1833
+ function countBlocked(route, counter) {
1834
+ try {
1835
+ const session = state().pages.get(route.request().frame().page());
1836
+ if (session) {
1837
+ session.meta[counter] += 1;
1838
+ session.save();
1839
+ }
1840
+ } catch {
1841
+ }
1842
+ }
1843
+ async function startSession(recording, page, injectNow) {
1844
+ const current = state();
1845
+ if (current.pages.has(page)) {
1846
+ return;
1847
+ }
1848
+ const session = new Session(recordingsDir(), recording, page.url());
1849
+ current.pages.set(page, session);
1850
+ recording.sessions.push(session);
1851
+ page.on("close", () => session.end());
1852
+ const video = page.video();
1853
+ if (video) {
1854
+ session.meta.video = await video.path().catch(() => null);
1855
+ session.save();
1856
+ }
1857
+ if (injectNow) {
1858
+ await page.evaluate(script()).catch(() => {
1859
+ });
1860
+ }
1861
+ }
1862
+ async function flush(context) {
1863
+ await Promise.all(context.pages().filter((page) => !page.isClosed()).map(
1864
+ (page) => page.evaluate(() => window.__tamsenseFlush?.(), void 0).catch(() => {
1865
+ })
1866
+ ));
1867
+ }
1868
+ async function recordContext(context, caption, automatic = false) {
1869
+ const checked = checkCaption(caption);
1870
+ const current = state();
1871
+ const known = current.contexts.get(context);
1872
+ if (known) {
1873
+ Object.assign(known, { caption: checked, automatic });
1874
+ for (const session of known.sessions) {
1875
+ Object.assign(session.meta, checked, { automatic });
1876
+ session.save();
1877
+ }
1878
+ return;
1879
+ }
1880
+ mkdirSync(recordingsDir(), { recursive: true });
1881
+ const recording = { caption: checked, automatic, sessions: [] };
1882
+ current.contexts.set(context, recording);
1883
+ const origins = trackerOrigins();
1884
+ await context.route((url) => !isLocalHost(url.hostname), async (route) => {
1885
+ const request = route.request();
1886
+ if (!isExternalWrite(request.method(), new URL(request.url()))) {
1887
+ await route.continue().catch(() => {
1888
+ });
1889
+ return;
1890
+ }
1891
+ countBlocked(route, "blockedExternalWrites");
1892
+ await route.abort("blockedbyclient").catch(() => {
1893
+ });
1894
+ });
1895
+ await context.route((url) => isTrackerIngest(url, origins), async (route) => {
1896
+ countBlocked(route, "blockedTrackerRequests");
1897
+ await route.fulfill({ status: 200, contentType: "application/json", body: '{"ok":true}' });
1898
+ });
1899
+ await context.exposeBinding(BINDING, ({ page }, payload) => current.pages.get(page)?.receive(payload));
1900
+ await context.addInitScript({ content: script() });
1901
+ context.on("page", (page) => void startSession(recording, page, false));
1902
+ context.on("close", () => recording.sessions.forEach((session) => session.end()));
1903
+ for (const page of context.pages()) {
1904
+ await startSession(recording, page, true);
1905
+ }
1906
+ const close = context.close.bind(context);
1907
+ context.close = async (options) => {
1908
+ await flush(context);
1909
+ return close(options);
1910
+ };
1911
+ }
1912
+ function instrument(exports) {
1913
+ const chromium = exports?.chromium;
1914
+ const instrumentation = exports?._instrumentation ?? chromium?._instrumentation;
1915
+ const current = state();
1916
+ if (typeof chromium?.launch !== "function" || typeof instrumentation?.addListener !== "function" || current.instrumented.has(instrumentation)) {
1917
+ return;
1918
+ }
1919
+ current.instrumented.add(instrumentation);
1920
+ instrumentation.addListener({
1921
+ runAfterCreateBrowserContext: async (context) => {
1922
+ try {
1923
+ if (!current.contexts.has(context)) {
1924
+ await recordContext(context, automaticCaption(), true);
1925
+ }
1926
+ } catch (error) {
1927
+ warn(`could not record this browser: ${error instanceof Error ? error.message : String(error)}`);
1928
+ }
1929
+ }
1930
+ });
1931
+ }
1932
+ function installAutoRecording() {
1933
+ const current = state();
1934
+ if (current.hooked) {
1935
+ return;
1936
+ }
1937
+ current.hooked = true;
1938
+ const loader = Module;
1939
+ const load = loader._load;
1940
+ loader._load = function(...args) {
1941
+ const exports = load.apply(this, args);
1942
+ try {
1943
+ instrument(exports);
1944
+ } catch {
1945
+ }
1946
+ return exports;
1947
+ };
1948
+ }
1949
+ if (process2.env[RECORDINGS_DIR_ENV]) {
1950
+ installAutoRecording();
1951
+ }
1952
+
1953
+ // src/recorderEnv.ts
1954
+ var RECORDER_ENV = "TAMSENSE_RECORDER";
1955
+ function bundledRecorder() {
1956
+ const path = fileURLToPath2(new URL("./recorder.cjs", import.meta.url));
1957
+ return existsSync2(path) ? path : null;
1958
+ }
1959
+ function recordingsFolder(home, runId) {
1960
+ return join4(home, ".tamsense", "recordings", `run-${runId}`);
1961
+ }
1962
+ function nodeOptionsWithRecorder(recorder, nodeOptions) {
1963
+ return [nodeOptions?.trim(), `--require "${recorder.replaceAll("\\", "/")}"`].filter(Boolean).join(" ");
1964
+ }
1965
+ function recorderEnv(input) {
1966
+ const { job } = input;
1967
+ return {
1968
+ [RECORDER_ENV]: input.recorder,
1969
+ [RECORDINGS_DIR_ENV]: input.folder,
1970
+ [SERVER_ENV]: input.server,
1971
+ [DEFAULT_TITLE_ENV]: `${job.stage.name} browser session`.slice(0, MAX_TITLE_LENGTH),
1972
+ [DEFAULT_PURPOSE_ENV]: `Recorded automatically in the ${job.stage.name} step of task #${job.taskId}`.slice(0, MAX_PURPOSE_LENGTH),
1973
+ NODE_OPTIONS: nodeOptionsWithRecorder(input.recorder, input.nodeOptions)
1974
+ };
1975
+ }
1976
+
1498
1977
  // src/scope.ts
1499
1978
  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.";
1500
1979
  function teamKeyProblem(isTeam, env) {
@@ -1508,7 +1987,6 @@ function describeDevice(identity) {
1508
1987
  import { spawn as spawn2 } from "child_process";
1509
1988
  import { hostname } from "os";
1510
1989
  import { delimiter as delimiter2 } from "path";
1511
- import { createInterface as createInterface2 } from "readline/promises";
1512
1990
  import { setTimeout as wait } from "timers/promises";
1513
1991
  var PLATFORM_LABELS = {
1514
1992
  win32: "Windows",
@@ -1544,30 +2022,87 @@ function withNodeOnPath(env, nodeDir, separator = delimiter2) {
1544
2022
  }
1545
2023
  return { ...env, [key]: [...entries, nodeDir].join(separator) };
1546
2024
  }
1547
- async function askYesNo(question) {
1548
- const prompt = createInterface2({ input: process.stdin, output: process.stdout });
2025
+ async function sleep(ms, signal) {
2026
+ await wait(ms, void 0, { signal }).catch(() => {
2027
+ });
2028
+ }
2029
+
2030
+ // src/update.ts
2031
+ import { spawn as spawn3 } from "child_process";
2032
+ import { readFileSync as readFileSync2 } from "fs";
2033
+ import process3 from "process";
2034
+ var PACKAGE_NAME = "@tamsensedev/runner";
2035
+ var RELAUNCHED_ENV = "TAMSENSE_RELAUNCHED";
2036
+ var NO_UPDATE_ENV = "TAMSENSE_NO_UPDATE";
2037
+ var REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME.replace("/", "%2f")}/latest`;
2038
+ var REGISTRY_TIMEOUT_MS = 3e3;
2039
+ var RELEASE_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
2040
+ function releaseParts(version) {
2041
+ const match = RELEASE_PATTERN.exec(version.trim());
2042
+ return match ? match.slice(1).map(Number) : null;
2043
+ }
2044
+ function isNewer(latest, current) {
2045
+ const next = releaseParts(latest);
2046
+ const now = releaseParts(current);
2047
+ if (!next || !now) {
2048
+ return false;
2049
+ }
2050
+ const index = next.findIndex((part, position) => part !== now[position]);
2051
+ return index !== -1 && next[index] > now[index];
2052
+ }
2053
+ function currentVersion() {
1549
2054
  try {
1550
- for (; ; ) {
1551
- const answer = (await prompt.question(question)).trim().toLowerCase();
1552
- if (answer === "" || answer === "y" || answer === "yes") {
1553
- return true;
1554
- }
1555
- if (answer === "n" || answer === "no") {
1556
- return false;
1557
- }
1558
- }
1559
- } finally {
1560
- prompt.close();
2055
+ const manifest = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
2056
+ return manifest.name === PACKAGE_NAME && typeof manifest.version === "string" ? manifest.version : null;
2057
+ } catch {
2058
+ return null;
1561
2059
  }
1562
2060
  }
1563
- async function sleep(ms, signal) {
1564
- await wait(ms, void 0, { signal }).catch(() => {
2061
+ async function fetchLatest(url, signal) {
2062
+ const response = await fetch(url, { signal, headers: { accept: "application/json" } });
2063
+ return response.ok ? await response.json() : null;
2064
+ }
2065
+ async function latestVersion(fetchJson = fetchLatest, timeoutMs = REGISTRY_TIMEOUT_MS) {
2066
+ try {
2067
+ const body = await fetchJson(REGISTRY_URL, AbortSignal.timeout(timeoutMs));
2068
+ const version = body && typeof body === "object" ? body.version : null;
2069
+ return typeof version === "string" ? version : null;
2070
+ } catch {
2071
+ return null;
2072
+ }
2073
+ }
2074
+ async function updateToRun(env, current, latest) {
2075
+ if (!current || env[RELAUNCHED_ENV] || env[NO_UPDATE_ENV]) {
2076
+ return null;
2077
+ }
2078
+ const found = await latest();
2079
+ return found && isNewer(found, current) ? { current, latest: found } : null;
2080
+ }
2081
+ function relaunchArgs(argv) {
2082
+ return ["--yes", `${PACKAGE_NAME}@latest`, ...argv];
2083
+ }
2084
+ function relaunch(argv, env) {
2085
+ const isWindows = process3.platform === "win32";
2086
+ return new Promise((resolve, reject) => {
2087
+ const child = spawn3(isWindows ? "npx.cmd" : "npx", relaunchArgs(argv), { stdio: "inherit", shell: isWindows, env: { ...env, [RELAUNCHED_ENV]: "1" } });
2088
+ const ignoreInterrupt = () => {
2089
+ };
2090
+ process3.on("SIGINT", ignoreInterrupt);
2091
+ child.on("error", (error) => {
2092
+ process3.off("SIGINT", ignoreInterrupt);
2093
+ reject(error);
2094
+ });
2095
+ child.on("close", (code) => {
2096
+ process3.off("SIGINT", ignoreInterrupt);
2097
+ resolve(code ?? 1);
2098
+ });
1565
2099
  });
1566
2100
  }
1567
2101
 
1568
2102
  // src/worker.ts
1569
- import { dirname as dirname3 } from "path";
1570
- import process2 from "process";
2103
+ import { rm as rm4 } from "fs/promises";
2104
+ import { dirname as dirname5 } from "path";
2105
+ import process4 from "process";
1571
2106
 
1572
2107
  // src/feedQueue.ts
1573
2108
  var MAX_BATCH = 200;
@@ -1631,43 +2166,269 @@ function keyedLock() {
1631
2166
  };
1632
2167
  }
1633
2168
 
2169
+ // src/recordings.ts
2170
+ import { randomUUID } from "crypto";
2171
+ import { createReadStream } from "fs";
2172
+ import { readdir, readFile as readFile3, stat, writeFile as writeFile3 } from "fs/promises";
2173
+ import { basename as basename2, dirname as dirname4, extname, isAbsolute, join as join5 } from "path";
2174
+ import { createInterface as createInterface2 } from "readline";
2175
+ import { Readable } from "stream";
2176
+ import { promisify } from "util";
2177
+ import { gzip } from "zlib";
2178
+ var gzipAsync = promisify(gzip);
2179
+ var CHUNK_TARGET_BYTES = 2 * 1024 * 1024;
2180
+ var MAX_LINE_BYTES = 30 * 1024 * 1024;
2181
+ var LEDGER_FILE = "uploaded.json";
2182
+ var RRWEB_FULL_SNAPSHOT = 2;
2183
+ var RRWEB_META2 = 4;
2184
+ var RRWEB_CUSTOM = 5;
2185
+ var VIDEO_TYPES_BY_EXTENSION = {
2186
+ ".webm": "video/webm",
2187
+ ".mp4": "video/mp4"
2188
+ };
2189
+ function eventOf(line) {
2190
+ try {
2191
+ const event = JSON.parse(line);
2192
+ return typeof event?.type === "number" && typeof event.timestamp === "number" ? event : null;
2193
+ } catch {
2194
+ return null;
2195
+ }
2196
+ }
2197
+ async function* replayChunks(lines, scan, targetBytes = CHUNK_TARGET_BYTES) {
2198
+ let chunk = [];
2199
+ let bytes = 0;
2200
+ let previous = null;
2201
+ for await (const line of lines) {
2202
+ const event = line.trim() ? eventOf(line) : null;
2203
+ if (!event) {
2204
+ continue;
2205
+ }
2206
+ if (line.length > MAX_LINE_BYTES) {
2207
+ throw new Error("One event of this replay is larger than 30 MB, so it cannot be uploaded.");
2208
+ }
2209
+ const startsGroup = event.type === RRWEB_META2 || event.type === RRWEB_FULL_SNAPSHOT && previous?.type !== RRWEB_META2;
2210
+ if (chunk.length > 0 && (startsGroup || bytes + line.length > targetBytes)) {
2211
+ yield `${chunk.join("\n")}
2212
+ `;
2213
+ chunk = [];
2214
+ bytes = 0;
2215
+ }
2216
+ chunk.push(line);
2217
+ bytes += line.length + 1;
2218
+ previous = event;
2219
+ scan.startTs = Math.min(scan.startTs, event.timestamp);
2220
+ scan.snapshot ||= event.type === RRWEB_FULL_SNAPSHOT;
2221
+ if (event.type === RRWEB_CUSTOM && event.data?.tag === MARK_TAG && typeof event.data.payload?.label === "string") {
2222
+ scan.marks.push({ ts: event.timestamp, label: event.data.payload.label });
2223
+ }
2224
+ }
2225
+ if (chunk.length > 0) {
2226
+ yield `${chunk.join("\n")}
2227
+ `;
2228
+ }
2229
+ }
2230
+ function marksFrom(marks, startTs) {
2231
+ return marks.map((mark) => ({ at: Math.max(0, mark.ts - startTs), label: mark.label })).sort((a, b) => a.at - b.at);
2232
+ }
2233
+ function sessionIdOf(file) {
2234
+ return basename2(file).replace(REPLAY_SUFFIX, "");
2235
+ }
2236
+ async function readMeta(file) {
2237
+ try {
2238
+ return JSON.parse(await readFile3(file, "utf8"));
2239
+ } catch {
2240
+ return null;
2241
+ }
2242
+ }
2243
+ async function metaFor(kind, file) {
2244
+ if (kind === "replay") {
2245
+ return readMeta(join5(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
2246
+ }
2247
+ const folder = basename2(dirname4(file)) === "video" ? dirname4(dirname4(file)) : dirname4(file);
2248
+ for (const name of await readdir(folder).catch(() => [])) {
2249
+ if (name.endsWith(META_SUFFIX) && name !== LEDGER_FILE) {
2250
+ const meta = await readMeta(join5(folder, name));
2251
+ if (meta?.video === file) {
2252
+ return meta;
2253
+ }
2254
+ }
2255
+ }
2256
+ return null;
2257
+ }
2258
+ async function ledger(dir) {
2259
+ try {
2260
+ return new Set(JSON.parse(await readFile3(join5(dir, LEDGER_FILE), "utf8")));
2261
+ } catch {
2262
+ return /* @__PURE__ */ new Set();
2263
+ }
2264
+ }
2265
+ async function remember(dir, file) {
2266
+ const done = await ledger(dir);
2267
+ done.add(file);
2268
+ await writeFile3(join5(dir, LEDGER_FILE), JSON.stringify([...done]));
2269
+ }
2270
+ async function nonEmpty(file) {
2271
+ return file !== null && ((await stat(file).catch(() => null))?.size ?? 0) > 0;
2272
+ }
2273
+ async function pendingRecordings(dir) {
2274
+ const done = await ledger(dir);
2275
+ const names = (await readdir(dir).catch(() => [])).sort();
2276
+ const pending = [];
2277
+ for (const name of names) {
2278
+ if (!name.endsWith(META_SUFFIX) || name === LEDGER_FILE) {
2279
+ continue;
2280
+ }
2281
+ const meta = await readMeta(join5(dir, name));
2282
+ const replay = join5(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
2283
+ if (!done.has(replay) && await nonEmpty(replay)) {
2284
+ pending.push({ kind: "replay", file: replay, meta });
2285
+ }
2286
+ if (meta?.video && !done.has(meta.video) && await nonEmpty(meta.video)) {
2287
+ pending.push({ kind: "video", file: meta.video, meta });
2288
+ }
2289
+ }
2290
+ return pending;
2291
+ }
2292
+ async function uploadReplay(upload, file, caption) {
2293
+ const id = randomUUID();
2294
+ const scan = { startTs: Number.POSITIVE_INFINITY, snapshot: false, marks: [] };
2295
+ const lines = createInterface2({ input: createReadStream(file, "utf8"), crlfDelay: Infinity });
2296
+ for await (const chunk of replayChunks(lines, scan)) {
2297
+ await upload.api.upload("POST", recordingUploadPath(upload.runId, id, "chunks"), new Uint8Array(await gzipAsync(chunk)), "application/gzip");
2298
+ }
2299
+ if (!scan.snapshot) {
2300
+ throw new Error("This replay has no page snapshot, so it cannot be played. Was the page ever opened?");
2301
+ }
2302
+ return finish(upload, { kind: "replay", upload: id, name: basename2(file), ...caption, contentType: "application/x-ndjson", durationMs: null, marks: marksFrom(scan.marks, scan.startTs) });
2303
+ }
2304
+ async function uploadVideo(upload, file, caption, meta) {
2305
+ const contentType = VIDEO_TYPES_BY_EXTENSION[extname(file).toLowerCase()];
2306
+ const info = await stat(file);
2307
+ if (info.size > MAX_VIDEO_BYTES) {
2308
+ throw new Error(`The video is larger than ${MAX_VIDEO_BYTES / 1024 / 1024} MB. Record a shorter one.`);
2309
+ }
2310
+ const id = randomUUID();
2311
+ const body = Readable.toWeb(createReadStream(file));
2312
+ await upload.api.upload("PUT", recordingUploadPath(upload.runId, id, "video"), body, contentType);
2313
+ const startedAt = meta?.startedAt ?? info.birthtimeMs;
2314
+ const endedAt = meta?.endedAt ?? info.mtimeMs;
2315
+ const durationMs = Math.max(0, Math.round(endedAt - startedAt));
2316
+ const marks = marksFrom(meta?.marks ?? [], startedAt).filter((mark) => mark.at <= durationMs);
2317
+ return finish(upload, { kind: "video", upload: id, name: basename2(file), ...caption, contentType, durationMs, marks });
2318
+ }
2319
+ async function finish(upload, body) {
2320
+ return (await upload.api.request("POST", runPath(upload.runId, "recordings"), body)).id;
2321
+ }
2322
+ async function attachRecording(api, runId, recordingsDir2, request) {
2323
+ if (!isAbsolute(request.path)) {
2324
+ return `Give the absolute path of the ${request.kind === "replay" ? "replay (.rrweb.ndjson)" : "video (.webm or .mp4)"} file.`;
2325
+ }
2326
+ const fitsKind = request.kind === "replay" ? request.path.endsWith(REPLAY_SUFFIX) : extname(request.path).toLowerCase() in VIDEO_TYPES_BY_EXTENSION;
2327
+ if (!fitsKind) {
2328
+ return request.kind === "replay" ? `A replay is the ${REPLAY_SUFFIX} file the TAMsense recorder wrote.` : "Only WebM and MP4 videos can be attached.";
2329
+ }
2330
+ if (!await nonEmpty(request.path)) {
2331
+ return `There is no recording at ${request.path}. Close the browser context first (closeRecorded) so the file is complete.`;
2332
+ }
2333
+ let caption;
2334
+ try {
2335
+ caption = checkCaption(request);
2336
+ } catch (error) {
2337
+ return error instanceof Error ? error.message : String(error);
2338
+ }
2339
+ try {
2340
+ const meta = await metaFor(request.kind, request.path);
2341
+ const upload = { api, runId };
2342
+ await (request.kind === "replay" ? uploadReplay(upload, request.path, caption) : uploadVideo(upload, request.path, caption, meta));
2343
+ await remember(recordingsDir2, request.path);
2344
+ } catch (error) {
2345
+ return `Could not attach the ${request.kind}: ${error instanceof Error ? error.message : String(error)}`;
2346
+ }
2347
+ return "Attached to the task.";
2348
+ }
2349
+ function numbered(key, title, used) {
2350
+ const count = (used.get(key) ?? 0) + 1;
2351
+ used.set(key, count);
2352
+ const suffix = count > 1 ? ` (${count})` : "";
2353
+ return `${title.slice(0, MAX_TITLE_LENGTH - suffix.length)}${suffix}`;
2354
+ }
2355
+ function overLimit(error) {
2356
+ return error instanceof ApiError && error.status === 422 && /at most/i.test(error.message);
2357
+ }
2358
+ async function uploadPendingRecordings(api, runId, dir) {
2359
+ const report = { uploaded: 0, failed: [], skipped: 0 };
2360
+ const used = /* @__PURE__ */ new Map();
2361
+ const full = /* @__PURE__ */ new Set();
2362
+ const upload = { api, runId };
2363
+ const pending = (await pendingRecordings(dir)).sort((a, b) => Number(a.meta?.automatic ?? true) - Number(b.meta?.automatic ?? true));
2364
+ for (const recording of pending) {
2365
+ if (full.has(recording.kind)) {
2366
+ report.skipped += 1;
2367
+ continue;
2368
+ }
2369
+ const title = recording.meta?.title || "Browser session";
2370
+ const caption = checkCaption({
2371
+ title: numbered(`${recording.kind}:${title}`, title, used),
2372
+ purpose: recording.meta?.purpose || "Recorded automatically while the agent used a browser"
2373
+ });
2374
+ try {
2375
+ await (recording.kind === "replay" ? uploadReplay(upload, recording.file, caption) : uploadVideo(upload, recording.file, caption, recording.meta));
2376
+ await remember(dir, recording.file);
2377
+ report.uploaded += 1;
2378
+ } catch (error) {
2379
+ if (overLimit(error)) {
2380
+ full.add(recording.kind);
2381
+ report.skipped += 1;
2382
+ continue;
2383
+ }
2384
+ report.failed.push(`${caption.title} (${recording.kind}): ${error instanceof Error ? error.message : String(error)}`);
2385
+ }
2386
+ }
2387
+ return report;
2388
+ }
2389
+
1634
2390
  // src/screenshot.ts
1635
- import { readFile as readFile3, stat } from "fs/promises";
1636
- import { basename, extname, isAbsolute } from "path";
2391
+ import { readFile as readFile4, stat as stat2 } from "fs/promises";
2392
+ import { basename as basename3, extname as extname2, isAbsolute as isAbsolute2 } from "path";
1637
2393
  var TYPES_BY_EXTENSION = {
1638
2394
  ".png": "image/png",
1639
2395
  ".jpg": "image/jpeg",
1640
2396
  ".jpeg": "image/jpeg",
1641
2397
  ".webp": "image/webp"
1642
2398
  };
1643
- var MAX_CAPTION_LENGTH = 300;
1644
2399
  async function readScreenshot(request) {
1645
- if (!isAbsolute(request.path)) {
2400
+ if (!isAbsolute2(request.path)) {
1646
2401
  return "Give the absolute path of the image file.";
1647
2402
  }
1648
- const contentType = TYPES_BY_EXTENSION[extname(request.path).toLowerCase()];
2403
+ const contentType = TYPES_BY_EXTENSION[extname2(request.path).toLowerCase()];
1649
2404
  if (!contentType) {
1650
2405
  return "Only PNG, JPEG and WebP images can be attached.";
1651
2406
  }
1652
- const info = await stat(request.path).catch(() => null);
2407
+ let caption;
2408
+ try {
2409
+ caption = checkCaption(request);
2410
+ } catch (error) {
2411
+ return error instanceof Error ? error.message : String(error);
2412
+ }
2413
+ const info = await stat2(request.path).catch(() => null);
1653
2414
  if (!info?.isFile()) {
1654
2415
  return `There is no file at ${request.path}.`;
1655
2416
  }
1656
2417
  if (info.size > MAX_ATTACHMENT_BYTES) {
1657
2418
  return "The image is larger than 5 MB. Take a smaller screenshot (viewport instead of full page, or JPEG).";
1658
2419
  }
1659
- const data = (await readFile3(request.path)).toString("base64");
1660
- return { name: basename(request.path), caption: request.caption.trim().slice(0, MAX_CAPTION_LENGTH), contentType, data };
2420
+ const data = (await readFile4(request.path)).toString("base64");
2421
+ return { name: basename3(request.path), ...caption, contentType, data };
1661
2422
  }
1662
2423
 
1663
2424
  // src/workspace.ts
1664
- import { existsSync as existsSync2 } from "fs";
2425
+ import { existsSync as existsSync3 } from "fs";
1665
2426
  import { rm as rm3 } from "fs/promises";
1666
- import { join as join3 } from "path";
2427
+ import { join as join6 } from "path";
1667
2428
 
1668
2429
  // src/git.ts
1669
2430
  import { Buffer } from "buffer";
1670
- import { spawn as spawn3 } from "child_process";
2431
+ import { spawn as spawn4 } from "child_process";
1671
2432
  var GITHUB_ORIGIN = "https://github.com/";
1672
2433
  function githubAuthEnv(token) {
1673
2434
  const credentials = Buffer.from(`x-access-token:${token}`).toString("base64");
@@ -1681,7 +2442,7 @@ function githubAuthEnv(token) {
1681
2442
  function runGit(args, options) {
1682
2443
  const env = { ...process.env, ...options.env, ...options.token ? githubAuthEnv(options.token) : {} };
1683
2444
  return new Promise((resolve, reject) => {
1684
- const child = spawn3("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"] });
2445
+ const child = spawn4("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"] });
1685
2446
  let stdout = "";
1686
2447
  let stderr = "";
1687
2448
  child.stdout.on("data", (chunk) => {
@@ -1709,7 +2470,7 @@ function githubRemoteUrl(fullName) {
1709
2470
  return `https://github.com/${fullName}.git`;
1710
2471
  }
1711
2472
  function tamsenseFolder(home, ...parts) {
1712
- return join3(home, ".tamsense", ...parts);
2473
+ return join6(home, ".tamsense", ...parts);
1713
2474
  }
1714
2475
  function folderNames(repositories) {
1715
2476
  const shortName = (fullName) => fullName.split("/").pop() ?? fullName;
@@ -1720,7 +2481,7 @@ function folderNames(repositories) {
1720
2481
  return repositories.map((repository) => counts.get(shortName(repository.fullName)) === 1 ? shortName(repository.fullName) : repository.fullName.replace("/", "__"));
1721
2482
  }
1722
2483
  async function refreshCache(cache, repository, token, tools) {
1723
- if (existsSync2(join3(cache, ".git"))) {
2484
+ if (existsSync3(join6(cache, ".git"))) {
1724
2485
  await git(["fetch", "--prune", "origin"], { cwd: cache, token });
1725
2486
  return;
1726
2487
  }
@@ -1735,6 +2496,16 @@ async function baseRef(cache, job, repository) {
1735
2496
  const pushed = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${job.branch}`], { cwd: cache });
1736
2497
  return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
1737
2498
  }
2499
+ async function keptBase(cache, path, job, repository) {
2500
+ if (!job.keepWorkspace || !existsSync3(join6(path, ".git"))) {
2501
+ return null;
2502
+ }
2503
+ const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
2504
+ if (branch.code !== 0 || branch.stdout !== job.branch) {
2505
+ return null;
2506
+ }
2507
+ return git(["rev-parse", await baseRef(cache, job, repository)], { cwd: cache });
2508
+ }
1738
2509
  async function prepareWorkspace(job, token, tools) {
1739
2510
  const root = tamsenseFolder(tools.home, "work", `task-${job.taskId}`);
1740
2511
  const folders = folderNames(job.repositories);
@@ -1742,9 +2513,13 @@ async function prepareWorkspace(job, token, tools) {
1742
2513
  for (const [index, repository] of job.repositories.entries()) {
1743
2514
  const cache = tamsenseFolder(tools.home, "repos", repository.fullName.replace("/", "__"));
1744
2515
  const folder = folders[index] ?? repository.fullName;
1745
- const path = join3(root, folder);
2516
+ const path = join6(root, folder);
1746
2517
  const baseSha = await cacheLock.run(repository.fullName, async () => {
1747
2518
  await refreshCache(cache, repository, token, tools);
2519
+ const kept = await keptBase(cache, path, job, repository);
2520
+ if (kept) {
2521
+ return kept;
2522
+ }
1748
2523
  await removeWorktree(cache, path);
1749
2524
  await git(["worktree", "add", "--force", "-B", job.branch, path, await baseRef(cache, job, repository)], { cwd: cache });
1750
2525
  return git(["rev-parse", "HEAD"], { cwd: path });
@@ -1921,6 +2696,13 @@ function specWorkTools2(job, api) {
1921
2696
  }
1922
2697
  return { stage: kind, review: (body) => api.request("POST", runPath(job.runId, "review"), body) };
1923
2698
  }
2699
+ function askingTools(job, api) {
2700
+ const people = job.people ?? [];
2701
+ if (people.length === 0 || job.stage.kind === "interview" || job.stage.kind === "spec") {
2702
+ return null;
2703
+ }
2704
+ return { people, send: (body) => api.request("POST", runPath(job.runId, "questions"), body) };
2705
+ }
1924
2706
  async function executeJob(job, tools, signal, input, record) {
1925
2707
  const isCheck = job.stage.kind === "review" || job.stage.kind === "test";
1926
2708
  tools.print("Preparing the code\u2026");
@@ -1930,25 +2712,30 @@ async function executeJob(job, tools, signal, input, record) {
1930
2712
  tools.print("Claude is working\u2026");
1931
2713
  const attached = { count: 0 };
1932
2714
  const session = sessionReporter(tools.api, job, tools.print);
2715
+ const recordings = recordingsFolder(tools.home, job.runId);
2716
+ const baseEnv = withNodeOnPath(process4.env, dirname5(process4.execPath));
2717
+ const recording = tools.recorder ? recorderEnv({ recorder: tools.recorder, folder: recordings, server: tools.api.server, job, nodeOptions: baseEnv.NODE_OPTIONS }) : {};
1933
2718
  const outcome = await tools.runAgent({
1934
2719
  cwd: workspace.root,
1935
- prompt: buildTaskPrompt(job, workspace, { browser: Boolean(tools.browser) }),
2720
+ prompt: buildTaskPrompt(job, workspace, { browser: Boolean(tools.browser), recorder: Boolean(tools.recorder) }),
1936
2721
  input,
1937
- env: { ...withNodeOnPath(process2.env, dirname3(process2.execPath)), ...tools.browser ? browserEnv(tools.browser) : {}, ...identity },
2722
+ env: { ...baseEnv, ...tools.browser ? browserEnv(tools.browser) : {}, ...recording, ...identity },
1938
2723
  signal,
1939
2724
  wantsVerdict: isCheck,
1940
2725
  specWork: specWorkTools2(job, tools.api),
2726
+ asking: askingTools(job, tools.api),
1941
2727
  handoffs: job.allowedHandoffs,
1942
2728
  resumeSessionId: job.resumeSessionId,
1943
2729
  model: job.model,
1944
2730
  effort: job.effort,
1945
2731
  onFeed: record,
1946
2732
  onSession: session.report,
1947
- attach: (screenshot) => attachScreenshot(tools.api, job, screenshot, attached)
2733
+ attach: (request) => request.kind === "screenshot" ? attachScreenshot(tools.api, job, request, attached) : attachRecording(tools.api, job.runId, recordings, request)
1948
2734
  });
1949
2735
  await session.sent();
1950
2736
  signal.throwIfAborted();
1951
- const shouldPublish = job.stage.kind === "code" && outcome.completed;
2737
+ const waitingFor = outcome.waitingFor ?? null;
2738
+ const shouldPublish = job.stage.kind === "code" && outcome.completed && waitingFor === null;
1952
2739
  const pushed = shouldPublish ? await publishWorkspace(workspace, job, await gitToken(tools.api, job), identity) : [];
1953
2740
  return {
1954
2741
  outcome: outcome.completed ? "succeeded" : "failed",
@@ -1961,27 +2748,51 @@ async function executeJob(job, tools, signal, input, record) {
1961
2748
  sessionId: outcome.sessionId ?? null,
1962
2749
  pausedUntil: outcome.pausedUntil ?? null,
1963
2750
  questions: outcome.questions ?? null,
1964
- spec: outcome.spec ?? null
2751
+ spec: outcome.spec ?? null,
2752
+ waitingFor
1965
2753
  };
1966
2754
  }
1967
2755
  function clockOf(iso) {
1968
2756
  const time = new Date(iso);
1969
2757
  return `${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
1970
2758
  }
1971
- function resultLine(finish) {
1972
- if (finish.pausedUntil) {
1973
- return `Paused: ${USAGE_LIMIT_REPORT}, resumes at ${clockOf(finish.pausedUntil)}`;
2759
+ function resultLine(finish2) {
2760
+ if (finish2.pausedUntil) {
2761
+ return `Paused: ${USAGE_LIMIT_REPORT}, resumes at ${clockOf(finish2.pausedUntil)}`;
1974
2762
  }
1975
- if (finish.verdict) {
1976
- return finish.verdict.passed ? "Check passed." : `Check did not pass: ${finish.verdict.report}`;
2763
+ if (finish2.waitingFor !== null) {
2764
+ return "Waiting for an answer on TAMsense. This computer is free for other tasks; the run continues here after the answer.";
1977
2765
  }
1978
- if (finish.questions) {
1979
- return finish.questions.questions.length > 0 ? `Done. ${finish.questions.questions.length} question(s) wait for the owner on TAMsense.` : "Done. No questions needed, the spec is written next.";
2766
+ if (finish2.verdict?.couldNotRun) {
2767
+ return `Check could not run: ${finish2.verdict.report}`;
1980
2768
  }
1981
- if (finish.spec) {
2769
+ if (finish2.verdict) {
2770
+ return finish2.verdict.passed ? "Check passed." : `Check did not pass: ${finish2.verdict.report}`;
2771
+ }
2772
+ if (finish2.questions) {
2773
+ 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.";
2774
+ }
2775
+ if (finish2.spec) {
1982
2776
  return "Done. The spec waits for the owner on TAMsense.";
1983
2777
  }
1984
- return finish.outcome === "succeeded" ? `Done. Pushed ${finish.pushed.length} branch(es), the pull requests open on TAMsense.` : `Failed: ${finish.report}`;
2778
+ return finish2.outcome === "succeeded" ? `Done. Pushed ${finish2.pushed.length} branch(es), the pull requests open on TAMsense.` : `Failed: ${finish2.report}`;
2779
+ }
2780
+ async function sendRecordings(job, tools) {
2781
+ const folder = recordingsFolder(tools.home, job.runId);
2782
+ try {
2783
+ const report = await uploadPendingRecordings(tools.api, job.runId, folder);
2784
+ if (report.uploaded > 0) {
2785
+ tools.print(`Attached ${report.uploaded} browser recording(s) to the task.`);
2786
+ }
2787
+ if (report.skipped > 0) {
2788
+ tools.print(`${report.skipped} more recording(s) were not attached: the run reached its limit.`);
2789
+ }
2790
+ for (const line of report.failed) {
2791
+ tools.print(`Could not attach a recording: ${line}`);
2792
+ }
2793
+ } finally {
2794
+ await rm4(folder, { recursive: true, force: true });
2795
+ }
1985
2796
  }
1986
2797
  function failureReport(error, signal) {
1987
2798
  return signal.reason === STOPPED_HERE ? "The run was stopped on the computer." : `The run stopped on the computer: ${messageOf(error)}`;
@@ -2002,11 +2813,11 @@ async function runJob(job, tools, stop) {
2002
2813
  run2.signal.addEventListener("abort", input.close, { once: true });
2003
2814
  void messages.poll();
2004
2815
  const polling = setInterval(() => void messages.poll(), INBOX_INTERVAL_MS);
2005
- let finish;
2816
+ let finish2;
2006
2817
  try {
2007
- finish = await executeJob(job, tools, run2.signal, input, record);
2818
+ finish2 = await executeJob(job, tools, run2.signal, input, record);
2008
2819
  } catch (error) {
2009
- finish = { outcome: "failed", report: failureReport(error, run2.signal), pushed: [], model: null, usage: null, verdict: null, handoff: null, sessionId: null, pausedUntil: null, questions: null, spec: null };
2820
+ 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, waitingFor: null };
2010
2821
  } finally {
2011
2822
  clearInterval(polling);
2012
2823
  clearInterval(feeding);
@@ -2019,12 +2830,14 @@ async function runJob(job, tools, stop) {
2019
2830
  }
2020
2831
  await feed.flush();
2021
2832
  if (run2.signal.reason === CLOSED_ON_SITE) {
2833
+ await rm4(recordingsFolder(tools.home, job.runId), { recursive: true, force: true });
2022
2834
  tools.print("TAMsense closed this run while it was in progress. The result was not sent.");
2023
2835
  return;
2024
2836
  }
2837
+ await sendRecordings(job, tools).catch((error) => tools.print(`Could not attach the browser recordings: ${messageOf(error)}`));
2025
2838
  try {
2026
- await tools.api.request("POST", runPath(job.runId, "finish"), finish);
2027
- tools.print(resultLine(finish));
2839
+ await tools.api.request("POST", runPath(job.runId, "finish"), finish2);
2840
+ tools.print(resultLine(finish2));
2028
2841
  } catch (error) {
2029
2842
  tools.print(`Could not send the result to TAMsense: ${messageOf(error)}`);
2030
2843
  }
@@ -2103,11 +2916,11 @@ var NO_TASKS_ACCESS_TEXT = "Your account no longer has access to Tasks. Ask the
2103
2916
  var ACCESS_PROBLEMS = { 401: DISCONNECTED_TEXT, 403: NO_TASKS_ACCESS_TEXT };
2104
2917
  var INTERRUPTED_EXIT_CODE = 130;
2105
2918
  function print(line) {
2106
- process3.stdout.write(`${line}
2919
+ process5.stdout.write(`${line}
2107
2920
  `);
2108
2921
  }
2109
2922
  function serverFor(flag, saved) {
2110
- return serverOrigin(flag ?? process3.env.TAMSENSE_URL ?? saved?.server ?? DEFAULT_SERVER);
2923
+ return serverOrigin(flag ?? process5.env.TAMSENSE_URL ?? saved?.server ?? DEFAULT_SERVER);
2111
2924
  }
2112
2925
  function accessProblem(error) {
2113
2926
  return error instanceof ApiError ? ACCESS_PROBLEMS[error.status] ?? null : null;
@@ -2128,33 +2941,46 @@ async function runLogin(server, deviceName) {
2128
2941
  { api, openBrowser, print, sleep, now: Date.now },
2129
2942
  { name, platform: platformLabel() }
2130
2943
  );
2131
- const browserDeclined = saved?.browserDeclined ? { browserDeclined: true } : {};
2132
- await writeConfig({ server: api.server, token: result.token, deviceId: result.device.id, deviceName: result.device.name, ...browserDeclined });
2944
+ await writeConfig({ server: api.server, token: result.token, deviceId: result.device.id, deviceName: result.device.name });
2133
2945
  print(`Connected: ${result.device.name}. To take your tasks, run ${START_COMMAND}`);
2134
2946
  }
2135
2947
  function stopOnInterrupt() {
2136
2948
  const stop = new AbortController();
2137
- process3.once("SIGINT", () => {
2949
+ process5.once("SIGINT", () => {
2138
2950
  print("Stopping\u2026 Press Ctrl+C again to quit at once.");
2139
2951
  stop.abort();
2140
- process3.once("SIGINT", () => process3.exit(INTERRUPTED_EXIT_CODE));
2952
+ process5.once("SIGINT", () => process5.exit(INTERRUPTED_EXIT_CODE));
2141
2953
  });
2142
2954
  return stop.signal;
2143
2955
  }
2144
2956
  function installTools(home) {
2145
- return { home, nodePath: process3.execPath, env: process3.env, print, run: runProcess, exists: existsSync3 };
2146
- }
2147
- async function browserForStart(home, saved) {
2148
- return await installedBrowser(home) ?? await offerBrowser({
2149
- interactive: Boolean(process3.stdin.isTTY && process3.stdout.isTTY),
2150
- declined: saved.browserDeclined === true,
2151
- print,
2152
- ask: askYesNo,
2153
- install: () => installBrowser(installTools(home)),
2154
- rememberDecline: async () => writeConfig({ ...saved, browserDeclined: true })
2155
- });
2957
+ return { home, nodePath: process5.execPath, env: process5.env, print, run: runProcess, exists: existsSync4 };
2958
+ }
2959
+ async function browserForStart(home, install) {
2960
+ const installed = await installedBrowser(home);
2961
+ if (installed || !install) {
2962
+ return installed;
2963
+ }
2964
+ return ensureBrowser({ print, install: () => installBrowser(installTools(home)) });
2156
2965
  }
2157
- async function runStart(parallel) {
2966
+ async function startedNewVersion() {
2967
+ const update = await updateToRun(process5.env, currentVersion(), () => latestVersion());
2968
+ if (!update) {
2969
+ return false;
2970
+ }
2971
+ print(`TAMsense runner ${update.latest} is out, this is ${update.current}. Starting the new version...`);
2972
+ try {
2973
+ process5.exitCode = await relaunch(process5.argv.slice(2), process5.env);
2974
+ return true;
2975
+ } catch {
2976
+ print("Could not start the new version. Going on with this one.");
2977
+ return false;
2978
+ }
2979
+ }
2980
+ async function runStart(parallel, installsBrowser) {
2981
+ if (await startedNewVersion()) {
2982
+ return;
2983
+ }
2158
2984
  const saved = await readConfig();
2159
2985
  if (!saved) {
2160
2986
  print(NOT_CONNECTED_TEXT);
@@ -2162,23 +2988,19 @@ async function runStart(parallel) {
2162
2988
  }
2163
2989
  const api = createApi(saved.server, saved.token);
2164
2990
  const identity = await api.request("GET", IDENTITY_PATH);
2165
- const problem = teamKeyProblem(identity.device.team, process3.env);
2991
+ const problem = teamKeyProblem(identity.device.team, process5.env);
2166
2992
  if (problem) {
2167
2993
  throw new Error(problem);
2168
2994
  }
2169
2995
  print(`${identity.device.name} is connected to ${saved.server} as ${describeDevice(identity)}.`);
2170
2996
  const home = homedir3();
2171
- const browser = await browserForStart(home, saved);
2172
- await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser }, stopOnInterrupt(), parallel);
2997
+ const browser = await browserForStart(home, installsBrowser);
2998
+ await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder() }, stopOnInterrupt(), parallel);
2173
2999
  }
2174
3000
  async function runBrowser(remove) {
2175
3001
  const home = homedir3();
2176
3002
  if (remove) {
2177
- print(await removeBrowser(home) ? "Removed the headless browser." : "No headless browser is installed.");
2178
- const saved = await readConfig();
2179
- if (saved) {
2180
- await writeConfig({ ...saved, browserDeclined: true });
2181
- }
3003
+ print(await removeBrowser(home) ? "Removed the headless browser. The next start installs it again, unless you add --no-browser." : "No headless browser is installed.");
2182
3004
  return;
2183
3005
  }
2184
3006
  const installed = await installedBrowser(home);
@@ -2213,7 +3035,7 @@ async function run(command) {
2213
3035
  if (command.name === "login") {
2214
3036
  await runLogin(command.server, command.deviceName);
2215
3037
  } else if (command.name === "start") {
2216
- await runStart(command.parallel);
3038
+ await runStart(command.parallel, command.browser);
2217
3039
  } else if (command.name === "browser") {
2218
3040
  await runBrowser(command.remove);
2219
3041
  } else if (command.name === "status") {
@@ -2225,10 +3047,10 @@ async function run(command) {
2225
3047
  }
2226
3048
  }
2227
3049
  try {
2228
- await run(parseCommand(process3.argv.slice(2), process3.env));
3050
+ await run(parseCommand(process5.argv.slice(2), process5.env));
2229
3051
  } catch (error) {
2230
- process3.stderr.write(`${accessProblem(error) ?? (error instanceof Error ? error.message : String(error))}
3052
+ process5.stderr.write(`${accessProblem(error) ?? (error instanceof Error ? error.message : String(error))}
2231
3053
  `);
2232
- process3.exitCode = 1;
3054
+ process5.exitCode = 1;
2233
3055
  }
2234
3056
  //# sourceMappingURL=cli.js.map