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