@tamsensedev/runner 0.10.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/dist/cli.js +790 -257
- package/dist/cli.js.map +1 -1
- package/dist/recorder.cjs +42 -6
- package/dist/recorder.cjs.map +1 -1
- package/library/agents/ui-tester.md +1 -1
- package/library/skills/ui-testing/SKILL.md +1 -1
- package/library/skills/ui-testing/references/headless-check.md +17 -2
- package/library/skills/ui-testing/references/recording.md +6 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,136 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { existsSync as
|
|
4
|
+
import { existsSync as existsSync8 } from "fs";
|
|
5
5
|
import { homedir as homedir3 } from "os";
|
|
6
6
|
import process7 from "process";
|
|
7
7
|
|
|
8
|
+
// src/api.ts
|
|
9
|
+
import { setTimeout as wait } from "timers/promises";
|
|
10
|
+
|
|
11
|
+
// src/connection.ts
|
|
12
|
+
var BUSY_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
|
|
13
|
+
var REFUSED_STATUSES = /* @__PURE__ */ new Set([429, 503]);
|
|
14
|
+
var NOT_FOUND_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "EAI_AGAIN"]);
|
|
15
|
+
var NO_CONNECTION_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH", "ENETDOWN", "UND_ERR_CONNECT_TIMEOUT"]);
|
|
16
|
+
var CLOSED_CODES = /* @__PURE__ */ new Set(["ECONNRESET", "EPIPE", "ECONNABORTED", "UND_ERR_SOCKET", "UND_ERR_CLOSED"]);
|
|
17
|
+
var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
|
|
18
|
+
var CERTIFICATE_CODE = /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|ERR_TLS/;
|
|
19
|
+
function codeOf(error) {
|
|
20
|
+
for (let current = error, depth = 0; current && typeof current === "object" && depth < 5; depth += 1) {
|
|
21
|
+
const fields = current;
|
|
22
|
+
if (typeof fields.code === "string") {
|
|
23
|
+
return fields.code;
|
|
24
|
+
}
|
|
25
|
+
current = fields.cause ?? (Array.isArray(fields.errors) ? fields.errors[0] : null);
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function isTimeout(error) {
|
|
30
|
+
return error instanceof Error && error.name === "TimeoutError";
|
|
31
|
+
}
|
|
32
|
+
function networkFailure(error, server, timeoutMs) {
|
|
33
|
+
const host = new URL(server).host;
|
|
34
|
+
const code = codeOf(error);
|
|
35
|
+
if (isTimeout(error)) {
|
|
36
|
+
return { kind: "timeout", text: `TAMsense did not answer in ${Math.round(timeoutMs / 1e3)} seconds`, unsent: false, waitMs: null };
|
|
37
|
+
}
|
|
38
|
+
if (code && TIMEOUT_CODES.has(code)) {
|
|
39
|
+
return { kind: "timeout", text: `TAMsense did not answer in time (${code})`, unsent: false, waitMs: null };
|
|
40
|
+
}
|
|
41
|
+
if (code && NOT_FOUND_CODES.has(code)) {
|
|
42
|
+
return { kind: "no_address", text: `Could not find ${host} (${code}). Check the internet connection`, unsent: true, waitMs: null };
|
|
43
|
+
}
|
|
44
|
+
if (code && NO_CONNECTION_CODES.has(code)) {
|
|
45
|
+
return { kind: "no_connection", text: `Could not connect to ${host} (${code})`, unsent: true, waitMs: null };
|
|
46
|
+
}
|
|
47
|
+
if (code && CLOSED_CODES.has(code)) {
|
|
48
|
+
return { kind: "closed", text: `The network closed the connection to TAMsense (${code})`, unsent: false, waitMs: null };
|
|
49
|
+
}
|
|
50
|
+
if (code && CERTIFICATE_CODE.test(code)) {
|
|
51
|
+
return { kind: "certificate", text: `The secure connection to ${host} failed (${code}). A proxy or antivirus may be in the way`, unsent: true, waitMs: null };
|
|
52
|
+
}
|
|
53
|
+
return { kind: "other", text: `Could not reach ${server}${code ? ` (${code})` : ""}. Check your internet connection`, unsent: false, waitMs: null };
|
|
54
|
+
}
|
|
55
|
+
function retryAfterMs(header, now) {
|
|
56
|
+
if (!header) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const seconds = Number(header);
|
|
60
|
+
const at = Number.isFinite(seconds) ? now + seconds * 1e3 : Date.parse(header);
|
|
61
|
+
return Number.isFinite(at) ? Math.max(0, at - now) : null;
|
|
62
|
+
}
|
|
63
|
+
function busyFailure(response, now = Date.now()) {
|
|
64
|
+
return {
|
|
65
|
+
kind: "busy",
|
|
66
|
+
text: `TAMsense is busy (${response.status})`,
|
|
67
|
+
unsent: REFUSED_STATUSES.has(response.status),
|
|
68
|
+
waitMs: retryAfterMs(response.headers.get("retry-after"), now)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function backoffMs(failures, baseMs, capMs, random = Math.random) {
|
|
72
|
+
const ceiling = Math.min(capMs, baseMs * 2 ** Math.max(0, failures - 1));
|
|
73
|
+
return Math.round(ceiling / 2 + random() * ceiling / 2);
|
|
74
|
+
}
|
|
75
|
+
function durationText(ms) {
|
|
76
|
+
const seconds = Math.round(ms / 1e3);
|
|
77
|
+
if (seconds < 60) {
|
|
78
|
+
return `${seconds} s`;
|
|
79
|
+
}
|
|
80
|
+
const minutes = Math.round(seconds / 60);
|
|
81
|
+
return minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
|
|
82
|
+
}
|
|
83
|
+
function connectionWatch(print2, now = Date.now) {
|
|
84
|
+
let lostAt = null;
|
|
85
|
+
let failures = 0;
|
|
86
|
+
let told = /* @__PURE__ */ new Set();
|
|
87
|
+
let reconnect = new AbortController();
|
|
88
|
+
return {
|
|
89
|
+
failed(failure) {
|
|
90
|
+
lostAt ??= now();
|
|
91
|
+
failures += 1;
|
|
92
|
+
if (!told.has(failure.kind)) {
|
|
93
|
+
told.add(failure.kind);
|
|
94
|
+
print2(`${failure.text}. Trying again.`);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
reached() {
|
|
98
|
+
if (lostAt === null) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
print2(`Connected to TAMsense again after ${durationText(now() - lostAt)}, ${failures} request(s) failed meanwhile.`);
|
|
102
|
+
lostAt = null;
|
|
103
|
+
failures = 0;
|
|
104
|
+
told = /* @__PURE__ */ new Set();
|
|
105
|
+
reconnect.abort();
|
|
106
|
+
reconnect = new AbortController();
|
|
107
|
+
},
|
|
108
|
+
nextReconnect: () => reconnect.signal
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
8
112
|
// src/api.ts
|
|
9
113
|
var DEFAULT_SERVER = "https://my.tamsense.com";
|
|
114
|
+
var REPEATABLE_TIMEOUT_MS = 3e4;
|
|
115
|
+
var ACTION_TIMEOUT_MS = 2 * 6e4;
|
|
116
|
+
var UPLOAD_TIMEOUT_MS = 15 * 6e4;
|
|
117
|
+
var ATTEMPTS = 3;
|
|
118
|
+
var RETRY_BASE_MS = 1e3;
|
|
119
|
+
var RETRY_CAP_MS = 1e4;
|
|
120
|
+
var REPEATABLE_METHODS = /* @__PURE__ */ new Set(["GET", "PUT", "DELETE"]);
|
|
121
|
+
var ConnectionError = class extends Error {
|
|
122
|
+
kind;
|
|
123
|
+
reported;
|
|
124
|
+
constructor(failure, reported) {
|
|
125
|
+
super(failure.text);
|
|
126
|
+
this.name = "ConnectionError";
|
|
127
|
+
this.kind = failure.kind;
|
|
128
|
+
this.reported = reported;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
function alreadyReported(error) {
|
|
132
|
+
return error instanceof ConnectionError && error.reported;
|
|
133
|
+
}
|
|
10
134
|
var ApiError = class extends Error {
|
|
11
135
|
status;
|
|
12
136
|
code;
|
|
@@ -33,32 +157,84 @@ function textOf(value) {
|
|
|
33
157
|
return typeof value === "string" && value.trim() ? value : null;
|
|
34
158
|
}
|
|
35
159
|
async function errorOf(response) {
|
|
36
|
-
const
|
|
37
|
-
const code = textOf(
|
|
38
|
-
const message = textOf(
|
|
160
|
+
const reply4 = await response.json().catch(() => ({}));
|
|
161
|
+
const code = textOf(reply4.data?.code);
|
|
162
|
+
const message = textOf(reply4.message) ?? textOf(reply4.statusMessage) ?? `TAMsense answered with status ${response.status}`;
|
|
39
163
|
return new ApiError(response.status, code, message);
|
|
40
164
|
}
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
165
|
+
function isFailure(outcome) {
|
|
166
|
+
return "kind" in outcome;
|
|
167
|
+
}
|
|
168
|
+
function createApi(server, token = null, options = {}) {
|
|
169
|
+
const { fetch: fetchImpl = fetch, watch, timeoutMs = REPEATABLE_TIMEOUT_MS, actionTimeoutMs = ACTION_TIMEOUT_MS, sleep: sleep2 = (ms) => wait(ms) } = options;
|
|
170
|
+
async function once(sending) {
|
|
171
|
+
const { method, path, headers, body } = sending;
|
|
47
172
|
let response;
|
|
48
173
|
try {
|
|
49
|
-
response = await fetchImpl(new URL(path, server), { method, headers
|
|
174
|
+
response = await fetchImpl(new URL(path, server), { method, headers, body, signal: AbortSignal.timeout(sending.timeoutMs), ...body instanceof ReadableStream ? { duplex: "half" } : {} });
|
|
50
175
|
} catch (error) {
|
|
51
|
-
|
|
176
|
+
return networkFailure(error, server, sending.timeoutMs);
|
|
52
177
|
}
|
|
178
|
+
if (BUSY_STATUSES.has(response.status)) {
|
|
179
|
+
await response.body?.cancel().catch(() => {
|
|
180
|
+
});
|
|
181
|
+
return busyFailure(response);
|
|
182
|
+
}
|
|
183
|
+
watch?.reached();
|
|
53
184
|
if (!response.ok) {
|
|
54
185
|
throw await errorOf(response);
|
|
55
186
|
}
|
|
56
|
-
|
|
187
|
+
try {
|
|
188
|
+
return { reply: await response.json() };
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof SyntaxError) {
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
return networkFailure(error, server, sending.timeoutMs);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function send(sending) {
|
|
197
|
+
const headers = { accept: "application/json", ...sending.headers };
|
|
198
|
+
if (token) {
|
|
199
|
+
headers.authorization = `Bearer ${token}`;
|
|
200
|
+
}
|
|
201
|
+
for (let attempt2 = 1; ; attempt2 += 1) {
|
|
202
|
+
const outcome = await once({ ...sending, headers });
|
|
203
|
+
if (!isFailure(outcome)) {
|
|
204
|
+
return outcome.reply;
|
|
205
|
+
}
|
|
206
|
+
if (attempt2 >= sending.attempts || !(outcome.unsent || sending.safeToRepeat)) {
|
|
207
|
+
watch?.failed(outcome);
|
|
208
|
+
throw new ConnectionError(outcome, Boolean(watch));
|
|
209
|
+
}
|
|
210
|
+
await sleep2(outcome.waitMs === null ? backoffMs(attempt2, RETRY_BASE_MS, RETRY_CAP_MS) : Math.min(outcome.waitMs, RETRY_CAP_MS));
|
|
211
|
+
}
|
|
57
212
|
}
|
|
58
213
|
return {
|
|
59
214
|
server,
|
|
60
|
-
request: (method, path, body
|
|
61
|
-
|
|
215
|
+
request: (method, path, body, requestOptions = {}) => {
|
|
216
|
+
const safeToRepeat = REPEATABLE_METHODS.has(method) || Boolean(requestOptions.safeToRepeat);
|
|
217
|
+
return send({
|
|
218
|
+
method,
|
|
219
|
+
path,
|
|
220
|
+
headers: body ? { "content-type": "application/json" } : {},
|
|
221
|
+
body: body && JSON.stringify(body),
|
|
222
|
+
attempts: ATTEMPTS,
|
|
223
|
+
timeoutMs: safeToRepeat ? timeoutMs : actionTimeoutMs,
|
|
224
|
+
safeToRepeat
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
// поток файла не перечитать, поэтому отправка файла не повторяется
|
|
228
|
+
upload: (method, path, body, contentType) => send({
|
|
229
|
+
method,
|
|
230
|
+
path,
|
|
231
|
+
headers: { "content-type": contentType },
|
|
232
|
+
body,
|
|
233
|
+
attempts: 1,
|
|
234
|
+
timeoutMs: UPLOAD_TIMEOUT_MS,
|
|
235
|
+
safeToRepeat: false
|
|
236
|
+
}),
|
|
237
|
+
...watch ? { nextReconnect: watch.nextReconnect } : {}
|
|
62
238
|
};
|
|
63
239
|
}
|
|
64
240
|
|
|
@@ -80,7 +256,8 @@ var HELP_TEXT = `Connects this computer to TAMsense so it can work on your tasks
|
|
|
80
256
|
|
|
81
257
|
Usage:
|
|
82
258
|
${LOGIN_COMMAND} connect this computer
|
|
83
|
-
${START_COMMAND} take your tasks and work on them until you press Ctrl+C
|
|
259
|
+
${START_COMMAND} take your tasks and work on them until you press Ctrl+C;
|
|
260
|
+
run it from your code folder (or the folder above it) and it finds the code there
|
|
84
261
|
${BROWSER_COMMAND} install the headless browser for interface checks now, or show it
|
|
85
262
|
${BROWSER_COMMAND} remove delete that browser
|
|
86
263
|
npx @tamsensedev/runner@latest status show the connection
|
|
@@ -340,8 +517,8 @@ async function probeCapabilities(stop) {
|
|
|
340
517
|
}
|
|
341
518
|
|
|
342
519
|
// src/claude.ts
|
|
343
|
-
import { createSdkMcpServer, query as
|
|
344
|
-
import { z as
|
|
520
|
+
import { createSdkMcpServer as createSdkMcpServer2, query as query3, tool as tool5 } from "@anthropic-ai/claude-agent-sdk";
|
|
521
|
+
import { z as z5 } from "zod";
|
|
345
522
|
|
|
346
523
|
// src/askPerson.ts
|
|
347
524
|
var MAX_ASK_TRIES = 5;
|
|
@@ -401,21 +578,21 @@ function shorten(text2) {
|
|
|
401
578
|
const line = text2.replace(/\s+/g, " ").trim();
|
|
402
579
|
return line.length > MAX_ACTIVITY_LENGTH ? `${line.slice(0, MAX_ACTIVITY_LENGTH - 1)}\u2026` : line;
|
|
403
580
|
}
|
|
404
|
-
function activityLine(
|
|
581
|
+
function activityLine(tool6, input) {
|
|
405
582
|
const fields = typeof input === "object" && input !== null ? input : {};
|
|
406
|
-
if (
|
|
583
|
+
if (tool6 === "Bash" && typeof fields.command === "string") {
|
|
407
584
|
return `$ ${shorten(fields.command)}`;
|
|
408
585
|
}
|
|
409
|
-
if (FILE_TOOLS.has(
|
|
410
|
-
return `${
|
|
586
|
+
if (FILE_TOOLS.has(tool6) && typeof fields.file_path === "string") {
|
|
587
|
+
return `${tool6} ${shorten(fields.file_path)}`;
|
|
411
588
|
}
|
|
412
|
-
if (
|
|
589
|
+
if (tool6 === "Skill" && typeof fields.skill === "string") {
|
|
413
590
|
return `Skill ${shorten(fields.skill)}`;
|
|
414
591
|
}
|
|
415
|
-
if (HELPER_TOOLS.has(
|
|
592
|
+
if (HELPER_TOOLS.has(tool6) && typeof fields.subagent_type === "string") {
|
|
416
593
|
return `Agent ${shorten(fields.subagent_type)}`;
|
|
417
594
|
}
|
|
418
|
-
return
|
|
595
|
+
return tool6;
|
|
419
596
|
}
|
|
420
597
|
function usageOf(modelUsage) {
|
|
421
598
|
const entries = Object.values(modelUsage);
|
|
@@ -450,6 +627,7 @@ var PAIRING_TOKEN_PATH = "/api/runner/pairings/token";
|
|
|
450
627
|
var IDENTITY_PATH = "/api/runner/me";
|
|
451
628
|
var NEXT_RUN_PATH = "/api/runner/runs/next";
|
|
452
629
|
var CAPABILITIES_PATH = "/api/runner/me/capabilities";
|
|
630
|
+
var FOUND_FOLDERS_PATH = "/api/runner/me/folders";
|
|
453
631
|
var FOLDER_CHECKS_PATH = "/api/runner/folders/checks";
|
|
454
632
|
function folderCheckPath(folderId) {
|
|
455
633
|
return `/api/runner/folders/${folderId}`;
|
|
@@ -469,6 +647,9 @@ function permissionPath(runId, permissionId) {
|
|
|
469
647
|
function recordingUploadPath(runId, upload, part) {
|
|
470
648
|
return `/api/runner/runs/${runId}/recordings/${upload}/${part}`;
|
|
471
649
|
}
|
|
650
|
+
function checkPath(checkId, action) {
|
|
651
|
+
return `/api/runner/checks/${checkId}/${action}`;
|
|
652
|
+
}
|
|
472
653
|
|
|
473
654
|
// src/feed.ts
|
|
474
655
|
var MAX_TEXT_LENGTH = 8e3;
|
|
@@ -489,29 +670,29 @@ function textField(fields, name) {
|
|
|
489
670
|
return typeof value === "string" && value.trim() ? value : null;
|
|
490
671
|
}
|
|
491
672
|
function toolDraft(block, parent) {
|
|
492
|
-
const
|
|
673
|
+
const tool6 = (block.name ?? "tool").replace(MCP_PREFIX, "");
|
|
493
674
|
const fields = fieldsOf(block.input);
|
|
494
|
-
const base = { tool:
|
|
495
|
-
if (
|
|
675
|
+
const base = { tool: tool6, ref: block.id ?? null, parent, failed: false };
|
|
676
|
+
if (tool6 === "Skill" && textField(fields, "skill")) {
|
|
496
677
|
return { ...base, kind: "skill", text: clip(textField(fields, "skill"), MAX_SUMMARY_LENGTH) };
|
|
497
678
|
}
|
|
498
|
-
if (HELPER_TOOLS2.has(
|
|
679
|
+
if (HELPER_TOOLS2.has(tool6)) {
|
|
499
680
|
const who = textField(fields, "subagent_type") ?? "general-purpose";
|
|
500
681
|
const what = textField(fields, "description");
|
|
501
682
|
return { ...base, kind: "agent", text: clip(what ? `${who}: ${what}` : who, MAX_SUMMARY_LENGTH) };
|
|
502
683
|
}
|
|
503
|
-
if (
|
|
684
|
+
if (tool6 === "submit_verdict") {
|
|
504
685
|
const verdict = fields.couldNotRun === true ? "Could not run" : fields.passed === true ? "Passed" : "Did not pass";
|
|
505
686
|
return { ...base, kind: "tool", text: clip(`${verdict}. ${textField(fields, "report") ?? ""}`, MAX_SUMMARY_LENGTH) };
|
|
506
687
|
}
|
|
507
|
-
if (
|
|
688
|
+
if (tool6 === "hand_off") {
|
|
508
689
|
return { ...base, kind: "tool", text: clip(`Hand off to ${textField(fields, "to") ?? "?"}: ${textField(fields, "note") ?? ""}`, MAX_SUMMARY_LENGTH) };
|
|
509
690
|
}
|
|
510
|
-
if (
|
|
511
|
-
return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[
|
|
691
|
+
if (tool6 in ATTACH_LABELS) {
|
|
692
|
+
return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[tool6]}: ${textField(fields, "title") ?? textField(fields, "path") ?? ""}`, MAX_SUMMARY_LENGTH) };
|
|
512
693
|
}
|
|
513
694
|
const command = textField(fields, "command");
|
|
514
|
-
const summary =
|
|
695
|
+
const summary = tool6 === "Bash" && command ? `$ ${command}` : activityLine(tool6, block.input);
|
|
515
696
|
return { ...base, kind: "tool", text: clip(summary, MAX_SUMMARY_LENGTH) };
|
|
516
697
|
}
|
|
517
698
|
function resultText(content) {
|
|
@@ -1050,8 +1231,8 @@ function messageOf2(error) {
|
|
|
1050
1231
|
async function decisionOf(tools, first, signal) {
|
|
1051
1232
|
const now = tools.now ?? Date.now;
|
|
1052
1233
|
const giveUpAt = Date.parse(first.waitUntil) + SITE_SILENCE_MS;
|
|
1053
|
-
let
|
|
1054
|
-
while (
|
|
1234
|
+
let reply4 = first;
|
|
1235
|
+
while (reply4.decision === "wait") {
|
|
1055
1236
|
await tools.sleep(CHECK_INTERVAL_MS, signal);
|
|
1056
1237
|
if (signal.aborted) {
|
|
1057
1238
|
return STOPPED_TEXT2;
|
|
@@ -1059,9 +1240,9 @@ async function decisionOf(tools, first, signal) {
|
|
|
1059
1240
|
if (now() > giveUpAt) {
|
|
1060
1241
|
return SITE_SILENT_TEXT;
|
|
1061
1242
|
}
|
|
1062
|
-
|
|
1243
|
+
reply4 = await tools.check(reply4.id).catch(() => reply4);
|
|
1063
1244
|
}
|
|
1064
|
-
return
|
|
1245
|
+
return reply4;
|
|
1065
1246
|
}
|
|
1066
1247
|
function askPermission(tools, lastWords, folder) {
|
|
1067
1248
|
return async (toolName, input, options) => {
|
|
@@ -1082,11 +1263,11 @@ function askPermission(tools, lastWords, folder) {
|
|
|
1082
1263
|
} catch (error) {
|
|
1083
1264
|
return deny(`Could not ask for permission on TAMsense: ${messageOf2(error)}. Do it another way, or explain in your report why it is needed.`);
|
|
1084
1265
|
}
|
|
1085
|
-
const
|
|
1086
|
-
if (typeof
|
|
1087
|
-
return deny(
|
|
1266
|
+
const reply4 = await decisionOf(tools, first, options.signal);
|
|
1267
|
+
if (typeof reply4 === "string") {
|
|
1268
|
+
return deny(reply4);
|
|
1088
1269
|
}
|
|
1089
|
-
return
|
|
1270
|
+
return reply4.decision === "allow" ? { behavior: "allow", updatedInput: input } : deny(reply4.message);
|
|
1090
1271
|
};
|
|
1091
1272
|
}
|
|
1092
1273
|
var denyWhenAsked = async () => deny(NOBODY_TO_ASK);
|
|
@@ -1097,6 +1278,7 @@ import { join as join4, resolve as resolve3 } from "path";
|
|
|
1097
1278
|
|
|
1098
1279
|
// src/folders.ts
|
|
1099
1280
|
import { existsSync as existsSync2, realpathSync, statSync } from "fs";
|
|
1281
|
+
import { readdir } from "fs/promises";
|
|
1100
1282
|
import { basename, isAbsolute, join as join2, relative, resolve } from "path";
|
|
1101
1283
|
import process3 from "process";
|
|
1102
1284
|
|
|
@@ -1173,9 +1355,12 @@ function remoteWithoutSecrets(url) {
|
|
|
1173
1355
|
return url;
|
|
1174
1356
|
}
|
|
1175
1357
|
}
|
|
1176
|
-
function
|
|
1358
|
+
function remoteRepository(remoteUrl) {
|
|
1177
1359
|
const parts = remoteUrl && NETWORK_REMOTE.test(remoteUrl) ? remoteUrl.replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).filter(Boolean) : [];
|
|
1178
|
-
return parts.length >= 3 ? `${parts.at(-2)}/${parts.at(-1)}` :
|
|
1360
|
+
return parts.length >= 3 ? `${parts.at(-2)}/${parts.at(-1)}` : null;
|
|
1361
|
+
}
|
|
1362
|
+
function repositoryName(remoteUrl, folder) {
|
|
1363
|
+
return remoteRepository(remoteUrl) ?? basename(folder);
|
|
1179
1364
|
}
|
|
1180
1365
|
async function remoteOf(folder) {
|
|
1181
1366
|
const remotes = (await gitText(["remote"], folder))?.split("\n").map((line) => line.trim()).filter(Boolean) ?? [];
|
|
@@ -1242,7 +1427,7 @@ async function checkFolders(tools, stop) {
|
|
|
1242
1427
|
let complained = false;
|
|
1243
1428
|
while (!stop.aborted) {
|
|
1244
1429
|
try {
|
|
1245
|
-
const { checks } = await tools.api.request("POST", FOLDER_CHECKS_PATH);
|
|
1430
|
+
const { checks } = await tools.api.request("POST", FOLDER_CHECKS_PATH, void 0, { safeToRepeat: true });
|
|
1246
1431
|
for (const check of checks) {
|
|
1247
1432
|
const report = await tools.inspect(check.path).catch((error) => problem(error instanceof Error ? error.message : String(error)));
|
|
1248
1433
|
await tools.api.request("POST", folderCheckPath(check.id), report);
|
|
@@ -1253,7 +1438,7 @@ async function checkFolders(tools, stop) {
|
|
|
1253
1438
|
if (error instanceof ApiError && error.status === 404) {
|
|
1254
1439
|
return;
|
|
1255
1440
|
}
|
|
1256
|
-
if (!complained && !stop.aborted) {
|
|
1441
|
+
if (!complained && !stop.aborted && !alreadyReported(error)) {
|
|
1257
1442
|
tools.print(`Could not check the folders for TAMsense: ${error instanceof Error ? error.message : String(error)}`);
|
|
1258
1443
|
complained = true;
|
|
1259
1444
|
}
|
|
@@ -1261,6 +1446,44 @@ async function checkFolders(tools, stop) {
|
|
|
1261
1446
|
await tools.sleep(CHECKS_INTERVAL_MS, stop);
|
|
1262
1447
|
}
|
|
1263
1448
|
}
|
|
1449
|
+
var SKIPPED_FOLDER = "node_modules";
|
|
1450
|
+
var MORE_CODE_TEXT = "To add more code, start the program from your code folder or the folder above it, or add a path in Board settings > Code.";
|
|
1451
|
+
async function childRepositories(start) {
|
|
1452
|
+
const entries = await readdir(start, { withFileTypes: true }).catch(() => []);
|
|
1453
|
+
return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== SKIPPED_FOLDER).map((entry) => join2(start, entry.name)).filter((folder) => existsSync2(join2(folder, ".git"))).sort();
|
|
1454
|
+
}
|
|
1455
|
+
async function findCodeFolders(start, home) {
|
|
1456
|
+
const folder = resolve(start);
|
|
1457
|
+
if (!existsSync2(folder)) {
|
|
1458
|
+
return [];
|
|
1459
|
+
}
|
|
1460
|
+
const top = await gitText(["rev-parse", "--show-toplevel"], folder);
|
|
1461
|
+
const candidates = top && samePath(top, folder) ? [folder] : await childRepositories(folder);
|
|
1462
|
+
const found = [];
|
|
1463
|
+
for (const candidate of candidates) {
|
|
1464
|
+
const report = await inspectFolder(candidate, home);
|
|
1465
|
+
if (report.ok) {
|
|
1466
|
+
const { currentBranch, defaultBranch, remoteUrl } = report;
|
|
1467
|
+
found.push({ path: candidate, name: basename(candidate), repository: remoteRepository(remoteUrl), currentBranch, defaultBranch, remoteUrl });
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
return found;
|
|
1471
|
+
}
|
|
1472
|
+
function foundCodeLines(start, found) {
|
|
1473
|
+
const lines = found.length > 0 ? found.map((folder) => `Found code: ${folder.name} (${folder.path})`) : [`No code found in ${resolve(start)}.`];
|
|
1474
|
+
return [...lines, MORE_CODE_TEXT];
|
|
1475
|
+
}
|
|
1476
|
+
async function reportFoundFolders(tools, start) {
|
|
1477
|
+
try {
|
|
1478
|
+
const found = await findCodeFolders(start, tools.home);
|
|
1479
|
+
foundCodeLines(start, found).forEach((line) => tools.print(line));
|
|
1480
|
+
await tools.api.request("PUT", FOUND_FOLDERS_PATH, { folders: found });
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
if (!(error instanceof ApiError && error.status === 404)) {
|
|
1483
|
+
tools.print(`Could not tell TAMsense about the code on this computer: ${error instanceof Error ? error.message : String(error)}`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1264
1487
|
|
|
1265
1488
|
// src/lock.ts
|
|
1266
1489
|
function keyedLock() {
|
|
@@ -1785,6 +2008,180 @@ function handOverTool(tools) {
|
|
|
1785
2008
|
);
|
|
1786
2009
|
}
|
|
1787
2010
|
|
|
2011
|
+
// src/schedules.ts
|
|
2012
|
+
import { existsSync as existsSync5 } from "fs";
|
|
2013
|
+
import { mkdir as mkdir3 } from "fs/promises";
|
|
2014
|
+
import { createSdkMcpServer, query as query2, tool as tool3 } from "@anthropic-ai/claude-agent-sdk";
|
|
2015
|
+
import { z as z3 } from "zod";
|
|
2016
|
+
var PROPOSE_SCHEDULE_TOOL_NAME = "propose_schedule";
|
|
2017
|
+
var REPORT_CHECK_TOOL_NAME = "report_check";
|
|
2018
|
+
var SCHEDULE_RULE = `Some waits are long: a local stand or a database copy that takes half an hour to come up, a slow build or import, a service that answers only after a while, logs that show a problem only over hours. Do not sleep or poll in a loop for that. Propose a schedule with the ${PROPOSE_SCHEDULE_TOOL_NAME} tool: a short check repeated on a timer by a cheap model that reads the notes of earlier runs and reports one line in the task chat. Give it a clear condition that ends it, the longest interval that still helps, and when to call a person. With wait true this run ends its turn and continues here when the schedule ends. Never use a schedule for work you can finish now, for a check you can do once, or to watch something "just in case". Without autopilot a person starts it first; on autopilot it starts at once within the project limits.`;
|
|
2019
|
+
var ALREADY_WAITING_TEXT2 = "This run already waits for something. End your turn now: the conversation continues when it comes.";
|
|
2020
|
+
function reply2(text2, isError = false) {
|
|
2021
|
+
return { content: [{ type: "text", text: text2 }], ...isError ? { isError: true } : {} };
|
|
2022
|
+
}
|
|
2023
|
+
function proposeScheduleTool(tools, waiting) {
|
|
2024
|
+
return tool3(
|
|
2025
|
+
PROPOSE_SCHEDULE_TOOL_NAME,
|
|
2026
|
+
"Repeat a short check on a timer while the task waits for something slow. Each run is a short cheap run that reads the notes of earlier runs and reports one line in the task chat; it ends when its condition comes, its time runs out or it made its runs.",
|
|
2027
|
+
{
|
|
2028
|
+
title: z3.string().describe('a short name, at most 80 characters: "Wait for the local stand"'),
|
|
2029
|
+
instruction: z3.string().describe("what each run does, in plain words, as a note to yourself: what to look at and what counts as new"),
|
|
2030
|
+
everyMinutes: z3.number().int().nullable().describe("repeat every N minutes (60 for every hour); null when dailyAt is set"),
|
|
2031
|
+
dailyAt: z3.string().nullable().describe("repeat every day at this time, HH:MM in the project time zone; null when everyMinutes is set"),
|
|
2032
|
+
days: z3.array(z3.number().int().min(0).max(6)).describe("with dailyAt: days of the week, 0 is Sunday; empty means every day"),
|
|
2033
|
+
until: z3.string().describe('the condition each run checks, for example "the stand answers on localhost:3100"; when it comes, the schedule ends. Empty when it simply runs for its time'),
|
|
2034
|
+
forMinutes: z3.number().int().nullable().describe("how long it runs at most, in minutes; null takes the longest the project allows"),
|
|
2035
|
+
maxRuns: z3.number().int().nullable().describe("the number of runs at most, or null"),
|
|
2036
|
+
where: z3.enum(["computer", "cloud"]).describe("computer: this computer, free, skipped while it sleeps; cloud: any time, for credits"),
|
|
2037
|
+
callWhen: z3.string().describe('when to call a person, in plain words: "a container keeps restarting"'),
|
|
2038
|
+
callOnTimeout: z3.boolean().describe("true: call a person when the condition did not come in time"),
|
|
2039
|
+
keepAfterStep: z3.boolean().describe("true only when it must keep running after the task leaves this step"),
|
|
2040
|
+
wait: z3.boolean().describe("true: this run ends its turn now and continues here when the schedule ends")
|
|
2041
|
+
},
|
|
2042
|
+
async (args) => {
|
|
2043
|
+
if (args.wait && (waiting.waitingFor !== null || waiting.waitingForSchedule !== null)) {
|
|
2044
|
+
return reply2(ALREADY_WAITING_TEXT2, true);
|
|
2045
|
+
}
|
|
2046
|
+
let answer;
|
|
2047
|
+
try {
|
|
2048
|
+
answer = await tools.send({ ...args, title: args.title.trim(), instruction: args.instruction.trim(), until: args.until.trim(), callWhen: args.callWhen.trim(), model: null });
|
|
2049
|
+
} catch (error) {
|
|
2050
|
+
return reply2(`Could not send the schedule: ${error instanceof Error ? error.message : String(error)}. Wait another way or say so in your report.`, true);
|
|
2051
|
+
}
|
|
2052
|
+
if (answer.id === null) {
|
|
2053
|
+
return reply2(answer.text, true);
|
|
2054
|
+
}
|
|
2055
|
+
if (answer.waits) {
|
|
2056
|
+
waiting.waitingForSchedule = answer.id;
|
|
2057
|
+
}
|
|
2058
|
+
return reply2(answer.text);
|
|
2059
|
+
}
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
function scheduleTools(api, path) {
|
|
2063
|
+
return { send: (body) => api.request("POST", path, body) };
|
|
2064
|
+
}
|
|
2065
|
+
async function checkFolder(home, job) {
|
|
2066
|
+
if (job.taskId !== null) {
|
|
2067
|
+
const step = taskFolder(home, { taskId: job.taskId, stage: { columnId: job.stepColumnId } });
|
|
2068
|
+
if (existsSync5(step)) {
|
|
2069
|
+
return step;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
const own = tamsenseFolder(home, "schedules", `schedule-${job.scheduleId}`);
|
|
2073
|
+
await mkdir3(own, { recursive: true });
|
|
2074
|
+
return own;
|
|
2075
|
+
}
|
|
2076
|
+
var CHECK_RULES = [
|
|
2077
|
+
"You run one check of a TAMsense schedule on the user's own computer: a short check repeated on a timer while a task waits for something. Nobody answers during the run.",
|
|
2078
|
+
"You only look. Never change, fix, restart, stop or delete anything, never commit, and never send anything to people. Reading commands are fine: listing containers, requesting a local address, reading logs, git log.",
|
|
2079
|
+
"This is the person's own computer. Never touch programs you did not start, and stop the processes you started yourself by their process id before you finish.",
|
|
2080
|
+
"Keep it short: a few commands at most. If you cannot check something, say so in the summary instead of guessing.",
|
|
2081
|
+
`Finish by calling ${REPORT_CHECK_TOOL_NAME} exactly once: summary is one short line for the task chat with the numbers that matter; changed is true only when something differs from the last note; note is the full facts only when something changed; conditionMet is true when the condition of the schedule came; callPerson is true when the schedule says to call a person for what you saw.`,
|
|
2082
|
+
"Write in the language of the task. No em dashes and no en dashes."
|
|
2083
|
+
];
|
|
2084
|
+
function noteLine(note) {
|
|
2085
|
+
const head = `- ${note.at.slice(0, 16).replace("T", " ")} (${note.outcome}): ${note.summary}`;
|
|
2086
|
+
return note.note ? `${head}
|
|
2087
|
+
${note.note.replace(/\n/g, "\n ")}` : head;
|
|
2088
|
+
}
|
|
2089
|
+
function buildCheckPrompt(job, folder) {
|
|
2090
|
+
const prompt = [
|
|
2091
|
+
`Schedule "${job.title}", run ${job.runNumber}${job.taskId === null ? "" : `, on TAMsense task #${job.taskId}: ${job.taskTitle}`}`,
|
|
2092
|
+
`What to check on each run:
|
|
2093
|
+
${job.instruction}`,
|
|
2094
|
+
job.until ? `The schedule ends when: ${job.until}` : "The schedule runs for its time; there is no condition to wait for.",
|
|
2095
|
+
job.callWhen ? `Call a person when: ${job.callWhen}` : "Call a person only when something is clearly wrong.",
|
|
2096
|
+
job.earlier.length > 0 ? `Notes of the earlier runs, the newest last:
|
|
2097
|
+
${job.earlier.map(noteLine).join("\n")}` : "This is the first run: there are no earlier notes."
|
|
2098
|
+
].join("\n\n");
|
|
2099
|
+
const instructions = [
|
|
2100
|
+
CHECK_RULES.join("\n"),
|
|
2101
|
+
workplaceText(job.computer, folder),
|
|
2102
|
+
job.safetyRules.trim() || null
|
|
2103
|
+
].filter(Boolean).join("\n\n");
|
|
2104
|
+
return { prompt, instructions };
|
|
2105
|
+
}
|
|
2106
|
+
var SERVER_NAME = "tamsense";
|
|
2107
|
+
var MAX_CHECK_TURNS = 30;
|
|
2108
|
+
function reportCheckTool(onReport) {
|
|
2109
|
+
return tool3(
|
|
2110
|
+
REPORT_CHECK_TOOL_NAME,
|
|
2111
|
+
"Hand in the result of this check. Call it exactly once, at the very end.",
|
|
2112
|
+
{
|
|
2113
|
+
changed: z3.boolean().describe("true when something changed since the earlier runs"),
|
|
2114
|
+
summary: z3.string().describe("one short line for the task chat: what you saw"),
|
|
2115
|
+
note: z3.string().nullable().describe("when something changed: the full facts, the exact lines and what they mean; null otherwise"),
|
|
2116
|
+
conditionMet: z3.boolean().describe("true when the condition of the schedule came"),
|
|
2117
|
+
callPerson: z3.boolean().describe("true when a person must look now, as the schedule says")
|
|
2118
|
+
},
|
|
2119
|
+
async (args) => {
|
|
2120
|
+
onReport({ changed: args.changed, summary: args.summary.trim(), note: args.note?.trim() || null, conditionMet: args.conditionMet, callPerson: args.callPerson });
|
|
2121
|
+
return reply2("Result received. You can stop now.");
|
|
2122
|
+
}
|
|
2123
|
+
);
|
|
2124
|
+
}
|
|
2125
|
+
function claudeCheckRunner(startQuery) {
|
|
2126
|
+
return async (request) => {
|
|
2127
|
+
request.signal.throwIfAborted();
|
|
2128
|
+
const abortController = new AbortController();
|
|
2129
|
+
request.signal.addEventListener("abort", () => abortController.abort(), { once: true });
|
|
2130
|
+
let result = null;
|
|
2131
|
+
const report = reportCheckTool((found) => {
|
|
2132
|
+
result = found;
|
|
2133
|
+
});
|
|
2134
|
+
const messages = startQuery({
|
|
2135
|
+
prompt: request.prompt.prompt,
|
|
2136
|
+
options: {
|
|
2137
|
+
cwd: request.cwd,
|
|
2138
|
+
systemPrompt: { type: "preset", preset: "claude_code", append: request.prompt.instructions },
|
|
2139
|
+
settingSources: ["user", "project", "local"],
|
|
2140
|
+
permissionMode: "auto",
|
|
2141
|
+
canUseTool: denyWhenAsked,
|
|
2142
|
+
mcpServers: { [SERVER_NAME]: createSdkMcpServer({ name: SERVER_NAME, version: "1.0.0", tools: [report] }) },
|
|
2143
|
+
allowedTools: [`mcp__${SERVER_NAME}__${REPORT_CHECK_TOOL_NAME}`],
|
|
2144
|
+
maxTurns: MAX_CHECK_TURNS,
|
|
2145
|
+
abortController,
|
|
2146
|
+
env: request.env,
|
|
2147
|
+
...request.model === null ? {} : { model: request.model }
|
|
2148
|
+
}
|
|
2149
|
+
});
|
|
2150
|
+
let outcome = { completed: false, report: "Claude finished without a result.", model: null, usage: null, result: null };
|
|
2151
|
+
for await (const message of messages) {
|
|
2152
|
+
if (message.type === "result") {
|
|
2153
|
+
const finished = outcomeOf(message, "");
|
|
2154
|
+
outcome = { completed: finished.completed, report: finished.report, model: finished.model, usage: finished.usage, result: null };
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
return { ...outcome, result };
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
function checkHeartbeat(api, job, abort) {
|
|
2161
|
+
return async () => {
|
|
2162
|
+
try {
|
|
2163
|
+
const answer = await api.request("POST", checkPath(job.checkId, "heartbeat"), void 0, { safeToRepeat: true });
|
|
2164
|
+
if (!answer.keepGoing) {
|
|
2165
|
+
abort();
|
|
2166
|
+
}
|
|
2167
|
+
} catch {
|
|
2168
|
+
}
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
function checkFinishBody(outcome) {
|
|
2172
|
+
return { outcome: outcome.completed && outcome.result ? "succeeded" : "failed", report: outcome.report, model: outcome.model, usage: outcome.usage, result: outcome.result };
|
|
2173
|
+
}
|
|
2174
|
+
function checkLine(body) {
|
|
2175
|
+
if (!body.result) {
|
|
2176
|
+
return `Check failed: ${body.report}`;
|
|
2177
|
+
}
|
|
2178
|
+
if (body.result.conditionMet) {
|
|
2179
|
+
return `Check: ${body.result.summary}. The condition came, the schedule ends.`;
|
|
2180
|
+
}
|
|
2181
|
+
return `Check: ${body.result.summary}`;
|
|
2182
|
+
}
|
|
2183
|
+
var runClaudeCheck = claudeCheckRunner(query2);
|
|
2184
|
+
|
|
1788
2185
|
// src/specText.ts
|
|
1789
2186
|
function isRecord(value) {
|
|
1790
2187
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -1915,7 +2312,7 @@ var READ_ONLY_CODE_RULES = [
|
|
|
1915
2312
|
"Check what you report with the project's own tools where they exist, and say how you checked it."
|
|
1916
2313
|
];
|
|
1917
2314
|
var READ_ONLY_REPORT_RULE = "End with a short report: what you found, how you checked it, and what is left open.";
|
|
1918
|
-
var
|
|
2315
|
+
var CHECK_RULES2 = [
|
|
1919
2316
|
"The work to check is on the current branch. See it with git diff against the base branch named for each repository below, and read the code around it.",
|
|
1920
2317
|
"Do not change, commit or push product code. You may run anything and create throwaway files outside the repositories.",
|
|
1921
2318
|
"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.",
|
|
@@ -1923,7 +2320,7 @@ var CHECK_RULES = [
|
|
|
1923
2320
|
];
|
|
1924
2321
|
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.`;
|
|
1925
2322
|
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".';
|
|
1926
|
-
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.`;
|
|
2323
|
+
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, { notes }), where notes say in your own words what you tested in that browser and what happened: what you opened, what you clicked or typed, what you expected, and whether it passed or failed (at most 600 characters). People watching the replay read them first. 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.`;
|
|
1927
2324
|
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.";
|
|
1928
2325
|
var FOLDER_RULE = "This board works without code: there are no repositories. The current folder belongs to this task: work in it and keep your throwaway files here.";
|
|
1929
2326
|
var RESULT_HOME_RULE = "The result lives only on the TAMsense task: never save it in outside services such as Google Drive or a shared folder. Use connected services only to read. Sending anything to real people, such as an email or a message, is an action that needs approval like any other.";
|
|
@@ -2213,6 +2610,7 @@ function buildFolderPrompt(job, workspace, options) {
|
|
|
2213
2610
|
job.handoffNote && `Note from the previous step: ${job.handoffNote}`,
|
|
2214
2611
|
job.review && `Notes to address:
|
|
2215
2612
|
${job.review}`,
|
|
2613
|
+
job.scheduleNote,
|
|
2216
2614
|
teamMessagesText(job.messages)
|
|
2217
2615
|
].filter(Boolean).join("\n\n");
|
|
2218
2616
|
const rules = isCheck ? FOLDER_CHECK_RULES : [...FOLDER_DO_RULES, ...job.review ? [FOLDER_REWORK_RULE] : [], FOLDER_REPORT_RULE];
|
|
@@ -2232,6 +2630,7 @@ ${bulletList(rules)}`,
|
|
|
2232
2630
|
peopleText(people),
|
|
2233
2631
|
isCheck ? null : handOverText(job.handOverBoards),
|
|
2234
2632
|
handoffText(job.allowedHandoffs, isCheck),
|
|
2633
|
+
job.canSchedule && SCHEDULE_RULE,
|
|
2235
2634
|
options.browser && BROWSER_RULE
|
|
2236
2635
|
].filter(Boolean).join("\n\n");
|
|
2237
2636
|
return { prompt, instructions };
|
|
@@ -2258,11 +2657,12 @@ function buildTaskPrompt(job, workspace, options = {}) {
|
|
|
2258
2657
|
job.handoffNote && `Note from the previous step: ${job.handoffNote}`,
|
|
2259
2658
|
job.review && `Review comments to address:
|
|
2260
2659
|
${job.review}`,
|
|
2660
|
+
job.scheduleNote,
|
|
2261
2661
|
teamMessagesText(job.messages)
|
|
2262
2662
|
].filter(Boolean).join("\n\n");
|
|
2263
2663
|
const evidence = [SCREENSHOT_RULE, ...options.recorder ? [RECORDING_RULE] : [], NAMING_RULE];
|
|
2264
2664
|
const localApp = localAppRule(job, isCheck, canAsk);
|
|
2265
|
-
const rules = isCheck ? [...
|
|
2665
|
+
const rules = isCheck ? [...CHECK_RULES2, DEPENDENCIES_RULE, localApp, ...evidence] : readOnly ? [...READ_ONLY_CODE_RULES, DEPENDENCIES_RULE, localApp, ...evidence, READ_ONLY_REPORT_RULE] : [...codeRules(job), ...job.review ? [REWORK_RULE] : [], DEPENDENCIES_RULE, localApp, ...evidence, CODE_REPORT_RULE];
|
|
2266
2666
|
const instructions = [
|
|
2267
2667
|
canAsk ? ASKING_RULE : UNATTENDED_RULE,
|
|
2268
2668
|
MACHINE_RULE,
|
|
@@ -2280,141 +2680,142 @@ ${bulletList(rules)}`,
|
|
|
2280
2680
|
APPROVAL_RULE,
|
|
2281
2681
|
peopleText(people),
|
|
2282
2682
|
handoffText(job.allowedHandoffs, isCheck),
|
|
2683
|
+
job.canSchedule && SCHEDULE_RULE,
|
|
2283
2684
|
options.browser && BROWSER_RULE
|
|
2284
2685
|
].filter(Boolean).join("\n\n");
|
|
2285
2686
|
return { prompt, instructions };
|
|
2286
2687
|
}
|
|
2287
2688
|
|
|
2288
2689
|
// src/specTools.ts
|
|
2289
|
-
import { tool as
|
|
2290
|
-
import { z as
|
|
2690
|
+
import { tool as tool4 } from "@anthropic-ai/claude-agent-sdk";
|
|
2691
|
+
import { z as z4 } from "zod";
|
|
2291
2692
|
var MAX_FIRST_ROUND = 4;
|
|
2292
|
-
var questionSchema =
|
|
2293
|
-
id:
|
|
2294
|
-
question:
|
|
2295
|
-
whyItMatters:
|
|
2296
|
-
options:
|
|
2297
|
-
label:
|
|
2298
|
-
description:
|
|
2299
|
-
recommended:
|
|
2693
|
+
var questionSchema = z4.object({
|
|
2694
|
+
id: z4.string().describe('Stable snake_case id, unique in the task, for example "empty_state"'),
|
|
2695
|
+
question: z4.string().describe("One plain sentence the owner can answer without reading code"),
|
|
2696
|
+
whyItMatters: z4.string().describe("One line: what changes in the result depending on the answer"),
|
|
2697
|
+
options: z4.array(z4.object({
|
|
2698
|
+
label: z4.string().describe('Short, concrete answer (1-6 words), without "(Recommended)": use the recommended flag'),
|
|
2699
|
+
description: z4.string().describe("One sentence: what happens if this option is chosen"),
|
|
2700
|
+
recommended: z4.boolean().describe("true on the option you recommend (in a multiSelect question, on every option you recommend). Recommended options come first")
|
|
2300
2701
|
})).min(2).max(4).describe('2-4 mutually exclusive options grounded in the code and data. The UI always adds a free-text "Other" field, so never add "Other" yourself'),
|
|
2301
|
-
multiSelect:
|
|
2702
|
+
multiSelect: z4.boolean().describe("true only when the options are additive and several can apply together")
|
|
2302
2703
|
});
|
|
2303
|
-
var briefSchema =
|
|
2304
|
-
findings:
|
|
2305
|
-
fact:
|
|
2306
|
-
where:
|
|
2704
|
+
var briefSchema = z4.object({
|
|
2705
|
+
findings: z4.array(z4.object({
|
|
2706
|
+
fact: z4.string().describe("What the code does today that matters for this task"),
|
|
2707
|
+
where: z4.string().describe("Where it is: owner/repo path:line")
|
|
2307
2708
|
})).describe("Findings from the code"),
|
|
2308
|
-
dataFacts:
|
|
2309
|
-
approach:
|
|
2310
|
-
codeMap:
|
|
2311
|
-
repo:
|
|
2312
|
-
file:
|
|
2313
|
-
role:
|
|
2709
|
+
dataFacts: z4.array(z4.string()).describe("Numbers and facts from the events or the business data, each with its source. Empty when the task needs no data"),
|
|
2710
|
+
approach: z4.string().describe("The approach you would take, in 2-4 sentences"),
|
|
2711
|
+
codeMap: z4.array(z4.object({
|
|
2712
|
+
repo: z4.string().describe("Repository full name (owner/name)"),
|
|
2713
|
+
file: z4.string().describe("Path relative to the repository root"),
|
|
2714
|
+
role: z4.string().describe("Why it matters: what to change or reuse there")
|
|
2314
2715
|
})).describe("The files the change will touch or must reuse"),
|
|
2315
|
-
assumptions:
|
|
2316
|
-
text:
|
|
2317
|
-
reason:
|
|
2716
|
+
assumptions: z4.array(z4.object({
|
|
2717
|
+
text: z4.string().describe("The default you chose"),
|
|
2718
|
+
reason: z4.string().describe("Why it is safe or why it was chosen")
|
|
2318
2719
|
})).describe("Corners settled with a default instead of a question"),
|
|
2319
|
-
pendingDecisions:
|
|
2720
|
+
pendingDecisions: z4.array(z4.string()).describe("Decisions that depend on an answer to this round and cannot be asked yet. Empty when none")
|
|
2320
2721
|
});
|
|
2321
|
-
var questionsPayloadSchema =
|
|
2722
|
+
var questionsPayloadSchema = z4.object({
|
|
2322
2723
|
brief: briefSchema,
|
|
2323
|
-
questions:
|
|
2724
|
+
questions: z4.array(questionSchema).max(MAX_FIRST_ROUND).describe(`0-${MAX_FIRST_ROUND} questions that pass the question test, most important first. An empty list when research settled everything`)
|
|
2324
2725
|
});
|
|
2325
|
-
var specSchema =
|
|
2326
|
-
goal:
|
|
2327
|
-
context:
|
|
2328
|
-
inScope:
|
|
2329
|
-
outOfScope:
|
|
2330
|
-
decisions:
|
|
2331
|
-
question:
|
|
2332
|
-
answer:
|
|
2333
|
-
by:
|
|
2726
|
+
var specSchema = z4.object({
|
|
2727
|
+
goal: z4.string().describe("The problem solved, for whom, and the outcome a user or the business will notice"),
|
|
2728
|
+
context: z4.string().describe("Summary: the problem in 1-2 sentences, then the chosen approach in 1-2 sentences"),
|
|
2729
|
+
inScope: z4.array(z4.string()).describe("What this task delivers, one item each"),
|
|
2730
|
+
outOfScope: z4.array(z4.string()).describe("What this task does not do: nearby additions a coder might be tempted to make and approaches that are ruled out. Never empty"),
|
|
2731
|
+
decisions: z4.array(z4.object({
|
|
2732
|
+
question: z4.string(),
|
|
2733
|
+
answer: z4.string(),
|
|
2734
|
+
by: z4.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
|
|
2334
2735
|
})).describe("Every answered question with by: owner, and your own calls that matter with by: agent"),
|
|
2335
|
-
codeMap:
|
|
2336
|
-
repo:
|
|
2337
|
-
file:
|
|
2338
|
-
role:
|
|
2339
|
-
use:
|
|
2736
|
+
codeMap: z4.array(z4.object({
|
|
2737
|
+
repo: z4.string().describe("Repository full name (owner/name)"),
|
|
2738
|
+
file: z4.string().describe("Path relative to the repository root"),
|
|
2739
|
+
role: z4.string().describe("What the coder needs to know about this file"),
|
|
2740
|
+
use: z4.enum(["reuse", "change", "keep"]).describe("reuse: build on it; change: it is in changes; keep: do not touch")
|
|
2340
2741
|
})).describe("The files a coder must read before starting"),
|
|
2341
|
-
changes:
|
|
2342
|
-
repo:
|
|
2343
|
-
file:
|
|
2344
|
-
action:
|
|
2345
|
-
description:
|
|
2346
|
-
produces:
|
|
2347
|
-
consumes:
|
|
2348
|
-
acceptance:
|
|
2742
|
+
changes: z4.array(z4.object({
|
|
2743
|
+
repo: z4.string().describe("Repository full name (owner/name) where the change happens"),
|
|
2744
|
+
file: z4.string().describe("Path to the file, relative to the repository root"),
|
|
2745
|
+
action: z4.enum(["create", "modify", "delete"]),
|
|
2746
|
+
description: z4.string().describe("The behaviour the file has after the change and which existing functions or patterns to extend. No code snippets or diffs"),
|
|
2747
|
+
produces: z4.array(z4.string()).describe("Names with signatures or data shapes this change creates or changes for other changes. Empty when none"),
|
|
2748
|
+
consumes: z4.array(z4.string()).describe("Names with signatures or data shapes this change uses from earlier changes. Empty when none"),
|
|
2749
|
+
acceptance: z4.array(z4.string()).describe("Ids of the criteria this change makes true, like AC-1")
|
|
2349
2750
|
})).min(1).describe("File changes in dependency order: each uses only what earlier changes or the code already provide"),
|
|
2350
|
-
acceptance:
|
|
2351
|
-
id:
|
|
2352
|
-
kind:
|
|
2353
|
-
text:
|
|
2751
|
+
acceptance: z4.array(z4.object({
|
|
2752
|
+
id: z4.string().describe("AC-1, AC-2 and so on, in order"),
|
|
2753
|
+
kind: z4.enum(["main", "edge", "keep"]).describe("main: the main path; edge: an error, empty or limit case; keep: existing behaviour that must keep working"),
|
|
2754
|
+
text: z4.string().describe('"Given <state>, when <action>, then <observable result>", saying where to look. No file paths or function names')
|
|
2354
2755
|
})).min(1).describe("Acceptance criteria: main path first, then edge cases, then what must keep working"),
|
|
2355
|
-
testPlan:
|
|
2356
|
-
automated:
|
|
2357
|
-
repo:
|
|
2358
|
-
command:
|
|
2359
|
-
expect:
|
|
2756
|
+
testPlan: z4.object({
|
|
2757
|
+
automated: z4.array(z4.object({
|
|
2758
|
+
repo: z4.string(),
|
|
2759
|
+
command: z4.string().describe("The exact command to run"),
|
|
2760
|
+
expect: z4.string().describe("What passing looks like")
|
|
2360
2761
|
})).describe("At least one command"),
|
|
2361
|
-
newTests:
|
|
2362
|
-
seam:
|
|
2363
|
-
covers:
|
|
2762
|
+
newTests: z4.array(z4.object({
|
|
2763
|
+
seam: z4.string().describe("Where the test goes: the highest seam that covers the behaviour (an endpoint, a page, a public function)"),
|
|
2764
|
+
covers: z4.array(z4.string()).describe("Ids of the criteria it covers")
|
|
2364
2765
|
})),
|
|
2365
|
-
manual:
|
|
2366
|
-
page:
|
|
2367
|
-
steps:
|
|
2766
|
+
manual: z4.array(z4.object({
|
|
2767
|
+
page: z4.string().describe("The page or screen"),
|
|
2768
|
+
steps: z4.string().describe("What a person does and sees")
|
|
2368
2769
|
}))
|
|
2369
2770
|
}),
|
|
2370
|
-
risks:
|
|
2371
|
-
risk:
|
|
2372
|
-
mitigation:
|
|
2771
|
+
risks: z4.array(z4.object({
|
|
2772
|
+
risk: z4.string(),
|
|
2773
|
+
mitigation: z4.string()
|
|
2373
2774
|
})).describe("What could go wrong: migrations, existing data, access, performance, compatibility"),
|
|
2374
|
-
riskLevel:
|
|
2375
|
-
assumptions:
|
|
2376
|
-
text:
|
|
2377
|
-
reason:
|
|
2775
|
+
riskLevel: z4.enum(["low", "medium", "high"]).describe("How closely a person should review the result"),
|
|
2776
|
+
assumptions: z4.array(z4.object({
|
|
2777
|
+
text: z4.string().describe("The default you chose"),
|
|
2778
|
+
reason: z4.string().describe("Why, so the owner can confirm or correct it quickly")
|
|
2378
2779
|
})).describe("Defaults chosen where the task was silent. Empty when nothing was assumed"),
|
|
2379
|
-
openQuestions:
|
|
2380
|
-
split:
|
|
2780
|
+
openQuestions: z4.array(questionSchema).describe("Questions only the owner can settle; the spec cannot be approved until each is answered or dismissed. Empty when none"),
|
|
2781
|
+
split: z4.array(z4.string()).describe("When the task holds more than one shippable goal or more than about 8 files: the separate tasks you propose, one line each. Empty otherwise")
|
|
2381
2782
|
});
|
|
2382
|
-
var resultBriefSchema =
|
|
2383
|
-
format:
|
|
2384
|
-
goal:
|
|
2385
|
-
context:
|
|
2386
|
-
contains:
|
|
2387
|
-
acceptance:
|
|
2388
|
-
id:
|
|
2389
|
-
text:
|
|
2783
|
+
var resultBriefSchema = z4.object({
|
|
2784
|
+
format: z4.literal("brief").describe('Always "brief"'),
|
|
2785
|
+
goal: z4.string().describe("What the work is for, for whom, and the outcome people will notice"),
|
|
2786
|
+
context: z4.string().describe("Summary: the situation in 1-2 sentences, then how the work will be done in 1-2 sentences"),
|
|
2787
|
+
contains: z4.array(z4.string()).min(1).describe("What the result must contain, one item each: the parts, facts and attachments a reader expects"),
|
|
2788
|
+
acceptance: z4.array(z4.object({
|
|
2789
|
+
id: z4.string().describe("AC-1, AC-2 and so on, in order"),
|
|
2790
|
+
text: z4.string().describe("One observable property of the result that a checker can confirm by reading it")
|
|
2390
2791
|
})).min(1).describe("Acceptance criteria the checks judge the result by, the most important first"),
|
|
2391
|
-
constraints:
|
|
2392
|
-
outOfScope:
|
|
2393
|
-
sources:
|
|
2394
|
-
source:
|
|
2395
|
-
use:
|
|
2792
|
+
constraints: z4.array(z4.string()).describe("Rules the result must keep: length, tone, language, deadlines, what must not be promised. Empty when none"),
|
|
2793
|
+
outOfScope: z4.array(z4.string()).describe("What this work does not do: nearby things someone might be tempted to add. Empty when nothing is tempting"),
|
|
2794
|
+
sources: z4.array(z4.object({
|
|
2795
|
+
source: z4.string().describe("Where the facts come from: a project knowledge note, a linked item, a file of the task, a connected service read only"),
|
|
2796
|
+
use: z4.string().describe("What to take from it")
|
|
2396
2797
|
})).describe("Sources the work must use. Empty when the task itself is enough"),
|
|
2397
|
-
approver:
|
|
2398
|
-
decisions:
|
|
2399
|
-
question:
|
|
2400
|
-
answer:
|
|
2401
|
-
by:
|
|
2798
|
+
approver: z4.string().describe("Who approves the result, as the task or the board names them"),
|
|
2799
|
+
decisions: z4.array(z4.object({
|
|
2800
|
+
question: z4.string(),
|
|
2801
|
+
answer: z4.string(),
|
|
2802
|
+
by: z4.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
|
|
2402
2803
|
})).describe("Every answered question with by: owner, and your own calls that matter with by: agent"),
|
|
2403
|
-
assumptions:
|
|
2404
|
-
text:
|
|
2405
|
-
reason:
|
|
2804
|
+
assumptions: z4.array(z4.object({
|
|
2805
|
+
text: z4.string().describe("The default you chose"),
|
|
2806
|
+
reason: z4.string().describe("Why, so the owner can confirm or correct it quickly")
|
|
2406
2807
|
})).describe("Defaults chosen where the task was silent. Empty when nothing was assumed"),
|
|
2407
|
-
openQuestions:
|
|
2808
|
+
openQuestions: z4.array(questionSchema).describe("Questions only the owner can settle; the brief cannot be approved until each is answered or dismissed. Empty when none")
|
|
2408
2809
|
});
|
|
2409
|
-
function
|
|
2810
|
+
function reply3(text2, isError = false) {
|
|
2410
2811
|
return { content: [{ type: "text", text: text2 }], ...isError ? { isError: true } : {} };
|
|
2411
2812
|
}
|
|
2412
2813
|
function unreachable(error, toolName) {
|
|
2413
2814
|
const message = error instanceof Error ? error.message : String(error);
|
|
2414
|
-
return
|
|
2815
|
+
return reply3(`Could not reach TAMsense to check this: ${message}. Call ${toolName} again in a moment.`, true);
|
|
2415
2816
|
}
|
|
2416
2817
|
function questionsTool(review, onSubmit) {
|
|
2417
|
-
return
|
|
2818
|
+
return tool4(
|
|
2418
2819
|
SUBMIT_QUESTIONS_TOOL_NAME,
|
|
2419
2820
|
"Hand in the brief and the clarifying questions of this round. Call it once, after the research. Pass an empty questions list when research settled everything: the spec is then written next. If it returns problems, the questions were not saved: fix them and call it again.",
|
|
2420
2821
|
questionsPayloadSchema.shape,
|
|
@@ -2426,10 +2827,10 @@ function questionsTool(review, onSubmit) {
|
|
|
2426
2827
|
return unreachable(error, SUBMIT_QUESTIONS_TOOL_NAME);
|
|
2427
2828
|
}
|
|
2428
2829
|
if (!verdict.accepted) {
|
|
2429
|
-
return
|
|
2830
|
+
return reply3(verdict.feedback, true);
|
|
2430
2831
|
}
|
|
2431
2832
|
onSubmit(args);
|
|
2432
|
-
return
|
|
2833
|
+
return reply3(args.questions.length > 0 ? "Questions saved. The owner answers them on the task page after this run. Finish now with a reply of one or two sentences." : "Saved with no questions: the spec is written next. Finish now with a reply of one or two sentences.");
|
|
2433
2834
|
}
|
|
2434
2835
|
);
|
|
2435
2836
|
}
|
|
@@ -2437,7 +2838,7 @@ var BUILDER_CHECK_FIRST = "The spec passed the lint but was not saved yet: the b
|
|
|
2437
2838
|
function specTool(review, onSubmit, builderCheckRan) {
|
|
2438
2839
|
let repairs = 0;
|
|
2439
2840
|
let reminded = false;
|
|
2440
|
-
return
|
|
2841
|
+
return tool4(
|
|
2441
2842
|
SUBMIT_SPEC_TOOL_NAME,
|
|
2442
2843
|
"Hand in the implementation spec. It replaces the previous version entirely, so always send the FULL spec. TAMsense lints it and checks the files first; if it returns feedback, the spec was not saved: fix it and call submit_spec again.",
|
|
2443
2844
|
specSchema.shape,
|
|
@@ -2450,20 +2851,20 @@ function specTool(review, onSubmit, builderCheckRan) {
|
|
|
2450
2851
|
}
|
|
2451
2852
|
if (!verdict.accepted) {
|
|
2452
2853
|
repairs += 1;
|
|
2453
|
-
return
|
|
2854
|
+
return reply3(verdict.feedback, true);
|
|
2454
2855
|
}
|
|
2455
2856
|
if (!builderCheckRan() && !reminded) {
|
|
2456
2857
|
reminded = true;
|
|
2457
|
-
return
|
|
2858
|
+
return reply3(BUILDER_CHECK_FIRST, true);
|
|
2458
2859
|
}
|
|
2459
2860
|
onSubmit({ spec: args, problems: verdict.problems });
|
|
2460
|
-
return
|
|
2861
|
+
return reply3(verdict.problems.length > 0 ? `Spec saved with these problems left: ${verdict.problems.join("; ")}. Mention them in your reply.` : "Spec saved.");
|
|
2461
2862
|
}
|
|
2462
2863
|
);
|
|
2463
2864
|
}
|
|
2464
2865
|
function briefTool(review, onSubmit) {
|
|
2465
2866
|
let repairs = 0;
|
|
2466
|
-
return
|
|
2867
|
+
return tool4(
|
|
2467
2868
|
SUBMIT_SPEC_TOOL_NAME,
|
|
2468
2869
|
"Hand in the brief of the result. It replaces the previous version entirely, so always send the FULL brief. TAMsense lints it first; if it returns feedback, the brief was not saved: fix it and call submit_spec again.",
|
|
2469
2870
|
resultBriefSchema.shape,
|
|
@@ -2476,10 +2877,10 @@ function briefTool(review, onSubmit) {
|
|
|
2476
2877
|
}
|
|
2477
2878
|
if (!verdict.accepted) {
|
|
2478
2879
|
repairs += 1;
|
|
2479
|
-
return
|
|
2880
|
+
return reply3(verdict.feedback, true);
|
|
2480
2881
|
}
|
|
2481
2882
|
onSubmit({ spec: args, problems: verdict.problems });
|
|
2482
|
-
return
|
|
2883
|
+
return reply3(verdict.problems.length > 0 ? `Brief saved with these problems left: ${verdict.problems.join("; ")}. Mention them in your reply.` : "Brief saved.");
|
|
2483
2884
|
}
|
|
2484
2885
|
);
|
|
2485
2886
|
}
|
|
@@ -2580,10 +2981,13 @@ function usageLimitWatch() {
|
|
|
2580
2981
|
|
|
2581
2982
|
// src/claude.ts
|
|
2582
2983
|
var NO_RESULT_TEXT = "Claude finished without a result.";
|
|
2583
|
-
var
|
|
2984
|
+
var SERVER_NAME2 = "tamsense";
|
|
2584
2985
|
var CAPTION_FIELDS = {
|
|
2585
|
-
title:
|
|
2586
|
-
purpose:
|
|
2986
|
+
title: z5.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"'),
|
|
2987
|
+
purpose: z5.string().describe('one line, at most 160 characters, on why it was made: "Comparing two layouts for the empty orders state"')
|
|
2988
|
+
};
|
|
2989
|
+
var NOTES_FIELD = {
|
|
2990
|
+
notes: z5.string().optional().describe('what you tested in it and what happened, at most 600 characters: what you opened, what you clicked or typed, what you expected, and whether it passed or failed. "Opened the disposed requests on the warehouse page, filtered by December, expected 3 rows: got 3, passed. Clicked Export: the file was empty, failed."')
|
|
2587
2991
|
};
|
|
2588
2992
|
var ATTACH_TOOLS = {
|
|
2589
2993
|
screenshot: {
|
|
@@ -2593,32 +2997,32 @@ var ATTACH_TOOLS = {
|
|
|
2593
2997
|
},
|
|
2594
2998
|
replay: {
|
|
2595
2999
|
name: REPLAY_TOOL_NAME,
|
|
2596
|
-
description: "Attach a browser replay (the .rrweb.ndjson file the TAMsense recorder wrote) to the task now, with your own title and
|
|
3000
|
+
description: "Attach a browser replay (the .rrweb.ndjson file the TAMsense recorder wrote) to the task now, with your own title, purpose and notes on what you tested and what happened. 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.",
|
|
2597
3001
|
path: "absolute path of the .rrweb.ndjson replay file"
|
|
2598
3002
|
},
|
|
2599
3003
|
video: {
|
|
2600
3004
|
name: VIDEO_TOOL_NAME,
|
|
2601
|
-
description: "Attach a browser video (.webm or .mp4, up to 200 MB) to the task now, with your own title and
|
|
3005
|
+
description: "Attach a browser video (.webm or .mp4, up to 200 MB) to the task now, with your own title, purpose and notes on what you tested and what happened. Videos recorded with video: true are attached at the end of the run anyway. Close the browser context first so the file is complete.",
|
|
2602
3006
|
path: "absolute path of the video file"
|
|
2603
3007
|
}
|
|
2604
3008
|
};
|
|
2605
3009
|
function attachTool(kind, attach) {
|
|
2606
3010
|
const spec = ATTACH_TOOLS[kind];
|
|
2607
|
-
return
|
|
3011
|
+
return tool5(
|
|
2608
3012
|
spec.name,
|
|
2609
3013
|
spec.description,
|
|
2610
|
-
{ path:
|
|
3014
|
+
{ path: z5.string().describe(spec.path), ...CAPTION_FIELDS, ...kind === "screenshot" ? {} : NOTES_FIELD },
|
|
2611
3015
|
async (args) => ({ content: [{ type: "text", text: await attach({ kind, ...args }) }] })
|
|
2612
3016
|
);
|
|
2613
3017
|
}
|
|
2614
3018
|
function verdictTool(onVerdict) {
|
|
2615
|
-
return
|
|
3019
|
+
return tool5(
|
|
2616
3020
|
VERDICT_TOOL_NAME,
|
|
2617
3021
|
"Hand in the result of this check. Call it exactly once, at the very end.",
|
|
2618
3022
|
{
|
|
2619
|
-
passed:
|
|
2620
|
-
report:
|
|
2621
|
-
couldNotRun:
|
|
3023
|
+
passed: z5.boolean().describe("true only if nothing must be fixed"),
|
|
3024
|
+
report: z5.string().describe("what you checked and, if it failed, exactly what to fix with file and line"),
|
|
3025
|
+
couldNotRun: z5.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")
|
|
2622
3026
|
},
|
|
2623
3027
|
async (args) => {
|
|
2624
3028
|
onVerdict(args.couldNotRun ? { passed: false, report: args.report, couldNotRun: true } : { passed: args.passed, report: args.report });
|
|
@@ -2627,13 +3031,13 @@ function verdictTool(onVerdict) {
|
|
|
2627
3031
|
);
|
|
2628
3032
|
}
|
|
2629
3033
|
function handoffTool(targets, onHandoff) {
|
|
2630
|
-
return
|
|
3034
|
+
return tool5(
|
|
2631
3035
|
HANDOFF_TOOL_NAME,
|
|
2632
3036
|
"Hand the task to the next step of the TAMsense board when you finish. The next agent and the task owner read your note. Only one handoff counts: calling it again replaces the earlier one.",
|
|
2633
3037
|
{
|
|
2634
|
-
to:
|
|
2635
|
-
outcome:
|
|
2636
|
-
note:
|
|
3038
|
+
to: z5.string().describe("name or id of the step to hand the task to"),
|
|
3039
|
+
outcome: z5.enum(HANDOFF_OUTCOMES).describe("pass: the work is done; problems: something must be fixed; ask: a question for that step"),
|
|
3040
|
+
note: z5.string().describe("for the next agent and the owner: what was done, what to check, what is open")
|
|
2637
3041
|
},
|
|
2638
3042
|
async (args) => {
|
|
2639
3043
|
const choice = chooseHandoff(targets, args);
|
|
@@ -2646,25 +3050,25 @@ function handoffTool(targets, onHandoff) {
|
|
|
2646
3050
|
);
|
|
2647
3051
|
}
|
|
2648
3052
|
function askPersonTool(asking, waiting) {
|
|
2649
|
-
return
|
|
3053
|
+
return tool5(
|
|
2650
3054
|
ASK_PERSON_TOOL_NAME,
|
|
2651
3055
|
"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.",
|
|
2652
3056
|
{
|
|
2653
|
-
to:
|
|
2654
|
-
why:
|
|
2655
|
-
question:
|
|
2656
|
-
context:
|
|
2657
|
-
tried:
|
|
2658
|
-
did:
|
|
2659
|
-
error:
|
|
2660
|
-
result:
|
|
3057
|
+
to: z5.union([z5.number(), z5.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"),
|
|
3058
|
+
why: z5.string().describe('one line on why this person: "assignee, and the run is on her computer", "Designer, owns empty states"'),
|
|
3059
|
+
question: z5.string().describe("one plain question the person can answer without reading the code"),
|
|
3060
|
+
context: z5.string().optional().describe("one line on what you saw that led to the question"),
|
|
3061
|
+
tried: z5.array(z5.object({
|
|
3062
|
+
did: z5.string().describe("the command or step"),
|
|
3063
|
+
error: z5.string().optional().describe("the exact error line, if there was one"),
|
|
3064
|
+
result: z5.string().describe("what it showed, one line")
|
|
2661
3065
|
})).max(MAX_ASK_TRIES).optional().describe("what you already tried, the most telling first"),
|
|
2662
|
-
options:
|
|
2663
|
-
label:
|
|
2664
|
-
detail:
|
|
3066
|
+
options: z5.array(z5.object({
|
|
3067
|
+
label: z5.string().describe("a short answer the person can pick"),
|
|
3068
|
+
detail: z5.string().describe("one line on what happens if it is picked")
|
|
2665
3069
|
})).max(MAX_ASK_OPTIONS).optional().describe("2 to 4 answers you suggest, the one you would pick first"),
|
|
2666
|
-
blocking:
|
|
2667
|
-
assumption:
|
|
3070
|
+
blocking: z5.boolean().describe("true when you cannot go on without the answer"),
|
|
3071
|
+
assumption: z5.string().optional().describe("when blocking is false: what you continue with until the answer comes")
|
|
2668
3072
|
},
|
|
2669
3073
|
async (args) => {
|
|
2670
3074
|
if (waiting.waitingFor !== null) {
|
|
@@ -2674,16 +3078,16 @@ function askPersonTool(asking, waiting) {
|
|
|
2674
3078
|
if ("error" in choice) {
|
|
2675
3079
|
return { content: [{ type: "text", text: choice.error }], isError: true };
|
|
2676
3080
|
}
|
|
2677
|
-
let
|
|
3081
|
+
let reply4;
|
|
2678
3082
|
try {
|
|
2679
|
-
|
|
3083
|
+
reply4 = await asking.send(choice.body);
|
|
2680
3084
|
} catch (error) {
|
|
2681
3085
|
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 };
|
|
2682
3086
|
}
|
|
2683
3087
|
if (choice.body.blocking) {
|
|
2684
|
-
waiting.waitingFor =
|
|
3088
|
+
waiting.waitingFor = reply4.id;
|
|
2685
3089
|
}
|
|
2686
|
-
return { content: [{ type: "text", text: askedText(
|
|
3090
|
+
return { content: [{ type: "text", text: askedText(reply4.name, choice.body) }] };
|
|
2687
3091
|
}
|
|
2688
3092
|
);
|
|
2689
3093
|
}
|
|
@@ -2727,11 +3131,12 @@ function tamsenseServer(request, submitted) {
|
|
|
2727
3131
|
submitted.handoff = handoff;
|
|
2728
3132
|
})] : [],
|
|
2729
3133
|
...request.results ? resultToolset(request.results) : [],
|
|
2730
|
-
...request.handOver ? [handOverTool(request.handOver)] : []
|
|
3134
|
+
...request.handOver ? [handOverTool(request.handOver)] : [],
|
|
3135
|
+
...request.schedules ? [proposeScheduleTool(request.schedules, submitted)] : []
|
|
2731
3136
|
];
|
|
2732
3137
|
return {
|
|
2733
|
-
server:
|
|
2734
|
-
allowed: tools.map((item) => `mcp__${
|
|
3138
|
+
server: createSdkMcpServer2({ name: SERVER_NAME2, version: "1.0.0", tools }),
|
|
3139
|
+
allowed: tools.map((item) => `mcp__${SERVER_NAME2}__${item.name}`)
|
|
2735
3140
|
};
|
|
2736
3141
|
}
|
|
2737
3142
|
function modelOptions(request) {
|
|
@@ -2767,7 +3172,7 @@ async function attempt(request, context, resume) {
|
|
|
2767
3172
|
// режим auto сам решает, что можно; о чём он спросил бы человека, решают правила проекта или люди задачи
|
|
2768
3173
|
canUseTool: request.permissions ? askPermission(request.permissions, () => context.lastWords.text, request.cwd) : denyWhenAsked,
|
|
2769
3174
|
plugins: [{ type: "local", path: request.library }],
|
|
2770
|
-
mcpServers: { [
|
|
3175
|
+
mcpServers: { [SERVER_NAME2]: tamsense.server },
|
|
2771
3176
|
allowedTools: tamsense.allowed,
|
|
2772
3177
|
abortController: context.abortController,
|
|
2773
3178
|
env: request.env,
|
|
@@ -2820,7 +3225,7 @@ function claudeRunner(startQuery) {
|
|
|
2820
3225
|
request.input.close();
|
|
2821
3226
|
abortController.abort();
|
|
2822
3227
|
}, { once: true });
|
|
2823
|
-
const submitted = { verdict: null, handoff: null, questions: null, spec: null, helperStarted: false, waitingFor: null };
|
|
3228
|
+
const submitted = { verdict: null, handoff: null, questions: null, spec: null, helperStarted: false, waitingFor: null, waitingForSchedule: null };
|
|
2824
3229
|
const limit = usageLimitWatch();
|
|
2825
3230
|
const context = { startQuery, abortController, submitted, limit, lastWords: { text: "" } };
|
|
2826
3231
|
try {
|
|
@@ -2830,16 +3235,16 @@ function claudeRunner(startQuery) {
|
|
|
2830
3235
|
}
|
|
2831
3236
|
const pausedUntil = limit.pausedUntil(result.outcome.completed);
|
|
2832
3237
|
const outcome = pausedUntil ? { ...result.outcome, completed: false, report: USAGE_LIMIT_REPORT } : result.outcome;
|
|
2833
|
-
return { ...outcome, verdict: submitted.verdict, handoff: submitted.handoff, questions: submitted.questions, spec: submitted.spec, sessionId: result.sessionId, pausedUntil, waitingFor: submitted.waitingFor };
|
|
3238
|
+
return { ...outcome, verdict: submitted.verdict, handoff: submitted.handoff, questions: submitted.questions, spec: submitted.spec, sessionId: result.sessionId, pausedUntil, waitingFor: submitted.waitingFor, waitingForSchedule: submitted.waitingForSchedule };
|
|
2834
3239
|
} finally {
|
|
2835
3240
|
request.input.close();
|
|
2836
3241
|
}
|
|
2837
3242
|
};
|
|
2838
3243
|
}
|
|
2839
|
-
var runClaude = claudeRunner(
|
|
3244
|
+
var runClaude = claudeRunner(query3);
|
|
2840
3245
|
|
|
2841
3246
|
// src/config.ts
|
|
2842
|
-
import { mkdir as
|
|
3247
|
+
import { mkdir as mkdir4, readFile as readFile3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
|
|
2843
3248
|
import { homedir as homedir2 } from "os";
|
|
2844
3249
|
import { dirname as dirname2, join as join6 } from "path";
|
|
2845
3250
|
var OWNER_ONLY_FILE = 384;
|
|
@@ -2872,7 +3277,7 @@ async function readConfig(path = configPath()) {
|
|
|
2872
3277
|
}
|
|
2873
3278
|
}
|
|
2874
3279
|
async function writeConfig(config, path = configPath()) {
|
|
2875
|
-
await
|
|
3280
|
+
await mkdir4(dirname2(path), { recursive: true, mode: OWNER_ONLY_FOLDER });
|
|
2876
3281
|
await writeFile3(path, `${JSON.stringify(config, null, 4)}
|
|
2877
3282
|
`, { mode: OWNER_ONLY_FILE });
|
|
2878
3283
|
}
|
|
@@ -2894,14 +3299,14 @@ async function login(tools, request) {
|
|
|
2894
3299
|
tools.openBrowser(pageUrl);
|
|
2895
3300
|
while (tools.now() < expiresAt) {
|
|
2896
3301
|
await tools.sleep(pairing.intervalSeconds * MS_PER_SECOND);
|
|
2897
|
-
const
|
|
2898
|
-
if (
|
|
2899
|
-
return { token:
|
|
3302
|
+
const reply4 = await tools.api.request("POST", PAIRING_TOKEN_PATH, { deviceCode: pairing.deviceCode });
|
|
3303
|
+
if (reply4.status === "connected") {
|
|
3304
|
+
return { token: reply4.token, device: reply4.device };
|
|
2900
3305
|
}
|
|
2901
|
-
if (
|
|
3306
|
+
if (reply4.status === "denied") {
|
|
2902
3307
|
throw new Error(DENIED_TEXT);
|
|
2903
3308
|
}
|
|
2904
|
-
if (
|
|
3309
|
+
if (reply4.status === "expired") {
|
|
2905
3310
|
break;
|
|
2906
3311
|
}
|
|
2907
3312
|
}
|
|
@@ -2909,7 +3314,7 @@ async function login(tools, request) {
|
|
|
2909
3314
|
}
|
|
2910
3315
|
|
|
2911
3316
|
// src/recorderEnv.ts
|
|
2912
|
-
import { existsSync as
|
|
3317
|
+
import { existsSync as existsSync6 } from "fs";
|
|
2913
3318
|
import { join as join8 } from "path";
|
|
2914
3319
|
import { fileURLToPath } from "url";
|
|
2915
3320
|
|
|
@@ -2927,6 +3332,7 @@ var REPLAY_SUFFIX = ".rrweb.ndjson";
|
|
|
2927
3332
|
var META_SUFFIX = ".json";
|
|
2928
3333
|
var MAX_TITLE_LENGTH = 60;
|
|
2929
3334
|
var MAX_PURPOSE_LENGTH = 160;
|
|
3335
|
+
var MAX_NOTES_LENGTH = 600;
|
|
2930
3336
|
var DEFAULT_TRACKER_ORIGINS = ["https://my.tamsense.com"];
|
|
2931
3337
|
var READ_METHODS = ["GET", "HEAD", "OPTIONS"];
|
|
2932
3338
|
var BINDING = "__tamsenseRecord";
|
|
@@ -2934,7 +3340,10 @@ var STATE_KEY = /* @__PURE__ */ Symbol.for("tamsense.recorder");
|
|
|
2934
3340
|
var FLUSH_MS = 100;
|
|
2935
3341
|
var MAX_SESSION_BYTES = 300 * 1024 * 1024;
|
|
2936
3342
|
var GAP_CHECK_MS = 1500;
|
|
3343
|
+
var RRWEB_INCREMENTAL = 3;
|
|
2937
3344
|
var RRWEB_META = 4;
|
|
3345
|
+
var RRWEB_SOURCE_MOUSE_INTERACTION = 2;
|
|
3346
|
+
var RRWEB_SOURCE_INPUT = 5;
|
|
2938
3347
|
function state() {
|
|
2939
3348
|
const holder = globalThis;
|
|
2940
3349
|
holder[STATE_KEY] ??= { contexts: /* @__PURE__ */ new WeakMap(), pages: /* @__PURE__ */ new WeakMap(), instrumented: /* @__PURE__ */ new WeakSet(), script: null, hooked: false };
|
|
@@ -2985,6 +3394,13 @@ function checkCaption(caption) {
|
|
|
2985
3394
|
}
|
|
2986
3395
|
return { title, purpose };
|
|
2987
3396
|
}
|
|
3397
|
+
function checkNotes(notes) {
|
|
3398
|
+
const text2 = typeof notes === "string" ? notes.trim() : "";
|
|
3399
|
+
if (text2.length > MAX_NOTES_LENGTH) {
|
|
3400
|
+
throw new Error(`Keep the notes within ${MAX_NOTES_LENGTH} characters: what you opened, clicked and expected, and whether it passed.`);
|
|
3401
|
+
}
|
|
3402
|
+
return text2;
|
|
3403
|
+
}
|
|
2988
3404
|
function clip2(text2, max) {
|
|
2989
3405
|
return text2.length > max ? `${text2.slice(0, max - 1).trimEnd()}\u2026` : text2;
|
|
2990
3406
|
}
|
|
@@ -3015,11 +3431,22 @@ const send = (extra) => {
|
|
|
3015
3431
|
buffer = [];
|
|
3016
3432
|
return window.${BINDING}(JSON.stringify({ events, ...extra })).catch(() => {});
|
|
3017
3433
|
};
|
|
3434
|
+
const urgent = (event) => event.type === ${RRWEB_INCREMENTAL} && (event.data.source === ${RRWEB_SOURCE_MOUSE_INTERACTION} || event.data.source === ${RRWEB_SOURCE_INPUT});
|
|
3435
|
+
const placeAtTarget = (interaction) => {
|
|
3436
|
+
if (interaction.x !== 0 || interaction.y !== 0) return;
|
|
3437
|
+
const node = rrweb.record.mirror.getNode(interaction.id);
|
|
3438
|
+
if (!node || typeof node.getBoundingClientRect !== 'function') return;
|
|
3439
|
+
const box = node.getBoundingClientRect();
|
|
3440
|
+
interaction.x = Math.round(box.left + box.width / 2);
|
|
3441
|
+
interaction.y = Math.round(box.top + box.height / 2);
|
|
3442
|
+
};
|
|
3018
3443
|
rrweb.record({
|
|
3019
3444
|
emit(event) {
|
|
3020
3445
|
buffer.push(event);
|
|
3021
|
-
if (
|
|
3446
|
+
if (urgent(event)) send();
|
|
3447
|
+
else if (!timer) timer = setTimeout(send, ${FLUSH_MS});
|
|
3022
3448
|
},
|
|
3449
|
+
hooks: { mouseInteraction: placeAtTarget },
|
|
3023
3450
|
maskInputOptions: { password: true },
|
|
3024
3451
|
});
|
|
3025
3452
|
const crossOrigin = (frame) => { try { return new URL(frame.src, location.href).origin !== location.origin; } catch { return false; } };
|
|
@@ -3032,6 +3459,7 @@ const checkGaps = () => {
|
|
|
3032
3459
|
};
|
|
3033
3460
|
window.__tamsenseFlush = () => send();
|
|
3034
3461
|
window.__tamsenseMark = (label) => { rrweb.record.addCustomEvent('${MARK_TAG}', { label }); return send(); };
|
|
3462
|
+
addEventListener('beforeunload', () => { send(); });
|
|
3035
3463
|
addEventListener('pagehide', () => { send(); });
|
|
3036
3464
|
addEventListener('load', () => setTimeout(checkGaps, ${GAP_CHECK_MS}));
|
|
3037
3465
|
})();`;
|
|
@@ -3050,7 +3478,7 @@ var Session = class {
|
|
|
3050
3478
|
const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
|
3051
3479
|
this.file = join7(dir, `${id}${REPLAY_SUFFIX}`);
|
|
3052
3480
|
this.metaFile = join7(dir, `${id}${META_SUFFIX}`);
|
|
3053
|
-
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 };
|
|
3481
|
+
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, notes: "" };
|
|
3054
3482
|
writeFileSync(this.file, "");
|
|
3055
3483
|
this.save();
|
|
3056
3484
|
}
|
|
@@ -3094,8 +3522,8 @@ var Session = class {
|
|
|
3094
3522
|
}
|
|
3095
3523
|
}
|
|
3096
3524
|
summary() {
|
|
3097
|
-
const { title, purpose, video, marks, gaps, blockedTrackerRequests, blockedExternalWrites } = this.meta;
|
|
3098
|
-
return { title, purpose, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests, blockedExternalWrites };
|
|
3525
|
+
const { title, purpose, notes, video, marks, gaps, blockedTrackerRequests, blockedExternalWrites } = this.meta;
|
|
3526
|
+
return { title, purpose, notes, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests, blockedExternalWrites };
|
|
3099
3527
|
}
|
|
3100
3528
|
};
|
|
3101
3529
|
function countBlocked(route, counter) {
|
|
@@ -3222,7 +3650,7 @@ if (process4.env[RECORDINGS_DIR_ENV]) {
|
|
|
3222
3650
|
var RECORDER_ENV = "TAMSENSE_RECORDER";
|
|
3223
3651
|
function bundledRecorder() {
|
|
3224
3652
|
const path = fileURLToPath(new URL("./recorder.cjs", import.meta.url));
|
|
3225
|
-
return
|
|
3653
|
+
return existsSync6(path) ? path : null;
|
|
3226
3654
|
}
|
|
3227
3655
|
function recordingsFolder(home, runId) {
|
|
3228
3656
|
return join8(home, ".tamsense", "recordings", `run-${runId}`);
|
|
@@ -3255,7 +3683,7 @@ function describeDevice(identity) {
|
|
|
3255
3683
|
import { spawn as spawn3 } from "child_process";
|
|
3256
3684
|
import { hostname } from "os";
|
|
3257
3685
|
import { delimiter as delimiter2 } from "path";
|
|
3258
|
-
import { setTimeout as
|
|
3686
|
+
import { setTimeout as wait2 } from "timers/promises";
|
|
3259
3687
|
var PLATFORM_LABELS = {
|
|
3260
3688
|
win32: "Windows",
|
|
3261
3689
|
darwin: "macOS",
|
|
@@ -3291,7 +3719,7 @@ function withNodeOnPath(env, nodeDir, separator = delimiter2) {
|
|
|
3291
3719
|
return { ...env, [key]: [...entries, nodeDir].join(separator) };
|
|
3292
3720
|
}
|
|
3293
3721
|
async function sleep(ms, signal) {
|
|
3294
|
-
await
|
|
3722
|
+
await wait2(ms, void 0, { signal }).catch(() => {
|
|
3295
3723
|
});
|
|
3296
3724
|
}
|
|
3297
3725
|
|
|
@@ -3379,6 +3807,7 @@ function feedQueue(send) {
|
|
|
3379
3807
|
let seq = 0;
|
|
3380
3808
|
let waiting = [];
|
|
3381
3809
|
let sending = Promise.resolve();
|
|
3810
|
+
let queued = false;
|
|
3382
3811
|
async function drain() {
|
|
3383
3812
|
while (waiting.length > 0) {
|
|
3384
3813
|
const batch = waiting.slice(0, MAX_BATCH);
|
|
@@ -3402,15 +3831,21 @@ function feedQueue(send) {
|
|
|
3402
3831
|
return entry;
|
|
3403
3832
|
},
|
|
3404
3833
|
flush() {
|
|
3405
|
-
|
|
3834
|
+
if (!queued) {
|
|
3835
|
+
queued = true;
|
|
3836
|
+
sending = sending.then(() => {
|
|
3837
|
+
queued = false;
|
|
3838
|
+
return drain();
|
|
3839
|
+
});
|
|
3840
|
+
}
|
|
3406
3841
|
return sending;
|
|
3407
3842
|
}
|
|
3408
3843
|
};
|
|
3409
3844
|
}
|
|
3410
3845
|
|
|
3411
3846
|
// src/library.ts
|
|
3412
|
-
import { existsSync as
|
|
3413
|
-
import { cp, mkdir as
|
|
3847
|
+
import { existsSync as existsSync7 } from "fs";
|
|
3848
|
+
import { cp, mkdir as mkdir5, rm as rm5 } from "fs/promises";
|
|
3414
3849
|
import { join as join9 } from "path";
|
|
3415
3850
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3416
3851
|
var LIBRARY_PATH = fileURLToPath2(new URL("../library", import.meta.url));
|
|
@@ -3431,10 +3866,10 @@ async function prepareLibrary(folder, skills, source = LIBRARY_PATH) {
|
|
|
3431
3866
|
return source;
|
|
3432
3867
|
}
|
|
3433
3868
|
await rm5(folder, { recursive: true, force: true });
|
|
3434
|
-
await
|
|
3869
|
+
await mkdir5(join9(folder, "skills"), { recursive: true });
|
|
3435
3870
|
await cp(join9(source, ".claude-plugin"), join9(folder, ".claude-plugin"), { recursive: true });
|
|
3436
3871
|
await cp(join9(source, "agents"), join9(folder, "agents"), { recursive: true });
|
|
3437
|
-
for (const skill of skills.filter((name) => SKILL_NAME.test(name) &&
|
|
3872
|
+
for (const skill of skills.filter((name) => SKILL_NAME.test(name) && existsSync7(join9(source, "skills", name)))) {
|
|
3438
3873
|
await cp(join9(source, "skills", skill), join9(folder, "skills", skill), { recursive: true });
|
|
3439
3874
|
}
|
|
3440
3875
|
return folder;
|
|
@@ -3443,7 +3878,7 @@ async function prepareLibrary(folder, skills, source = LIBRARY_PATH) {
|
|
|
3443
3878
|
// src/recordings.ts
|
|
3444
3879
|
import { randomUUID } from "crypto";
|
|
3445
3880
|
import { createReadStream } from "fs";
|
|
3446
|
-
import { readdir, readFile as readFile4, stat as stat2, writeFile as writeFile4 } from "fs/promises";
|
|
3881
|
+
import { readdir as readdir2, readFile as readFile4, stat as stat2, writeFile as writeFile4 } from "fs/promises";
|
|
3447
3882
|
import { basename as basename4, dirname as dirname4, extname as extname2, isAbsolute as isAbsolute3, join as join10 } from "path";
|
|
3448
3883
|
import { createInterface as createInterface2 } from "readline";
|
|
3449
3884
|
import { Readable } from "stream";
|
|
@@ -3501,6 +3936,14 @@ async function* replayChunks(lines, scan, targetBytes = CHUNK_TARGET_BYTES) {
|
|
|
3501
3936
|
`;
|
|
3502
3937
|
}
|
|
3503
3938
|
}
|
|
3939
|
+
function recordingNotes(meta) {
|
|
3940
|
+
const notes = meta?.notes?.trim();
|
|
3941
|
+
if (notes) {
|
|
3942
|
+
return notes;
|
|
3943
|
+
}
|
|
3944
|
+
const marks = meta?.marks ?? [];
|
|
3945
|
+
return marks.length > 0 ? clip2(`Steps marked: ${marks.map((mark) => mark.label).join("; ")}.`, MAX_NOTES_LENGTH) : "";
|
|
3946
|
+
}
|
|
3504
3947
|
function marksFrom(marks, startTs) {
|
|
3505
3948
|
return marks.map((mark) => ({ at: Math.max(0, mark.ts - startTs), label: mark.label })).sort((a, b) => a.at - b.at);
|
|
3506
3949
|
}
|
|
@@ -3519,7 +3962,7 @@ async function metaFor(kind, file) {
|
|
|
3519
3962
|
return readMeta(join10(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
|
|
3520
3963
|
}
|
|
3521
3964
|
const folder = basename4(dirname4(file)) === "video" ? dirname4(dirname4(file)) : dirname4(file);
|
|
3522
|
-
for (const name of await
|
|
3965
|
+
for (const name of await readdir2(folder).catch(() => [])) {
|
|
3523
3966
|
if (name.endsWith(META_SUFFIX) && name !== LEDGER_FILE) {
|
|
3524
3967
|
const meta = await readMeta(join10(folder, name));
|
|
3525
3968
|
if (meta?.video === file) {
|
|
@@ -3546,7 +3989,7 @@ async function nonEmpty(file) {
|
|
|
3546
3989
|
}
|
|
3547
3990
|
async function pendingRecordings(dir) {
|
|
3548
3991
|
const done2 = await ledger(dir);
|
|
3549
|
-
const names = (await
|
|
3992
|
+
const names = (await readdir2(dir).catch(() => [])).sort();
|
|
3550
3993
|
const pending = [];
|
|
3551
3994
|
for (const name of names) {
|
|
3552
3995
|
if (!name.endsWith(META_SUFFIX) || name === LEDGER_FILE) {
|
|
@@ -3563,7 +4006,7 @@ async function pendingRecordings(dir) {
|
|
|
3563
4006
|
}
|
|
3564
4007
|
return pending;
|
|
3565
4008
|
}
|
|
3566
|
-
async function uploadReplay(upload, file,
|
|
4009
|
+
async function uploadReplay(upload, file, label) {
|
|
3567
4010
|
const id = randomUUID();
|
|
3568
4011
|
const scan = { startTs: Number.POSITIVE_INFINITY, snapshot: false, marks: [] };
|
|
3569
4012
|
const lines = createInterface2({ input: createReadStream(file, "utf8"), crlfDelay: Infinity });
|
|
@@ -3573,9 +4016,9 @@ async function uploadReplay(upload, file, caption) {
|
|
|
3573
4016
|
if (!scan.snapshot) {
|
|
3574
4017
|
throw new Error("This replay has no page snapshot, so it cannot be played. Was the page ever opened?");
|
|
3575
4018
|
}
|
|
3576
|
-
return finish(upload, { kind: "replay", upload: id, name: basename4(file), ...
|
|
4019
|
+
return finish(upload, { kind: "replay", upload: id, name: basename4(file), ...label, contentType: "application/x-ndjson", durationMs: null, marks: marksFrom(scan.marks, scan.startTs) });
|
|
3577
4020
|
}
|
|
3578
|
-
async function uploadVideo(upload, file,
|
|
4021
|
+
async function uploadVideo(upload, file, label, meta) {
|
|
3579
4022
|
const contentType = VIDEO_TYPES_BY_EXTENSION[extname2(file).toLowerCase()];
|
|
3580
4023
|
const info = await stat2(file);
|
|
3581
4024
|
if (info.size > MAX_VIDEO_BYTES) {
|
|
@@ -3588,7 +4031,7 @@ async function uploadVideo(upload, file, caption, meta) {
|
|
|
3588
4031
|
const endedAt = meta?.endedAt ?? info.mtimeMs;
|
|
3589
4032
|
const durationMs = Math.max(0, Math.round(endedAt - startedAt));
|
|
3590
4033
|
const marks = marksFrom(meta?.marks ?? [], startedAt).filter((mark) => mark.at <= durationMs);
|
|
3591
|
-
return finish(upload, { kind: "video", upload: id, name: basename4(file), ...
|
|
4034
|
+
return finish(upload, { kind: "video", upload: id, name: basename4(file), ...label, contentType, durationMs, marks });
|
|
3592
4035
|
}
|
|
3593
4036
|
async function finish(upload, body) {
|
|
3594
4037
|
return (await upload.api.request("POST", runPath(upload.runId, "recordings"), body)).id;
|
|
@@ -3605,15 +4048,18 @@ async function attachRecording(api, runId, recordingsDir2, request) {
|
|
|
3605
4048
|
return `There is no recording at ${request.path}. Close the browser context first (closeRecorded) so the file is complete.`;
|
|
3606
4049
|
}
|
|
3607
4050
|
let caption;
|
|
4051
|
+
let notes;
|
|
3608
4052
|
try {
|
|
3609
4053
|
caption = checkCaption(request);
|
|
4054
|
+
notes = checkNotes(request.notes);
|
|
3610
4055
|
} catch (error) {
|
|
3611
4056
|
return error instanceof Error ? error.message : String(error);
|
|
3612
4057
|
}
|
|
3613
4058
|
try {
|
|
3614
4059
|
const meta = await metaFor(request.kind, request.path);
|
|
4060
|
+
const label = { ...caption, notes: notes || recordingNotes(meta) };
|
|
3615
4061
|
const upload = { api, runId };
|
|
3616
|
-
await (request.kind === "replay" ? uploadReplay(upload, request.path,
|
|
4062
|
+
await (request.kind === "replay" ? uploadReplay(upload, request.path, label) : uploadVideo(upload, request.path, label, meta));
|
|
3617
4063
|
await remember(recordingsDir2, request.path);
|
|
3618
4064
|
} catch (error) {
|
|
3619
4065
|
return `Could not attach the ${request.kind}: ${error instanceof Error ? error.message : String(error)}`;
|
|
@@ -3645,8 +4091,9 @@ async function uploadPendingRecordings(api, runId, dir) {
|
|
|
3645
4091
|
title: numbered(`${recording.kind}:${title}`, title, used),
|
|
3646
4092
|
purpose: recording.meta?.purpose || "Recorded automatically while the agent used a browser"
|
|
3647
4093
|
});
|
|
4094
|
+
const label = { ...caption, notes: recordingNotes(recording.meta) };
|
|
3648
4095
|
try {
|
|
3649
|
-
await (recording.kind === "replay" ? uploadReplay(upload, recording.file,
|
|
4096
|
+
await (recording.kind === "replay" ? uploadReplay(upload, recording.file, label) : uploadVideo(upload, recording.file, label, recording.meta));
|
|
3650
4097
|
await remember(dir, recording.file);
|
|
3651
4098
|
report.uploaded += 1;
|
|
3652
4099
|
} catch (error) {
|
|
@@ -3697,12 +4144,16 @@ async function readScreenshot(request) {
|
|
|
3697
4144
|
|
|
3698
4145
|
// src/worker.ts
|
|
3699
4146
|
var POLL_INTERVAL_MS = 5e3;
|
|
3700
|
-
var
|
|
4147
|
+
var MAX_RETRY_WAIT_MS = 6e4;
|
|
4148
|
+
var POLL_DEADLINE_MS = 3 * 6e4;
|
|
3701
4149
|
var INBOX_INTERVAL_MS = 2e3;
|
|
3702
4150
|
var MAX_MESSAGE_LINE = 200;
|
|
3703
4151
|
var CAPABILITIES_INTERVAL_MS = 30 * 6e4;
|
|
3704
4152
|
var FEED_INTERVAL_MS = 1500;
|
|
3705
4153
|
var FATAL_STATUSES = /* @__PURE__ */ new Set([401, 403]);
|
|
4154
|
+
var MAX_CHECKS = 2;
|
|
4155
|
+
var CHECK_HEARTBEAT_MS = 3e4;
|
|
4156
|
+
var CHECK_DEADLINE_MS = 10 * 6e4;
|
|
3706
4157
|
var STOPPED_HERE = "stopped_here";
|
|
3707
4158
|
var CLOSED_ON_SITE = "closed_on_site";
|
|
3708
4159
|
function messageOf3(error) {
|
|
@@ -3734,7 +4185,7 @@ function sessionReporter(api, job, print2) {
|
|
|
3734
4185
|
let sending = null;
|
|
3735
4186
|
return {
|
|
3736
4187
|
report: (session) => {
|
|
3737
|
-
sending ??= api.request("POST", runPath(job.runId, "session"), session).then(() => {
|
|
4188
|
+
sending ??= api.request("POST", runPath(job.runId, "session"), session, { safeToRepeat: true }).then(() => {
|
|
3738
4189
|
}, (error) => print2(`Could not tell TAMsense which Claude took the task: ${messageOf3(error)}`));
|
|
3739
4190
|
},
|
|
3740
4191
|
sent: async () => {
|
|
@@ -3777,35 +4228,35 @@ function inbox(job, tools, record, closedOnSite) {
|
|
|
3777
4228
|
async function check() {
|
|
3778
4229
|
const ack = input.takeDelivered();
|
|
3779
4230
|
const body = { ack };
|
|
3780
|
-
let
|
|
4231
|
+
let reply4;
|
|
3781
4232
|
try {
|
|
3782
|
-
|
|
4233
|
+
reply4 = await tools.api.request("POST", runPath(job.runId, "inbox"), body, { safeToRepeat: true });
|
|
3783
4234
|
} catch (error) {
|
|
3784
4235
|
input.restoreDelivered(ack);
|
|
3785
|
-
if (!failing) {
|
|
4236
|
+
if (!failing && !alreadyReported(error)) {
|
|
3786
4237
|
tools.print(`Could not check for new messages on TAMsense, trying again: ${messageOf3(error)}`);
|
|
3787
4238
|
}
|
|
3788
4239
|
failing = true;
|
|
3789
4240
|
return;
|
|
3790
4241
|
}
|
|
3791
4242
|
failing = false;
|
|
3792
|
-
if (!
|
|
4243
|
+
if (!reply4.keepGoing) {
|
|
3793
4244
|
closedOnSite();
|
|
3794
4245
|
return;
|
|
3795
4246
|
}
|
|
3796
|
-
for (const message of
|
|
4247
|
+
for (const message of reply4.messages) {
|
|
3797
4248
|
if (input.push(message)) {
|
|
3798
4249
|
tools.print(` ${messageLine(message)}`);
|
|
3799
4250
|
}
|
|
3800
4251
|
}
|
|
3801
|
-
for (const stop of
|
|
4252
|
+
for (const stop of reply4.helperStops ?? []) {
|
|
3802
4253
|
if (!stopsSeen.has(stop.ref)) {
|
|
3803
4254
|
stopsSeen.add(stop.ref);
|
|
3804
4255
|
void stopHelper(input, stop, tools.print);
|
|
3805
4256
|
}
|
|
3806
4257
|
}
|
|
3807
|
-
if (
|
|
3808
|
-
const change = { model:
|
|
4258
|
+
if (reply4.model !== null || reply4.effort !== null) {
|
|
4259
|
+
const change = { model: reply4.model, effort: reply4.effort };
|
|
3809
4260
|
void input.switchModel(change).then(
|
|
3810
4261
|
(applied) => {
|
|
3811
4262
|
if (applied) {
|
|
@@ -3860,11 +4311,11 @@ function permissionTools(job, tools) {
|
|
|
3860
4311
|
}
|
|
3861
4312
|
return {
|
|
3862
4313
|
send: async (body) => {
|
|
3863
|
-
const
|
|
3864
|
-
if (
|
|
4314
|
+
const reply4 = await tools.api.request("POST", runPath(job.runId, "permissions"), body);
|
|
4315
|
+
if (reply4.decision === "wait") {
|
|
3865
4316
|
tools.print(` Waiting for a person on TAMsense to allow: ${body.title || body.action}`);
|
|
3866
4317
|
}
|
|
3867
|
-
return
|
|
4318
|
+
return reply4;
|
|
3868
4319
|
},
|
|
3869
4320
|
check: (id) => tools.api.request("GET", permissionPath(job.runId, id)),
|
|
3870
4321
|
sleep: tools.sleep
|
|
@@ -3939,12 +4390,14 @@ async function executeJob(job, tools, signal, input, record) {
|
|
|
3939
4390
|
onSession: session.report,
|
|
3940
4391
|
attach: (request) => request.kind === "screenshot" ? attachScreenshot(tools.api, job, request, attached) : attachRecording(tools.api, job.runId, recordings, request),
|
|
3941
4392
|
results: resultTools(job, tools.api, workspace.root),
|
|
3942
|
-
handOver: handOverTools(job, tools.api)
|
|
4393
|
+
handOver: handOverTools(job, tools.api),
|
|
4394
|
+
schedules: job.canSchedule ? scheduleTools(tools.api, runPath(job.runId, "schedules")) : null
|
|
3943
4395
|
});
|
|
3944
4396
|
await session.sent();
|
|
3945
4397
|
signal.throwIfAborted();
|
|
3946
4398
|
const waitingFor = outcome.waitingFor ?? null;
|
|
3947
|
-
const
|
|
4399
|
+
const waitingForSchedule = outcome.waitingForSchedule ?? null;
|
|
4400
|
+
const shouldPublish = job.stage.kind === "code" && !isFolderJob(job) && !isReadOnlyCode(job.stage) && outcome.completed && waitingFor === null && waitingForSchedule === null;
|
|
3948
4401
|
const pushed = shouldPublish ? await publish(workspace, job, tools.api, identity) : [];
|
|
3949
4402
|
return {
|
|
3950
4403
|
outcome: outcome.completed ? "succeeded" : "failed",
|
|
@@ -3958,7 +4411,8 @@ async function executeJob(job, tools, signal, input, record) {
|
|
|
3958
4411
|
pausedUntil: outcome.pausedUntil ?? null,
|
|
3959
4412
|
questions: outcome.questions ?? null,
|
|
3960
4413
|
spec: outcome.spec ?? null,
|
|
3961
|
-
waitingFor
|
|
4414
|
+
waitingFor,
|
|
4415
|
+
waitingForSchedule
|
|
3962
4416
|
};
|
|
3963
4417
|
}
|
|
3964
4418
|
function clockOf(iso) {
|
|
@@ -3972,6 +4426,9 @@ function resultLine(finish2, workspace = "repositories") {
|
|
|
3972
4426
|
if (finish2.waitingFor !== null) {
|
|
3973
4427
|
return "Waiting for an answer on TAMsense. This computer is free for other tasks; the run continues here after the answer.";
|
|
3974
4428
|
}
|
|
4429
|
+
if (finish2.waitingForSchedule) {
|
|
4430
|
+
return "Waiting for a schedule on TAMsense. This computer is free for other tasks; the run continues here when the schedule ends.";
|
|
4431
|
+
}
|
|
3975
4432
|
if (finish2.verdict?.couldNotRun) {
|
|
3976
4433
|
return `Check could not run: ${finish2.verdict.report}`;
|
|
3977
4434
|
}
|
|
@@ -4023,7 +4480,7 @@ async function runJob(job, tools, stop) {
|
|
|
4023
4480
|
stopRun();
|
|
4024
4481
|
}
|
|
4025
4482
|
stop.addEventListener("abort", stopRun, { once: true });
|
|
4026
|
-
const feed = feedQueue((entries) => tools.api.request("POST", runPath(job.runId, "feed"), { entries }));
|
|
4483
|
+
const feed = feedQueue((entries) => tools.api.request("POST", runPath(job.runId, "feed"), { entries }, { safeToRepeat: true }));
|
|
4027
4484
|
const feeding = setInterval(() => void feed.flush(), FEED_INTERVAL_MS);
|
|
4028
4485
|
const record = journal(feed, tools.print);
|
|
4029
4486
|
const messages = inbox(job, tools, record, () => run2.abort(CLOSED_ON_SITE));
|
|
@@ -4061,6 +4518,44 @@ async function runJob(job, tools, stop) {
|
|
|
4061
4518
|
tools.print(`Could not send the result to TAMsense: ${messageOf3(error)}`);
|
|
4062
4519
|
}
|
|
4063
4520
|
}
|
|
4521
|
+
var CHECK_TOO_LONG = "check_too_long";
|
|
4522
|
+
function checkFailure(error, signal) {
|
|
4523
|
+
return signal.reason === CHECK_TOO_LONG ? `The check took longer than ${CHECK_DEADLINE_MS / 6e4} minutes.` : failureReport(error, signal);
|
|
4524
|
+
}
|
|
4525
|
+
async function runCheckJob(check, runCheck, tools, stop) {
|
|
4526
|
+
tools.print(`Schedule check: ${check.title}${check.taskId === null ? "" : ` (task #${check.taskId})`}`);
|
|
4527
|
+
const run2 = new AbortController();
|
|
4528
|
+
const stopRun = () => run2.abort(STOPPED_HERE);
|
|
4529
|
+
if (stop.aborted) {
|
|
4530
|
+
stopRun();
|
|
4531
|
+
}
|
|
4532
|
+
stop.addEventListener("abort", stopRun, { once: true });
|
|
4533
|
+
const deadline = setTimeout(() => run2.abort(CHECK_TOO_LONG), CHECK_DEADLINE_MS);
|
|
4534
|
+
const beat = checkHeartbeat(tools.api, check, () => run2.abort(CLOSED_ON_SITE));
|
|
4535
|
+
const beating = setInterval(() => void beat(), CHECK_HEARTBEAT_MS);
|
|
4536
|
+
let body;
|
|
4537
|
+
try {
|
|
4538
|
+
const folder = await checkFolder(tools.home, check);
|
|
4539
|
+
const env = withNodeOnPath(process6.env, dirname5(process6.execPath));
|
|
4540
|
+
body = checkFinishBody(await runCheck({ cwd: folder, prompt: buildCheckPrompt(check, folder), model: check.model, env, signal: run2.signal }));
|
|
4541
|
+
} catch (error) {
|
|
4542
|
+
body = { outcome: "failed", report: checkFailure(error, run2.signal), model: null, usage: null, result: null };
|
|
4543
|
+
} finally {
|
|
4544
|
+
clearTimeout(deadline);
|
|
4545
|
+
clearInterval(beating);
|
|
4546
|
+
stop.removeEventListener("abort", stopRun);
|
|
4547
|
+
}
|
|
4548
|
+
if (run2.signal.reason === CLOSED_ON_SITE) {
|
|
4549
|
+
tools.print("TAMsense closed this check while it was in progress. The result was not sent.");
|
|
4550
|
+
return;
|
|
4551
|
+
}
|
|
4552
|
+
try {
|
|
4553
|
+
await tools.api.request("POST", checkPath(check.checkId, "finish"), body);
|
|
4554
|
+
tools.print(checkLine(body));
|
|
4555
|
+
} catch (error) {
|
|
4556
|
+
tools.print(`Could not send the check result to TAMsense: ${messageOf3(error)}`);
|
|
4557
|
+
}
|
|
4558
|
+
}
|
|
4064
4559
|
async function reportCapabilities(tools, stop) {
|
|
4065
4560
|
if (!tools.capabilities) {
|
|
4066
4561
|
return;
|
|
@@ -4076,7 +4571,7 @@ async function reportCapabilities(tools, stop) {
|
|
|
4076
4571
|
printed = true;
|
|
4077
4572
|
}
|
|
4078
4573
|
} catch (error) {
|
|
4079
|
-
if (!complained && !stop.aborted) {
|
|
4574
|
+
if (!complained && !stop.aborted && !alreadyReported(error)) {
|
|
4080
4575
|
tools.print(`Could not tell TAMsense which Claude models this computer has: ${messageOf3(error)}`);
|
|
4081
4576
|
complained = true;
|
|
4082
4577
|
}
|
|
@@ -4084,6 +4579,24 @@ async function reportCapabilities(tools, stop) {
|
|
|
4084
4579
|
await tools.sleep(CAPABILITIES_INTERVAL_MS, stop);
|
|
4085
4580
|
}
|
|
4086
4581
|
}
|
|
4582
|
+
async function withDeadline(work2, ms, sleep2) {
|
|
4583
|
+
const settled = new AbortController();
|
|
4584
|
+
const deadline = new Promise((_, reject) => {
|
|
4585
|
+
void sleep2(ms, settled.signal).then(() => {
|
|
4586
|
+
if (!settled.signal.aborted) {
|
|
4587
|
+
reject(new Error(`TAMsense did not answer for ${Math.round(ms / 6e4)} minutes`));
|
|
4588
|
+
}
|
|
4589
|
+
});
|
|
4590
|
+
});
|
|
4591
|
+
try {
|
|
4592
|
+
return await Promise.race([work2, deadline]);
|
|
4593
|
+
} finally {
|
|
4594
|
+
settled.abort();
|
|
4595
|
+
}
|
|
4596
|
+
}
|
|
4597
|
+
function untilReconnect(api, stop) {
|
|
4598
|
+
return api.nextReconnect ? AbortSignal.any([stop, api.nextReconnect()]) : stop;
|
|
4599
|
+
}
|
|
4087
4600
|
function jobTools(job, tools, parallel) {
|
|
4088
4601
|
return parallel > 1 ? { ...tools, print: (line) => tools.print(`[#${job.taskId} ${job.stage.name}] ${line}`) } : tools;
|
|
4089
4602
|
}
|
|
@@ -4092,40 +4605,60 @@ async function work(tools, stop, parallel = 1) {
|
|
|
4092
4605
|
const halt = new AbortController();
|
|
4093
4606
|
const stopAll = AbortSignal.any([stop, halt.signal]);
|
|
4094
4607
|
const running = /* @__PURE__ */ new Set();
|
|
4608
|
+
const checks = /* @__PURE__ */ new Set();
|
|
4095
4609
|
const folderLock = keyedLock();
|
|
4610
|
+
const scheduleLock = keyedLock();
|
|
4611
|
+
const runCheck = tools.version ? tools.runCheck : void 0;
|
|
4096
4612
|
const reporting = reportCapabilities(tools, stopAll);
|
|
4097
4613
|
const checking = tools.inspectFolder ? checkFolders({ ...tools, inspect: tools.inspectFolder }, stopAll) : Promise.resolve();
|
|
4614
|
+
if (tools.startFolder) {
|
|
4615
|
+
await reportFoundFolders(tools, tools.startFolder);
|
|
4616
|
+
}
|
|
4098
4617
|
const start = (job) => {
|
|
4099
4618
|
const runTools = jobTools(job, tools, parallel);
|
|
4100
4619
|
const task = folderLock.run(stepFolder(job).join("/"), () => runJob(job, runTools, stopAll)).catch((error) => runTools.print(`The run stopped: ${messageOf3(error)}`)).finally(() => running.delete(task));
|
|
4101
4620
|
running.add(task);
|
|
4102
4621
|
};
|
|
4622
|
+
const startCheck = (check, runner) => {
|
|
4623
|
+
const task = scheduleLock.run(`schedule-${check.scheduleId}`, () => runCheckJob(check, runner, tools, stopAll)).catch((error) => tools.print(`The check stopped: ${messageOf3(error)}`)).finally(() => checks.delete(task));
|
|
4624
|
+
checks.add(task);
|
|
4625
|
+
};
|
|
4626
|
+
let failures = 0;
|
|
4103
4627
|
try {
|
|
4104
4628
|
while (!stopAll.aborted) {
|
|
4105
|
-
|
|
4106
|
-
|
|
4629
|
+
const jobsFull = running.size >= parallel;
|
|
4630
|
+
const checksFull = !runCheck || checks.size >= MAX_CHECKS;
|
|
4631
|
+
if (jobsFull && checksFull) {
|
|
4632
|
+
await Promise.race([...running, ...checks]);
|
|
4107
4633
|
continue;
|
|
4108
4634
|
}
|
|
4109
|
-
|
|
4635
|
+
const body = runCheck && tools.version ? { version: tools.version, jobs: !jobsFull, checks: !checksFull } : void 0;
|
|
4636
|
+
let reply4;
|
|
4110
4637
|
try {
|
|
4111
|
-
|
|
4638
|
+
reply4 = await withDeadline(tools.api.request("POST", NEXT_RUN_PATH, body, { safeToRepeat: true }), POLL_DEADLINE_MS, tools.sleep);
|
|
4112
4639
|
} catch (error) {
|
|
4113
4640
|
if (isFatal(error)) {
|
|
4114
4641
|
halt.abort();
|
|
4115
4642
|
throw error;
|
|
4116
4643
|
}
|
|
4117
|
-
|
|
4118
|
-
|
|
4644
|
+
failures += 1;
|
|
4645
|
+
if (failures === 1 && !alreadyReported(error)) {
|
|
4646
|
+
tools.print(`Could not ask TAMsense for tasks: ${messageOf3(error)}. Trying again.`);
|
|
4647
|
+
}
|
|
4648
|
+
await tools.sleep(backoffMs(failures, POLL_INTERVAL_MS, MAX_RETRY_WAIT_MS), untilReconnect(tools.api, stopAll));
|
|
4119
4649
|
continue;
|
|
4120
4650
|
}
|
|
4121
|
-
|
|
4122
|
-
|
|
4651
|
+
failures = 0;
|
|
4652
|
+
if (reply4.check && runCheck) {
|
|
4653
|
+
startCheck(reply4.check, runCheck);
|
|
4654
|
+
} else if (reply4.job) {
|
|
4655
|
+
start(reply4.job);
|
|
4123
4656
|
} else {
|
|
4124
4657
|
await tools.sleep(POLL_INTERVAL_MS, stopAll);
|
|
4125
4658
|
}
|
|
4126
4659
|
}
|
|
4127
4660
|
} finally {
|
|
4128
|
-
await Promise.allSettled([...running, reporting, checking]);
|
|
4661
|
+
await Promise.allSettled([...running, ...checks, reporting, checking]);
|
|
4129
4662
|
}
|
|
4130
4663
|
}
|
|
4131
4664
|
|
|
@@ -4162,7 +4695,7 @@ async function runLogin(server, deviceName) {
|
|
|
4162
4695
|
{ name, platform: platformLabel() }
|
|
4163
4696
|
);
|
|
4164
4697
|
await writeConfig({ server: api.server, token: result.token, deviceId: result.device.id, deviceName: result.device.name });
|
|
4165
|
-
print(`Connected: ${result.device.name}. To take your tasks, run ${START_COMMAND}`);
|
|
4698
|
+
print(`Connected: ${result.device.name}. To take your tasks, go to your code folder and run ${START_COMMAND}`);
|
|
4166
4699
|
}
|
|
4167
4700
|
function stopOnInterrupt() {
|
|
4168
4701
|
const stop = new AbortController();
|
|
@@ -4174,7 +4707,7 @@ function stopOnInterrupt() {
|
|
|
4174
4707
|
return stop.signal;
|
|
4175
4708
|
}
|
|
4176
4709
|
function installTools(home) {
|
|
4177
|
-
return { home, nodePath: process7.execPath, env: process7.env, print, run: runProcess, exists:
|
|
4710
|
+
return { home, nodePath: process7.execPath, env: process7.env, print, run: runProcess, exists: existsSync8 };
|
|
4178
4711
|
}
|
|
4179
4712
|
async function browserForStart(home, install) {
|
|
4180
4713
|
const installed = await installedBrowser(home);
|
|
@@ -4206,8 +4739,7 @@ async function runStart(parallel, installsBrowser) {
|
|
|
4206
4739
|
print(NOT_CONNECTED_TEXT);
|
|
4207
4740
|
return;
|
|
4208
4741
|
}
|
|
4209
|
-
const
|
|
4210
|
-
const identity = await api.request("GET", IDENTITY_PATH);
|
|
4742
|
+
const identity = await createApi(saved.server, saved.token).request("GET", IDENTITY_PATH);
|
|
4211
4743
|
const problem2 = teamKeyProblem(identity.device.team, process7.env);
|
|
4212
4744
|
if (problem2) {
|
|
4213
4745
|
throw new Error(problem2);
|
|
@@ -4215,7 +4747,8 @@ async function runStart(parallel, installsBrowser) {
|
|
|
4215
4747
|
print(`${identity.device.name} is connected to ${saved.server} as ${describeDevice(identity)}.`);
|
|
4216
4748
|
const home = homedir3();
|
|
4217
4749
|
const browser = await browserForStart(home, installsBrowser);
|
|
4218
|
-
|
|
4750
|
+
const api = createApi(saved.server, saved.token, { watch: connectionWatch(print) });
|
|
4751
|
+
await work({ api, runAgent: runClaude, runCheck: runClaudeCheck, version: currentVersion() ?? void 0, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder(), inspectFolder: (path) => inspectFolder(path, home), startFolder: process7.cwd() }, stopOnInterrupt(), parallel);
|
|
4219
4752
|
}
|
|
4220
4753
|
async function runBrowser(remove) {
|
|
4221
4754
|
const home = homedir3();
|