@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 +3 -1
- package/dist/cli.js +229 -87
- package/dist/cli.js.map +1 -1
- package/dist/recorder.cjs +39 -11
- package/dist/recorder.cjs.map +1 -1
- package/library/agents/design-critic.md +1 -1
- package/library/agents/ui-tester.md +3 -2
- package/library/skills/ui-testing/SKILL.md +5 -5
- package/library/skills/ui-testing/references/local-stand.md +51 -0
- package/library/skills/ui-testing/references/recording.md +1 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
252
|
-
|
|
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(
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
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,
|
|
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") {
|