@tamsensedev/runner 0.2.1 → 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/README.md CHANGED
@@ -38,10 +38,12 @@ Options:
38
38
  - `--name <name>` how this computer is called in TAMsense (login)
39
39
  - `--server <url>` TAMsense address, default https://my.tamsense.com (login)
40
40
  - `--parallel <n>` how many tasks to work on at once (start)
41
+ - `--no-browser` do not install the headless browser for interface checks (start)
41
42
 
42
43
  ## What it does on your computer
43
44
 
44
45
  - Clones the task's repositories into `~/.tamsense/repos` and works in `~/.tamsense/work`.
46
+ - Installs a headless browser for interface checks into `~/.tamsense` when `start` finds none, about 150 MB, without asking. If that fails, it says why and tasks still run without it.
45
47
  - Gets a short-lived GitHub token from TAMsense for those repositories only, and pushes the task branch. Pull requests are opened by TAMsense.
46
- - Records the browser Claude drives during a task (a replay of each page, and a video when asked) into `~/.tamsense/recordings`, attaches it to the task and deletes the local copy when the step ends. Requests from the tested site to the TAMsense tracker are blocked during these runs, so they never reach your analytics.
48
+ - Records the browser Claude drives during a task (a replay of each page, and a video when asked) into `~/.tamsense/recordings`, attaches it to the task and deletes the local copy when the step ends. Requests from the tested site to the TAMsense tracker are blocked during these runs, so they never reach your analytics, and so are requests that change data (anything but GET, HEAD and OPTIONS) to addresses outside your computer.
47
49
  - Never stops processes it did not start.
