@tamsensedev/runner 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +5 -5
- package/README.md +47 -46
- package/dist/cli.js +764 -84
- package/dist/cli.js.map +1 -1
- package/dist/recorder.cjs +396 -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 +1 -1
- package/library/agents/ui-tester.md +4 -2
- 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 +5 -2
- package/library/skills/ui-testing/references/headless-check.md +26 -8
- package/library/skills/ui-testing/references/playwright-patterns.md +1 -1
- package/library/skills/ui-testing/references/recording.md +57 -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.
|
|
@@ -84,8 +83,8 @@ Usage:
|
|
|
84
83
|
${START_COMMAND} take your tasks and work on them until you press Ctrl+C
|
|
85
84
|
${BROWSER_COMMAND} install the headless browser for interface checks, 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)
|
|
@@ -407,6 +406,7 @@ var MAX_RESULT_LENGTH = 2e3;
|
|
|
407
406
|
var MAX_SUMMARY_LENGTH = 500;
|
|
408
407
|
var HELPER_TOOLS2 = /* @__PURE__ */ new Set(["Task", "Agent"]);
|
|
409
408
|
var MCP_PREFIX = /^mcp__[^_]+(?:_[^_]+)*__/;
|
|
409
|
+
var ATTACH_LABELS = { attach_screenshot: "Screenshot", attach_replay: "Replay", attach_video: "Video" };
|
|
410
410
|
function clip(text2, max) {
|
|
411
411
|
const trimmed = text2.trim();
|
|
412
412
|
return trimmed.length > max ? `${trimmed.slice(0, max - 1)}\u2026` : trimmed;
|
|
@@ -437,8 +437,8 @@ function toolDraft(block, parent) {
|
|
|
437
437
|
if (tool3 === "hand_off") {
|
|
438
438
|
return { ...base, kind: "tool", text: clip(`Hand off to ${textField(fields, "to") ?? "?"}: ${textField(fields, "note") ?? ""}`, MAX_SUMMARY_LENGTH) };
|
|
439
439
|
}
|
|
440
|
-
if (tool3
|
|
441
|
-
return { ...base, kind: "tool", text: clip(
|
|
440
|
+
if (tool3 in ATTACH_LABELS) {
|
|
441
|
+
return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[tool3]}: ${textField(fields, "title") ?? textField(fields, "path") ?? ""}`, MAX_SUMMARY_LENGTH) };
|
|
442
442
|
}
|
|
443
443
|
const command = textField(fields, "command");
|
|
444
444
|
const summary = tool3 === "Bash" && command ? `$ ${command}` : activityLine(tool3, block.input);
|
|
@@ -813,6 +813,8 @@ function specPromptText(spec) {
|
|
|
813
813
|
// src/prompt.ts
|
|
814
814
|
var VERDICT_TOOL_NAME = "submit_verdict";
|
|
815
815
|
var SCREENSHOT_TOOL_NAME = "attach_screenshot";
|
|
816
|
+
var REPLAY_TOOL_NAME = "attach_replay";
|
|
817
|
+
var VIDEO_TOOL_NAME = "attach_video";
|
|
816
818
|
var HANDOFF_TOOL_NAME = "hand_off";
|
|
817
819
|
var SUBMIT_QUESTIONS_TOOL_NAME = "submit_questions";
|
|
818
820
|
var SUBMIT_SPEC_TOOL_NAME = "submit_spec";
|
|
@@ -832,7 +834,9 @@ var CHECK_RULES = [
|
|
|
832
834
|
"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
835
|
`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
836
|
];
|
|
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
|
|
837
|
+
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
|
+
var NAMING_RULE = 'Name everything you attach (screenshots, replays, videos) by what it shows and why it was made: a short title such as "Design option 1", "Feature testing" or "Feature testing, attempt 2", and a one-line purpose such as "Comparing two layouts for the empty orders state". Number design variants, name test runs by their goal and attempt, and never use generic names like "screenshot1".';
|
|
839
|
+
var RECORDING_RULE = `Every browser you open with Playwright from Node is recorded for the task as a replay people can watch, and requests from the tested site to the TAMsense tracker are blocked, so your runs never reach analytics. In every browser script, load the recorder from the TAMSENSE_RECORDER environment variable (require(process.env.TAMSENSE_RECORDER), or createRequire(import.meta.url)(process.env.TAMSENSE_RECORDER) in an .mjs script), open each context with await recorder.newRecordedContext(browser, { title, purpose, context: { viewport } }), call await recorder.mark(page, 'what just happened') at every meaningful step, and end with await recorder.closeRecorded(context). Add video: true when the page has a canvas, a map, a video player or iframes from another site (a replay cannot show them), or when someone asks for a video. Recordings are attached to the task when the run ends; ${REPLAY_TOOL_NAME} and ${VIDEO_TOOL_NAME} attach one earlier with a better title.`;
|
|
836
840
|
var BROWSER_RULE = "A headless browser is installed for interface checks. If the project has no Playwright of its own, load playwright-core from the folder in the TAMSENSE_PLAYWRIGHT environment variable, with PLAYWRIGHT_BROWSERS_PATH set to the folder in TAMSENSE_PLAYWRIGHT_BROWSERS. Never install browsers or packages yourself.";
|
|
837
841
|
var STAGE_GOALS = {
|
|
838
842
|
code: "Implement",
|
|
@@ -976,7 +980,8 @@ function buildTaskPrompt(job, workspace, options = {}) {
|
|
|
976
980
|
${job.review}`,
|
|
977
981
|
teamMessagesText(job.messages)
|
|
978
982
|
].filter(Boolean).join("\n\n");
|
|
979
|
-
const
|
|
983
|
+
const evidence = [SCREENSHOT_RULE, ...options.recorder ? [RECORDING_RULE] : [], NAMING_RULE];
|
|
984
|
+
const rules = isCheck ? [...CHECK_RULES, ...evidence] : [...CODE_RULES, ...job.review ? [REWORK_RULE] : [], ...evidence, CODE_REPORT_RULE];
|
|
980
985
|
const instructions = [
|
|
981
986
|
UNATTENDED_RULE,
|
|
982
987
|
MACHINE_RULE,
|
|
@@ -1243,15 +1248,34 @@ var NO_RESULT_TEXT = "Claude finished without a result.";
|
|
|
1243
1248
|
var SERVER_NAME = "tamsense";
|
|
1244
1249
|
var LIBRARY_PATH = fileURLToPath(new URL("../library", import.meta.url));
|
|
1245
1250
|
var denyWhenAsked = async () => ({ behavior: "deny", message: NOBODY_TO_ASK });
|
|
1246
|
-
|
|
1251
|
+
var CAPTION_FIELDS = {
|
|
1252
|
+
title: z2.string().describe('short name of what it shows, at most 60 characters: "Design option 1", "Feature testing", "Feature testing, attempt 2". Never generic names like "screenshot1"'),
|
|
1253
|
+
purpose: z2.string().describe('one line, at most 160 characters, on why it was made: "Comparing two layouts for the empty orders state"')
|
|
1254
|
+
};
|
|
1255
|
+
var ATTACH_TOOLS = {
|
|
1256
|
+
screenshot: {
|
|
1257
|
+
name: SCREENSHOT_TOOL_NAME,
|
|
1258
|
+
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.",
|
|
1259
|
+
path: "absolute path of the image file"
|
|
1260
|
+
},
|
|
1261
|
+
replay: {
|
|
1262
|
+
name: REPLAY_TOOL_NAME,
|
|
1263
|
+
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.",
|
|
1264
|
+
path: "absolute path of the .rrweb.ndjson replay file"
|
|
1265
|
+
},
|
|
1266
|
+
video: {
|
|
1267
|
+
name: VIDEO_TOOL_NAME,
|
|
1268
|
+
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.",
|
|
1269
|
+
path: "absolute path of the video file"
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
function attachTool(kind, attach) {
|
|
1273
|
+
const spec = ATTACH_TOOLS[kind];
|
|
1247
1274
|
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) }] })
|
|
1275
|
+
spec.name,
|
|
1276
|
+
spec.description,
|
|
1277
|
+
{ path: z2.string().describe(spec.path), ...CAPTION_FIELDS },
|
|
1278
|
+
async (args) => ({ content: [{ type: "text", text: await attach({ kind, ...args }) }] })
|
|
1255
1279
|
);
|
|
1256
1280
|
}
|
|
1257
1281
|
function verdictTool(onVerdict) {
|
|
@@ -1299,7 +1323,9 @@ function specWorkTools(specWork, submitted) {
|
|
|
1299
1323
|
}
|
|
1300
1324
|
function tamsenseServer(request, submitted) {
|
|
1301
1325
|
const tools = [
|
|
1302
|
-
|
|
1326
|
+
attachTool("screenshot", request.attach),
|
|
1327
|
+
attachTool("replay", request.attach),
|
|
1328
|
+
attachTool("video", request.attach),
|
|
1303
1329
|
...request.wantsVerdict ? [verdictTool((verdict) => {
|
|
1304
1330
|
submitted.verdict = verdict;
|
|
1305
1331
|
})] : [],
|
|
@@ -1458,6 +1484,7 @@ async function removeConfig(path = configPath()) {
|
|
|
1458
1484
|
// src/protocol.ts
|
|
1459
1485
|
var MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
|
1460
1486
|
var MAX_ATTACHMENTS_PER_RUN = 20;
|
|
1487
|
+
var MAX_VIDEO_BYTES = 200 * 1024 * 1024;
|
|
1461
1488
|
var PAIRINGS_PATH = "/api/runner/pairings";
|
|
1462
1489
|
var PAIRING_TOKEN_PATH = "/api/runner/pairings/token";
|
|
1463
1490
|
var IDENTITY_PATH = "/api/runner/me";
|
|
@@ -1466,6 +1493,9 @@ var CAPABILITIES_PATH = "/api/runner/me/capabilities";
|
|
|
1466
1493
|
function runPath(runId, action) {
|
|
1467
1494
|
return `/api/runner/runs/${runId}/${action}`;
|
|
1468
1495
|
}
|
|
1496
|
+
function recordingUploadPath(runId, upload, part) {
|
|
1497
|
+
return `/api/runner/runs/${runId}/recordings/${upload}/${part}`;
|
|
1498
|
+
}
|
|
1469
1499
|
|
|
1470
1500
|
// src/login.ts
|
|
1471
1501
|
var MS_PER_SECOND = 1e3;
|
|
@@ -1495,6 +1525,318 @@ async function login(tools, request) {
|
|
|
1495
1525
|
throw new Error(EXPIRED_TEXT);
|
|
1496
1526
|
}
|
|
1497
1527
|
|
|
1528
|
+
// src/recorderEnv.ts
|
|
1529
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1530
|
+
import { join as join4 } from "path";
|
|
1531
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1532
|
+
|
|
1533
|
+
// src/recorder.ts
|
|
1534
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
1535
|
+
import Module, { createRequire } from "module";
|
|
1536
|
+
import { basename, dirname as dirname3, join as join3 } from "path";
|
|
1537
|
+
import process2 from "process";
|
|
1538
|
+
var RECORDINGS_DIR_ENV = "TAMSENSE_RECORDINGS_DIR";
|
|
1539
|
+
var SERVER_ENV = "TAMSENSE_SERVER";
|
|
1540
|
+
var DEFAULT_TITLE_ENV = "TAMSENSE_RECORDING_TITLE";
|
|
1541
|
+
var DEFAULT_PURPOSE_ENV = "TAMSENSE_RECORDING_PURPOSE";
|
|
1542
|
+
var MARK_TAG = "tamsense.mark";
|
|
1543
|
+
var REPLAY_SUFFIX = ".rrweb.ndjson";
|
|
1544
|
+
var META_SUFFIX = ".json";
|
|
1545
|
+
var MAX_TITLE_LENGTH = 60;
|
|
1546
|
+
var MAX_PURPOSE_LENGTH = 160;
|
|
1547
|
+
var DEFAULT_TRACKER_ORIGINS = ["https://my.tamsense.com"];
|
|
1548
|
+
var BINDING = "__tamsenseRecord";
|
|
1549
|
+
var STATE_KEY = /* @__PURE__ */ Symbol.for("tamsense.recorder");
|
|
1550
|
+
var FLUSH_MS = 100;
|
|
1551
|
+
var MAX_SESSION_BYTES = 300 * 1024 * 1024;
|
|
1552
|
+
var GAP_CHECK_MS = 1500;
|
|
1553
|
+
var RRWEB_META = 4;
|
|
1554
|
+
function state() {
|
|
1555
|
+
const holder = globalThis;
|
|
1556
|
+
holder[STATE_KEY] ??= { contexts: /* @__PURE__ */ new WeakMap(), pages: /* @__PURE__ */ new WeakMap(), instrumented: /* @__PURE__ */ new WeakSet(), script: null, hooked: false };
|
|
1557
|
+
return holder[STATE_KEY];
|
|
1558
|
+
}
|
|
1559
|
+
function recordingsDir() {
|
|
1560
|
+
const dir = process2.env[RECORDINGS_DIR_ENV];
|
|
1561
|
+
if (!dir) {
|
|
1562
|
+
throw new Error(`${RECORDINGS_DIR_ENV} is not set: recordings work only inside a TAMsense run`);
|
|
1563
|
+
}
|
|
1564
|
+
return dir;
|
|
1565
|
+
}
|
|
1566
|
+
function warn(text2) {
|
|
1567
|
+
process2.stderr.write(`[tamsense recorder] ${text2}
|
|
1568
|
+
`);
|
|
1569
|
+
}
|
|
1570
|
+
function isTrackerIngest(url, tamsenseOrigins) {
|
|
1571
|
+
if (!/\/api\/events\/?$/.test(url.pathname)) {
|
|
1572
|
+
return false;
|
|
1573
|
+
}
|
|
1574
|
+
return url.searchParams.has("key") || tamsenseOrigins.includes(url.origin);
|
|
1575
|
+
}
|
|
1576
|
+
function trackerOrigins(env = process2.env) {
|
|
1577
|
+
const origins = new Set(DEFAULT_TRACKER_ORIGINS);
|
|
1578
|
+
try {
|
|
1579
|
+
if (env[SERVER_ENV]) {
|
|
1580
|
+
origins.add(new URL(env[SERVER_ENV]).origin);
|
|
1581
|
+
}
|
|
1582
|
+
} catch {
|
|
1583
|
+
}
|
|
1584
|
+
return [...origins];
|
|
1585
|
+
}
|
|
1586
|
+
function checkCaption(caption) {
|
|
1587
|
+
const title = typeof caption?.title === "string" ? caption.title.trim() : "";
|
|
1588
|
+
const purpose = typeof caption?.purpose === "string" ? caption.purpose.trim() : "";
|
|
1589
|
+
if (!title || !purpose) {
|
|
1590
|
+
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).');
|
|
1591
|
+
}
|
|
1592
|
+
if (title.length > MAX_TITLE_LENGTH || purpose.length > MAX_PURPOSE_LENGTH) {
|
|
1593
|
+
throw new Error(`Keep the title within ${MAX_TITLE_LENGTH} characters and the purpose within ${MAX_PURPOSE_LENGTH}.`);
|
|
1594
|
+
}
|
|
1595
|
+
return { title, purpose };
|
|
1596
|
+
}
|
|
1597
|
+
function clip2(text2, max) {
|
|
1598
|
+
return text2.length > max ? `${text2.slice(0, max - 1).trimEnd()}\u2026` : text2;
|
|
1599
|
+
}
|
|
1600
|
+
function automaticCaption(env = process2.env, script2 = process2.argv[1]) {
|
|
1601
|
+
const from = script2 ? `, opened by ${basename(script2)}` : "";
|
|
1602
|
+
return {
|
|
1603
|
+
title: clip2(env[DEFAULT_TITLE_ENV]?.trim() || "Browser session", MAX_TITLE_LENGTH),
|
|
1604
|
+
purpose: clip2(`${env[DEFAULT_PURPOSE_ENV]?.trim() || "Recorded automatically while the agent used a browser"}${from}`, MAX_PURPOSE_LENGTH)
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
function rrwebSource() {
|
|
1608
|
+
const entry = createRequire(import.meta.url).resolve("rrweb");
|
|
1609
|
+
return readFileSync(join3(dirname3(entry), "rrweb.umd.min.cjs"), "utf8");
|
|
1610
|
+
}
|
|
1611
|
+
function pageScript(rrweb) {
|
|
1612
|
+
return `(() => {
|
|
1613
|
+
if (window.top !== window || window.__tamsenseRecording) return;
|
|
1614
|
+
window.__tamsenseRecording = true;
|
|
1615
|
+
const rrweb = (() => { const module = { exports: {} }; const exports = module.exports; ${rrweb}
|
|
1616
|
+
; return module.exports; })();
|
|
1617
|
+
let buffer = [];
|
|
1618
|
+
let timer = null;
|
|
1619
|
+
const send = (extra) => {
|
|
1620
|
+
clearTimeout(timer);
|
|
1621
|
+
timer = null;
|
|
1622
|
+
if (!buffer.length && !extra) return Promise.resolve();
|
|
1623
|
+
const events = buffer;
|
|
1624
|
+
buffer = [];
|
|
1625
|
+
return window.${BINDING}(JSON.stringify({ events, ...extra })).catch(() => {});
|
|
1626
|
+
};
|
|
1627
|
+
rrweb.record({
|
|
1628
|
+
emit(event) {
|
|
1629
|
+
buffer.push(event);
|
|
1630
|
+
if (!timer) timer = setTimeout(send, ${FLUSH_MS});
|
|
1631
|
+
},
|
|
1632
|
+
maskInputOptions: { password: true },
|
|
1633
|
+
});
|
|
1634
|
+
const crossOrigin = (frame) => { try { return new URL(frame.src, location.href).origin !== location.origin; } catch { return false; } };
|
|
1635
|
+
const checkGaps = () => {
|
|
1636
|
+
const gaps = [];
|
|
1637
|
+
if (document.querySelector('canvas')) gaps.push('canvas');
|
|
1638
|
+
if (document.querySelector('video')) gaps.push('video');
|
|
1639
|
+
if ([...document.querySelectorAll('iframe')].some(crossOrigin)) gaps.push('cross-origin iframe');
|
|
1640
|
+
if (gaps.length) send({ gaps });
|
|
1641
|
+
};
|
|
1642
|
+
window.__tamsenseFlush = () => send();
|
|
1643
|
+
window.__tamsenseMark = (label) => { rrweb.record.addCustomEvent('${MARK_TAG}', { label }); return send(); };
|
|
1644
|
+
addEventListener('pagehide', () => { send(); });
|
|
1645
|
+
addEventListener('load', () => setTimeout(checkGaps, ${GAP_CHECK_MS}));
|
|
1646
|
+
})();`;
|
|
1647
|
+
}
|
|
1648
|
+
function script() {
|
|
1649
|
+
const current = state();
|
|
1650
|
+
current.script ??= pageScript(rrwebSource());
|
|
1651
|
+
return current.script;
|
|
1652
|
+
}
|
|
1653
|
+
var Session = class {
|
|
1654
|
+
file;
|
|
1655
|
+
metaFile;
|
|
1656
|
+
meta;
|
|
1657
|
+
bytes = 0;
|
|
1658
|
+
constructor(dir, recording, url) {
|
|
1659
|
+
const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
|
1660
|
+
this.file = join3(dir, `${id}${REPLAY_SUFFIX}`);
|
|
1661
|
+
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 };
|
|
1663
|
+
writeFileSync(this.file, "");
|
|
1664
|
+
this.save();
|
|
1665
|
+
}
|
|
1666
|
+
save() {
|
|
1667
|
+
writeFileSync(this.metaFile, `${JSON.stringify(this.meta, null, 2)}
|
|
1668
|
+
`);
|
|
1669
|
+
}
|
|
1670
|
+
receive(payload) {
|
|
1671
|
+
let batch;
|
|
1672
|
+
try {
|
|
1673
|
+
batch = JSON.parse(payload);
|
|
1674
|
+
} catch {
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
if (batch.gaps?.length) {
|
|
1678
|
+
this.meta.gaps = [.../* @__PURE__ */ new Set([...this.meta.gaps, ...batch.gaps])];
|
|
1679
|
+
this.save();
|
|
1680
|
+
}
|
|
1681
|
+
const page = batch.events?.find((event) => event.type === RRWEB_META && typeof event.data?.href === "string" && event.data.href !== "about:blank");
|
|
1682
|
+
if (page && this.meta.url === "about:blank") {
|
|
1683
|
+
this.meta.url = String(page.data.href);
|
|
1684
|
+
this.save();
|
|
1685
|
+
}
|
|
1686
|
+
const lines = (batch.events ?? []).map((event) => JSON.stringify(event)).join("\n");
|
|
1687
|
+
if (!lines || this.meta.truncated) {
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
if (this.bytes + lines.length > MAX_SESSION_BYTES) {
|
|
1691
|
+
this.meta.truncated = true;
|
|
1692
|
+
this.save();
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
this.bytes += lines.length + 1;
|
|
1696
|
+
appendFileSync(this.file, `${lines}
|
|
1697
|
+
`);
|
|
1698
|
+
}
|
|
1699
|
+
end() {
|
|
1700
|
+
if (this.meta.endedAt === null) {
|
|
1701
|
+
this.meta.endedAt = Date.now();
|
|
1702
|
+
this.save();
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
summary() {
|
|
1706
|
+
const { title, purpose, video, marks, gaps, blockedTrackerRequests } = this.meta;
|
|
1707
|
+
return { title, purpose, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests };
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
async function startSession(recording, page, injectNow) {
|
|
1711
|
+
const current = state();
|
|
1712
|
+
if (current.pages.has(page)) {
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
const session = new Session(recordingsDir(), recording, page.url());
|
|
1716
|
+
current.pages.set(page, session);
|
|
1717
|
+
recording.sessions.push(session);
|
|
1718
|
+
page.on("close", () => session.end());
|
|
1719
|
+
const video = page.video();
|
|
1720
|
+
if (video) {
|
|
1721
|
+
session.meta.video = await video.path().catch(() => null);
|
|
1722
|
+
session.save();
|
|
1723
|
+
}
|
|
1724
|
+
if (injectNow) {
|
|
1725
|
+
await page.evaluate(script()).catch(() => {
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
async function flush(context) {
|
|
1730
|
+
await Promise.all(context.pages().filter((page) => !page.isClosed()).map(
|
|
1731
|
+
(page) => page.evaluate(() => window.__tamsenseFlush?.(), void 0).catch(() => {
|
|
1732
|
+
})
|
|
1733
|
+
));
|
|
1734
|
+
}
|
|
1735
|
+
async function recordContext(context, caption, automatic = false) {
|
|
1736
|
+
const checked = checkCaption(caption);
|
|
1737
|
+
const current = state();
|
|
1738
|
+
const known = current.contexts.get(context);
|
|
1739
|
+
if (known) {
|
|
1740
|
+
Object.assign(known, { caption: checked, automatic });
|
|
1741
|
+
for (const session of known.sessions) {
|
|
1742
|
+
Object.assign(session.meta, checked, { automatic });
|
|
1743
|
+
session.save();
|
|
1744
|
+
}
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
mkdirSync(recordingsDir(), { recursive: true });
|
|
1748
|
+
const recording = { caption: checked, automatic, sessions: [] };
|
|
1749
|
+
current.contexts.set(context, recording);
|
|
1750
|
+
const origins = trackerOrigins();
|
|
1751
|
+
await context.route((url) => isTrackerIngest(url, origins), async (route) => {
|
|
1752
|
+
try {
|
|
1753
|
+
const session = current.pages.get(route.request().frame().page());
|
|
1754
|
+
if (session) {
|
|
1755
|
+
session.meta.blockedTrackerRequests += 1;
|
|
1756
|
+
session.save();
|
|
1757
|
+
}
|
|
1758
|
+
} catch {
|
|
1759
|
+
}
|
|
1760
|
+
await route.fulfill({ status: 200, contentType: "application/json", body: '{"ok":true}' });
|
|
1761
|
+
});
|
|
1762
|
+
await context.exposeBinding(BINDING, ({ page }, payload) => current.pages.get(page)?.receive(payload));
|
|
1763
|
+
await context.addInitScript({ content: script() });
|
|
1764
|
+
context.on("page", (page) => void startSession(recording, page, false));
|
|
1765
|
+
context.on("close", () => recording.sessions.forEach((session) => session.end()));
|
|
1766
|
+
for (const page of context.pages()) {
|
|
1767
|
+
await startSession(recording, page, true);
|
|
1768
|
+
}
|
|
1769
|
+
const close = context.close.bind(context);
|
|
1770
|
+
context.close = async (options) => {
|
|
1771
|
+
await flush(context);
|
|
1772
|
+
return close(options);
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
function instrument(exports) {
|
|
1776
|
+
const chromium = exports?.chromium;
|
|
1777
|
+
const instrumentation = exports?._instrumentation ?? chromium?._instrumentation;
|
|
1778
|
+
const current = state();
|
|
1779
|
+
if (typeof chromium?.launch !== "function" || typeof instrumentation?.addListener !== "function" || current.instrumented.has(instrumentation)) {
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
current.instrumented.add(instrumentation);
|
|
1783
|
+
instrumentation.addListener({
|
|
1784
|
+
runAfterCreateBrowserContext: async (context) => {
|
|
1785
|
+
try {
|
|
1786
|
+
if (!current.contexts.has(context)) {
|
|
1787
|
+
await recordContext(context, automaticCaption(), true);
|
|
1788
|
+
}
|
|
1789
|
+
} catch (error) {
|
|
1790
|
+
warn(`could not record this browser: ${error instanceof Error ? error.message : String(error)}`);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
function installAutoRecording() {
|
|
1796
|
+
const current = state();
|
|
1797
|
+
if (current.hooked) {
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
current.hooked = true;
|
|
1801
|
+
const loader = Module;
|
|
1802
|
+
const load = loader._load;
|
|
1803
|
+
loader._load = function(...args) {
|
|
1804
|
+
const exports = load.apply(this, args);
|
|
1805
|
+
try {
|
|
1806
|
+
instrument(exports);
|
|
1807
|
+
} catch {
|
|
1808
|
+
}
|
|
1809
|
+
return exports;
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
if (process2.env[RECORDINGS_DIR_ENV]) {
|
|
1813
|
+
installAutoRecording();
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// src/recorderEnv.ts
|
|
1817
|
+
var RECORDER_ENV = "TAMSENSE_RECORDER";
|
|
1818
|
+
function bundledRecorder() {
|
|
1819
|
+
const path = fileURLToPath2(new URL("./recorder.cjs", import.meta.url));
|
|
1820
|
+
return existsSync2(path) ? path : null;
|
|
1821
|
+
}
|
|
1822
|
+
function recordingsFolder(home, runId) {
|
|
1823
|
+
return join4(home, ".tamsense", "recordings", `run-${runId}`);
|
|
1824
|
+
}
|
|
1825
|
+
function nodeOptionsWithRecorder(recorder, nodeOptions) {
|
|
1826
|
+
return [nodeOptions?.trim(), `--require "${recorder.replaceAll("\\", "/")}"`].filter(Boolean).join(" ");
|
|
1827
|
+
}
|
|
1828
|
+
function recorderEnv(input) {
|
|
1829
|
+
const { job } = input;
|
|
1830
|
+
return {
|
|
1831
|
+
[RECORDER_ENV]: input.recorder,
|
|
1832
|
+
[RECORDINGS_DIR_ENV]: input.folder,
|
|
1833
|
+
[SERVER_ENV]: input.server,
|
|
1834
|
+
[DEFAULT_TITLE_ENV]: `${job.stage.name} browser session`.slice(0, MAX_TITLE_LENGTH),
|
|
1835
|
+
[DEFAULT_PURPOSE_ENV]: `Recorded automatically in the ${job.stage.name} step of task #${job.taskId}`.slice(0, MAX_PURPOSE_LENGTH),
|
|
1836
|
+
NODE_OPTIONS: nodeOptionsWithRecorder(input.recorder, input.nodeOptions)
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1498
1840
|
// src/scope.ts
|
|
1499
1841
|
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
1842
|
function teamKeyProblem(isTeam, env) {
|
|
@@ -1565,9 +1907,82 @@ async function sleep(ms, signal) {
|
|
|
1565
1907
|
});
|
|
1566
1908
|
}
|
|
1567
1909
|
|
|
1910
|
+
// src/update.ts
|
|
1911
|
+
import { spawn as spawn3 } from "child_process";
|
|
1912
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
1913
|
+
import process3 from "process";
|
|
1914
|
+
var PACKAGE_NAME = "@tamsensedev/runner";
|
|
1915
|
+
var RELAUNCHED_ENV = "TAMSENSE_RELAUNCHED";
|
|
1916
|
+
var NO_UPDATE_ENV = "TAMSENSE_NO_UPDATE";
|
|
1917
|
+
var REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME.replace("/", "%2f")}/latest`;
|
|
1918
|
+
var REGISTRY_TIMEOUT_MS = 3e3;
|
|
1919
|
+
var RELEASE_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
1920
|
+
function releaseParts(version) {
|
|
1921
|
+
const match = RELEASE_PATTERN.exec(version.trim());
|
|
1922
|
+
return match ? match.slice(1).map(Number) : null;
|
|
1923
|
+
}
|
|
1924
|
+
function isNewer(latest, current) {
|
|
1925
|
+
const next = releaseParts(latest);
|
|
1926
|
+
const now = releaseParts(current);
|
|
1927
|
+
if (!next || !now) {
|
|
1928
|
+
return false;
|
|
1929
|
+
}
|
|
1930
|
+
const index = next.findIndex((part, position) => part !== now[position]);
|
|
1931
|
+
return index !== -1 && next[index] > now[index];
|
|
1932
|
+
}
|
|
1933
|
+
function currentVersion() {
|
|
1934
|
+
try {
|
|
1935
|
+
const manifest = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
|
|
1936
|
+
return manifest.name === PACKAGE_NAME && typeof manifest.version === "string" ? manifest.version : null;
|
|
1937
|
+
} catch {
|
|
1938
|
+
return null;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
async function fetchLatest(url, signal) {
|
|
1942
|
+
const response = await fetch(url, { signal, headers: { accept: "application/json" } });
|
|
1943
|
+
return response.ok ? await response.json() : null;
|
|
1944
|
+
}
|
|
1945
|
+
async function latestVersion(fetchJson = fetchLatest, timeoutMs = REGISTRY_TIMEOUT_MS) {
|
|
1946
|
+
try {
|
|
1947
|
+
const body = await fetchJson(REGISTRY_URL, AbortSignal.timeout(timeoutMs));
|
|
1948
|
+
const version = body && typeof body === "object" ? body.version : null;
|
|
1949
|
+
return typeof version === "string" ? version : null;
|
|
1950
|
+
} catch {
|
|
1951
|
+
return null;
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
async function updateToRun(env, current, latest) {
|
|
1955
|
+
if (!current || env[RELAUNCHED_ENV] || env[NO_UPDATE_ENV]) {
|
|
1956
|
+
return null;
|
|
1957
|
+
}
|
|
1958
|
+
const found = await latest();
|
|
1959
|
+
return found && isNewer(found, current) ? { current, latest: found } : null;
|
|
1960
|
+
}
|
|
1961
|
+
function relaunchArgs(argv) {
|
|
1962
|
+
return ["--yes", `${PACKAGE_NAME}@latest`, ...argv];
|
|
1963
|
+
}
|
|
1964
|
+
function relaunch(argv, env) {
|
|
1965
|
+
const isWindows = process3.platform === "win32";
|
|
1966
|
+
return new Promise((resolve, reject) => {
|
|
1967
|
+
const child = spawn3(isWindows ? "npx.cmd" : "npx", relaunchArgs(argv), { stdio: "inherit", shell: isWindows, env: { ...env, [RELAUNCHED_ENV]: "1" } });
|
|
1968
|
+
const ignoreInterrupt = () => {
|
|
1969
|
+
};
|
|
1970
|
+
process3.on("SIGINT", ignoreInterrupt);
|
|
1971
|
+
child.on("error", (error) => {
|
|
1972
|
+
process3.off("SIGINT", ignoreInterrupt);
|
|
1973
|
+
reject(error);
|
|
1974
|
+
});
|
|
1975
|
+
child.on("close", (code) => {
|
|
1976
|
+
process3.off("SIGINT", ignoreInterrupt);
|
|
1977
|
+
resolve(code ?? 1);
|
|
1978
|
+
});
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1568
1982
|
// src/worker.ts
|
|
1569
|
-
import {
|
|
1570
|
-
import
|
|
1983
|
+
import { rm as rm4 } from "fs/promises";
|
|
1984
|
+
import { dirname as dirname5 } from "path";
|
|
1985
|
+
import process4 from "process";
|
|
1571
1986
|
|
|
1572
1987
|
// src/feedQueue.ts
|
|
1573
1988
|
var MAX_BATCH = 200;
|
|
@@ -1631,43 +2046,269 @@ function keyedLock() {
|
|
|
1631
2046
|
};
|
|
1632
2047
|
}
|
|
1633
2048
|
|
|
2049
|
+
// src/recordings.ts
|
|
2050
|
+
import { randomUUID } from "crypto";
|
|
2051
|
+
import { createReadStream } from "fs";
|
|
2052
|
+
import { readdir, readFile as readFile3, stat, writeFile as writeFile3 } from "fs/promises";
|
|
2053
|
+
import { basename as basename2, dirname as dirname4, extname, isAbsolute, join as join5 } from "path";
|
|
2054
|
+
import { createInterface as createInterface3 } from "readline";
|
|
2055
|
+
import { Readable } from "stream";
|
|
2056
|
+
import { promisify } from "util";
|
|
2057
|
+
import { gzip } from "zlib";
|
|
2058
|
+
var gzipAsync = promisify(gzip);
|
|
2059
|
+
var CHUNK_TARGET_BYTES = 2 * 1024 * 1024;
|
|
2060
|
+
var MAX_LINE_BYTES = 30 * 1024 * 1024;
|
|
2061
|
+
var LEDGER_FILE = "uploaded.json";
|
|
2062
|
+
var RRWEB_FULL_SNAPSHOT = 2;
|
|
2063
|
+
var RRWEB_META2 = 4;
|
|
2064
|
+
var RRWEB_CUSTOM = 5;
|
|
2065
|
+
var VIDEO_TYPES_BY_EXTENSION = {
|
|
2066
|
+
".webm": "video/webm",
|
|
2067
|
+
".mp4": "video/mp4"
|
|
2068
|
+
};
|
|
2069
|
+
function eventOf(line) {
|
|
2070
|
+
try {
|
|
2071
|
+
const event = JSON.parse(line);
|
|
2072
|
+
return typeof event?.type === "number" && typeof event.timestamp === "number" ? event : null;
|
|
2073
|
+
} catch {
|
|
2074
|
+
return null;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
async function* replayChunks(lines, scan, targetBytes = CHUNK_TARGET_BYTES) {
|
|
2078
|
+
let chunk = [];
|
|
2079
|
+
let bytes = 0;
|
|
2080
|
+
let previous = null;
|
|
2081
|
+
for await (const line of lines) {
|
|
2082
|
+
const event = line.trim() ? eventOf(line) : null;
|
|
2083
|
+
if (!event) {
|
|
2084
|
+
continue;
|
|
2085
|
+
}
|
|
2086
|
+
if (line.length > MAX_LINE_BYTES) {
|
|
2087
|
+
throw new Error("One event of this replay is larger than 30 MB, so it cannot be uploaded.");
|
|
2088
|
+
}
|
|
2089
|
+
const startsGroup = event.type === RRWEB_META2 || event.type === RRWEB_FULL_SNAPSHOT && previous?.type !== RRWEB_META2;
|
|
2090
|
+
if (chunk.length > 0 && (startsGroup || bytes + line.length > targetBytes)) {
|
|
2091
|
+
yield `${chunk.join("\n")}
|
|
2092
|
+
`;
|
|
2093
|
+
chunk = [];
|
|
2094
|
+
bytes = 0;
|
|
2095
|
+
}
|
|
2096
|
+
chunk.push(line);
|
|
2097
|
+
bytes += line.length + 1;
|
|
2098
|
+
previous = event;
|
|
2099
|
+
scan.startTs = Math.min(scan.startTs, event.timestamp);
|
|
2100
|
+
scan.snapshot ||= event.type === RRWEB_FULL_SNAPSHOT;
|
|
2101
|
+
if (event.type === RRWEB_CUSTOM && event.data?.tag === MARK_TAG && typeof event.data.payload?.label === "string") {
|
|
2102
|
+
scan.marks.push({ ts: event.timestamp, label: event.data.payload.label });
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
if (chunk.length > 0) {
|
|
2106
|
+
yield `${chunk.join("\n")}
|
|
2107
|
+
`;
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
function marksFrom(marks, startTs) {
|
|
2111
|
+
return marks.map((mark) => ({ at: Math.max(0, mark.ts - startTs), label: mark.label })).sort((a, b) => a.at - b.at);
|
|
2112
|
+
}
|
|
2113
|
+
function sessionIdOf(file) {
|
|
2114
|
+
return basename2(file).replace(REPLAY_SUFFIX, "");
|
|
2115
|
+
}
|
|
2116
|
+
async function readMeta(file) {
|
|
2117
|
+
try {
|
|
2118
|
+
return JSON.parse(await readFile3(file, "utf8"));
|
|
2119
|
+
} catch {
|
|
2120
|
+
return null;
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
async function metaFor(kind, file) {
|
|
2124
|
+
if (kind === "replay") {
|
|
2125
|
+
return readMeta(join5(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
|
|
2126
|
+
}
|
|
2127
|
+
const folder = basename2(dirname4(file)) === "video" ? dirname4(dirname4(file)) : dirname4(file);
|
|
2128
|
+
for (const name of await readdir(folder).catch(() => [])) {
|
|
2129
|
+
if (name.endsWith(META_SUFFIX) && name !== LEDGER_FILE) {
|
|
2130
|
+
const meta = await readMeta(join5(folder, name));
|
|
2131
|
+
if (meta?.video === file) {
|
|
2132
|
+
return meta;
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
return null;
|
|
2137
|
+
}
|
|
2138
|
+
async function ledger(dir) {
|
|
2139
|
+
try {
|
|
2140
|
+
return new Set(JSON.parse(await readFile3(join5(dir, LEDGER_FILE), "utf8")));
|
|
2141
|
+
} catch {
|
|
2142
|
+
return /* @__PURE__ */ new Set();
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
async function remember(dir, file) {
|
|
2146
|
+
const done = await ledger(dir);
|
|
2147
|
+
done.add(file);
|
|
2148
|
+
await writeFile3(join5(dir, LEDGER_FILE), JSON.stringify([...done]));
|
|
2149
|
+
}
|
|
2150
|
+
async function nonEmpty(file) {
|
|
2151
|
+
return file !== null && ((await stat(file).catch(() => null))?.size ?? 0) > 0;
|
|
2152
|
+
}
|
|
2153
|
+
async function pendingRecordings(dir) {
|
|
2154
|
+
const done = await ledger(dir);
|
|
2155
|
+
const names = (await readdir(dir).catch(() => [])).sort();
|
|
2156
|
+
const pending = [];
|
|
2157
|
+
for (const name of names) {
|
|
2158
|
+
if (!name.endsWith(META_SUFFIX) || name === LEDGER_FILE) {
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
2161
|
+
const meta = await readMeta(join5(dir, name));
|
|
2162
|
+
const replay = join5(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
|
|
2163
|
+
if (!done.has(replay) && await nonEmpty(replay)) {
|
|
2164
|
+
pending.push({ kind: "replay", file: replay, meta });
|
|
2165
|
+
}
|
|
2166
|
+
if (meta?.video && !done.has(meta.video) && await nonEmpty(meta.video)) {
|
|
2167
|
+
pending.push({ kind: "video", file: meta.video, meta });
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
return pending;
|
|
2171
|
+
}
|
|
2172
|
+
async function uploadReplay(upload, file, caption) {
|
|
2173
|
+
const id = randomUUID();
|
|
2174
|
+
const scan = { startTs: Number.POSITIVE_INFINITY, snapshot: false, marks: [] };
|
|
2175
|
+
const lines = createInterface3({ input: createReadStream(file, "utf8"), crlfDelay: Infinity });
|
|
2176
|
+
for await (const chunk of replayChunks(lines, scan)) {
|
|
2177
|
+
await upload.api.upload("POST", recordingUploadPath(upload.runId, id, "chunks"), new Uint8Array(await gzipAsync(chunk)), "application/gzip");
|
|
2178
|
+
}
|
|
2179
|
+
if (!scan.snapshot) {
|
|
2180
|
+
throw new Error("This replay has no page snapshot, so it cannot be played. Was the page ever opened?");
|
|
2181
|
+
}
|
|
2182
|
+
return finish(upload, { kind: "replay", upload: id, name: basename2(file), ...caption, contentType: "application/x-ndjson", durationMs: null, marks: marksFrom(scan.marks, scan.startTs) });
|
|
2183
|
+
}
|
|
2184
|
+
async function uploadVideo(upload, file, caption, meta) {
|
|
2185
|
+
const contentType = VIDEO_TYPES_BY_EXTENSION[extname(file).toLowerCase()];
|
|
2186
|
+
const info = await stat(file);
|
|
2187
|
+
if (info.size > MAX_VIDEO_BYTES) {
|
|
2188
|
+
throw new Error(`The video is larger than ${MAX_VIDEO_BYTES / 1024 / 1024} MB. Record a shorter one.`);
|
|
2189
|
+
}
|
|
2190
|
+
const id = randomUUID();
|
|
2191
|
+
const body = Readable.toWeb(createReadStream(file));
|
|
2192
|
+
await upload.api.upload("PUT", recordingUploadPath(upload.runId, id, "video"), body, contentType);
|
|
2193
|
+
const startedAt = meta?.startedAt ?? info.birthtimeMs;
|
|
2194
|
+
const endedAt = meta?.endedAt ?? info.mtimeMs;
|
|
2195
|
+
const durationMs = Math.max(0, Math.round(endedAt - startedAt));
|
|
2196
|
+
const marks = marksFrom(meta?.marks ?? [], startedAt).filter((mark) => mark.at <= durationMs);
|
|
2197
|
+
return finish(upload, { kind: "video", upload: id, name: basename2(file), ...caption, contentType, durationMs, marks });
|
|
2198
|
+
}
|
|
2199
|
+
async function finish(upload, body) {
|
|
2200
|
+
return (await upload.api.request("POST", runPath(upload.runId, "recordings"), body)).id;
|
|
2201
|
+
}
|
|
2202
|
+
async function attachRecording(api, runId, recordingsDir2, request) {
|
|
2203
|
+
if (!isAbsolute(request.path)) {
|
|
2204
|
+
return `Give the absolute path of the ${request.kind === "replay" ? "replay (.rrweb.ndjson)" : "video (.webm or .mp4)"} file.`;
|
|
2205
|
+
}
|
|
2206
|
+
const fitsKind = request.kind === "replay" ? request.path.endsWith(REPLAY_SUFFIX) : extname(request.path).toLowerCase() in VIDEO_TYPES_BY_EXTENSION;
|
|
2207
|
+
if (!fitsKind) {
|
|
2208
|
+
return request.kind === "replay" ? `A replay is the ${REPLAY_SUFFIX} file the TAMsense recorder wrote.` : "Only WebM and MP4 videos can be attached.";
|
|
2209
|
+
}
|
|
2210
|
+
if (!await nonEmpty(request.path)) {
|
|
2211
|
+
return `There is no recording at ${request.path}. Close the browser context first (closeRecorded) so the file is complete.`;
|
|
2212
|
+
}
|
|
2213
|
+
let caption;
|
|
2214
|
+
try {
|
|
2215
|
+
caption = checkCaption(request);
|
|
2216
|
+
} catch (error) {
|
|
2217
|
+
return error instanceof Error ? error.message : String(error);
|
|
2218
|
+
}
|
|
2219
|
+
try {
|
|
2220
|
+
const meta = await metaFor(request.kind, request.path);
|
|
2221
|
+
const upload = { api, runId };
|
|
2222
|
+
await (request.kind === "replay" ? uploadReplay(upload, request.path, caption) : uploadVideo(upload, request.path, caption, meta));
|
|
2223
|
+
await remember(recordingsDir2, request.path);
|
|
2224
|
+
} catch (error) {
|
|
2225
|
+
return `Could not attach the ${request.kind}: ${error instanceof Error ? error.message : String(error)}`;
|
|
2226
|
+
}
|
|
2227
|
+
return "Attached to the task.";
|
|
2228
|
+
}
|
|
2229
|
+
function numbered(key, title, used) {
|
|
2230
|
+
const count = (used.get(key) ?? 0) + 1;
|
|
2231
|
+
used.set(key, count);
|
|
2232
|
+
const suffix = count > 1 ? ` (${count})` : "";
|
|
2233
|
+
return `${title.slice(0, MAX_TITLE_LENGTH - suffix.length)}${suffix}`;
|
|
2234
|
+
}
|
|
2235
|
+
function overLimit(error) {
|
|
2236
|
+
return error instanceof ApiError && error.status === 422 && /at most/i.test(error.message);
|
|
2237
|
+
}
|
|
2238
|
+
async function uploadPendingRecordings(api, runId, dir) {
|
|
2239
|
+
const report = { uploaded: 0, failed: [], skipped: 0 };
|
|
2240
|
+
const used = /* @__PURE__ */ new Map();
|
|
2241
|
+
const full = /* @__PURE__ */ new Set();
|
|
2242
|
+
const upload = { api, runId };
|
|
2243
|
+
const pending = (await pendingRecordings(dir)).sort((a, b) => Number(a.meta?.automatic ?? true) - Number(b.meta?.automatic ?? true));
|
|
2244
|
+
for (const recording of pending) {
|
|
2245
|
+
if (full.has(recording.kind)) {
|
|
2246
|
+
report.skipped += 1;
|
|
2247
|
+
continue;
|
|
2248
|
+
}
|
|
2249
|
+
const title = recording.meta?.title || "Browser session";
|
|
2250
|
+
const caption = checkCaption({
|
|
2251
|
+
title: numbered(`${recording.kind}:${title}`, title, used),
|
|
2252
|
+
purpose: recording.meta?.purpose || "Recorded automatically while the agent used a browser"
|
|
2253
|
+
});
|
|
2254
|
+
try {
|
|
2255
|
+
await (recording.kind === "replay" ? uploadReplay(upload, recording.file, caption) : uploadVideo(upload, recording.file, caption, recording.meta));
|
|
2256
|
+
await remember(dir, recording.file);
|
|
2257
|
+
report.uploaded += 1;
|
|
2258
|
+
} catch (error) {
|
|
2259
|
+
if (overLimit(error)) {
|
|
2260
|
+
full.add(recording.kind);
|
|
2261
|
+
report.skipped += 1;
|
|
2262
|
+
continue;
|
|
2263
|
+
}
|
|
2264
|
+
report.failed.push(`${caption.title} (${recording.kind}): ${error instanceof Error ? error.message : String(error)}`);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
return report;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
1634
2270
|
// src/screenshot.ts
|
|
1635
|
-
import { readFile as
|
|
1636
|
-
import { basename, extname, isAbsolute } from "path";
|
|
2271
|
+
import { readFile as readFile4, stat as stat2 } from "fs/promises";
|
|
2272
|
+
import { basename as basename3, extname as extname2, isAbsolute as isAbsolute2 } from "path";
|
|
1637
2273
|
var TYPES_BY_EXTENSION = {
|
|
1638
2274
|
".png": "image/png",
|
|
1639
2275
|
".jpg": "image/jpeg",
|
|
1640
2276
|
".jpeg": "image/jpeg",
|
|
1641
2277
|
".webp": "image/webp"
|
|
1642
2278
|
};
|
|
1643
|
-
var MAX_CAPTION_LENGTH = 300;
|
|
1644
2279
|
async function readScreenshot(request) {
|
|
1645
|
-
if (!
|
|
2280
|
+
if (!isAbsolute2(request.path)) {
|
|
1646
2281
|
return "Give the absolute path of the image file.";
|
|
1647
2282
|
}
|
|
1648
|
-
const contentType = TYPES_BY_EXTENSION[
|
|
2283
|
+
const contentType = TYPES_BY_EXTENSION[extname2(request.path).toLowerCase()];
|
|
1649
2284
|
if (!contentType) {
|
|
1650
2285
|
return "Only PNG, JPEG and WebP images can be attached.";
|
|
1651
2286
|
}
|
|
1652
|
-
|
|
2287
|
+
let caption;
|
|
2288
|
+
try {
|
|
2289
|
+
caption = checkCaption(request);
|
|
2290
|
+
} catch (error) {
|
|
2291
|
+
return error instanceof Error ? error.message : String(error);
|
|
2292
|
+
}
|
|
2293
|
+
const info = await stat2(request.path).catch(() => null);
|
|
1653
2294
|
if (!info?.isFile()) {
|
|
1654
2295
|
return `There is no file at ${request.path}.`;
|
|
1655
2296
|
}
|
|
1656
2297
|
if (info.size > MAX_ATTACHMENT_BYTES) {
|
|
1657
2298
|
return "The image is larger than 5 MB. Take a smaller screenshot (viewport instead of full page, or JPEG).";
|
|
1658
2299
|
}
|
|
1659
|
-
const data = (await
|
|
1660
|
-
return { name:
|
|
2300
|
+
const data = (await readFile4(request.path)).toString("base64");
|
|
2301
|
+
return { name: basename3(request.path), ...caption, contentType, data };
|
|
1661
2302
|
}
|
|
1662
2303
|
|
|
1663
2304
|
// src/workspace.ts
|
|
1664
|
-
import { existsSync as
|
|
2305
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1665
2306
|
import { rm as rm3 } from "fs/promises";
|
|
1666
|
-
import { join as
|
|
2307
|
+
import { join as join6 } from "path";
|
|
1667
2308
|
|
|
1668
2309
|
// src/git.ts
|
|
1669
2310
|
import { Buffer } from "buffer";
|
|
1670
|
-
import { spawn as
|
|
2311
|
+
import { spawn as spawn4 } from "child_process";
|
|
1671
2312
|
var GITHUB_ORIGIN = "https://github.com/";
|
|
1672
2313
|
function githubAuthEnv(token) {
|
|
1673
2314
|
const credentials = Buffer.from(`x-access-token:${token}`).toString("base64");
|
|
@@ -1681,7 +2322,7 @@ function githubAuthEnv(token) {
|
|
|
1681
2322
|
function runGit(args, options) {
|
|
1682
2323
|
const env = { ...process.env, ...options.env, ...options.token ? githubAuthEnv(options.token) : {} };
|
|
1683
2324
|
return new Promise((resolve, reject) => {
|
|
1684
|
-
const child =
|
|
2325
|
+
const child = spawn4("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
1685
2326
|
let stdout = "";
|
|
1686
2327
|
let stderr = "";
|
|
1687
2328
|
child.stdout.on("data", (chunk) => {
|
|
@@ -1709,7 +2350,7 @@ function githubRemoteUrl(fullName) {
|
|
|
1709
2350
|
return `https://github.com/${fullName}.git`;
|
|
1710
2351
|
}
|
|
1711
2352
|
function tamsenseFolder(home, ...parts) {
|
|
1712
|
-
return
|
|
2353
|
+
return join6(home, ".tamsense", ...parts);
|
|
1713
2354
|
}
|
|
1714
2355
|
function folderNames(repositories) {
|
|
1715
2356
|
const shortName = (fullName) => fullName.split("/").pop() ?? fullName;
|
|
@@ -1720,7 +2361,7 @@ function folderNames(repositories) {
|
|
|
1720
2361
|
return repositories.map((repository) => counts.get(shortName(repository.fullName)) === 1 ? shortName(repository.fullName) : repository.fullName.replace("/", "__"));
|
|
1721
2362
|
}
|
|
1722
2363
|
async function refreshCache(cache, repository, token, tools) {
|
|
1723
|
-
if (
|
|
2364
|
+
if (existsSync3(join6(cache, ".git"))) {
|
|
1724
2365
|
await git(["fetch", "--prune", "origin"], { cwd: cache, token });
|
|
1725
2366
|
return;
|
|
1726
2367
|
}
|
|
@@ -1742,7 +2383,7 @@ async function prepareWorkspace(job, token, tools) {
|
|
|
1742
2383
|
for (const [index, repository] of job.repositories.entries()) {
|
|
1743
2384
|
const cache = tamsenseFolder(tools.home, "repos", repository.fullName.replace("/", "__"));
|
|
1744
2385
|
const folder = folders[index] ?? repository.fullName;
|
|
1745
|
-
const path =
|
|
2386
|
+
const path = join6(root, folder);
|
|
1746
2387
|
const baseSha = await cacheLock.run(repository.fullName, async () => {
|
|
1747
2388
|
await refreshCache(cache, repository, token, tools);
|
|
1748
2389
|
await removeWorktree(cache, path);
|
|
@@ -1930,11 +2571,14 @@ async function executeJob(job, tools, signal, input, record) {
|
|
|
1930
2571
|
tools.print("Claude is working\u2026");
|
|
1931
2572
|
const attached = { count: 0 };
|
|
1932
2573
|
const session = sessionReporter(tools.api, job, tools.print);
|
|
2574
|
+
const recordings = recordingsFolder(tools.home, job.runId);
|
|
2575
|
+
const baseEnv = withNodeOnPath(process4.env, dirname5(process4.execPath));
|
|
2576
|
+
const recording = tools.recorder ? recorderEnv({ recorder: tools.recorder, folder: recordings, server: tools.api.server, job, nodeOptions: baseEnv.NODE_OPTIONS }) : {};
|
|
1933
2577
|
const outcome = await tools.runAgent({
|
|
1934
2578
|
cwd: workspace.root,
|
|
1935
|
-
prompt: buildTaskPrompt(job, workspace, { browser: Boolean(tools.browser) }),
|
|
2579
|
+
prompt: buildTaskPrompt(job, workspace, { browser: Boolean(tools.browser), recorder: Boolean(tools.recorder) }),
|
|
1936
2580
|
input,
|
|
1937
|
-
env: { ...
|
|
2581
|
+
env: { ...baseEnv, ...tools.browser ? browserEnv(tools.browser) : {}, ...recording, ...identity },
|
|
1938
2582
|
signal,
|
|
1939
2583
|
wantsVerdict: isCheck,
|
|
1940
2584
|
specWork: specWorkTools2(job, tools.api),
|
|
@@ -1944,7 +2588,7 @@ async function executeJob(job, tools, signal, input, record) {
|
|
|
1944
2588
|
effort: job.effort,
|
|
1945
2589
|
onFeed: record,
|
|
1946
2590
|
onSession: session.report,
|
|
1947
|
-
attach: (
|
|
2591
|
+
attach: (request) => request.kind === "screenshot" ? attachScreenshot(tools.api, job, request, attached) : attachRecording(tools.api, job.runId, recordings, request)
|
|
1948
2592
|
});
|
|
1949
2593
|
await session.sent();
|
|
1950
2594
|
signal.throwIfAborted();
|
|
@@ -1968,20 +2612,37 @@ function clockOf(iso) {
|
|
|
1968
2612
|
const time = new Date(iso);
|
|
1969
2613
|
return `${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
|
|
1970
2614
|
}
|
|
1971
|
-
function resultLine(
|
|
1972
|
-
if (
|
|
1973
|
-
return `Paused: ${USAGE_LIMIT_REPORT}, resumes at ${clockOf(
|
|
2615
|
+
function resultLine(finish2) {
|
|
2616
|
+
if (finish2.pausedUntil) {
|
|
2617
|
+
return `Paused: ${USAGE_LIMIT_REPORT}, resumes at ${clockOf(finish2.pausedUntil)}`;
|
|
1974
2618
|
}
|
|
1975
|
-
if (
|
|
1976
|
-
return
|
|
2619
|
+
if (finish2.verdict) {
|
|
2620
|
+
return finish2.verdict.passed ? "Check passed." : `Check did not pass: ${finish2.verdict.report}`;
|
|
1977
2621
|
}
|
|
1978
|
-
if (
|
|
1979
|
-
return
|
|
2622
|
+
if (finish2.questions) {
|
|
2623
|
+
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.";
|
|
1980
2624
|
}
|
|
1981
|
-
if (
|
|
2625
|
+
if (finish2.spec) {
|
|
1982
2626
|
return "Done. The spec waits for the owner on TAMsense.";
|
|
1983
2627
|
}
|
|
1984
|
-
return
|
|
2628
|
+
return finish2.outcome === "succeeded" ? `Done. Pushed ${finish2.pushed.length} branch(es), the pull requests open on TAMsense.` : `Failed: ${finish2.report}`;
|
|
2629
|
+
}
|
|
2630
|
+
async function sendRecordings(job, tools) {
|
|
2631
|
+
const folder = recordingsFolder(tools.home, job.runId);
|
|
2632
|
+
try {
|
|
2633
|
+
const report = await uploadPendingRecordings(tools.api, job.runId, folder);
|
|
2634
|
+
if (report.uploaded > 0) {
|
|
2635
|
+
tools.print(`Attached ${report.uploaded} browser recording(s) to the task.`);
|
|
2636
|
+
}
|
|
2637
|
+
if (report.skipped > 0) {
|
|
2638
|
+
tools.print(`${report.skipped} more recording(s) were not attached: the run reached its limit.`);
|
|
2639
|
+
}
|
|
2640
|
+
for (const line of report.failed) {
|
|
2641
|
+
tools.print(`Could not attach a recording: ${line}`);
|
|
2642
|
+
}
|
|
2643
|
+
} finally {
|
|
2644
|
+
await rm4(folder, { recursive: true, force: true });
|
|
2645
|
+
}
|
|
1985
2646
|
}
|
|
1986
2647
|
function failureReport(error, signal) {
|
|
1987
2648
|
return signal.reason === STOPPED_HERE ? "The run was stopped on the computer." : `The run stopped on the computer: ${messageOf(error)}`;
|
|
@@ -2002,11 +2663,11 @@ async function runJob(job, tools, stop) {
|
|
|
2002
2663
|
run2.signal.addEventListener("abort", input.close, { once: true });
|
|
2003
2664
|
void messages.poll();
|
|
2004
2665
|
const polling = setInterval(() => void messages.poll(), INBOX_INTERVAL_MS);
|
|
2005
|
-
let
|
|
2666
|
+
let finish2;
|
|
2006
2667
|
try {
|
|
2007
|
-
|
|
2668
|
+
finish2 = await executeJob(job, tools, run2.signal, input, record);
|
|
2008
2669
|
} catch (error) {
|
|
2009
|
-
|
|
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 };
|
|
2010
2671
|
} finally {
|
|
2011
2672
|
clearInterval(polling);
|
|
2012
2673
|
clearInterval(feeding);
|
|
@@ -2019,12 +2680,14 @@ async function runJob(job, tools, stop) {
|
|
|
2019
2680
|
}
|
|
2020
2681
|
await feed.flush();
|
|
2021
2682
|
if (run2.signal.reason === CLOSED_ON_SITE) {
|
|
2683
|
+
await rm4(recordingsFolder(tools.home, job.runId), { recursive: true, force: true });
|
|
2022
2684
|
tools.print("TAMsense closed this run while it was in progress. The result was not sent.");
|
|
2023
2685
|
return;
|
|
2024
2686
|
}
|
|
2687
|
+
await sendRecordings(job, tools).catch((error) => tools.print(`Could not attach the browser recordings: ${messageOf(error)}`));
|
|
2025
2688
|
try {
|
|
2026
|
-
await tools.api.request("POST", runPath(job.runId, "finish"),
|
|
2027
|
-
tools.print(resultLine(
|
|
2689
|
+
await tools.api.request("POST", runPath(job.runId, "finish"), finish2);
|
|
2690
|
+
tools.print(resultLine(finish2));
|
|
2028
2691
|
} catch (error) {
|
|
2029
2692
|
tools.print(`Could not send the result to TAMsense: ${messageOf(error)}`);
|
|
2030
2693
|
}
|
|
@@ -2103,11 +2766,11 @@ var NO_TASKS_ACCESS_TEXT = "Your account no longer has access to Tasks. Ask the
|
|
|
2103
2766
|
var ACCESS_PROBLEMS = { 401: DISCONNECTED_TEXT, 403: NO_TASKS_ACCESS_TEXT };
|
|
2104
2767
|
var INTERRUPTED_EXIT_CODE = 130;
|
|
2105
2768
|
function print(line) {
|
|
2106
|
-
|
|
2769
|
+
process5.stdout.write(`${line}
|
|
2107
2770
|
`);
|
|
2108
2771
|
}
|
|
2109
2772
|
function serverFor(flag, saved) {
|
|
2110
|
-
return serverOrigin(flag ??
|
|
2773
|
+
return serverOrigin(flag ?? process5.env.TAMSENSE_URL ?? saved?.server ?? DEFAULT_SERVER);
|
|
2111
2774
|
}
|
|
2112
2775
|
function accessProblem(error) {
|
|
2113
2776
|
return error instanceof ApiError ? ACCESS_PROBLEMS[error.status] ?? null : null;
|
|
@@ -2134,19 +2797,19 @@ async function runLogin(server, deviceName) {
|
|
|
2134
2797
|
}
|
|
2135
2798
|
function stopOnInterrupt() {
|
|
2136
2799
|
const stop = new AbortController();
|
|
2137
|
-
|
|
2800
|
+
process5.once("SIGINT", () => {
|
|
2138
2801
|
print("Stopping\u2026 Press Ctrl+C again to quit at once.");
|
|
2139
2802
|
stop.abort();
|
|
2140
|
-
|
|
2803
|
+
process5.once("SIGINT", () => process5.exit(INTERRUPTED_EXIT_CODE));
|
|
2141
2804
|
});
|
|
2142
2805
|
return stop.signal;
|
|
2143
2806
|
}
|
|
2144
2807
|
function installTools(home) {
|
|
2145
|
-
return { home, nodePath:
|
|
2808
|
+
return { home, nodePath: process5.execPath, env: process5.env, print, run: runProcess, exists: existsSync4 };
|
|
2146
2809
|
}
|
|
2147
2810
|
async function browserForStart(home, saved) {
|
|
2148
2811
|
return await installedBrowser(home) ?? await offerBrowser({
|
|
2149
|
-
interactive: Boolean(
|
|
2812
|
+
interactive: Boolean(process5.stdin.isTTY && process5.stdout.isTTY),
|
|
2150
2813
|
declined: saved.browserDeclined === true,
|
|
2151
2814
|
print,
|
|
2152
2815
|
ask: askYesNo,
|
|
@@ -2154,7 +2817,24 @@ async function browserForStart(home, saved) {
|
|
|
2154
2817
|
rememberDecline: async () => writeConfig({ ...saved, browserDeclined: true })
|
|
2155
2818
|
});
|
|
2156
2819
|
}
|
|
2820
|
+
async function startedNewVersion() {
|
|
2821
|
+
const update = await updateToRun(process5.env, currentVersion(), () => latestVersion());
|
|
2822
|
+
if (!update) {
|
|
2823
|
+
return false;
|
|
2824
|
+
}
|
|
2825
|
+
print(`TAMsense runner ${update.latest} is out, this is ${update.current}. Starting the new version...`);
|
|
2826
|
+
try {
|
|
2827
|
+
process5.exitCode = await relaunch(process5.argv.slice(2), process5.env);
|
|
2828
|
+
return true;
|
|
2829
|
+
} catch {
|
|
2830
|
+
print("Could not start the new version. Going on with this one.");
|
|
2831
|
+
return false;
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2157
2834
|
async function runStart(parallel) {
|
|
2835
|
+
if (await startedNewVersion()) {
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2158
2838
|
const saved = await readConfig();
|
|
2159
2839
|
if (!saved) {
|
|
2160
2840
|
print(NOT_CONNECTED_TEXT);
|
|
@@ -2162,14 +2842,14 @@ async function runStart(parallel) {
|
|
|
2162
2842
|
}
|
|
2163
2843
|
const api = createApi(saved.server, saved.token);
|
|
2164
2844
|
const identity = await api.request("GET", IDENTITY_PATH);
|
|
2165
|
-
const problem = teamKeyProblem(identity.device.team,
|
|
2845
|
+
const problem = teamKeyProblem(identity.device.team, process5.env);
|
|
2166
2846
|
if (problem) {
|
|
2167
2847
|
throw new Error(problem);
|
|
2168
2848
|
}
|
|
2169
2849
|
print(`${identity.device.name} is connected to ${saved.server} as ${describeDevice(identity)}.`);
|
|
2170
2850
|
const home = homedir3();
|
|
2171
2851
|
const browser = await browserForStart(home, saved);
|
|
2172
|
-
await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser }, stopOnInterrupt(), parallel);
|
|
2852
|
+
await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder() }, stopOnInterrupt(), parallel);
|
|
2173
2853
|
}
|
|
2174
2854
|
async function runBrowser(remove) {
|
|
2175
2855
|
const home = homedir3();
|
|
@@ -2225,10 +2905,10 @@ async function run(command) {
|
|
|
2225
2905
|
}
|
|
2226
2906
|
}
|
|
2227
2907
|
try {
|
|
2228
|
-
await run(parseCommand(
|
|
2908
|
+
await run(parseCommand(process5.argv.slice(2), process5.env));
|
|
2229
2909
|
} catch (error) {
|
|
2230
|
-
|
|
2910
|
+
process5.stderr.write(`${accessProblem(error) ?? (error instanceof Error ? error.message : String(error))}
|
|
2231
2911
|
`);
|
|
2232
|
-
|
|
2912
|
+
process5.exitCode = 1;
|
|
2233
2913
|
}
|
|
2234
2914
|
//# sourceMappingURL=cli.js.map
|