@q-agent/agent 0.1.1 → 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 CHANGED
@@ -17,7 +17,18 @@ Chromium is installed automatically: the first time you run `qagent-agent start`
17
17
  the agent downloads Playwright's Chromium if it isn't already present (one-time,
18
18
  ~100 MB, with visible progress). No manual `npx playwright install` step is needed.
19
19
 
20
- ## Install & run
20
+ ## Run with the UI (easiest)
21
+
22
+ ```bash
23
+ npx @q-agent/agent
24
+ ```
25
+
26
+ Running with no command opens a local web UI in your browser. Paste a pairing
27
+ code (from the Q-Agent app's **Local Agent** screen) and the server URL, click
28
+ **Connect**, and watch the pull/run progress live. The device token is stored
29
+ locally, so next time it reconnects and starts pulling automatically.
30
+
31
+ ## Command-line
21
32
 
22
33
  ```bash
23
34
  npx @q-agent/agent pair <code> --server https://your-qagent-server.example.com/api
@@ -62,6 +73,22 @@ Chromium still auto-installs on first `start`. Build the bundle on Windows with
62
73
  Node 20+ that supports SEA (the local `node.exe` is used as the executable base —
63
74
  no compiler or download needed).
64
75
 
76
+ ## Desktop app (Electron)
77
+
78
+ A native window that wraps the same web UI:
79
+
80
+ ```bash
81
+ cd agent
82
+ npm install
83
+ npm run desktop # dev: builds, then opens the app window
84
+ npm run dist:desktop # build a Windows installer (electron-builder → dist-bin/desktop/)
85
+ ```
86
+
87
+ The Electron shell runs the agent's UI server in-process and shows it in a
88
+ window — pair by entering the 6-digit code + server URL, then watch progress.
89
+ Child processes (Playwright / login capture) run via Electron-as-Node
90
+ (`ELECTRON_RUN_AS_NODE`), so no separate Node install is needed.
91
+
65
92
  ## Commands
66
93
 
67
94
  | Command | Description |
package/dist/src/api.js CHANGED
@@ -41,10 +41,15 @@ var __importStar = (this && this.__importStar) || (function () {
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
42
  exports.ApiError = void 0;
43
43
  exports.redeemDevice = redeemDevice;
44
+ exports.disconnectDevice = disconnectDevice;
44
45
  exports.claimNextJob = claimNextJob;
46
+ exports.claimNextCapture = claimNextCapture;
47
+ exports.postCaptureComplete = postCaptureComplete;
45
48
  exports.postEvent = postEvent;
46
49
  exports.postResult = postResult;
47
50
  exports.postEvidence = postEvidence;
51
+ exports.postHealFix = postHealFix;
52
+ exports.postHealFinalize = postHealFinalize;
48
53
  exports.postComplete = postComplete;
49
54
  const fs = __importStar(require("fs"));
50
55
  /** Raised for any non-2xx/204 response; carries the HTTP status for callers that care (e.g. 409). */
@@ -76,6 +81,16 @@ async function redeemDevice(serverUrl, code, name) {
76
81
  await throwIfNotOk(res);
77
82
  return (await res.json());
78
83
  }
84
+ /** Self-revoke this device on the server (the "Disconnect" action). Best-effort:
85
+ * the caller wipes the local token regardless, so a failure here only means the
86
+ * server list clears on `last_seen_at` staleness instead of immediately. */
87
+ async function disconnectDevice(cfg) {
88
+ const res = await fetch(`${cfg.serverUrl}/agent/disconnect`, {
89
+ method: "POST",
90
+ headers: authHeaders(cfg.deviceToken),
91
+ });
92
+ await throwIfNotOk(res);
93
+ }
79
94
  /** Long-poll claim the next queued job for this device's owner. `null` on 204 (nothing queued). */
80
95
  async function claimNextJob(cfg) {
81
96
  const res = await fetch(`${cfg.serverUrl}/agent/jobs/next`, {
@@ -87,6 +102,26 @@ async function claimNextJob(cfg) {
87
102
  await throwIfNotOk(res);
88
103
  return (await res.json());
89
104
  }
105
+ /** Claim the next queued auth-capture for this device's owner. `null` on 204. */
106
+ async function claimNextCapture(cfg) {
107
+ const res = await fetch(`${cfg.serverUrl}/agent/auth/next`, {
108
+ method: "POST",
109
+ headers: authHeaders(cfg.deviceToken),
110
+ });
111
+ if (res.status === 204)
112
+ return null;
113
+ await throwIfNotOk(res);
114
+ return (await res.json());
115
+ }
116
+ /** Report a capture's outcome so the server clears "capturing" + stamps the marker. */
117
+ async function postCaptureComplete(cfg, captureId, ok, error) {
118
+ const res = await fetch(`${cfg.serverUrl}/agent/auth/${captureId}/complete`, {
119
+ method: "POST",
120
+ headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
121
+ body: JSON.stringify({ ok, error }),
122
+ });
123
+ await throwIfNotOk(res);
124
+ }
90
125
  /** Push one progress event; the server re-emits it on the run's WebSocket unchanged. */
91
126
  async function postEvent(cfg, executionId, event, payload) {
92
127
  const res = await fetch(`${cfg.serverUrl}/agent/jobs/${executionId}/events`, {
@@ -120,6 +155,25 @@ async function postEvidence(cfg, executionId, ev) {
120
155
  });
121
156
  await throwIfNotOk(res);
122
157
  }
158
+ /** Ask the server to classify + fix one failed heal attempt (Claude + KB live server-side). */
159
+ async function postHealFix(cfg, caseId, body) {
160
+ const res = await fetch(`${cfg.serverUrl}/agent/heal/${caseId}/fix`, {
161
+ method: "POST",
162
+ headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
163
+ body: JSON.stringify(body),
164
+ });
165
+ await throwIfNotOk(res);
166
+ return (await res.json());
167
+ }
168
+ /** Persist the heal's final outcome + feed a passing DOM-grounded heal into the KB. */
169
+ async function postHealFinalize(cfg, caseId, body) {
170
+ const res = await fetch(`${cfg.serverUrl}/agent/heal/${caseId}/finalize`, {
171
+ method: "POST",
172
+ headers: { ...authHeaders(cfg.deviceToken), "Content-Type": "application/json" },
173
+ body: JSON.stringify(body),
174
+ });
175
+ await throwIfNotOk(res);
176
+ }
123
177
  /** Finalize an execution with its aggregate counts + captured log tail. */
124
178
  async function postComplete(cfg, executionId, body) {
125
179
  const res = await fetch(`${cfg.serverUrl}/agent/jobs/${executionId}/complete`, {
package/dist/src/cli.js CHANGED
@@ -48,6 +48,7 @@ const os = __importStar(require("os"));
48
48
  const config_1 = require("./config");
49
49
  const api_1 = require("./api");
50
50
  const runner_1 = require("./runner");
51
+ const ui_1 = require("./ui");
51
52
  /** How the user invoked this agent, for accurate "run X" hints: the bare command
52
53
  * inside a packaged bundle, else the `npx` form of the published package. */
53
54
  function invocation() {
@@ -59,10 +60,10 @@ program
59
60
  .command("pair")
60
61
  .description("Redeem a one-time pairing code (from the Q-Agent SPA) for a device token")
61
62
  .argument("<code>", "pairing code shown in the SPA")
62
- .option("--server <url>", "Q-Agent server base URL", "http://127.0.0.1:8787")
63
+ .option("--server <url>", "Q-Agent server base URL (defaults to the server baked into this build)")
63
64
  .option("--name <name>", "device name shown in the paired-devices list", os.hostname())
64
65
  .action(async (code, opts) => {
65
- const serverUrl = opts.server.replace(/\/+$/, "");
66
+ const serverUrl = (opts.server || (0, config_1.defaultServerUrl)() || "http://127.0.0.1:8787").replace(/\/+$/, "");
66
67
  try {
67
68
  const { deviceToken, deviceId } = await (0, api_1.redeemDevice)(serverUrl, code, opts.name);
68
69
  const cfg = { serverUrl, deviceToken, deviceId, deviceName: opts.name };
@@ -117,6 +118,14 @@ program
117
118
  (0, config_1.clearConfig)();
118
119
  console.log(`Device token cleared. Run \`${invocation()} pair <code>\` to pair again.`);
