@foldspace_npm/harness 0.1.4 → 0.1.6
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 +10 -3
- package/bin/attach.mjs +22 -17
- package/bin/cli.mjs +30 -4
- package/package.json +1 -1
- package/src/attach-daemon.mjs +268 -0
- package/src/attach-helpers.mjs +15 -0
- package/src/cli-registry.mjs +17 -5
- package/templates/agent-starter/CLAUDE.md +49 -20
- package/templates/agent-starter/README.md +17 -8
- package/templates/agent-starter/package.json +2 -1
package/README.md
CHANGED
|
@@ -138,13 +138,19 @@ The generated npm scripts intentionally remain the normal project interface;
|
|
|
138
138
|
has an SDK without the configured agent.
|
|
139
139
|
|
|
140
140
|
Use `foldspace help attach` for mode requirements, effects, and safety options.
|
|
141
|
+
Coding agents should run `foldspace attach --daemon` so the invoking tool
|
|
142
|
+
returns after `[lifecycle] inspect_registration:…`. Foreground attach remains
|
|
143
|
+
the default for humans watching the terminal. `attach --status` and
|
|
144
|
+
`attach --stop` inspect and tear down a daemon. Observe customer-app network
|
|
145
|
+
traffic after `inject` and before attach; attach owns the debug port.
|
|
141
146
|
|
|
142
147
|
### Verify actions through the agent
|
|
143
148
|
|
|
144
149
|
`foldspace attach` does not invoke action handlers directly. It loads the local
|
|
145
|
-
artifact, verifies that
|
|
146
|
-
and then observes action callbacks while
|
|
147
|
-
experience.
|
|
150
|
+
artifact, verifies that the captured local registry matches the configured SDK
|
|
151
|
+
agent — including an empty registry — and then observes action callbacks while
|
|
152
|
+
you exercise the normal agent experience. Named actions are not required to
|
|
153
|
+
attach.
|
|
148
154
|
|
|
149
155
|
Action output identifies SDK callbacks and local `execute`/`render` phases:
|
|
150
156
|
|
|
@@ -171,6 +177,7 @@ npm i -D @foldspace_npm/harness
|
|
|
171
177
|
"build": "foldspace build",
|
|
172
178
|
"inject": "foldspace inject",
|
|
173
179
|
"attach": "foldspace attach",
|
|
180
|
+
"attach:daemon": "foldspace attach --daemon",
|
|
174
181
|
"deploy": "foldspace deploy"
|
|
175
182
|
} }
|
|
176
183
|
```
|
package/bin/attach.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
hostMatches,
|
|
31
31
|
hostPatternsFromTarget,
|
|
32
32
|
parseAgentId,
|
|
33
|
+
registrationVerified,
|
|
33
34
|
} from "../src/attach-helpers.mjs";
|
|
34
35
|
import {
|
|
35
36
|
buildReplacePrelude,
|
|
@@ -294,8 +295,10 @@ const sessionCollector = createSessionCollector();
|
|
|
294
295
|
// sessionId -> preparation state used for deterministic detach cleanup.
|
|
295
296
|
const prepared = new Map();
|
|
296
297
|
const preparing = new Set();
|
|
297
|
-
// Rejections are scoped to the
|
|
298
|
-
|
|
298
|
+
// Rejections are scoped to the CDP session. SPA URL changes must not retry
|
|
299
|
+
// prepare() on the same tab. Off-host navigation still clears rejected in
|
|
300
|
+
// targetInfoChanged so a later return to the app can retry.
|
|
301
|
+
const rejected = new Set();
|
|
299
302
|
// targetId -> sessionId, so a target that navigates INTO a matching host can be
|
|
300
303
|
// prepared later. Without this, opening Chrome on a new tab and then browsing
|
|
301
304
|
// to the app never arms the swap.
|
|
@@ -451,13 +454,7 @@ async function verifyPreparedPage(ws, sessionId) {
|
|
|
451
454
|
},
|
|
452
455
|
);
|
|
453
456
|
latest.registration = registration;
|
|
454
|
-
if (
|
|
455
|
-
observation?.captureCount > 0 &&
|
|
456
|
-
expectedActionNames.length > 0 &&
|
|
457
|
-
registration?.agentFound &&
|
|
458
|
-
registration.missingActionNames?.length === 0 &&
|
|
459
|
-
registration.unexpectedActionNames?.length === 0
|
|
460
|
-
) {
|
|
457
|
+
if (registrationVerified(observation, registration)) {
|
|
461
458
|
return {
|
|
462
459
|
ok: true,
|
|
463
460
|
state,
|
|
@@ -493,9 +490,6 @@ async function verifyPreparedPage(ws, sessionId) {
|
|
|
493
490
|
}
|
|
494
491
|
|
|
495
492
|
async function prepare(ws, sessionId, url) {
|
|
496
|
-
if (rejected.has(sessionId) && rejected.get(sessionId) !== url) {
|
|
497
|
-
rejected.delete(sessionId);
|
|
498
|
-
}
|
|
499
493
|
if (
|
|
500
494
|
prepared.has(sessionId) ||
|
|
501
495
|
preparing.has(sessionId) ||
|
|
@@ -527,7 +521,7 @@ async function prepare(ws, sessionId, url) {
|
|
|
527
521
|
pageProductIds,
|
|
528
522
|
});
|
|
529
523
|
if (!guard.ok) {
|
|
530
|
-
rejected.
|
|
524
|
+
rejected.add(sessionId);
|
|
531
525
|
logLifecycle(
|
|
532
526
|
createLifecycleResult({
|
|
533
527
|
operation: "prepare_page",
|
|
@@ -697,6 +691,11 @@ async function prepare(ws, sessionId, url) {
|
|
|
697
691
|
verification.registration?.actionNames || [],
|
|
698
692
|
expectedActionNames:
|
|
699
693
|
verification.observation?.expectedActionNames || [],
|
|
694
|
+
expectedActionCount: Array.isArray(
|
|
695
|
+
verification.observation?.expectedActionNames,
|
|
696
|
+
)
|
|
697
|
+
? verification.observation.expectedActionNames.length
|
|
698
|
+
: 0,
|
|
700
699
|
missingActionNames:
|
|
701
700
|
verification.registration?.missingActionNames || [],
|
|
702
701
|
unexpectedActionNames:
|
|
@@ -715,7 +714,7 @@ async function prepare(ws, sessionId, url) {
|
|
|
715
714
|
await cleanupPreparedSession(ws, sessionId, state);
|
|
716
715
|
prepared.delete(sessionId);
|
|
717
716
|
}
|
|
718
|
-
rejected.
|
|
717
|
+
rejected.add(sessionId);
|
|
719
718
|
}
|
|
720
719
|
}
|
|
721
720
|
} finally {
|
|
@@ -920,7 +919,10 @@ console.log(`Mode: ${attachMode}`);
|
|
|
920
919
|
console.log(`Agent: ${agentApiName} (product ${productId}, ${agentMode})`);
|
|
921
920
|
console.log(`Test: ${noTestMode ? "OFF — conversations WILL appear in the dashboard" : "on"}`);
|
|
922
921
|
console.log(`Hosts: ${hostPatterns.join(", ")}`);
|
|
923
|
-
console.log(`Serving: ${path.relative(root, bundlePath)}
|
|
922
|
+
console.log(`Serving: ${path.relative(root, bundlePath)}`);
|
|
923
|
+
console.log(
|
|
924
|
+
`CDP: this process owns the debug port. Detach before using chrome-devtools MCP against the same Chrome.\n`,
|
|
925
|
+
);
|
|
924
926
|
|
|
925
927
|
function printSessionSummary() {
|
|
926
928
|
if (summaryPrinted) return;
|
|
@@ -1010,7 +1012,7 @@ async function cleanup() {
|
|
|
1010
1012
|
return result;
|
|
1011
1013
|
}
|
|
1012
1014
|
|
|
1013
|
-
|
|
1015
|
+
function requestDetach() {
|
|
1014
1016
|
if (shuttingDown) return;
|
|
1015
1017
|
shuttingDown = true;
|
|
1016
1018
|
void cleanup().finally(() => {
|
|
@@ -1018,7 +1020,10 @@ process.on("SIGINT", () => {
|
|
|
1018
1020
|
console.log(`\nDetached. Served the local bundle ${served} time(s).`);
|
|
1019
1021
|
process.exit(0);
|
|
1020
1022
|
});
|
|
1021
|
-
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
process.on("SIGINT", requestDetach);
|
|
1026
|
+
process.on("SIGTERM", requestDetach);
|
|
1022
1027
|
|
|
1023
1028
|
// A closed socket does not mean the browser closed. Chrome drops the CDP
|
|
1024
1029
|
// connection on target churn and transient errors too, and exiting on those
|
package/bin/cli.mjs
CHANGED
|
@@ -5,6 +5,10 @@ import fs from "node:fs";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { runInit } from "../src/init.mjs";
|
|
8
|
+
import {
|
|
9
|
+
classifyAttachControl,
|
|
10
|
+
runAttachControl,
|
|
11
|
+
} from "../src/attach-daemon.mjs";
|
|
8
12
|
import {
|
|
9
13
|
CLI_COMMANDS,
|
|
10
14
|
createCliRegistry,
|
|
@@ -151,10 +155,32 @@ if (!command || command === "--help" || command === "-h") {
|
|
|
151
155
|
} else {
|
|
152
156
|
try {
|
|
153
157
|
const definition = commandByName(command);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
const normalized = normalizeCommandArgs(definition, args);
|
|
159
|
+
if (command === "attach") {
|
|
160
|
+
const control = classifyAttachControl(normalized);
|
|
161
|
+
if (control) {
|
|
162
|
+
Promise.resolve()
|
|
163
|
+
.then(() =>
|
|
164
|
+
runAttachControl(control, normalized, {
|
|
165
|
+
attachScript: path.join(here, definition.entry),
|
|
166
|
+
spawnFn: spawn,
|
|
167
|
+
}),
|
|
168
|
+
)
|
|
169
|
+
.then((report) => {
|
|
170
|
+
console.log(JSON.stringify(report, null, 2));
|
|
171
|
+
if (control === "daemon" && report.ok === false) {
|
|
172
|
+
process.exitCode = 1;
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
.catch((error) => {
|
|
176
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
177
|
+
});
|
|
178
|
+
} else {
|
|
179
|
+
runScript(definition.entry, normalized);
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
runScript(definition.entry, normalized);
|
|
183
|
+
}
|
|
158
184
|
} catch (error) {
|
|
159
185
|
fail(error instanceof Error ? error.message : String(error));
|
|
160
186
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const ATTACH_DAEMON_ENV = "FOLDSPACE_ATTACH_DAEMON";
|
|
5
|
+
export const ATTACH_DAEMON_STATE_FILE = "attach-daemon.json";
|
|
6
|
+
export const ATTACH_DAEMON_LOG_FILE = "attach-daemon.log";
|
|
7
|
+
|
|
8
|
+
const CONTROL_FLAGS = ["--daemon", "--status", "--stop"];
|
|
9
|
+
const LIFECYCLE_PATTERN =
|
|
10
|
+
/\[lifecycle\] inspect_registration:(registration_ok|registration_mismatch)/g;
|
|
11
|
+
|
|
12
|
+
export function projectRoot(env = process.env, cwd = process.cwd()) {
|
|
13
|
+
return env.FOLDSPACE_PROJECT_DIR || cwd;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function daemonPaths(root) {
|
|
17
|
+
const dir = path.join(root, ".foldspace-dev");
|
|
18
|
+
return {
|
|
19
|
+
dir,
|
|
20
|
+
stateFile: path.join(dir, ATTACH_DAEMON_STATE_FILE),
|
|
21
|
+
logFile: path.join(dir, ATTACH_DAEMON_LOG_FILE),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isProcessAlive(pid, killFn = process.kill) {
|
|
26
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
27
|
+
try {
|
|
28
|
+
killFn(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function classifyAttachControl(args) {
|
|
36
|
+
const selected = CONTROL_FLAGS.filter((flag) => args.includes(flag));
|
|
37
|
+
if (selected.length > 1) {
|
|
38
|
+
throw new Error("--daemon, --status, and --stop cannot be used together");
|
|
39
|
+
}
|
|
40
|
+
if (selected[0] === "--daemon") return "daemon";
|
|
41
|
+
if (selected[0] === "--status") return "status";
|
|
42
|
+
if (selected[0] === "--stop") return "stop";
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function childAttachArgs(args) {
|
|
47
|
+
return args.filter((token) => !CONTROL_FLAGS.includes(token));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseRegistrationLifecycle(logText) {
|
|
51
|
+
let last = null;
|
|
52
|
+
const matches = String(logText ?? "").matchAll(LIFECYCLE_PATTERN);
|
|
53
|
+
for (const match of matches) {
|
|
54
|
+
last = {
|
|
55
|
+
state: match[1],
|
|
56
|
+
ok: match[1] === "registration_ok",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return last;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readDaemonState(root) {
|
|
63
|
+
const { stateFile } = daemonPaths(root);
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
66
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
67
|
+
return parsed;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function writeDaemonState(root, state) {
|
|
74
|
+
const { dir, stateFile } = daemonPaths(root);
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
76
|
+
fs.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function clearDaemonState(root) {
|
|
80
|
+
const { stateFile } = daemonPaths(root);
|
|
81
|
+
try {
|
|
82
|
+
fs.unlinkSync(stateFile);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error?.code !== "ENOENT") throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sleep(ms) {
|
|
89
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function waitForRegistration({
|
|
93
|
+
logFile,
|
|
94
|
+
pid,
|
|
95
|
+
attempts = 60,
|
|
96
|
+
intervalMs = 250,
|
|
97
|
+
readFile = fs.readFileSync,
|
|
98
|
+
isAlive = isProcessAlive,
|
|
99
|
+
delay = sleep,
|
|
100
|
+
} = {}) {
|
|
101
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
102
|
+
let text = "";
|
|
103
|
+
try {
|
|
104
|
+
text = readFile(logFile, "utf8");
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.code !== "ENOENT") throw error;
|
|
107
|
+
}
|
|
108
|
+
const registration = parseRegistrationLifecycle(text);
|
|
109
|
+
if (registration) return registration;
|
|
110
|
+
if (!isAlive(pid)) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Attach daemon exited before inspect_registration. Check ${logFile}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
await delay(intervalMs);
|
|
116
|
+
}
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Attach daemon did not report inspect_registration. Check ${logFile}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function liveDaemon(root, isAlive = isProcessAlive) {
|
|
123
|
+
const state = readDaemonState(root);
|
|
124
|
+
if (!state?.pid || !isAlive(state.pid)) return null;
|
|
125
|
+
return state;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function daemonReport(state, extra = {}) {
|
|
129
|
+
let logText = "";
|
|
130
|
+
if (state?.logPath) {
|
|
131
|
+
try {
|
|
132
|
+
logText = fs.readFileSync(state.logPath, "utf8");
|
|
133
|
+
} catch {
|
|
134
|
+
logText = "";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const registration = parseRegistrationLifecycle(logText);
|
|
138
|
+
return {
|
|
139
|
+
daemon: true,
|
|
140
|
+
pid: state?.pid ?? null,
|
|
141
|
+
logPath: state?.logPath ?? null,
|
|
142
|
+
...(registration
|
|
143
|
+
? { ok: registration.ok, registration: registration.state }
|
|
144
|
+
: {}),
|
|
145
|
+
...extra,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function stopAttachDaemon({
|
|
150
|
+
root,
|
|
151
|
+
killFn = process.kill,
|
|
152
|
+
isAlive = isProcessAlive,
|
|
153
|
+
delay = sleep,
|
|
154
|
+
attempts = 20,
|
|
155
|
+
intervalMs = 100,
|
|
156
|
+
} = {}) {
|
|
157
|
+
const state = readDaemonState(root);
|
|
158
|
+
if (!state?.pid || !isAlive(state.pid)) {
|
|
159
|
+
clearDaemonState(root);
|
|
160
|
+
return { daemon: true, stopped: true, pid: state?.pid ?? null, reused: false };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
killFn(state.pid, "SIGTERM");
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error?.code !== "ESRCH") throw error;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
170
|
+
if (!isAlive(state.pid)) break;
|
|
171
|
+
await delay(intervalMs);
|
|
172
|
+
}
|
|
173
|
+
if (isAlive(state.pid)) {
|
|
174
|
+
try {
|
|
175
|
+
killFn(state.pid, "SIGKILL");
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error?.code !== "ESRCH") throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
clearDaemonState(root);
|
|
182
|
+
return { daemon: true, stopped: true, pid: state.pid };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function startAttachDaemon({
|
|
186
|
+
root,
|
|
187
|
+
attachScript,
|
|
188
|
+
args,
|
|
189
|
+
spawnFn,
|
|
190
|
+
env = process.env,
|
|
191
|
+
wait = waitForRegistration,
|
|
192
|
+
isAlive = isProcessAlive,
|
|
193
|
+
killFn = process.kill,
|
|
194
|
+
} = {}) {
|
|
195
|
+
const existing = liveDaemon(root, isAlive);
|
|
196
|
+
if (existing) {
|
|
197
|
+
return daemonReport(existing, { reused: true, alive: true });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const stale = readDaemonState(root);
|
|
201
|
+
if (stale) clearDaemonState(root);
|
|
202
|
+
|
|
203
|
+
const paths = daemonPaths(root);
|
|
204
|
+
fs.mkdirSync(paths.dir, { recursive: true });
|
|
205
|
+
fs.writeFileSync(paths.logFile, "");
|
|
206
|
+
const logFd = fs.openSync(paths.logFile, "a");
|
|
207
|
+
let child;
|
|
208
|
+
try {
|
|
209
|
+
child = spawnFn(process.execPath, [attachScript, ...childAttachArgs(args)], {
|
|
210
|
+
detached: true,
|
|
211
|
+
env: { ...env, [ATTACH_DAEMON_ENV]: "1" },
|
|
212
|
+
stdio: ["ignore", logFd, logFd],
|
|
213
|
+
});
|
|
214
|
+
} finally {
|
|
215
|
+
fs.closeSync(logFd);
|
|
216
|
+
}
|
|
217
|
+
if (typeof child.unref === "function") child.unref();
|
|
218
|
+
|
|
219
|
+
const state = {
|
|
220
|
+
pid: child.pid,
|
|
221
|
+
startedAt: new Date().toISOString(),
|
|
222
|
+
logPath: paths.logFile,
|
|
223
|
+
};
|
|
224
|
+
writeDaemonState(root, state);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const registration = await wait({
|
|
228
|
+
logFile: paths.logFile,
|
|
229
|
+
pid: child.pid,
|
|
230
|
+
isAlive,
|
|
231
|
+
});
|
|
232
|
+
return daemonReport(state, {
|
|
233
|
+
reused: false,
|
|
234
|
+
ready: true,
|
|
235
|
+
ok: registration.ok,
|
|
236
|
+
registration: registration.state,
|
|
237
|
+
});
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (isAlive(child.pid)) {
|
|
240
|
+
try {
|
|
241
|
+
killFn(child.pid, "SIGTERM");
|
|
242
|
+
} catch {
|
|
243
|
+
// Best-effort: the wait error is what the caller should see.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
clearDaemonState(root);
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function runAttachControl(control, args, options = {}) {
|
|
252
|
+
const root = options.root ?? projectRoot(options.env);
|
|
253
|
+
const isAlive = options.isAlive ?? isProcessAlive;
|
|
254
|
+
if (control === "status") {
|
|
255
|
+
const state = readDaemonState(root);
|
|
256
|
+
if (!state) {
|
|
257
|
+
return { daemon: false, alive: false, pid: null, logPath: null };
|
|
258
|
+
}
|
|
259
|
+
return daemonReport(state, {
|
|
260
|
+
reused: true,
|
|
261
|
+
alive: Boolean(state.pid && isAlive(state.pid)),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (control === "stop") {
|
|
265
|
+
return stopAttachDaemon({ root, isAlive, killFn: options.killFn });
|
|
266
|
+
}
|
|
267
|
+
return startAttachDaemon({ root, args, isAlive, ...options });
|
|
268
|
+
}
|
package/src/attach-helpers.mjs
CHANGED
|
@@ -140,6 +140,21 @@ export function actionRequestMatchesTarget(request, target) {
|
|
|
140
140
|
);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
export function registrationVerified(observation, registration) {
|
|
144
|
+
return Boolean(
|
|
145
|
+
observation?.installed === true &&
|
|
146
|
+
observation?.ownerMatches === true &&
|
|
147
|
+
observation?.actionNameLimitExceeded !== true &&
|
|
148
|
+
observation?.captureCount > 0 &&
|
|
149
|
+
Array.isArray(observation.expectedActionNames) &&
|
|
150
|
+
registration?.agentFound === true &&
|
|
151
|
+
Array.isArray(registration.missingActionNames) &&
|
|
152
|
+
registration.missingActionNames.length === 0 &&
|
|
153
|
+
Array.isArray(registration.unexpectedActionNames) &&
|
|
154
|
+
registration.unexpectedActionNames.length === 0,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
143
158
|
export function summarizeConsoleMessages(messages, limit = 50) {
|
|
144
159
|
const summaries = new Map();
|
|
145
160
|
for (const message of messages) {
|
package/src/cli-registry.mjs
CHANGED
|
@@ -118,7 +118,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
118
118
|
"Otherwise creates an isolated profile and writes .foldspace-dev/state.json",
|
|
119
119
|
"Does not load the SDK or action artifact",
|
|
120
120
|
],
|
|
121
|
-
next: ["Sign in to the target application", "
|
|
121
|
+
next: ["Sign in to the target application", "Observe the real workflow, or run foldspace attach"],
|
|
122
122
|
}),
|
|
123
123
|
Object.freeze({
|
|
124
124
|
name: "attach",
|
|
@@ -127,7 +127,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
127
127
|
summary:
|
|
128
128
|
"Load local actions over CDP and observe the normal agent experience",
|
|
129
129
|
usage:
|
|
130
|
-
"foldspace attach [--bootstrap | --replace] [--target <name>] [--port <number>] [--agent <api-name>] [--no-test-mode] [--no-badge]",
|
|
130
|
+
"foldspace attach [--bootstrap | --replace] [--daemon | --status | --stop] [--target <name>] [--port <number>] [--agent <api-name>] [--no-test-mode] [--no-badge]",
|
|
131
131
|
risk: "browser-session",
|
|
132
132
|
environment: "local-chrome",
|
|
133
133
|
environmentVariables: [
|
|
@@ -178,6 +178,12 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
178
178
|
"Do not mark conversations as test traffic; use deliberately",
|
|
179
179
|
),
|
|
180
180
|
flag("--no-badge", "Hide the visible Foldspace development badge"),
|
|
181
|
+
flag(
|
|
182
|
+
"--daemon",
|
|
183
|
+
"Start attach in the background and return after inspect_registration",
|
|
184
|
+
),
|
|
185
|
+
flag("--status", "Print whether the attach daemon is running"),
|
|
186
|
+
flag("--stop", "Stop the attach daemon and restore prepared pages"),
|
|
181
187
|
],
|
|
182
188
|
prerequisites: [
|
|
183
189
|
"foldspace.dev.json",
|
|
@@ -189,13 +195,17 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
189
195
|
"May reload and instrument matching target pages",
|
|
190
196
|
"Test mode is enabled unless --no-test-mode is passed",
|
|
191
197
|
"Never directly invokes an action handler",
|
|
198
|
+
"An empty local action registry is valid; named actions are not required",
|
|
192
199
|
"Restores prepared pages when detached cleanly",
|
|
200
|
+
"--daemon returns after inspect_registration and keeps CDP in a child process",
|
|
193
201
|
],
|
|
194
202
|
next: [
|
|
203
|
+
"Coding agents should use foldspace attach --daemon so the invoking tool returns",
|
|
195
204
|
"Exercise the visible agent normally",
|
|
196
|
-
"Confirm [lifecycle] inspect_registration:registration_ok",
|
|
197
|
-
"
|
|
198
|
-
"
|
|
205
|
+
"Confirm [lifecycle] inspect_registration:registration_ok in the terminal or daemon log",
|
|
206
|
+
"On mismatch, read foldspace help attach --json diagnostics and the lifecycle details",
|
|
207
|
+
"After a named action, read [actions] execute/render lines from the attach log",
|
|
208
|
+
"Detach with Ctrl-C, or foldspace attach --stop for a daemon",
|
|
199
209
|
],
|
|
200
210
|
}),
|
|
201
211
|
Object.freeze({
|
|
@@ -305,6 +315,8 @@ export function createCliRegistry({ packageName, packageVersion }) {
|
|
|
305
315
|
"Capability metadata describes current behavior; it is not hosted-environment enforcement.",
|
|
306
316
|
"Action verification is passive and agent-driven.",
|
|
307
317
|
"The harness bin alias is equivalent to foldspace.",
|
|
318
|
+
"attach diagnostics are attach-internal; interpret them from the lifecycle log, not as CLI commands.",
|
|
319
|
+
"Coding agents should run attach --daemon; foreground attach is for humans watching the terminal.",
|
|
308
320
|
],
|
|
309
321
|
};
|
|
310
322
|
}
|
|
@@ -15,22 +15,31 @@ changes there.
|
|
|
15
15
|
3. Inspect the selected agent with `get_agent_settings`, `list_actions`, and
|
|
16
16
|
`list_task_agents`.
|
|
17
17
|
4. Use `discover_actions` to identify candidate experiences, then let the user
|
|
18
|
-
choose what to build.
|
|
18
|
+
choose what to build. Treat those ideas as candidates, not an inventory.
|
|
19
19
|
|
|
20
20
|
## Build workflow
|
|
21
21
|
|
|
22
22
|
An experience can require several reusable actions. Separate lookup actions
|
|
23
23
|
from actions that read or mutate a selected resource.
|
|
24
24
|
|
|
25
|
-
1. Agree on the experience and how its actions compose.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
1. Agree on the candidate experience and how its actions would compose. This is
|
|
26
|
+
not a publish and not a commitment.
|
|
27
|
+
2. Run `npm run inject`. Ask the user to sign in and perform the real target
|
|
28
|
+
workflow in that Chrome window.
|
|
29
|
+
3. Capture at least one real HTTP 200 for the data the action needs. Use
|
|
30
|
+
chrome-devtools MCP against the inject Chrome, or a page-context `fetch`,
|
|
31
|
+
**before** `attach` owns the debug port. If there is no 200,
|
|
32
|
+
pivot; do not create Foldspace resources.
|
|
33
|
+
4. Create action metadata as a draft. `generate_action_handler` works from the
|
|
34
|
+
draft schema; do not publish yet.
|
|
35
|
+
5. Implement the handler using the observed request and response shapes.
|
|
36
|
+
6. Register the handler in `agent/actions/index.ts` and build.
|
|
37
|
+
7. Run `npx foldspace attach --daemon` (add `--bootstrap` or `--replace` when
|
|
38
|
+
the page requires it). An empty local registry is valid if you only want to
|
|
39
|
+
see how the agent works.
|
|
40
|
+
8. Ask before publishing. Publishing is required only so the copilot can call
|
|
41
|
+
the action, and it is a live product change when the agent has real users.
|
|
42
|
+
9. Complete the verification gates below.
|
|
34
43
|
|
|
35
44
|
## API rules
|
|
36
45
|
|
|
@@ -38,8 +47,8 @@ Actions execute in the user's signed-in browser session.
|
|
|
38
47
|
|
|
39
48
|
- Use the same internal APIs as the product page, with
|
|
40
49
|
`credentials: "include"`.
|
|
41
|
-
- Never guess endpoints or schemas. Capture at least one real
|
|
42
|
-
|
|
50
|
+
- Never guess endpoints or schemas. Capture at least one real 200 before
|
|
51
|
+
implementing a parser. Do not implement a path that was not observed.
|
|
43
52
|
- Verify that the user is signed in before observing a workflow.
|
|
44
53
|
- Do not substitute a public developer API when the browser session is missing;
|
|
45
54
|
ask the user to sign in.
|
|
@@ -50,12 +59,17 @@ Actions execute in the user's signed-in browser session.
|
|
|
50
59
|
```bash
|
|
51
60
|
npm run dev
|
|
52
61
|
npm run inject
|
|
53
|
-
|
|
62
|
+
npx foldspace attach --daemon
|
|
54
63
|
```
|
|
55
64
|
|
|
56
65
|
`inject` launches an isolated Chrome profile and records its debug port.
|
|
57
|
-
It does not generate or load an application extension.
|
|
58
|
-
|
|
66
|
+
It does not generate or load an application extension. Observe the customer's
|
|
67
|
+
workflow after inject and **before** attach, while chrome-devtools MCP can use
|
|
68
|
+
the same Chrome. `attach` prepares the page and loads the local `dist/index.js`
|
|
69
|
+
bundle through CDP. Coding agents must use `--daemon` so the invoking tool
|
|
70
|
+
returns after `[lifecycle] inspect_registration:…`; foreground `npm run attach`
|
|
71
|
+
is for humans watching the terminal. An empty local registry is valid. `npm run
|
|
72
|
+
build` is still required so `dist/index.js` exists.
|
|
59
73
|
|
|
60
74
|
Use the default swap only when the page already has the configured product and
|
|
61
75
|
agent. Use `--bootstrap` only when the page has no Foldspace SDK, and
|
|
@@ -63,9 +77,21 @@ agent. Use `--bootstrap` only when the page has no Foldspace SDK, and
|
|
|
63
77
|
present without the configured agent.
|
|
64
78
|
|
|
65
79
|
The attach log must report `inspect_registration:registration_ok` before
|
|
66
|
-
treating the page as registered.
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
treating the page as registered. Zero captured actions is success when the
|
|
81
|
+
local registry is empty. On `registration_mismatch`, read
|
|
82
|
+
`npx foldspace help attach --json` diagnostics and map those names onto the
|
|
83
|
+
lifecycle details (`missingActionNames`, `unexpectedActionNames`,
|
|
84
|
+
`diagnosticError`).
|
|
85
|
+
|
|
86
|
+
While attach is running it owns the debug port. Detach (`attach --stop`, or
|
|
87
|
+
Ctrl-C in the foreground) before using chrome-devtools MCP against the same
|
|
88
|
+
Chrome.
|
|
89
|
+
|
|
90
|
+
Prove a named action through the visible agent. After the user talks to the
|
|
91
|
+
copilot, read the daemon log for `[actions]` SDK callback and local
|
|
92
|
+
execute/render lines; do not invoke the handler directly. Those lines record
|
|
93
|
+
names, statuses, durations, and parameter keys only — not results or error
|
|
94
|
+
bodies.
|
|
69
95
|
|
|
70
96
|
## Verification gates
|
|
71
97
|
|
|
@@ -73,8 +99,11 @@ Do not report success without all six:
|
|
|
73
99
|
|
|
74
100
|
1. TypeScript compiles with `npx tsc --noEmit -p tsconfig.json`.
|
|
75
101
|
2. The expected handler appears in `dist/index.js`.
|
|
76
|
-
3. The browser reports
|
|
77
|
-
|
|
102
|
+
3. The browser reports `inspect_registration:registration_ok`. For a named
|
|
103
|
+
action, the captured registry includes that handler.
|
|
104
|
+
4. The action behaves correctly against the real target workflow. Confirm
|
|
105
|
+
`[actions] local-handler:execute` in the attach log after the user exercises
|
|
106
|
+
the visible agent.
|
|
78
107
|
5. Existing neighbouring action fixtures still pass when fixtures exist.
|
|
79
108
|
6. Browser evidence came from the live target, not from hand-authored examples.
|
|
80
109
|
|
|
@@ -50,11 +50,17 @@ npm run build # create dist/index.js
|
|
|
50
50
|
npm run dev # rebuild dist/index.js on changes
|
|
51
51
|
npm run inject # launch the dedicated Chrome profile
|
|
52
52
|
npm run attach # load local actions and observe the agent over CDP
|
|
53
|
+
npm run attach:daemon # same, but return after inspect_registration
|
|
53
54
|
```
|
|
54
55
|
|
|
55
56
|
Run `inject` before `attach`. Sign in to the product in the Chrome window that
|
|
56
|
-
`inject` opens.
|
|
57
|
-
|
|
57
|
+
`inject` opens. Observe the real product workflow in that window **before**
|
|
58
|
+
attach if you need customer API evidence; `attach` owns the debug port.
|
|
59
|
+
`inject` does not generate an application extension; `attach` loads
|
|
60
|
+
`dist/index.js` directly through CDP. An empty local registry is valid,
|
|
61
|
+
so you can attach before implementing handlers. Coding agents should use
|
|
62
|
+
`attach --daemon` (or `npm run attach:daemon`) so the command returns after
|
|
63
|
+
registration instead of occupying the terminal.
|
|
58
64
|
|
|
59
65
|
Choose the attach mode from the state of the target page:
|
|
60
66
|
|
|
@@ -68,10 +74,13 @@ agents can read the versioned contract with `npx foldspace help --json`.
|
|
|
68
74
|
|
|
69
75
|
## Add an action
|
|
70
76
|
|
|
71
|
-
1.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
1. Observe the real product workflow in the inject Chrome and capture a real
|
|
78
|
+
200 for the API the action will call.
|
|
79
|
+
2. Create the action as a draft in Agent Studio or through Product MCP.
|
|
80
|
+
3. Copy `agent/actions/_example.ts` to `agent/actions/<action_key>.ts`.
|
|
81
|
+
4. Implement the handler from that observed request and response.
|
|
82
|
+
5. Import and register the handler in `agent/actions/index.ts`.
|
|
83
|
+
6. Type-check, rebuild, `attach --daemon`, and verify it through the visible
|
|
84
|
+
agent. Publish only when the copilot must call the action.
|
|
76
85
|
|
|
77
|
-
Action keys and parameter names must exactly match the
|
|
86
|
+
Action keys and parameter names must exactly match the action schema.
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"dev": "foldspace build --watch",
|
|
9
9
|
"build": "foldspace build",
|
|
10
10
|
"inject": "foldspace inject",
|
|
11
|
-
"attach": "foldspace attach"
|
|
11
|
+
"attach": "foldspace attach",
|
|
12
|
+
"attach:daemon": "foldspace attach --daemon"
|
|
12
13
|
},
|
|
13
14
|
"devDependencies": {
|
|
14
15
|
"@foldspace_npm/harness": "{{HARNESS_VERSION}}",
|