@foldspace_npm/harness 0.1.1 → 0.1.3
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 +79 -31
- package/bin/attach.mjs +779 -107
- package/bin/cli.mjs +139 -11
- package/bin/deploy.mjs +37 -33
- package/bin/inject.mjs +64 -295
- package/package.json +6 -7
- package/src/action-observer.mjs +271 -0
- package/src/attach-helpers.mjs +162 -0
- package/src/attach-preflight.mjs +332 -0
- package/src/bootstrap-script.mjs +120 -0
- package/src/cdp-request-manager.mjs +61 -0
- package/src/cli-help.mjs +143 -0
- package/src/cli-registry.mjs +309 -0
- package/src/diagnostics.mjs +482 -0
- package/src/init.mjs +192 -18
- package/src/project-config.mjs +37 -0
- package/src/protocol.mjs +181 -0
- package/src/session-summary.mjs +133 -0
- package/templates/agent-starter/CLAUDE.md +31 -8
- package/templates/agent-starter/README.md +26 -3
- package/templates/agent-starter/foldspace.dev.json +1 -3
- package/templates/agent-starter/package.json +4 -4
- package/bin/buildExtension.mjs +0 -90
- package/bin/packageExtension.mjs +0 -29
package/src/init.mjs
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import readline from "node:readline/promises";
|
|
5
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { commandByName } from "./cli-registry.mjs";
|
|
4
8
|
|
|
5
9
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
6
10
|
const defaultTemplateRoot = path.join(packageRoot, "templates", "agent-starter");
|
|
7
11
|
const allowedFlags = new Set(["name", "product-id", "agent-api-name", "domain"]);
|
|
8
12
|
const tokenPattern = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
13
|
+
const defaultDirectory = "my-agent";
|
|
9
14
|
|
|
10
|
-
export const initUsage =
|
|
11
|
-
"foldspace init <directory> --product-id <id> --agent-api-name <name> --domain <host> [--name <display-name>]";
|
|
15
|
+
export const initUsage = commandByName("init").usage;
|
|
12
16
|
|
|
13
17
|
function assertSupportedNode() {
|
|
14
18
|
const major = Number.parseInt(process.versions.node.split(".", 1)[0], 10);
|
|
@@ -17,6 +21,13 @@ function assertSupportedNode() {
|
|
|
17
21
|
}
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
function isInteractive(options = {}) {
|
|
25
|
+
if (typeof options.interactive === "boolean") {
|
|
26
|
+
return options.interactive;
|
|
27
|
+
}
|
|
28
|
+
return Boolean(options.stdin?.isTTY ?? input.isTTY);
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
function readFlag(argv, index) {
|
|
21
32
|
const argument = argv[index];
|
|
22
33
|
const equalsIndex = argument.indexOf("=");
|
|
@@ -61,25 +72,28 @@ export function parseInitArgs(argv) {
|
|
|
61
72
|
index += 1;
|
|
62
73
|
}
|
|
63
74
|
|
|
64
|
-
if (positional.length
|
|
75
|
+
if (positional.length > 1) {
|
|
65
76
|
throw new Error(`Usage: ${initUsage}`);
|
|
66
77
|
}
|
|
67
78
|
|
|
68
|
-
for (const required of ["product-id", "agent-api-name", "domain"]) {
|
|
69
|
-
if (!flags[required]) {
|
|
70
|
-
throw new Error(`Missing required option: --${required}\nUsage: ${initUsage}`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
79
|
return {
|
|
75
|
-
|
|
76
|
-
displayName: flags.name ||
|
|
77
|
-
productId: flags["product-id"],
|
|
78
|
-
agentApiName: flags["agent-api-name"],
|
|
79
|
-
domain: flags.domain,
|
|
80
|
+
directory: positional[0] || null,
|
|
81
|
+
displayName: flags.name || null,
|
|
82
|
+
productId: flags["product-id"] || null,
|
|
83
|
+
agentApiName: flags["agent-api-name"] || null,
|
|
84
|
+
domain: flags.domain || null,
|
|
80
85
|
};
|
|
81
86
|
}
|
|
82
87
|
|
|
88
|
+
function missingInitFields(parsed) {
|
|
89
|
+
const missing = [];
|
|
90
|
+
if (!parsed.directory) missing.push("directory");
|
|
91
|
+
if (!parsed.productId) missing.push("product-id");
|
|
92
|
+
if (!parsed.agentApiName) missing.push("agent-api-name");
|
|
93
|
+
if (!parsed.domain) missing.push("domain");
|
|
94
|
+
return missing;
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
function toPackageName(value) {
|
|
84
98
|
return value
|
|
85
99
|
.trim()
|
|
@@ -142,6 +156,111 @@ function normalizeTarget(value) {
|
|
|
142
156
|
};
|
|
143
157
|
}
|
|
144
158
|
|
|
159
|
+
async function ask(question, options = {}) {
|
|
160
|
+
if (typeof options.ask === "function") {
|
|
161
|
+
return options.ask(question);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const rl = readline.createInterface({
|
|
165
|
+
input: options.stdin || input,
|
|
166
|
+
output: options.stdout || output,
|
|
167
|
+
});
|
|
168
|
+
try {
|
|
169
|
+
return (await rl.question(question)).trim();
|
|
170
|
+
} finally {
|
|
171
|
+
rl.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function askRequired(label, options = {}) {
|
|
176
|
+
while (true) {
|
|
177
|
+
const value = await ask(`${label}: `, options);
|
|
178
|
+
if (value) {
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
(options.log || console.log)(`${label} is required.`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function askUntilValid(label, validate, options = {}) {
|
|
186
|
+
while (true) {
|
|
187
|
+
const value = await askRequired(label, options);
|
|
188
|
+
try {
|
|
189
|
+
return validate(value);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
(options.log || console.log)(error instanceof Error ? error.message : String(error));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function promptForMissingFields(parsed, options = {}) {
|
|
197
|
+
const next = { ...parsed };
|
|
198
|
+
const log = options.log || console.log;
|
|
199
|
+
|
|
200
|
+
if (!next.directory) {
|
|
201
|
+
const value = await ask(`Project directory [${defaultDirectory}]: `, options);
|
|
202
|
+
next.directory = value || defaultDirectory;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!next.productId) {
|
|
206
|
+
next.productId = await askUntilValid("Product ID", validateProductId, options);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!next.agentApiName) {
|
|
210
|
+
next.agentApiName = await askUntilValid(
|
|
211
|
+
"Agent API name",
|
|
212
|
+
(value) => validateIdentifier(value, "Agent API name"),
|
|
213
|
+
options,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!next.domain) {
|
|
218
|
+
next.domain = await askUntilValid(
|
|
219
|
+
"Domain",
|
|
220
|
+
(value) => {
|
|
221
|
+
normalizeTarget(value);
|
|
222
|
+
return value.trim();
|
|
223
|
+
},
|
|
224
|
+
options,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!next.displayName) {
|
|
229
|
+
const defaultName = path.basename(path.resolve(next.directory));
|
|
230
|
+
const value = await ask(`Display name [${defaultName}]: `, options);
|
|
231
|
+
next.displayName = value || defaultName;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
log("");
|
|
235
|
+
return next;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function finalizeInitConfig(parsed) {
|
|
239
|
+
const directory = parsed.directory;
|
|
240
|
+
if (!directory) {
|
|
241
|
+
throw new Error(`Missing required option: directory\nUsage: ${initUsage}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const [key, label] of [
|
|
245
|
+
["productId", "product-id"],
|
|
246
|
+
["agentApiName", "agent-api-name"],
|
|
247
|
+
["domain", "domain"],
|
|
248
|
+
]) {
|
|
249
|
+
if (!parsed[key]) {
|
|
250
|
+
throw new Error(`Missing required option: --${label}\nUsage: ${initUsage}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const targetDir = path.resolve(directory);
|
|
255
|
+
return {
|
|
256
|
+
targetDir,
|
|
257
|
+
displayName: parsed.displayName || path.basename(targetDir),
|
|
258
|
+
productId: parsed.productId,
|
|
259
|
+
agentApiName: parsed.agentApiName,
|
|
260
|
+
domain: parsed.domain,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
145
264
|
function readHarnessVersion(root) {
|
|
146
265
|
const manifestPath = path.join(root, "package.json");
|
|
147
266
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
@@ -281,21 +400,76 @@ export function scaffoldProject(config, options = {}) {
|
|
|
281
400
|
};
|
|
282
401
|
}
|
|
283
402
|
|
|
284
|
-
|
|
403
|
+
function runInstall(targetDir, options = {}) {
|
|
404
|
+
const install = options.install || ((cwd) => {
|
|
405
|
+
execFileSync("npm", ["install", "--ignore-scripts"], {
|
|
406
|
+
cwd,
|
|
407
|
+
stdio: "inherit",
|
|
408
|
+
env: process.env,
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
install(targetDir);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function isAffirmative(value) {
|
|
415
|
+
const normalized = value.trim().toLowerCase();
|
|
416
|
+
return normalized === "" || normalized === "y" || normalized === "yes";
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function maybeInstallDependencies(targetDir, options = {}) {
|
|
420
|
+
if (!isInteractive(options)) {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (options.autoInstall === true) {
|
|
425
|
+
runInstall(targetDir, options);
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (options.autoInstall === false) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const answer = await ask("Run npm install --ignore-scripts now? [Y/n] ", options);
|
|
434
|
+
if (!isAffirmative(answer)) {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
runInstall(targetDir, options);
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export async function runInit(argv, options = {}) {
|
|
285
443
|
assertSupportedNode();
|
|
286
|
-
|
|
444
|
+
let parsed = parseInitArgs(argv);
|
|
445
|
+
const missing = missingInitFields(parsed);
|
|
446
|
+
|
|
447
|
+
if (missing.length > 0) {
|
|
448
|
+
if (!isInteractive(options)) {
|
|
449
|
+
const label = missing[0] === "directory" ? "directory" : `--${missing[0]}`;
|
|
450
|
+
throw new Error(`Missing required option: ${label}\nUsage: ${initUsage}`);
|
|
451
|
+
}
|
|
452
|
+
parsed = await promptForMissingFields(parsed, options);
|
|
453
|
+
} else if (!parsed.displayName) {
|
|
454
|
+
parsed.displayName = path.basename(path.resolve(parsed.directory));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const config = finalizeInitConfig(parsed);
|
|
287
458
|
const result = scaffoldProject(config, options);
|
|
288
459
|
const log = options.log || console.log;
|
|
460
|
+
const installed = await maybeInstallDependencies(result.targetDir, options);
|
|
289
461
|
|
|
290
462
|
log(`Created Foldspace project at ${result.targetDir}`);
|
|
291
463
|
log(`Using @foldspace_npm/harness ${result.harnessVersion}`);
|
|
292
464
|
log("");
|
|
293
465
|
log("Next steps:");
|
|
294
466
|
log(` cd ${result.targetDir}`);
|
|
295
|
-
|
|
467
|
+
if (!installed) {
|
|
468
|
+
log(" npm install --ignore-scripts");
|
|
469
|
+
}
|
|
296
470
|
log(" npm run build");
|
|
297
471
|
log(" npm run inject");
|
|
298
472
|
log(" npm run attach");
|
|
299
473
|
|
|
300
|
-
return result;
|
|
474
|
+
return { ...result, installed };
|
|
301
475
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function readProjectConfig(projectDir) {
|
|
5
|
+
const configPath = path.join(projectDir, "foldspace.dev.json");
|
|
6
|
+
if (!fs.existsSync(configPath)) {
|
|
7
|
+
throw new Error("foldspace.dev.json not found");
|
|
8
|
+
}
|
|
9
|
+
let config;
|
|
10
|
+
try {
|
|
11
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
12
|
+
} catch (error) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`foldspace.dev.json is invalid: ${
|
|
15
|
+
error instanceof Error ? error.message : String(error)
|
|
16
|
+
}`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return config;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveConfiguredTarget(config, requestedTarget) {
|
|
23
|
+
const targetName = requestedTarget || config?.defaultTarget;
|
|
24
|
+
if (typeof targetName !== "string" || !targetName) {
|
|
25
|
+
throw new Error("foldspace.dev.json must define defaultTarget");
|
|
26
|
+
}
|
|
27
|
+
const target = config?.targets?.[targetName];
|
|
28
|
+
if (!target || typeof target !== "object") {
|
|
29
|
+
throw new Error(`target "${targetName}" is not defined`);
|
|
30
|
+
}
|
|
31
|
+
for (const field of ["productId", "agentApiName"]) {
|
|
32
|
+
if (typeof target[field] !== "string" || !target[field]) {
|
|
33
|
+
throw new Error(`target "${targetName}" must define ${field}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { targetName, target };
|
|
37
|
+
}
|
package/src/protocol.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Browser-safe request/result contracts shared by local CDP and future
|
|
2
|
+
// extension adapters. This module describes evidence; it does not inspect a
|
|
3
|
+
// page or select a transport.
|
|
4
|
+
export const HARNESS_PROTOCOL_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export const CAPABILITIES = Object.freeze({
|
|
7
|
+
PAGE_EVALUATE: "page.evaluate",
|
|
8
|
+
PAGE_MUTATE: "page.mutate",
|
|
9
|
+
ARTIFACT_LOAD: "artifact.load",
|
|
10
|
+
CDP: "browser.cdp",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const ERROR_CODES = Object.freeze({
|
|
14
|
+
INVALID_REQUEST: "INVALID_REQUEST",
|
|
15
|
+
CAPABILITY_DISABLED: "CAPABILITY_DISABLED",
|
|
16
|
+
SDK_NOT_FOUND: "SDK_NOT_FOUND",
|
|
17
|
+
AGENT_NOT_FOUND: "AGENT_NOT_FOUND",
|
|
18
|
+
REGISTRATION_MISMATCH: "REGISTRATION_MISMATCH",
|
|
19
|
+
DIAGNOSTIC_FAILED: "DIAGNOSTIC_FAILED",
|
|
20
|
+
TIMEOUT: "TIMEOUT",
|
|
21
|
+
EVIDENCE_LIMIT: "EVIDENCE_LIMIT",
|
|
22
|
+
TRANSPORT_ERROR: "TRANSPORT_ERROR",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const LIFECYCLE_OPERATIONS = Object.freeze([
|
|
26
|
+
"prepare_page",
|
|
27
|
+
"load_artifact",
|
|
28
|
+
"inspect_registration",
|
|
29
|
+
"run_diagnostic",
|
|
30
|
+
"cleanup",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
export const PROTOCOL_LIMITS = Object.freeze({
|
|
34
|
+
maxRequestBytes: 16 * 1024,
|
|
35
|
+
maxResultBytes: 64 * 1024,
|
|
36
|
+
maxIdentifierLength: 128,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
40
|
+
|
|
41
|
+
function isRecord(value) {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function jsonByteLength(value, label) {
|
|
46
|
+
let json;
|
|
47
|
+
try {
|
|
48
|
+
json = JSON.stringify(value);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new TypeError(`${label} must be JSON serializable`);
|
|
51
|
+
}
|
|
52
|
+
if (json === undefined) {
|
|
53
|
+
throw new TypeError(`${label} must be JSON serializable`);
|
|
54
|
+
}
|
|
55
|
+
return new TextEncoder().encode(json).length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function assertIdentifier(value, label) {
|
|
59
|
+
if (
|
|
60
|
+
typeof value !== "string" ||
|
|
61
|
+
value.length === 0 ||
|
|
62
|
+
value.length > PROTOCOL_LIMITS.maxIdentifierLength ||
|
|
63
|
+
!identifierPattern.test(value)
|
|
64
|
+
) {
|
|
65
|
+
throw new TypeError(
|
|
66
|
+
`${label} must be a non-empty identifier of at most ${PROTOCOL_LIMITS.maxIdentifierLength} characters`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createDiagnosticRequest({
|
|
72
|
+
id,
|
|
73
|
+
name,
|
|
74
|
+
args = {},
|
|
75
|
+
timeoutMs = 10_000,
|
|
76
|
+
}) {
|
|
77
|
+
assertIdentifier(id, "Diagnostic request id");
|
|
78
|
+
assertIdentifier(name, "Diagnostic name");
|
|
79
|
+
if (!isRecord(args)) {
|
|
80
|
+
throw new TypeError("Diagnostic args must be an object");
|
|
81
|
+
}
|
|
82
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) {
|
|
83
|
+
throw new TypeError("Diagnostic timeoutMs must be an integer between 1 and 60000");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const request = {
|
|
87
|
+
protocolVersion: HARNESS_PROTOCOL_VERSION,
|
|
88
|
+
id,
|
|
89
|
+
name,
|
|
90
|
+
args,
|
|
91
|
+
timeoutMs,
|
|
92
|
+
};
|
|
93
|
+
if (jsonByteLength(request, "Diagnostic request") > PROTOCOL_LIMITS.maxRequestBytes) {
|
|
94
|
+
throw new RangeError("Diagnostic request exceeds the protocol size limit");
|
|
95
|
+
}
|
|
96
|
+
return request;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function diagnosticSuccess(request, result, metadata = {}) {
|
|
100
|
+
if (!isRecord(metadata)) {
|
|
101
|
+
throw new TypeError("Diagnostic metadata must be an object");
|
|
102
|
+
}
|
|
103
|
+
const response = {
|
|
104
|
+
protocolVersion: HARNESS_PROTOCOL_VERSION,
|
|
105
|
+
id: request.id,
|
|
106
|
+
name: request.name,
|
|
107
|
+
ok: true,
|
|
108
|
+
result,
|
|
109
|
+
metadata,
|
|
110
|
+
};
|
|
111
|
+
if (jsonByteLength(response, "Diagnostic result") > PROTOCOL_LIMITS.maxResultBytes) {
|
|
112
|
+
return diagnosticFailure(request, {
|
|
113
|
+
code: ERROR_CODES.EVIDENCE_LIMIT,
|
|
114
|
+
message: "Diagnostic result exceeded the protocol size limit",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return response;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function diagnosticFailure(request, error) {
|
|
121
|
+
const code = Object.values(ERROR_CODES).includes(error?.code)
|
|
122
|
+
? error.code
|
|
123
|
+
: ERROR_CODES.DIAGNOSTIC_FAILED;
|
|
124
|
+
const message =
|
|
125
|
+
typeof error?.message === "string" && error.message.trim()
|
|
126
|
+
? error.message.slice(0, 2_000)
|
|
127
|
+
: "Diagnostic failed";
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
protocolVersion: HARNESS_PROTOCOL_VERSION,
|
|
131
|
+
id: request.id,
|
|
132
|
+
name: request.name,
|
|
133
|
+
ok: false,
|
|
134
|
+
error: { code, message },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createLifecycleResult({
|
|
139
|
+
operation,
|
|
140
|
+
ok,
|
|
141
|
+
state,
|
|
142
|
+
error,
|
|
143
|
+
details = {},
|
|
144
|
+
}) {
|
|
145
|
+
if (!LIFECYCLE_OPERATIONS.includes(operation)) {
|
|
146
|
+
throw new TypeError(`Unknown lifecycle operation: ${operation}`);
|
|
147
|
+
}
|
|
148
|
+
if (typeof ok !== "boolean") {
|
|
149
|
+
throw new TypeError("Lifecycle ok must be a boolean");
|
|
150
|
+
}
|
|
151
|
+
assertIdentifier(state, "Lifecycle state");
|
|
152
|
+
if (!isRecord(details)) {
|
|
153
|
+
throw new TypeError("Lifecycle details must be an object");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const result = {
|
|
157
|
+
protocolVersion: HARNESS_PROTOCOL_VERSION,
|
|
158
|
+
operation,
|
|
159
|
+
ok,
|
|
160
|
+
state,
|
|
161
|
+
details,
|
|
162
|
+
...(ok
|
|
163
|
+
? {}
|
|
164
|
+
: {
|
|
165
|
+
error: {
|
|
166
|
+
code: Object.values(ERROR_CODES).includes(error?.code)
|
|
167
|
+
? error.code
|
|
168
|
+
: ERROR_CODES.TRANSPORT_ERROR,
|
|
169
|
+
message:
|
|
170
|
+
typeof error?.message === "string" && error.message.trim()
|
|
171
|
+
? error.message.slice(0, 2_000)
|
|
172
|
+
: "Lifecycle operation failed",
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (jsonByteLength(result, "Lifecycle result") > PROTOCOL_LIMITS.maxResultBytes) {
|
|
178
|
+
throw new RangeError("Lifecycle result exceeds the protocol size limit");
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { ACTION_LOG_PREFIX } from "./action-observer.mjs";
|
|
2
|
+
|
|
3
|
+
function normalizeConsoleText(value) {
|
|
4
|
+
return String(value ?? "")
|
|
5
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, "<id>")
|
|
6
|
+
.replace(/\b(?:bearer\s+)?[A-Za-z0-9_-]{40,}\b/gi, "<redacted>")
|
|
7
|
+
.replace(/([?&](?:token|key|secret|code)=)[^&\s]+/gi, "$1<redacted>")
|
|
8
|
+
.replace(/\s+/g, " ")
|
|
9
|
+
.trim()
|
|
10
|
+
.slice(0, 1_000);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseActionObservation(text) {
|
|
14
|
+
const value = String(text ?? "");
|
|
15
|
+
if (!value.startsWith(`${ACTION_LOG_PREFIX} `)) return null;
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(value.slice(ACTION_LOG_PREFIX.length + 1));
|
|
18
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatActionObservation(event) {
|
|
25
|
+
const action = event.actionName ? ` ${event.actionName}` : "";
|
|
26
|
+
const duration = Number.isFinite(event.durationMs)
|
|
27
|
+
? ` ${event.durationMs}ms`
|
|
28
|
+
: "";
|
|
29
|
+
const keys =
|
|
30
|
+
Array.isArray(event.parameterKeys) && event.parameterKeys.length
|
|
31
|
+
? ` keys=[${event.parameterKeys.join(",")}]`
|
|
32
|
+
: "";
|
|
33
|
+
if (event.phase === "registration") {
|
|
34
|
+
const count = Array.isArray(event.actionNames)
|
|
35
|
+
? event.actionNames.length
|
|
36
|
+
: 0;
|
|
37
|
+
return `[actions] local artifact ${event.status} (${count} action${count === 1 ? "" : "s"})`;
|
|
38
|
+
}
|
|
39
|
+
return `[actions] ${event.source}:${event.phase}${action} ${event.status}${duration}${keys}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createSessionCollector({
|
|
43
|
+
maxErrorGroups = 50,
|
|
44
|
+
maxActionEvents = 200,
|
|
45
|
+
} = {}) {
|
|
46
|
+
const errorGroups = new Map();
|
|
47
|
+
const actionEvents = [];
|
|
48
|
+
let droppedErrorGroups = 0;
|
|
49
|
+
let droppedActionEvents = 0;
|
|
50
|
+
|
|
51
|
+
function recordConsole({ level = "log", text = "" } = {}) {
|
|
52
|
+
const action = parseActionObservation(text);
|
|
53
|
+
if (action) {
|
|
54
|
+
if (actionEvents.length >= maxActionEvents) {
|
|
55
|
+
actionEvents.shift();
|
|
56
|
+
droppedActionEvents += 1;
|
|
57
|
+
}
|
|
58
|
+
actionEvents.push(action);
|
|
59
|
+
return action;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const normalized = normalizeConsoleText(text);
|
|
63
|
+
const normalizedLevel = String(level).toLowerCase();
|
|
64
|
+
const isFailure =
|
|
65
|
+
normalizedLevel === "error" ||
|
|
66
|
+
normalizedLevel === "warning" ||
|
|
67
|
+
normalizedLevel === "warn" ||
|
|
68
|
+
/\b(?:error|failed|failure|status\s+[45]\d\d)\b/i.test(normalized);
|
|
69
|
+
if (!normalized || !isFailure) return null;
|
|
70
|
+
|
|
71
|
+
const key = `${normalizedLevel}:${normalized}`;
|
|
72
|
+
const existing = errorGroups.get(key);
|
|
73
|
+
if (existing) {
|
|
74
|
+
existing.count += 1;
|
|
75
|
+
} else if (errorGroups.size < maxErrorGroups) {
|
|
76
|
+
errorGroups.set(key, {
|
|
77
|
+
level: normalizedLevel,
|
|
78
|
+
text: normalized,
|
|
79
|
+
count: 1,
|
|
80
|
+
});
|
|
81
|
+
} else {
|
|
82
|
+
droppedErrorGroups += 1;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function snapshot() {
|
|
88
|
+
return {
|
|
89
|
+
actionEvents: actionEvents.map((event) => ({ ...event })),
|
|
90
|
+
errorGroups: Array.from(errorGroups.values()).map((group) => ({
|
|
91
|
+
...group,
|
|
92
|
+
})),
|
|
93
|
+
droppedActionEvents,
|
|
94
|
+
droppedErrorGroups,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { recordConsole, snapshot };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function formatSessionSummary(snapshot) {
|
|
102
|
+
const lines = [];
|
|
103
|
+
if (snapshot.actionEvents.length) {
|
|
104
|
+
lines.push(
|
|
105
|
+
`Action observations (${snapshot.actionEvents.length}${
|
|
106
|
+
snapshot.droppedActionEvents
|
|
107
|
+
? `, ${snapshot.droppedActionEvents} older dropped`
|
|
108
|
+
: ""
|
|
109
|
+
}):`,
|
|
110
|
+
);
|
|
111
|
+
for (const event of snapshot.actionEvents.slice(-20)) {
|
|
112
|
+
lines.push(` ${formatActionObservation(event)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (snapshot.errorGroups.length) {
|
|
116
|
+
lines.push(
|
|
117
|
+
`Page failures (${snapshot.errorGroups.length} group${
|
|
118
|
+
snapshot.errorGroups.length === 1 ? "" : "s"
|
|
119
|
+
}):`,
|
|
120
|
+
);
|
|
121
|
+
for (const group of snapshot.errorGroups) {
|
|
122
|
+
lines.push(
|
|
123
|
+
` [${group.level} x${group.count}] ${group.text}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (snapshot.droppedErrorGroups) {
|
|
127
|
+
lines.push(
|
|
128
|
+
` ${snapshot.droppedErrorGroups} additional failure group(s) omitted`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return lines.join("\n");
|
|
133
|
+
}
|
|
@@ -8,10 +8,13 @@ changes there.
|
|
|
8
8
|
|
|
9
9
|
## Before building
|
|
10
10
|
|
|
11
|
-
1.
|
|
12
|
-
|
|
11
|
+
1. Run `npx foldspace help --json` before an unfamiliar harness operation.
|
|
12
|
+
Treat its risk, prerequisites, effects, and next-step fields as the current
|
|
13
|
+
CLI contract.
|
|
14
|
+
2. Connect Product MCP and verify that `list_agents` works.
|
|
15
|
+
3. Inspect the selected agent with `get_agent_settings`, `list_actions`, and
|
|
13
16
|
`list_task_agents`.
|
|
14
|
-
|
|
17
|
+
4. Use `discover_actions` to identify candidate experiences, then let the user
|
|
15
18
|
choose what to build.
|
|
16
19
|
|
|
17
20
|
## Build workflow
|
|
@@ -51,12 +54,18 @@ npm run attach
|
|
|
51
54
|
```
|
|
52
55
|
|
|
53
56
|
`inject` launches an isolated Chrome profile and records its debug port.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
Foldspace SDK.
|
|
57
|
+
It does not generate or load an application extension. `attach` prepares the
|
|
58
|
+
page and loads the local `dist/index.js` bundle through CDP.
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
Use the default swap only when the page already has the configured product and
|
|
61
|
+
agent. Use `--bootstrap` only when the page has no Foldspace SDK, and
|
|
62
|
+
`--replace` when it embeds a different product or agent, or when an SDK is
|
|
63
|
+
present without the configured agent.
|
|
64
|
+
|
|
65
|
+
The attach log must report `inspect_registration:registration_ok` before
|
|
66
|
+
treating the page as registered. Prove the experience through the visible agent
|
|
67
|
+
and require both an SDK action callback and local execute/render evidence; do
|
|
68
|
+
not invoke the handler directly.
|
|
60
69
|
|
|
61
70
|
## Verification gates
|
|
62
71
|
|
|
@@ -76,9 +85,23 @@ Do not report success without all six:
|
|
|
76
85
|
- `agent/constants.ts` — agent, product, and domain identifiers
|
|
77
86
|
- `agent/utils.ts` — Foldspace agent lookup
|
|
78
87
|
- `foldspace.dev.json` — local harness target configuration
|
|
88
|
+
- `docs/` — optional coding-agent learnings; create on demand
|
|
79
89
|
|
|
80
90
|
Do not introduce another bundler or bundle format.
|
|
81
91
|
|
|
92
|
+
## Agent learnings
|
|
93
|
+
|
|
94
|
+
Use `docs/` to record durable, repository-specific lessons so later sessions do
|
|
95
|
+
not repeat the same mistakes.
|
|
96
|
+
|
|
97
|
+
- Before similar work, read any relevant notes already in `docs/`.
|
|
98
|
+
- After non-obvious discoveries, add a short note covering verified gotchas,
|
|
99
|
+
failed approaches, design rationale, or useful verification commands.
|
|
100
|
+
- Keep notes concise and evidence-based. Create `docs/` when the first note is
|
|
101
|
+
useful; do not leave an empty directory.
|
|
102
|
+
- Do not store secrets, cookies, HAR files, browser storage, or other transient
|
|
103
|
+
session data in `docs/`.
|
|
104
|
+
|
|
82
105
|
## Safety
|
|
83
106
|
|
|
84
107
|
- Do not commit secrets, cookies, HAR files, browser storage, or
|