119
120
  });
121
+ program
122
+ .command("ui", { isDefault: true })
123
+ .description("Open the local web UI to pair and watch progress in your browser (default)")
124
+ .option("--port <port>", "port for the local UI server (default 7420)")
125
+ .option("--no-open", "start the UI server without opening a browser")
126
+ .action((opts) => {
127
+ (0, ui_1.startUi)({ port: opts.port ? Number(opts.port) : undefined, open: opts.open !== false });
128
+ });
120
129
  program.parseAsync(process.argv).catch((err) => {
121
130
  console.error(err);
122
131
  process.exitCode = 1;
@@ -38,6 +38,7 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  };
39
39
  })();
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.defaultServerUrl = defaultServerUrl;
41
42
  exports.configDir = configDir;
42
43
  exports.loadConfig = loadConfig;
43
44
  exports.saveConfig = saveConfig;
@@ -45,6 +46,30 @@ exports.clearConfig = clearConfig;
45
46
  const fs = __importStar(require("fs"));
46
47
  const os = __importStar(require("os"));
47
48
  const path = __importStar(require("path"));
49
+ /** The server URL baked into this build, so the packaged desktop app can
50
+ * pre-fill it and the user only types the 6-digit code (no URL to enter).
51
+ *
52
+ * Resolution: a runtime ``QAGENT_SERVER`` env override wins (dev/testing), then
53
+ * the ``qagentServer`` field electron-builder merges into the packaged
54
+ * ``package.json`` (see build.extraMetadata). Empty for the generic npm/npx
55
+ * build — those callers still pass ``--server`` (or type the URL) as before. */
56
+ function defaultServerUrl() {
57
+ const fromEnv = (process.env.QAGENT_SERVER || "").trim();
58
+ if (fromEnv)
59
+ return fromEnv.replace(/\/+$/, "");
60
+ try {
61
+ // dist/src/config.js → ../../package.json (the app's own manifest, which in
62
+ // a packaged build carries the baked-in qagentServer).
63
+ const pkgPath = path.join(__dirname, "..", "..", "package.json");
64
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
65
+ if (typeof pkg.qagentServer === "string")
66
+ return pkg.qagentServer.trim().replace(/\/+$/, "");
67
+ }
68
+ catch {
69
+ // Generic build / manifest unreadable — no baked server.
70
+ }
71
+ return "";
72
+ }
48
73
  /** Directory holding the agent's persisted config + local session store. */