package/dist/cli.js CHANGED
@@ -81,7 +81,7 @@ var HELP_TEXT = `Connects this computer to TAMsense so it can work on your tasks
81
81
  Usage:
82
82
  ${LOGIN_COMMAND} connect this computer
83
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
84
+ ${BROWSER_COMMAND} install the headless browser for interface checks now, or show it
85
85
  ${BROWSER_COMMAND} remove delete that browser
86
86
  npx @tamsensedev/runner@latest status show the connection
87
87
  npx @tamsensedev/runner@latest logout disconnect this computer
@@ -91,7 +91,8 @@ Login options:
91
91
  --server <url> TAMsense address (default: ${DEFAULT_SERVER})
92
92
 
93
93
  Start options:
94
- --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)`;
95
96
  function parallelFor(flag, env) {
96
97
  const [source, value] = flag !== void 0 ? ["--parallel", flag] : ["TAMSENSE_PARALLEL", env?.trim() || void 0];
97
98
  if (value === void 0) {
@@ -109,10 +110,11 @@ function parseCommand(argv, env = {}) {
109
110
  allowPositionals: true,
110
111
  strict: true,
111
112
  options: {
112
- server: { type: "string" },
113
- name: { type: "string" },
114
- parallel: { type: "string" },
115
- help: { type: "boolean" }
113
+ "server": { type: "string" },
114
+ "name": { type: "string" },
115
+ "parallel": { type: "string" },
116
+ "no-browser": { type: "boolean" },
117
+ "help": { type: "boolean" }
116
118
  }
117
119
  });
118
120
  const [name, detail] = positionals;
@@ -129,7 +131,7 @@ function parseCommand(argv, env = {}) {
129
131
  return { name: "login", server: values.server ?? null, deviceName: values.name ?? null };
130
132
  }
131
133
  if (name === "start") {
132
- 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"] };
133
135
  }
134
136
  if (name === "status" || name === "logout") {
135
137
  return { name };
@@ -145,8 +147,7 @@ var OUTPUT_TAIL_LINES = 40;
145
147
  var ERROR_TAIL_LINES = 3;
146
148
  var ANSI_PATTERN = /\u001B\[[\d;]*m/g;
147
149
  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 INSTALLING_TEXT = "Interface checks need a headless browser. Installing it once into ~/.tamsense, about 150 MB. To start without it, add --no-browser.";
150
151
  var OFFLINE_TEXT = `No connection to download it. Check your internet connection and run ${BROWSER_COMMAND} again.`;
151
152
  function browserFolders(home) {
152
153
  const prefix = join(home, ".tamsense", "playwright");
@@ -248,24 +249,13 @@ async function removeBrowser(home) {
248
249
  await rm(folders.browsers, { recursive: true, force: true });
249
250
  return present2;
250
251
  }
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
- }
252
+ async function ensureBrowser(tools) {
253
+ tools.print(INSTALLING_TEXT);
264
254
  try {
265
255
  return await tools.install();
266
256
  } catch (error) {
267
257
  tools.print(`Could not install the browser: ${error instanceof Error ? error.message : String(error)}`);
268
- tools.print("Tasks still run without it.");
258
+ tools.print(`Tasks still run without it. To try again, run ${BROWSER_COMMAND}`);
269
259
  return null;
270
260
  }
271
261
  }
@@ -354,6 +344,56 @@ import { fileURLToPath } from "url";
354
344
  import { createSdkMcpServer, query as query2, tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
355
345
  import { z as z2 } from "zod";
356
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
+
357
397
  // src/claudeResult.ts
358
398
  var MAX_ACTIVITY_LENGTH = 120;
359
399
  var FILE_TOOLS = /* @__PURE__ */ new Set(["Read", "Edit", "Write", "MultiEdit", "NotebookEdit"]);
@@ -431,7 +471,7 @@ 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") {
@@ -818,7 +858,10 @@ var VIDEO_TOOL_NAME = "attach_video";
818
858
  var HANDOFF_TOOL_NAME = "hand_off";
819
859
  var SUBMIT_QUESTIONS_TOOL_NAME = "submit_questions";
820
860
  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.`;
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.`;
822
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.";
823
866
  var CODE_RULES = [
824
867
  "Make the changes the task asks for, and keep them focused on it.",
@@ -836,8 +879,22 @@ var CHECK_RULES = [
836
879
  ];
837
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.`;
838
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".';
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.";
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
+ }
841
898
  var STAGE_GOALS = {
842
899
  code: "Implement",
843
900
  review: "Review the code for",
@@ -888,6 +945,16 @@ function teamMessagesText(messages) {
888
945
  return messages.length > 0 ? `Messages from the team before this run:
889
946
  ${messages.map((message) => `${message.authorName}: ${message.text}`).join("\n")}` : null;
890
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
+ }
891
958
  function handoffText(targets, isCheck) {
892
959
  if (targets.length === 0) {
893
960
  return null;
@@ -971,6 +1038,8 @@ function buildTaskPrompt(job, workspace, options = {}) {
971
1038
  return buildSpecPrompt(job, job.specWork, workspace);
972
1039
  }
973
1040
  const isCheck = kind !== "code";
1041
+ const people = job.people ?? [];
1042
+ const canAsk = people.length > 0;
974
1043
  const prompt = [
975
1044
  `${STAGE_GOALS[kind]} TAMsense task #${job.taskId}: ${job.title}`,
976
1045
  job.description,
@@ -981,9 +1050,10 @@ ${job.review}`,
981
1050
  teamMessagesText(job.messages)
982
1051
  ].filter(Boolean).join("\n\n");
983
1052
  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];
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];
985
1055
  const instructions = [
986
- UNATTENDED_RULE,
1056
+ canAsk ? ASKING_RULE : UNATTENDED_RULE,
987
1057
  MACHINE_RULE,
988
1058
  stepLine(job.stage),
989
1059
  job.stage.instructions,
@@ -992,6 +1062,7 @@ ${job.review}`,
992
1062
  ${bulletList(repositoryLines(job, workspace))}`,
993
1063
  `How to work:
994
1064
  ${bulletList(rules)}`,
1065
+ peopleText(people),
995
1066
  handoffText(job.allowedHandoffs, isCheck),
996
1067
  options.browser && BROWSER_RULE
997
1068
  ].filter(Boolean).join("\n\n");
@@ -1284,10 +1355,11 @@ function verdictTool(onVerdict) {
1284
1355
  "Hand in the result of this check. Call it exactly once, at the very end.",
1285
1356
  {
1286
1357
  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")
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")
1288
1360
  },
1289
1361
  async (args) => {
1290
- onVerdict({ passed: args.passed, report: args.report });
1362
+ onVerdict(args.couldNotRun ? { passed: false, report: args.report, couldNotRun: true } : { passed: args.passed, report: args.report });
1291
1363
  return { content: [{ type: "text", text: "Verdict received. You can stop now." }] };
1292
1364
  }
1293
1365
  );
@@ -1311,6 +1383,48 @@ function handoffTool(targets, onHandoff) {
1311
1383
  }
1312
1384
  );
1313
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
+ }
1314
1428
  function specWorkTools(specWork, submitted) {
1315
1429
  if (!specWork) {
1316
1430
  return [];
@@ -1330,6 +1444,7 @@ function tamsenseServer(request, submitted) {
1330
1444
  submitted.verdict = verdict;
1331
1445
  })] : [],
1332
1446
  ...specWorkTools(request.specWork, submitted),
1447
+ ...request.asking ? [askPersonTool(request.asking, submitted)] : [],
1333
1448
  ...request.handoffs.length > 0 ? [handoffTool(request.handoffs, (handoff) => {
1334
1449
  submitted.handoff = handoff;
1335
1450
  })] : []
@@ -1421,7 +1536,7 @@ function claudeRunner(startQuery) {
1421
1536
  request.input.close();
1422
1537
  abortController.abort();
1423
1538
  }, { once: true });
1424
- 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 };
1425
1540
  const limit = usageLimitWatch();
1426
1541
  const context = { startQuery, abortController, submitted, limit };
1427
1542
  try {
@@ -1431,7 +1546,7 @@ function claudeRunner(startQuery) {
1431
1546
  }
1432
1547
  const pausedUntil = limit.pausedUntil(result.outcome.completed);
1433
1548
  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 };
1549
+ return { ...outcome, verdict: submitted.verdict, handoff: submitted.handoff, questions: submitted.questions, spec: submitted.spec, sessionId: result.sessionId, pausedUntil, waitingFor: submitted.waitingFor };
1435
1550
  } finally {
1436
1551
  request.input.close();
1437
1552
  }
@@ -1455,9 +1570,9 @@ function parseConfig(value) {
1455
1570
  if (!isRecord2(value)) {
1456
1571
  return null;
1457
1572
  }
1458
- const { server, token, deviceId, deviceName, browserDeclined } = value;
1573
+ const { server, token, deviceId, deviceName } = value;
1459
1574
  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;
1575
+ return isValid ? { server, token, deviceId, deviceName } : null;
1461
1576
  }
1462
1577
  function isMissingFile(error) {
1463
1578
  return error instanceof Error && "code" in error && error.code === "ENOENT";
@@ -1545,6 +1660,7 @@ var META_SUFFIX = ".json";
1545
1660
  var MAX_TITLE_LENGTH = 60;
1546
1661
  var MAX_PURPOSE_LENGTH = 160;
1547
1662
  var DEFAULT_TRACKER_ORIGINS = ["https://my.tamsense.com"];
1663
+ var READ_METHODS = ["GET", "HEAD", "OPTIONS"];
1548
1664
  var BINDING = "__tamsenseRecord";
1549
1665
  var STATE_KEY = /* @__PURE__ */ Symbol.for("tamsense.recorder");
1550
1666
  var FLUSH_MS = 100;
@@ -1573,6 +1689,13 @@ function isTrackerIngest(url, tamsenseOrigins) {
1573
1689
  }
1574
1690
  return url.searchParams.has("key") || tamsenseOrigins.includes(url.origin);
1575
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
+ }
1576
1699
  function trackerOrigins(env = process2.env) {
1577
1700
  const origins = new Set(DEFAULT_TRACKER_ORIGINS);
1578
1701
  try {
@@ -1659,7 +1782,7 @@ var Session = class {
1659
1782
  const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
1660
1783
  this.file = join3(dir, `${id}${REPLAY_SUFFIX}`);
1661
1784
  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 };
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 };
1663
1786
  writeFileSync(this.file, "");
1664
1787
  this.save();
1665
1788
  }
@@ -1703,10 +1826,20 @@ var Session = class {
1703
1826
  }
1704
1827
  }
1705
1828
  summary() {
1706
- const { title, purpose, video, marks, gaps, blockedTrackerRequests } = this.meta;
1707
- return { title, purpose, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests };
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 };
1708
1831
  }
1709
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
+ }
1710
1843
  async function startSession(recording, page, injectNow) {
1711
1844
  const current = state();
1712
1845
  if (current.pages.has(page)) {
@@ -1748,15 +1881,19 @@ async function recordContext(context, caption, automatic = false) {
1748
1881
  const recording = { caption: checked, automatic, sessions: [] };
1749
1882
  current.contexts.set(context, recording);
1750
1883
  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 {
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;
1759
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");
1760
1897
  await route.fulfill({ status: 200, contentType: "application/json", body: '{"ok":true}' });
1761
1898
  });
1762
1899
  await context.exposeBinding(BINDING, ({ page }, payload) => current.pages.get(page)?.receive(payload));
@@ -1850,7 +1987,6 @@ function describeDevice(identity) {
1850
1987
  import { spawn as spawn2 } from "child_process";
1851
1988
  import { hostname } from "os";
1852
1989
  import { delimiter as delimiter2 } from "path";
1853
- import { createInterface as createInterface2 } from "readline/promises";
1854
1990
  import { setTimeout as wait } from "timers/promises";
1855
1991
  var PLATFORM_LABELS = {
1856
1992
  win32: "Windows",
@@ -1886,22 +2022,6 @@ function withNodeOnPath(env, nodeDir, separator = delimiter2) {
1886
2022
  }
1887
2023
  return { ...env, [key]: [...entries, nodeDir].join(separator) };
1888
2024
  }
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
2025
  async function sleep(ms, signal) {
1906
2026
  await wait(ms, void 0, { signal }).catch(() => {
1907
2027
  });
@@ -2051,7 +2171,7 @@ import { randomUUID } from "crypto";
2051
2171
  import { createReadStream } from "fs";
2052
2172
  import { readdir, readFile as readFile3, stat, writeFile as writeFile3 } from "fs/promises";
2053
2173
  import { basename as basename2, dirname as dirname4, extname, isAbsolute, join as join5 } from "path";
2054
- import { createInterface as createInterface3 } from "readline";
2174
+ import { createInterface as createInterface2 } from "readline";
2055
2175
  import { Readable } from "stream";
2056
2176
  import { promisify } from "util";
2057
2177
  import { gzip } from "zlib";
@@ -2172,7 +2292,7 @@ async function pendingRecordings(dir) {
2172
2292
  async function uploadReplay(upload, file, caption) {
2173
2293
  const id = randomUUID();
2174
2294
  const scan = { startTs: Number.POSITIVE_INFINITY, snapshot: false, marks: [] };
2175
- const lines = createInterface3({ input: createReadStream(file, "utf8"), crlfDelay: Infinity });
2295
+ const lines = createInterface2({ input: createReadStream(file, "utf8"), crlfDelay: Infinity });
2176
2296
  for await (const chunk of replayChunks(lines, scan)) {
2177
2297
  await upload.api.upload("POST", recordingUploadPath(upload.runId, id, "chunks"), new Uint8Array(await gzipAsync(chunk)), "application/gzip");
2178
2298
  }
@@ -2376,6 +2496,16 @@ async function baseRef(cache, job, repository) {
2376
2496
  const pushed = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${job.branch}`], { cwd: cache });
2377
2497
  return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
2378
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
+ }
2379
2509
  async function prepareWorkspace(job, token, tools) {
2380
2510
  const root = tamsenseFolder(tools.home, "work", `task-${job.taskId}`);
2381
2511
  const folders = folderNames(job.repositories);
@@ -2386,6 +2516,10 @@ async function prepareWorkspace(job, token, tools) {
2386
2516
  const path = join6(root, folder);
2387
2517
  const baseSha = await cacheLock.run(repository.fullName, async () => {
2388
2518
  await refreshCache(cache, repository, token, tools);
2519
+ const kept = await keptBase(cache, path, job, repository);
2520
+ if (kept) {
2521
+ return kept;
2522
+ }
2389
2523
  await removeWorktree(cache, path);
2390
2524
  await git(["worktree", "add", "--force", "-B", job.branch, path, await baseRef(cache, job, repository)], { cwd: cache });
2391
2525
  return git(["rev-parse", "HEAD"], { cwd: path });
@@ -2562,6 +2696,13 @@ function specWorkTools2(job, api) {
2562
2696
  }
2563
2697
  return { stage: kind, review: (body) => api.request("POST", runPath(job.runId, "review"), body) };
2564
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
+ }
2565
2706
  async function executeJob(job, tools, signal, input, record) {
2566
2707
  const isCheck = job.stage.kind === "review" || job.stage.kind === "test";
2567
2708
  tools.print("Preparing the code\u2026");
@@ -2582,6 +2723,7 @@ async function executeJob(job, tools, signal, input, record) {
2582
2723
  signal,
2583
2724
  wantsVerdict: isCheck,
2584
2725
  specWork: specWorkTools2(job, tools.api),
2726
+ asking: askingTools(job, tools.api),
2585
2727
  handoffs: job.allowedHandoffs,
2586
2728
  resumeSessionId: job.resumeSessionId,
2587
2729
  model: job.model,
@@ -2592,7 +2734,8 @@ async function executeJob(job, tools, signal, input, record) {
2592
2734
  });
2593
2735
  await session.sent();
2594
2736
  signal.throwIfAborted();
2595
- 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;
2596
2739
  const pushed = shouldPublish ? await publishWorkspace(workspace, job, await gitToken(tools.api, job), identity) : [];
2597
2740
  return {
2598
2741
  outcome: outcome.completed ? "succeeded" : "failed",
@@ -2605,7 +2748,8 @@ async function executeJob(job, tools, signal, input, record) {
2605
2748
  sessionId: outcome.sessionId ?? null,
2606
2749
  pausedUntil: outcome.pausedUntil ?? null,
2607
2750
  questions: outcome.questions ?? null,
2608
- spec: outcome.spec ?? null
2751
+ spec: outcome.spec ?? null,
2752
+ waitingFor
2609
2753
  };
2610
2754
  }
2611
2755
  function clockOf(iso) {
@@ -2616,6 +2760,12 @@ function resultLine(finish2) {
2616
2760
  if (finish2.pausedUntil) {
2617
2761
  return `Paused: ${USAGE_LIMIT_REPORT}, resumes at ${clockOf(finish2.pausedUntil)}`;
2618
2762
  }
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.";
2765
+ }
2766
+ if (finish2.verdict?.couldNotRun) {
2767
+ return `Check could not run: ${finish2.verdict.report}`;
2768
+ }
2619
2769
  if (finish2.verdict) {
2620
2770
  return finish2.verdict.passed ? "Check passed." : `Check did not pass: ${finish2.verdict.report}`;
2621
2771
  }
@@ -2667,7 +2817,7 @@ async function runJob(job, tools, stop) {
2667
2817
  try {
2668
2818
  finish2 = await executeJob(job, tools, run2.signal, input, record);
2669
2819
  } 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 };
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 };
2671
2821
  } finally {
2672
2822
  clearInterval(polling);
2673
2823
  clearInterval(feeding);
@@ -2791,8 +2941,7 @@ async function runLogin(server, deviceName) {
2791
2941
  { api, openBrowser, print, sleep, now: Date.now },
2792
2942
  { name, platform: platformLabel() }
2793
2943
  );
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 });
2944
+ await writeConfig({ server: api.server, token: result.token, deviceId: result.device.id, deviceName: result.device.name });
2796
2945
  print(`Connected: ${result.device.name}. To take your tasks, run ${START_COMMAND}`);
2797
2946
  }
2798
2947
  function stopOnInterrupt() {
@@ -2807,15 +2956,12 @@ function stopOnInterrupt() {
2807
2956
  function installTools(home) {
2808
2957
  return { home, nodePath: process5.execPath, env: process5.env, print, run: runProcess, exists: existsSync4 };
2809
2958
  }
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
- });
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)) });
2819
2965
  }
