fraim-framework 2.0.161 → 2.0.162

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.
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ManagedBrowser = void 0;
7
+ exports.browserExecutableCandidates = browserExecutableCandidates;
8
+ exports.resolveBrowserExecutable = resolveBrowserExecutable;
9
+ exports.buildBrowserLaunchArgs = buildBrowserLaunchArgs;
10
+ exports.buildBrowserContextNote = buildBrowserContextNote;
11
+ exports.defaultBrowserProfileDir = defaultBrowserProfileDir;
12
+ exports.probeCdpEndpoint = probeCdpEndpoint;
13
+ /**
14
+ * #521 — FRAIM-managed persistent browser.
15
+ *
16
+ * Problem: the Hub runs each agent turn as a one-shot process (`claude -p …`).
17
+ * When a turn ends, that process exits and any browser it launched (a child of
18
+ * the agent) is torn down — so "open a browser, I'll log in, then continue"
19
+ * never works: the window dies at the turn boundary before the manager can log in.
20
+ *
21
+ * Fix: FRAIM owns ONE long-lived Chromium browser (Chrome or Edge) launched with
22
+ * a remote-debugging port and a persistent profile. It outlives agent turns.
23
+ * Agents `connectOverCDP` to it instead of launching their own, so the session —
24
+ * including a manual login — survives across turns. One shared browser, one
25
+ * persistent profile; concurrency isolation is intentionally NOT solved here
26
+ * (see persistent-browser-session skill) to keep this simple.
27
+ */
28
+ const child_process_1 = require("child_process");
29
+ const http_1 = __importDefault(require("http"));
30
+ const os_1 = __importDefault(require("os"));
31
+ const path_1 = __importDefault(require("path"));
32
+ const fs_1 = __importDefault(require("fs"));
33
+ /**
34
+ * Known install locations for Chrome and Edge per platform. Pure + injectable so
35
+ * resolution can be unit-tested without the browsers actually being installed.
36
+ */
37
+ function browserExecutableCandidates(platform = process.platform, env = process.env) {
38
+ if (platform === 'win32') {
39
+ const pf = env['ProgramFiles'] || 'C:\\Program Files';
40
+ const pf86 = env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
41
+ const local = env['LOCALAPPDATA']
42
+ || path_1.default.join(env['USERPROFILE'] || 'C:\\Users\\Default', 'AppData', 'Local');
43
+ return {
44
+ chrome: [
45
+ path_1.default.join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'),
46
+ path_1.default.join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
47
+ path_1.default.join(local, 'Google', 'Chrome', 'Application', 'chrome.exe'),
48
+ ],
49
+ msedge: [
50
+ path_1.default.join(pf86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
51
+ path_1.default.join(pf, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
52
+ ],
53
+ };
54
+ }
55
+ if (platform === 'darwin') {
56
+ return {
57
+ chrome: ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'],
58
+ msedge: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
59
+ };
60
+ }
61
+ // linux + anything else
62
+ return {
63
+ chrome: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'],
64
+ msedge: ['/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable'],
65
+ };
66
+ }
67
+ /**
68
+ * Resolve a Chromium executable. Returns null when neither Chrome nor Edge is
69
+ * found (caller surfaces a clear "install Chrome or Edge" error).
70
+ */
71
+ function resolveBrowserExecutable(opts = {}) {
72
+ const exists = opts.exists || ((p) => fs_1.default.existsSync(p));
73
+ if (opts.explicitPath) {
74
+ if (exists(opts.explicitPath)) {
75
+ const channel = /edge|msedge/i.test(opts.explicitPath) ? 'msedge' : 'chrome';
76
+ return { channel, path: opts.explicitPath };
77
+ }
78
+ return null;
79
+ }
80
+ const candidates = browserExecutableCandidates(opts.platform, opts.env);
81
+ const order = opts.channel === 'msedge'
82
+ ? ['msedge', 'chrome']
83
+ : ['chrome', 'msedge']; // 'chrome' and 'auto' both prefer Chrome first
84
+ for (const channel of order) {
85
+ for (const candidate of candidates[channel]) {
86
+ if (exists(candidate))
87
+ return { channel, path: candidate };
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+ /**
93
+ * Args that put Chrome/Edge into a debuggable, prompt-free, persistent-profile
94
+ * state. Headed (no --headless) so the manager can log in by hand.
95
+ */
96
+ function buildBrowserLaunchArgs(opts) {
97
+ return [
98
+ `--remote-debugging-port=${opts.port}`,
99
+ `--user-data-dir=${opts.userDataDir}`,
100
+ '--no-first-run',
101
+ '--no-default-browser-check',
102
+ // Required so Playwright's CDP WebSocket handshake is accepted by recent Chromium.
103
+ '--remote-allow-origins=*',
104
+ 'about:blank',
105
+ ];
106
+ }
107
+ /**
108
+ * The runtime guidance the Hub injects into an agent's start message so it uses
109
+ * the shared browser. This is intentionally a UI/Hub-layer injection — it is NOT
110
+ * baked into any registry job or skill. Returns '' when the shared browser is not
111
+ * available (so the note only appears when there is something to connect to).
112
+ */
113
+ function buildBrowserContextNote(cdpEndpoint, hubBaseUrl) {
114
+ if (!cdpEndpoint || !hubBaseUrl)
115
+ return '';
116
+ return [
117
+ '',
118
+ '[FRAIM shared browser] A persistent shared browser is available — use it instead of launching your own.',
119
+ `Before using a browser, ensure it is running: \`POST ${hubBaseUrl}/api/ai-hub/browser/start\` (idempotent — it reuses the running browser, or relaunches it if it was closed).`,
120
+ `Then drive the shared browser at ${cdpEndpoint}: your Playwright/browser tools are pointed at it on supported agents; if your tools would otherwise open their own browser, connect to the shared one with \`connectOverCDP("${cdpEndpoint}")\` and reuse its existing context/tab.`,
121
+ 'Never close the shared browser or sign me out. If a site needs login, open it there and ask me to log in — the session persists to your next turn.',
122
+ ].join('\n');
123
+ }
124
+ /** Default persistent profile directory — sandboxed from the user's daily browser. */
125
+ function defaultBrowserProfileDir(env = process.env) {
126
+ const home = env['FRAIM_HOME'] || os_1.default.homedir();
127
+ return path_1.default.join(home, '.fraim', 'browser-profile');
128
+ }
129
+ /**
130
+ * Probe a CDP endpoint's /json/version. Resolves true only when it answers with a
131
+ * Chromium DevTools payload. Uses node http (no fetch dependency).
132
+ */
133
+ function probeCdpEndpoint(endpoint, timeoutMs = 1500) {
134
+ return new Promise((resolve) => {
135
+ let url;
136
+ try {
137
+ url = new URL('/json/version', endpoint);
138
+ }
139
+ catch {
140
+ resolve(false);
141
+ return;
142
+ }
143
+ const req = http_1.default.get(url, { timeout: timeoutMs }, (res) => {
144
+ let body = '';
145
+ res.on('data', (chunk) => { body += chunk.toString(); });
146
+ res.on('end', () => {
147
+ try {
148
+ const data = JSON.parse(body);
149
+ resolve(!!(data && (data.webSocketDebuggerUrl || data.Browser)));
150
+ }
151
+ catch {
152
+ resolve(false);
153
+ }
154
+ });
155
+ });
156
+ req.on('error', () => resolve(false));
157
+ req.on('timeout', () => { req.destroy(); resolve(false); });
158
+ });
159
+ }
160
+ const DEFAULT_PORT = 9222;
161
+ /**
162
+ * Owns the lifecycle of the single shared browser. start() is idempotent — it
163
+ * reuses an already-running browser on the port — so multiple callers (and Hub
164
+ * restarts) converge on one instance.
165
+ */
166
+ class ManagedBrowser {
167
+ constructor(opts = {}) {
168
+ this.child = null;
169
+ this.ownsProcess = false;
170
+ this.resolved = null;
171
+ this.opts = opts;
172
+ this.channel = opts.channel || 'auto';
173
+ this.port = opts.port || DEFAULT_PORT;
174
+ this.host = opts.host || '127.0.0.1';
175
+ this.userDataDir = opts.userDataDir || defaultBrowserProfileDir(opts.env);
176
+ }
177
+ cdpEndpoint() {
178
+ return `http://${this.host}:${this.port}`;
179
+ }
180
+ probe() {
181
+ return this.opts.probeFn ? this.opts.probeFn(this.cdpEndpoint()) : probeCdpEndpoint(this.cdpEndpoint());
182
+ }
183
+ /** Live check — does a Chromium CDP endpoint answer on the port right now. */
184
+ isRunning() {
185
+ return this.probe();
186
+ }
187
+ async start() {
188
+ // 1. Reuse a browser already listening on the port (idempotent across callers
189
+ // and Hub restarts; also lets the manager pre-launch their own).
190
+ if (await this.probe()) {
191
+ return { endpoint: this.cdpEndpoint(), reused: true, executable: this.resolved?.path || null, channel: this.resolved?.channel || null };
192
+ }
193
+ // Already spawned by us but not yet reachable → just wait for it.
194
+ if (this.child && this.ownsProcess) {
195
+ await this.waitForRunning();
196
+ return { endpoint: this.cdpEndpoint(), reused: false, executable: this.resolved?.path || null, channel: this.resolved?.channel || null };
197
+ }
198
+ // 2. Resolve the executable.
199
+ const exe = resolveBrowserExecutable({
200
+ channel: this.channel,
201
+ explicitPath: this.opts.explicitPath,
202
+ platform: this.opts.platform,
203
+ env: this.opts.env,
204
+ exists: this.opts.exists,
205
+ });
206
+ if (!exe) {
207
+ throw new Error('No Chrome or Edge found. Install Google Chrome or Microsoft Edge, or set FRAIM_BROWSER_PATH to a Chromium executable.');
208
+ }
209
+ this.resolved = exe;
210
+ // 3. Ensure the profile dir exists, then spawn the browser long-lived. It is a
211
+ // child of the Hub server (so it dies with the Hub) but NOT of any agent
212
+ // turn — that is the whole point.
213
+ try {
214
+ if (!this.opts.exists)
215
+ fs_1.default.mkdirSync(this.userDataDir, { recursive: true });
216
+ }
217
+ catch { /* best-effort; browser will create it */ }
218
+ const args = buildBrowserLaunchArgs({ port: this.port, userDataDir: this.userDataDir });
219
+ const spawnFn = this.opts.spawnFn || ((c, a, o) => (0, child_process_1.spawn)(c, a, o));
220
+ const child = spawnFn(exe.path, args, { stdio: 'ignore', windowsHide: false });
221
+ this.child = child;
222
+ this.ownsProcess = true;
223
+ if (typeof child.on === 'function') {
224
+ child.on('exit', () => {
225
+ if (this.child === child) {
226
+ this.child = null;
227
+ this.ownsProcess = false;
228
+ }
229
+ });
230
+ }
231
+ // 4. Wait until the CDP endpoint is reachable before returning the endpoint.
232
+ await this.waitForRunning();
233
+ return { endpoint: this.cdpEndpoint(), reused: false, executable: exe.path, channel: exe.channel };
234
+ }
235
+ async waitForRunning() {
236
+ const timeoutMs = this.opts.waitTimeoutMs ?? 15000;
237
+ const intervalMs = this.opts.waitIntervalMs ?? 250;
238
+ const startedAt = Date.now();
239
+ for (;;) {
240
+ if (await this.probe())
241
+ return;
242
+ if (Date.now() - startedAt > timeoutMs) {
243
+ throw new Error(`Browser did not become reachable on ${this.cdpEndpoint()} within ${timeoutMs}ms.`);
244
+ }
245
+ await new Promise((r) => setTimeout(r, intervalMs));
246
+ }
247
+ }
248
+ /** Kill the browser only if WE launched it — never a browser the manager owns. */
249
+ stop() {
250
+ if (this.child && this.ownsProcess && typeof this.child.kill === 'function') {
251
+ try {
252
+ this.child.kill();
253
+ }
254
+ catch { /* ignore */ }
255
+ }
256
+ this.child = null;
257
+ this.ownsProcess = false;
258
+ }
259
+ status() {
260
+ return {
261
+ ownsProcess: this.ownsProcess,
262
+ endpoint: this.cdpEndpoint(),
263
+ channel: this.resolved?.channel || this.channel,
264
+ userDataDir: this.userDataDir,
265
+ executable: this.resolved?.path || null,
266
+ };
267
+ }
268
+ }
269
+ exports.ManagedBrowser = ManagedBrowser;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.extractExplicitFraimInvocation = extractExplicitFraimInvocation;
4
4
  exports.fraimInvocationFor = fraimInvocationFor;
5
+ exports.buildCommunicationStyleNote = buildCommunicationStyleNote;
5
6
  exports.buildManagerMessage = buildManagerMessage;
6
7
  function extractExplicitFraimInvocation(text) {
7
8
  const raw = String(text || '');
@@ -23,6 +24,18 @@ function fraimInvocationFor(employeeId, jobId) {
23
24
  const symbol = employeeId === 'codex' ? '$fraim' : '/fraim';
24
25
  return `${symbol} ${jobId}`;
25
26
  }
27
+ // #521: a Hub-injected communication-style note so the employee's messages to the
28
+ // manager stay about the JOB and its outcomes — not the FRAIM machinery (seeking
29
+ // mentoring, tool calls, git, phase switches). The structured signals (the
30
+ // tracker's seekMentoring events, the review_handoff) still flow as tool calls
31
+ // behind the scenes, so this only shapes the human-facing prose. Injected at the
32
+ // Hub layer — it never edits the jobs.
33
+ function buildCommunicationStyleNote() {
34
+ return [
35
+ '',
36
+ '[How to talk to me] In your messages to me, report ONLY on the job and its outcome — what you found, what you changed, the decisions you made, blockers, and what you need from me. Do NOT narrate the FRAIM machinery: don\'t announce that you are talking to FRAIM, asking your mentor, following the process, moving between phases, or calling tools (git, playwright, etc.). I can see the raw tool activity separately if I want it. Keep your updates short and about the work, not the process.',
37
+ ].join('\n');
38
+ }
26
39
  function buildManagerMessage(employeeId, jobId, kind, instructions, stubPath) {
27
40
  const trimmed = String(instructions || '').trim();
28
41
  const explicit = extractExplicitFraimInvocation(trimmed);
@@ -14,6 +14,7 @@ const defaultPreferences = (projectPath) => ({
14
14
  employeeId: DEFAULT_EMPLOYEE,
15
15
  categoryId: DEFAULT_CATEGORY,
16
16
  recentJobIds: [],
17
+ recentJobInstructions: {},
17
18
  personaKey: null,
18
19
  });
19
20
  class AiHubPreferencesStore {
@@ -31,6 +32,9 @@ class AiHubPreferencesStore {
31
32
  employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex') ? raw.employeeId : DEFAULT_EMPLOYEE,
32
33
  categoryId: typeof raw.categoryId === 'string' && raw.categoryId.length > 0 ? raw.categoryId : DEFAULT_CATEGORY,
33
34
  recentJobIds: Array.isArray(raw.recentJobIds) ? raw.recentJobIds.filter((value) => typeof value === 'string') : [],
35
+ recentJobInstructions: (typeof raw.recentJobInstructions === 'object' && raw.recentJobInstructions !== null && !Array.isArray(raw.recentJobInstructions))
36
+ ? raw.recentJobInstructions
37
+ : {},
34
38
  personaKey: typeof raw.personaKey === 'string' ? raw.personaKey : null,
35
39
  apiKey: typeof raw.apiKey === 'string' && raw.apiKey.length > 0 ? raw.apiKey : undefined,
36
40
  };
@@ -43,13 +47,18 @@ class AiHubPreferencesStore {
43
47
  fs_1.default.mkdirSync(path_1.default.dirname(this.stateFilePath), { recursive: true });
44
48
  fs_1.default.writeFileSync(this.stateFilePath, JSON.stringify(preferences, null, 2));
45
49
  }
46
- remember(preferences, jobId) {
50
+ remember(preferences, jobId, instructions) {
47
51
  const recentJobIds = jobId
48
52
  ? [jobId, ...preferences.recentJobIds.filter((value) => value !== jobId)].slice(0, 8)
49
53
  : preferences.recentJobIds.slice(0, 8);
54
+ const recentJobInstructions = { ...preferences.recentJobInstructions };
55
+ if (jobId && instructions) {
56
+ recentJobInstructions[jobId] = instructions.slice(0, 500);
57
+ }
50
58
  const next = {
51
59
  ...preferences,
52
60
  recentJobIds,
61
+ recentJobInstructions,
53
62
  };
54
63
  this.save(next);
55
64
  return next;