49
74
  function configDir() {
50
75
  return path.join(os.homedir(), ".qagent-agent");
@@ -74,7 +74,10 @@ async function ensureChromium() {
74
74
  return true;
75
75
  console.log("Chromium not found — installing Playwright's Chromium (one-time download)...");
76
76
  const code = await new Promise((resolve) => {
77
- const child = (0, child_process_1.spawn)((0, paths_1.nodeBin)(), [(0, paths_1.playwrightCli)(), "install", "chromium"], { stdio: "inherit" });
77
+ const child = (0, child_process_1.spawn)((0, paths_1.nodeBin)(), [(0, paths_1.playwrightCli)(), "install", "chromium"], {
78
+ stdio: "inherit",
79
+ env: { ...process.env, ...(0, paths_1.childNodeEnv)() },
80
+ });
78
81
  child.on("close", resolve);
79
82
  child.on("error", () => resolve(null));
80
83
  });
package/dist/src/paths.js CHANGED
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.firstExisting = firstExisting;
37
+ exports.isElectron = isElectron;
38
+ exports.childNodeEnv = childNodeEnv;
37
39
  exports.packagedRoot = packagedRoot;
38
40
  exports.nodeBin = nodeBin;
39
41
  exports.agentNodeModules = agentNodeModules;
@@ -61,6 +63,19 @@ function firstExisting(candidates) {
61
63
  }
62
64
  return null;
63
65
  }
66
+ /** True when running inside Electron (the desktop app) rather than plain Node. */
67
+ function isElectron() {
68
+ return Boolean(process.versions?.electron);
69
+ }
70
+ /**
71
+ * Environment additions for spawned child node processes. Under Electron,
72
+ * `process.execPath` is the Electron binary, so `nodeBin()` returns it — set
73
+ * ELECTRON_RUN_AS_NODE=1 so it runs the given script as Node rather than
74
+ * launching another app window.
75
+ */
76
+ function childNodeEnv() {
77
+ return isElectron() ? { ELECTRON_RUN_AS_NODE: "1" } : {};
78
+ }
64
79
  /** True when running as a Node Single Executable Application (the packaged .exe). */
65
80
  function isSea() {
66
81
  try {
@@ -1,10 +1,10 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Port of `api/app/services/playwright_runner.py`'s config template
4
- * (`_PLAYWRIGHT_CONFIG_TEMPLATE` + `_write_config`) and the sessionStorage
5
- * fixture shim (`_auth_fixtures_ts` + `_apply_auth_fixtures`), reproduced
6
- * faithfully so a spec that runs on the server and one that runs via the
7
- * Local Agent behave identically.
4
+ * (`_PLAYWRIGHT_CONFIG_TEMPLATE` + `_write_config`) and the injected-fixtures
5
+ * shim (`_fixtures_ts` + `_apply_fixtures`), reproduced faithfully so a spec
6
+ * that runs on the server and one that runs via the Local Agent behave
7
+ * identically including always-on DOM capture (raw + distilled).
8
8
  */
9
9
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
10
  if (k2 === undefined) k2 = k;
@@ -41,8 +41,8 @@ var __importStar = (this && this.__importStar) || (function () {
41
41
  })();
42
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
43
  exports.writeConfig = writeConfig;
44
- exports.authFixturesTs = authFixturesTs;
45
- exports.applyAuthFixtures = applyAuthFixtures;
44
+ exports.fixturesTs = fixturesTs;
45
+ exports.applyFixtures = applyFixtures;
46
46
  const fs = __importStar(require("fs"));
47
47
  const path = __importStar(require("path"));
48
48
  const PLAYWRIGHT_CONFIG_TEMPLATE = `import { defineConfig } from '@playwright/test';
@@ -83,14 +83,37 @@ function writeConfig(specDir, workers, headless, baseUrl = "", storageState = ""
83
83
  fs.writeFileSync(path.join(specDir, "playwright.config.ts"), content, "utf-8");
84
84
  }
85
85
  /**
86
- * TypeScript for a generated `fixtures.ts` that replays sessionStorage
87
- * port of `_auth_fixtures_ts`.
86
+ * TypeScript for a generated `fixtures.ts` that captures the page DOM after
87
+ * every test, and optionally replays a captured sessionStorage — port of
88
+ * `_fixtures_ts`.
89
+ *
90
+ * The module re-exports Playwright's `test` extended with:
91
+ * - an `{auto: true}` fixture that, after each test, best-effort attaches the
92
+ * live page's raw HTML (`qagent-dom-raw`) and a distilled inventory of the
93
+ * page's interactable elements (`qagent-dom-distilled`) via `testInfo.attach`,
94
+ * so self-heal can ground on the real DOM. Wrapped in try/catch so it never
95
+ * fails a test.
96
+ * - (only when `replaySession`) a `context` override that restores the captured
97
+ * `sessionStorage` (MSAL/SPA tokens) for the current origin before app code runs.
88
98
  *
89
99
  * @param sessionFile Absolute path to the captured `sessionStorage.json`;
90
- * embedded as a JSON-encoded string literal so the fixture reads it at
91
- * runtime.
100
+ * embedded as a JSON-encoded string literal, read at runtime only when
101
+ * `replaySession` is set.
102
+ * @param replaySession Whether to inject the sessionStorage-replay `context`
103
+ * override (DOM capture is always injected regardless).
92
104
  */
93
- function authFixturesTs(sessionFile) {
105
+ function fixturesTs(sessionFile, replaySession) {
106
+ const contextFixture = replaySession
107
+ ? " context: async ({ context }, use) => {\n" +
108
+ " await context.addInitScript((sessions: Record<string, Record<string, string>>) => {\n" +
109
+ " try {\n" +
110
+ " const entries = sessions[location.origin];\n" +
111
+ " if (entries) for (const k in entries) window.sessionStorage.setItem(k, entries[k]);\n" +
112
+ " } catch {}\n" +
113
+ " }, SESSIONS);\n" +
114
+ " await use(context);\n" +
115
+ " },\n"
116
+ : "";
94
117
  return ("import { test as base, expect } from '@playwright/test';\n" +
95
118
  "import * as fs from 'fs';\n" +
96
119
  "\n" +
@@ -98,47 +121,71 @@ function authFixturesTs(sessionFile) {
98
121
  "let SESSIONS: Record<string, Record<string, string>> = {};\n" +
99
122
  "try { SESSIONS = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8')); } catch {}\n" +
100
123
  "\n" +
101
- "export const test = base.extend({\n" +
102
- " context: async ({ context }, use) => {\n" +
103
- " await context.addInitScript((sessions: Record<string, Record<string, string>>) => {\n" +
104
- " try {\n" +
105
- " const entries = sessions[location.origin];\n" +
106
- " if (entries) for (const k in entries) window.sessionStorage.setItem(k, entries[k]);\n" +
107
- " } catch {}\n" +
108
- " }, SESSIONS);\n" +
109
- " await use(context);\n" +
110
- " },\n" +
124
+ "export const test = base.extend<{ _domCapture: void }>({\n" +
125
+ contextFixture +
126
+ " // Always-on DOM capture: after each test, snapshot the live page so the\n" +
127
+ " // runner (evidence) and self-heal loop (real selectors) can use it.\n" +
128
+ " _domCapture: [async ({ page }, use, testInfo) => {\n" +
129
+ " await use();\n" +
130
+ " try {\n" +
131
+ " const raw = await page.content();\n" +
132
+ " const rawPath = testInfo.outputPath('qagent-dom-raw.html');\n" +
133
+ " fs.writeFileSync(rawPath, raw, 'utf-8');\n" +
134
+ " await testInfo.attach('qagent-dom-raw', { path: rawPath, contentType: 'text/html' });\n" +
135
+ " } catch {}\n" +
136
+ " try {\n" +
137
+ " const snapshot = await page.evaluate(() => {\n" +
138
+ " const SEL = 'a,button,input,select,textarea,[role],[data-testid],[data-test],[id]';\n" +
139
+ " const elements = Array.from(document.querySelectorAll(SEL)).slice(0, 400).map((node) => {\n" +
140
+ " const el = node as HTMLElement;\n" +
141
+ " const text = (el.innerText || '').trim().slice(0, 80);\n" +
142
+ " return {\n" +
143
+ " tag: el.tagName.toLowerCase(),\n" +
144
+ " role: el.getAttribute('role') || undefined,\n" +
145
+ " testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || undefined,\n" +
146
+ " id: el.id || undefined,\n" +
147
+ " name: el.getAttribute('name') || undefined,\n" +
148
+ " text: text || undefined,\n" +
149
+ " placeholder: el.getAttribute('placeholder') || undefined,\n" +
150
+ " type: el.getAttribute('type') || undefined,\n" +
151
+ " };\n" +
152
+ " });\n" +
153
+ " return { path: location.pathname, url: location.href, elements };\n" +
154
+ " });\n" +
155
+ " const distilledPath = testInfo.outputPath('qagent-dom-distilled.json');\n" +
156
+ " fs.writeFileSync(distilledPath, JSON.stringify(snapshot), 'utf-8');\n" +
157
+ " await testInfo.attach('qagent-dom-distilled', { path: distilledPath, contentType: 'application/json' });\n" +
158
+ " } catch {}\n" +
159
+ " }, { auto: true }],\n" +
111
160
  "});\n" +
112
161
  "\n" +
113
162
  "export { expect };\n" +
114
163
  "export type * from '@playwright/test';\n");
115
164
  }
116
165
  /**
117
- * Rewrite each spec's Playwright import to use (or stop using) the
118
- * sessionStorage fixture — port of `_apply_auth_fixtures`. Only the module
119
- * specifier is touched.
166
+ * Point each spec's Playwright import at the generated `fixtures.ts` and write
167
+ * it — port of `_apply_fixtures`. Only the module specifier is touched.
168
+ *
169
+ * DOM capture is always on, so fixtures are ALWAYS injected (unlike the previous
170
+ * auth-only behavior): each spec's `'@playwright/test'` import is rewritten to
171
+ * `'./fixtures'` and `fixtures.ts` is (re)written every run. `replaySession` only
172
+ * controls whether the generated module also replays the captured sessionStorage.
120
173
  *
121
- * The Python version globs `*.spec.ts` in `specDir`; the agent already
122
- * knows exactly which spec filenames it wrote for this job, so
123
- * `specFilenames` is passed explicitly instead of re-scanning the
124
- * directory (equivalent behavior, no extra glob dependency).
174
+ * The Python version globs `*.spec.ts` in `specDir`; the agent already knows
175
+ * exactly which spec filenames it wrote for this job, so `specFilenames` is
176
+ * passed explicitly instead of re-scanning the directory.
125
177
  *
126
178
  * @param specDir The job's local workdir containing the spec files.
127
179
  * @param specFilenames The spec filenames written into `specDir` for this job.
128
180
  * @param sessionFile Absolute path to the `sessionStorage.json` snapshot embedded
129
181
  * in the generated `fixtures.ts`.
130
- * @param enabled Whether sessionStorage replay is active for this run.
182
+ * @param replaySession Whether sessionStorage replay is active for this run.
131
183
  */
132
- function applyAuthFixtures(specDir, specFilenames, sessionFile, enabled) {
133
- const replacements = enabled
134
- ? [
135
- ["'@playwright/test'", "'./fixtures'"],
136
- ['"@playwright/test"', '"./fixtures"'],
137
- ]
138
- : [
139
- ["'./fixtures'", "'@playwright/test'"],
140
- ['"./fixtures"', '"@playwright/test"'],
141
- ];
184
+ function applyFixtures(specDir, specFilenames, sessionFile, replaySession) {
185
+ const replacements = [
186
+ ["'@playwright/test'", "'./fixtures'"],
187
+ ['"@playwright/test"', '"./fixtures"'],
188
+ ];
142
189
  for (const filename of specFilenames) {
143
190
  const specPath = path.join(specDir, filename);
144
191
  let text;
@@ -156,7 +203,5 @@ function applyAuthFixtures(specDir, specFilenames, sessionFile, enabled) {
156
203
  fs.writeFileSync(specPath, newText, "utf-8");
157
204
  }
158
205
  }
159
- if (enabled) {
160
- fs.writeFileSync(path.join(specDir, "fixtures.ts"), authFixturesTs(sessionFile), "utf-8");
161
- }
206
+ fs.writeFileSync(path.join(specDir, "fixtures.ts"), fixturesTs(sessionFile, replaySession), "utf-8");
162
207
  }
@@ -22,6 +22,10 @@ const ATTACHMENT_KIND_MAP = {
22
22
  screenshot: "screenshot",
23
23
  video: "video",
24
24
  trace: "trace",
25
+ // DOM captured by the injected fixtures (see playwrightConfig.fixturesTs):
26
+ // raw page HTML + a distilled interactable-element inventory.
27
+ "qagent-dom-raw": "dom",
28
+ "qagent-dom-distilled": "dom-distilled",
25
29
  };
26
30
  /* eslint-enable @typescript-eslint/no-explicit-any */
27
31
  /**