2820
2966
  async function startedNewVersion() {
2821
2967
  const update = await updateToRun(process5.env, currentVersion(), () => latestVersion());
@@ -2831,7 +2977,7 @@ async function startedNewVersion() {
2831
2977
  return false;
2832
2978
  }
2833
2979
  }
2834
- async function runStart(parallel) {
2980
+ async function runStart(parallel, installsBrowser) {
2835
2981
  if (await startedNewVersion()) {
2836
2982
  return;
2837
2983
  }
@@ -2848,17 +2994,13 @@ async function runStart(parallel) {
2848
2994
  }
2849
2995
  print(`${identity.device.name} is connected to ${saved.server} as ${describeDevice(identity)}.`);
2850
2996
  const home = homedir3();
2851
- const browser = await browserForStart(home, saved);
2997
+ const browser = await browserForStart(home, installsBrowser);
2852
2998
  await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder() }, stopOnInterrupt(), parallel);
2853
2999
  }
2854
3000
  async function runBrowser(remove) {
2855
3001
  const home = homedir3();
2856
3002
  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
- }
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.");
2862
3004
  return;
2863
3005
  }
2864
3006
  const installed = await installedBrowser(home);
@@ -2893,7 +3035,7 @@ async function run(command) {
2893
3035
  if (command.name === "login") {
2894
3036
  await runLogin(command.server, command.deviceName);
2895
3037
  } else if (command.name === "start") {
2896
- await runStart(command.parallel);
3038
+ await runStart(command.parallel, command.browser);
2897
3039
  } else if (command.name === "browser") {
2898
3040
  await runBrowser(command.remove);
2899
3041
  } else if (command.name === "status") {