@tamsensedev/runner 0.2.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  }
@@ -351,8 +341,58 @@ async function probeCapabilities(stop) {
351
341
 
352
342
  // src/claude.ts
353
343
  import { fileURLToPath } from "url";
354
- import { createSdkMcpServer, query as query2, tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
355
- import { z as z2 } from "zod";
344
+ import { createSdkMcpServer, query as query2, tool as tool3 } from "@anthropic-ai/claude-agent-sdk";
345
+ import { z as z3 } from "zod";
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 ? `. Team roles: ${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.";
356
396
 
357
397
  // src/claudeResult.ts
358
398
  var MAX_ACTIVITY_LENGTH = 120;
@@ -362,21 +402,21 @@ function shorten(text2) {
362
402
  const line = text2.replace(/\s+/g, " ").trim();
363
403
  return line.length > MAX_ACTIVITY_LENGTH ? `${line.slice(0, MAX_ACTIVITY_LENGTH - 1)}\u2026` : line;
364
404
  }
365
- function activityLine(tool3, input) {
405
+ function activityLine(tool4, input) {
366
406
  const fields = typeof input === "object" && input !== null ? input : {};
367
- if (tool3 === "Bash" && typeof fields.command === "string") {
407
+ if (tool4 === "Bash" && typeof fields.command === "string") {
368
408
  return `$ ${shorten(fields.command)}`;
369
409
  }
370
- if (FILE_TOOLS.has(tool3) && typeof fields.file_path === "string") {
371
- return `${tool3} ${shorten(fields.file_path)}`;
410
+ if (FILE_TOOLS.has(tool4) && typeof fields.file_path === "string") {
411
+ return `${tool4} ${shorten(fields.file_path)}`;
372
412
  }
373
- if (tool3 === "Skill" && typeof fields.skill === "string") {
413
+ if (tool4 === "Skill" && typeof fields.skill === "string") {
374
414
  return `Skill ${shorten(fields.skill)}`;
375
415
  }
376
- if (HELPER_TOOLS.has(tool3) && typeof fields.subagent_type === "string") {
416
+ if (HELPER_TOOLS.has(tool4) && typeof fields.subagent_type === "string") {
377
417
  return `Agent ${shorten(fields.subagent_type)}`;
378
418
  }
379
- return tool3;
419
+ return tool4;
380
420
  }
381
421
  function usageOf(modelUsage) {
382
422
  const entries = Object.values(modelUsage);
@@ -419,29 +459,29 @@ function textField(fields, name) {
419
459
  return typeof value === "string" && value.trim() ? value : null;
420
460
  }
421
461
  function toolDraft(block, parent) {
422
- const tool3 = (block.name ?? "tool").replace(MCP_PREFIX, "");
462
+ const tool4 = (block.name ?? "tool").replace(MCP_PREFIX, "");
423
463
  const fields = fieldsOf(block.input);
424
- const base = { tool: tool3, ref: block.id ?? null, parent, failed: false };
425
- if (tool3 === "Skill" && textField(fields, "skill")) {
464
+ const base = { tool: tool4, ref: block.id ?? null, parent, failed: false };
465
+ if (tool4 === "Skill" && textField(fields, "skill")) {
426
466
  return { ...base, kind: "skill", text: clip(textField(fields, "skill"), MAX_SUMMARY_LENGTH) };
427
467
  }
428
- if (HELPER_TOOLS2.has(tool3)) {
468
+ if (HELPER_TOOLS2.has(tool4)) {
429
469
  const who = textField(fields, "subagent_type") ?? "general-purpose";
430
470
  const what = textField(fields, "description");
431
471
  return { ...base, kind: "agent", text: clip(what ? `${who}: ${what}` : who, MAX_SUMMARY_LENGTH) };
432
472
  }
433
- if (tool3 === "submit_verdict") {
434
- const verdict = fields.passed === true ? "Passed" : "Did not pass";
473
+ if (tool4 === "submit_verdict") {
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
- if (tool3 === "hand_off") {
477
+ if (tool4 === "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 in ATTACH_LABELS) {
441
- return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[tool3]}: ${textField(fields, "title") ?? textField(fields, "path") ?? ""}`, MAX_SUMMARY_LENGTH) };
480
+ if (tool4 in ATTACH_LABELS) {
481
+ return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[tool4]}: ${textField(fields, "title") ?? textField(fields, "path") ?? ""}`, MAX_SUMMARY_LENGTH) };
442
482
  }
443
483
  const command = textField(fields, "command");
444
- const summary = tool3 === "Bash" && command ? `$ ${command}` : activityLine(tool3, block.input);
484
+ const summary = tool4 === "Bash" && command ? `$ ${command}` : activityLine(tool4, block.input);
445
485
  return { ...base, kind: "tool", text: clip(summary, MAX_SUMMARY_LENGTH) };
446
486
  }
447
487
  function resultText(content) {
@@ -730,6 +770,204 @@ function inputChannel(options = {}) {
730
770
  };
731
771
  }
732
772
 
773
+ // src/knowledge.ts
774
+ import { tool } from "@anthropic-ai/claude-agent-sdk";
775
+ import { z } from "zod";
776
+ var PROPOSE_KNOWLEDGE_TOOL_NAME = "propose_knowledge";
777
+ var UPDATE_KNOWLEDGE_TOOL_NAME = "update_knowledge";
778
+ var READ_KNOWLEDGE_TOOL_NAME = "read_knowledge";
779
+ var MAX_KNOWLEDGE_TITLE = 80;
780
+ var MAX_KNOWLEDGE_TEXT = 600;
781
+ var UPDATE_RULE = `Rely on this knowledge instead of asking people again. When you relied on an entry and found it true, call ${UPDATE_KNOWLEDGE_TOOL_NAME} with stillTrue true. When the code shows an entry is out of date (a file moved, a command or a port changed), fix it yourself at once: ${UPDATE_KNOWLEDGE_TOOL_NAME} with stillTrue false, the full new text and the evidence (the commit, the file or the command output that shows it). The change applies right away and people can revert it. Never ask a person whether an entry is still right, and never move facts of this computer into project knowledge.`;
782
+ var PROPOSE_RULE = `When a person's answer to your question teaches something later runs need (how the team runs the local stand, where something lives, a convention), propose one short note with ${PROPOSE_KNOWLEDGE_TOOL_NAME} and the question number from the answer. Scope computer is for what is true only on this computer (folders, ports, installed tools, local quirks); scope project is for what comes from the repository and works on every computer. Never propose secrets, personal data or decisions about this one task. A person approves every note; keep working while they decide.`;
783
+ function dateOf(iso) {
784
+ return iso ? `checked ${iso.slice(0, 10)}` : "not checked yet";
785
+ }
786
+ function entryText(entry) {
787
+ return `[${entry.id}] ${entry.title}: ${entry.text.replace(/\s+/g, " ").trim()} (${dateOf(entry.checkedAt)})`;
788
+ }
789
+ function entryLine(entry) {
790
+ return `- ${entryText(entry)}`;
791
+ }
792
+ function workplaceText(computer, folder) {
793
+ if (!computer) {
794
+ return null;
795
+ }
796
+ return `You run on ${computer.name}, the ${computer.platform} computer of ${computer.ownerName}. Your task folder is ${folder}; everything outside it belongs to ${computer.ownerName}.`;
797
+ }
798
+ function hasKnowledge(knowledge) {
799
+ return Boolean(knowledge && (knowledge.project.length > 0 || knowledge.computer.length > 0 || knowledge.more.length > 0));
800
+ }
801
+ function knowledgeText(knowledge, computer, canPropose) {
802
+ const rules = canPropose ? [PROPOSE_RULE] : [];
803
+ if (!hasKnowledge(knowledge)) {
804
+ return rules.length > 0 ? `Knowledge of this project is empty so far. ${rules.join(" ")}` : null;
805
+ }
806
+ const here = computer ? computer.name : "this computer";
807
+ const sections = [
808
+ knowledge.project.length > 0 ? `Project knowledge (approved by the team, true on every computer):
809
+ ${knowledge.project.map(entryLine).join("\n")}` : null,
810
+ knowledge.computer.length > 0 ? `Knowledge for ${here} only (true here, not on other computers):
811
+ ${knowledge.computer.map(entryLine).join("\n")}` : null,
812
+ knowledge.more.length > 0 ? `More entries, titles only to keep this short: ${knowledge.more.map((entry) => `[${entry.id}] ${entry.title} (${entry.scope})`).join("; ")}. When a title touches your work, read the entry with ${READ_KNOWLEDGE_TOOL_NAME} and its number.` : null
813
+ ];
814
+ return [...sections, UPDATE_RULE, ...rules].filter(Boolean).join("\n\n");
815
+ }
816
+ function knowledgeIds(knowledge) {
817
+ return knowledge ? [...knowledge.project, ...knowledge.computer, ...knowledge.more].map((entry) => entry.id) : [];
818
+ }
819
+ function failed(text2) {
820
+ return { content: [{ type: "text", text: text2 }], isError: true };
821
+ }
822
+ function done(text2) {
823
+ return { content: [{ type: "text", text: text2 }] };
824
+ }
825
+ function unknownEntry(tools, entry) {
826
+ return failed(`There is no entry ${entry} in this run. Use a number in square brackets from your instructions: ${tools.entryIds.join(", ") || "none"}.`);
827
+ }
828
+ function messageOf(error) {
829
+ return error instanceof Error ? error.message : String(error);
830
+ }
831
+ function proposeKnowledgeTool(tools) {
832
+ return tool(
833
+ PROPOSE_KNOWLEDGE_TOOL_NAME,
834
+ "Propose a short note for the TAMsense project knowledge after a person answered your question, so later runs do not ask again. A person on the task approves it: for the whole project, for this computer only, or not at all.",
835
+ {
836
+ title: z.string().describe(`a few words, at most ${MAX_KNOWLEDGE_TITLE} characters: "Local stand and database"`),
837
+ text: z.string().describe(`the fact in one to three plain sentences, at most ${MAX_KNOWLEDGE_TEXT} characters, with exact commands and paths in backticks`),
838
+ scope: z.enum(["project", "computer"]).describe("computer: true only on this computer (folders, ports, installed tools); project: comes from the repository and works everywhere"),
839
+ question: z.number().optional().describe('the number of the question whose answer this comes from, as in "(question 31)"')
840
+ },
841
+ async (args) => {
842
+ const title = args.title.trim();
843
+ const text2 = args.text.trim();
844
+ if (!title || !text2 || title.length > MAX_KNOWLEDGE_TITLE || text2.length > MAX_KNOWLEDGE_TEXT) {
845
+ return failed(`Write a title of at most ${MAX_KNOWLEDGE_TITLE} characters and a text of at most ${MAX_KNOWLEDGE_TEXT}.`);
846
+ }
847
+ try {
848
+ await tools.propose({ title, text: text2, scope: args.scope, question: args.question ?? null });
849
+ } catch (error) {
850
+ return failed(`The note was not suggested: ${messageOf(error)}`);
851
+ }
852
+ return done("Suggested. A person on the task decides whether to save it; keep working.");
853
+ }
854
+ );
855
+ }
856
+ function updateKnowledgeTool(tools) {
857
+ return tool(
858
+ UPDATE_KNOWLEDGE_TOOL_NAME,
859
+ "Keep the TAMsense knowledge current. stillTrue true: you relied on the entry and it holds. stillTrue false: the code shows it is out of date; the new text applies at once and people see your evidence.",
860
+ {
861
+ entry: z.number().describe("the entry number in square brackets from your instructions"),
862
+ stillTrue: z.boolean(),
863
+ text: z.string().optional().describe(`when stillTrue is false: the full new text, at most ${MAX_KNOWLEDGE_TEXT} characters`),
864
+ evidence: z.string().optional().describe("when stillTrue is false: one line on what shows the old text is wrong, such as a commit, a file or a command output")
865
+ },
866
+ async (args) => {
867
+ if (!tools.entryIds.includes(args.entry)) {
868
+ return unknownEntry(tools, args.entry);
869
+ }
870
+ const text2 = args.text?.trim() ?? "";
871
+ const evidence = args.evidence?.trim() ?? "";
872
+ if (!args.stillTrue && (!text2 || !evidence || text2.length > MAX_KNOWLEDGE_TEXT)) {
873
+ return failed(`An update needs the full new text (at most ${MAX_KNOWLEDGE_TEXT} characters) and the evidence.`);
874
+ }
875
+ try {
876
+ await tools.update(args.entry, { stillTrue: args.stillTrue, text: text2, evidence });
877
+ } catch (error) {
878
+ return failed(`The entry was not updated: ${messageOf(error)}`);
879
+ }
880
+ return done(args.stillTrue ? "Marked as checked today." : "Updated. People see the old text, your evidence and can revert it.");
881
+ }
882
+ );
883
+ }
884
+ function readKnowledgeTool(tools) {
885
+ return tool(
886
+ READ_KNOWLEDGE_TOOL_NAME,
887
+ "Read a TAMsense knowledge entry that your instructions list by title only.",
888
+ {
889
+ entry: z.number().describe("the entry number in square brackets from your instructions")
890
+ },
891
+ async (args) => {
892
+ if (!tools.entryIds.includes(args.entry)) {
893
+ return unknownEntry(tools, args.entry);
894
+ }
895
+ try {
896
+ return done(entryText(await tools.read(args.entry)));
897
+ } catch (error) {
898
+ return failed(`The entry was not read: ${messageOf(error)}`);
899
+ }
900
+ }
901
+ );
902
+ }
903
+
904
+ // src/permissions.ts
905
+ var NOBODY_TO_ASK = "Nobody is at the computer to approve this during a TAMsense run. Do it another way, or explain in your report why it is needed.";
906
+ var CHECK_INTERVAL_MS = 5e3;
907
+ var SITE_SILENCE_MS = 6e4;
908
+ var MAX_ACTION_LENGTH = 4e3;
909
+ var STOPPED_TEXT2 = "The run is stopping, so this was not done.";
910
+ var SITE_SILENT_TEXT = "TAMsense did not answer about this action, so it was not done. Do it another way, or explain in your report why it is needed.";
911
+ var NEVER_SENT = /* @__PURE__ */ new Set(["AskUserQuestion", "ExitPlanMode"]);
912
+ var ACTION_FIELDS = ["command", "file_path", "notebook_path", "url", "query", "path"];
913
+ function actionOf(input) {
914
+ const field = ACTION_FIELDS.map((key) => input[key]).find((value) => typeof value === "string" && value.trim());
915
+ return (typeof field === "string" ? field.trim() : JSON.stringify(input)).slice(0, MAX_ACTION_LENGTH);
916
+ }
917
+ function titleOf(input, options) {
918
+ const described = typeof input.description === "string" ? input.description.trim() : "";
919
+ return described || options.title?.trim() || options.displayName?.trim() || "";
920
+ }
921
+ function deny(message) {
922
+ return { behavior: "deny", message };
923
+ }
924
+ function messageOf2(error) {
925
+ return error instanceof Error ? error.message : String(error);
926
+ }
927
+ async function decisionOf(tools, first, signal) {
928
+ const now = tools.now ?? Date.now;
929
+ const giveUpAt = Date.parse(first.waitUntil) + SITE_SILENCE_MS;
930
+ let reply2 = first;
931
+ while (reply2.decision === "wait") {
932
+ await tools.sleep(CHECK_INTERVAL_MS, signal);
933
+ if (signal.aborted) {
934
+ return STOPPED_TEXT2;
935
+ }
936
+ if (now() > giveUpAt) {
937
+ return SITE_SILENT_TEXT;
938
+ }
939
+ reply2 = await tools.check(reply2.id).catch(() => reply2);
940
+ }
941
+ return reply2;
942
+ }
943
+ function askPermission(tools, lastWords, folder) {
944
+ return async (toolName, input, options) => {
945
+ if (NEVER_SENT.has(toolName)) {
946
+ return deny(NOBODY_TO_ASK);
947
+ }
948
+ const body = {
949
+ tool: toolName,
950
+ action: actionOf(input),
951
+ title: titleOf(input, options),
952
+ reason: lastWords() || options.decisionReason || "",
953
+ folder,
954
+ outsideFolder: Boolean(options.blockedPath)
955
+ };
956
+ let first;
957
+ try {
958
+ first = await tools.send(body);
959
+ } catch (error) {
960
+ return deny(`Could not ask for permission on TAMsense: ${messageOf2(error)}. Do it another way, or explain in your report why it is needed.`);
961
+ }
962
+ const reply2 = await decisionOf(tools, first, options.signal);
963
+ if (typeof reply2 === "string") {
964
+ return deny(reply2);
965
+ }
966
+ return reply2.decision === "allow" ? { behavior: "allow", updatedInput: input } : deny(reply2.message);
967
+ };
968
+ }
969
+ var denyWhenAsked = async () => deny(NOBODY_TO_ASK);
970
+
733
971
  // src/specText.ts
734
972
  function isRecord(value) {
735
973
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -818,7 +1056,11 @@ var VIDEO_TOOL_NAME = "attach_video";
818
1056
  var HANDOFF_TOOL_NAME = "hand_off";
819
1057
  var SUBMIT_QUESTIONS_TOOL_NAME = "submit_questions";
820
1058
  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.`;
1059
+ var ASK_PERSON_TOOL_NAME = "ask_person";
1060
+ 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.`;
1061
+ var UNATTENDED_RULE = `${MESSAGES_RULE} Nobody answers questions during the run, so never stop to wait for a reply: decide yourself, keep working, and explain your decisions in your report.`;
1062
+ 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.`;
1063
+ var APPROVAL_RULE = "Some actions need a person's approval. When Claude Code would ask for one, TAMsense checks the project safety rules: a Never action is refused at once, an Allowed one goes ahead, and anything else goes to a person on the task while your tool call waits up to 15 minutes. Before such an action, write one short line: why you need it and what you will do instead if it is denied. Never get around a refused action with another command that has the same effect.";
822
1064
  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
1065
  var CODE_RULES = [
824
1066
  "Make the changes the task asks for, and keep them focused on it.",
@@ -836,8 +1078,22 @@ var CHECK_RULES = [
836
1078
  ];
837
1079
  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
1080
  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.";
1081
+ 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.`;
1082
+ 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.";
1083
+ 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.";
1084
+ function localAppRule(taskId, isCheck, canAsk) {
1085
+ const project = `tamsense-task-${taskId}`;
1086
+ 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.";
1087
+ return [
1088
+ "When you need the running app, start a local copy from this task folder only.",
1089
+ "Find how the project runs locally yourself: the README, compose files named local or dev, .env.example and local env files, package scripts.",
1090
+ 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,
1091
+ "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.",
1092
+ "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.",
1093
+ `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).`,
1094
+ `If Docker is not running, there is no local setup, or the settings point outside this computer, do not start anything: ${cannotRun}`
1095
+ ].filter(Boolean).join(" ");
1096
+ }
841
1097
  var STAGE_GOALS = {
842
1098
  code: "Implement",
843
1099
  review: "Review the code for",
@@ -870,6 +1126,9 @@ function contextText(context) {
870
1126
  return context.trim() ? `Project context from the team (follow it like the project's own rules):
871
1127
  ${context.trim()}` : null;
872
1128
  }
1129
+ function safetyText(job) {
1130
+ return job.safetyRules?.trim() || null;
1131
+ }
873
1132
  function bulletList(lines) {
874
1133
  return lines.map((line) => `- ${line}`).join("\n");
875
1134
  }
@@ -878,7 +1137,7 @@ function repositoryLines(job, workspace) {
878
1137
  return workspace.repositories.map((repository) => {
879
1138
  const source = sourceOf.get(repository.fullName);
880
1139
  const pull = source?.pullRequestUrl ? `, pull request ${source.pullRequestUrl}` : "";
881
- return `${repository.folder} (${repository.fullName}), base branch origin/${source?.defaultBranch ?? "main"}${pull}`;
1140
+ return `${repository.folder} (${repository.fullName}) at commit ${repository.baseSha.slice(0, 7)}, base branch origin/${source?.defaultBranch ?? "main"}${pull}`;
882
1141
  });
883
1142
  }
884
1143
  function stepLine(stage) {
@@ -888,6 +1147,19 @@ function teamMessagesText(messages) {
888
1147
  return messages.length > 0 ? `Messages from the team before this run:
889
1148
  ${messages.map((message) => `${message.authorName}: ${message.text}`).join("\n")}` : null;
890
1149
  }
1150
+ function peopleText(people) {
1151
+ if (people.length === 0) {
1152
+ return null;
1153
+ }
1154
+ return [
1155
+ `People on this task you can ask with ${ASK_PERSON_TOOL_NAME} (id: name, relation, access, then their team roles with what each owns):`,
1156
+ bulletList(people.map(personLine)),
1157
+ 'Ask the assignee first. A question on a topic goes to the person whose team role fits it, and then why names that role and what it owns, for example "Designer, owns empty states". 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.'
1158
+ ].join("\n");
1159
+ }
1160
+ function canProposeKnowledge(job) {
1161
+ return job.knowledge !== void 0 && (job.people ?? []).length > 0 && job.stage.kind !== "interview" && job.stage.kind !== "spec";
1162
+ }
891
1163
  function handoffText(targets, isCheck) {
892
1164
  if (targets.length === 0) {
893
1165
  return null;
@@ -950,6 +1222,7 @@ ${JSON.stringify(job.spec)}` : null,
950
1222
  const instructions = [
951
1223
  SPEC_UNATTENDED_RULE,
952
1224
  MACHINE_RULE,
1225
+ workplaceText(job.computer, workspace.root),
953
1226
  stepLine(job.stage),
954
1227
  job.stage.instructions,
955
1228
  contextText(job.projectContext),
@@ -958,6 +1231,8 @@ ${bulletList(repositoryLines(job, workspace))}`,
958
1231
  READ_ONLY_RULE,
959
1232
  `How to work:
960
1233
  ${bulletList(work2.round === null ? SPEC_RULES : questionRules(work2))}`,
1234
+ safetyText(job),
1235
+ knowledgeText(job.knowledge, job.computer, false),
961
1236
  SPEC_STYLE_RULE
962
1237
  ].filter(Boolean).join("\n\n");
963
1238
  return { prompt, instructions };
@@ -971,6 +1246,8 @@ function buildTaskPrompt(job, workspace, options = {}) {
971
1246
  return buildSpecPrompt(job, job.specWork, workspace);
972
1247
  }
973
1248
  const isCheck = kind !== "code";
1249
+ const people = job.people ?? [];
1250
+ const canAsk = people.length > 0;
974
1251
  const prompt = [
975
1252
  `${STAGE_GOALS[kind]} TAMsense task #${job.taskId}: ${job.title}`,
976
1253
  job.description,
@@ -981,10 +1258,12 @@ ${job.review}`,
981
1258
  teamMessagesText(job.messages)
982
1259
  ].filter(Boolean).join("\n\n");
983
1260
  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];
1261
+ const localApp = localAppRule(job.taskId, isCheck, canAsk);
1262
+ const rules = isCheck ? [...CHECK_RULES, DEPENDENCIES_RULE, localApp, ...evidence] : [...CODE_RULES, ...job.review ? [REWORK_RULE] : [], DEPENDENCIES_RULE, localApp, ...evidence, CODE_REPORT_RULE];
985
1263
  const instructions = [
986
- UNATTENDED_RULE,
1264
+ canAsk ? ASKING_RULE : UNATTENDED_RULE,
987
1265
  MACHINE_RULE,
1266
+ workplaceText(job.computer, workspace.root),
988
1267
  stepLine(job.stage),
989
1268
  job.stage.instructions,
990
1269
  contextText(job.projectContext),
@@ -992,6 +1271,10 @@ ${job.review}`,
992
1271
  ${bulletList(repositoryLines(job, workspace))}`,
993
1272
  `How to work:
994
1273
  ${bulletList(rules)}`,
1274
+ safetyText(job),
1275
+ knowledgeText(job.knowledge, job.computer, canProposeKnowledge(job)),
1276
+ APPROVAL_RULE,
1277
+ peopleText(people),
995
1278
  handoffText(job.allowedHandoffs, isCheck),
996
1279
  options.browser && BROWSER_RULE
997
1280
  ].filter(Boolean).join("\n\n");
@@ -999,98 +1282,98 @@ ${bulletList(rules)}`,
999
1282
  }
1000
1283
 
1001
1284
  // src/specTools.ts
1002
- import { tool } from "@anthropic-ai/claude-agent-sdk";
1003
- import { z } from "zod";
1285
+ import { tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
1286
+ import { z as z2 } from "zod";
1004
1287
  var MAX_FIRST_ROUND = 4;
1005
- var questionSchema = z.object({
1006
- id: z.string().describe('Stable snake_case id, unique in the task, for example "empty_state"'),
1007
- question: z.string().describe("One plain sentence the owner can answer without reading code"),
1008
- whyItMatters: z.string().describe("One line: what changes in the result depending on the answer"),
1009
- options: z.array(z.object({
1010
- label: z.string().describe('Short, concrete answer (1-6 words), without "(Recommended)": use the recommended flag'),
1011
- description: z.string().describe("One sentence: what happens if this option is chosen"),
1012
- recommended: z.boolean().describe("true on the option you recommend (in a multiSelect question, on every option you recommend). Recommended options come first")
1288
+ var questionSchema = z2.object({
1289
+ id: z2.string().describe('Stable snake_case id, unique in the task, for example "empty_state"'),
1290
+ question: z2.string().describe("One plain sentence the owner can answer without reading code"),
1291
+ whyItMatters: z2.string().describe("One line: what changes in the result depending on the answer"),
1292
+ options: z2.array(z2.object({
1293
+ label: z2.string().describe('Short, concrete answer (1-6 words), without "(Recommended)": use the recommended flag'),
1294
+ description: z2.string().describe("One sentence: what happens if this option is chosen"),
1295
+ recommended: z2.boolean().describe("true on the option you recommend (in a multiSelect question, on every option you recommend). Recommended options come first")
1013
1296
  })).min(2).max(4).describe('2-4 mutually exclusive options grounded in the code and data. The UI always adds a free-text "Other" field, so never add "Other" yourself'),
1014
- multiSelect: z.boolean().describe("true only when the options are additive and several can apply together")
1297
+ multiSelect: z2.boolean().describe("true only when the options are additive and several can apply together")
1015
1298
  });
1016
- var briefSchema = z.object({
1017
- findings: z.array(z.object({
1018
- fact: z.string().describe("What the code does today that matters for this task"),
1019
- where: z.string().describe("Where it is: owner/repo path:line")
1299
+ var briefSchema = z2.object({
1300
+ findings: z2.array(z2.object({
1301
+ fact: z2.string().describe("What the code does today that matters for this task"),
1302
+ where: z2.string().describe("Where it is: owner/repo path:line")
1020
1303
  })).describe("Findings from the code"),
1021
- dataFacts: z.array(z.string()).describe("Numbers and facts from the events or the business data, each with its source. Empty when the task needs no data"),
1022
- approach: z.string().describe("The approach you would take, in 2-4 sentences"),
1023
- codeMap: z.array(z.object({
1024
- repo: z.string().describe("Repository full name (owner/name)"),
1025
- file: z.string().describe("Path relative to the repository root"),
1026
- role: z.string().describe("Why it matters: what to change or reuse there")
1304
+ dataFacts: z2.array(z2.string()).describe("Numbers and facts from the events or the business data, each with its source. Empty when the task needs no data"),
1305
+ approach: z2.string().describe("The approach you would take, in 2-4 sentences"),
1306
+ codeMap: z2.array(z2.object({
1307
+ repo: z2.string().describe("Repository full name (owner/name)"),
1308
+ file: z2.string().describe("Path relative to the repository root"),
1309
+ role: z2.string().describe("Why it matters: what to change or reuse there")
1027
1310
  })).describe("The files the change will touch or must reuse"),
1028
- assumptions: z.array(z.object({
1029
- text: z.string().describe("The default you chose"),
1030
- reason: z.string().describe("Why it is safe or why it was chosen")
1311
+ assumptions: z2.array(z2.object({
1312
+ text: z2.string().describe("The default you chose"),
1313
+ reason: z2.string().describe("Why it is safe or why it was chosen")
1031
1314
  })).describe("Corners settled with a default instead of a question"),
1032
- pendingDecisions: z.array(z.string()).describe("Decisions that depend on an answer to this round and cannot be asked yet. Empty when none")
1315
+ pendingDecisions: z2.array(z2.string()).describe("Decisions that depend on an answer to this round and cannot be asked yet. Empty when none")
1033
1316
  });
1034
- var questionsPayloadSchema = z.object({
1317
+ var questionsPayloadSchema = z2.object({
1035
1318
  brief: briefSchema,
1036
- questions: z.array(questionSchema).max(MAX_FIRST_ROUND).describe(`0-${MAX_FIRST_ROUND} questions that pass the question test, most important first. An empty list when research settled everything`)
1319
+ questions: z2.array(questionSchema).max(MAX_FIRST_ROUND).describe(`0-${MAX_FIRST_ROUND} questions that pass the question test, most important first. An empty list when research settled everything`)
1037
1320
  });
1038
- var specSchema = z.object({
1039
- goal: z.string().describe("The problem solved, for whom, and the outcome a user or the business will notice"),
1040
- context: z.string().describe("Summary: the problem in 1-2 sentences, then the chosen approach in 1-2 sentences"),
1041
- inScope: z.array(z.string()).describe("What this task delivers, one item each"),
1042
- outOfScope: z.array(z.string()).describe("What this task does not do: nearby additions a coder might be tempted to make and approaches that are ruled out. Never empty"),
1043
- decisions: z.array(z.object({
1044
- question: z.string(),
1045
- answer: z.string(),
1046
- by: z.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
1321
+ var specSchema = z2.object({
1322
+ goal: z2.string().describe("The problem solved, for whom, and the outcome a user or the business will notice"),
1323
+ context: z2.string().describe("Summary: the problem in 1-2 sentences, then the chosen approach in 1-2 sentences"),
1324
+ inScope: z2.array(z2.string()).describe("What this task delivers, one item each"),
1325
+ outOfScope: z2.array(z2.string()).describe("What this task does not do: nearby additions a coder might be tempted to make and approaches that are ruled out. Never empty"),
1326
+ decisions: z2.array(z2.object({
1327
+ question: z2.string(),
1328
+ answer: z2.string(),
1329
+ by: z2.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
1047
1330
  })).describe("Every answered question with by: owner, and your own calls that matter with by: agent"),
1048
- codeMap: z.array(z.object({
1049
- repo: z.string().describe("Repository full name (owner/name)"),
1050
- file: z.string().describe("Path relative to the repository root"),
1051
- role: z.string().describe("What the coder needs to know about this file"),
1052
- use: z.enum(["reuse", "change", "keep"]).describe("reuse: build on it; change: it is in changes; keep: do not touch")
1331
+ codeMap: z2.array(z2.object({
1332
+ repo: z2.string().describe("Repository full name (owner/name)"),
1333
+ file: z2.string().describe("Path relative to the repository root"),
1334
+ role: z2.string().describe("What the coder needs to know about this file"),
1335
+ use: z2.enum(["reuse", "change", "keep"]).describe("reuse: build on it; change: it is in changes; keep: do not touch")
1053
1336
  })).describe("The files a coder must read before starting"),
1054
- changes: z.array(z.object({
1055
- repo: z.string().describe("Repository full name (owner/name) where the change happens"),
1056
- file: z.string().describe("Path to the file, relative to the repository root"),
1057
- action: z.enum(["create", "modify", "delete"]),
1058
- description: z.string().describe("The behaviour the file has after the change and which existing functions or patterns to extend. No code snippets or diffs"),
1059
- produces: z.array(z.string()).describe("Names with signatures or data shapes this change creates or changes for other changes. Empty when none"),
1060
- consumes: z.array(z.string()).describe("Names with signatures or data shapes this change uses from earlier changes. Empty when none"),
1061
- acceptance: z.array(z.string()).describe("Ids of the criteria this change makes true, like AC-1")
1337
+ changes: z2.array(z2.object({
1338
+ repo: z2.string().describe("Repository full name (owner/name) where the change happens"),
1339
+ file: z2.string().describe("Path to the file, relative to the repository root"),
1340
+ action: z2.enum(["create", "modify", "delete"]),
1341
+ description: z2.string().describe("The behaviour the file has after the change and which existing functions or patterns to extend. No code snippets or diffs"),
1342
+ produces: z2.array(z2.string()).describe("Names with signatures or data shapes this change creates or changes for other changes. Empty when none"),
1343
+ consumes: z2.array(z2.string()).describe("Names with signatures or data shapes this change uses from earlier changes. Empty when none"),
1344
+ acceptance: z2.array(z2.string()).describe("Ids of the criteria this change makes true, like AC-1")
1062
1345
  })).min(1).describe("File changes in dependency order: each uses only what earlier changes or the code already provide"),
1063
- acceptance: z.array(z.object({
1064
- id: z.string().describe("AC-1, AC-2 and so on, in order"),
1065
- kind: z.enum(["main", "edge", "keep"]).describe("main: the main path; edge: an error, empty or limit case; keep: existing behaviour that must keep working"),
1066
- text: z.string().describe('"Given <state>, when <action>, then <observable result>", saying where to look. No file paths or function names')
1346
+ acceptance: z2.array(z2.object({
1347
+ id: z2.string().describe("AC-1, AC-2 and so on, in order"),
1348
+ kind: z2.enum(["main", "edge", "keep"]).describe("main: the main path; edge: an error, empty or limit case; keep: existing behaviour that must keep working"),
1349
+ text: z2.string().describe('"Given <state>, when <action>, then <observable result>", saying where to look. No file paths or function names')
1067
1350
  })).min(1).describe("Acceptance criteria: main path first, then edge cases, then what must keep working"),
1068
- testPlan: z.object({
1069
- automated: z.array(z.object({
1070
- repo: z.string(),
1071
- command: z.string().describe("The exact command to run"),
1072
- expect: z.string().describe("What passing looks like")
1351
+ testPlan: z2.object({
1352
+ automated: z2.array(z2.object({
1353
+ repo: z2.string(),
1354
+ command: z2.string().describe("The exact command to run"),
1355
+ expect: z2.string().describe("What passing looks like")
1073
1356
  })).describe("At least one command"),
1074
- newTests: z.array(z.object({
1075
- seam: z.string().describe("Where the test goes: the highest seam that covers the behaviour (an endpoint, a page, a public function)"),
1076
- covers: z.array(z.string()).describe("Ids of the criteria it covers")
1357
+ newTests: z2.array(z2.object({
1358
+ seam: z2.string().describe("Where the test goes: the highest seam that covers the behaviour (an endpoint, a page, a public function)"),
1359
+ covers: z2.array(z2.string()).describe("Ids of the criteria it covers")
1077
1360
  })),
1078
- manual: z.array(z.object({
1079
- page: z.string().describe("The page or screen"),
1080
- steps: z.string().describe("What a person does and sees")
1361
+ manual: z2.array(z2.object({
1362
+ page: z2.string().describe("The page or screen"),
1363
+ steps: z2.string().describe("What a person does and sees")
1081
1364
  }))
1082
1365
  }),
1083
- risks: z.array(z.object({
1084
- risk: z.string(),
1085
- mitigation: z.string()
1366
+ risks: z2.array(z2.object({
1367
+ risk: z2.string(),
1368
+ mitigation: z2.string()
1086
1369
  })).describe("What could go wrong: migrations, existing data, access, performance, compatibility"),
1087
- riskLevel: z.enum(["low", "medium", "high"]).describe("How closely a person should review the result"),
1088
- assumptions: z.array(z.object({
1089
- text: z.string().describe("The default you chose"),
1090
- reason: z.string().describe("Why, so the owner can confirm or correct it quickly")
1370
+ riskLevel: z2.enum(["low", "medium", "high"]).describe("How closely a person should review the result"),
1371
+ assumptions: z2.array(z2.object({
1372
+ text: z2.string().describe("The default you chose"),
1373
+ reason: z2.string().describe("Why, so the owner can confirm or correct it quickly")
1091
1374
  })).describe("Defaults chosen where the task was silent. Empty when nothing was assumed"),
1092
- openQuestions: z.array(questionSchema).describe("Questions only the owner can settle; the spec cannot be approved until each is answered or dismissed. Empty when none"),
1093
- split: z.array(z.string()).describe("When the task holds more than one shippable goal or more than about 8 files: the separate tasks you propose, one line each. Empty otherwise")
1375
+ openQuestions: z2.array(questionSchema).describe("Questions only the owner can settle; the spec cannot be approved until each is answered or dismissed. Empty when none"),
1376
+ split: z2.array(z2.string()).describe("When the task holds more than one shippable goal or more than about 8 files: the separate tasks you propose, one line each. Empty otherwise")
1094
1377
  });
1095
1378
  function reply(text2, isError = false) {
1096
1379
  return { content: [{ type: "text", text: text2 }], ...isError ? { isError: true } : {} };
@@ -1100,7 +1383,7 @@ function unreachable(error, toolName) {
1100
1383
  return reply(`Could not reach TAMsense to check this: ${message}. Call ${toolName} again in a moment.`, true);
1101
1384
  }
1102
1385
  function questionsTool(review, onSubmit) {
1103
- return tool(
1386
+ return tool2(
1104
1387
  SUBMIT_QUESTIONS_TOOL_NAME,
1105
1388
  "Hand in the brief and the clarifying questions of this round. Call it once, after the research. Pass an empty questions list when research settled everything: the spec is then written next. If it returns problems, the questions were not saved: fix them and call it again.",
1106
1389
  questionsPayloadSchema.shape,
@@ -1123,7 +1406,7 @@ var BUILDER_CHECK_FIRST = "The spec passed the lint but was not saved yet: the b
1123
1406
  function specTool(review, onSubmit, builderCheckRan) {
1124
1407
  let repairs = 0;
1125
1408
  let reminded = false;
1126
- return tool(
1409
+ return tool2(
1127
1410
  SUBMIT_SPEC_TOOL_NAME,
1128
1411
  "Hand in the implementation spec. It replaces the previous version entirely, so always send the FULL spec. TAMsense lints it and checks the files first; if it returns feedback, the spec was not saved: fix it and call submit_spec again.",
1129
1412
  specSchema.shape,
@@ -1243,14 +1526,12 @@ function usageLimitWatch() {
1243
1526
  }
1244
1527
 
1245
1528
  // src/claude.ts
1246
- var NOBODY_TO_ASK = "Nobody is at the computer to approve this during a TAMsense run. Do it another way, or explain in your report why it is needed.";
1247
1529
  var NO_RESULT_TEXT = "Claude finished without a result.";
1248
1530
  var SERVER_NAME = "tamsense";
1249
1531
  var LIBRARY_PATH = fileURLToPath(new URL("../library", import.meta.url));
1250
- var denyWhenAsked = async () => ({ behavior: "deny", message: NOBODY_TO_ASK });
1251
1532
  var CAPTION_FIELDS = {
1252
- title: z2.string().describe('short name of what it shows, at most 60 characters: "Design option 1", "Feature testing", "Feature testing, attempt 2". Never generic names like "screenshot1"'),
1253
- purpose: z2.string().describe('one line, at most 160 characters, on why it was made: "Comparing two layouts for the empty orders state"')
1533
+ title: z3.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"'),
1534
+ purpose: z3.string().describe('one line, at most 160 characters, on why it was made: "Comparing two layouts for the empty orders state"')
1254
1535
  };
1255
1536
  var ATTACH_TOOLS = {
1256
1537
  screenshot: {
@@ -1271,35 +1552,36 @@ var ATTACH_TOOLS = {
1271
1552
  };
1272
1553
  function attachTool(kind, attach) {
1273
1554
  const spec = ATTACH_TOOLS[kind];
1274
- return tool2(
1555
+ return tool3(
1275
1556
  spec.name,
1276
1557
  spec.description,
1277
- { path: z2.string().describe(spec.path), ...CAPTION_FIELDS },
1558
+ { path: z3.string().describe(spec.path), ...CAPTION_FIELDS },
1278
1559
  async (args) => ({ content: [{ type: "text", text: await attach({ kind, ...args }) }] })
1279
1560
  );
1280
1561
  }
1281
1562
  function verdictTool(onVerdict) {
1282
- return tool2(
1563
+ return tool3(
1283
1564
  VERDICT_TOOL_NAME,
1284
1565
  "Hand in the result of this check. Call it exactly once, at the very end.",
1285
1566
  {
1286
- passed: z2.boolean().describe("true only if nothing must be fixed"),
1287
- report: z2.string().describe("what you checked and, if it failed, exactly what to fix with file and line")
1567
+ passed: z3.boolean().describe("true only if nothing must be fixed"),
1568
+ report: z3.string().describe("what you checked and, if it failed, exactly what to fix with file and line"),
1569
+ couldNotRun: z3.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
1570
  },
1289
1571
  async (args) => {
1290
- onVerdict({ passed: args.passed, report: args.report });
1572
+ onVerdict(args.couldNotRun ? { passed: false, report: args.report, couldNotRun: true } : { passed: args.passed, report: args.report });
1291
1573
  return { content: [{ type: "text", text: "Verdict received. You can stop now." }] };
1292
1574
  }
1293
1575
  );
1294
1576
  }
1295
1577
  function handoffTool(targets, onHandoff) {
1296
- return tool2(
1578
+ return tool3(
1297
1579
  HANDOFF_TOOL_NAME,
1298
1580
  "Hand the task to the next step of the TAMsense board when you finish. The next agent and the task owner read your note. Only one handoff counts: calling it again replaces the earlier one.",
1299
1581
  {
1300
- to: z2.string().describe("name or id of the step to hand the task to"),
1301
- outcome: z2.enum(HANDOFF_OUTCOMES).describe("pass: the work is done; problems: something must be fixed; ask: a question for that step"),
1302
- note: z2.string().describe("for the next agent and the owner: what was done, what to check, what is open")
1582
+ to: z3.string().describe("name or id of the step to hand the task to"),
1583
+ outcome: z3.enum(HANDOFF_OUTCOMES).describe("pass: the work is done; problems: something must be fixed; ask: a question for that step"),
1584
+ note: z3.string().describe("for the next agent and the owner: what was done, what to check, what is open")
1303
1585
  },
1304
1586
  async (args) => {
1305
1587
  const choice = chooseHandoff(targets, args);
@@ -1311,6 +1593,48 @@ function handoffTool(targets, onHandoff) {
1311
1593
  }
1312
1594
  );
1313
1595
  }
1596
+ function askPersonTool(asking, waiting) {
1597
+ return tool3(
1598
+ ASK_PERSON_TOOL_NAME,
1599
+ "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.",
1600
+ {
1601
+ to: z3.union([z3.number(), z3.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"),
1602
+ why: z3.string().describe('one line on why this person: "assignee, and the run is on her computer", "Designer, owns empty states"'),
1603
+ question: z3.string().describe("one plain question the person can answer without reading the code"),
1604
+ context: z3.string().optional().describe("one line on what you saw that led to the question"),
1605
+ tried: z3.array(z3.object({
1606
+ did: z3.string().describe("the command or step"),
1607
+ error: z3.string().optional().describe("the exact error line, if there was one"),
1608
+ result: z3.string().describe("what it showed, one line")
1609
+ })).max(MAX_ASK_TRIES).optional().describe("what you already tried, the most telling first"),
1610
+ options: z3.array(z3.object({
1611
+ label: z3.string().describe("a short answer the person can pick"),
1612
+ detail: z3.string().describe("one line on what happens if it is picked")
1613
+ })).max(MAX_ASK_OPTIONS).optional().describe("2 to 4 answers you suggest, the one you would pick first"),
1614
+ blocking: z3.boolean().describe("true when you cannot go on without the answer"),
1615
+ assumption: z3.string().optional().describe("when blocking is false: what you continue with until the answer comes")
1616
+ },
1617
+ async (args) => {
1618
+ if (waiting.waitingFor !== null) {
1619
+ return { content: [{ type: "text", text: ALREADY_WAITING_TEXT }], isError: true };
1620
+ }
1621
+ const choice = checkAsk(asking.people, args);
1622
+ if ("error" in choice) {
1623
+ return { content: [{ type: "text", text: choice.error }], isError: true };
1624
+ }
1625
+ let reply2;
1626
+ try {
1627
+ reply2 = await asking.send(choice.body);
1628
+ } catch (error) {
1629
+ 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 };
1630
+ }
1631
+ if (choice.body.blocking) {
1632
+ waiting.waitingFor = reply2.id;
1633
+ }
1634
+ return { content: [{ type: "text", text: askedText(reply2.name, choice.body) }] };
1635
+ }
1636
+ );
1637
+ }
1314
1638
  function specWorkTools(specWork, submitted) {
1315
1639
  if (!specWork) {
1316
1640
  return [];
@@ -1321,6 +1645,16 @@ function specWorkTools(specWork, submitted) {
1321
1645
  submitted.spec = spec;
1322
1646
  }, () => submitted.helperStarted)];
1323
1647
  }
1648
+ function knowledgeTools(knowledge) {
1649
+ if (!knowledge) {
1650
+ return [];
1651
+ }
1652
+ return [
1653
+ ...knowledge.entryIds.length > 0 ? [updateKnowledgeTool(knowledge)] : [],
1654
+ ...knowledge.moreIds.length > 0 ? [readKnowledgeTool(knowledge)] : [],
1655
+ ...knowledge.canPropose ? [proposeKnowledgeTool(knowledge)] : []
1656
+ ];
1657
+ }
1324
1658
  function tamsenseServer(request, submitted) {
1325
1659
  const tools = [
1326
1660
  attachTool("screenshot", request.attach),
@@ -1330,6 +1664,8 @@ function tamsenseServer(request, submitted) {
1330
1664
  submitted.verdict = verdict;
1331
1665
  })] : [],
1332
1666
  ...specWorkTools(request.specWork, submitted),
1667
+ ...request.asking ? [askPersonTool(request.asking, submitted)] : [],
1668
+ ...knowledgeTools(request.knowledge),
1333
1669
  ...request.handoffs.length > 0 ? [handoffTool(request.handoffs, (handoff) => {
1334
1670
  submitted.handoff = handoff;
1335
1671
  })] : []
@@ -1368,7 +1704,8 @@ async function attempt(request, context, resume) {
1368
1704
  permissionMode: "auto",
1369
1705
  // краткий пересказ размышлений Claude идёт в журнал задачи, как в Paseo
1370
1706
  thinking: { type: "adaptive", display: "summarized" },
1371
- canUseTool: denyWhenAsked,
1707
+ // режим auto сам решает, что можно; о чём он спросил бы человека, решают правила проекта или люди задачи
1708
+ canUseTool: request.permissions ? askPermission(request.permissions, () => context.lastWords.text, request.cwd) : denyWhenAsked,
1372
1709
  plugins: [{ type: "local", path: LIBRARY_PATH }],
1373
1710
  mcpServers: { [SERVER_NAME]: tamsense.server },
1374
1711
  allowedTools: tamsense.allowed,
@@ -1397,6 +1734,7 @@ async function attempt(request, context, resume) {
1397
1734
  request.onFeed(draft);
1398
1735
  if (draft.kind === "text" && draft.parent === null) {
1399
1736
  lastText = draft.text;
1737
+ context.lastWords.text = draft.text;
1400
1738
  }
1401
1739
  }
1402
1740
  if (message.type === "result") {
@@ -1421,9 +1759,9 @@ function claudeRunner(startQuery) {
1421
1759
  request.input.close();
1422
1760
  abortController.abort();
1423
1761
  }, { once: true });
1424
- const submitted = { verdict: null, handoff: null, questions: null, spec: null, helperStarted: false };
1762
+ const submitted = { verdict: null, handoff: null, questions: null, spec: null, helperStarted: false, waitingFor: null };
1425
1763
  const limit = usageLimitWatch();
1426
- const context = { startQuery, abortController, submitted, limit };
1764
+ const context = { startQuery, abortController, submitted, limit, lastWords: { text: "" } };
1427
1765
  try {
1428
1766
  let result = await attempt(request, context, request.resumeSessionId);
1429
1767
  if (result.resumeFailed) {
@@ -1431,7 +1769,7 @@ function claudeRunner(startQuery) {
1431
1769
  }
1432
1770
  const pausedUntil = limit.pausedUntil(result.outcome.completed);
1433
1771
  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 };
1772
+ return { ...outcome, verdict: submitted.verdict, handoff: submitted.handoff, questions: submitted.questions, spec: submitted.spec, sessionId: result.sessionId, pausedUntil, waitingFor: submitted.waitingFor };
1435
1773
  } finally {
1436
1774
  request.input.close();
1437
1775
  }
@@ -1455,9 +1793,9 @@ function parseConfig(value) {
1455
1793
  if (!isRecord2(value)) {
1456
1794
  return null;
1457
1795
  }
1458
- const { server, token, deviceId, deviceName, browserDeclined } = value;
1796
+ const { server, token, deviceId, deviceName } = value;
1459
1797
  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;
1798
+ return isValid ? { server, token, deviceId, deviceName } : null;
1461
1799
  }
1462
1800
  function isMissingFile(error) {
1463
1801
  return error instanceof Error && "code" in error && error.code === "ENOENT";
@@ -1493,6 +1831,12 @@ var CAPABILITIES_PATH = "/api/runner/me/capabilities";
1493
1831
  function runPath(runId, action) {
1494
1832
  return `/api/runner/runs/${runId}/${action}`;
1495
1833
  }
1834
+ function knowledgePath(runId, entryId) {
1835
+ return `/api/runner/runs/${runId}/knowledge/${entryId}`;
1836
+ }
1837
+ function permissionPath(runId, permissionId) {
1838
+ return `/api/runner/runs/${runId}/permissions/${permissionId}`;
1839
+ }
1496
1840
  function recordingUploadPath(runId, upload, part) {
1497
1841
  return `/api/runner/runs/${runId}/recordings/${upload}/${part}`;
1498
1842
  }
@@ -1545,6 +1889,7 @@ var META_SUFFIX = ".json";
1545
1889
  var MAX_TITLE_LENGTH = 60;
1546
1890
  var MAX_PURPOSE_LENGTH = 160;
1547
1891
  var DEFAULT_TRACKER_ORIGINS = ["https://my.tamsense.com"];
1892
+ var READ_METHODS = ["GET", "HEAD", "OPTIONS"];
1548
1893
  var BINDING = "__tamsenseRecord";
1549
1894
  var STATE_KEY = /* @__PURE__ */ Symbol.for("tamsense.recorder");
1550
1895
  var FLUSH_MS = 100;
@@ -1573,6 +1918,13 @@ function isTrackerIngest(url, tamsenseOrigins) {
1573
1918
  }
1574
1919
  return url.searchParams.has("key") || tamsenseOrigins.includes(url.origin);
1575
1920
  }
1921
+ function isLocalHost(hostname2) {
1922
+ const host = hostname2.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
1923
+ return host === "localhost" || host.endsWith(".localhost") || host === "::1" || host === "0.0.0.0" || /^127(?:\.\d{1,3}){3}$/.test(host);
1924
+ }
1925
+ function isExternalWrite(method, url) {
1926
+ return !READ_METHODS.includes(method.toUpperCase()) && !isLocalHost(url.hostname);
1927
+ }
1576
1928
  function trackerOrigins(env = process2.env) {
1577
1929
  const origins = new Set(DEFAULT_TRACKER_ORIGINS);
1578
1930
  try {
@@ -1659,7 +2011,7 @@ var Session = class {
1659
2011
  const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
1660
2012
  this.file = join3(dir, `${id}${REPLAY_SUFFIX}`);
1661
2013
  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 };
2014
+ 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
2015
  writeFileSync(this.file, "");
1664
2016
  this.save();
1665
2017
  }
@@ -1703,10 +2055,20 @@ var Session = class {
1703
2055
  }
1704
2056
  }
1705
2057
  summary() {
1706
- const { title, purpose, video, marks, gaps, blockedTrackerRequests } = this.meta;
1707
- return { title, purpose, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests };
2058
+ const { title, purpose, video, marks, gaps, blockedTrackerRequests, blockedExternalWrites } = this.meta;
2059
+ return { title, purpose, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests, blockedExternalWrites };
1708
2060
  }
1709
2061
  };
2062
+ function countBlocked(route, counter) {
2063
+ try {
2064
+ const session = state().pages.get(route.request().frame().page());
2065
+ if (session) {
2066
+ session.meta[counter] += 1;
2067
+ session.save();
2068
+ }
2069
+ } catch {
2070
+ }
2071
+ }
1710
2072
  async function startSession(recording, page, injectNow) {
1711
2073
  const current = state();
1712
2074
  if (current.pages.has(page)) {
@@ -1748,15 +2110,19 @@ async function recordContext(context, caption, automatic = false) {
1748
2110
  const recording = { caption: checked, automatic, sessions: [] };
1749
2111
  current.contexts.set(context, recording);
1750
2112
  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 {
2113
+ await context.route((url) => !isLocalHost(url.hostname), async (route) => {
2114
+ const request = route.request();
2115
+ if (!isExternalWrite(request.method(), new URL(request.url()))) {
2116
+ await route.continue().catch(() => {
2117
+ });
2118
+ return;
1759
2119
  }
2120
+ countBlocked(route, "blockedExternalWrites");
2121
+ await route.abort("blockedbyclient").catch(() => {
2122
+ });
2123
+ });
2124
+ await context.route((url) => isTrackerIngest(url, origins), async (route) => {
2125
+ countBlocked(route, "blockedTrackerRequests");
1760
2126
  await route.fulfill({ status: 200, contentType: "application/json", body: '{"ok":true}' });
1761
2127
  });
1762
2128
  await context.exposeBinding(BINDING, ({ page }, payload) => current.pages.get(page)?.receive(payload));
@@ -1850,7 +2216,6 @@ function describeDevice(identity) {
1850
2216
  import { spawn as spawn2 } from "child_process";
1851
2217
  import { hostname } from "os";
1852
2218
  import { delimiter as delimiter2 } from "path";
1853
- import { createInterface as createInterface2 } from "readline/promises";
1854
2219
  import { setTimeout as wait } from "timers/promises";
1855
2220
  var PLATFORM_LABELS = {
1856
2221
  win32: "Windows",
@@ -1886,22 +2251,6 @@ function withNodeOnPath(env, nodeDir, separator = delimiter2) {
1886
2251
  }
1887
2252
  return { ...env, [key]: [...entries, nodeDir].join(separator) };
1888
2253
  }
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
2254
  async function sleep(ms, signal) {
1906
2255
  await wait(ms, void 0, { signal }).catch(() => {
1907
2256
  });
@@ -2028,10 +2377,10 @@ function keyedLock() {
2028
2377
  const previous = tails.get(key) ?? Promise.resolve();
2029
2378
  let release = () => {
2030
2379
  };
2031
- const done = new Promise((resolve) => {
2380
+ const done2 = new Promise((resolve) => {
2032
2381
  release = resolve;
2033
2382
  });
2034
- const tail = previous.then(() => done);
2383
+ const tail = previous.then(() => done2);
2035
2384
  tails.set(key, tail);
2036
2385
  await previous;
2037
2386
  try {
@@ -2051,7 +2400,7 @@ import { randomUUID } from "crypto";
2051
2400
  import { createReadStream } from "fs";
2052
2401
  import { readdir, readFile as readFile3, stat, writeFile as writeFile3 } from "fs/promises";
2053
2402
  import { basename as basename2, dirname as dirname4, extname, isAbsolute, join as join5 } from "path";
2054
- import { createInterface as createInterface3 } from "readline";
2403
+ import { createInterface as createInterface2 } from "readline";
2055
2404
  import { Readable } from "stream";
2056
2405
  import { promisify } from "util";
2057
2406
  import { gzip } from "zlib";
@@ -2143,15 +2492,15 @@ async function ledger(dir) {
2143
2492
  }
2144
2493
  }
2145
2494
  async function remember(dir, file) {
2146
- const done = await ledger(dir);
2147
- done.add(file);
2148
- await writeFile3(join5(dir, LEDGER_FILE), JSON.stringify([...done]));
2495
+ const done2 = await ledger(dir);
2496
+ done2.add(file);
2497
+ await writeFile3(join5(dir, LEDGER_FILE), JSON.stringify([...done2]));
2149
2498
  }
2150
2499
  async function nonEmpty(file) {
2151
2500
  return file !== null && ((await stat(file).catch(() => null))?.size ?? 0) > 0;
2152
2501
  }
2153
2502
  async function pendingRecordings(dir) {
2154
- const done = await ledger(dir);
2503
+ const done2 = await ledger(dir);
2155
2504
  const names = (await readdir(dir).catch(() => [])).sort();
2156
2505
  const pending = [];
2157
2506
  for (const name of names) {
@@ -2160,10 +2509,10 @@ async function pendingRecordings(dir) {
2160
2509
  }
2161
2510
  const meta = await readMeta(join5(dir, name));
2162
2511
  const replay = join5(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
2163
- if (!done.has(replay) && await nonEmpty(replay)) {
2512
+ if (!done2.has(replay) && await nonEmpty(replay)) {
2164
2513
  pending.push({ kind: "replay", file: replay, meta });
2165
2514
  }
2166
- if (meta?.video && !done.has(meta.video) && await nonEmpty(meta.video)) {
2515
+ if (meta?.video && !done2.has(meta.video) && await nonEmpty(meta.video)) {
2167
2516
  pending.push({ kind: "video", file: meta.video, meta });
2168
2517
  }
2169
2518
  }
@@ -2172,7 +2521,7 @@ async function pendingRecordings(dir) {
2172
2521
  async function uploadReplay(upload, file, caption) {
2173
2522
  const id = randomUUID();
2174
2523
  const scan = { startTs: Number.POSITIVE_INFINITY, snapshot: false, marks: [] };
2175
- const lines = createInterface3({ input: createReadStream(file, "utf8"), crlfDelay: Infinity });
2524
+ const lines = createInterface2({ input: createReadStream(file, "utf8"), crlfDelay: Infinity });
2176
2525
  for await (const chunk of replayChunks(lines, scan)) {
2177
2526
  await upload.api.upload("POST", recordingUploadPath(upload.runId, id, "chunks"), new Uint8Array(await gzipAsync(chunk)), "application/gzip");
2178
2527
  }
@@ -2376,6 +2725,16 @@ async function baseRef(cache, job, repository) {
2376
2725
  const pushed = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${job.branch}`], { cwd: cache });
2377
2726
  return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
2378
2727
  }
2728
+ async function keptBase(cache, path, job, repository) {
2729
+ if (!job.keepWorkspace || !existsSync3(join6(path, ".git"))) {
2730
+ return null;
2731
+ }
2732
+ const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
2733
+ if (branch.code !== 0 || branch.stdout !== job.branch) {
2734
+ return null;
2735
+ }
2736
+ return git(["rev-parse", await baseRef(cache, job, repository)], { cwd: cache });
2737
+ }
2379
2738
  async function prepareWorkspace(job, token, tools) {
2380
2739
  const root = tamsenseFolder(tools.home, "work", `task-${job.taskId}`);
2381
2740
  const folders = folderNames(job.repositories);
@@ -2386,6 +2745,10 @@ async function prepareWorkspace(job, token, tools) {
2386
2745
  const path = join6(root, folder);
2387
2746
  const baseSha = await cacheLock.run(repository.fullName, async () => {
2388
2747
  await refreshCache(cache, repository, token, tools);
2748
+ const kept = await keptBase(cache, path, job, repository);
2749
+ if (kept) {
2750
+ return kept;
2751
+ }
2389
2752
  await removeWorktree(cache, path);
2390
2753
  await git(["worktree", "add", "--force", "-B", job.branch, path, await baseRef(cache, job, repository)], { cwd: cache });
2391
2754
  return git(["rev-parse", "HEAD"], { cwd: path });
@@ -2440,7 +2803,7 @@ var FEED_INTERVAL_MS = 1500;
2440
2803
  var FATAL_STATUSES = /* @__PURE__ */ new Set([401, 403]);
2441
2804
  var STOPPED_HERE = "stopped_here";
2442
2805
  var CLOSED_ON_SITE = "closed_on_site";
2443
- function messageOf(error) {
2806
+ function messageOf3(error) {
2444
2807
  return error instanceof Error ? error.message : String(error);
2445
2808
  }
2446
2809
  function isFatal(error) {
@@ -2460,7 +2823,7 @@ async function attachScreenshot(api, job, screenshot, attached) {
2460
2823
  try {
2461
2824
  await api.request("POST", runPath(job.runId, "attachments"), body);
2462
2825
  } catch (error) {
2463
- return `Could not attach the screenshot: ${messageOf(error)}`;
2826
+ return `Could not attach the screenshot: ${messageOf3(error)}`;
2464
2827
  }
2465
2828
  attached.count += 1;
2466
2829
  return "Attached to the task.";
@@ -2470,7 +2833,7 @@ function sessionReporter(api, job, print2) {
2470
2833
  return {
2471
2834
  report: (session) => {
2472
2835
  sending ??= api.request("POST", runPath(job.runId, "session"), session).then(() => {
2473
- }, (error) => print2(`Could not tell TAMsense which Claude took the task: ${messageOf(error)}`));
2836
+ }, (error) => print2(`Could not tell TAMsense which Claude took the task: ${messageOf3(error)}`));
2474
2837
  },
2475
2838
  sent: async () => {
2476
2839
  await sending;
@@ -2504,7 +2867,7 @@ function inbox(job, tools, record, closedOnSite) {
2504
2867
  } catch (error) {
2505
2868
  input.restoreDelivered(ack);
2506
2869
  if (!failing) {
2507
- tools.print(`Could not check for new messages on TAMsense, trying again: ${messageOf(error)}`);
2870
+ tools.print(`Could not check for new messages on TAMsense, trying again: ${messageOf3(error)}`);
2508
2871
  }
2509
2872
  failing = true;
2510
2873
  return;
@@ -2527,7 +2890,7 @@ function inbox(job, tools, record, closedOnSite) {
2527
2890
  record({ kind: "tool", text: switchLine(change), tool: "model", ref: null, parent: null, failed: false });
2528
2891
  }
2529
2892
  },
2530
- (error) => tools.print(`Could not switch the model: ${messageOf(error)}`)
2893
+ (error) => tools.print(`Could not switch the model: ${messageOf3(error)}`)
2531
2894
  );
2532
2895
  }
2533
2896
  }
@@ -2562,6 +2925,42 @@ function specWorkTools2(job, api) {
2562
2925
  }
2563
2926
  return { stage: kind, review: (body) => api.request("POST", runPath(job.runId, "review"), body) };
2564
2927
  }
2928
+ function askingTools(job, api) {
2929
+ const people = job.people ?? [];
2930
+ if (people.length === 0 || job.stage.kind === "interview" || job.stage.kind === "spec") {
2931
+ return null;
2932
+ }
2933
+ return { people, send: (body) => api.request("POST", runPath(job.runId, "questions"), body) };
2934
+ }
2935
+ function permissionTools(job, tools) {
2936
+ if (job.stage.kind === "interview" || job.stage.kind === "spec") {
2937
+ return null;
2938
+ }
2939
+ return {
2940
+ send: async (body) => {
2941
+ const reply2 = await tools.api.request("POST", runPath(job.runId, "permissions"), body);
2942
+ if (reply2.decision === "wait") {
2943
+ tools.print(` Waiting for a person on TAMsense to allow: ${body.title || body.action}`);
2944
+ }
2945
+ return reply2;
2946
+ },
2947
+ check: (id) => tools.api.request("GET", permissionPath(job.runId, id)),
2948
+ sleep: tools.sleep
2949
+ };
2950
+ }
2951
+ function knowledgeTools2(job, api) {
2952
+ if (!job.knowledge) {
2953
+ return null;
2954
+ }
2955
+ return {
2956
+ entryIds: knowledgeIds(job.knowledge),
2957
+ moreIds: job.knowledge.more.map((entry) => entry.id),
2958
+ canPropose: canProposeKnowledge(job),
2959
+ propose: (body) => api.request("POST", runPath(job.runId, "knowledge"), body),
2960
+ update: (entryId, body) => api.request("POST", knowledgePath(job.runId, entryId), body),
2961
+ read: (entryId) => api.request("GET", knowledgePath(job.runId, entryId))
2962
+ };
2963
+ }
2565
2964
  async function executeJob(job, tools, signal, input, record) {
2566
2965
  const isCheck = job.stage.kind === "review" || job.stage.kind === "test";
2567
2966
  tools.print("Preparing the code\u2026");
@@ -2582,6 +2981,9 @@ async function executeJob(job, tools, signal, input, record) {
2582
2981
  signal,
2583
2982
  wantsVerdict: isCheck,
2584
2983
  specWork: specWorkTools2(job, tools.api),
2984
+ asking: askingTools(job, tools.api),
2985
+ permissions: permissionTools(job, tools),
2986
+ knowledge: knowledgeTools2(job, tools.api),
2585
2987
  handoffs: job.allowedHandoffs,
2586
2988
  resumeSessionId: job.resumeSessionId,
2587
2989
  model: job.model,
@@ -2592,7 +2994,8 @@ async function executeJob(job, tools, signal, input, record) {
2592
2994
  });
2593
2995
  await session.sent();
2594
2996
  signal.throwIfAborted();
2595
- const shouldPublish = job.stage.kind === "code" && outcome.completed;
2997
+ const waitingFor = outcome.waitingFor ?? null;
2998
+ const shouldPublish = job.stage.kind === "code" && outcome.completed && waitingFor === null;
2596
2999
  const pushed = shouldPublish ? await publishWorkspace(workspace, job, await gitToken(tools.api, job), identity) : [];
2597
3000
  return {
2598
3001
  outcome: outcome.completed ? "succeeded" : "failed",
@@ -2605,7 +3008,8 @@ async function executeJob(job, tools, signal, input, record) {
2605
3008
  sessionId: outcome.sessionId ?? null,
2606
3009
  pausedUntil: outcome.pausedUntil ?? null,
2607
3010
  questions: outcome.questions ?? null,
2608
- spec: outcome.spec ?? null
3011
+ spec: outcome.spec ?? null,
3012
+ waitingFor
2609
3013
  };
2610
3014
  }
2611
3015
  function clockOf(iso) {
@@ -2616,6 +3020,12 @@ function resultLine(finish2) {
2616
3020
  if (finish2.pausedUntil) {
2617
3021
  return `Paused: ${USAGE_LIMIT_REPORT}, resumes at ${clockOf(finish2.pausedUntil)}`;
2618
3022
  }
3023
+ if (finish2.waitingFor !== null) {
3024
+ return "Waiting for an answer on TAMsense. This computer is free for other tasks; the run continues here after the answer.";
3025
+ }
3026
+ if (finish2.verdict?.couldNotRun) {
3027
+ return `Check could not run: ${finish2.verdict.report}`;
3028
+ }
2619
3029
  if (finish2.verdict) {
2620
3030
  return finish2.verdict.passed ? "Check passed." : `Check did not pass: ${finish2.verdict.report}`;
2621
3031
  }
@@ -2645,7 +3055,7 @@ async function sendRecordings(job, tools) {
2645
3055
  }
2646
3056
  }
2647
3057
  function failureReport(error, signal) {
2648
- return signal.reason === STOPPED_HERE ? "The run was stopped on the computer." : `The run stopped on the computer: ${messageOf(error)}`;
3058
+ return signal.reason === STOPPED_HERE ? "The run was stopped on the computer." : `The run stopped on the computer: ${messageOf3(error)}`;
2649
3059
  }
2650
3060
  async function runJob(job, tools, stop) {
2651
3061
  tools.print(`Task #${job.taskId}: ${job.title} (${job.stage.name})`);
@@ -2667,7 +3077,7 @@ async function runJob(job, tools, stop) {
2667
3077
  try {
2668
3078
  finish2 = await executeJob(job, tools, run2.signal, input, record);
2669
3079
  } 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 };
3080
+ 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
3081
  } finally {
2672
3082
  clearInterval(polling);
2673
3083
  clearInterval(feeding);
@@ -2684,12 +3094,12 @@ async function runJob(job, tools, stop) {
2684
3094
  tools.print("TAMsense closed this run while it was in progress. The result was not sent.");
2685
3095
  return;
2686
3096
  }
2687
- await sendRecordings(job, tools).catch((error) => tools.print(`Could not attach the browser recordings: ${messageOf(error)}`));
3097
+ await sendRecordings(job, tools).catch((error) => tools.print(`Could not attach the browser recordings: ${messageOf3(error)}`));
2688
3098
  try {
2689
3099
  await tools.api.request("POST", runPath(job.runId, "finish"), finish2);
2690
3100
  tools.print(resultLine(finish2));
2691
3101
  } catch (error) {
2692
- tools.print(`Could not send the result to TAMsense: ${messageOf(error)}`);
3102
+ tools.print(`Could not send the result to TAMsense: ${messageOf3(error)}`);
2693
3103
  }
2694
3104
  }
2695
3105
  async function reportCapabilities(tools, stop) {
@@ -2708,7 +3118,7 @@ async function reportCapabilities(tools, stop) {
2708
3118
  }
2709
3119
  } catch (error) {
2710
3120
  if (!complained && !stop.aborted) {
2711
- tools.print(`Could not tell TAMsense which Claude models this computer has: ${messageOf(error)}`);
3121
+ tools.print(`Could not tell TAMsense which Claude models this computer has: ${messageOf3(error)}`);
2712
3122
  complained = true;
2713
3123
  }
2714
3124
  }
@@ -2727,7 +3137,7 @@ async function work(tools, stop, parallel = 1) {
2727
3137
  const reporting = reportCapabilities(tools, stopAll);
2728
3138
  const start = (job) => {
2729
3139
  const runTools = jobTools(job, tools, parallel);
2730
- const task = taskLock.run(String(job.taskId), () => runJob(job, runTools, stopAll)).catch((error) => runTools.print(`The run stopped: ${messageOf(error)}`)).finally(() => running.delete(task));
3140
+ const task = taskLock.run(String(job.taskId), () => runJob(job, runTools, stopAll)).catch((error) => runTools.print(`The run stopped: ${messageOf3(error)}`)).finally(() => running.delete(task));
2731
3141
  running.add(task);
2732
3142
  };
2733
3143
  try {
@@ -2744,7 +3154,7 @@ async function work(tools, stop, parallel = 1) {
2744
3154
  halt.abort();
2745
3155
  throw error;
2746
3156
  }
2747
- tools.print(`Could not reach TAMsense: ${messageOf(error)}. Trying again soon.`);
3157
+ tools.print(`Could not reach TAMsense: ${messageOf3(error)}. Trying again soon.`);
2748
3158
  await tools.sleep(RETRY_AFTER_ERROR_MS, stopAll);
2749
3159
  continue;
2750
3160
  }
@@ -2791,8 +3201,7 @@ async function runLogin(server, deviceName) {
2791
3201
  { api, openBrowser, print, sleep, now: Date.now },
2792
3202
  { name, platform: platformLabel() }
2793
3203
  );
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 });
3204
+ await writeConfig({ server: api.server, token: result.token, deviceId: result.device.id, deviceName: result.device.name });
2796
3205
  print(`Connected: ${result.device.name}. To take your tasks, run ${START_COMMAND}`);
2797
3206
  }
2798
3207
  function stopOnInterrupt() {
@@ -2807,15 +3216,12 @@ function stopOnInterrupt() {
2807
3216
  function installTools(home) {
2808
3217
  return { home, nodePath: process5.execPath, env: process5.env, print, run: runProcess, exists: existsSync4 };
2809
3218
  }
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
- });
3219
+ async function browserForStart(home, install) {
3220
+ const installed = await installedBrowser(home);
3221
+ if (installed || !install) {
3222
+ return installed;
3223
+ }
3224
+ return ensureBrowser({ print, install: () => installBrowser(installTools(home)) });
2819
3225
  }
2820
3226
  async function startedNewVersion() {
2821
3227
  const update = await updateToRun(process5.env, currentVersion(), () => latestVersion());
@@ -2831,7 +3237,7 @@ async function startedNewVersion() {
2831
3237
  return false;
2832
3238
  }
2833
3239
  }
2834
- async function runStart(parallel) {
3240
+ async function runStart(parallel, installsBrowser) {
2835
3241
  if (await startedNewVersion()) {
2836
3242
  return;
2837
3243
  }
@@ -2848,17 +3254,13 @@ async function runStart(parallel) {
2848
3254
  }
2849
3255
  print(`${identity.device.name} is connected to ${saved.server} as ${describeDevice(identity)}.`);
2850
3256
  const home = homedir3();
2851
- const browser = await browserForStart(home, saved);
3257
+ const browser = await browserForStart(home, installsBrowser);
2852
3258
  await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder() }, stopOnInterrupt(), parallel);
2853
3259
  }
2854
3260
  async function runBrowser(remove) {
2855
3261
  const home = homedir3();
2856
3262
  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
- }
3263
+ 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
3264
  return;
2863
3265
  }
2864
3266
  const installed = await installedBrowser(home);
@@ -2893,7 +3295,7 @@ async function run(command) {
2893
3295
  if (command.name === "login") {
2894
3296
  await runLogin(command.server, command.deviceName);
2895
3297
  } else if (command.name === "start") {
2896
- await runStart(command.parallel);
3298
+ await runStart(command.parallel, command.browser);
2897
3299
  } else if (command.name === "browser") {
2898
3300
  await runBrowser(command.remove);
2899
3301
  } else if (command.name === "status") {