From 1c2a8101ee6d125b62da65b5493ec5adfb6cd257 Mon Sep 17 00:00:00 2001 From: Raza Rauf Date: Thu, 22 Jan 2026 20:33:55 +0500 Subject: [PATCH] refactor: modularize preload.ts into domain-specific modules with tests --- package-lock.json | 535 ++- package.json | 10 +- scripts/build-preload.mjs | 45 + src/__tests__/main/preload/agents.test.ts | 379 ++ .../main/preload/attachments.test.ts | 129 + src/__tests__/main/preload/autorun.test.ts | 490 +++ src/__tests__/main/preload/commands.test.ts | 285 ++ src/__tests__/main/preload/context.test.ts | 132 + src/__tests__/main/preload/debug.test.ts | 171 + src/__tests__/main/preload/files.test.ts | 361 ++ src/__tests__/main/preload/fs.test.ts | 212 + src/__tests__/main/preload/git.test.ts | 324 ++ src/__tests__/main/preload/groupChat.test.ts | 729 ++++ .../main/preload/leaderboard.test.ts | 176 + src/__tests__/main/preload/logger.test.ts | 284 ++ .../main/preload/notifications.test.ts | 125 + src/__tests__/main/preload/process.test.ts | 298 ++ src/__tests__/main/preload/sessions.test.ts | 448 ++ src/__tests__/main/preload/settings.test.ts | 200 + src/__tests__/main/preload/sshRemote.test.ts | 211 + src/__tests__/main/preload/stats.test.ts | 368 ++ src/__tests__/main/preload/system.test.ts | 553 +++ src/__tests__/main/preload/web.test.ts | 272 ++ src/main/auto-updater.ts | 38 +- src/main/index.ts | 61 +- src/main/preload.ts | 3761 ----------------- src/main/preload/agents.ts | 189 + src/main/preload/attachments.ts | 96 + src/main/preload/autorun.ts | 174 + src/main/preload/commands.ts | 132 + src/main/preload/context.ts | 66 + src/main/preload/debug.ts | 66 + src/main/preload/files.ts | 108 + src/main/preload/fs.ts | 125 + src/main/preload/git.ts | 359 ++ src/main/preload/groupChat.ts | 213 + src/main/preload/index.ts | 407 ++ src/main/preload/leaderboard.ts | 221 + src/main/preload/logger.ts | 58 + src/main/preload/notifications.ts | 73 + src/main/preload/process.ts | 420 ++ src/main/preload/sessions.ts | 352 ++ src/main/preload/settings.ts | 62 + src/main/preload/sshRemote.ts | 74 + src/main/preload/stats.ts | 207 + src/main/preload/system.ts | 206 + src/main/preload/web.ts | 103 + 47 files changed, 10498 insertions(+), 3810 deletions(-) create mode 100644 scripts/build-preload.mjs create mode 100644 src/__tests__/main/preload/agents.test.ts create mode 100644 src/__tests__/main/preload/attachments.test.ts create mode 100644 src/__tests__/main/preload/autorun.test.ts create mode 100644 src/__tests__/main/preload/commands.test.ts create mode 100644 src/__tests__/main/preload/context.test.ts create mode 100644 src/__tests__/main/preload/debug.test.ts create mode 100644 src/__tests__/main/preload/files.test.ts create mode 100644 src/__tests__/main/preload/fs.test.ts create mode 100644 src/__tests__/main/preload/git.test.ts create mode 100644 src/__tests__/main/preload/groupChat.test.ts create mode 100644 src/__tests__/main/preload/leaderboard.test.ts create mode 100644 src/__tests__/main/preload/logger.test.ts create mode 100644 src/__tests__/main/preload/notifications.test.ts create mode 100644 src/__tests__/main/preload/process.test.ts create mode 100644 src/__tests__/main/preload/sessions.test.ts create mode 100644 src/__tests__/main/preload/settings.test.ts create mode 100644 src/__tests__/main/preload/sshRemote.test.ts create mode 100644 src/__tests__/main/preload/stats.test.ts create mode 100644 src/__tests__/main/preload/system.test.ts create mode 100644 src/__tests__/main/preload/web.test.ts delete mode 100644 src/main/preload.ts create mode 100644 src/main/preload/agents.ts create mode 100644 src/main/preload/attachments.ts create mode 100644 src/main/preload/autorun.ts create mode 100644 src/main/preload/commands.ts create mode 100644 src/main/preload/context.ts create mode 100644 src/main/preload/debug.ts create mode 100644 src/main/preload/files.ts create mode 100644 src/main/preload/fs.ts create mode 100644 src/main/preload/git.ts create mode 100644 src/main/preload/groupChat.ts create mode 100644 src/main/preload/index.ts create mode 100644 src/main/preload/leaderboard.ts create mode 100644 src/main/preload/logger.ts create mode 100644 src/main/preload/notifications.ts create mode 100644 src/main/preload/process.ts create mode 100644 src/main/preload/sessions.ts create mode 100644 src/main/preload/settings.ts create mode 100644 src/main/preload/sshRemote.ts create mode 100644 src/main/preload/stats.ts create mode 100644 src/main/preload/system.ts create mode 100644 src/main/preload/web.ts diff --git a/package-lock.json b/package-lock.json index 02397c75..c93d944a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,9 +78,10 @@ "@vitest/coverage-v8": "^4.0.15", "@welldone-software/why-did-you-render": "^8.0.3", "autoprefixer": "^10.4.16", + "baseline-browser-mapping": "^2.9.17", "canvas": "^3.2.0", "concurrently": "^8.2.2", - "electron": "^28.1.0", + "electron": "^28.3.3", "electron-builder": "^24.9.1", "electron-devtools-installer": "^4.0.0", "electron-playwright-helpers": "^2.0.1", @@ -91,7 +92,9 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "globals": "^16.5.0", + "husky": "^9.1.7", "jsdom": "^27.2.0", + "lint-staged": "^16.2.7", "lucide-react": "^0.303.0", "playwright": "^1.57.0", "postcss": "^8.4.33", @@ -4914,6 +4917,22 @@ "ajv": "^6.9.1" } }, + "node_modules/ansi-escapes": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", + "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -5709,9 +5728,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.30", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", - "integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==", + "version": "2.9.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz", + "integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6562,6 +6581,13 @@ "color-support": "bin.js" } }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -7890,9 +7916,9 @@ "license": "Apache-2.0" }, "node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -8764,6 +8790,19 @@ "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -10151,6 +10190,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -10881,6 +10933,22 @@ "ms": "^2.0.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", @@ -12197,6 +12265,201 @@ "dev": true, "license": "MIT" }, + "node_modules/lint-staged": { + "version": "16.2.7", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", + "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.2", + "listr2": "^9.0.5", + "micromatch": "^4.0.8", + "nano-spawn": "^2.0.0", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.1" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/cli-truncate": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", + "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/local-pkg": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", @@ -12311,6 +12574,193 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -12826,9 +13276,9 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -13588,6 +14038,19 @@ "node": ">=8" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", @@ -13923,6 +14386,19 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nano-spawn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", + "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -14667,6 +15143,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", @@ -16939,6 +17428,16 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -19784,6 +20283,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index a2a0df30..43f25c66 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,14 @@ "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", "dev:prod-data": "USE_PROD_DATA=1 concurrently \"npm run dev:main:prod-data\" \"npm run dev:renderer\"", "dev:demo": "MAESTRO_DEMO_DIR=/tmp/maestro-demo npm run dev", - "dev:main": "npm run build:prompts && tsc -p tsconfig.main.json && NODE_ENV=development electron .", - "dev:main:prod-data": "npm run build:prompts && tsc -p tsconfig.main.json && NODE_ENV=development USE_PROD_DATA=1 electron .", + "dev:main": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development electron .", + "dev:main:prod-data": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development USE_PROD_DATA=1 electron .", "dev:renderer": "vite", "dev:web": "vite --config vite.config.web.mts", - "build": "npm run build:prompts && npm run build:main && npm run build:renderer && npm run build:web && npm run build:cli", + "build": "npm run build:prompts && npm run build:main && npm run build:preload && npm run build:renderer && npm run build:web && npm run build:cli", "build:prompts": "node scripts/generate-prompts.mjs", "build:main": "tsc -p tsconfig.main.json", + "build:preload": "node scripts/build-preload.mjs", "build:cli": "node scripts/build-cli.mjs", "build:renderer": "vite build", "build:web": "vite build --config vite.config.web.mts", @@ -273,9 +274,10 @@ "@vitest/coverage-v8": "^4.0.15", "@welldone-software/why-did-you-render": "^8.0.3", "autoprefixer": "^10.4.16", + "baseline-browser-mapping": "^2.9.17", "canvas": "^3.2.0", "concurrently": "^8.2.2", - "electron": "^28.1.0", + "electron": "^28.3.3", "electron-builder": "^24.9.1", "electron-devtools-installer": "^4.0.0", "electron-playwright-helpers": "^2.0.1", diff --git a/scripts/build-preload.mjs b/scripts/build-preload.mjs new file mode 100644 index 00000000..81f17726 --- /dev/null +++ b/scripts/build-preload.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/** + * Build script for the Electron preload script using esbuild. + * + * Bundles the preload script into a single JavaScript file. + * This is necessary because Electron's sandboxed preload environment + * doesn't support multi-file CommonJS requires the same way Node.js does. + */ + +import * as esbuild from 'esbuild'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '..'); + +const outfile = path.join(rootDir, 'dist/main/preload.js'); + +async function build() { + console.log('Building preload script with esbuild...'); + + try { + await esbuild.build({ + entryPoints: [path.join(rootDir, 'src/main/preload/index.ts')], + bundle: true, + platform: 'node', + target: 'node18', // Match Electron's Node version + outfile, + format: 'cjs', + sourcemap: false, + minify: false, // Keep readable for debugging + external: ['electron'], // Don't bundle electron - it's provided by Electron runtime + }); + + const stats = fs.statSync(outfile); + const sizeKB = (stats.size / 1024).toFixed(1); + console.log(`✓ Built ${outfile} (${sizeKB} KB)`); + } catch (error) { + console.error('Preload build failed:', error); + process.exit(1); + } +} + +build(); diff --git a/src/__tests__/main/preload/agents.test.ts b/src/__tests__/main/preload/agents.test.ts new file mode 100644 index 00000000..ec37624e --- /dev/null +++ b/src/__tests__/main/preload/agents.test.ts @@ -0,0 +1,379 @@ +/** + * Tests for agents preload API + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + }, +})); + +import { createAgentsApi } from '../../../main/preload/agents'; + +describe('Agents Preload API', () => { + let api: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + api = createAgentsApi(); + }); + + describe('detect', () => { + it('should invoke agents:detect', async () => { + const mockAgents = [ + { + id: 'claude-code', + name: 'Claude Code', + command: 'claude', + available: true, + path: '/usr/local/bin/claude', + }, + ]; + mockInvoke.mockResolvedValue(mockAgents); + + const result = await api.detect(); + + expect(mockInvoke).toHaveBeenCalledWith('agents:detect', undefined); + expect(result).toEqual(mockAgents); + }); + + it('should invoke agents:detect with SSH remote', async () => { + mockInvoke.mockResolvedValue([]); + + await api.detect('remote-1'); + + expect(mockInvoke).toHaveBeenCalledWith('agents:detect', 'remote-1'); + }); + }); + + describe('refresh', () => { + it('should invoke agents:refresh without parameters', async () => { + const mockResult = { agents: [], debugInfo: {} }; + mockInvoke.mockResolvedValue(mockResult); + + const result = await api.refresh(); + + expect(mockInvoke).toHaveBeenCalledWith('agents:refresh', undefined, undefined); + expect(result).toEqual(mockResult); + }); + + it('should invoke agents:refresh with agentId', async () => { + mockInvoke.mockResolvedValue({ agents: [], debugInfo: {} }); + + await api.refresh('claude-code'); + + expect(mockInvoke).toHaveBeenCalledWith('agents:refresh', 'claude-code', undefined); + }); + + it('should invoke agents:refresh with SSH remote', async () => { + mockInvoke.mockResolvedValue({ agents: [], debugInfo: {} }); + + await api.refresh(undefined, 'remote-1'); + + expect(mockInvoke).toHaveBeenCalledWith('agents:refresh', undefined, 'remote-1'); + }); + }); + + describe('get', () => { + it('should invoke agents:get with agentId', async () => { + const mockAgent = { + id: 'claude-code', + name: 'Claude Code', + command: 'claude', + available: true, + }; + mockInvoke.mockResolvedValue(mockAgent); + + const result = await api.get('claude-code'); + + expect(mockInvoke).toHaveBeenCalledWith('agents:get', 'claude-code'); + expect(result).toEqual(mockAgent); + }); + + it('should return null for non-existent agent', async () => { + mockInvoke.mockResolvedValue(null); + + const result = await api.get('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('getCapabilities', () => { + it('should invoke agents:getCapabilities with agentId', async () => { + const mockCapabilities = { + supportsResume: true, + supportsReadOnlyMode: true, + supportsJsonOutput: true, + supportsSessionId: true, + supportsImageInput: true, + supportsImageInputOnResume: false, + supportsSlashCommands: true, + supportsSessionStorage: true, + supportsCostTracking: true, + supportsUsageStats: true, + supportsBatchMode: true, + requiresPromptToStart: false, + supportsStreaming: true, + supportsResultMessages: true, + supportsModelSelection: false, + supportsStreamJsonInput: true, + }; + mockInvoke.mockResolvedValue(mockCapabilities); + + const result = await api.getCapabilities('claude-code'); + + expect(mockInvoke).toHaveBeenCalledWith('agents:getCapabilities', 'claude-code'); + expect(result).toEqual(mockCapabilities); + }); + }); + + describe('getConfig', () => { + it('should invoke agents:getConfig with agentId', async () => { + const mockConfig = { theme: 'dark', autoSave: true }; + mockInvoke.mockResolvedValue(mockConfig); + + const result = await api.getConfig('claude-code'); + + expect(mockInvoke).toHaveBeenCalledWith('agents:getConfig', 'claude-code'); + expect(result).toEqual(mockConfig); + }); + }); + + describe('setConfig', () => { + it('should invoke agents:setConfig with agentId and config', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.setConfig('claude-code', { theme: 'light' }); + + expect(mockInvoke).toHaveBeenCalledWith('agents:setConfig', 'claude-code', { + theme: 'light', + }); + expect(result).toBe(true); + }); + }); + + describe('getConfigValue', () => { + it('should invoke agents:getConfigValue with agentId and key', async () => { + mockInvoke.mockResolvedValue('dark'); + + const result = await api.getConfigValue('claude-code', 'theme'); + + expect(mockInvoke).toHaveBeenCalledWith('agents:getConfigValue', 'claude-code', 'theme'); + expect(result).toBe('dark'); + }); + }); + + describe('setConfigValue', () => { + it('should invoke agents:setConfigValue with agentId, key, and value', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.setConfigValue('claude-code', 'theme', 'light'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agents:setConfigValue', + 'claude-code', + 'theme', + 'light' + ); + expect(result).toBe(true); + }); + }); + + describe('setCustomPath', () => { + it('should invoke agents:setCustomPath with agentId and customPath', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.setCustomPath('claude-code', '/custom/path/claude'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agents:setCustomPath', + 'claude-code', + '/custom/path/claude' + ); + expect(result).toBe(true); + }); + + it('should invoke agents:setCustomPath with null to clear', async () => { + mockInvoke.mockResolvedValue(true); + + await api.setCustomPath('claude-code', null); + + expect(mockInvoke).toHaveBeenCalledWith('agents:setCustomPath', 'claude-code', null); + }); + }); + + describe('getCustomPath', () => { + it('should invoke agents:getCustomPath with agentId', async () => { + mockInvoke.mockResolvedValue('/custom/path/claude'); + + const result = await api.getCustomPath('claude-code'); + + expect(mockInvoke).toHaveBeenCalledWith('agents:getCustomPath', 'claude-code'); + expect(result).toBe('/custom/path/claude'); + }); + + it('should return null when no custom path', async () => { + mockInvoke.mockResolvedValue(null); + + const result = await api.getCustomPath('claude-code'); + + expect(result).toBeNull(); + }); + }); + + describe('getAllCustomPaths', () => { + it('should invoke agents:getAllCustomPaths', async () => { + const mockPaths = { + 'claude-code': '/custom/claude', + codex: '/custom/codex', + }; + mockInvoke.mockResolvedValue(mockPaths); + + const result = await api.getAllCustomPaths(); + + expect(mockInvoke).toHaveBeenCalledWith('agents:getAllCustomPaths'); + expect(result).toEqual(mockPaths); + }); + }); + + describe('setCustomArgs', () => { + it('should invoke agents:setCustomArgs', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.setCustomArgs('claude-code', '--verbose --debug'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agents:setCustomArgs', + 'claude-code', + '--verbose --debug' + ); + expect(result).toBe(true); + }); + }); + + describe('getCustomArgs', () => { + it('should invoke agents:getCustomArgs', async () => { + mockInvoke.mockResolvedValue('--verbose'); + + const result = await api.getCustomArgs('claude-code'); + + expect(mockInvoke).toHaveBeenCalledWith('agents:getCustomArgs', 'claude-code'); + expect(result).toBe('--verbose'); + }); + }); + + describe('getAllCustomArgs', () => { + it('should invoke agents:getAllCustomArgs', async () => { + const mockArgs = { 'claude-code': '--verbose' }; + mockInvoke.mockResolvedValue(mockArgs); + + const result = await api.getAllCustomArgs(); + + expect(mockInvoke).toHaveBeenCalledWith('agents:getAllCustomArgs'); + expect(result).toEqual(mockArgs); + }); + }); + + describe('setCustomEnvVars', () => { + it('should invoke agents:setCustomEnvVars', async () => { + mockInvoke.mockResolvedValue(true); + + const envVars = { CLAUDE_API_KEY: 'test-key' }; + const result = await api.setCustomEnvVars('claude-code', envVars); + + expect(mockInvoke).toHaveBeenCalledWith('agents:setCustomEnvVars', 'claude-code', envVars); + expect(result).toBe(true); + }); + }); + + describe('getCustomEnvVars', () => { + it('should invoke agents:getCustomEnvVars', async () => { + const envVars = { CLAUDE_API_KEY: 'test-key' }; + mockInvoke.mockResolvedValue(envVars); + + const result = await api.getCustomEnvVars('claude-code'); + + expect(mockInvoke).toHaveBeenCalledWith('agents:getCustomEnvVars', 'claude-code'); + expect(result).toEqual(envVars); + }); + }); + + describe('getAllCustomEnvVars', () => { + it('should invoke agents:getAllCustomEnvVars', async () => { + const allEnvVars = { + 'claude-code': { CLAUDE_API_KEY: 'key1' }, + codex: { OPENAI_API_KEY: 'key2' }, + }; + mockInvoke.mockResolvedValue(allEnvVars); + + const result = await api.getAllCustomEnvVars(); + + expect(mockInvoke).toHaveBeenCalledWith('agents:getAllCustomEnvVars'); + expect(result).toEqual(allEnvVars); + }); + }); + + describe('getModels', () => { + it('should invoke agents:getModels with agentId', async () => { + const models = ['gpt-4', 'gpt-3.5-turbo']; + mockInvoke.mockResolvedValue(models); + + const result = await api.getModels('opencode'); + + expect(mockInvoke).toHaveBeenCalledWith('agents:getModels', 'opencode', undefined); + expect(result).toEqual(models); + }); + + it('should invoke agents:getModels with forceRefresh', async () => { + mockInvoke.mockResolvedValue([]); + + await api.getModels('opencode', true); + + expect(mockInvoke).toHaveBeenCalledWith('agents:getModels', 'opencode', true); + }); + }); + + describe('discoverSlashCommands', () => { + it('should invoke agents:discoverSlashCommands', async () => { + const commands = ['compact', 'help', 'review']; + mockInvoke.mockResolvedValue(commands); + + const result = await api.discoverSlashCommands('claude-code', '/home/user/project'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agents:discoverSlashCommands', + 'claude-code', + '/home/user/project', + undefined + ); + expect(result).toEqual(commands); + }); + + it('should invoke agents:discoverSlashCommands with customPath', async () => { + mockInvoke.mockResolvedValue(['help']); + + await api.discoverSlashCommands('claude-code', '/home/user/project', '/custom/claude'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agents:discoverSlashCommands', + 'claude-code', + '/home/user/project', + '/custom/claude' + ); + }); + + it('should return null when discovery fails', async () => { + mockInvoke.mockResolvedValue(null); + + const result = await api.discoverSlashCommands('unknown-agent', '/home/user/project'); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/__tests__/main/preload/attachments.test.ts b/src/__tests__/main/preload/attachments.test.ts new file mode 100644 index 00000000..312c9be5 --- /dev/null +++ b/src/__tests__/main/preload/attachments.test.ts @@ -0,0 +1,129 @@ +/** + * Tests for attachments preload API + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + }, +})); + +import { createAttachmentsApi } from '../../../main/preload/attachments'; + +describe('Attachments Preload API', () => { + let api: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + api = createAttachmentsApi(); + }); + + describe('save', () => { + it('should invoke attachments:save with sessionId, base64Data, and filename', async () => { + mockInvoke.mockResolvedValue({ + success: true, + path: '/path/to/file', + filename: 'image.png', + }); + + const result = await api.save('session-123', 'base64data', 'image.png'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'attachments:save', + 'session-123', + 'base64data', + 'image.png' + ); + expect(result.success).toBe(true); + expect(result.path).toBe('/path/to/file'); + expect(result.filename).toBe('image.png'); + }); + + it('should handle errors', async () => { + mockInvoke.mockResolvedValue({ success: false, error: 'Save failed' }); + + const result = await api.save('session-123', 'base64data', 'image.png'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Save failed'); + }); + }); + + describe('load', () => { + it('should invoke attachments:load with sessionId and filename', async () => { + mockInvoke.mockResolvedValue({ + success: true, + dataUrl: 'data:image/png;base64,abc123', + }); + + const result = await api.load('session-123', 'image.png'); + + expect(mockInvoke).toHaveBeenCalledWith('attachments:load', 'session-123', 'image.png'); + expect(result.success).toBe(true); + expect(result.dataUrl).toBe('data:image/png;base64,abc123'); + }); + + it('should handle missing files', async () => { + mockInvoke.mockResolvedValue({ success: false, error: 'File not found' }); + + const result = await api.load('session-123', 'nonexistent.png'); + + expect(result.success).toBe(false); + expect(result.error).toBe('File not found'); + }); + }); + + describe('delete', () => { + it('should invoke attachments:delete with sessionId and filename', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + const result = await api.delete('session-123', 'image.png'); + + expect(mockInvoke).toHaveBeenCalledWith('attachments:delete', 'session-123', 'image.png'); + expect(result.success).toBe(true); + }); + }); + + describe('list', () => { + it('should invoke attachments:list with sessionId', async () => { + mockInvoke.mockResolvedValue({ + success: true, + files: ['image1.png', 'image2.jpg'], + }); + + const result = await api.list('session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('attachments:list', 'session-123'); + expect(result.success).toBe(true); + expect(result.files).toEqual(['image1.png', 'image2.jpg']); + }); + + it('should return empty array when no files exist', async () => { + mockInvoke.mockResolvedValue({ success: true, files: [] }); + + const result = await api.list('session-123'); + + expect(result.files).toEqual([]); + }); + }); + + describe('getPath', () => { + it('should invoke attachments:getPath with sessionId', async () => { + mockInvoke.mockResolvedValue({ + success: true, + path: '/home/user/.maestro/attachments/session-123', + }); + + const result = await api.getPath('session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('attachments:getPath', 'session-123'); + expect(result.success).toBe(true); + expect(result.path).toBe('/home/user/.maestro/attachments/session-123'); + }); + }); +}); diff --git a/src/__tests__/main/preload/autorun.test.ts b/src/__tests__/main/preload/autorun.test.ts new file mode 100644 index 00000000..6330e638 --- /dev/null +++ b/src/__tests__/main/preload/autorun.test.ts @@ -0,0 +1,490 @@ +/** + * Tests for autorun preload API + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); +const mockOn = vi.fn(); +const mockRemoveListener = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + on: (...args: unknown[]) => mockOn(...args), + removeListener: (...args: unknown[]) => mockRemoveListener(...args), + }, +})); + +import { + createAutorunApi, + createPlaybooksApi, + createMarketplaceApi, +} from '../../../main/preload/autorun'; + +describe('Autorun Preload API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createAutorunApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createAutorunApi(); + }); + + describe('listDocs', () => { + it('should invoke autorun:listDocs', async () => { + mockInvoke.mockResolvedValue(['doc1.md', 'doc2.md']); + + const result = await api.listDocs('/project/.maestro'); + + expect(mockInvoke).toHaveBeenCalledWith('autorun:listDocs', '/project/.maestro', undefined); + expect(result).toEqual(['doc1.md', 'doc2.md']); + }); + + it('should invoke with sshRemoteId', async () => { + mockInvoke.mockResolvedValue([]); + + await api.listDocs('/project/.maestro', 'ssh-remote-1'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'autorun:listDocs', + '/project/.maestro', + 'ssh-remote-1' + ); + }); + }); + + describe('hasDocuments', () => { + it('should invoke autorun:hasDocuments', async () => { + mockInvoke.mockResolvedValue({ hasDocuments: true }); + + const result = await api.hasDocuments('/project'); + + expect(mockInvoke).toHaveBeenCalledWith('autorun:hasDocuments', '/project'); + expect(result.hasDocuments).toBe(true); + }); + }); + + describe('readDoc', () => { + it('should invoke autorun:readDoc', async () => { + mockInvoke.mockResolvedValue('# Document Content'); + + const result = await api.readDoc('/project/.maestro', 'tasks.md'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'autorun:readDoc', + '/project/.maestro', + 'tasks.md', + undefined + ); + expect(result).toBe('# Document Content'); + }); + }); + + describe('writeDoc', () => { + it('should invoke autorun:writeDoc', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.writeDoc('/project/.maestro', 'tasks.md', '# New Content'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'autorun:writeDoc', + '/project/.maestro', + 'tasks.md', + '# New Content', + undefined + ); + }); + }); + + describe('saveImage', () => { + it('should invoke autorun:saveImage', async () => { + mockInvoke.mockResolvedValue({ path: 'image.png' }); + + await api.saveImage('/project/.maestro', 'doc1', 'base64data', 'png'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'autorun:saveImage', + '/project/.maestro', + 'doc1', + 'base64data', + 'png' + ); + }); + }); + + describe('deleteImage', () => { + it('should invoke autorun:deleteImage', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.deleteImage('/project/.maestro', 'images/image.png'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'autorun:deleteImage', + '/project/.maestro', + 'images/image.png' + ); + }); + }); + + describe('listImages', () => { + it('should invoke autorun:listImages', async () => { + mockInvoke.mockResolvedValue(['image1.png', 'image2.jpg']); + + const result = await api.listImages('/project/.maestro', 'doc1'); + + expect(mockInvoke).toHaveBeenCalledWith('autorun:listImages', '/project/.maestro', 'doc1'); + expect(result).toEqual(['image1.png', 'image2.jpg']); + }); + }); + + describe('deleteFolder', () => { + it('should invoke autorun:deleteFolder', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.deleteFolder('/project'); + + expect(mockInvoke).toHaveBeenCalledWith('autorun:deleteFolder', '/project'); + }); + }); + + describe('watchFolder', () => { + it('should invoke autorun:watchFolder', async () => { + mockInvoke.mockResolvedValue({}); + + await api.watchFolder('/project/.maestro'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'autorun:watchFolder', + '/project/.maestro', + undefined + ); + }); + + it('should handle remote folder', async () => { + mockInvoke.mockResolvedValue({ + isRemote: true, + message: 'File watching not available for remote', + }); + + const result = await api.watchFolder('/project/.maestro', 'ssh-remote-1'); + + expect(result.isRemote).toBe(true); + }); + }); + + describe('unwatchFolder', () => { + it('should invoke autorun:unwatchFolder', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.unwatchFolder('/project/.maestro'); + + expect(mockInvoke).toHaveBeenCalledWith('autorun:unwatchFolder', '/project/.maestro'); + }); + }); + + describe('onFileChanged', () => { + it('should register event listener and return cleanup function', () => { + const callback = vi.fn(); + + const cleanup = api.onFileChanged(callback); + + expect(mockOn).toHaveBeenCalledWith('autorun:fileChanged', expect.any(Function)); + expect(typeof cleanup).toBe('function'); + }); + + it('should call callback when event is received', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, data: unknown) => void; + + mockOn.mockImplementation( + (_channel: string, handler: (event: unknown, data: unknown) => void) => { + registeredHandler = handler; + } + ); + + api.onFileChanged(callback); + + const data = { folderPath: '/project', filename: 'tasks.md', eventType: 'change' }; + registeredHandler!({}, data); + + expect(callback).toHaveBeenCalledWith(data); + }); + }); + + describe('createBackup', () => { + it('should invoke autorun:createBackup', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.createBackup('/project/.maestro', 'tasks.md'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'autorun:createBackup', + '/project/.maestro', + 'tasks.md' + ); + }); + }); + + describe('restoreBackup', () => { + it('should invoke autorun:restoreBackup', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.restoreBackup('/project/.maestro', 'tasks.md'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'autorun:restoreBackup', + '/project/.maestro', + 'tasks.md' + ); + }); + }); + + describe('deleteBackups', () => { + it('should invoke autorun:deleteBackups', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.deleteBackups('/project/.maestro'); + + expect(mockInvoke).toHaveBeenCalledWith('autorun:deleteBackups', '/project/.maestro'); + }); + }); + + describe('createWorkingCopy', () => { + it('should invoke autorun:createWorkingCopy', async () => { + mockInvoke.mockResolvedValue({ + workingCopyPath: '/project/.maestro/tasks.loop-1.md', + originalPath: '/project/.maestro/tasks.md', + }); + + const result = await api.createWorkingCopy('/project/.maestro', 'tasks.md', 1); + + expect(mockInvoke).toHaveBeenCalledWith( + 'autorun:createWorkingCopy', + '/project/.maestro', + 'tasks.md', + 1 + ); + expect(result.workingCopyPath).toContain('loop-1'); + }); + }); + }); + + describe('createPlaybooksApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createPlaybooksApi(); + }); + + describe('list', () => { + it('should invoke playbooks:list', async () => { + mockInvoke.mockResolvedValue([{ id: 'pb-1', name: 'Playbook 1' }]); + + const result = await api.list('session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('playbooks:list', 'session-123'); + expect(result).toEqual([{ id: 'pb-1', name: 'Playbook 1' }]); + }); + }); + + describe('create', () => { + it('should invoke playbooks:create', async () => { + const playbook = { + name: 'New Playbook', + documents: [{ filename: 'tasks.md', resetOnCompletion: false }], + loopEnabled: false, + prompt: 'Run these tasks', + }; + mockInvoke.mockResolvedValue({ id: 'pb-new' }); + + await api.create('session-123', playbook); + + expect(mockInvoke).toHaveBeenCalledWith('playbooks:create', 'session-123', playbook); + }); + }); + + describe('update', () => { + it('should invoke playbooks:update', async () => { + const updates = { name: 'Updated Name' }; + mockInvoke.mockResolvedValue({ success: true }); + + await api.update('session-123', 'pb-1', updates); + + expect(mockInvoke).toHaveBeenCalledWith('playbooks:update', 'session-123', 'pb-1', updates); + }); + }); + + describe('delete', () => { + it('should invoke playbooks:delete', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.delete('session-123', 'pb-1'); + + expect(mockInvoke).toHaveBeenCalledWith('playbooks:delete', 'session-123', 'pb-1'); + }); + }); + + describe('deleteAll', () => { + it('should invoke playbooks:deleteAll', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.deleteAll('session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('playbooks:deleteAll', 'session-123'); + }); + }); + + describe('export', () => { + it('should invoke playbooks:export', async () => { + mockInvoke.mockResolvedValue({ success: true, path: '/export/playbook.zip' }); + + await api.export('session-123', 'pb-1', '/project/.maestro'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'playbooks:export', + 'session-123', + 'pb-1', + '/project/.maestro' + ); + }); + }); + + describe('import', () => { + it('should invoke playbooks:import', async () => { + mockInvoke.mockResolvedValue({ success: true, playbookId: 'pb-imported' }); + + await api.import('session-123', '/project/.maestro'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'playbooks:import', + 'session-123', + '/project/.maestro' + ); + }); + }); + }); + + describe('createMarketplaceApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createMarketplaceApi(); + }); + + describe('getManifest', () => { + it('should invoke marketplace:getManifest', async () => { + const manifest = { playbooks: [{ id: 'mp-1', name: 'Marketplace Playbook' }] }; + mockInvoke.mockResolvedValue(manifest); + + const result = await api.getManifest(); + + expect(mockInvoke).toHaveBeenCalledWith('marketplace:getManifest'); + expect(result).toEqual(manifest); + }); + }); + + describe('refreshManifest', () => { + it('should invoke marketplace:refreshManifest', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.refreshManifest(); + + expect(mockInvoke).toHaveBeenCalledWith('marketplace:refreshManifest'); + }); + }); + + describe('getDocument', () => { + it('should invoke marketplace:getDocument', async () => { + mockInvoke.mockResolvedValue('# Document content'); + + const result = await api.getDocument('playbooks/example', 'tasks.md'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'marketplace:getDocument', + 'playbooks/example', + 'tasks.md' + ); + expect(result).toBe('# Document content'); + }); + }); + + describe('getReadme', () => { + it('should invoke marketplace:getReadme', async () => { + mockInvoke.mockResolvedValue('# README'); + + const result = await api.getReadme('playbooks/example'); + + expect(mockInvoke).toHaveBeenCalledWith('marketplace:getReadme', 'playbooks/example'); + expect(result).toBe('# README'); + }); + }); + + describe('importPlaybook', () => { + it('should invoke marketplace:importPlaybook', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.importPlaybook('mp-1', 'my-playbook', '/project/.maestro', 'session-123'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'marketplace:importPlaybook', + 'mp-1', + 'my-playbook', + '/project/.maestro', + 'session-123', + undefined + ); + }); + + it('should invoke with sshRemoteId', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.importPlaybook( + 'mp-1', + 'my-playbook', + '/project/.maestro', + 'session-123', + 'ssh-remote-1' + ); + + expect(mockInvoke).toHaveBeenCalledWith( + 'marketplace:importPlaybook', + 'mp-1', + 'my-playbook', + '/project/.maestro', + 'session-123', + 'ssh-remote-1' + ); + }); + }); + + describe('onManifestChanged', () => { + it('should register event listener and return cleanup function', () => { + const callback = vi.fn(); + + const cleanup = api.onManifestChanged(callback); + + expect(mockOn).toHaveBeenCalledWith('marketplace:manifestChanged', expect.any(Function)); + expect(typeof cleanup).toBe('function'); + }); + + it('should call callback when event is received', () => { + const callback = vi.fn(); + let registeredHandler: () => void; + + mockOn.mockImplementation((_channel: string, handler: () => void) => { + registeredHandler = handler; + }); + + api.onManifestChanged(callback); + registeredHandler!(); + + expect(callback).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/__tests__/main/preload/commands.test.ts b/src/__tests__/main/preload/commands.test.ts new file mode 100644 index 00000000..a51d0c17 --- /dev/null +++ b/src/__tests__/main/preload/commands.test.ts @@ -0,0 +1,285 @@ +/** + * Tests for commands preload API + * + * Coverage: + * - createSpeckitApi: getMetadata, getPrompts, getCommand, savePrompt, resetPrompt, refresh + * - createOpenspecApi: getMetadata, getPrompts, getCommand, savePrompt, resetPrompt, refresh + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + }, +})); + +import { createSpeckitApi, createOpenspecApi } from '../../../main/preload/commands'; + +describe('Commands Preload API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createSpeckitApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createSpeckitApi(); + }); + + describe('getMetadata', () => { + it('should invoke speckit:getMetadata', async () => { + const metadata = { + success: true, + metadata: { + lastRefreshed: '2024-01-01', + commitSha: 'abc123', + sourceVersion: '1.0.0', + sourceUrl: 'https://github.com/example/speckit', + }, + }; + mockInvoke.mockResolvedValue(metadata); + + const result = await api.getMetadata(); + + expect(mockInvoke).toHaveBeenCalledWith('speckit:getMetadata'); + expect(result).toEqual(metadata); + }); + }); + + describe('getPrompts', () => { + it('should invoke speckit:getPrompts', async () => { + const response = { + success: true, + commands: [ + { + id: 'cmd-1', + command: '/test', + description: 'Test command', + prompt: 'Test prompt', + isCustom: false, + isModified: false, + }, + ], + }; + mockInvoke.mockResolvedValue(response); + + const result = await api.getPrompts(); + + expect(mockInvoke).toHaveBeenCalledWith('speckit:getPrompts'); + expect(result).toEqual(response); + }); + }); + + describe('getCommand', () => { + it('should invoke speckit:getCommand', async () => { + const response = { + success: true, + command: { + id: 'cmd-1', + command: '/test', + description: 'Test command', + prompt: 'Test prompt', + isCustom: false, + isModified: false, + }, + }; + mockInvoke.mockResolvedValue(response); + + const result = await api.getCommand('/test'); + + expect(mockInvoke).toHaveBeenCalledWith('speckit:getCommand', '/test'); + expect(result).toEqual(response); + }); + + it('should handle command not found', async () => { + mockInvoke.mockResolvedValue({ success: true, command: null }); + + const result = await api.getCommand('/nonexistent'); + + expect(result.command).toBeNull(); + }); + }); + + describe('savePrompt', () => { + it('should invoke speckit:savePrompt', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + const result = await api.savePrompt('cmd-1', 'Updated prompt content'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'speckit:savePrompt', + 'cmd-1', + 'Updated prompt content' + ); + expect(result.success).toBe(true); + }); + + it('should handle save error', async () => { + mockInvoke.mockResolvedValue({ success: false, error: 'Failed to save' }); + + const result = await api.savePrompt('cmd-1', 'content'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to save'); + }); + }); + + describe('resetPrompt', () => { + it('should invoke speckit:resetPrompt', async () => { + mockInvoke.mockResolvedValue({ success: true, prompt: 'Original prompt' }); + + const result = await api.resetPrompt('cmd-1'); + + expect(mockInvoke).toHaveBeenCalledWith('speckit:resetPrompt', 'cmd-1'); + expect(result.success).toBe(true); + expect(result.prompt).toBe('Original prompt'); + }); + }); + + describe('refresh', () => { + it('should invoke speckit:refresh', async () => { + const metadata = { + success: true, + metadata: { + lastRefreshed: '2024-01-02', + commitSha: 'def456', + sourceVersion: '1.0.1', + sourceUrl: 'https://github.com/example/speckit', + }, + }; + mockInvoke.mockResolvedValue(metadata); + + const result = await api.refresh(); + + expect(mockInvoke).toHaveBeenCalledWith('speckit:refresh'); + expect(result).toEqual(metadata); + }); + }); + }); + + describe('createOpenspecApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createOpenspecApi(); + }); + + describe('getMetadata', () => { + it('should invoke openspec:getMetadata', async () => { + const metadata = { + success: true, + metadata: { + lastRefreshed: '2024-01-01', + commitSha: 'abc123', + sourceVersion: '1.0.0', + sourceUrl: 'https://github.com/example/openspec', + }, + }; + mockInvoke.mockResolvedValue(metadata); + + const result = await api.getMetadata(); + + expect(mockInvoke).toHaveBeenCalledWith('openspec:getMetadata'); + expect(result).toEqual(metadata); + }); + }); + + describe('getPrompts', () => { + it('should invoke openspec:getPrompts', async () => { + const response = { + success: true, + commands: [ + { + id: 'spec-1', + command: '/spec', + description: 'Spec command', + prompt: 'Spec prompt', + isCustom: false, + isModified: false, + }, + ], + }; + mockInvoke.mockResolvedValue(response); + + const result = await api.getPrompts(); + + expect(mockInvoke).toHaveBeenCalledWith('openspec:getPrompts'); + expect(result).toEqual(response); + }); + }); + + describe('getCommand', () => { + it('should invoke openspec:getCommand', async () => { + const response = { + success: true, + command: { + id: 'spec-1', + command: '/spec', + description: 'Spec command', + prompt: 'Spec prompt', + isCustom: false, + isModified: false, + }, + }; + mockInvoke.mockResolvedValue(response); + + const result = await api.getCommand('/spec'); + + expect(mockInvoke).toHaveBeenCalledWith('openspec:getCommand', '/spec'); + expect(result).toEqual(response); + }); + }); + + describe('savePrompt', () => { + it('should invoke openspec:savePrompt', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + const result = await api.savePrompt('spec-1', 'Updated spec prompt'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'openspec:savePrompt', + 'spec-1', + 'Updated spec prompt' + ); + expect(result.success).toBe(true); + }); + }); + + describe('resetPrompt', () => { + it('should invoke openspec:resetPrompt', async () => { + mockInvoke.mockResolvedValue({ success: true, prompt: 'Original spec prompt' }); + + const result = await api.resetPrompt('spec-1'); + + expect(mockInvoke).toHaveBeenCalledWith('openspec:resetPrompt', 'spec-1'); + expect(result.success).toBe(true); + expect(result.prompt).toBe('Original spec prompt'); + }); + }); + + describe('refresh', () => { + it('should invoke openspec:refresh', async () => { + const metadata = { + success: true, + metadata: { + lastRefreshed: '2024-01-02', + commitSha: 'xyz789', + sourceVersion: '2.0.0', + sourceUrl: 'https://github.com/example/openspec', + }, + }; + mockInvoke.mockResolvedValue(metadata); + + const result = await api.refresh(); + + expect(mockInvoke).toHaveBeenCalledWith('openspec:refresh'); + expect(result).toEqual(metadata); + }); + }); + }); +}); diff --git a/src/__tests__/main/preload/context.test.ts b/src/__tests__/main/preload/context.test.ts new file mode 100644 index 00000000..edc2ef15 --- /dev/null +++ b/src/__tests__/main/preload/context.test.ts @@ -0,0 +1,132 @@ +/** + * Tests for context preload API + * + * Coverage: + * - createContextApi: getStoredSession, groomContext, cancelGrooming, + * createGroomingSession (deprecated), sendGroomingPrompt (deprecated), cleanupGroomingSession + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + }, +})); + +import { createContextApi } from '../../../main/preload/context'; + +describe('Context Preload API', () => { + let api: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + api = createContextApi(); + }); + + describe('getStoredSession', () => { + it('should invoke context:getStoredSession with correct parameters', async () => { + const mockResponse = { + messages: [{ type: 'user', content: 'Hello', timestamp: '2024-01-01', uuid: '123' }], + total: 1, + hasMore: false, + }; + mockInvoke.mockResolvedValue(mockResponse); + + const result = await api.getStoredSession('claude-code', '/project', 'session-123'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'context:getStoredSession', + 'claude-code', + '/project', + 'session-123' + ); + expect(result).toEqual(mockResponse); + }); + + it('should return null for non-existent session', async () => { + mockInvoke.mockResolvedValue(null); + + const result = await api.getStoredSession('claude-code', '/project', 'non-existent'); + + expect(result).toBeNull(); + }); + }); + + describe('groomContext', () => { + it('should invoke context:groomContext with correct parameters', async () => { + mockInvoke.mockResolvedValue('groomed context response'); + + const result = await api.groomContext('/project', 'claude-code', 'summarize this'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'context:groomContext', + '/project', + 'claude-code', + 'summarize this' + ); + expect(result).toBe('groomed context response'); + }); + + it('should propagate errors from IPC', async () => { + mockInvoke.mockRejectedValue(new Error('Grooming failed')); + + await expect(api.groomContext('/project', 'claude-code', 'prompt')).rejects.toThrow( + 'Grooming failed' + ); + }); + }); + + describe('cancelGrooming', () => { + it('should invoke context:cancelGrooming', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.cancelGrooming(); + + expect(mockInvoke).toHaveBeenCalledWith('context:cancelGrooming'); + }); + }); + + describe('createGroomingSession (deprecated)', () => { + it('should invoke context:createGroomingSession', async () => { + mockInvoke.mockResolvedValue('grooming-session-id'); + + const result = await api.createGroomingSession('/project', 'claude-code'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'context:createGroomingSession', + '/project', + 'claude-code' + ); + expect(result).toBe('grooming-session-id'); + }); + }); + + describe('sendGroomingPrompt (deprecated)', () => { + it('should invoke context:sendGroomingPrompt', async () => { + mockInvoke.mockResolvedValue('response text'); + + const result = await api.sendGroomingPrompt('session-123', 'prompt text'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'context:sendGroomingPrompt', + 'session-123', + 'prompt text' + ); + expect(result).toBe('response text'); + }); + }); + + describe('cleanupGroomingSession', () => { + it('should invoke context:cleanupGroomingSession', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.cleanupGroomingSession('session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('context:cleanupGroomingSession', 'session-123'); + }); + }); +}); diff --git a/src/__tests__/main/preload/debug.test.ts b/src/__tests__/main/preload/debug.test.ts new file mode 100644 index 00000000..564bcdbd --- /dev/null +++ b/src/__tests__/main/preload/debug.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for debug preload API + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); +const mockOn = vi.fn(); +const mockRemoveListener = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + on: (...args: unknown[]) => mockOn(...args), + removeListener: (...args: unknown[]) => mockRemoveListener(...args), + }, +})); + +import { createDebugApi, createDocumentGraphApi } from '../../../main/preload/debug'; + +describe('Debug Preload API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createDebugApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createDebugApi(); + }); + + describe('createPackage', () => { + it('should invoke debug:createPackage without options', async () => { + mockInvoke.mockResolvedValue({ success: true, path: '/tmp/debug-package.zip' }); + + const result = await api.createPackage(); + + expect(mockInvoke).toHaveBeenCalledWith('debug:createPackage', undefined); + expect(result).toEqual({ success: true, path: '/tmp/debug-package.zip' }); + }); + + it('should invoke debug:createPackage with options', async () => { + mockInvoke.mockResolvedValue({ success: true, path: '/tmp/debug-package.zip' }); + const options = { + includeLogs: true, + includeErrors: true, + includeSessions: false, + includeGroupChats: false, + includeBatchState: true, + }; + + const result = await api.createPackage(options); + + expect(mockInvoke).toHaveBeenCalledWith('debug:createPackage', options); + expect(result.success).toBe(true); + }); + + it('should handle errors', async () => { + mockInvoke.mockResolvedValue({ success: false, error: 'Failed to create package' }); + + const result = await api.createPackage(); + + expect(result.success).toBe(false); + }); + }); + + describe('previewPackage', () => { + it('should invoke debug:previewPackage', async () => { + const preview = { + logs: 150, + errors: 5, + sessions: 10, + groupChats: 2, + estimatedSize: '5.2 MB', + }; + mockInvoke.mockResolvedValue(preview); + + const result = await api.previewPackage(); + + expect(mockInvoke).toHaveBeenCalledWith('debug:previewPackage'); + expect(result).toEqual(preview); + }); + }); + }); + + describe('createDocumentGraphApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createDocumentGraphApi(); + }); + + describe('watchFolder', () => { + it('should invoke documentGraph:watchFolder', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + const result = await api.watchFolder('/project'); + + expect(mockInvoke).toHaveBeenCalledWith('documentGraph:watchFolder', '/project'); + expect(result).toEqual({ success: true }); + }); + }); + + describe('unwatchFolder', () => { + it('should invoke documentGraph:unwatchFolder', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + const result = await api.unwatchFolder('/project'); + + expect(mockInvoke).toHaveBeenCalledWith('documentGraph:unwatchFolder', '/project'); + expect(result).toEqual({ success: true }); + }); + }); + + describe('onFilesChanged', () => { + it('should register event listener and return cleanup function', () => { + const callback = vi.fn(); + + const cleanup = api.onFilesChanged(callback); + + expect(mockOn).toHaveBeenCalledWith('documentGraph:filesChanged', expect.any(Function)); + expect(typeof cleanup).toBe('function'); + }); + + it('should call callback when event is received', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, data: unknown) => void; + + mockOn.mockImplementation( + (_channel: string, handler: (event: unknown, data: unknown) => void) => { + registeredHandler = handler; + } + ); + + api.onFilesChanged(callback); + + const data = { + rootPath: '/project', + changes: [ + { filePath: '/project/file1.ts', eventType: 'add' as const }, + { filePath: '/project/file2.ts', eventType: 'change' as const }, + ], + }; + registeredHandler!({}, data); + + expect(callback).toHaveBeenCalledWith(data); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, data: unknown) => void; + + mockOn.mockImplementation( + (_channel: string, handler: (event: unknown, data: unknown) => void) => { + registeredHandler = handler; + } + ); + + const cleanup = api.onFilesChanged(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith( + 'documentGraph:filesChanged', + registeredHandler! + ); + }); + }); + }); +}); diff --git a/src/__tests__/main/preload/files.test.ts b/src/__tests__/main/preload/files.test.ts new file mode 100644 index 00000000..9bb3b02c --- /dev/null +++ b/src/__tests__/main/preload/files.test.ts @@ -0,0 +1,361 @@ +/** + * Tests for files preload API + * + * Coverage: + * - createTempfileApi: write, read, delete + * - createHistoryApi: getAll, getAllPaginated, add, clear, delete, update, updateSessionName, + * getFilePath, listSessions, onExternalChange, reload + * - createCliApi: getActivity, onActivityChange + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); +const mockOn = vi.fn(); +const mockRemoveListener = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + on: (...args: unknown[]) => mockOn(...args), + removeListener: (...args: unknown[]) => mockRemoveListener(...args), + }, +})); + +import { createTempfileApi, createHistoryApi, createCliApi } from '../../../main/preload/files'; + +describe('Files Preload API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createTempfileApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createTempfileApi(); + }); + + describe('write', () => { + it('should invoke tempfile:write with content', async () => { + mockInvoke.mockResolvedValue('/tmp/tempfile-123.txt'); + + const result = await api.write('file content'); + + expect(mockInvoke).toHaveBeenCalledWith('tempfile:write', 'file content', undefined); + expect(result).toBe('/tmp/tempfile-123.txt'); + }); + + it('should invoke tempfile:write with filename', async () => { + mockInvoke.mockResolvedValue('/tmp/custom-name.txt'); + + const result = await api.write('file content', 'custom-name.txt'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'tempfile:write', + 'file content', + 'custom-name.txt' + ); + expect(result).toBe('/tmp/custom-name.txt'); + }); + }); + + describe('read', () => { + it('should invoke tempfile:read with path', async () => { + mockInvoke.mockResolvedValue('file content'); + + const result = await api.read('/tmp/tempfile-123.txt'); + + expect(mockInvoke).toHaveBeenCalledWith('tempfile:read', '/tmp/tempfile-123.txt'); + expect(result).toBe('file content'); + }); + }); + + describe('delete', () => { + it('should invoke tempfile:delete with path', async () => { + mockInvoke.mockResolvedValue(true); + + await api.delete('/tmp/tempfile-123.txt'); + + expect(mockInvoke).toHaveBeenCalledWith('tempfile:delete', '/tmp/tempfile-123.txt'); + }); + }); + }); + + describe('createHistoryApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createHistoryApi(); + }); + + describe('getAll', () => { + it('should invoke history:getAll without parameters', async () => { + mockInvoke.mockResolvedValue([]); + + await api.getAll(); + + expect(mockInvoke).toHaveBeenCalledWith('history:getAll', undefined, undefined); + }); + + it('should invoke history:getAll with projectPath', async () => { + mockInvoke.mockResolvedValue([]); + + await api.getAll('/project'); + + expect(mockInvoke).toHaveBeenCalledWith('history:getAll', '/project', undefined); + }); + + it('should invoke history:getAll with sessionId', async () => { + mockInvoke.mockResolvedValue([]); + + await api.getAll('/project', 'session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('history:getAll', '/project', 'session-123'); + }); + }); + + describe('getAllPaginated', () => { + it('should invoke history:getAllPaginated with options', async () => { + mockInvoke.mockResolvedValue({ entries: [], total: 0 }); + const options = { + projectPath: '/project', + sessionId: 'session-123', + pagination: { limit: 50, offset: 0 }, + }; + + await api.getAllPaginated(options); + + expect(mockInvoke).toHaveBeenCalledWith('history:getAllPaginated', options); + }); + }); + + describe('add', () => { + it('should invoke history:add with entry', async () => { + mockInvoke.mockResolvedValue({ id: 'entry-123' }); + const entry = { + id: 'entry-123', + type: 'USER' as const, + timestamp: Date.now(), + summary: 'Test entry', + projectPath: '/project', + }; + + await api.add(entry); + + expect(mockInvoke).toHaveBeenCalledWith('history:add', entry); + }); + }); + + describe('clear', () => { + it('should invoke history:clear without projectPath', async () => { + mockInvoke.mockResolvedValue(true); + + await api.clear(); + + expect(mockInvoke).toHaveBeenCalledWith('history:clear', undefined); + }); + + it('should invoke history:clear with projectPath', async () => { + mockInvoke.mockResolvedValue(true); + + await api.clear('/project'); + + expect(mockInvoke).toHaveBeenCalledWith('history:clear', '/project'); + }); + }); + + describe('delete', () => { + it('should invoke history:delete with entryId', async () => { + mockInvoke.mockResolvedValue(true); + + await api.delete('entry-123'); + + expect(mockInvoke).toHaveBeenCalledWith('history:delete', 'entry-123', undefined); + }); + + it('should invoke history:delete with sessionId', async () => { + mockInvoke.mockResolvedValue(true); + + await api.delete('entry-123', 'session-456'); + + expect(mockInvoke).toHaveBeenCalledWith('history:delete', 'entry-123', 'session-456'); + }); + }); + + describe('update', () => { + it('should invoke history:update with updates', async () => { + mockInvoke.mockResolvedValue(true); + + await api.update('entry-123', { validated: true }); + + expect(mockInvoke).toHaveBeenCalledWith( + 'history:update', + 'entry-123', + { validated: true }, + undefined + ); + }); + + it('should invoke history:update with sessionId', async () => { + mockInvoke.mockResolvedValue(true); + + await api.update('entry-123', { validated: false }, 'session-456'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'history:update', + 'entry-123', + { validated: false }, + 'session-456' + ); + }); + }); + + describe('updateSessionName', () => { + it('should invoke history:updateSessionName', async () => { + mockInvoke.mockResolvedValue(true); + + await api.updateSessionName('agent-session-123', 'New Session Name'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'history:updateSessionName', + 'agent-session-123', + 'New Session Name' + ); + }); + }); + + describe('getFilePath', () => { + it('should invoke history:getFilePath', async () => { + mockInvoke.mockResolvedValue('/path/to/history.json'); + + const result = await api.getFilePath('session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('history:getFilePath', 'session-123'); + expect(result).toBe('/path/to/history.json'); + }); + }); + + describe('listSessions', () => { + it('should invoke history:listSessions', async () => { + mockInvoke.mockResolvedValue(['session-1', 'session-2']); + + const result = await api.listSessions(); + + expect(mockInvoke).toHaveBeenCalledWith('history:listSessions'); + expect(result).toEqual(['session-1', 'session-2']); + }); + }); + + describe('onExternalChange', () => { + it('should register event listener and return cleanup function', () => { + const callback = vi.fn(); + + const cleanup = api.onExternalChange(callback); + + expect(mockOn).toHaveBeenCalledWith('history:externalChange', expect.any(Function)); + expect(typeof cleanup).toBe('function'); + }); + + it('should call callback when event is received', () => { + const callback = vi.fn(); + let registeredHandler: () => void; + + mockOn.mockImplementation((_channel: string, handler: () => void) => { + registeredHandler = handler; + }); + + api.onExternalChange(callback); + registeredHandler!(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: () => void; + + mockOn.mockImplementation((_channel: string, handler: () => void) => { + registeredHandler = handler; + }); + + const cleanup = api.onExternalChange(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith( + 'history:externalChange', + registeredHandler! + ); + }); + }); + + describe('reload', () => { + it('should invoke history:reload', async () => { + mockInvoke.mockResolvedValue(true); + + await api.reload(); + + expect(mockInvoke).toHaveBeenCalledWith('history:reload'); + }); + }); + }); + + describe('createCliApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createCliApi(); + }); + + describe('getActivity', () => { + it('should invoke cli:getActivity', async () => { + mockInvoke.mockResolvedValue({ active: true, pid: 12345 }); + + const result = await api.getActivity(); + + expect(mockInvoke).toHaveBeenCalledWith('cli:getActivity'); + expect(result).toEqual({ active: true, pid: 12345 }); + }); + }); + + describe('onActivityChange', () => { + it('should register event listener and return cleanup function', () => { + const callback = vi.fn(); + + const cleanup = api.onActivityChange(callback); + + expect(mockOn).toHaveBeenCalledWith('cli:activityChange', expect.any(Function)); + expect(typeof cleanup).toBe('function'); + }); + + it('should call callback when event is received', () => { + const callback = vi.fn(); + let registeredHandler: () => void; + + mockOn.mockImplementation((_channel: string, handler: () => void) => { + registeredHandler = handler; + }); + + api.onActivityChange(callback); + registeredHandler!(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: () => void; + + mockOn.mockImplementation((_channel: string, handler: () => void) => { + registeredHandler = handler; + }); + + const cleanup = api.onActivityChange(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith('cli:activityChange', registeredHandler!); + }); + }); + }); +}); diff --git a/src/__tests__/main/preload/fs.test.ts b/src/__tests__/main/preload/fs.test.ts new file mode 100644 index 00000000..4ed2a60c --- /dev/null +++ b/src/__tests__/main/preload/fs.test.ts @@ -0,0 +1,212 @@ +/** + * Tests for filesystem preload API + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + }, +})); + +import { createFsApi } from '../../../main/preload/fs'; + +describe('Filesystem Preload API', () => { + let api: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + api = createFsApi(); + }); + + describe('homeDir', () => { + it('should invoke fs:homeDir', async () => { + mockInvoke.mockResolvedValue('/home/user'); + + const result = await api.homeDir(); + + expect(mockInvoke).toHaveBeenCalledWith('fs:homeDir'); + expect(result).toBe('/home/user'); + }); + }); + + describe('readDir', () => { + it('should invoke fs:readDir with dirPath', async () => { + const mockEntries = [ + { name: 'file.txt', isDirectory: false, path: '/home/user/file.txt' }, + { name: 'subdir', isDirectory: true, path: '/home/user/subdir' }, + ]; + mockInvoke.mockResolvedValue(mockEntries); + + const result = await api.readDir('/home/user'); + + expect(mockInvoke).toHaveBeenCalledWith('fs:readDir', '/home/user', undefined); + expect(result).toEqual(mockEntries); + }); + + it('should invoke fs:readDir with SSH remote', async () => { + mockInvoke.mockResolvedValue([]); + + await api.readDir('/home/user', 'remote-1'); + + expect(mockInvoke).toHaveBeenCalledWith('fs:readDir', '/home/user', 'remote-1'); + }); + }); + + describe('readFile', () => { + it('should invoke fs:readFile with filePath', async () => { + mockInvoke.mockResolvedValue('file contents'); + + const result = await api.readFile('/home/user/file.txt'); + + expect(mockInvoke).toHaveBeenCalledWith('fs:readFile', '/home/user/file.txt', undefined); + expect(result).toBe('file contents'); + }); + + it('should invoke fs:readFile with SSH remote', async () => { + mockInvoke.mockResolvedValue('remote file contents'); + + await api.readFile('/home/user/file.txt', 'remote-1'); + + expect(mockInvoke).toHaveBeenCalledWith('fs:readFile', '/home/user/file.txt', 'remote-1'); + }); + }); + + describe('writeFile', () => { + it('should invoke fs:writeFile with filePath and content', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + const result = await api.writeFile('/home/user/file.txt', 'new contents'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'fs:writeFile', + '/home/user/file.txt', + 'new contents' + ); + expect(result.success).toBe(true); + }); + }); + + describe('stat', () => { + it('should invoke fs:stat with filePath', async () => { + const mockStat = { + size: 1024, + createdAt: '2024-01-01T00:00:00Z', + modifiedAt: '2024-01-02T00:00:00Z', + isDirectory: false, + isFile: true, + }; + mockInvoke.mockResolvedValue(mockStat); + + const result = await api.stat('/home/user/file.txt'); + + expect(mockInvoke).toHaveBeenCalledWith('fs:stat', '/home/user/file.txt', undefined); + expect(result).toEqual(mockStat); + }); + }); + + describe('directorySize', () => { + it('should invoke fs:directorySize with dirPath', async () => { + const mockSize = { + totalSize: 10240, + fileCount: 10, + folderCount: 2, + }; + mockInvoke.mockResolvedValue(mockSize); + + const result = await api.directorySize('/home/user/project'); + + expect(mockInvoke).toHaveBeenCalledWith('fs:directorySize', '/home/user/project', undefined); + expect(result).toEqual(mockSize); + }); + }); + + describe('fetchImageAsBase64', () => { + it('should invoke fs:fetchImageAsBase64 with url', async () => { + mockInvoke.mockResolvedValue('base64encodedimage'); + + const result = await api.fetchImageAsBase64('https://example.com/image.png'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'fs:fetchImageAsBase64', + 'https://example.com/image.png' + ); + expect(result).toBe('base64encodedimage'); + }); + + it('should return null for failed fetch', async () => { + mockInvoke.mockResolvedValue(null); + + const result = await api.fetchImageAsBase64('https://example.com/notfound.png'); + + expect(result).toBeNull(); + }); + }); + + describe('rename', () => { + it('should invoke fs:rename with oldPath and newPath', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + const result = await api.rename('/home/user/old.txt', '/home/user/new.txt'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'fs:rename', + '/home/user/old.txt', + '/home/user/new.txt', + undefined + ); + expect(result.success).toBe(true); + }); + + it('should invoke fs:rename with SSH remote', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.rename('/home/user/old.txt', '/home/user/new.txt', 'remote-1'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'fs:rename', + '/home/user/old.txt', + '/home/user/new.txt', + 'remote-1' + ); + }); + }); + + describe('delete', () => { + it('should invoke fs:delete with targetPath', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + const result = await api.delete('/home/user/file.txt'); + + expect(mockInvoke).toHaveBeenCalledWith('fs:delete', '/home/user/file.txt', undefined); + expect(result.success).toBe(true); + }); + + it('should invoke fs:delete with options', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.delete('/home/user/dir', { recursive: true, sshRemoteId: 'remote-1' }); + + expect(mockInvoke).toHaveBeenCalledWith('fs:delete', '/home/user/dir', { + recursive: true, + sshRemoteId: 'remote-1', + }); + }); + }); + + describe('countItems', () => { + it('should invoke fs:countItems with dirPath', async () => { + mockInvoke.mockResolvedValue({ fileCount: 5, folderCount: 2 }); + + const result = await api.countItems('/home/user/project'); + + expect(mockInvoke).toHaveBeenCalledWith('fs:countItems', '/home/user/project', undefined); + expect(result.fileCount).toBe(5); + expect(result.folderCount).toBe(2); + }); + }); +}); diff --git a/src/__tests__/main/preload/git.test.ts b/src/__tests__/main/preload/git.test.ts new file mode 100644 index 00000000..7eae3080 --- /dev/null +++ b/src/__tests__/main/preload/git.test.ts @@ -0,0 +1,324 @@ +/** + * Tests for git preload API + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); +const mockOn = vi.fn(); +const mockRemoveListener = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + on: (...args: unknown[]) => mockOn(...args), + removeListener: (...args: unknown[]) => mockRemoveListener(...args), + }, +})); + +import { createGitApi } from '../../../main/preload/git'; + +describe('Git Preload API', () => { + let api: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + api = createGitApi(); + }); + + describe('status', () => { + it('should invoke git:status with cwd', async () => { + mockInvoke.mockResolvedValue('M src/file.ts'); + + const result = await api.status('/home/user/project'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'git:status', + '/home/user/project', + undefined, + undefined + ); + expect(result).toBe('M src/file.ts'); + }); + + it('should invoke git:status with SSH remote parameters', async () => { + mockInvoke.mockResolvedValue('M src/file.ts'); + + await api.status('/home/user/project', 'remote-1', '/remote/cwd'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'git:status', + '/home/user/project', + 'remote-1', + '/remote/cwd' + ); + }); + }); + + describe('diff', () => { + it('should invoke git:diff with cwd and optional file', async () => { + mockInvoke.mockResolvedValue('diff output'); + + const result = await api.diff('/home/user/project', 'src/file.ts'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'git:diff', + '/home/user/project', + 'src/file.ts', + undefined, + undefined + ); + expect(result).toBe('diff output'); + }); + + it('should invoke git:diff without file', async () => { + mockInvoke.mockResolvedValue('full diff'); + + await api.diff('/home/user/project'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'git:diff', + '/home/user/project', + undefined, + undefined, + undefined + ); + }); + }); + + describe('isRepo', () => { + it('should invoke git:isRepo and return true for git repo', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.isRepo('/home/user/project'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'git:isRepo', + '/home/user/project', + undefined, + undefined + ); + expect(result).toBe(true); + }); + + it('should return false for non-git directory', async () => { + mockInvoke.mockResolvedValue(false); + + const result = await api.isRepo('/home/user/not-a-repo'); + + expect(result).toBe(false); + }); + }); + + describe('branch', () => { + it('should invoke git:branch and return current branch', async () => { + mockInvoke.mockResolvedValue({ stdout: 'main', stderr: '' }); + + const result = await api.branch('/home/user/project'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'git:branch', + '/home/user/project', + undefined, + undefined + ); + expect(result.stdout).toBe('main'); + }); + }); + + describe('info', () => { + it('should invoke git:info and return comprehensive info', async () => { + const mockInfo = { + branch: 'main', + remote: 'origin', + behind: 2, + ahead: 1, + uncommittedChanges: 3, + }; + mockInvoke.mockResolvedValue(mockInfo); + + const result = await api.info('/home/user/project'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'git:info', + '/home/user/project', + undefined, + undefined + ); + expect(result).toEqual(mockInfo); + }); + }); + + describe('log', () => { + it('should invoke git:log with options', async () => { + const mockEntries = [ + { + hash: 'abc123', + shortHash: 'abc', + author: 'User', + date: '2024-01-01', + refs: ['HEAD'], + subject: 'Initial commit', + }, + ]; + mockInvoke.mockResolvedValue({ entries: mockEntries, error: null }); + + const result = await api.log('/home/user/project', { limit: 10, search: 'fix' }); + + expect(mockInvoke).toHaveBeenCalledWith('git:log', '/home/user/project', { + limit: 10, + search: 'fix', + }); + expect(result.entries).toEqual(mockEntries); + }); + }); + + describe('worktreeInfo', () => { + it('should invoke git:worktreeInfo', async () => { + mockInvoke.mockResolvedValue({ + success: true, + exists: true, + isWorktree: true, + currentBranch: 'feature-branch', + repoRoot: '/home/user/project', + }); + + const result = await api.worktreeInfo('/home/user/worktree'); + + expect(mockInvoke).toHaveBeenCalledWith('git:worktreeInfo', '/home/user/worktree', undefined); + expect(result.success).toBe(true); + expect(result.isWorktree).toBe(true); + }); + }); + + describe('worktreeSetup', () => { + it('should invoke git:worktreeSetup with all parameters', async () => { + mockInvoke.mockResolvedValue({ + success: true, + created: true, + currentBranch: 'feature-branch', + }); + + const result = await api.worktreeSetup( + '/home/user/project', + '/home/user/worktree', + 'feature-branch', + 'remote-1' + ); + + expect(mockInvoke).toHaveBeenCalledWith( + 'git:worktreeSetup', + '/home/user/project', + '/home/user/worktree', + 'feature-branch', + 'remote-1' + ); + expect(result.success).toBe(true); + expect(result.created).toBe(true); + }); + }); + + describe('createPR', () => { + it('should invoke git:createPR', async () => { + mockInvoke.mockResolvedValue({ + success: true, + prUrl: 'https://github.com/user/repo/pull/123', + }); + + const result = await api.createPR('/home/user/worktree', 'main', 'Feature Title', 'PR body'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'git:createPR', + '/home/user/worktree', + 'main', + 'Feature Title', + 'PR body', + undefined + ); + expect(result.success).toBe(true); + expect(result.prUrl).toBe('https://github.com/user/repo/pull/123'); + }); + }); + + describe('checkGhCli', () => { + it('should invoke git:checkGhCli', async () => { + mockInvoke.mockResolvedValue({ installed: true, authenticated: true }); + + const result = await api.checkGhCli(); + + expect(mockInvoke).toHaveBeenCalledWith('git:checkGhCli', undefined); + expect(result.installed).toBe(true); + expect(result.authenticated).toBe(true); + }); + }); + + describe('createGist', () => { + it('should invoke git:createGist', async () => { + mockInvoke.mockResolvedValue({ + success: true, + gistUrl: 'https://gist.github.com/user/abc123', + }); + + const result = await api.createGist('file.txt', 'content', 'description', true); + + expect(mockInvoke).toHaveBeenCalledWith( + 'git:createGist', + 'file.txt', + 'content', + 'description', + true, + undefined + ); + expect(result.success).toBe(true); + expect(result.gistUrl).toBe('https://gist.github.com/user/abc123'); + }); + }); + + describe('listWorktrees', () => { + it('should invoke git:listWorktrees', async () => { + const mockWorktrees = [ + { path: '/home/user/project', head: 'abc123', branch: 'main', isBare: false }, + { path: '/home/user/worktree', head: 'def456', branch: 'feature', isBare: false }, + ]; + mockInvoke.mockResolvedValue({ worktrees: mockWorktrees }); + + const result = await api.listWorktrees('/home/user/project'); + + expect(mockInvoke).toHaveBeenCalledWith('git:listWorktrees', '/home/user/project', undefined); + expect(result.worktrees).toEqual(mockWorktrees); + }); + }); + + describe('onWorktreeDiscovered', () => { + it('should register event listener and return cleanup function', () => { + const callback = vi.fn(); + + const cleanup = api.onWorktreeDiscovered(callback); + + expect(mockOn).toHaveBeenCalledWith('worktree:discovered', expect.any(Function)); + expect(typeof cleanup).toBe('function'); + }); + + it('should call callback with worktree data', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, data: unknown) => void; + + mockOn.mockImplementation((channel: string, handler: typeof registeredHandler) => { + if (channel === 'worktree:discovered') { + registeredHandler = handler; + } + }); + + api.onWorktreeDiscovered(callback); + + const data = { + sessionId: 'session-123', + worktree: { path: '/home/user/worktree', name: 'feature', branch: 'feature-branch' }, + }; + registeredHandler!({}, data); + + expect(callback).toHaveBeenCalledWith(data); + }); + }); +}); diff --git a/src/__tests__/main/preload/groupChat.test.ts b/src/__tests__/main/preload/groupChat.test.ts new file mode 100644 index 00000000..653b5fe0 --- /dev/null +++ b/src/__tests__/main/preload/groupChat.test.ts @@ -0,0 +1,729 @@ +/** + * Tests for groupChat preload API + * + * Coverage: + * - createGroupChatApi: Storage, chat log, moderator, participant, history, export operations + * - Event subscriptions: onMessage, onStateChange, onParticipantsChanged, onModeratorUsage, + * onHistoryEntry, onParticipantState, onModeratorSessionIdChanged + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); +const mockOn = vi.fn(); +const mockRemoveListener = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + on: (...args: unknown[]) => mockOn(...args), + removeListener: (...args: unknown[]) => mockRemoveListener(...args), + }, +})); + +import { createGroupChatApi } from '../../../main/preload/groupChat'; + +describe('GroupChat Preload API', () => { + let api: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + api = createGroupChatApi(); + }); + + describe('Storage operations', () => { + describe('create', () => { + it('should invoke groupChat:create', async () => { + mockInvoke.mockResolvedValue({ id: 'gc-123' }); + + const result = await api.create('My Group Chat', 'claude-code'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'groupChat:create', + 'My Group Chat', + 'claude-code', + undefined + ); + expect(result).toEqual({ id: 'gc-123' }); + }); + + it('should invoke with moderator config', async () => { + mockInvoke.mockResolvedValue({ id: 'gc-123' }); + const moderatorConfig = { customPath: '/custom/path' }; + + await api.create('My Group Chat', 'claude-code', moderatorConfig); + + expect(mockInvoke).toHaveBeenCalledWith( + 'groupChat:create', + 'My Group Chat', + 'claude-code', + moderatorConfig + ); + }); + }); + + describe('list', () => { + it('should invoke groupChat:list', async () => { + mockInvoke.mockResolvedValue([{ id: 'gc-1', name: 'Chat 1' }]); + + const result = await api.list(); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:list'); + expect(result).toEqual([{ id: 'gc-1', name: 'Chat 1' }]); + }); + }); + + describe('load', () => { + it('should invoke groupChat:load', async () => { + mockInvoke.mockResolvedValue({ id: 'gc-123', name: 'My Chat', participants: [] }); + + const result = await api.load('gc-123'); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:load', 'gc-123'); + expect(result.id).toBe('gc-123'); + }); + }); + + describe('delete', () => { + it('should invoke groupChat:delete', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.delete('gc-123'); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:delete', 'gc-123'); + }); + }); + + describe('rename', () => { + it('should invoke groupChat:rename', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.rename('gc-123', 'New Name'); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:rename', 'gc-123', 'New Name'); + }); + }); + + describe('update', () => { + it('should invoke groupChat:update', async () => { + mockInvoke.mockResolvedValue({ success: true }); + const updates = { name: 'Updated', moderatorAgentId: 'opencode' }; + + await api.update('gc-123', updates); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:update', 'gc-123', updates); + }); + }); + }); + + describe('Chat log operations', () => { + describe('appendMessage', () => { + it('should invoke groupChat:appendMessage', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.appendMessage('gc-123', 'Moderator', 'Hello everyone!'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'groupChat:appendMessage', + 'gc-123', + 'Moderator', + 'Hello everyone!' + ); + }); + }); + + describe('getMessages', () => { + it('should invoke groupChat:getMessages', async () => { + const messages = [{ timestamp: '2024-01-01', from: 'User', content: 'Hi' }]; + mockInvoke.mockResolvedValue(messages); + + const result = await api.getMessages('gc-123'); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:getMessages', 'gc-123'); + expect(result).toEqual(messages); + }); + }); + + describe('saveImage', () => { + it('should invoke groupChat:saveImage', async () => { + mockInvoke.mockResolvedValue({ path: 'images/img.png' }); + + await api.saveImage('gc-123', 'base64data', 'image.png'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'groupChat:saveImage', + 'gc-123', + 'base64data', + 'image.png' + ); + }); + }); + }); + + describe('Moderator operations', () => { + describe('startModerator', () => { + it('should invoke groupChat:startModerator', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.startModerator('gc-123'); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:startModerator', 'gc-123'); + }); + }); + + describe('sendToModerator', () => { + it('should invoke groupChat:sendToModerator', async () => { + mockInvoke.mockResolvedValue({ response: 'Moderator response' }); + + await api.sendToModerator('gc-123', 'Please coordinate', undefined, false); + + expect(mockInvoke).toHaveBeenCalledWith( + 'groupChat:sendToModerator', + 'gc-123', + 'Please coordinate', + undefined, + false + ); + }); + + it('should invoke with images and readOnly', async () => { + mockInvoke.mockResolvedValue({ response: 'Response' }); + + await api.sendToModerator('gc-123', 'Message', ['image1.png'], true); + + expect(mockInvoke).toHaveBeenCalledWith( + 'groupChat:sendToModerator', + 'gc-123', + 'Message', + ['image1.png'], + true + ); + }); + }); + + describe('stopModerator', () => { + it('should invoke groupChat:stopModerator', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.stopModerator('gc-123'); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:stopModerator', 'gc-123'); + }); + }); + + describe('getModeratorSessionId', () => { + it('should invoke groupChat:getModeratorSessionId', async () => { + mockInvoke.mockResolvedValue('mod-session-456'); + + const result = await api.getModeratorSessionId('gc-123'); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:getModeratorSessionId', 'gc-123'); + expect(result).toBe('mod-session-456'); + }); + }); + }); + + describe('Participant operations', () => { + describe('addParticipant', () => { + it('should invoke groupChat:addParticipant', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.addParticipant('gc-123', 'Agent1', 'claude-code'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'groupChat:addParticipant', + 'gc-123', + 'Agent1', + 'claude-code', + undefined + ); + }); + + it('should invoke with cwd', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.addParticipant('gc-123', 'Agent1', 'claude-code', '/project'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'groupChat:addParticipant', + 'gc-123', + 'Agent1', + 'claude-code', + '/project' + ); + }); + }); + + describe('sendToParticipant', () => { + it('should invoke groupChat:sendToParticipant', async () => { + mockInvoke.mockResolvedValue({ response: 'Participant response' }); + + await api.sendToParticipant('gc-123', 'Agent1', 'Do this task'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'groupChat:sendToParticipant', + 'gc-123', + 'Agent1', + 'Do this task', + undefined + ); + }); + + it('should invoke with images', async () => { + mockInvoke.mockResolvedValue({ response: 'Response' }); + + await api.sendToParticipant('gc-123', 'Agent1', 'Look at this', ['screenshot.png']); + + expect(mockInvoke).toHaveBeenCalledWith( + 'groupChat:sendToParticipant', + 'gc-123', + 'Agent1', + 'Look at this', + ['screenshot.png'] + ); + }); + }); + + describe('removeParticipant', () => { + it('should invoke groupChat:removeParticipant', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.removeParticipant('gc-123', 'Agent1'); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:removeParticipant', 'gc-123', 'Agent1'); + }); + }); + + describe('resetParticipantContext', () => { + it('should invoke groupChat:resetParticipantContext', async () => { + mockInvoke.mockResolvedValue({ newAgentSessionId: 'new-session-789' }); + + const result = await api.resetParticipantContext('gc-123', 'Agent1'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'groupChat:resetParticipantContext', + 'gc-123', + 'Agent1', + undefined + ); + expect(result.newAgentSessionId).toBe('new-session-789'); + }); + }); + }); + + describe('History operations', () => { + describe('getHistory', () => { + it('should invoke groupChat:getHistory', async () => { + const history = [{ id: 'h-1', timestamp: Date.now(), summary: 'Test' }]; + mockInvoke.mockResolvedValue(history); + + const result = await api.getHistory('gc-123'); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:getHistory', 'gc-123'); + expect(result).toEqual(history); + }); + }); + + describe('addHistoryEntry', () => { + it('should invoke groupChat:addHistoryEntry', async () => { + mockInvoke.mockResolvedValue({ id: 'h-new' }); + const entry = { + timestamp: Date.now(), + summary: 'Task completed', + participantName: 'Agent1', + participantColor: '#ff0000', + type: 'response' as const, + }; + + await api.addHistoryEntry('gc-123', entry); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:addHistoryEntry', 'gc-123', entry); + }); + }); + + describe('deleteHistoryEntry', () => { + it('should invoke groupChat:deleteHistoryEntry', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.deleteHistoryEntry('gc-123', 'h-1'); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:deleteHistoryEntry', 'gc-123', 'h-1'); + }); + }); + + describe('clearHistory', () => { + it('should invoke groupChat:clearHistory', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.clearHistory('gc-123'); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:clearHistory', 'gc-123'); + }); + }); + + describe('getHistoryFilePath', () => { + it('should invoke groupChat:getHistoryFilePath', async () => { + mockInvoke.mockResolvedValue('/path/to/history.json'); + + const result = await api.getHistoryFilePath('gc-123'); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:getHistoryFilePath', 'gc-123'); + expect(result).toBe('/path/to/history.json'); + }); + }); + }); + + describe('Export operations', () => { + describe('getImages', () => { + it('should invoke groupChat:getImages', async () => { + const images = { 'img1.png': 'base64data1', 'img2.png': 'base64data2' }; + mockInvoke.mockResolvedValue(images); + + const result = await api.getImages('gc-123'); + + expect(mockInvoke).toHaveBeenCalledWith('groupChat:getImages', 'gc-123'); + expect(result).toEqual(images); + }); + }); + }); + + describe('Event subscriptions', () => { + describe('onMessage', () => { + it('should register and handle message events', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, groupChatId: string, message: unknown) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: (event: unknown, groupChatId: string, message: unknown) => void + ) => { + registeredHandler = handler; + } + ); + + const cleanup = api.onMessage(callback); + + expect(mockOn).toHaveBeenCalledWith('groupChat:message', expect.any(Function)); + + const message = { timestamp: '2024-01-01', from: 'User', content: 'Hi' }; + registeredHandler!({}, 'gc-123', message); + + expect(callback).toHaveBeenCalledWith('gc-123', message); + expect(typeof cleanup).toBe('function'); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, groupChatId: string, message: unknown) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: (event: unknown, groupChatId: string, message: unknown) => void + ) => { + registeredHandler = handler; + } + ); + + const cleanup = api.onMessage(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith('groupChat:message', registeredHandler!); + }); + }); + + describe('onStateChange', () => { + it('should register and handle state change events', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, groupChatId: string, state: string) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: (event: unknown, groupChatId: string, state: string) => void + ) => { + registeredHandler = handler; + } + ); + + api.onStateChange(callback); + registeredHandler!({}, 'gc-123', 'moderator-thinking'); + + expect(callback).toHaveBeenCalledWith('gc-123', 'moderator-thinking'); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, groupChatId: string, state: string) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: (event: unknown, groupChatId: string, state: string) => void + ) => { + registeredHandler = handler; + } + ); + + const cleanup = api.onStateChange(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith( + 'groupChat:stateChange', + registeredHandler! + ); + }); + }); + + describe('onParticipantsChanged', () => { + it('should register and handle participants changed events', () => { + const callback = vi.fn(); + let registeredHandler: ( + event: unknown, + groupChatId: string, + participants: unknown[] + ) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: (event: unknown, groupChatId: string, participants: unknown[]) => void + ) => { + registeredHandler = handler; + } + ); + + api.onParticipantsChanged(callback); + + const participants = [{ name: 'Agent1', agentId: 'claude-code' }]; + registeredHandler!({}, 'gc-123', participants); + + expect(callback).toHaveBeenCalledWith('gc-123', participants); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: ( + event: unknown, + groupChatId: string, + participants: unknown[] + ) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: (event: unknown, groupChatId: string, participants: unknown[]) => void + ) => { + registeredHandler = handler; + } + ); + + const cleanup = api.onParticipantsChanged(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith( + 'groupChat:participantsChanged', + registeredHandler! + ); + }); + }); + + describe('onModeratorUsage', () => { + it('should register and handle moderator usage events', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, groupChatId: string, usage: unknown) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: (event: unknown, groupChatId: string, usage: unknown) => void + ) => { + registeredHandler = handler; + } + ); + + api.onModeratorUsage(callback); + + const usage = { contextUsage: 50, totalCost: 0.05, tokenCount: 1000 }; + registeredHandler!({}, 'gc-123', usage); + + expect(callback).toHaveBeenCalledWith('gc-123', usage); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, groupChatId: string, usage: unknown) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: (event: unknown, groupChatId: string, usage: unknown) => void + ) => { + registeredHandler = handler; + } + ); + + const cleanup = api.onModeratorUsage(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith( + 'groupChat:moderatorUsage', + registeredHandler! + ); + }); + }); + + describe('onHistoryEntry', () => { + it('should register and handle history entry events', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, groupChatId: string, entry: unknown) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: (event: unknown, groupChatId: string, entry: unknown) => void + ) => { + registeredHandler = handler; + } + ); + + api.onHistoryEntry(callback); + + const entry = { id: 'h-1', summary: 'Task done' }; + registeredHandler!({}, 'gc-123', entry); + + expect(callback).toHaveBeenCalledWith('gc-123', entry); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, groupChatId: string, entry: unknown) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: (event: unknown, groupChatId: string, entry: unknown) => void + ) => { + registeredHandler = handler; + } + ); + + const cleanup = api.onHistoryEntry(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith( + 'groupChat:historyEntry', + registeredHandler! + ); + }); + }); + + describe('onParticipantState', () => { + it('should register and handle participant state events', () => { + const callback = vi.fn(); + let registeredHandler: ( + event: unknown, + groupChatId: string, + participantName: string, + state: string + ) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: ( + event: unknown, + groupChatId: string, + participantName: string, + state: string + ) => void + ) => { + registeredHandler = handler; + } + ); + + api.onParticipantState(callback); + registeredHandler!({}, 'gc-123', 'Agent1', 'working'); + + expect(callback).toHaveBeenCalledWith('gc-123', 'Agent1', 'working'); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: ( + event: unknown, + groupChatId: string, + participantName: string, + state: string + ) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: ( + event: unknown, + groupChatId: string, + participantName: string, + state: string + ) => void + ) => { + registeredHandler = handler; + } + ); + + const cleanup = api.onParticipantState(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith( + 'groupChat:participantState', + registeredHandler! + ); + }); + }); + + describe('onModeratorSessionIdChanged', () => { + it('should register and handle moderator session id changed events', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, groupChatId: string, sessionId: string) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: (event: unknown, groupChatId: string, sessionId: string) => void + ) => { + registeredHandler = handler; + } + ); + + api.onModeratorSessionIdChanged(callback); + registeredHandler!({}, 'gc-123', 'new-session-id'); + + expect(callback).toHaveBeenCalledWith('gc-123', 'new-session-id'); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, groupChatId: string, sessionId: string) => void; + + mockOn.mockImplementation( + ( + _channel: string, + handler: (event: unknown, groupChatId: string, sessionId: string) => void + ) => { + registeredHandler = handler; + } + ); + + const cleanup = api.onModeratorSessionIdChanged(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith( + 'groupChat:moderatorSessionIdChanged', + registeredHandler! + ); + }); + }); + }); +}); diff --git a/src/__tests__/main/preload/leaderboard.test.ts b/src/__tests__/main/preload/leaderboard.test.ts new file mode 100644 index 00000000..5dfd3b59 --- /dev/null +++ b/src/__tests__/main/preload/leaderboard.test.ts @@ -0,0 +1,176 @@ +/** + * Tests for leaderboard preload API + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + }, +})); + +import { createLeaderboardApi } from '../../../main/preload/leaderboard'; + +describe('Leaderboard Preload API', () => { + let api: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + api = createLeaderboardApi(); + }); + + describe('getInstallationId', () => { + it('should invoke leaderboard:getInstallationId', async () => { + mockInvoke.mockResolvedValue('test-installation-id'); + + const result = await api.getInstallationId(); + + expect(mockInvoke).toHaveBeenCalledWith('leaderboard:getInstallationId'); + expect(result).toBe('test-installation-id'); + }); + + it('should return null when no installation ID', async () => { + mockInvoke.mockResolvedValue(null); + + const result = await api.getInstallationId(); + + expect(result).toBeNull(); + }); + }); + + describe('submit', () => { + it('should invoke leaderboard:submit with data', async () => { + const submitData = { + email: 'test@example.com', + displayName: 'Test User', + badgeLevel: 1, + badgeName: 'Bronze', + cumulativeTimeMs: 10000, + totalRuns: 5, + }; + mockInvoke.mockResolvedValue({ success: true, message: 'Submitted' }); + + const result = await api.submit(submitData); + + expect(mockInvoke).toHaveBeenCalledWith('leaderboard:submit', submitData); + expect(result.success).toBe(true); + }); + }); + + describe('pollAuthStatus', () => { + it('should invoke leaderboard:pollAuthStatus with clientToken', async () => { + mockInvoke.mockResolvedValue({ status: 'confirmed', authToken: 'new-token' }); + + const result = await api.pollAuthStatus('client-token'); + + expect(mockInvoke).toHaveBeenCalledWith('leaderboard:pollAuthStatus', 'client-token'); + expect(result.status).toBe('confirmed'); + expect(result.authToken).toBe('new-token'); + }); + + it('should handle pending status', async () => { + mockInvoke.mockResolvedValue({ status: 'pending' }); + + const result = await api.pollAuthStatus('client-token'); + + expect(result.status).toBe('pending'); + }); + }); + + describe('resendConfirmation', () => { + it('should invoke leaderboard:resendConfirmation with email and clientToken', async () => { + mockInvoke.mockResolvedValue({ success: true, message: 'Email sent' }); + + const result = await api.resendConfirmation({ + email: 'test@example.com', + clientToken: 'token', + }); + + expect(mockInvoke).toHaveBeenCalledWith('leaderboard:resendConfirmation', { + email: 'test@example.com', + clientToken: 'token', + }); + expect(result.success).toBe(true); + }); + }); + + describe('get', () => { + it('should invoke leaderboard:get without options', async () => { + mockInvoke.mockResolvedValue({ success: true, entries: [] }); + + const result = await api.get(); + + expect(mockInvoke).toHaveBeenCalledWith('leaderboard:get', undefined); + expect(result.success).toBe(true); + }); + + it('should invoke leaderboard:get with limit option', async () => { + const mockEntries = [ + { rank: 1, displayName: 'User', badgeLevel: 5, cumulativeTimeMs: 100000 }, + ]; + mockInvoke.mockResolvedValue({ success: true, entries: mockEntries }); + + const result = await api.get({ limit: 10 }); + + expect(mockInvoke).toHaveBeenCalledWith('leaderboard:get', { limit: 10 }); + expect(result.entries).toEqual(mockEntries); + }); + }); + + describe('getLongestRuns', () => { + it('should invoke leaderboard:getLongestRuns', async () => { + const mockEntries = [ + { rank: 1, displayName: 'User', longestRunMs: 50000, runDate: '2024-01-01' }, + ]; + mockInvoke.mockResolvedValue({ success: true, entries: mockEntries }); + + const result = await api.getLongestRuns({ limit: 5 }); + + expect(mockInvoke).toHaveBeenCalledWith('leaderboard:getLongestRuns', { limit: 5 }); + expect(result.entries).toEqual(mockEntries); + }); + }); + + describe('sync', () => { + it('should invoke leaderboard:sync with email and authToken', async () => { + const mockData = { + displayName: 'Test User', + badgeLevel: 3, + badgeName: 'Gold', + cumulativeTimeMs: 50000, + totalRuns: 25, + longestRunMs: 5000, + longestRunDate: '2024-01-01', + keyboardLevel: 2, + coveragePercent: 75, + ranking: { + cumulative: { rank: 5, total: 100 }, + longestRun: { rank: 10, total: 100 }, + }, + }; + mockInvoke.mockResolvedValue({ success: true, found: true, data: mockData }); + + const result = await api.sync({ email: 'test@example.com', authToken: 'token' }); + + expect(mockInvoke).toHaveBeenCalledWith('leaderboard:sync', { + email: 'test@example.com', + authToken: 'token', + }); + expect(result.success).toBe(true); + expect(result.found).toBe(true); + expect(result.data).toEqual(mockData); + }); + + it('should handle user not found', async () => { + mockInvoke.mockResolvedValue({ success: true, found: false }); + + const result = await api.sync({ email: 'test@example.com', authToken: 'token' }); + + expect(result.found).toBe(false); + }); + }); +}); diff --git a/src/__tests__/main/preload/logger.test.ts b/src/__tests__/main/preload/logger.test.ts new file mode 100644 index 00000000..45fac439 --- /dev/null +++ b/src/__tests__/main/preload/logger.test.ts @@ -0,0 +1,284 @@ +/** + * Tests for logger preload API + * + * Coverage: + * - createLoggerApi: log, getLogs, clearLogs, setLogLevel, getLogLevel, setMaxLogBuffer, + * getMaxLogBuffer, toast, autorun, onNewLog, getLogFilePath, isFileLoggingEnabled, enableFileLogging + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); +const mockOn = vi.fn(); +const mockRemoveListener = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + on: (...args: unknown[]) => mockOn(...args), + removeListener: (...args: unknown[]) => mockRemoveListener(...args), + }, +})); + +import { createLoggerApi } from '../../../main/preload/logger'; + +describe('Logger Preload API', () => { + let api: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + api = createLoggerApi(); + }); + + describe('log', () => { + it('should invoke logger:log with level and message', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.log('info', 'Test message'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'logger:log', + 'info', + 'Test message', + undefined, + undefined + ); + }); + + it('should invoke logger:log with context and data', async () => { + mockInvoke.mockResolvedValue(undefined); + const data = { key: 'value' }; + + await api.log('error', 'Error occurred', 'MyContext', data); + + expect(mockInvoke).toHaveBeenCalledWith( + 'logger:log', + 'error', + 'Error occurred', + 'MyContext', + data + ); + }); + }); + + describe('getLogs', () => { + it('should invoke logger:getLogs without filter', async () => { + const logs = [{ level: 'info', message: 'Test', timestamp: Date.now() }]; + mockInvoke.mockResolvedValue(logs); + + const result = await api.getLogs(); + + expect(mockInvoke).toHaveBeenCalledWith('logger:getLogs', undefined); + expect(result).toEqual(logs); + }); + + it('should invoke logger:getLogs with filter', async () => { + mockInvoke.mockResolvedValue([]); + const filter = { level: 'error' as const, context: 'Test', limit: 100 }; + + await api.getLogs(filter); + + expect(mockInvoke).toHaveBeenCalledWith('logger:getLogs', filter); + }); + }); + + describe('clearLogs', () => { + it('should invoke logger:clearLogs', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.clearLogs(); + + expect(mockInvoke).toHaveBeenCalledWith('logger:clearLogs'); + }); + }); + + describe('setLogLevel', () => { + it('should invoke logger:setLogLevel', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.setLogLevel('debug'); + + expect(mockInvoke).toHaveBeenCalledWith('logger:setLogLevel', 'debug'); + }); + }); + + describe('getLogLevel', () => { + it('should invoke logger:getLogLevel', async () => { + mockInvoke.mockResolvedValue('info'); + + const result = await api.getLogLevel(); + + expect(mockInvoke).toHaveBeenCalledWith('logger:getLogLevel'); + expect(result).toBe('info'); + }); + }); + + describe('setMaxLogBuffer', () => { + it('should invoke logger:setMaxLogBuffer', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.setMaxLogBuffer(500); + + expect(mockInvoke).toHaveBeenCalledWith('logger:setMaxLogBuffer', 500); + }); + }); + + describe('getMaxLogBuffer', () => { + it('should invoke logger:getMaxLogBuffer', async () => { + mockInvoke.mockResolvedValue(1000); + + const result = await api.getMaxLogBuffer(); + + expect(mockInvoke).toHaveBeenCalledWith('logger:getMaxLogBuffer'); + expect(result).toBe(1000); + }); + }); + + describe('toast', () => { + it('should invoke logger:log with toast level', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.toast('Notification title'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'logger:log', + 'toast', + 'Notification title', + 'Toast', + undefined + ); + }); + + it('should invoke with data', async () => { + mockInvoke.mockResolvedValue(undefined); + const data = { action: 'clicked' }; + + await api.toast('Notification', data); + + expect(mockInvoke).toHaveBeenCalledWith('logger:log', 'toast', 'Notification', 'Toast', data); + }); + }); + + describe('autorun', () => { + it('should invoke logger:log with autorun level', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.autorun('Task started'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'logger:log', + 'autorun', + 'Task started', + 'AutoRun', + undefined + ); + }); + + it('should invoke with custom context', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.autorun('Task completed', 'Playbook'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'logger:log', + 'autorun', + 'Task completed', + 'Playbook', + undefined + ); + }); + + it('should invoke with data', async () => { + mockInvoke.mockResolvedValue(undefined); + const data = { taskIndex: 5 }; + + await api.autorun('Processing', 'AutoRun', data); + + expect(mockInvoke).toHaveBeenCalledWith( + 'logger:log', + 'autorun', + 'Processing', + 'AutoRun', + data + ); + }); + }); + + describe('onNewLog', () => { + it('should register event listener and return cleanup function', () => { + const callback = vi.fn(); + + const cleanup = api.onNewLog(callback); + + expect(mockOn).toHaveBeenCalledWith('logger:newLog', expect.any(Function)); + expect(typeof cleanup).toBe('function'); + }); + + it('should call callback when event is received', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, log: unknown) => void; + + mockOn.mockImplementation( + (_channel: string, handler: (event: unknown, log: unknown) => void) => { + registeredHandler = handler; + } + ); + + api.onNewLog(callback); + + const logEntry = { level: 'info', message: 'Test', timestamp: Date.now() }; + registeredHandler!({}, logEntry); + + expect(callback).toHaveBeenCalledWith(logEntry); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, log: unknown) => void; + + mockOn.mockImplementation( + (_channel: string, handler: (event: unknown, log: unknown) => void) => { + registeredHandler = handler; + } + ); + + const cleanup = api.onNewLog(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith('logger:newLog', registeredHandler!); + }); + }); + + describe('getLogFilePath', () => { + it('should invoke logger:getLogFilePath', async () => { + mockInvoke.mockResolvedValue('/path/to/logs/maestro.log'); + + const result = await api.getLogFilePath(); + + expect(mockInvoke).toHaveBeenCalledWith('logger:getLogFilePath'); + expect(result).toBe('/path/to/logs/maestro.log'); + }); + }); + + describe('isFileLoggingEnabled', () => { + it('should invoke logger:isFileLoggingEnabled', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.isFileLoggingEnabled(); + + expect(mockInvoke).toHaveBeenCalledWith('logger:isFileLoggingEnabled'); + expect(result).toBe(true); + }); + }); + + describe('enableFileLogging', () => { + it('should invoke logger:enableFileLogging', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.enableFileLogging(); + + expect(mockInvoke).toHaveBeenCalledWith('logger:enableFileLogging'); + }); + }); +}); diff --git a/src/__tests__/main/preload/notifications.test.ts b/src/__tests__/main/preload/notifications.test.ts new file mode 100644 index 00000000..517c9ae5 --- /dev/null +++ b/src/__tests__/main/preload/notifications.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for notifications preload API + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); +const mockOn = vi.fn(); +const mockRemoveListener = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + on: (...args: unknown[]) => mockOn(...args), + removeListener: (...args: unknown[]) => mockRemoveListener(...args), + }, +})); + +import { createNotificationApi } from '../../../main/preload/notifications'; + +describe('Notification Preload API', () => { + let api: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + api = createNotificationApi(); + }); + + describe('show', () => { + it('should invoke notification:show with title and body', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + const result = await api.show('Test Title', 'Test Body'); + + expect(mockInvoke).toHaveBeenCalledWith('notification:show', 'Test Title', 'Test Body'); + expect(result).toEqual({ success: true }); + }); + + it('should handle errors', async () => { + mockInvoke.mockResolvedValue({ success: false, error: 'Failed to show notification' }); + + const result = await api.show('Title', 'Body'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to show notification'); + }); + }); + + describe('speak', () => { + it('should invoke notification:speak with text', async () => { + mockInvoke.mockResolvedValue({ success: true, ttsId: 123 }); + + const result = await api.speak('Hello world'); + + expect(mockInvoke).toHaveBeenCalledWith('notification:speak', 'Hello world', undefined); + expect(result).toEqual({ success: true, ttsId: 123 }); + }); + + it('should invoke notification:speak with custom command', async () => { + mockInvoke.mockResolvedValue({ success: true, ttsId: 456 }); + + const result = await api.speak('Hello', 'espeak'); + + expect(mockInvoke).toHaveBeenCalledWith('notification:speak', 'Hello', 'espeak'); + expect(result.ttsId).toBe(456); + }); + }); + + describe('stopSpeak', () => { + it('should invoke notification:stopSpeak with ttsId', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + const result = await api.stopSpeak(123); + + expect(mockInvoke).toHaveBeenCalledWith('notification:stopSpeak', 123); + expect(result.success).toBe(true); + }); + }); + + describe('onTtsCompleted', () => { + it('should register event listener and return cleanup function', () => { + const callback = vi.fn(); + + const cleanup = api.onTtsCompleted(callback); + + expect(mockOn).toHaveBeenCalledWith('tts:completed', expect.any(Function)); + expect(typeof cleanup).toBe('function'); + }); + + it('should call callback when event is received', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, ttsId: number) => void; + + mockOn.mockImplementation( + (_channel: string, handler: (event: unknown, ttsId: number) => void) => { + registeredHandler = handler; + } + ); + + api.onTtsCompleted(callback); + + // Simulate receiving the event + registeredHandler!({}, 789); + + expect(callback).toHaveBeenCalledWith(789); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, ttsId: number) => void; + + mockOn.mockImplementation( + (_channel: string, handler: (event: unknown, ttsId: number) => void) => { + registeredHandler = handler; + } + ); + + const cleanup = api.onTtsCompleted(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith('tts:completed', registeredHandler!); + }); + }); +}); diff --git a/src/__tests__/main/preload/process.test.ts b/src/__tests__/main/preload/process.test.ts new file mode 100644 index 00000000..6e0a9631 --- /dev/null +++ b/src/__tests__/main/preload/process.test.ts @@ -0,0 +1,298 @@ +/** + * Tests for process preload API + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); +const mockOn = vi.fn(); +const mockRemoveListener = vi.fn(); +const mockSend = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + on: (...args: unknown[]) => mockOn(...args), + removeListener: (...args: unknown[]) => mockRemoveListener(...args), + send: (...args: unknown[]) => mockSend(...args), + }, +})); + +import { createProcessApi, type ProcessConfig } from '../../../main/preload/process'; + +describe('Process Preload API', () => { + let api: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + api = createProcessApi(); + }); + + describe('spawn', () => { + it('should invoke process:spawn with config', async () => { + const config: ProcessConfig = { + sessionId: 'session-123', + toolType: 'claude-code', + cwd: '/home/user/project', + command: 'claude', + args: ['--json'], + }; + mockInvoke.mockResolvedValue({ pid: 1234, success: true }); + + const result = await api.spawn(config); + + expect(mockInvoke).toHaveBeenCalledWith('process:spawn', config); + expect(result.pid).toBe(1234); + expect(result.success).toBe(true); + }); + + it('should handle SSH remote response', async () => { + const config: ProcessConfig = { + sessionId: 'session-123', + toolType: 'claude-code', + cwd: '/home/user/project', + command: 'claude', + args: [], + }; + mockInvoke.mockResolvedValue({ + pid: 1234, + success: true, + sshRemote: { id: 'remote-1', name: 'My Server', host: 'example.com' }, + }); + + const result = await api.spawn(config); + + expect(result.sshRemote).toEqual({ id: 'remote-1', name: 'My Server', host: 'example.com' }); + }); + }); + + describe('write', () => { + it('should invoke process:write with sessionId and data', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.write('session-123', 'Hello'); + + expect(mockInvoke).toHaveBeenCalledWith('process:write', 'session-123', 'Hello'); + expect(result).toBe(true); + }); + }); + + describe('interrupt', () => { + it('should invoke process:interrupt with sessionId', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.interrupt('session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('process:interrupt', 'session-123'); + expect(result).toBe(true); + }); + }); + + describe('kill', () => { + it('should invoke process:kill with sessionId', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.kill('session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('process:kill', 'session-123'); + expect(result).toBe(true); + }); + }); + + describe('resize', () => { + it('should invoke process:resize with sessionId, cols, and rows', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.resize('session-123', 120, 40); + + expect(mockInvoke).toHaveBeenCalledWith('process:resize', 'session-123', 120, 40); + expect(result).toBe(true); + }); + }); + + describe('runCommand', () => { + it('should invoke process:runCommand with config', async () => { + const config = { + sessionId: 'session-123', + command: 'ls -la', + cwd: '/home/user', + shell: '/bin/bash', + }; + mockInvoke.mockResolvedValue({ exitCode: 0 }); + + const result = await api.runCommand(config); + + expect(mockInvoke).toHaveBeenCalledWith('process:runCommand', config); + expect(result.exitCode).toBe(0); + }); + + it('should handle SSH remote config', async () => { + const config = { + sessionId: 'session-123', + command: 'ls -la', + cwd: '/home/user', + sessionSshRemoteConfig: { + enabled: true, + remoteId: 'remote-1', + workingDirOverride: '/remote/path', + }, + }; + mockInvoke.mockResolvedValue({ exitCode: 0 }); + + await api.runCommand(config); + + expect(mockInvoke).toHaveBeenCalledWith('process:runCommand', config); + }); + }); + + describe('getActiveProcesses', () => { + it('should invoke process:getActiveProcesses', async () => { + const mockProcesses = [ + { + sessionId: 'session-123', + toolType: 'claude-code', + pid: 1234, + cwd: '/home/user', + isTerminal: false, + isBatchMode: false, + startTime: Date.now(), + }, + ]; + mockInvoke.mockResolvedValue(mockProcesses); + + const result = await api.getActiveProcesses(); + + expect(mockInvoke).toHaveBeenCalledWith('process:getActiveProcesses'); + expect(result).toEqual(mockProcesses); + }); + }); + + describe('onData', () => { + it('should register event listener for process:data', () => { + const callback = vi.fn(); + + const cleanup = api.onData(callback); + + expect(mockOn).toHaveBeenCalledWith('process:data', expect.any(Function)); + expect(typeof cleanup).toBe('function'); + }); + + it('should call callback with sessionId and data', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, sessionId: string, data: string) => void; + + mockOn.mockImplementation((channel: string, handler: typeof registeredHandler) => { + if (channel === 'process:data') { + registeredHandler = handler; + } + }); + + api.onData(callback); + registeredHandler!({}, 'session-123', 'output data'); + + expect(callback).toHaveBeenCalledWith('session-123', 'output data'); + }); + }); + + describe('onExit', () => { + it('should register event listener for process:exit', () => { + const callback = vi.fn(); + + const cleanup = api.onExit(callback); + + expect(mockOn).toHaveBeenCalledWith('process:exit', expect.any(Function)); + expect(typeof cleanup).toBe('function'); + }); + }); + + describe('onUsage', () => { + it('should register event listener for process:usage', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, sessionId: string, usageStats: unknown) => void; + + mockOn.mockImplementation((channel: string, handler: typeof registeredHandler) => { + if (channel === 'process:usage') { + registeredHandler = handler; + } + }); + + api.onUsage(callback); + + const usageStats = { + inputTokens: 100, + outputTokens: 200, + cacheReadInputTokens: 50, + cacheCreationInputTokens: 25, + totalCostUsd: 0.01, + contextWindow: 100000, + }; + registeredHandler!({}, 'session-123', usageStats); + + expect(callback).toHaveBeenCalledWith('session-123', usageStats); + }); + }); + + describe('onAgentError', () => { + it('should register event listener for agent:error', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, sessionId: string, error: unknown) => void; + + mockOn.mockImplementation((channel: string, handler: typeof registeredHandler) => { + if (channel === 'agent:error') { + registeredHandler = handler; + } + }); + + api.onAgentError(callback); + + const error = { + type: 'auth_expired', + message: 'Authentication expired', + recoverable: true, + agentId: 'claude-code', + timestamp: Date.now(), + }; + registeredHandler!({}, 'session-123', error); + + expect(callback).toHaveBeenCalledWith('session-123', error); + }); + }); + + describe('sendRemoteNewTabResponse', () => { + it('should send response via ipcRenderer.send', () => { + api.sendRemoteNewTabResponse('response-channel', { tabId: 'tab-123' }); + + expect(mockSend).toHaveBeenCalledWith('response-channel', { tabId: 'tab-123' }); + }); + + it('should send null result', () => { + api.sendRemoteNewTabResponse('response-channel', null); + + expect(mockSend).toHaveBeenCalledWith('response-channel', null); + }); + }); + + describe('onRemoteCommand', () => { + it('should register listener and invoke callback with all parameters', () => { + const callback = vi.fn(); + let registeredHandler: ( + event: unknown, + sessionId: string, + command: string, + inputMode?: 'ai' | 'terminal' + ) => void; + + mockOn.mockImplementation((channel: string, handler: typeof registeredHandler) => { + if (channel === 'remote:executeCommand') { + registeredHandler = handler; + } + }); + + api.onRemoteCommand(callback); + registeredHandler!({}, 'session-123', 'test command', 'ai'); + + expect(callback).toHaveBeenCalledWith('session-123', 'test command', 'ai'); + }); + }); +}); diff --git a/src/__tests__/main/preload/sessions.test.ts b/src/__tests__/main/preload/sessions.test.ts new file mode 100644 index 00000000..3129669c --- /dev/null +++ b/src/__tests__/main/preload/sessions.test.ts @@ -0,0 +1,448 @@ +/** + * Tests for sessions preload API + * + * Coverage: + * - createClaudeApi (deprecated): listSessions, listSessionsPaginated, getProjectStats, + * readSessionMessages, searchSessions, deleteMessagePair + * - createAgentSessionsApi: list, listPaginated, read, search, getPath, deleteMessagePair, + * hasStorage, getAvailableStorages, getGlobalStats, getAllNamedSessions, onGlobalStatsUpdate, + * registerSessionOrigin, updateSessionName, getOrigins, setSessionName, setSessionStarred + * + * Note: createClaudeApi is deprecated but still tested for backwards compatibility. + * These tests ensure the deprecation warnings are logged correctly. + */ + +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); +const mockOn = vi.fn(); +const mockRemoveListener = vi.fn(); + +// Mock console.warn for deprecation warnings +const mockConsoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + on: (...args: unknown[]) => mockOn(...args), + removeListener: (...args: unknown[]) => mockRemoveListener(...args), + }, +})); + +import { createClaudeApi, createAgentSessionsApi } from '../../../main/preload/sessions'; + +describe('Sessions Preload API', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConsoleWarn.mockClear(); + }); + + afterAll(() => { + mockConsoleWarn.mockRestore(); + }); + + describe('createClaudeApi (deprecated)', () => { + let api: ReturnType; + + beforeEach(() => { + api = createClaudeApi(); + }); + + describe('listSessions', () => { + it('should invoke claude:listSessions and log deprecation warning', async () => { + mockInvoke.mockResolvedValue([]); + + await api.listSessions('/project'); + + expect(mockInvoke).toHaveBeenCalledWith('claude:listSessions', '/project'); + expect(mockConsoleWarn).toHaveBeenCalledWith(expect.stringContaining('deprecated')); + }); + }); + + describe('listSessionsPaginated', () => { + it('should invoke claude:listSessionsPaginated', async () => { + mockInvoke.mockResolvedValue({ sessions: [], cursor: null }); + + await api.listSessionsPaginated('/project', { limit: 10 }); + + expect(mockInvoke).toHaveBeenCalledWith('claude:listSessionsPaginated', '/project', { + limit: 10, + }); + }); + }); + + describe('getProjectStats', () => { + it('should invoke claude:getProjectStats', async () => { + mockInvoke.mockResolvedValue({ totalSessions: 5 }); + + await api.getProjectStats('/project'); + + expect(mockInvoke).toHaveBeenCalledWith('claude:getProjectStats', '/project'); + }); + }); + + describe('readSessionMessages', () => { + it('should invoke claude:readSessionMessages', async () => { + mockInvoke.mockResolvedValue({ messages: [] }); + + await api.readSessionMessages('/project', 'session-123', { limit: 50 }); + + expect(mockInvoke).toHaveBeenCalledWith( + 'claude:readSessionMessages', + '/project', + 'session-123', + { limit: 50 } + ); + }); + }); + + describe('searchSessions', () => { + it('should invoke claude:searchSessions', async () => { + mockInvoke.mockResolvedValue([]); + + await api.searchSessions('/project', 'query', 'all'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'claude:searchSessions', + '/project', + 'query', + 'all' + ); + }); + }); + + describe('deleteMessagePair', () => { + it('should invoke claude:deleteMessagePair', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.deleteMessagePair('/project', 'session-123', 'uuid-456', 'fallback'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'claude:deleteMessagePair', + '/project', + 'session-123', + 'uuid-456', + 'fallback' + ); + }); + }); + }); + + describe('createAgentSessionsApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createAgentSessionsApi(); + }); + + describe('list', () => { + it('should invoke agentSessions:list', async () => { + mockInvoke.mockResolvedValue([]); + + await api.list('claude-code', '/project'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agentSessions:list', + 'claude-code', + '/project', + undefined + ); + }); + + it('should invoke with sshRemoteId', async () => { + mockInvoke.mockResolvedValue([]); + + await api.list('claude-code', '/project', 'ssh-remote-1'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agentSessions:list', + 'claude-code', + '/project', + 'ssh-remote-1' + ); + }); + }); + + describe('listPaginated', () => { + it('should invoke agentSessions:listPaginated', async () => { + mockInvoke.mockResolvedValue({ sessions: [], cursor: null }); + + await api.listPaginated('claude-code', '/project', { limit: 20 }); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agentSessions:listPaginated', + 'claude-code', + '/project', + { limit: 20 }, + undefined + ); + }); + }); + + describe('read', () => { + it('should invoke agentSessions:read', async () => { + mockInvoke.mockResolvedValue({ messages: [] }); + + await api.read('claude-code', '/project', 'session-123', { offset: 0, limit: 50 }); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agentSessions:read', + 'claude-code', + '/project', + 'session-123', + { offset: 0, limit: 50 }, + undefined + ); + }); + }); + + describe('search', () => { + it('should invoke agentSessions:search', async () => { + mockInvoke.mockResolvedValue([]); + + await api.search('claude-code', '/project', 'search query', 'title'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agentSessions:search', + 'claude-code', + '/project', + 'search query', + 'title', + undefined + ); + }); + }); + + describe('getPath', () => { + it('should invoke agentSessions:getPath', async () => { + mockInvoke.mockResolvedValue('/path/to/session.json'); + + const result = await api.getPath('claude-code', '/project', 'session-123'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agentSessions:getPath', + 'claude-code', + '/project', + 'session-123', + undefined + ); + expect(result).toBe('/path/to/session.json'); + }); + }); + + describe('deleteMessagePair', () => { + it('should invoke agentSessions:deleteMessagePair', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.deleteMessagePair('claude-code', '/project', 'session-123', 'uuid-456'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agentSessions:deleteMessagePair', + 'claude-code', + '/project', + 'session-123', + 'uuid-456', + undefined + ); + }); + }); + + describe('hasStorage', () => { + it('should invoke agentSessions:hasStorage', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.hasStorage('claude-code'); + + expect(mockInvoke).toHaveBeenCalledWith('agentSessions:hasStorage', 'claude-code'); + expect(result).toBe(true); + }); + }); + + describe('getAvailableStorages', () => { + it('should invoke agentSessions:getAvailableStorages', async () => { + mockInvoke.mockResolvedValue(['claude-code', 'opencode']); + + const result = await api.getAvailableStorages(); + + expect(mockInvoke).toHaveBeenCalledWith('agentSessions:getAvailableStorages'); + expect(result).toEqual(['claude-code', 'opencode']); + }); + }); + + describe('getGlobalStats', () => { + it('should invoke agentSessions:getGlobalStats', async () => { + const stats = { totalSessions: 100, totalMessages: 1000 }; + mockInvoke.mockResolvedValue(stats); + + const result = await api.getGlobalStats(); + + expect(mockInvoke).toHaveBeenCalledWith('agentSessions:getGlobalStats'); + expect(result).toEqual(stats); + }); + }); + + describe('getAllNamedSessions', () => { + it('should invoke agentSessions:getAllNamedSessions', async () => { + const sessions = [ + { + agentId: 'claude-code', + agentSessionId: '123', + projectPath: '/project', + sessionName: 'Test', + }, + ]; + mockInvoke.mockResolvedValue(sessions); + + const result = await api.getAllNamedSessions(); + + expect(mockInvoke).toHaveBeenCalledWith('agentSessions:getAllNamedSessions'); + expect(result).toEqual(sessions); + }); + }); + + describe('onGlobalStatsUpdate', () => { + it('should register event listener and return cleanup function', () => { + const callback = vi.fn(); + + const cleanup = api.onGlobalStatsUpdate(callback); + + expect(mockOn).toHaveBeenCalledWith( + 'agentSessions:globalStatsUpdate', + expect.any(Function) + ); + expect(typeof cleanup).toBe('function'); + }); + + it('should call callback when event is received', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, stats: unknown) => void; + + mockOn.mockImplementation( + (_channel: string, handler: (event: unknown, stats: unknown) => void) => { + registeredHandler = handler; + } + ); + + api.onGlobalStatsUpdate(callback); + + const stats = { totalSessions: 50 }; + registeredHandler!({}, stats); + + expect(callback).toHaveBeenCalledWith(stats); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, stats: unknown) => void; + + mockOn.mockImplementation( + (_channel: string, handler: (event: unknown, stats: unknown) => void) => { + registeredHandler = handler; + } + ); + + const cleanup = api.onGlobalStatsUpdate(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith( + 'agentSessions:globalStatsUpdate', + registeredHandler! + ); + }); + }); + + describe('registerSessionOrigin', () => { + it('should invoke claude:registerSessionOrigin', async () => { + mockInvoke.mockResolvedValue(true); + + await api.registerSessionOrigin('/project', 'agent-session-123', 'user', 'My Session'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'claude:registerSessionOrigin', + '/project', + 'agent-session-123', + 'user', + 'My Session' + ); + }); + }); + + describe('updateSessionName', () => { + it('should invoke claude:updateSessionName', async () => { + mockInvoke.mockResolvedValue(true); + + await api.updateSessionName('/project', 'agent-session-123', 'New Name'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'claude:updateSessionName', + '/project', + 'agent-session-123', + 'New Name' + ); + }); + }); + + describe('getOrigins', () => { + it('should invoke agentSessions:getOrigins', async () => { + const origins = { 'session-1': { origin: 'user', sessionName: 'Test' } }; + mockInvoke.mockResolvedValue(origins); + + const result = await api.getOrigins('claude-code', '/project'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agentSessions:getOrigins', + 'claude-code', + '/project' + ); + expect(result).toEqual(origins); + }); + }); + + describe('setSessionName', () => { + it('should invoke agentSessions:setSessionName', async () => { + mockInvoke.mockResolvedValue(true); + + await api.setSessionName('claude-code', '/project', 'session-123', 'New Name'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agentSessions:setSessionName', + 'claude-code', + '/project', + 'session-123', + 'New Name' + ); + }); + + it('should handle null to clear name', async () => { + mockInvoke.mockResolvedValue(true); + + await api.setSessionName('claude-code', '/project', 'session-123', null); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agentSessions:setSessionName', + 'claude-code', + '/project', + 'session-123', + null + ); + }); + }); + + describe('setSessionStarred', () => { + it('should invoke agentSessions:setSessionStarred', async () => { + mockInvoke.mockResolvedValue(true); + + await api.setSessionStarred('claude-code', '/project', 'session-123', true); + + expect(mockInvoke).toHaveBeenCalledWith( + 'agentSessions:setSessionStarred', + 'claude-code', + '/project', + 'session-123', + true + ); + }); + }); + }); +}); diff --git a/src/__tests__/main/preload/settings.test.ts b/src/__tests__/main/preload/settings.test.ts new file mode 100644 index 00000000..b5f54b94 --- /dev/null +++ b/src/__tests__/main/preload/settings.test.ts @@ -0,0 +1,200 @@ +/** + * Tests for settings preload API + * + * Coverage: + * - createSettingsApi: get, set, getAll + * - createSessionsApi: getAll, setAll + * - createGroupsApi: getAll, setAll + * - createAgentErrorApi: clearError, retryAfterError + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + }, +})); + +import { + createSettingsApi, + createSessionsApi, + createGroupsApi, + createAgentErrorApi, +} from '../../../main/preload/settings'; + +describe('Settings Preload API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createSettingsApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createSettingsApi(); + }); + + describe('get', () => { + it('should invoke settings:get with key', async () => { + mockInvoke.mockResolvedValue('test-value'); + + const result = await api.get('theme'); + + expect(mockInvoke).toHaveBeenCalledWith('settings:get', 'theme'); + expect(result).toBe('test-value'); + }); + + it('should return undefined for non-existent key', async () => { + mockInvoke.mockResolvedValue(undefined); + + const result = await api.get('non-existent'); + + expect(result).toBeUndefined(); + }); + }); + + describe('set', () => { + it('should invoke settings:set with key and value and return result', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.set('theme', 'dark'); + + expect(mockInvoke).toHaveBeenCalledWith('settings:set', 'theme', 'dark'); + expect(result).toBe(true); + }); + + it('should handle complex values', async () => { + mockInvoke.mockResolvedValue(true); + const complexValue = { nested: { key: 'value' }, array: [1, 2, 3] }; + + const result = await api.set('config', complexValue); + + expect(mockInvoke).toHaveBeenCalledWith('settings:set', 'config', complexValue); + expect(result).toBe(true); + }); + }); + + describe('getAll', () => { + it('should invoke settings:getAll', async () => { + const allSettings = { theme: 'dark', fontSize: 14 }; + mockInvoke.mockResolvedValue(allSettings); + + const result = await api.getAll(); + + expect(mockInvoke).toHaveBeenCalledWith('settings:getAll'); + expect(result).toEqual(allSettings); + }); + }); + }); + + describe('createSessionsApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createSessionsApi(); + }); + + describe('getAll', () => { + it('should invoke sessions:getAll', async () => { + const sessions = [{ id: '1', name: 'Session 1' }]; + mockInvoke.mockResolvedValue(sessions); + + const result = await api.getAll(); + + expect(mockInvoke).toHaveBeenCalledWith('sessions:getAll'); + expect(result).toEqual(sessions); + }); + }); + + describe('setAll', () => { + it('should invoke sessions:setAll with sessions array and return result', async () => { + const sessions = [ + { id: '1', name: 'Session 1' }, + { id: '2', name: 'Session 2' }, + ]; + mockInvoke.mockResolvedValue(true); + + const result = await api.setAll(sessions); + + expect(mockInvoke).toHaveBeenCalledWith('sessions:setAll', sessions); + expect(result).toBe(true); + }); + }); + }); + + describe('createGroupsApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createGroupsApi(); + }); + + describe('getAll', () => { + it('should invoke groups:getAll', async () => { + const groups = [{ id: '1', name: 'Group 1' }]; + mockInvoke.mockResolvedValue(groups); + + const result = await api.getAll(); + + expect(mockInvoke).toHaveBeenCalledWith('groups:getAll'); + expect(result).toEqual(groups); + }); + }); + + describe('setAll', () => { + it('should invoke groups:setAll with groups array and return result', async () => { + const groups = [{ id: '1', name: 'Group 1' }]; + mockInvoke.mockResolvedValue(true); + + const result = await api.setAll(groups); + + expect(mockInvoke).toHaveBeenCalledWith('groups:setAll', groups); + expect(result).toBe(true); + }); + }); + }); + + describe('createAgentErrorApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createAgentErrorApi(); + }); + + describe('clearError', () => { + it('should invoke agent:clearError with sessionId and return result', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.clearError('session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('agent:clearError', 'session-123'); + expect(result).toBe(true); + }); + }); + + describe('retryAfterError', () => { + it('should invoke agent:retryAfterError with sessionId and undefined options', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.retryAfterError('session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('agent:retryAfterError', 'session-123', undefined); + expect(result).toBe(true); + }); + + it('should invoke agent:retryAfterError with options', async () => { + mockInvoke.mockResolvedValue(true); + const options = { prompt: 'retry prompt', newSession: true }; + + const result = await api.retryAfterError('session-123', options); + + expect(mockInvoke).toHaveBeenCalledWith('agent:retryAfterError', 'session-123', options); + expect(result).toBe(true); + }); + }); + }); +}); diff --git a/src/__tests__/main/preload/sshRemote.test.ts b/src/__tests__/main/preload/sshRemote.test.ts new file mode 100644 index 00000000..cef02471 --- /dev/null +++ b/src/__tests__/main/preload/sshRemote.test.ts @@ -0,0 +1,211 @@ +/** + * Tests for sshRemote preload API + * + * Coverage: + * - createSshRemoteApi: saveConfig, deleteConfig, getConfigs, getDefaultId, setDefaultId, + * test, getSshConfigHosts + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + }, +})); + +import { createSshRemoteApi, type SshRemoteConfig } from '../../../main/preload/sshRemote'; + +describe('SSH Remote Preload API', () => { + let api: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + api = createSshRemoteApi(); + }); + + describe('saveConfig', () => { + it('should invoke ssh-remote:saveConfig with config', async () => { + mockInvoke.mockResolvedValue({ id: 'config-123' }); + const config = { + name: 'My Server', + host: 'server.example.com', + port: 22, + username: 'user', + privateKeyPath: '/path/to/key', + enabled: true, + }; + + const result = await api.saveConfig(config); + + expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:saveConfig', config); + expect(result).toEqual({ id: 'config-123' }); + }); + + it('should handle updating existing config', async () => { + mockInvoke.mockResolvedValue({ id: 'existing-id' }); + const config = { + id: 'existing-id', + name: 'Updated Server', + }; + + await api.saveConfig(config); + + expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:saveConfig', config); + }); + }); + + describe('deleteConfig', () => { + it('should invoke ssh-remote:deleteConfig with id', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.deleteConfig('config-123'); + + expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:deleteConfig', 'config-123'); + expect(result).toBe(true); + }); + }); + + describe('getConfigs', () => { + it('should invoke ssh-remote:getConfigs', async () => { + const configs: SshRemoteConfig[] = [ + { + id: '1', + name: 'Server 1', + host: 'server1.example.com', + port: 22, + username: 'user1', + privateKeyPath: '/path/to/key1', + enabled: true, + }, + { + id: '2', + name: 'Server 2', + host: 'server2.example.com', + port: 2222, + username: 'user2', + privateKeyPath: '/path/to/key2', + enabled: false, + }, + ]; + mockInvoke.mockResolvedValue(configs); + + const result = await api.getConfigs(); + + expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:getConfigs'); + expect(result).toEqual(configs); + }); + }); + + describe('getDefaultId', () => { + it('should invoke ssh-remote:getDefaultId', async () => { + mockInvoke.mockResolvedValue('default-config-id'); + + const result = await api.getDefaultId(); + + expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:getDefaultId'); + expect(result).toBe('default-config-id'); + }); + + it('should return null when no default', async () => { + mockInvoke.mockResolvedValue(null); + + const result = await api.getDefaultId(); + + expect(result).toBeNull(); + }); + }); + + describe('setDefaultId', () => { + it('should invoke ssh-remote:setDefaultId with id', async () => { + mockInvoke.mockResolvedValue(true); + + await api.setDefaultId('config-123'); + + expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:setDefaultId', 'config-123'); + }); + + it('should handle null to clear default', async () => { + mockInvoke.mockResolvedValue(true); + + await api.setDefaultId(null); + + expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:setDefaultId', null); + }); + }); + + describe('test', () => { + it('should invoke ssh-remote:test with config id', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + const result = await api.test('config-123'); + + expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:test', 'config-123', undefined); + expect(result).toEqual({ success: true }); + }); + + it('should invoke ssh-remote:test with config object', async () => { + mockInvoke.mockResolvedValue({ success: true }); + const config: SshRemoteConfig = { + id: 'test', + name: 'Test Server', + host: 'test.example.com', + port: 22, + username: 'testuser', + privateKeyPath: '/path/to/key', + enabled: true, + }; + + const result = await api.test(config); + + expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:test', config, undefined); + expect(result).toEqual({ success: true }); + }); + + it('should invoke ssh-remote:test with agent command', async () => { + mockInvoke.mockResolvedValue({ success: true, agentVersion: '1.0.0' }); + + const result = await api.test('config-123', 'claude'); + + expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:test', 'config-123', 'claude'); + expect(result).toEqual({ success: true, agentVersion: '1.0.0' }); + }); + }); + + describe('getSshConfigHosts', () => { + it('should invoke ssh-remote:getSshConfigHosts', async () => { + const response = { + success: true, + hosts: [ + { host: 'server1', hostName: 'server1.example.com', user: 'user1' }, + { host: 'server2', hostName: 'server2.example.com', port: 2222 }, + ], + configPath: '/home/user/.ssh/config', + }; + mockInvoke.mockResolvedValue(response); + + const result = await api.getSshConfigHosts(); + + expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:getSshConfigHosts'); + expect(result).toEqual(response); + }); + + it('should handle errors', async () => { + const response = { + success: false, + hosts: [], + error: 'Config file not found', + configPath: '/home/user/.ssh/config', + }; + mockInvoke.mockResolvedValue(response); + + const result = await api.getSshConfigHosts(); + + expect(result.success).toBe(false); + expect(result.error).toBe('Config file not found'); + }); + }); +}); diff --git a/src/__tests__/main/preload/stats.test.ts b/src/__tests__/main/preload/stats.test.ts new file mode 100644 index 00000000..8fe88808 --- /dev/null +++ b/src/__tests__/main/preload/stats.test.ts @@ -0,0 +1,368 @@ +/** + * Tests for stats preload API + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); +const mockOn = vi.fn(); +const mockRemoveListener = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + on: (...args: unknown[]) => mockOn(...args), + removeListener: (...args: unknown[]) => mockRemoveListener(...args), + }, +})); + +import { createStatsApi } from '../../../main/preload/stats'; + +describe('Stats Preload API', () => { + let api: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + api = createStatsApi(); + }); + + describe('recordQuery', () => { + it('should invoke stats:record-query', async () => { + mockInvoke.mockResolvedValue('query-123'); + const event = { + sessionId: 'session-1', + agentType: 'claude-code', + source: 'user' as const, + startTime: Date.now(), + duration: 5000, + projectPath: '/project', + }; + + const result = await api.recordQuery(event); + + expect(mockInvoke).toHaveBeenCalledWith('stats:record-query', event); + expect(result).toBe('query-123'); + }); + }); + + describe('startAutoRun', () => { + it('should invoke stats:start-autorun', async () => { + mockInvoke.mockResolvedValue('autorun-123'); + const session = { + sessionId: 'session-1', + agentType: 'claude-code', + documentPath: '/project/.maestro/tasks.md', + startTime: Date.now(), + tasksTotal: 10, + projectPath: '/project', + }; + + const result = await api.startAutoRun(session); + + expect(mockInvoke).toHaveBeenCalledWith('stats:start-autorun', session); + expect(result).toBe('autorun-123'); + }); + }); + + describe('endAutoRun', () => { + it('should invoke stats:end-autorun', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.endAutoRun('autorun-123', 60000, 8); + + expect(mockInvoke).toHaveBeenCalledWith('stats:end-autorun', 'autorun-123', 60000, 8); + expect(result).toBe(true); + }); + }); + + describe('recordAutoTask', () => { + it('should invoke stats:record-task', async () => { + mockInvoke.mockResolvedValue('task-123'); + const task = { + autoRunSessionId: 'autorun-123', + sessionId: 'session-1', + agentType: 'claude-code', + taskIndex: 3, + taskContent: 'Fix the bug', + startTime: Date.now(), + duration: 10000, + success: true, + }; + + const result = await api.recordAutoTask(task); + + expect(mockInvoke).toHaveBeenCalledWith('stats:record-task', task); + expect(result).toBe('task-123'); + }); + }); + + describe('getStats', () => { + it('should invoke stats:get-stats without filters', async () => { + mockInvoke.mockResolvedValue([]); + + const result = await api.getStats('day'); + + expect(mockInvoke).toHaveBeenCalledWith('stats:get-stats', 'day', undefined); + expect(result).toEqual([]); + }); + + it('should invoke stats:get-stats with filters', async () => { + mockInvoke.mockResolvedValue([]); + const filters = { + agentType: 'claude-code', + source: 'user' as const, + projectPath: '/project', + }; + + await api.getStats('week', filters); + + expect(mockInvoke).toHaveBeenCalledWith('stats:get-stats', 'week', filters); + }); + + it('should handle all time ranges', async () => { + mockInvoke.mockResolvedValue([]); + + await api.getStats('month'); + await api.getStats('year'); + await api.getStats('all'); + + expect(mockInvoke).toHaveBeenCalledWith('stats:get-stats', 'month', undefined); + expect(mockInvoke).toHaveBeenCalledWith('stats:get-stats', 'year', undefined); + expect(mockInvoke).toHaveBeenCalledWith('stats:get-stats', 'all', undefined); + }); + }); + + describe('getAutoRunSessions', () => { + it('should invoke stats:get-autorun-sessions', async () => { + const sessions = [ + { + id: 'ar-1', + sessionId: 'session-1', + agentType: 'claude-code', + startTime: Date.now(), + duration: 60000, + tasksTotal: 10, + tasksCompleted: 8, + }, + ]; + mockInvoke.mockResolvedValue(sessions); + + const result = await api.getAutoRunSessions('day'); + + expect(mockInvoke).toHaveBeenCalledWith('stats:get-autorun-sessions', 'day'); + expect(result).toEqual(sessions); + }); + }); + + describe('getAutoRunTasks', () => { + it('should invoke stats:get-autorun-tasks', async () => { + const tasks = [ + { + id: 'task-1', + autoRunSessionId: 'ar-1', + sessionId: 'session-1', + agentType: 'claude-code', + taskIndex: 0, + taskContent: 'Task 1', + startTime: Date.now(), + duration: 5000, + success: true, + }, + ]; + mockInvoke.mockResolvedValue(tasks); + + const result = await api.getAutoRunTasks('ar-1'); + + expect(mockInvoke).toHaveBeenCalledWith('stats:get-autorun-tasks', 'ar-1'); + expect(result).toEqual(tasks); + }); + }); + + describe('getAggregation', () => { + it('should invoke stats:get-aggregation', async () => { + const aggregation = { + totalQueries: 100, + totalDuration: 500000, + avgDuration: 5000, + byAgent: { + 'claude-code': { count: 80, duration: 400000 }, + opencode: { count: 20, duration: 100000 }, + }, + bySource: { user: 70, auto: 30 }, + byDay: [ + { date: '2024-01-01', count: 50, duration: 250000 }, + { date: '2024-01-02', count: 50, duration: 250000 }, + ], + }; + mockInvoke.mockResolvedValue(aggregation); + + const result = await api.getAggregation('week'); + + expect(mockInvoke).toHaveBeenCalledWith('stats:get-aggregation', 'week'); + expect(result).toEqual(aggregation); + }); + }); + + describe('exportCsv', () => { + it('should invoke stats:export-csv', async () => { + mockInvoke.mockResolvedValue('/path/to/export.csv'); + + const result = await api.exportCsv('month'); + + expect(mockInvoke).toHaveBeenCalledWith('stats:export-csv', 'month'); + expect(result).toBe('/path/to/export.csv'); + }); + }); + + describe('onStatsUpdate', () => { + it('should register event listener and return cleanup function', () => { + const callback = vi.fn(); + + const cleanup = api.onStatsUpdate(callback); + + expect(mockOn).toHaveBeenCalledWith('stats:updated', expect.any(Function)); + expect(typeof cleanup).toBe('function'); + }); + + it('should call callback when event is received', () => { + const callback = vi.fn(); + let registeredHandler: () => void; + + mockOn.mockImplementation((_channel: string, handler: () => void) => { + registeredHandler = handler; + }); + + api.onStatsUpdate(callback); + registeredHandler!(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: () => void; + + mockOn.mockImplementation((_channel: string, handler: () => void) => { + registeredHandler = handler; + }); + + const cleanup = api.onStatsUpdate(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith('stats:updated', registeredHandler!); + }); + }); + + describe('clearOldData', () => { + it('should invoke stats:clear-old-data', async () => { + const response = { + success: true, + deletedQueryEvents: 100, + deletedAutoRunSessions: 10, + deletedAutoRunTasks: 50, + }; + mockInvoke.mockResolvedValue(response); + + const result = await api.clearOldData(30); + + expect(mockInvoke).toHaveBeenCalledWith('stats:clear-old-data', 30); + expect(result).toEqual(response); + }); + + it('should handle errors', async () => { + mockInvoke.mockResolvedValue({ + success: false, + deletedQueryEvents: 0, + deletedAutoRunSessions: 0, + deletedAutoRunTasks: 0, + error: 'Database error', + }); + + const result = await api.clearOldData(7); + + expect(result.success).toBe(false); + expect(result.error).toBe('Database error'); + }); + }); + + describe('getDatabaseSize', () => { + it('should invoke stats:get-database-size', async () => { + mockInvoke.mockResolvedValue(1024000); + + const result = await api.getDatabaseSize(); + + expect(mockInvoke).toHaveBeenCalledWith('stats:get-database-size'); + expect(result).toBe(1024000); + }); + }); + + describe('recordSessionCreated', () => { + it('should invoke stats:record-session-created', async () => { + mockInvoke.mockResolvedValue('lifecycle-123'); + const event = { + sessionId: 'session-1', + agentType: 'claude-code', + projectPath: '/project', + createdAt: Date.now(), + isRemote: false, + }; + + const result = await api.recordSessionCreated(event); + + expect(mockInvoke).toHaveBeenCalledWith('stats:record-session-created', event); + expect(result).toBe('lifecycle-123'); + }); + + it('should return null for duplicate sessions', async () => { + mockInvoke.mockResolvedValue(null); + const event = { + sessionId: 'session-1', + agentType: 'claude-code', + createdAt: Date.now(), + }; + + const result = await api.recordSessionCreated(event); + + expect(result).toBeNull(); + }); + }); + + describe('recordSessionClosed', () => { + it('should invoke stats:record-session-closed', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.recordSessionClosed('session-1', Date.now()); + + expect(mockInvoke).toHaveBeenCalledWith( + 'stats:record-session-closed', + 'session-1', + expect.any(Number) + ); + expect(result).toBe(true); + }); + }); + + describe('getSessionLifecycle', () => { + it('should invoke stats:get-session-lifecycle', async () => { + const lifecycle = [ + { + id: 'lc-1', + sessionId: 'session-1', + agentType: 'claude-code', + projectPath: '/project', + createdAt: Date.now() - 60000, + closedAt: Date.now(), + duration: 60000, + isRemote: false, + }, + ]; + mockInvoke.mockResolvedValue(lifecycle); + + const result = await api.getSessionLifecycle('day'); + + expect(mockInvoke).toHaveBeenCalledWith('stats:get-session-lifecycle', 'day'); + expect(result).toEqual(lifecycle); + }); + }); +}); diff --git a/src/__tests__/main/preload/system.test.ts b/src/__tests__/main/preload/system.test.ts new file mode 100644 index 00000000..7c74dc7f --- /dev/null +++ b/src/__tests__/main/preload/system.test.ts @@ -0,0 +1,553 @@ +/** + * Tests for system preload API + * + * Coverage: + * - createDialogApi: selectFolder, saveFile + * - createFontsApi: detect + * - createShellsApi: detect + * - createShellApi: openExternal, trashItem + * - createTunnelApi: isCloudflaredInstalled, start, stop, getStatus + * - createSyncApi: getDefaultPath, getSettings, getCurrentStoragePath, selectSyncFolder, setCustomPath + * - createDevtoolsApi: open, close, toggle + * - createPowerApi: setEnabled, isEnabled, getStatus, addReason, removeReason + * - createUpdatesApi: check, download, install, getStatus, onStatus, setAllowPrerelease + * - createAppApi: onQuitConfirmationRequest, confirmQuit, cancelQuit + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); +const mockOn = vi.fn(); +const mockRemoveListener = vi.fn(); +const mockSend = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + on: (...args: unknown[]) => mockOn(...args), + removeListener: (...args: unknown[]) => mockRemoveListener(...args), + send: (...args: unknown[]) => mockSend(...args), + }, +})); + +import { + createDialogApi, + createFontsApi, + createShellsApi, + createShellApi, + createTunnelApi, + createSyncApi, + createDevtoolsApi, + createPowerApi, + createUpdatesApi, + createAppApi, +} from '../../../main/preload/system'; + +describe('System Preload API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createDialogApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createDialogApi(); + }); + + describe('selectFolder', () => { + it('should invoke dialog:selectFolder', async () => { + mockInvoke.mockResolvedValue('/selected/path'); + + const result = await api.selectFolder(); + + expect(mockInvoke).toHaveBeenCalledWith('dialog:selectFolder'); + expect(result).toBe('/selected/path'); + }); + }); + + describe('saveFile', () => { + it('should invoke dialog:saveFile with options', async () => { + mockInvoke.mockResolvedValue('/saved/file.txt'); + const options = { + defaultPath: '/default/path.txt', + filters: [{ name: 'Text', extensions: ['txt'] }], + title: 'Save File', + }; + + const result = await api.saveFile(options); + + expect(mockInvoke).toHaveBeenCalledWith('dialog:saveFile', options); + expect(result).toBe('/saved/file.txt'); + }); + }); + }); + + describe('createFontsApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createFontsApi(); + }); + + describe('detect', () => { + it('should invoke fonts:detect', async () => { + mockInvoke.mockResolvedValue(['Arial', 'Helvetica', 'Monaco']); + + const result = await api.detect(); + + expect(mockInvoke).toHaveBeenCalledWith('fonts:detect'); + expect(result).toEqual(['Arial', 'Helvetica', 'Monaco']); + }); + }); + }); + + describe('createShellsApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createShellsApi(); + }); + + describe('detect', () => { + it('should invoke shells:detect', async () => { + const shells = [ + { id: 'bash', name: 'Bash', available: true, path: '/bin/bash' }, + { id: 'zsh', name: 'Zsh', available: true, path: '/bin/zsh' }, + ]; + mockInvoke.mockResolvedValue(shells); + + const result = await api.detect(); + + expect(mockInvoke).toHaveBeenCalledWith('shells:detect'); + expect(result).toEqual(shells); + }); + }); + }); + + describe('createShellApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createShellApi(); + }); + + describe('openExternal', () => { + it('should invoke shell:openExternal with url', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.openExternal('https://example.com'); + + expect(mockInvoke).toHaveBeenCalledWith('shell:openExternal', 'https://example.com'); + }); + }); + + describe('trashItem', () => { + it('should invoke shell:trashItem with path', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.trashItem('/path/to/file'); + + expect(mockInvoke).toHaveBeenCalledWith('shell:trashItem', '/path/to/file'); + }); + }); + }); + + describe('createTunnelApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createTunnelApi(); + }); + + describe('isCloudflaredInstalled', () => { + it('should invoke tunnel:isCloudflaredInstalled', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.isCloudflaredInstalled(); + + expect(mockInvoke).toHaveBeenCalledWith('tunnel:isCloudflaredInstalled'); + expect(result).toBe(true); + }); + }); + + describe('start', () => { + it('should invoke tunnel:start', async () => { + mockInvoke.mockResolvedValue({ url: 'https://tunnel.example.com' }); + + const result = await api.start(); + + expect(mockInvoke).toHaveBeenCalledWith('tunnel:start'); + expect(result).toEqual({ url: 'https://tunnel.example.com' }); + }); + }); + + describe('stop', () => { + it('should invoke tunnel:stop', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.stop(); + + expect(mockInvoke).toHaveBeenCalledWith('tunnel:stop'); + }); + }); + + describe('getStatus', () => { + it('should invoke tunnel:getStatus', async () => { + mockInvoke.mockResolvedValue({ running: true, url: 'https://tunnel.example.com' }); + + const result = await api.getStatus(); + + expect(mockInvoke).toHaveBeenCalledWith('tunnel:getStatus'); + expect(result).toEqual({ running: true, url: 'https://tunnel.example.com' }); + }); + }); + }); + + describe('createSyncApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createSyncApi(); + }); + + describe('getDefaultPath', () => { + it('should invoke sync:getDefaultPath', async () => { + mockInvoke.mockResolvedValue('/default/sync/path'); + + const result = await api.getDefaultPath(); + + expect(mockInvoke).toHaveBeenCalledWith('sync:getDefaultPath'); + expect(result).toBe('/default/sync/path'); + }); + }); + + describe('getSettings', () => { + it('should invoke sync:getSettings', async () => { + mockInvoke.mockResolvedValue({ customSyncPath: '/custom/path' }); + + const result = await api.getSettings(); + + expect(mockInvoke).toHaveBeenCalledWith('sync:getSettings'); + expect(result).toEqual({ customSyncPath: '/custom/path' }); + }); + }); + + describe('getCurrentStoragePath', () => { + it('should invoke sync:getCurrentStoragePath', async () => { + mockInvoke.mockResolvedValue('/current/storage/path'); + + const result = await api.getCurrentStoragePath(); + + expect(mockInvoke).toHaveBeenCalledWith('sync:getCurrentStoragePath'); + expect(result).toBe('/current/storage/path'); + }); + }); + + describe('selectSyncFolder', () => { + it('should invoke sync:selectSyncFolder', async () => { + mockInvoke.mockResolvedValue('/selected/sync/folder'); + + const result = await api.selectSyncFolder(); + + expect(mockInvoke).toHaveBeenCalledWith('sync:selectSyncFolder'); + expect(result).toBe('/selected/sync/folder'); + }); + }); + + describe('setCustomPath', () => { + it('should invoke sync:setCustomPath', async () => { + mockInvoke.mockResolvedValue({ success: true, migrated: 5 }); + + const result = await api.setCustomPath('/new/custom/path'); + + expect(mockInvoke).toHaveBeenCalledWith('sync:setCustomPath', '/new/custom/path'); + expect(result).toEqual({ success: true, migrated: 5 }); + }); + + it('should handle null to reset path', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + const result = await api.setCustomPath(null); + + expect(mockInvoke).toHaveBeenCalledWith('sync:setCustomPath', null); + expect(result.success).toBe(true); + }); + }); + }); + + describe('createDevtoolsApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createDevtoolsApi(); + }); + + describe('open', () => { + it('should invoke devtools:open', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.open(); + + expect(mockInvoke).toHaveBeenCalledWith('devtools:open'); + }); + }); + + describe('close', () => { + it('should invoke devtools:close', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.close(); + + expect(mockInvoke).toHaveBeenCalledWith('devtools:close'); + }); + }); + + describe('toggle', () => { + it('should invoke devtools:toggle', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.toggle(); + + expect(mockInvoke).toHaveBeenCalledWith('devtools:toggle'); + }); + }); + }); + + describe('createPowerApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createPowerApi(); + }); + + describe('setEnabled', () => { + it('should invoke power:setEnabled', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.setEnabled(true); + + expect(mockInvoke).toHaveBeenCalledWith('power:setEnabled', true); + }); + }); + + describe('isEnabled', () => { + it('should invoke power:isEnabled', async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.isEnabled(); + + expect(mockInvoke).toHaveBeenCalledWith('power:isEnabled'); + expect(result).toBe(true); + }); + }); + + describe('getStatus', () => { + it('should invoke power:getStatus', async () => { + const status = { + enabled: true, + blocking: true, + reasons: ['Auto Run in progress'], + platform: 'darwin' as const, + }; + mockInvoke.mockResolvedValue(status); + + const result = await api.getStatus(); + + expect(mockInvoke).toHaveBeenCalledWith('power:getStatus'); + expect(result).toEqual(status); + }); + }); + + describe('addReason', () => { + it('should invoke power:addReason', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.addReason('Auto Run in progress'); + + expect(mockInvoke).toHaveBeenCalledWith('power:addReason', 'Auto Run in progress'); + }); + }); + + describe('removeReason', () => { + it('should invoke power:removeReason', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.removeReason('Auto Run in progress'); + + expect(mockInvoke).toHaveBeenCalledWith('power:removeReason', 'Auto Run in progress'); + }); + }); + }); + + describe('createUpdatesApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createUpdatesApi(); + }); + + describe('check', () => { + it('should invoke updates:check', async () => { + const updateInfo = { + currentVersion: '1.0.0', + latestVersion: '1.1.0', + updateAvailable: true, + versionsBehind: 1, + releases: [], + releasesUrl: 'https://github.com/example/releases', + }; + mockInvoke.mockResolvedValue(updateInfo); + + const result = await api.check(); + + expect(mockInvoke).toHaveBeenCalledWith('updates:check', undefined); + expect(result).toEqual(updateInfo); + }); + + it('should invoke updates:check with prerelease flag', async () => { + mockInvoke.mockResolvedValue({}); + + await api.check(true); + + expect(mockInvoke).toHaveBeenCalledWith('updates:check', true); + }); + }); + + describe('download', () => { + it('should invoke updates:download', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + const result = await api.download(); + + expect(mockInvoke).toHaveBeenCalledWith('updates:download'); + expect(result).toEqual({ success: true }); + }); + }); + + describe('install', () => { + it('should invoke updates:install', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.install(); + + expect(mockInvoke).toHaveBeenCalledWith('updates:install'); + }); + }); + + describe('getStatus', () => { + it('should invoke updates:getStatus', async () => { + const status = { status: 'idle' as const }; + mockInvoke.mockResolvedValue(status); + + const result = await api.getStatus(); + + expect(mockInvoke).toHaveBeenCalledWith('updates:getStatus'); + expect(result).toEqual(status); + }); + }); + + describe('onStatus', () => { + it('should register event listener and return cleanup function', () => { + const callback = vi.fn(); + + const cleanup = api.onStatus(callback); + + expect(mockOn).toHaveBeenCalledWith('updates:status', expect.any(Function)); + expect(typeof cleanup).toBe('function'); + }); + + it('should call callback when event is received', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, status: unknown) => void; + + mockOn.mockImplementation( + (_channel: string, handler: (event: unknown, status: unknown) => void) => { + registeredHandler = handler; + } + ); + + api.onStatus(callback); + registeredHandler!({}, { status: 'downloading' }); + + expect(callback).toHaveBeenCalledWith({ status: 'downloading' }); + }); + + it('should remove listener when cleanup is called', () => { + const callback = vi.fn(); + let registeredHandler: (event: unknown, status: unknown) => void; + + mockOn.mockImplementation( + (_channel: string, handler: (event: unknown, status: unknown) => void) => { + registeredHandler = handler; + } + ); + + const cleanup = api.onStatus(callback); + cleanup(); + + expect(mockRemoveListener).toHaveBeenCalledWith('updates:status', registeredHandler!); + }); + }); + + describe('setAllowPrerelease', () => { + it('should invoke updates:setAllowPrerelease', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.setAllowPrerelease(true); + + expect(mockInvoke).toHaveBeenCalledWith('updates:setAllowPrerelease', true); + }); + }); + }); + + describe('createAppApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createAppApi(); + }); + + describe('onQuitConfirmationRequest', () => { + it('should register event listener and return cleanup function', () => { + const callback = vi.fn(); + + const cleanup = api.onQuitConfirmationRequest(callback); + + expect(mockOn).toHaveBeenCalledWith('app:requestQuitConfirmation', expect.any(Function)); + expect(typeof cleanup).toBe('function'); + }); + + it('should call callback when event is received', () => { + const callback = vi.fn(); + let registeredHandler: () => void; + + mockOn.mockImplementation((_channel: string, handler: () => void) => { + registeredHandler = handler; + }); + + api.onQuitConfirmationRequest(callback); + registeredHandler!(); + + expect(callback).toHaveBeenCalled(); + }); + }); + + describe('confirmQuit', () => { + it('should send app:quitConfirmed', () => { + api.confirmQuit(); + + expect(mockSend).toHaveBeenCalledWith('app:quitConfirmed'); + }); + }); + + describe('cancelQuit', () => { + it('should send app:quitCancelled', () => { + api.cancelQuit(); + + expect(mockSend).toHaveBeenCalledWith('app:quitCancelled'); + }); + }); + }); +}); diff --git a/src/__tests__/main/preload/web.test.ts b/src/__tests__/main/preload/web.test.ts new file mode 100644 index 00000000..ee2eca15 --- /dev/null +++ b/src/__tests__/main/preload/web.test.ts @@ -0,0 +1,272 @@ +/** + * Tests for web preload API + * + * Coverage: + * - createWebApi: broadcastUserInput, broadcastAutoRunState, broadcastTabsChange, broadcastSessionState + * - createWebserverApi: getUrl, getConnectedClients + * - createLiveApi: toggle, getStatus, getDashboardUrl, getLiveSessions, broadcastActiveSession, + * disableAll, startServer, stopServer + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron ipcRenderer +const mockInvoke = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + }, +})); + +import { createWebApi, createWebserverApi, createLiveApi } from '../../../main/preload/web'; + +describe('Web Preload API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createWebApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createWebApi(); + }); + + describe('broadcastUserInput', () => { + it('should invoke web:broadcastUserInput with correct parameters', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.broadcastUserInput('session-123', 'hello world', 'ai'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'web:broadcastUserInput', + 'session-123', + 'hello world', + 'ai' + ); + }); + + it('should handle terminal mode', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.broadcastUserInput('session-123', 'ls -la', 'terminal'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'web:broadcastUserInput', + 'session-123', + 'ls -la', + 'terminal' + ); + }); + }); + + describe('broadcastAutoRunState', () => { + it('should invoke web:broadcastAutoRunState with state', async () => { + mockInvoke.mockResolvedValue(undefined); + const state = { + isRunning: true, + totalTasks: 10, + completedTasks: 5, + currentTaskIndex: 5, + }; + + await api.broadcastAutoRunState('session-123', state); + + expect(mockInvoke).toHaveBeenCalledWith('web:broadcastAutoRunState', 'session-123', state); + }); + + it('should handle null state', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.broadcastAutoRunState('session-123', null); + + expect(mockInvoke).toHaveBeenCalledWith('web:broadcastAutoRunState', 'session-123', null); + }); + }); + + describe('broadcastTabsChange', () => { + it('should invoke web:broadcastTabsChange with tabs', async () => { + mockInvoke.mockResolvedValue(undefined); + const tabs = [ + { + id: 'tab-1', + agentSessionId: 'agent-1', + name: 'Tab 1', + starred: false, + inputValue: '', + createdAt: Date.now(), + state: 'idle' as const, + }, + ]; + + await api.broadcastTabsChange('session-123', tabs, 'tab-1'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'web:broadcastTabsChange', + 'session-123', + tabs, + 'tab-1' + ); + }); + }); + + describe('broadcastSessionState', () => { + it('should invoke web:broadcastSessionState with state', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.broadcastSessionState('session-123', 'busy'); + + expect(mockInvoke).toHaveBeenCalledWith( + 'web:broadcastSessionState', + 'session-123', + 'busy', + undefined + ); + }); + + it('should invoke with additional data', async () => { + mockInvoke.mockResolvedValue(undefined); + const additionalData = { name: 'My Session', toolType: 'claude-code' }; + + await api.broadcastSessionState('session-123', 'idle', additionalData); + + expect(mockInvoke).toHaveBeenCalledWith( + 'web:broadcastSessionState', + 'session-123', + 'idle', + additionalData + ); + }); + }); + }); + + describe('createWebserverApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createWebserverApi(); + }); + + describe('getUrl', () => { + it('should invoke webserver:getUrl', async () => { + mockInvoke.mockResolvedValue('http://localhost:3000'); + + const result = await api.getUrl(); + + expect(mockInvoke).toHaveBeenCalledWith('webserver:getUrl'); + expect(result).toBe('http://localhost:3000'); + }); + }); + + describe('getConnectedClients', () => { + it('should invoke webserver:getConnectedClients', async () => { + mockInvoke.mockResolvedValue(['client-1', 'client-2']); + + const result = await api.getConnectedClients(); + + expect(mockInvoke).toHaveBeenCalledWith('webserver:getConnectedClients'); + expect(result).toEqual(['client-1', 'client-2']); + }); + }); + }); + + describe('createLiveApi', () => { + let api: ReturnType; + + beforeEach(() => { + api = createLiveApi(); + }); + + describe('toggle', () => { + it('should invoke live:toggle with sessionId', async () => { + mockInvoke.mockResolvedValue({ enabled: true }); + + await api.toggle('session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('live:toggle', 'session-123', undefined); + }); + + it('should invoke live:toggle with agentSessionId', async () => { + mockInvoke.mockResolvedValue({ enabled: true }); + + await api.toggle('session-123', 'agent-session-456'); + + expect(mockInvoke).toHaveBeenCalledWith('live:toggle', 'session-123', 'agent-session-456'); + }); + }); + + describe('getStatus', () => { + it('should invoke live:getStatus', async () => { + mockInvoke.mockResolvedValue({ enabled: true, url: 'https://live.example.com' }); + + const result = await api.getStatus('session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('live:getStatus', 'session-123'); + expect(result).toEqual({ enabled: true, url: 'https://live.example.com' }); + }); + }); + + describe('getDashboardUrl', () => { + it('should invoke live:getDashboardUrl', async () => { + mockInvoke.mockResolvedValue('https://dashboard.example.com'); + + const result = await api.getDashboardUrl(); + + expect(mockInvoke).toHaveBeenCalledWith('live:getDashboardUrl'); + expect(result).toBe('https://dashboard.example.com'); + }); + }); + + describe('getLiveSessions', () => { + it('should invoke live:getLiveSessions', async () => { + mockInvoke.mockResolvedValue([{ sessionId: '123', agentSessionId: '456' }]); + + const result = await api.getLiveSessions(); + + expect(mockInvoke).toHaveBeenCalledWith('live:getLiveSessions'); + expect(result).toEqual([{ sessionId: '123', agentSessionId: '456' }]); + }); + }); + + describe('broadcastActiveSession', () => { + it('should invoke live:broadcastActiveSession', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.broadcastActiveSession('session-123'); + + expect(mockInvoke).toHaveBeenCalledWith('live:broadcastActiveSession', 'session-123'); + }); + }); + + describe('disableAll', () => { + it('should invoke live:disableAll', async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.disableAll(); + + expect(mockInvoke).toHaveBeenCalledWith('live:disableAll'); + }); + }); + + describe('startServer', () => { + it('should invoke live:startServer', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.startServer(); + + expect(mockInvoke).toHaveBeenCalledWith('live:startServer'); + }); + }); + + describe('stopServer', () => { + it('should invoke live:stopServer', async () => { + mockInvoke.mockResolvedValue({ success: true }); + + await api.stopServer(); + + expect(mockInvoke).toHaveBeenCalledWith('live:stopServer'); + }); + }); + }); +}); diff --git a/src/main/auto-updater.ts b/src/main/auto-updater.ts index bf1ed13b..9efb3b2a 100644 --- a/src/main/auto-updater.ts +++ b/src/main/auto-updater.ts @@ -1,18 +1,16 @@ /** * Auto-updater module for Maestro * Uses electron-updater to download and install updates from GitHub releases + * + * Note: electron-updater accesses electron.app at module load time, so we use + * lazy initialization to avoid "Cannot read properties of undefined" errors + * when the module is imported before app.whenReady(). */ -import { autoUpdater, UpdateInfo, ProgressInfo } from 'electron-updater'; +import type { UpdateInfo, ProgressInfo, AppUpdater } from 'electron-updater'; import { BrowserWindow, ipcMain } from 'electron'; import { logger } from './utils/logger'; -// Don't auto-download - we want user to initiate -autoUpdater.autoDownload = false; -autoUpdater.autoInstallOnAppQuit = true; -// Default to stable releases only (will be updated from settings) -autoUpdater.allowPrerelease = false; - export interface UpdateStatus { status: | 'idle' @@ -31,12 +29,34 @@ let mainWindow: BrowserWindow | null = null; let currentStatus: UpdateStatus = { status: 'idle' }; let ipcHandlersRegistered = false; +// Lazy-loaded autoUpdater instance +let _autoUpdater: AppUpdater | null = null; + +/** + * Get the autoUpdater instance, initializing it lazily + * This is necessary because electron-updater accesses electron.app at import time + */ +function getAutoUpdater(): AppUpdater { + if (!_autoUpdater) { + // Dynamic require to defer the module load + const { autoUpdater } = require('electron-updater'); + _autoUpdater = autoUpdater; + // Configure defaults + _autoUpdater!.autoDownload = false; + _autoUpdater!.autoInstallOnAppQuit = true; + _autoUpdater!.allowPrerelease = false; + } + return _autoUpdater!; +} + /** * Initialize the auto-updater and set up event handlers */ export function initAutoUpdater(window: BrowserWindow): void { mainWindow = window; + const autoUpdater = getAutoUpdater(); + // Update available autoUpdater.on('update-available', (info: UpdateInfo) => { logger.info(`Update available: ${info.version}`, 'AutoUpdater'); @@ -93,6 +113,8 @@ function setupIpcHandlers(): void { } ipcHandlersRegistered = true; + const autoUpdater = getAutoUpdater(); + // Check for updates using electron-updater (different from manual GitHub API check) ipcMain.handle('updates:checkAutoUpdater', async () => { try { @@ -159,6 +181,7 @@ function setupIpcHandlers(): void { */ export async function checkForUpdatesManual(): Promise { try { + const autoUpdater = getAutoUpdater(); const result = await autoUpdater.checkForUpdates(); return result?.updateInfo || null; } catch { @@ -171,6 +194,7 @@ export async function checkForUpdatesManual(): Promise { * This should be called when the user setting changes */ export function setAllowPrerelease(allow: boolean): void { + const autoUpdater = getAutoUpdater(); autoUpdater.allowPrerelease = allow; logger.info(`Auto-updater prerelease mode: ${allow ? 'enabled' : 'disabled'}`, 'AutoUpdater'); } diff --git a/src/main/index.ts b/src/main/index.ts index d67b08d2..e5b80162 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,8 +2,8 @@ import { app, BrowserWindow, ipcMain } from 'electron'; import path from 'path'; import fsSync from 'fs'; import crypto from 'crypto'; -import * as Sentry from '@sentry/electron/main'; -import { IPCMode } from '@sentry/electron/main'; +// Sentry is imported dynamically below to avoid module-load-time access to electron.app +// which causes "Cannot read properties of undefined (reading 'getAppPath')" errors import { ProcessManager } from './process-manager'; import { WebServer } from './web-server'; import { AgentDetector } from './agent-detector'; @@ -153,30 +153,6 @@ if (disableGpuAcceleration) { console.log('[STARTUP] GPU hardware acceleration disabled by user preference'); } -// Initialize Sentry for crash reporting -// Only enable in production - skip during development to avoid noise from hot-reload artifacts -if (crashReportingEnabled && !isDevelopment) { - Sentry.init({ - dsn: 'https://2303c5f787f910863d83ed5d27ce8ed2@o4510554134740992.ingest.us.sentry.io/4510554135789568', - // Set release version for better debugging - release: app.getVersion(), - // Use Classic IPC mode to avoid "sentry-ipc:// URL scheme not supported" errors - // See: https://github.com/getsentry/sentry-electron/issues/661 - ipcMode: IPCMode.Classic, - // Only send errors, not performance data - tracesSampleRate: 0, - // Filter out sensitive data - beforeSend(event) { - // Remove any potential sensitive data from the event - if (event.user) { - delete event.user.ip_address; - delete event.user.email; - } - return event; - }, - }); -} - // Generate installation ID on first run (one-time generation) // This creates a unique identifier per Maestro installation for telemetry differentiation const store = getSettingsStore(); @@ -187,9 +163,38 @@ if (!installationId) { logger.info('Generated new installation ID', 'Startup', { installationId }); } -// Add installation ID to Sentry for error correlation across installations +// Initialize Sentry for crash reporting (dynamic import to avoid module-load-time errors) +// Only enable in production - skip during development to avoid noise from hot-reload artifacts +// The dynamic import is necessary because @sentry/electron accesses electron.app at module load time +// which fails if the module is imported before app.whenReady() in some Node/Electron version combinations if (crashReportingEnabled && !isDevelopment) { - Sentry.setTag('installationId', installationId); + import('@sentry/electron/main') + .then(({ init, setTag, IPCMode }) => { + init({ + dsn: 'https://2303c5f787f910863d83ed5d27ce8ed2@o4510554134740992.ingest.us.sentry.io/4510554135789568', + // Set release version for better debugging + release: app.getVersion(), + // Use Classic IPC mode to avoid "sentry-ipc:// URL scheme not supported" errors + // See: https://github.com/getsentry/sentry-electron/issues/661 + ipcMode: IPCMode.Classic, + // Only send errors, not performance data + tracesSampleRate: 0, + // Filter out sensitive data + beforeSend(event) { + // Remove any potential sensitive data from the event + if (event.user) { + delete event.user.ip_address; + delete event.user.email; + } + return event; + }, + }); + // Add installation ID to Sentry for error correlation across installations + setTag('installationId', installationId); + }) + .catch((err) => { + logger.warn('Failed to initialize Sentry', 'Startup', { error: String(err) }); + }); } // Create local references to stores for use throughout this module diff --git a/src/main/preload.ts b/src/main/preload.ts deleted file mode 100644 index 4138d655..00000000 --- a/src/main/preload.ts +++ /dev/null @@ -1,3761 +0,0 @@ -import { contextBridge, ipcRenderer } from 'electron'; -import type { MainLogLevel, SystemLogEntry } from '../shared/logger-types'; - -// Type definitions that match renderer types -interface ProcessConfig { - sessionId: string; - toolType: string; - cwd: string; - command: string; - args: string[]; - prompt?: string; - shell?: string; - images?: string[]; // Base64 data URLs for images - // Agent-specific spawn options (used to build args via agent config) - agentSessionId?: string; // For session resume (uses agent's resumeArgs builder) - readOnlyMode?: boolean; // For read-only/plan mode (uses agent's readOnlyArgs) - modelId?: string; // For model selection (uses agent's modelArgs builder) - yoloMode?: boolean; // For YOLO/full-access mode (uses agent's yoloModeArgs) - // Stats tracking options - querySource?: 'user' | 'auto'; // Whether this query is user-initiated or from Auto Run - tabId?: string; // Tab ID for multi-tab tracking -} - -/** - * Capability flags that determine what features are available for each agent. - * This is a simplified version for the renderer - full definition in agent-capabilities.ts - */ -interface AgentCapabilities { - supportsResume: boolean; - supportsReadOnlyMode: boolean; - supportsJsonOutput: boolean; - supportsSessionId: boolean; - supportsImageInput: boolean; - supportsImageInputOnResume: boolean; - supportsSlashCommands: boolean; - supportsSessionStorage: boolean; - supportsCostTracking: boolean; - supportsUsageStats: boolean; - supportsBatchMode: boolean; - requiresPromptToStart: boolean; - supportsStreaming: boolean; - supportsResultMessages: boolean; - supportsModelSelection: boolean; - supportsStreamJsonInput: boolean; -} - -interface AgentConfig { - id: string; - name: string; - command: string; - args?: string[]; - available: boolean; - path?: string; - capabilities?: AgentCapabilities; -} - -interface DirectoryEntry { - name: string; - isDirectory: boolean; - path: string; -} - -interface ShellInfo { - id: string; - name: string; - available: boolean; - path?: string; -} - -// Helper to log deprecation warnings -const logDeprecationWarning = (method: string, replacement?: string) => { - const message = replacement - ? `[Deprecation Warning] window.maestro.claude.${method}() is deprecated. Use window.maestro.agentSessions.${replacement}() instead.` - : `[Deprecation Warning] window.maestro.claude.${method}() is deprecated. Use the agentSessions API instead.`; - console.warn(message); -}; - -// Expose protected methods that allow the renderer process to use -// the ipcRenderer without exposing the entire object -contextBridge.exposeInMainWorld('maestro', { - // Settings API - settings: { - get: (key: string) => ipcRenderer.invoke('settings:get', key), - set: (key: string, value: unknown) => ipcRenderer.invoke('settings:set', key, value), - getAll: () => ipcRenderer.invoke('settings:getAll'), - }, - - // Sessions persistence API - sessions: { - getAll: () => ipcRenderer.invoke('sessions:getAll'), - setAll: (sessions: any[]) => ipcRenderer.invoke('sessions:setAll', sessions), - }, - - // Groups persistence API - groups: { - getAll: () => ipcRenderer.invoke('groups:getAll'), - setAll: (groups: any[]) => ipcRenderer.invoke('groups:setAll', groups), - }, - - // Process/Session API - process: { - spawn: (config: ProcessConfig) => ipcRenderer.invoke('process:spawn', config), - write: (sessionId: string, data: string) => - ipcRenderer.invoke('process:write', sessionId, data), - interrupt: (sessionId: string) => ipcRenderer.invoke('process:interrupt', sessionId), - kill: (sessionId: string) => ipcRenderer.invoke('process:kill', sessionId), - resize: (sessionId: string, cols: number, rows: number) => - ipcRenderer.invoke('process:resize', sessionId, cols, rows), - - // Run a single command and capture only stdout/stderr (no PTY echo/prompts) - // Supports SSH remote execution when sessionSshRemoteConfig is provided - runCommand: (config: { - sessionId: string; - command: string; - cwd: string; - shell?: string; - sessionSshRemoteConfig?: { - enabled: boolean; - remoteId: string | null; - workingDirOverride?: string; - }; - }) => ipcRenderer.invoke('process:runCommand', config), - - // Get all active processes from ProcessManager - getActiveProcesses: () => ipcRenderer.invoke('process:getActiveProcesses'), - - // Event listeners - onData: (callback: (sessionId: string, data: string) => void) => { - const handler = (_: any, sessionId: string, data: string) => callback(sessionId, data); - ipcRenderer.on('process:data', handler); - return () => ipcRenderer.removeListener('process:data', handler); - }, - onExit: (callback: (sessionId: string, code: number) => void) => { - const handler = (_: any, sessionId: string, code: number) => callback(sessionId, code); - ipcRenderer.on('process:exit', handler); - return () => ipcRenderer.removeListener('process:exit', handler); - }, - onSessionId: (callback: (sessionId: string, agentSessionId: string) => void) => { - const handler = (_: any, sessionId: string, agentSessionId: string) => - callback(sessionId, agentSessionId); - ipcRenderer.on('process:session-id', handler); - return () => ipcRenderer.removeListener('process:session-id', handler); - }, - onSlashCommands: (callback: (sessionId: string, slashCommands: string[]) => void) => { - const handler = (_: any, sessionId: string, slashCommands: string[]) => - callback(sessionId, slashCommands); - ipcRenderer.on('process:slash-commands', handler); - return () => ipcRenderer.removeListener('process:slash-commands', handler); - }, - // Thinking/streaming content chunks from AI agents - // Emitted when agents produce partial text events (isPartial: true) - // Renderer decides whether to display based on tab's showThinking setting - onThinkingChunk: (callback: (sessionId: string, content: string) => void) => { - const handler = (_: any, sessionId: string, content: string) => callback(sessionId, content); - ipcRenderer.on('process:thinking-chunk', handler); - return () => ipcRenderer.removeListener('process:thinking-chunk', handler); - }, - onToolExecution: ( - callback: ( - sessionId: string, - toolEvent: { toolName: string; state?: unknown; timestamp: number } - ) => void - ) => { - const handler = ( - _: any, - sessionId: string, - toolEvent: { toolName: string; state?: unknown; timestamp: number } - ) => callback(sessionId, toolEvent); - ipcRenderer.on('process:tool-execution', handler); - return () => ipcRenderer.removeListener('process:tool-execution', handler); - }, - // SSH remote execution status - // Emitted when a process starts executing via SSH on a remote host - onSshRemote: ( - callback: ( - sessionId: string, - sshRemote: { id: string; name: string; host: string } | null - ) => void - ) => { - const handler = ( - _: any, - sessionId: string, - sshRemote: { id: string; name: string; host: string } | null - ) => callback(sessionId, sshRemote); - ipcRenderer.on('process:ssh-remote', handler); - return () => ipcRenderer.removeListener('process:ssh-remote', handler); - }, - // Remote command execution from web interface - // This allows web commands to go through the same code path as desktop commands - // inputMode is optional - if provided, renderer should use it instead of session state - onRemoteCommand: ( - callback: (sessionId: string, command: string, inputMode?: 'ai' | 'terminal') => void - ) => { - console.log( - '[Preload] Registering onRemoteCommand listener, callback type:', - typeof callback - ); - const handler = ( - _: any, - sessionId: string, - command: string, - inputMode?: 'ai' | 'terminal' - ) => { - console.log('[Preload] Received remote:executeCommand IPC:', { - sessionId, - command: command?.substring(0, 50), - inputMode, - }); - console.log('[Preload] About to invoke callback, callback type:', typeof callback); - try { - callback(sessionId, command, inputMode); - console.log('[Preload] Callback invoked successfully'); - } catch (error) { - console.error('[Preload] Error invoking remote command callback:', error); - } - }; - ipcRenderer.on('remote:executeCommand', handler); - return () => ipcRenderer.removeListener('remote:executeCommand', handler); - }, - // Remote mode switch from web interface - forwards to desktop's toggleInputMode logic - onRemoteSwitchMode: (callback: (sessionId: string, mode: 'ai' | 'terminal') => void) => { - console.log('[Preload] Registering onRemoteSwitchMode listener'); - const handler = (_: any, sessionId: string, mode: 'ai' | 'terminal') => { - console.log('[Preload] Received remote:switchMode IPC:', { sessionId, mode }); - callback(sessionId, mode); - }; - ipcRenderer.on('remote:switchMode', handler); - return () => ipcRenderer.removeListener('remote:switchMode', handler); - }, - // Remote interrupt from web interface - forwards to desktop's handleInterrupt logic - onRemoteInterrupt: (callback: (sessionId: string) => void) => { - const handler = (_: any, sessionId: string) => callback(sessionId); - ipcRenderer.on('remote:interrupt', handler); - return () => ipcRenderer.removeListener('remote:interrupt', handler); - }, - // Remote session selection from web interface - forwards to desktop's setActiveSessionId logic - // Optional tabId to also switch to a specific tab within the session - onRemoteSelectSession: (callback: (sessionId: string, tabId?: string) => void) => { - console.log('[Preload] Registering onRemoteSelectSession listener'); - const handler = (_: any, sessionId: string, tabId?: string) => { - console.log('[Preload] Received remote:selectSession IPC:', { sessionId, tabId }); - callback(sessionId, tabId); - }; - ipcRenderer.on('remote:selectSession', handler); - return () => ipcRenderer.removeListener('remote:selectSession', handler); - }, - // Remote tab selection from web interface - onRemoteSelectTab: (callback: (sessionId: string, tabId: string) => void) => { - const handler = (_: any, sessionId: string, tabId: string) => callback(sessionId, tabId); - ipcRenderer.on('remote:selectTab', handler); - return () => ipcRenderer.removeListener('remote:selectTab', handler); - }, - // Remote new tab from web interface - onRemoteNewTab: (callback: (sessionId: string, responseChannel: string) => void) => { - const handler = (_: any, sessionId: string, responseChannel: string) => - callback(sessionId, responseChannel); - ipcRenderer.on('remote:newTab', handler); - return () => ipcRenderer.removeListener('remote:newTab', handler); - }, - // Send response for remote new tab - sendRemoteNewTabResponse: (responseChannel: string, result: { tabId: string } | null) => { - ipcRenderer.send(responseChannel, result); - }, - // Remote close tab from web interface - onRemoteCloseTab: (callback: (sessionId: string, tabId: string) => void) => { - const handler = (_: any, sessionId: string, tabId: string) => callback(sessionId, tabId); - ipcRenderer.on('remote:closeTab', handler); - return () => ipcRenderer.removeListener('remote:closeTab', handler); - }, - // Remote rename tab from web interface - onRemoteRenameTab: (callback: (sessionId: string, tabId: string, newName: string) => void) => { - const handler = (_: any, sessionId: string, tabId: string, newName: string) => - callback(sessionId, tabId, newName); - ipcRenderer.on('remote:renameTab', handler); - return () => ipcRenderer.removeListener('remote:renameTab', handler); - }, - // Stderr listener for runCommand (separate stream) - onStderr: (callback: (sessionId: string, data: string) => void) => { - const handler = (_: any, sessionId: string, data: string) => callback(sessionId, data); - ipcRenderer.on('process:stderr', handler); - return () => ipcRenderer.removeListener('process:stderr', handler); - }, - // Command exit listener for runCommand (separate from PTY exit) - onCommandExit: (callback: (sessionId: string, code: number) => void) => { - const handler = (_: any, sessionId: string, code: number) => callback(sessionId, code); - ipcRenderer.on('process:command-exit', handler); - return () => ipcRenderer.removeListener('process:command-exit', handler); - }, - // Usage statistics listener for AI responses - onUsage: ( - callback: ( - sessionId: string, - usageStats: { - inputTokens: number; - outputTokens: number; - cacheReadInputTokens: number; - cacheCreationInputTokens: number; - totalCostUsd: number; - contextWindow: number; - reasoningTokens?: number; // Separate reasoning tokens (Codex o3/o4-mini) - } - ) => void - ) => { - const handler = (_: any, sessionId: string, usageStats: any) => - callback(sessionId, usageStats); - ipcRenderer.on('process:usage', handler); - return () => ipcRenderer.removeListener('process:usage', handler); - }, - // Agent error event listener (auth expired, token exhaustion, rate limits, etc.) - onAgentError: ( - callback: ( - sessionId: string, - error: { - type: string; - message: string; - recoverable: boolean; - agentId: string; - sessionId?: string; - timestamp: number; - raw?: { - exitCode?: number; - stderr?: string; - stdout?: string; - errorLine?: string; - }; - } - ) => void - ) => { - const handler = (_: any, sessionId: string, error: any) => callback(sessionId, error); - ipcRenderer.on('agent:error', handler); - return () => ipcRenderer.removeListener('agent:error', handler); - }, - }, - - // Agent Error Handling API - agentError: { - // Clear an error state for a session (called after recovery action) - clearError: (sessionId: string) => ipcRenderer.invoke('agent:clearError', sessionId), - // Retry the last operation after an error - retryAfterError: ( - sessionId: string, - options?: { - prompt?: string; - newSession?: boolean; - } - ) => ipcRenderer.invoke('agent:retryAfterError', sessionId, options), - }, - - // Context Merge API (for session context transfer and grooming) - context: { - // Get context from a stored agent session - getStoredSession: (agentId: string, projectRoot: string, sessionId: string) => - ipcRenderer.invoke('context:getStoredSession', agentId, projectRoot, sessionId) as Promise<{ - messages: Array<{ - type: string; - role?: string; - content: string; - timestamp: string; - uuid: string; - toolUse?: unknown; - }>; - total: number; - hasMore: boolean; - } | null>, - // NEW: Single-call grooming (recommended) - spawns batch process and returns response - groomContext: (projectRoot: string, agentType: string, prompt: string) => - ipcRenderer.invoke('context:groomContext', projectRoot, agentType, prompt) as Promise, - // Cancel all active grooming sessions - cancelGrooming: () => ipcRenderer.invoke('context:cancelGrooming') as Promise, - // DEPRECATED: Create a temporary session for context grooming - createGroomingSession: (projectRoot: string, agentType: string) => - ipcRenderer.invoke( - 'context:createGroomingSession', - projectRoot, - agentType - ) as Promise, - // DEPRECATED: Send grooming prompt to a session and get response - sendGroomingPrompt: (sessionId: string, prompt: string) => - ipcRenderer.invoke('context:sendGroomingPrompt', sessionId, prompt) as Promise, - // Clean up a temporary grooming session - cleanupGroomingSession: (sessionId: string) => - ipcRenderer.invoke('context:cleanupGroomingSession', sessionId) as Promise, - }, - - // Web interface API - web: { - // Broadcast user input to web clients (for keeping web interface in sync) - broadcastUserInput: (sessionId: string, command: string, inputMode: 'ai' | 'terminal') => - ipcRenderer.invoke('web:broadcastUserInput', sessionId, command, inputMode), - // Broadcast AutoRun state to web clients (for showing task progress on mobile) - broadcastAutoRunState: ( - sessionId: string, - state: { - isRunning: boolean; - totalTasks: number; - completedTasks: number; - currentTaskIndex: number; - isStopping?: boolean; - // Multi-document progress fields - totalDocuments?: number; - currentDocumentIndex?: number; - totalTasksAcrossAllDocs?: number; - completedTasksAcrossAllDocs?: number; - } | null - ) => ipcRenderer.invoke('web:broadcastAutoRunState', sessionId, state), - // Broadcast tab changes to web clients (for tab sync) - broadcastTabsChange: ( - sessionId: string, - aiTabs: Array<{ - id: string; - agentSessionId: string | null; - name: string | null; - starred: boolean; - inputValue: string; - usageStats?: any; - createdAt: number; - state: 'idle' | 'busy'; - thinkingStartTime?: number | null; - }>, - activeTabId: string - ) => ipcRenderer.invoke('web:broadcastTabsChange', sessionId, aiTabs, activeTabId), - // Broadcast session state change to web clients (for real-time busy/idle updates) - // This bypasses the debounced persistence which resets state to idle - broadcastSessionState: ( - sessionId: string, - state: string, - additionalData?: { - name?: string; - toolType?: string; - inputMode?: string; - cwd?: string; - } - ) => ipcRenderer.invoke('web:broadcastSessionState', sessionId, state, additionalData), - }, - - // Git API - // All methods accept optional sshRemoteId and remoteCwd for remote execution via SSH - git: { - status: (cwd: string, sshRemoteId?: string, remoteCwd?: string) => - ipcRenderer.invoke('git:status', cwd, sshRemoteId, remoteCwd), - diff: (cwd: string, file?: string, sshRemoteId?: string, remoteCwd?: string) => - ipcRenderer.invoke('git:diff', cwd, file, sshRemoteId, remoteCwd), - isRepo: (cwd: string, sshRemoteId?: string, remoteCwd?: string) => - ipcRenderer.invoke('git:isRepo', cwd, sshRemoteId, remoteCwd), - numstat: (cwd: string, sshRemoteId?: string, remoteCwd?: string) => - ipcRenderer.invoke('git:numstat', cwd, sshRemoteId, remoteCwd), - branch: (cwd: string, sshRemoteId?: string, remoteCwd?: string) => - ipcRenderer.invoke('git:branch', cwd, sshRemoteId, remoteCwd), - branches: (cwd: string, sshRemoteId?: string, remoteCwd?: string) => - ipcRenderer.invoke('git:branches', cwd, sshRemoteId, remoteCwd), - tags: (cwd: string, sshRemoteId?: string, remoteCwd?: string) => - ipcRenderer.invoke('git:tags', cwd, sshRemoteId, remoteCwd), - remote: (cwd: string, sshRemoteId?: string, remoteCwd?: string) => - ipcRenderer.invoke('git:remote', cwd, sshRemoteId, remoteCwd), - info: (cwd: string, sshRemoteId?: string, remoteCwd?: string) => - ipcRenderer.invoke('git:info', cwd, sshRemoteId, remoteCwd), - log: (cwd: string, options?: { limit?: number; search?: string }) => - ipcRenderer.invoke('git:log', cwd, options), - commitCount: (cwd: string) => - ipcRenderer.invoke('git:commitCount', cwd) as Promise<{ - count: number; - error: string | null; - }>, - show: (cwd: string, hash: string) => ipcRenderer.invoke('git:show', cwd, hash), - showFile: (cwd: string, ref: string, filePath: string) => - ipcRenderer.invoke('git:showFile', cwd, ref, filePath) as Promise<{ - content?: string; - error?: string; - }>, - // Git worktree operations for Auto Run parallelization - // All worktree operations support SSH remote execution via optional sshRemoteId parameter - worktreeInfo: (worktreePath: string, sshRemoteId?: string) => - ipcRenderer.invoke('git:worktreeInfo', worktreePath, sshRemoteId) as Promise<{ - success: boolean; - exists?: boolean; - isWorktree?: boolean; - currentBranch?: string; - repoRoot?: string; - error?: string; - }>, - getRepoRoot: (cwd: string, sshRemoteId?: string) => - ipcRenderer.invoke('git:getRepoRoot', cwd, sshRemoteId) as Promise<{ - success: boolean; - root?: string; - error?: string; - }>, - worktreeSetup: ( - mainRepoCwd: string, - worktreePath: string, - branchName: string, - sshRemoteId?: string - ) => - ipcRenderer.invoke( - 'git:worktreeSetup', - mainRepoCwd, - worktreePath, - branchName, - sshRemoteId - ) as Promise<{ - success: boolean; - created?: boolean; - currentBranch?: string; - requestedBranch?: string; - branchMismatch?: boolean; - error?: string; - }>, - worktreeCheckout: ( - worktreePath: string, - branchName: string, - createIfMissing: boolean, - sshRemoteId?: string - ) => - ipcRenderer.invoke( - 'git:worktreeCheckout', - worktreePath, - branchName, - createIfMissing, - sshRemoteId - ) as Promise<{ - success: boolean; - hasUncommittedChanges: boolean; - error?: string; - }>, - createPR: ( - worktreePath: string, - baseBranch: string, - title: string, - body: string, - ghPath?: string - ) => - ipcRenderer.invoke('git:createPR', worktreePath, baseBranch, title, body, ghPath) as Promise<{ - success: boolean; - prUrl?: string; - error?: string; - }>, - getDefaultBranch: (cwd: string) => - ipcRenderer.invoke('git:getDefaultBranch', cwd) as Promise<{ - success: boolean; - branch?: string; - error?: string; - }>, - checkGhCli: (ghPath?: string) => - ipcRenderer.invoke('git:checkGhCli', ghPath) as Promise<{ - installed: boolean; - authenticated: boolean; - }>, - // Create a GitHub Gist from file content - createGist: ( - filename: string, - content: string, - description: string, - isPublic: boolean, - ghPath?: string - ) => - ipcRenderer.invoke( - 'git:createGist', - filename, - content, - description, - isPublic, - ghPath - ) as Promise<{ - success: boolean; - gistUrl?: string; - error?: string; - }>, - // List all worktrees for a git repository - // Supports SSH remote execution via optional sshRemoteId parameter - listWorktrees: (cwd: string, sshRemoteId?: string) => - ipcRenderer.invoke('git:listWorktrees', cwd, sshRemoteId) as Promise<{ - worktrees: Array<{ - path: string; - head: string; - branch: string | null; - isBare: boolean; - }>; - }>, - // Scan a directory for subdirectories that are git repositories or worktrees - // Supports SSH remote execution via optional sshRemoteId parameter - scanWorktreeDirectory: (parentPath: string, sshRemoteId?: string) => - ipcRenderer.invoke('git:scanWorktreeDirectory', parentPath, sshRemoteId) as Promise<{ - gitSubdirs: Array<{ - path: string; - name: string; - isWorktree: boolean; - branch: string | null; - repoRoot: string | null; - }>; - }>, - // Watch a worktree directory for new worktrees - // Note: File watching is not available for SSH remote sessions. - // For remote sessions, returns isRemote: true indicating polling should be used instead. - watchWorktreeDirectory: (sessionId: string, worktreePath: string, sshRemoteId?: string) => - ipcRenderer.invoke( - 'git:watchWorktreeDirectory', - sessionId, - worktreePath, - sshRemoteId - ) as Promise<{ - success: boolean; - error?: string; - isRemote?: boolean; - message?: string; - }>, - // Stop watching a worktree directory - unwatchWorktreeDirectory: (sessionId: string) => - ipcRenderer.invoke('git:unwatchWorktreeDirectory', sessionId) as Promise<{ - success: boolean; - }>, - // Remove a worktree directory from disk - removeWorktree: (worktreePath: string, force?: boolean) => - ipcRenderer.invoke('git:removeWorktree', worktreePath, force) as Promise<{ - success: boolean; - error?: string; - hasUncommittedChanges?: boolean; - }>, - // Listen for discovered worktrees - onWorktreeDiscovered: ( - callback: (data: { - sessionId: string; - worktree: { path: string; name: string; branch: string | null }; - }) => void - ) => { - const handler = ( - _event: Electron.IpcRendererEvent, - data: { sessionId: string; worktree: { path: string; name: string; branch: string | null } } - ) => callback(data); - ipcRenderer.on('worktree:discovered', handler); - return () => ipcRenderer.removeListener('worktree:discovered', handler); - }, - }, - - // File System API - fs: { - homeDir: () => ipcRenderer.invoke('fs:homeDir') as Promise, - readDir: (dirPath: string, sshRemoteId?: string) => - ipcRenderer.invoke('fs:readDir', dirPath, sshRemoteId), - readFile: (filePath: string, sshRemoteId?: string) => - ipcRenderer.invoke('fs:readFile', filePath, sshRemoteId), - writeFile: (filePath: string, content: string) => - ipcRenderer.invoke('fs:writeFile', filePath, content) as Promise<{ success: boolean }>, - stat: (filePath: string, sshRemoteId?: string) => - ipcRenderer.invoke('fs:stat', filePath, sshRemoteId), - directorySize: (dirPath: string, sshRemoteId?: string) => - ipcRenderer.invoke('fs:directorySize', dirPath, sshRemoteId) as Promise<{ - totalSize: number; - fileCount: number; - folderCount: number; - }>, - fetchImageAsBase64: (url: string) => - ipcRenderer.invoke('fs:fetchImageAsBase64', url) as Promise, - rename: (oldPath: string, newPath: string, sshRemoteId?: string) => - ipcRenderer.invoke('fs:rename', oldPath, newPath, sshRemoteId) as Promise<{ - success: boolean; - }>, - delete: (targetPath: string, options?: { recursive?: boolean; sshRemoteId?: string }) => - ipcRenderer.invoke('fs:delete', targetPath, options) as Promise<{ success: boolean }>, - countItems: (dirPath: string, sshRemoteId?: string) => - ipcRenderer.invoke('fs:countItems', dirPath, sshRemoteId) as Promise<{ - fileCount: number; - folderCount: number; - }>, - }, - - // Web Server API - webserver: { - getUrl: () => ipcRenderer.invoke('webserver:getUrl'), - getConnectedClients: () => ipcRenderer.invoke('webserver:getConnectedClients'), - }, - - // Live Session API - toggle sessions as live/offline in web interface - live: { - toggle: (sessionId: string, agentSessionId?: string) => - ipcRenderer.invoke('live:toggle', sessionId, agentSessionId), - getStatus: (sessionId: string) => ipcRenderer.invoke('live:getStatus', sessionId), - getDashboardUrl: () => ipcRenderer.invoke('live:getDashboardUrl'), - getLiveSessions: () => ipcRenderer.invoke('live:getLiveSessions'), - broadcastActiveSession: (sessionId: string) => - ipcRenderer.invoke('live:broadcastActiveSession', sessionId), - disableAll: () => ipcRenderer.invoke('live:disableAll'), - startServer: () => ipcRenderer.invoke('live:startServer'), - stopServer: () => ipcRenderer.invoke('live:stopServer'), - }, - - // Agent API - agents: { - detect: (sshRemoteId?: string) => ipcRenderer.invoke('agents:detect', sshRemoteId), - refresh: (agentId?: string, sshRemoteId?: string) => - ipcRenderer.invoke('agents:refresh', agentId, sshRemoteId), - get: (agentId: string) => ipcRenderer.invoke('agents:get', agentId), - getCapabilities: (agentId: string) => ipcRenderer.invoke('agents:getCapabilities', agentId), - getConfig: (agentId: string) => ipcRenderer.invoke('agents:getConfig', agentId), - setConfig: (agentId: string, config: Record) => - ipcRenderer.invoke('agents:setConfig', agentId, config), - getConfigValue: (agentId: string, key: string) => - ipcRenderer.invoke('agents:getConfigValue', agentId, key), - setConfigValue: (agentId: string, key: string, value: any) => - ipcRenderer.invoke('agents:setConfigValue', agentId, key, value), - setCustomPath: (agentId: string, customPath: string | null) => - ipcRenderer.invoke('agents:setCustomPath', agentId, customPath), - getCustomPath: (agentId: string) => ipcRenderer.invoke('agents:getCustomPath', agentId), - getAllCustomPaths: () => ipcRenderer.invoke('agents:getAllCustomPaths'), - // Custom CLI arguments that are appended to all agent invocations - setCustomArgs: (agentId: string, customArgs: string | null) => - ipcRenderer.invoke('agents:setCustomArgs', agentId, customArgs), - getCustomArgs: (agentId: string) => - ipcRenderer.invoke('agents:getCustomArgs', agentId) as Promise, - getAllCustomArgs: () => - ipcRenderer.invoke('agents:getAllCustomArgs') as Promise>, - // Custom environment variables that are passed to all agent invocations - setCustomEnvVars: (agentId: string, customEnvVars: Record | null) => - ipcRenderer.invoke('agents:setCustomEnvVars', agentId, customEnvVars), - getCustomEnvVars: (agentId: string) => - ipcRenderer.invoke('agents:getCustomEnvVars', agentId) as Promise | null>, - getAllCustomEnvVars: () => - ipcRenderer.invoke('agents:getAllCustomEnvVars') as Promise< - Record> - >, - // Discover available models for agents that support model selection (e.g., OpenCode with Ollama) - getModels: (agentId: string, forceRefresh?: boolean) => - ipcRenderer.invoke('agents:getModels', agentId, forceRefresh) as Promise, - // Discover available slash commands for an agent by spawning it briefly - // Returns array of command names (e.g., ['compact', 'help', 'my-custom-command']) - discoverSlashCommands: (agentId: string, cwd: string, customPath?: string) => - ipcRenderer.invoke('agents:discoverSlashCommands', agentId, cwd, customPath) as Promise< - string[] | null - >, - }, - - // Dialog API - dialog: { - selectFolder: () => ipcRenderer.invoke('dialog:selectFolder'), - saveFile: (options: { - defaultPath?: string; - filters?: Array<{ name: string; extensions: string[] }>; - title?: string; - }) => ipcRenderer.invoke('dialog:saveFile', options), - }, - - // Font API - fonts: { - detect: () => ipcRenderer.invoke('fonts:detect'), - }, - - // Shells API (terminal shells, not to be confused with shell:openExternal) - shells: { - detect: () => ipcRenderer.invoke('shells:detect'), - }, - - // Shell API - shell: { - openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), - trashItem: (itemPath: string) => ipcRenderer.invoke('shell:trashItem', itemPath), - }, - - // Tunnel API (Cloudflare tunnel support) - tunnel: { - isCloudflaredInstalled: () => ipcRenderer.invoke('tunnel:isCloudflaredInstalled'), - start: () => ipcRenderer.invoke('tunnel:start'), - stop: () => ipcRenderer.invoke('tunnel:stop'), - getStatus: () => ipcRenderer.invoke('tunnel:getStatus'), - }, - - // SSH Remote API (execute agents on remote hosts via SSH) - sshRemote: { - saveConfig: (config: { - id?: string; - name?: string; - host?: string; - port?: number; - username?: string; - privateKeyPath?: string; - remoteEnv?: Record; - enabled?: boolean; - }) => ipcRenderer.invoke('ssh-remote:saveConfig', config), - deleteConfig: (id: string) => ipcRenderer.invoke('ssh-remote:deleteConfig', id), - getConfigs: () => ipcRenderer.invoke('ssh-remote:getConfigs'), - getDefaultId: () => ipcRenderer.invoke('ssh-remote:getDefaultId'), - setDefaultId: (id: string | null) => ipcRenderer.invoke('ssh-remote:setDefaultId', id), - test: ( - configOrId: - | string - | { - id: string; - name: string; - host: string; - port: number; - username: string; - privateKeyPath: string; - remoteEnv?: Record; - enabled: boolean; - }, - agentCommand?: string - ) => ipcRenderer.invoke('ssh-remote:test', configOrId, agentCommand), - getSshConfigHosts: () => - ipcRenderer.invoke('ssh-remote:getSshConfigHosts') as Promise<{ - success: boolean; - hosts: Array<{ - host: string; - hostName?: string; - port?: number; - user?: string; - identityFile?: string; - proxyJump?: string; - }>; - error?: string; - configPath: string; - }>, - }, - - // Sync API (custom storage location for cross-device sync) - sync: { - getDefaultPath: () => ipcRenderer.invoke('sync:getDefaultPath') as Promise, - getSettings: () => - ipcRenderer.invoke('sync:getSettings') as Promise<{ - customSyncPath?: string; - }>, - getCurrentStoragePath: () => - ipcRenderer.invoke('sync:getCurrentStoragePath') as Promise, - selectSyncFolder: () => ipcRenderer.invoke('sync:selectSyncFolder') as Promise, - setCustomPath: (customPath: string | null) => - ipcRenderer.invoke('sync:setCustomPath', customPath) as Promise<{ - success: boolean; - migrated?: number; - errors?: string[]; - requiresRestart?: boolean; - error?: string; - }>, - }, - - // DevTools API - devtools: { - open: () => ipcRenderer.invoke('devtools:open'), - close: () => ipcRenderer.invoke('devtools:close'), - toggle: () => ipcRenderer.invoke('devtools:toggle'), - }, - - // Power Management API (system sleep prevention) - power: { - setEnabled: (enabled: boolean) => - ipcRenderer.invoke('power:setEnabled', enabled) as Promise, - isEnabled: () => ipcRenderer.invoke('power:isEnabled') as Promise, - getStatus: () => - ipcRenderer.invoke('power:getStatus') as Promise<{ - enabled: boolean; - blocking: boolean; - reasons: string[]; - platform: 'darwin' | 'win32' | 'linux'; - }>, - addReason: (reason: string) => ipcRenderer.invoke('power:addReason', reason) as Promise, - removeReason: (reason: string) => - ipcRenderer.invoke('power:removeReason', reason) as Promise, - }, - - // Updates API - updates: { - check: (includePrerelease?: boolean) => - ipcRenderer.invoke('updates:check', includePrerelease) as Promise<{ - currentVersion: string; - latestVersion: string; - updateAvailable: boolean; - versionsBehind: number; - releases: Array<{ - tag_name: string; - name: string; - body: string; - html_url: string; - published_at: string; - }>; - releasesUrl: string; - error?: string; - }>, - // Auto-updater APIs (electron-updater) - download: () => - ipcRenderer.invoke('updates:download') as Promise<{ success: boolean; error?: string }>, - install: () => ipcRenderer.invoke('updates:install') as Promise, - getStatus: () => - ipcRenderer.invoke('updates:getStatus') as Promise<{ - status: - | 'idle' - | 'checking' - | 'available' - | 'not-available' - | 'downloading' - | 'downloaded' - | 'error'; - info?: { version: string }; - progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number }; - error?: string; - }>, - // Subscribe to update status changes - onStatus: ( - callback: (status: { - status: - | 'idle' - | 'checking' - | 'available' - | 'not-available' - | 'downloading' - | 'downloaded' - | 'error'; - info?: { version: string }; - progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number }; - error?: string; - }) => void - ) => { - const handler = (_: any, status: any) => callback(status); - ipcRenderer.on('updates:status', handler); - return () => ipcRenderer.removeListener('updates:status', handler); - }, - // Set whether to allow prerelease updates (for electron-updater) - setAllowPrerelease: (allow: boolean) => - ipcRenderer.invoke('updates:setAllowPrerelease', allow) as Promise, - }, - - // Logger API - logger: { - log: (level: MainLogLevel, message: string, context?: string, data?: unknown) => - ipcRenderer.invoke('logger:log', level, message, context, data), - getLogs: (filter?: { level?: MainLogLevel; context?: string; limit?: number }) => - ipcRenderer.invoke('logger:getLogs', filter), - clearLogs: () => ipcRenderer.invoke('logger:clearLogs'), - setLogLevel: (level: MainLogLevel) => ipcRenderer.invoke('logger:setLogLevel', level), - getLogLevel: () => ipcRenderer.invoke('logger:getLogLevel'), - setMaxLogBuffer: (max: number) => ipcRenderer.invoke('logger:setMaxLogBuffer', max), - getMaxLogBuffer: () => ipcRenderer.invoke('logger:getMaxLogBuffer'), - // Convenience method for logging toast notifications - toast: (title: string, data?: unknown) => - ipcRenderer.invoke('logger:log', 'toast', title, 'Toast', data), - // Convenience method for Auto Run workflow logging (cannot be turned off) - autorun: (message: string, context?: string, data?: unknown) => - ipcRenderer.invoke('logger:log', 'autorun', message, context || 'AutoRun', data), - // Subscribe to new log entries in real-time - onNewLog: (callback: (log: SystemLogEntry) => void) => { - const handler = (_: Electron.IpcRendererEvent, log: SystemLogEntry) => callback(log); - ipcRenderer.on('logger:newLog', handler); - return () => ipcRenderer.removeListener('logger:newLog', handler); - }, - // File logging (enabled by default on Windows for debugging) - getLogFilePath: () => ipcRenderer.invoke('logger:getLogFilePath') as Promise, - isFileLoggingEnabled: () => - ipcRenderer.invoke('logger:isFileLoggingEnabled') as Promise, - enableFileLogging: () => ipcRenderer.invoke('logger:enableFileLogging') as Promise, - }, - - // Claude Code sessions API - // DEPRECATED: Use agentSessions API instead for new code - claude: { - listSessions: (projectPath: string) => { - logDeprecationWarning('listSessions', 'list'); - return ipcRenderer.invoke('claude:listSessions', projectPath); - }, - // Paginated version for better performance with many sessions - listSessionsPaginated: (projectPath: string, options?: { cursor?: string; limit?: number }) => { - logDeprecationWarning('listSessionsPaginated', 'listPaginated'); - return ipcRenderer.invoke('claude:listSessionsPaginated', projectPath, options); - }, - // Get aggregate stats for all sessions in a project (streams progressive updates) - getProjectStats: (projectPath: string) => { - logDeprecationWarning('getProjectStats'); - return ipcRenderer.invoke('claude:getProjectStats', projectPath); - }, - // Get all session timestamps for activity graph (lightweight) - getSessionTimestamps: (projectPath: string) => { - logDeprecationWarning('getSessionTimestamps'); - return ipcRenderer.invoke('claude:getSessionTimestamps', projectPath) as Promise<{ - timestamps: string[]; - }>; - }, - onProjectStatsUpdate: ( - callback: (stats: { - projectPath: string; - totalSessions: number; - totalMessages: number; - totalCostUsd: number; - totalSizeBytes: number; - oldestTimestamp: string | null; - processedCount: number; - isComplete: boolean; - }) => void - ) => { - logDeprecationWarning('onProjectStatsUpdate'); - const handler = (_: any, stats: any) => callback(stats); - ipcRenderer.on('claude:projectStatsUpdate', handler); - return () => ipcRenderer.removeListener('claude:projectStatsUpdate', handler); - }, - getGlobalStats: () => { - logDeprecationWarning('getGlobalStats'); - return ipcRenderer.invoke('claude:getGlobalStats'); - }, - onGlobalStatsUpdate: ( - callback: (stats: { - totalSessions: number; - totalMessages: number; - totalInputTokens: number; - totalOutputTokens: number; - totalCacheReadTokens: number; - totalCacheCreationTokens: number; - totalCostUsd: number; - totalSizeBytes: number; - isComplete: boolean; - }) => void - ) => { - logDeprecationWarning('onGlobalStatsUpdate'); - const handler = (_: any, stats: any) => callback(stats); - ipcRenderer.on('claude:globalStatsUpdate', handler); - return () => ipcRenderer.removeListener('claude:globalStatsUpdate', handler); - }, - readSessionMessages: ( - projectPath: string, - sessionId: string, - options?: { offset?: number; limit?: number } - ) => { - logDeprecationWarning('readSessionMessages', 'read'); - return ipcRenderer.invoke('claude:readSessionMessages', projectPath, sessionId, options); - }, - searchSessions: ( - projectPath: string, - query: string, - searchMode: 'title' | 'user' | 'assistant' | 'all' - ) => { - logDeprecationWarning('searchSessions', 'search'); - return ipcRenderer.invoke('claude:searchSessions', projectPath, query, searchMode); - }, - getCommands: (projectPath: string) => { - logDeprecationWarning('getCommands'); - return ipcRenderer.invoke('claude:getCommands', projectPath); - }, - // Session origin tracking (distinguishes Maestro sessions from CLI sessions) - registerSessionOrigin: ( - projectPath: string, - agentSessionId: string, - origin: 'user' | 'auto', - sessionName?: string - ) => { - logDeprecationWarning('registerSessionOrigin'); - return ipcRenderer.invoke( - 'claude:registerSessionOrigin', - projectPath, - agentSessionId, - origin, - sessionName - ); - }, - updateSessionName: (projectPath: string, agentSessionId: string, sessionName: string) => { - logDeprecationWarning('updateSessionName'); - return ipcRenderer.invoke( - 'claude:updateSessionName', - projectPath, - agentSessionId, - sessionName - ); - }, - updateSessionStarred: (projectPath: string, agentSessionId: string, starred: boolean) => { - logDeprecationWarning('updateSessionStarred'); - return ipcRenderer.invoke( - 'claude:updateSessionStarred', - projectPath, - agentSessionId, - starred - ); - }, - updateSessionContextUsage: ( - projectPath: string, - agentSessionId: string, - contextUsage: number - ) => { - return ipcRenderer.invoke( - 'claude:updateSessionContextUsage', - projectPath, - agentSessionId, - contextUsage - ); - }, - getSessionOrigins: (projectPath: string) => { - logDeprecationWarning('getSessionOrigins'); - return ipcRenderer.invoke('claude:getSessionOrigins', projectPath); - }, - getAllNamedSessions: () => { - logDeprecationWarning('getAllNamedSessions'); - return ipcRenderer.invoke('claude:getAllNamedSessions') as Promise< - Array<{ - agentSessionId: string; - projectPath: string; - sessionName: string; - starred?: boolean; - lastActivityAt?: number; - }> - >; - }, - deleteMessagePair: ( - projectPath: string, - sessionId: string, - userMessageUuid: string, - fallbackContent?: string - ) => { - logDeprecationWarning('deleteMessagePair', 'deleteMessagePair'); - return ipcRenderer.invoke( - 'claude:deleteMessagePair', - projectPath, - sessionId, - userMessageUuid, - fallbackContent - ); - }, - }, - - // Agent Sessions API (generic multi-agent session storage) - // This is the preferred API for new code. The claude.* API is deprecated. - // All methods accept optional sshRemoteId for SSH remote session storage access. - agentSessions: { - // List all sessions for an agent - list: (agentId: string, projectPath: string, sshRemoteId?: string) => - ipcRenderer.invoke('agentSessions:list', agentId, projectPath, sshRemoteId), - // List sessions with pagination - listPaginated: ( - agentId: string, - projectPath: string, - options?: { cursor?: string; limit?: number }, - sshRemoteId?: string - ) => - ipcRenderer.invoke('agentSessions:listPaginated', agentId, projectPath, options, sshRemoteId), - // Read session messages - read: ( - agentId: string, - projectPath: string, - sessionId: string, - options?: { offset?: number; limit?: number }, - sshRemoteId?: string - ) => - ipcRenderer.invoke( - 'agentSessions:read', - agentId, - projectPath, - sessionId, - options, - sshRemoteId - ), - // Search sessions - search: ( - agentId: string, - projectPath: string, - query: string, - searchMode: 'title' | 'user' | 'assistant' | 'all', - sshRemoteId?: string - ) => - ipcRenderer.invoke( - 'agentSessions:search', - agentId, - projectPath, - query, - searchMode, - sshRemoteId - ), - // Get session file path - getPath: (agentId: string, projectPath: string, sessionId: string, sshRemoteId?: string) => - ipcRenderer.invoke('agentSessions:getPath', agentId, projectPath, sessionId, sshRemoteId), - // Delete a message pair from a session (not supported for SSH remote sessions) - deleteMessagePair: ( - agentId: string, - projectPath: string, - sessionId: string, - userMessageUuid: string, - fallbackContent?: string - ) => - ipcRenderer.invoke( - 'agentSessions:deleteMessagePair', - agentId, - projectPath, - sessionId, - userMessageUuid, - fallbackContent - ), - // Check if an agent has session storage support - hasStorage: (agentId: string) => ipcRenderer.invoke('agentSessions:hasStorage', agentId), - // Get list of agent IDs that have session storage - getAvailableStorages: () => ipcRenderer.invoke('agentSessions:getAvailableStorages'), - // Get global stats aggregated from all providers - getGlobalStats: () => ipcRenderer.invoke('agentSessions:getGlobalStats'), - // Get all named sessions across all providers - getAllNamedSessions: () => - ipcRenderer.invoke('agentSessions:getAllNamedSessions') as Promise< - Array<{ - agentId: string; - agentSessionId: string; - projectPath: string; - sessionName: string; - starred?: boolean; - lastActivityAt?: number; - }> - >, - // Subscribe to global stats updates (streaming) - onGlobalStatsUpdate: ( - callback: (stats: { - totalSessions: number; - totalMessages: number; - totalInputTokens: number; - totalOutputTokens: number; - totalCacheReadTokens: number; - totalCacheCreationTokens: number; - totalCostUsd: number; - hasCostData: boolean; - totalSizeBytes: number; - isComplete: boolean; - byProvider: Record< - string, - { - sessions: number; - messages: number; - inputTokens: number; - outputTokens: number; - costUsd: number; - hasCostData: boolean; - } - >; - }) => void - ) => { - const handler = (_: unknown, stats: Parameters[0]) => callback(stats); - ipcRenderer.on('agentSessions:globalStatsUpdate', handler); - return () => ipcRenderer.removeListener('agentSessions:globalStatsUpdate', handler); - }, - // Register a session's origin (user-initiated vs auto/batch) - // Currently delegates to claude: handlers for backwards compatibility - registerSessionOrigin: ( - projectPath: string, - agentSessionId: string, - origin: 'user' | 'auto', - sessionName?: string - ) => - ipcRenderer.invoke( - 'claude:registerSessionOrigin', - projectPath, - agentSessionId, - origin, - sessionName - ), - // Update a session's display name - updateSessionName: (projectPath: string, agentSessionId: string, sessionName: string) => - ipcRenderer.invoke('claude:updateSessionName', projectPath, agentSessionId, sessionName), - // Generic session origins API (for non-Claude agents like Codex, OpenCode) - // Get session origins (names, starred status) for an agent/project - getOrigins: (agentId: string, projectPath: string) => - ipcRenderer.invoke('agentSessions:getOrigins', agentId, projectPath) as Promise< - Record - >, - // Set session name for any agent - setSessionName: ( - agentId: string, - projectPath: string, - sessionId: string, - sessionName: string | null - ) => - ipcRenderer.invoke( - 'agentSessions:setSessionName', - agentId, - projectPath, - sessionId, - sessionName - ), - // Set session starred status for any agent - setSessionStarred: ( - agentId: string, - projectPath: string, - sessionId: string, - starred: boolean - ) => - ipcRenderer.invoke( - 'agentSessions:setSessionStarred', - agentId, - projectPath, - sessionId, - starred - ), - }, - - // Temp file API (for batch processing) - tempfile: { - write: (content: string, filename?: string) => - ipcRenderer.invoke('tempfile:write', content, filename), - read: (filePath: string) => ipcRenderer.invoke('tempfile:read', filePath), - delete: (filePath: string) => ipcRenderer.invoke('tempfile:delete', filePath), - }, - - // History API (per-project persistence) - history: { - getAll: (projectPath?: string, sessionId?: string) => - ipcRenderer.invoke('history:getAll', projectPath, sessionId), - // Paginated API for large datasets - getAllPaginated: (options?: { - projectPath?: string; - sessionId?: string; - pagination?: { limit?: number; offset?: number }; - }) => ipcRenderer.invoke('history:getAllPaginated', options), - add: (entry: { - id: string; - type: 'AUTO' | 'USER'; - timestamp: number; - summary: string; - fullResponse?: string; - agentSessionId?: string; - projectPath: string; - sessionId?: string; - sessionName?: string; - contextUsage?: number; - usageStats?: { - inputTokens: number; - outputTokens: number; - cacheReadInputTokens: number; - cacheCreationInputTokens: number; - totalCostUsd: number; - contextWindow: number; - }; - success?: boolean; - elapsedTimeMs?: number; - validated?: boolean; - }) => ipcRenderer.invoke('history:add', entry), - clear: (projectPath?: string) => ipcRenderer.invoke('history:clear', projectPath), - delete: (entryId: string, sessionId?: string) => - ipcRenderer.invoke('history:delete', entryId, sessionId), - update: (entryId: string, updates: { validated?: boolean }, sessionId?: string) => - ipcRenderer.invoke('history:update', entryId, updates, sessionId), - // Update sessionName for all entries matching a agentSessionId (used when renaming tabs) - updateSessionName: (agentSessionId: string, sessionName: string) => - ipcRenderer.invoke('history:updateSessionName', agentSessionId, sessionName), - // NEW: Get history file path for AI context integration - getFilePath: (sessionId: string) => ipcRenderer.invoke('history:getFilePath', sessionId), - // NEW: List sessions with history - listSessions: () => ipcRenderer.invoke('history:listSessions'), - onExternalChange: (handler: () => void) => { - const wrappedHandler = () => handler(); - ipcRenderer.on('history:externalChange', wrappedHandler); - return () => ipcRenderer.removeListener('history:externalChange', wrappedHandler); - }, - reload: () => ipcRenderer.invoke('history:reload'), - }, - - // CLI activity API (for detecting when CLI is running playbooks) - cli: { - getActivity: () => ipcRenderer.invoke('cli:getActivity'), - onActivityChange: (handler: () => void) => { - const wrappedHandler = () => handler(); - ipcRenderer.on('cli:activityChange', wrappedHandler); - return () => ipcRenderer.removeListener('cli:activityChange', wrappedHandler); - }, - }, - - // Spec Kit API (bundled spec-kit slash commands) - speckit: { - // Get metadata (version, last refresh date) - getMetadata: () => - ipcRenderer.invoke('speckit:getMetadata') as Promise<{ - success: boolean; - metadata?: { - lastRefreshed: string; - commitSha: string; - sourceVersion: string; - sourceUrl: string; - }; - error?: string; - }>, - // Get all spec-kit prompts - getPrompts: () => - ipcRenderer.invoke('speckit:getPrompts') as Promise<{ - success: boolean; - commands?: Array<{ - id: string; - command: string; - description: string; - prompt: string; - isCustom: boolean; - isModified: boolean; - }>; - error?: string; - }>, - // Get a single command by slash command string - getCommand: (slashCommand: string) => - ipcRenderer.invoke('speckit:getCommand', slashCommand) as Promise<{ - success: boolean; - command?: { - id: string; - command: string; - description: string; - prompt: string; - isCustom: boolean; - isModified: boolean; - } | null; - error?: string; - }>, - // Save user's edit to a prompt - savePrompt: (id: string, content: string) => - ipcRenderer.invoke('speckit:savePrompt', id, content) as Promise<{ - success: boolean; - error?: string; - }>, - // Reset a prompt to bundled default - resetPrompt: (id: string) => - ipcRenderer.invoke('speckit:resetPrompt', id) as Promise<{ - success: boolean; - prompt?: string; - error?: string; - }>, - // Refresh prompts from GitHub - refresh: () => - ipcRenderer.invoke('speckit:refresh') as Promise<{ - success: boolean; - metadata?: { - lastRefreshed: string; - commitSha: string; - sourceVersion: string; - sourceUrl: string; - }; - error?: string; - }>, - }, - - // OpenSpec API (bundled OpenSpec slash commands) - openspec: { - // Get metadata (version, last refresh date) - getMetadata: () => - ipcRenderer.invoke('openspec:getMetadata') as Promise<{ - success: boolean; - metadata?: { - lastRefreshed: string; - commitSha: string; - sourceVersion: string; - sourceUrl: string; - }; - error?: string; - }>, - // Get all openspec prompts - getPrompts: () => - ipcRenderer.invoke('openspec:getPrompts') as Promise<{ - success: boolean; - commands?: Array<{ - id: string; - command: string; - description: string; - prompt: string; - isCustom: boolean; - isModified: boolean; - }>; - error?: string; - }>, - // Get a single command by slash command string - getCommand: (slashCommand: string) => - ipcRenderer.invoke('openspec:getCommand', slashCommand) as Promise<{ - success: boolean; - command?: { - id: string; - command: string; - description: string; - prompt: string; - isCustom: boolean; - isModified: boolean; - } | null; - error?: string; - }>, - // Save user's edit to a prompt - savePrompt: (id: string, content: string) => - ipcRenderer.invoke('openspec:savePrompt', id, content) as Promise<{ - success: boolean; - error?: string; - }>, - // Reset a prompt to bundled default - resetPrompt: (id: string) => - ipcRenderer.invoke('openspec:resetPrompt', id) as Promise<{ - success: boolean; - prompt?: string; - error?: string; - }>, - // Refresh prompts from GitHub - refresh: () => - ipcRenderer.invoke('openspec:refresh') as Promise<{ - success: boolean; - metadata?: { - lastRefreshed: string; - commitSha: string; - sourceVersion: string; - sourceUrl: string; - }; - error?: string; - }>, - }, - - // Notification API - notification: { - show: (title: string, body: string) => ipcRenderer.invoke('notification:show', title, body), - speak: (text: string, command?: string) => - ipcRenderer.invoke('notification:speak', text, command), - stopSpeak: (ttsId: number) => ipcRenderer.invoke('notification:stopSpeak', ttsId), - onTtsCompleted: (handler: (ttsId: number) => void) => { - const wrappedHandler = (_event: Electron.IpcRendererEvent, ttsId: number) => handler(ttsId); - ipcRenderer.on('tts:completed', wrappedHandler); - return () => ipcRenderer.removeListener('tts:completed', wrappedHandler); - }, - }, - - // Attachments API (per-session image storage for scratchpad) - attachments: { - save: (sessionId: string, base64Data: string, filename: string) => - ipcRenderer.invoke('attachments:save', sessionId, base64Data, filename), - load: (sessionId: string, filename: string) => - ipcRenderer.invoke('attachments:load', sessionId, filename), - delete: (sessionId: string, filename: string) => - ipcRenderer.invoke('attachments:delete', sessionId, filename), - list: (sessionId: string) => ipcRenderer.invoke('attachments:list', sessionId), - getPath: (sessionId: string) => ipcRenderer.invoke('attachments:getPath', sessionId), - }, - - // Auto Run API (file-system-based document runner) - // SSH remote support: Core operations accept optional sshRemoteId for remote file operations - autorun: { - listDocs: (folderPath: string, sshRemoteId?: string) => - ipcRenderer.invoke('autorun:listDocs', folderPath, sshRemoteId), - hasDocuments: (folderPath: string): Promise<{ hasDocuments: boolean }> => - ipcRenderer.invoke('autorun:hasDocuments', folderPath), - readDoc: (folderPath: string, filename: string, sshRemoteId?: string) => - ipcRenderer.invoke('autorun:readDoc', folderPath, filename, sshRemoteId), - writeDoc: (folderPath: string, filename: string, content: string, sshRemoteId?: string) => - ipcRenderer.invoke('autorun:writeDoc', folderPath, filename, content, sshRemoteId), - saveImage: (folderPath: string, docName: string, base64Data: string, extension: string) => - ipcRenderer.invoke('autorun:saveImage', folderPath, docName, base64Data, extension), - deleteImage: (folderPath: string, relativePath: string) => - ipcRenderer.invoke('autorun:deleteImage', folderPath, relativePath), - listImages: (folderPath: string, docName: string) => - ipcRenderer.invoke('autorun:listImages', folderPath, docName), - deleteFolder: (projectPath: string) => ipcRenderer.invoke('autorun:deleteFolder', projectPath), - // File watching for live updates - // For remote sessions (sshRemoteId provided), returns isRemote: true indicating polling should be used - watchFolder: ( - folderPath: string, - sshRemoteId?: string - ): Promise<{ isRemote?: boolean; message?: string }> => - ipcRenderer.invoke('autorun:watchFolder', folderPath, sshRemoteId), - unwatchFolder: (folderPath: string) => ipcRenderer.invoke('autorun:unwatchFolder', folderPath), - onFileChanged: ( - handler: (data: { folderPath: string; filename: string; eventType: string }) => void - ) => { - const wrappedHandler = ( - _event: Electron.IpcRendererEvent, - data: { folderPath: string; filename: string; eventType: string } - ) => handler(data); - ipcRenderer.on('autorun:fileChanged', wrappedHandler); - return () => ipcRenderer.removeListener('autorun:fileChanged', wrappedHandler); - }, - // Backup operations for reset-on-completion documents (legacy) - createBackup: (folderPath: string, filename: string) => - ipcRenderer.invoke('autorun:createBackup', folderPath, filename), - restoreBackup: (folderPath: string, filename: string) => - ipcRenderer.invoke('autorun:restoreBackup', folderPath, filename), - deleteBackups: (folderPath: string) => ipcRenderer.invoke('autorun:deleteBackups', folderPath), - // Working copy operations for reset-on-completion documents (preferred) - // Creates a copy in /Runs/ subdirectory: {name}-{timestamp}-loop-{N}.md - createWorkingCopy: ( - folderPath: string, - filename: string, - loopNumber: number - ): Promise<{ workingCopyPath: string; originalPath: string }> => - ipcRenderer.invoke('autorun:createWorkingCopy', folderPath, filename, loopNumber), - }, - - // Playbooks API (saved batch run configurations) - playbooks: { - list: (sessionId: string) => ipcRenderer.invoke('playbooks:list', sessionId), - create: ( - sessionId: string, - playbook: { - name: string; - documents: Array<{ filename: string; resetOnCompletion: boolean }>; - loopEnabled: boolean; - maxLoops?: number | null; - prompt: string; - worktreeSettings?: { - branchNameTemplate: string; - createPROnCompletion: boolean; - prTargetBranch?: string; - }; - } - ) => ipcRenderer.invoke('playbooks:create', sessionId, playbook), - update: ( - sessionId: string, - playbookId: string, - updates: Partial<{ - name: string; - documents: Array<{ filename: string; resetOnCompletion: boolean }>; - loopEnabled: boolean; - maxLoops?: number | null; - prompt: string; - updatedAt: number; - worktreeSettings?: { - branchNameTemplate: string; - createPROnCompletion: boolean; - prTargetBranch?: string; - }; - }> - ) => ipcRenderer.invoke('playbooks:update', sessionId, playbookId, updates), - delete: (sessionId: string, playbookId: string) => - ipcRenderer.invoke('playbooks:delete', sessionId, playbookId), - deleteAll: (sessionId: string) => ipcRenderer.invoke('playbooks:deleteAll', sessionId), - export: (sessionId: string, playbookId: string, autoRunFolderPath: string) => - ipcRenderer.invoke('playbooks:export', sessionId, playbookId, autoRunFolderPath), - import: (sessionId: string, autoRunFolderPath: string) => - ipcRenderer.invoke('playbooks:import', sessionId, autoRunFolderPath), - }, - - // Marketplace API (browse and import playbooks from GitHub) - marketplace: { - getManifest: () => ipcRenderer.invoke('marketplace:getManifest'), - refreshManifest: () => ipcRenderer.invoke('marketplace:refreshManifest'), - getDocument: (playbookPath: string, filename: string) => - ipcRenderer.invoke('marketplace:getDocument', playbookPath, filename), - getReadme: (playbookPath: string) => ipcRenderer.invoke('marketplace:getReadme', playbookPath), - importPlaybook: ( - playbookId: string, - targetFolderName: string, - autoRunFolderPath: string, - sessionId: string, - sshRemoteId?: string - ) => - ipcRenderer.invoke( - 'marketplace:importPlaybook', - playbookId, - targetFolderName, - autoRunFolderPath, - sessionId, - sshRemoteId - ), - onManifestChanged: (handler: () => void) => { - const wrappedHandler = () => handler(); - ipcRenderer.on('marketplace:manifestChanged', wrappedHandler); - return () => ipcRenderer.removeListener('marketplace:manifestChanged', wrappedHandler); - }, - }, - - // Debug Package API (generate support bundles for bug reporting) - debug: { - createPackage: (options?: { - includeLogs?: boolean; - includeErrors?: boolean; - includeSessions?: boolean; - includeGroupChats?: boolean; - includeBatchState?: boolean; - }) => ipcRenderer.invoke('debug:createPackage', options), - previewPackage: () => ipcRenderer.invoke('debug:previewPackage'), - }, - - // Document Graph API (file watching for graph visualization) - documentGraph: { - watchFolder: (rootPath: string) => ipcRenderer.invoke('documentGraph:watchFolder', rootPath), - unwatchFolder: (rootPath: string) => - ipcRenderer.invoke('documentGraph:unwatchFolder', rootPath), - onFilesChanged: ( - handler: (data: { - rootPath: string; - changes: Array<{ - filePath: string; - eventType: 'add' | 'change' | 'unlink'; - }>; - }) => void - ) => { - const wrappedHandler = ( - _event: Electron.IpcRendererEvent, - data: { - rootPath: string; - changes: Array<{ - filePath: string; - eventType: 'add' | 'change' | 'unlink'; - }>; - } - ) => handler(data); - ipcRenderer.on('documentGraph:filesChanged', wrappedHandler); - return () => ipcRenderer.removeListener('documentGraph:filesChanged', wrappedHandler); - }, - }, - - // Group Chat API (multi-agent coordination) - groupChat: { - // Storage - create: ( - name: string, - moderatorAgentId: string, - moderatorConfig?: { - customPath?: string; - customArgs?: string; - customEnvVars?: Record; - } - ) => ipcRenderer.invoke('groupChat:create', name, moderatorAgentId, moderatorConfig), - list: () => ipcRenderer.invoke('groupChat:list'), - load: (id: string) => ipcRenderer.invoke('groupChat:load', id), - delete: (id: string) => ipcRenderer.invoke('groupChat:delete', id), - rename: (id: string, name: string) => ipcRenderer.invoke('groupChat:rename', id, name), - update: ( - id: string, - updates: { - name?: string; - moderatorAgentId?: string; - moderatorConfig?: { - customPath?: string; - customArgs?: string; - customEnvVars?: Record; - }; - } - ) => ipcRenderer.invoke('groupChat:update', id, updates), - - // Chat log - appendMessage: (id: string, from: string, content: string) => - ipcRenderer.invoke('groupChat:appendMessage', id, from, content), - getMessages: (id: string) => ipcRenderer.invoke('groupChat:getMessages', id), - saveImage: (id: string, imageData: string, filename: string) => - ipcRenderer.invoke('groupChat:saveImage', id, imageData, filename), - - // Moderator - startModerator: (id: string) => ipcRenderer.invoke('groupChat:startModerator', id), - sendToModerator: (id: string, message: string, images?: string[], readOnly?: boolean) => - ipcRenderer.invoke('groupChat:sendToModerator', id, message, images, readOnly), - stopModerator: (id: string) => ipcRenderer.invoke('groupChat:stopModerator', id), - getModeratorSessionId: (id: string) => - ipcRenderer.invoke('groupChat:getModeratorSessionId', id), - - // Participants - addParticipant: (id: string, name: string, agentId: string, cwd?: string) => - ipcRenderer.invoke('groupChat:addParticipant', id, name, agentId, cwd), - sendToParticipant: (id: string, name: string, message: string, images?: string[]) => - ipcRenderer.invoke('groupChat:sendToParticipant', id, name, message, images), - removeParticipant: (id: string, name: string) => - ipcRenderer.invoke('groupChat:removeParticipant', id, name), - resetParticipantContext: (id: string, name: string, cwd?: string) => - ipcRenderer.invoke('groupChat:resetParticipantContext', id, name, cwd) as Promise<{ - newAgentSessionId: string; - }>, - - // History - getHistory: (id: string) => ipcRenderer.invoke('groupChat:getHistory', id), - addHistoryEntry: ( - id: string, - entry: { - timestamp: number; - summary: string; - participantName: string; - participantColor: string; - type: 'delegation' | 'response' | 'synthesis' | 'error'; - elapsedTimeMs?: number; - tokenCount?: number; - cost?: number; - fullResponse?: string; - } - ) => ipcRenderer.invoke('groupChat:addHistoryEntry', id, entry), - deleteHistoryEntry: (groupChatId: string, entryId: string) => - ipcRenderer.invoke('groupChat:deleteHistoryEntry', groupChatId, entryId), - clearHistory: (id: string) => ipcRenderer.invoke('groupChat:clearHistory', id), - getHistoryFilePath: (id: string) => ipcRenderer.invoke('groupChat:getHistoryFilePath', id), - - // Export - getImages: (id: string) => - ipcRenderer.invoke('groupChat:getImages', id) as Promise>, - - // Events - onMessage: ( - callback: ( - groupChatId: string, - message: { - timestamp: string; - from: string; - content: string; - } - ) => void - ) => { - const handler = (_: any, groupChatId: string, message: any) => callback(groupChatId, message); - ipcRenderer.on('groupChat:message', handler); - return () => ipcRenderer.removeListener('groupChat:message', handler); - }, - onStateChange: ( - callback: ( - groupChatId: string, - state: 'idle' | 'moderator-thinking' | 'agent-working' - ) => void - ) => { - const handler = ( - _: any, - groupChatId: string, - state: 'idle' | 'moderator-thinking' | 'agent-working' - ) => callback(groupChatId, state); - ipcRenderer.on('groupChat:stateChange', handler); - return () => ipcRenderer.removeListener('groupChat:stateChange', handler); - }, - onParticipantsChanged: ( - callback: ( - groupChatId: string, - participants: Array<{ - name: string; - agentId: string; - sessionId: string; - addedAt: number; - }> - ) => void - ) => { - const handler = (_: any, groupChatId: string, participants: any[]) => - callback(groupChatId, participants); - ipcRenderer.on('groupChat:participantsChanged', handler); - return () => ipcRenderer.removeListener('groupChat:participantsChanged', handler); - }, - onModeratorUsage: ( - callback: ( - groupChatId: string, - usage: { - contextUsage: number; - totalCost: number; - tokenCount: number; - } - ) => void - ) => { - const handler = (_: any, groupChatId: string, usage: any) => callback(groupChatId, usage); - ipcRenderer.on('groupChat:moderatorUsage', handler); - return () => ipcRenderer.removeListener('groupChat:moderatorUsage', handler); - }, - onHistoryEntry: ( - callback: ( - groupChatId: string, - entry: { - id: string; - timestamp: number; - summary: string; - participantName: string; - participantColor: string; - type: 'delegation' | 'response' | 'synthesis' | 'error'; - elapsedTimeMs?: number; - tokenCount?: number; - cost?: number; - fullResponse?: string; - } - ) => void - ) => { - const handler = (_: any, groupChatId: string, entry: any) => callback(groupChatId, entry); - ipcRenderer.on('groupChat:historyEntry', handler); - return () => ipcRenderer.removeListener('groupChat:historyEntry', handler); - }, - onParticipantState: ( - callback: (groupChatId: string, participantName: string, state: 'idle' | 'working') => void - ) => { - const handler = ( - _: any, - groupChatId: string, - participantName: string, - state: 'idle' | 'working' - ) => callback(groupChatId, participantName, state); - ipcRenderer.on('groupChat:participantState', handler); - return () => ipcRenderer.removeListener('groupChat:participantState', handler); - }, - onModeratorSessionIdChanged: (callback: (groupChatId: string, sessionId: string) => void) => { - const handler = (_: any, groupChatId: string, sessionId: string) => - callback(groupChatId, sessionId); - ipcRenderer.on('groupChat:moderatorSessionIdChanged', handler); - return () => ipcRenderer.removeListener('groupChat:moderatorSessionIdChanged', handler); - }, - }, - - // App lifecycle API (quit confirmation) - app: { - // Listen for quit confirmation request from main process - onQuitConfirmationRequest: (callback: () => void) => { - const handler = () => callback(); - ipcRenderer.on('app:requestQuitConfirmation', handler); - return () => ipcRenderer.removeListener('app:requestQuitConfirmation', handler); - }, - // Confirm quit (user approved or no busy agents) - confirmQuit: () => { - ipcRenderer.send('app:quitConfirmed'); - }, - // Cancel quit (user declined) - cancelQuit: () => { - ipcRenderer.send('app:quitCancelled'); - }, - }, - - // Stats API (usage tracking and analytics) - stats: { - // Record a query event (interactive conversation turn) - recordQuery: (event: { - sessionId: string; - agentType: string; - source: 'user' | 'auto'; - startTime: number; - duration: number; - projectPath?: string; - tabId?: string; - isRemote?: boolean; - }) => ipcRenderer.invoke('stats:record-query', event) as Promise, - - // Start an Auto Run session (returns session ID) - startAutoRun: (session: { - sessionId: string; - agentType: string; - documentPath?: string; - startTime: number; - tasksTotal?: number; - projectPath?: string; - }) => ipcRenderer.invoke('stats:start-autorun', session) as Promise, - - // End an Auto Run session (update duration and completed count) - endAutoRun: (id: string, duration: number, tasksCompleted: number) => - ipcRenderer.invoke('stats:end-autorun', id, duration, tasksCompleted) as Promise, - - // Record an Auto Run task completion - recordAutoTask: (task: { - autoRunSessionId: string; - sessionId: string; - agentType: string; - taskIndex: number; - taskContent?: string; - startTime: number; - duration: number; - success: boolean; - }) => ipcRenderer.invoke('stats:record-task', task) as Promise, - - // Get query events with time range and optional filters - getStats: ( - range: 'day' | 'week' | 'month' | 'year' | 'all', - filters?: { - agentType?: string; - source?: 'user' | 'auto'; - projectPath?: string; - sessionId?: string; - } - ) => - ipcRenderer.invoke('stats:get-stats', range, filters) as Promise< - Array<{ - id: string; - sessionId: string; - agentType: string; - source: 'user' | 'auto'; - startTime: number; - duration: number; - projectPath?: string; - tabId?: string; - }> - >, - - // Get Auto Run sessions within a time range - getAutoRunSessions: (range: 'day' | 'week' | 'month' | 'year' | 'all') => - ipcRenderer.invoke('stats:get-autorun-sessions', range) as Promise< - Array<{ - id: string; - sessionId: string; - agentType: string; - documentPath?: string; - startTime: number; - duration: number; - tasksTotal?: number; - tasksCompleted?: number; - projectPath?: string; - }> - >, - - // Get tasks for a specific Auto Run session - getAutoRunTasks: (autoRunSessionId: string) => - ipcRenderer.invoke('stats:get-autorun-tasks', autoRunSessionId) as Promise< - Array<{ - id: string; - autoRunSessionId: string; - sessionId: string; - agentType: string; - taskIndex: number; - taskContent?: string; - startTime: number; - duration: number; - success: boolean; - }> - >, - - // Get aggregated stats for dashboard display - getAggregation: (range: 'day' | 'week' | 'month' | 'year' | 'all') => - ipcRenderer.invoke('stats:get-aggregation', range) as Promise<{ - totalQueries: number; - totalDuration: number; - avgDuration: number; - byAgent: Record; - bySource: { user: number; auto: number }; - byDay: Array<{ date: string; count: number; duration: number }>; - }>, - - // Export query events to CSV - exportCsv: (range: 'day' | 'week' | 'month' | 'year' | 'all') => - ipcRenderer.invoke('stats:export-csv', range) as Promise, - - // Subscribe to stats updates (for real-time dashboard refresh) - onStatsUpdate: (callback: () => void) => { - const handler = () => callback(); - ipcRenderer.on('stats:updated', handler); - return () => ipcRenderer.removeListener('stats:updated', handler); - }, - - // Clear old stats data (older than specified number of days) - clearOldData: (olderThanDays: number) => - ipcRenderer.invoke('stats:clear-old-data', olderThanDays) as Promise<{ - success: boolean; - deletedQueryEvents: number; - deletedAutoRunSessions: number; - deletedAutoRunTasks: number; - error?: string; - }>, - - // Get database size in bytes - getDatabaseSize: () => ipcRenderer.invoke('stats:get-database-size') as Promise, - - // Record session creation (for lifecycle tracking) - recordSessionCreated: (event: { - sessionId: string; - agentType: string; - projectPath?: string; - createdAt: number; - isRemote?: boolean; - }) => ipcRenderer.invoke('stats:record-session-created', event) as Promise, - - // Record session closure (for lifecycle tracking) - recordSessionClosed: (sessionId: string, closedAt: number) => - ipcRenderer.invoke('stats:record-session-closed', sessionId, closedAt) as Promise, - - // Get session lifecycle events within a time range - getSessionLifecycle: (range: 'day' | 'week' | 'month' | 'year' | 'all') => - ipcRenderer.invoke('stats:get-session-lifecycle', range) as Promise< - Array<{ - id: string; - sessionId: string; - agentType: string; - projectPath?: string; - createdAt: number; - closedAt?: number; - duration?: number; - isRemote?: boolean; - }> - >, - }, - - // Leaderboard API (runmaestro.ai integration) - leaderboard: { - getInstallationId: () => - ipcRenderer.invoke('leaderboard:getInstallationId') as Promise, - submit: (data: { - email: string; - displayName: string; - githubUsername?: string; - twitterHandle?: string; - linkedinHandle?: string; - discordUsername?: string; - blueskyHandle?: string; - badgeLevel: number; - badgeName: string; - // Stats fields are optional for profile-only submissions (multi-device safe) - // When omitted, server keeps existing values instead of overwriting - cumulativeTimeMs?: number; - totalRuns?: number; - longestRunMs?: number; - longestRunDate?: string; - currentRunMs?: number; - theme?: string; - clientToken?: string; - authToken?: string; - // Keyboard mastery data (aligned with RunMaestro.ai server schema) - keyboardMasteryLevel?: number; - keyboardMasteryTitle?: string; - keyboardCoveragePercent?: number; - keyboardKeysUnlocked?: number; - keyboardTotalKeys?: number; - // Delta mode for multi-device aggregation - deltaMs?: number; - deltaRuns?: number; - // Installation tracking for multi-device differentiation - installationId?: string; // Unique GUID per Maestro installation (auto-injected by main process) - clientTotalTimeMs?: number; // Client's self-proclaimed total time (for discrepancy detection) - }) => ipcRenderer.invoke('leaderboard:submit', data), - pollAuthStatus: (clientToken: string) => - ipcRenderer.invoke('leaderboard:pollAuthStatus', clientToken), - resendConfirmation: (data: { email: string; clientToken: string }) => - ipcRenderer.invoke('leaderboard:resendConfirmation', data), - get: (options?: { limit?: number }) => ipcRenderer.invoke('leaderboard:get', options), - getLongestRuns: (options?: { limit?: number }) => - ipcRenderer.invoke('leaderboard:getLongestRuns', options), - // Sync stats from server (for new device installations) - sync: (data: { email: string; authToken: string }) => - ipcRenderer.invoke('leaderboard:sync', data) as Promise<{ - success: boolean; - found: boolean; - message?: string; - error?: string; - errorCode?: 'EMAIL_NOT_CONFIRMED' | 'INVALID_TOKEN' | 'MISSING_FIELDS'; - data?: { - displayName: string; - badgeLevel: number; - badgeName: string; - cumulativeTimeMs: number; - totalRuns: number; - longestRunMs: number | null; - longestRunDate: string | null; - keyboardLevel: number | null; - coveragePercent: number | null; - ranking: { - cumulative: { rank: number; total: number }; - longestRun: { rank: number; total: number } | null; - }; - }; - }>, - }, -}); - -// Type definitions for TypeScript -export interface MaestroAPI { - settings: { - get: (key: string) => Promise; - set: (key: string, value: unknown) => Promise; - getAll: () => Promise>; - }; - sessions: { - getAll: () => Promise; - setAll: (sessions: any[]) => Promise; - }; - groups: { - getAll: () => Promise; - setAll: (groups: any[]) => Promise; - }; - process: { - spawn: (config: ProcessConfig) => Promise<{ - pid: number; - success: boolean; - sshRemote?: { id: string; name: string; host: string }; - }>; - write: (sessionId: string, data: string) => Promise; - interrupt: (sessionId: string) => Promise; - kill: (sessionId: string) => Promise; - resize: (sessionId: string, cols: number, rows: number) => Promise; - runCommand: (config: { - sessionId: string; - command: string; - cwd: string; - shell?: string; - sessionSshRemoteConfig?: { - enabled: boolean; - remoteId: string | null; - workingDirOverride?: string; - }; - }) => Promise<{ exitCode: number }>; - getActiveProcesses: () => Promise< - Array<{ - sessionId: string; - toolType: string; - pid: number; - cwd: string; - isTerminal: boolean; - isBatchMode: boolean; - startTime: number; - command?: string; - args?: string[]; - }> - >; - onData: (callback: (sessionId: string, data: string) => void) => () => void; - onExit: (callback: (sessionId: string, code: number) => void) => () => void; - onSessionId: (callback: (sessionId: string, agentSessionId: string) => void) => () => void; - onSlashCommands: (callback: (sessionId: string, slashCommands: string[]) => void) => () => void; - onThinkingChunk: (callback: (sessionId: string, content: string) => void) => () => void; - onToolExecution: ( - callback: ( - sessionId: string, - toolEvent: { toolName: string; state?: unknown; timestamp: number } - ) => void - ) => () => void; - onSshRemote: ( - callback: ( - sessionId: string, - sshRemote: { id: string; name: string; host: string } | null - ) => void - ) => () => void; - onRemoteCommand: (callback: (sessionId: string, command: string) => void) => () => void; - onRemoteSwitchMode: ( - callback: (sessionId: string, mode: 'ai' | 'terminal') => void - ) => () => void; - onRemoteInterrupt: (callback: (sessionId: string) => void) => () => void; - onRemoteSelectSession: (callback: (sessionId: string) => void) => () => void; - onStderr: (callback: (sessionId: string, data: string) => void) => () => void; - onCommandExit: (callback: (sessionId: string, code: number) => void) => () => void; - onUsage: ( - callback: ( - sessionId: string, - usageStats: { - inputTokens: number; - outputTokens: number; - cacheReadInputTokens: number; - cacheCreationInputTokens: number; - totalCostUsd: number; - contextWindow: number; - reasoningTokens?: number; - } - ) => void - ) => () => void; - onAgentError: ( - callback: ( - sessionId: string, - error: { - type: string; - message: string; - recoverable: boolean; - agentId: string; - sessionId?: string; - timestamp: number; - raw?: { - exitCode?: number; - stderr?: string; - stdout?: string; - errorLine?: string; - }; - } - ) => void - ) => () => void; - }; - agentError: { - clearError: (sessionId: string) => Promise<{ success: boolean }>; - retryAfterError: ( - sessionId: string, - options?: { - prompt?: string; - newSession?: boolean; - } - ) => Promise<{ success: boolean }>; - }; - git: { - status: (cwd: string) => Promise; - diff: (cwd: string, file?: string) => Promise; - isRepo: (cwd: string) => Promise; - numstat: (cwd: string) => Promise<{ stdout: string; stderr: string }>; - branch: (cwd: string) => Promise<{ stdout: string; stderr: string }>; - remote: (cwd: string) => Promise<{ stdout: string; stderr: string }>; - info: (cwd: string) => Promise<{ - branch: string; - remote: string; - behind: number; - ahead: number; - uncommittedChanges: number; - }>; - log: ( - cwd: string, - options?: { limit?: number; search?: string } - ) => Promise<{ - entries: Array<{ - hash: string; - shortHash: string; - author: string; - date: string; - refs: string[]; - subject: string; - }>; - error: string | null; - }>; - show: (cwd: string, hash: string) => Promise<{ stdout: string; stderr: string }>; - showFile: ( - cwd: string, - ref: string, - filePath: string - ) => Promise<{ content?: string; error?: string }>; - // Git worktree operations for Auto Run parallelization - worktreeInfo: (worktreePath: string) => Promise<{ - success: boolean; - exists?: boolean; - isWorktree?: boolean; - currentBranch?: string; - repoRoot?: string; - error?: string; - }>; - getRepoRoot: (cwd: string) => Promise<{ - success: boolean; - root?: string; - error?: string; - }>; - worktreeSetup: ( - mainRepoCwd: string, - worktreePath: string, - branchName: string - ) => Promise<{ - success: boolean; - created?: boolean; - currentBranch?: string; - requestedBranch?: string; - branchMismatch?: boolean; - error?: string; - }>; - worktreeCheckout: ( - worktreePath: string, - branchName: string, - createIfMissing: boolean - ) => Promise<{ - success: boolean; - hasUncommittedChanges: boolean; - error?: string; - }>; - createPR: ( - worktreePath: string, - baseBranch: string, - title: string, - body: string, - ghPath?: string - ) => Promise<{ - success: boolean; - prUrl?: string; - error?: string; - }>; - getDefaultBranch: (cwd: string) => Promise<{ - success: boolean; - branch?: string; - error?: string; - }>; - checkGhCli: (ghPath?: string) => Promise<{ - installed: boolean; - authenticated: boolean; - }>; - createGist: ( - filename: string, - content: string, - description: string, - isPublic: boolean, - ghPath?: string - ) => Promise<{ - success: boolean; - gistUrl?: string; - error?: string; - }>; - listWorktrees: (cwd: string) => Promise<{ - worktrees: Array<{ - path: string; - head: string; - branch: string | null; - isBare: boolean; - }>; - }>; - scanWorktreeDirectory: ( - parentPath: string, - sshRemoteId?: string - ) => Promise<{ - gitSubdirs: Array<{ - path: string; - name: string; - isWorktree: boolean; - branch: string | null; - repoRoot: string | null; - }>; - }>; - }; - fs: { - homeDir: () => Promise; - readDir: (dirPath: string, sshRemoteId?: string) => Promise; - readFile: (filePath: string, sshRemoteId?: string) => Promise; - writeFile: (filePath: string, content: string) => Promise<{ success: boolean }>; - stat: ( - filePath: string, - sshRemoteId?: string - ) => Promise<{ - size: number; - createdAt: string; - modifiedAt: string; - isDirectory: boolean; - isFile: boolean; - }>; - directorySize: ( - dirPath: string, - sshRemoteId?: string - ) => Promise<{ - totalSize: number; - fileCount: number; - folderCount: number; - }>; - fetchImageAsBase64: (url: string) => Promise; - rename: (oldPath: string, newPath: string) => Promise<{ success: boolean }>; - delete: ( - targetPath: string, - options?: { recursive?: boolean } - ) => Promise<{ success: boolean }>; - countItems: (dirPath: string) => Promise<{ fileCount: number; folderCount: number }>; - }; - webserver: { - getUrl: () => Promise; - getConnectedClients: () => Promise; - }; - live: { - toggle: ( - sessionId: string, - agentSessionId?: string - ) => Promise<{ live: boolean; url: string | null }>; - getStatus: (sessionId: string) => Promise<{ live: boolean; url: string | null }>; - getDashboardUrl: () => Promise; - getLiveSessions: () => Promise< - Array<{ sessionId: string; agentSessionId?: string; enabledAt: number }> - >; - broadcastActiveSession: (sessionId: string) => Promise; - disableAll: () => Promise<{ success: boolean; count: number }>; - startServer: () => Promise<{ success: boolean; url?: string; error?: string }>; - stopServer: () => Promise<{ success: boolean }>; - }; - agents: { - detect: (sshRemoteId?: string) => Promise; - refresh: ( - agentId?: string, - sshRemoteId?: string - ) => Promise<{ agents: AgentConfig[]; debugInfo: any }>; - get: (agentId: string) => Promise; - getCapabilities: (agentId: string) => Promise; - getConfig: (agentId: string) => Promise>; - setConfig: (agentId: string, config: Record) => Promise; - getConfigValue: (agentId: string, key: string) => Promise; - setConfigValue: (agentId: string, key: string, value: any) => Promise; - setCustomPath: (agentId: string, customPath: string | null) => Promise; - getCustomPath: (agentId: string) => Promise; - getAllCustomPaths: () => Promise>; - setCustomArgs: (agentId: string, customArgs: string | null) => Promise; - getCustomArgs: (agentId: string) => Promise; - getAllCustomArgs: () => Promise>; - setCustomEnvVars: ( - agentId: string, - customEnvVars: Record | null - ) => Promise; - getCustomEnvVars: (agentId: string) => Promise | null>; - getAllCustomEnvVars: () => Promise>>; - getModels: (agentId: string, forceRefresh?: boolean) => Promise; - discoverSlashCommands: ( - agentId: string, - cwd: string, - customPath?: string - ) => Promise; - }; - dialog: { - selectFolder: () => Promise; - }; - fonts: { - detect: () => Promise; - }; - shells: { - detect: () => Promise; - }; - shell: { - openExternal: (url: string) => Promise; - trashItem: (itemPath: string) => Promise; - }; - tunnel: { - isCloudflaredInstalled: () => Promise; - start: () => Promise<{ success: boolean; url?: string; error?: string }>; - stop: () => Promise<{ success: boolean }>; - getStatus: () => Promise<{ isRunning: boolean; url: string | null; error: string | null }>; - }; - sshRemote: { - saveConfig: (config: { - id?: string; - name?: string; - host?: string; - port?: number; - username?: string; - privateKeyPath?: string; - remoteEnv?: Record; - enabled?: boolean; - }) => Promise<{ - success: boolean; - config?: { - id: string; - name: string; - host: string; - port: number; - username: string; - privateKeyPath: string; - remoteEnv?: Record; - enabled: boolean; - }; - error?: string; - }>; - deleteConfig: (id: string) => Promise<{ success: boolean; error?: string }>; - getConfigs: () => Promise<{ - success: boolean; - configs?: Array<{ - id: string; - name: string; - host: string; - port: number; - username: string; - privateKeyPath: string; - remoteEnv?: Record; - enabled: boolean; - }>; - error?: string; - }>; - getDefaultId: () => Promise<{ success: boolean; id?: string | null; error?: string }>; - setDefaultId: (id: string | null) => Promise<{ success: boolean; error?: string }>; - test: ( - configOrId: - | string - | { - id: string; - name: string; - host: string; - port: number; - username: string; - privateKeyPath: string; - remoteEnv?: Record; - enabled: boolean; - }, - agentCommand?: string - ) => Promise<{ - success: boolean; - result?: { - success: boolean; - error?: string; - remoteInfo?: { - hostname: string; - agentVersion?: string; - }; - }; - error?: string; - }>; - getSshConfigHosts: () => Promise<{ - success: boolean; - hosts: Array<{ - host: string; - hostName?: string; - port?: number; - user?: string; - identityFile?: string; - proxyJump?: string; - }>; - error?: string; - configPath: string; - }>; - }; - sync: { - getDefaultPath: () => Promise; - getSettings: () => Promise<{ - customSyncPath?: string; - }>; - getCurrentStoragePath: () => Promise; - selectSyncFolder: () => Promise; - setCustomPath: (customPath: string | null) => Promise<{ - success: boolean; - migrated?: number; - errors?: string[]; - requiresRestart?: boolean; - error?: string; - }>; - }; - devtools: { - open: () => Promise; - close: () => Promise; - toggle: () => Promise; - }; - app: { - onQuitConfirmationRequest: (callback: () => void) => () => void; - confirmQuit: () => void; - cancelQuit: () => void; - }; - stats: { - recordQuery: (event: { - sessionId: string; - agentType: string; - source: 'user' | 'auto'; - startTime: number; - duration: number; - projectPath?: string; - tabId?: string; - isRemote?: boolean; - }) => Promise; - startAutoRun: (session: { - sessionId: string; - agentType: string; - documentPath?: string; - startTime: number; - tasksTotal?: number; - projectPath?: string; - }) => Promise; - endAutoRun: (id: string, duration: number, tasksCompleted: number) => Promise; - recordAutoTask: (task: { - autoRunSessionId: string; - sessionId: string; - agentType: string; - taskIndex: number; - taskContent?: string; - startTime: number; - duration: number; - success: boolean; - }) => Promise; - getStats: ( - range: 'day' | 'week' | 'month' | 'year' | 'all', - filters?: { - agentType?: string; - source?: 'user' | 'auto'; - projectPath?: string; - sessionId?: string; - } - ) => Promise< - Array<{ - id: string; - sessionId: string; - agentType: string; - source: 'user' | 'auto'; - startTime: number; - duration: number; - projectPath?: string; - tabId?: string; - }> - >; - getAutoRunSessions: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise< - Array<{ - id: string; - sessionId: string; - agentType: string; - documentPath?: string; - startTime: number; - duration: number; - tasksTotal?: number; - tasksCompleted?: number; - projectPath?: string; - }> - >; - getAutoRunTasks: (autoRunSessionId: string) => Promise< - Array<{ - id: string; - autoRunSessionId: string; - sessionId: string; - agentType: string; - taskIndex: number; - taskContent?: string; - startTime: number; - duration: number; - success: boolean; - }> - >; - getAggregation: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise<{ - totalQueries: number; - totalDuration: number; - avgDuration: number; - byAgent: Record; - bySource: { user: number; auto: number }; - byDay: Array<{ date: string; count: number; duration: number }>; - }>; - exportCsv: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise; - onStatsUpdate: (callback: () => void) => () => void; - clearOldData: (olderThanDays: number) => Promise<{ - success: boolean; - deletedQueryEvents: number; - deletedAutoRunSessions: number; - deletedAutoRunTasks: number; - error?: string; - }>; - getDatabaseSize: () => Promise; - }; - updates: { - check: () => Promise<{ - currentVersion: string; - latestVersion: string; - updateAvailable: boolean; - versionsBehind: number; - releases: Array<{ - tag_name: string; - name: string; - body: string; - html_url: string; - published_at: string; - }>; - releasesUrl: string; - error?: string; - }>; - download: () => Promise<{ success: boolean; error?: string }>; - install: () => Promise; - getStatus: () => Promise<{ - status: - | 'idle' - | 'checking' - | 'available' - | 'not-available' - | 'downloading' - | 'downloaded' - | 'error'; - info?: { version: string }; - progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number }; - error?: string; - }>; - onStatus: ( - callback: (status: { - status: - | 'idle' - | 'checking' - | 'available' - | 'not-available' - | 'downloading' - | 'downloaded' - | 'error'; - info?: { version: string }; - progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number }; - error?: string; - }) => void - ) => () => void; - }; - logger: { - log: (level: MainLogLevel, message: string, context?: string, data?: unknown) => Promise; - getLogs: (filter?: { - level?: MainLogLevel; - context?: string; - limit?: number; - }) => Promise; - clearLogs: () => Promise; - setLogLevel: (level: MainLogLevel) => Promise; - getLogLevel: () => Promise; - setMaxLogBuffer: (max: number) => Promise; - getMaxLogBuffer: () => Promise; - toast: (title: string, data?: unknown) => Promise; - autorun: (message: string, context?: string, data?: unknown) => Promise; - onNewLog: (callback: (log: SystemLogEntry) => void) => () => void; - }; - claude: { - listSessions: (projectPath: string) => Promise< - Array<{ - sessionId: string; - projectPath: string; - timestamp: string; - modifiedAt: string; - firstMessage: string; - messageCount: number; - sizeBytes: number; - costUsd: number; - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheCreationTokens: number; - durationSeconds: number; - origin?: 'user' | 'auto'; // Maestro session origin, undefined for CLI sessions - sessionName?: string; // User-defined session name from Maestro - }> - >; - // Paginated version for better performance with many sessions - listSessionsPaginated: ( - projectPath: string, - options?: { cursor?: string; limit?: number } - ) => Promise<{ - sessions: Array<{ - sessionId: string; - projectPath: string; - timestamp: string; - modifiedAt: string; - firstMessage: string; - messageCount: number; - sizeBytes: number; - costUsd: number; - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheCreationTokens: number; - durationSeconds: number; - origin?: 'user' | 'auto'; - sessionName?: string; - }>; - hasMore: boolean; - totalCount: number; - nextCursor: string | null; - }>; - // Get aggregate stats for all sessions in a project - getProjectStats: (projectPath: string) => Promise<{ - totalSessions: number; - totalMessages: number; - totalCostUsd: number; - totalSizeBytes: number; - oldestTimestamp: string | null; - }>; - onProjectStatsUpdate: ( - callback: (stats: { - projectPath: string; - totalSessions: number; - totalMessages: number; - totalCostUsd: number; - totalSizeBytes: number; - oldestTimestamp: string | null; - processedCount: number; - isComplete: boolean; - }) => void - ) => () => void; - onGlobalStatsUpdate: ( - callback: (stats: { - totalSessions: number; - totalMessages: number; - totalInputTokens: number; - totalOutputTokens: number; - totalCacheReadTokens: number; - totalCacheCreationTokens: number; - totalCostUsd: number; - totalSizeBytes: number; - isComplete: boolean; - }) => void - ) => () => void; - readSessionMessages: ( - projectPath: string, - sessionId: string, - options?: { offset?: number; limit?: number } - ) => Promise<{ - messages: Array<{ - type: string; - role?: string; - content: string; - timestamp: string; - uuid: string; - toolUse?: any; - }>; - total: number; - hasMore: boolean; - }>; - searchSessions: ( - projectPath: string, - query: string, - searchMode: 'title' | 'user' | 'assistant' | 'all' - ) => Promise< - Array<{ - sessionId: string; - matchType: 'title' | 'user' | 'assistant'; - matchPreview: string; - matchCount: number; - }> - >; - getCommands: (projectPath: string) => Promise< - Array<{ - command: string; - description: string; - }> - >; - registerSessionOrigin: ( - projectPath: string, - agentSessionId: string, - origin: 'user' | 'auto', - sessionName?: string - ) => Promise; - updateSessionName: ( - projectPath: string, - agentSessionId: string, - sessionName: string - ) => Promise; - updateSessionStarred: ( - projectPath: string, - agentSessionId: string, - starred: boolean - ) => Promise; - updateSessionContextUsage: ( - projectPath: string, - agentSessionId: string, - contextUsage: number - ) => Promise; - getSessionOrigins: (projectPath: string) => Promise< - Record< - string, - | 'user' - | 'auto' - | { - origin: 'user' | 'auto'; - sessionName?: string; - starred?: boolean; - contextUsage?: number; - } - > - >; - deleteMessagePair: ( - projectPath: string, - sessionId: string, - userMessageUuid: string, - fallbackContent?: string - ) => Promise<{ success: boolean; linesRemoved?: number; error?: string }>; - }; - agentSessions: { - list: ( - agentId: string, - projectPath: string - ) => Promise< - Array<{ - sessionId: string; - projectPath: string; - timestamp: string; - modifiedAt: string; - firstMessage: string; - messageCount: number; - sizeBytes: number; - costUsd: number; - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheCreationTokens: number; - durationSeconds: number; - origin?: 'user' | 'auto'; - sessionName?: string; - starred?: boolean; - }> - >; - listPaginated: ( - agentId: string, - projectPath: string, - options?: { cursor?: string; limit?: number } - ) => Promise<{ - sessions: Array<{ - sessionId: string; - projectPath: string; - timestamp: string; - modifiedAt: string; - firstMessage: string; - messageCount: number; - sizeBytes: number; - costUsd: number; - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheCreationTokens: number; - durationSeconds: number; - origin?: 'user' | 'auto'; - sessionName?: string; - starred?: boolean; - }>; - hasMore: boolean; - totalCount: number; - nextCursor: string | null; - }>; - read: ( - agentId: string, - projectPath: string, - sessionId: string, - options?: { offset?: number; limit?: number } - ) => Promise<{ - messages: Array<{ - type: string; - role?: string; - content: string; - timestamp: string; - uuid: string; - toolUse?: any; - }>; - total: number; - hasMore: boolean; - }>; - search: ( - agentId: string, - projectPath: string, - query: string, - searchMode: 'title' | 'user' | 'assistant' | 'all' - ) => Promise< - Array<{ - sessionId: string; - matchType: 'title' | 'user' | 'assistant'; - matchPreview: string; - matchCount: number; - }> - >; - getPath: (agentId: string, projectPath: string, sessionId: string) => Promise; - deleteMessagePair: ( - agentId: string, - projectPath: string, - sessionId: string, - userMessageUuid: string, - fallbackContent?: string - ) => Promise<{ success: boolean; linesRemoved?: number; error?: string }>; - hasStorage: (agentId: string) => Promise; - getAvailableStorages: () => Promise; - getAllNamedSessions: () => Promise< - Array<{ - agentSessionId: string; - projectPath: string; - sessionName: string; - starred?: boolean; - lastActivityAt?: number; - }> - >; - registerSessionOrigin: ( - projectPath: string, - agentSessionId: string, - origin: 'user' | 'auto', - sessionName?: string - ) => Promise; - updateSessionName: ( - projectPath: string, - agentSessionId: string, - sessionName: string - ) => Promise; - // Generic session origins API (for non-Claude agents like Codex, OpenCode) - getOrigins: ( - agentId: string, - projectPath: string - ) => Promise< - Record - >; - setSessionName: ( - agentId: string, - projectPath: string, - sessionId: string, - sessionName: string | null - ) => Promise; - setSessionStarred: ( - agentId: string, - projectPath: string, - sessionId: string, - starred: boolean - ) => Promise; - }; - tempfile: { - write: ( - content: string, - filename?: string - ) => Promise<{ success: boolean; path?: string; error?: string }>; - read: (filePath: string) => Promise<{ success: boolean; content?: string; error?: string }>; - delete: (filePath: string) => Promise<{ success: boolean; error?: string }>; - }; - history: { - getAll: ( - projectPath?: string, - sessionId?: string - ) => Promise< - Array<{ - id: string; - type: 'AUTO' | 'USER'; - timestamp: number; - summary: string; - fullResponse?: string; - agentSessionId?: string; - projectPath: string; - sessionId?: string; - sessionName?: string; - contextUsage?: number; - usageStats?: { - inputTokens: number; - outputTokens: number; - cacheReadInputTokens: number; - cacheCreationInputTokens: number; - totalCostUsd: number; - contextWindow: number; - }; - success?: boolean; - elapsedTimeMs?: number; - validated?: boolean; - }> - >; - getAllPaginated: (options?: { - projectPath?: string; - sessionId?: string; - pagination?: { limit?: number; offset?: number }; - }) => Promise<{ - entries: Array<{ - id: string; - type: 'AUTO' | 'USER'; - timestamp: number; - summary: string; - fullResponse?: string; - agentSessionId?: string; - projectPath: string; - sessionId?: string; - sessionName?: string; - contextUsage?: number; - usageStats?: { - inputTokens: number; - outputTokens: number; - cacheReadInputTokens: number; - cacheCreationInputTokens: number; - totalCostUsd: number; - contextWindow: number; - }; - success?: boolean; - elapsedTimeMs?: number; - validated?: boolean; - }>; - total: number; - limit: number; - offset: number; - hasMore: boolean; - }>; - add: (entry: { - id: string; - type: 'AUTO' | 'USER'; - timestamp: number; - summary: string; - fullResponse?: string; - agentSessionId?: string; - projectPath: string; - sessionId?: string; - sessionName?: string; - contextUsage?: number; - usageStats?: { - inputTokens: number; - outputTokens: number; - cacheReadInputTokens: number; - cacheCreationInputTokens: number; - totalCostUsd: number; - contextWindow: number; - }; - success?: boolean; - elapsedTimeMs?: number; - validated?: boolean; - }) => Promise; - clear: (projectPath?: string) => Promise; - delete: (entryId: string, sessionId?: string) => Promise; - update: ( - entryId: string, - updates: { validated?: boolean }, - sessionId?: string - ) => Promise; - // Update sessionName for all entries matching a agentSessionId (used when renaming tabs) - updateSessionName: (agentSessionId: string, sessionName: string) => Promise; - onExternalChange: (handler: () => void) => () => void; - reload: () => Promise; - // NEW: Get history file path for AI context integration - getFilePath: (sessionId: string) => Promise; - // NEW: List sessions with history - listSessions: () => Promise; - }; - cli: { - getActivity: () => Promise< - Array<{ - sessionId: string; - playbookId: string; - playbookName: string; - startedAt: number; - pid: number; - currentTask?: string; - currentDocument?: string; - }> - >; - onActivityChange: (handler: () => void) => () => void; - }; - notification: { - show: (title: string, body: string) => Promise<{ success: boolean; error?: string }>; - speak: ( - text: string, - command?: string - ) => Promise<{ success: boolean; ttsId?: number; error?: string }>; - stopSpeak: (ttsId: number) => Promise<{ success: boolean; error?: string }>; - onTtsCompleted: (handler: (ttsId: number) => void) => () => void; - }; - attachments: { - save: ( - sessionId: string, - base64Data: string, - filename: string - ) => Promise<{ success: boolean; path?: string; filename?: string; error?: string }>; - load: ( - sessionId: string, - filename: string - ) => Promise<{ success: boolean; dataUrl?: string; error?: string }>; - delete: (sessionId: string, filename: string) => Promise<{ success: boolean; error?: string }>; - list: (sessionId: string) => Promise<{ success: boolean; files: string[]; error?: string }>; - getPath: (sessionId: string) => Promise<{ success: boolean; path: string }>; - }; - autorun: { - listDocs: ( - folderPath: string - ) => Promise<{ success: boolean; files: string[]; error?: string }>; - hasDocuments: (folderPath: string) => Promise<{ hasDocuments: boolean }>; - readDoc: ( - folderPath: string, - filename: string - ) => Promise<{ success: boolean; content?: string; error?: string }>; - writeDoc: ( - folderPath: string, - filename: string, - content: string - ) => Promise<{ success: boolean; error?: string }>; - saveImage: ( - folderPath: string, - docName: string, - base64Data: string, - extension: string - ) => Promise<{ success: boolean; relativePath?: string; error?: string }>; - deleteImage: ( - folderPath: string, - relativePath: string - ) => Promise<{ success: boolean; error?: string }>; - listImages: ( - folderPath: string, - docName: string - ) => Promise<{ - success: boolean; - images?: { filename: string; relativePath: string }[]; - error?: string; - }>; - deleteFolder: (projectPath: string) => Promise<{ success: boolean; error?: string }>; - watchFolder: (folderPath: string) => Promise<{ success: boolean; error?: string }>; - unwatchFolder: (folderPath: string) => Promise<{ success: boolean; error?: string }>; - onFileChanged: ( - handler: (data: { folderPath: string; filename: string; eventType: string }) => void - ) => () => void; - createBackup: ( - folderPath: string, - filename: string - ) => Promise<{ success: boolean; backupFilename?: string; error?: string }>; - restoreBackup: ( - folderPath: string, - filename: string - ) => Promise<{ success: boolean; error?: string }>; - deleteBackups: ( - folderPath: string - ) => Promise<{ success: boolean; deletedCount?: number; error?: string }>; - createWorkingCopy: ( - folderPath: string, - filename: string, - loopNumber: number - ) => Promise<{ workingCopyPath: string; originalPath: string }>; - }; - documentGraph: { - watchFolder: (rootPath: string) => Promise<{ success: boolean; error?: string }>; - unwatchFolder: (rootPath: string) => Promise<{ success: boolean; error?: string }>; - onFilesChanged: ( - handler: (data: { - rootPath: string; - changes: Array<{ - filePath: string; - eventType: 'add' | 'change' | 'unlink'; - }>; - }) => void - ) => () => void; - }; - playbooks: { - list: (sessionId: string) => Promise<{ - success: boolean; - playbooks: Array<{ - id: string; - name: string; - createdAt: number; - updatedAt: number; - documents: Array<{ filename: string; resetOnCompletion: boolean }>; - loopEnabled: boolean; - maxLoops?: number | null; - prompt: string; - worktreeSettings?: { - branchNameTemplate: string; - createPROnCompletion: boolean; - prTargetBranch?: string; - }; - }>; - error?: string; - }>; - create: ( - sessionId: string, - playbook: { - name: string; - documents: Array<{ filename: string; resetOnCompletion: boolean }>; - loopEnabled: boolean; - maxLoops?: number | null; - prompt: string; - worktreeSettings?: { - branchNameTemplate: string; - createPROnCompletion: boolean; - prTargetBranch?: string; - }; - } - ) => Promise<{ - success: boolean; - playbook?: { - id: string; - name: string; - createdAt: number; - updatedAt: number; - documents: Array<{ filename: string; resetOnCompletion: boolean }>; - loopEnabled: boolean; - maxLoops?: number | null; - prompt: string; - worktreeSettings?: { - branchNameTemplate: string; - createPROnCompletion: boolean; - prTargetBranch?: string; - }; - }; - error?: string; - }>; - update: ( - sessionId: string, - playbookId: string, - updates: Partial<{ - name: string; - documents: Array<{ filename: string; resetOnCompletion: boolean }>; - loopEnabled: boolean; - maxLoops?: number | null; - prompt: string; - updatedAt: number; - worktreeSettings?: { - branchNameTemplate: string; - createPROnCompletion: boolean; - prTargetBranch?: string; - }; - }> - ) => Promise<{ - success: boolean; - playbook?: { - id: string; - name: string; - createdAt: number; - updatedAt: number; - documents: Array<{ filename: string; resetOnCompletion: boolean }>; - loopEnabled: boolean; - maxLoops?: number | null; - prompt: string; - worktreeSettings?: { - branchNameTemplate: string; - createPROnCompletion: boolean; - prTargetBranch?: string; - }; - }; - error?: string; - }>; - delete: ( - sessionId: string, - playbookId: string - ) => Promise<{ - success: boolean; - error?: string; - }>; - deleteAll: (sessionId: string) => Promise<{ - success: boolean; - error?: string; - }>; - }; - debug: { - createPackage: (options?: { - includeLogs?: boolean; - includeErrors?: boolean; - includeSessions?: boolean; - includeGroupChats?: boolean; - includeBatchState?: boolean; - }) => Promise<{ - success: boolean; - path?: string; - filesIncluded: string[]; - totalSizeBytes: number; - cancelled?: boolean; - error?: string; - }>; - previewPackage: () => Promise<{ - success: boolean; - categories: Array<{ - id: string; - name: string; - included: boolean; - sizeEstimate: string; - }>; - error?: string; - }>; - }; - groupChat: { - // Storage - create: ( - name: string, - moderatorAgentId: string - ) => Promise<{ - id: string; - name: string; - moderatorAgentId: string; - moderatorSessionId: string; - participants: Array<{ - name: string; - agentId: string; - sessionId: string; - addedAt: number; - }>; - logPath: string; - imagesDir: string; - createdAt: number; - }>; - list: () => Promise< - Array<{ - id: string; - name: string; - moderatorAgentId: string; - moderatorSessionId: string; - participants: Array<{ - name: string; - agentId: string; - sessionId: string; - addedAt: number; - }>; - logPath: string; - imagesDir: string; - createdAt: number; - }> - >; - load: (id: string) => Promise<{ - id: string; - name: string; - moderatorAgentId: string; - moderatorSessionId: string; - participants: Array<{ - name: string; - agentId: string; - sessionId: string; - addedAt: number; - }>; - logPath: string; - imagesDir: string; - createdAt: number; - } | null>; - delete: (id: string) => Promise; - rename: ( - id: string, - name: string - ) => Promise<{ - id: string; - name: string; - moderatorAgentId: string; - moderatorSessionId: string; - participants: Array<{ - name: string; - agentId: string; - sessionId: string; - addedAt: number; - }>; - logPath: string; - imagesDir: string; - createdAt: number; - }>; - - // Chat log - appendMessage: (id: string, from: string, content: string) => Promise; - getMessages: (id: string) => Promise< - Array<{ - timestamp: string; - from: string; - content: string; - }> - >; - saveImage: (id: string, imageData: string, filename: string) => Promise; - - // Moderator - startModerator: (id: string) => Promise; - sendToModerator: ( - id: string, - message: string, - images?: string[], - readOnly?: boolean - ) => Promise; - stopModerator: (id: string) => Promise; - getModeratorSessionId: (id: string) => Promise; - - // Participants - addParticipant: ( - id: string, - name: string, - agentId: string, - cwd?: string - ) => Promise<{ - name: string; - agentId: string; - sessionId: string; - addedAt: number; - }>; - sendToParticipant: ( - id: string, - name: string, - message: string, - images?: string[] - ) => Promise; - removeParticipant: (id: string, name: string) => Promise; - resetParticipantContext: ( - id: string, - name: string, - cwd?: string - ) => Promise<{ newAgentSessionId: string }>; - - // History - getHistory: (id: string) => Promise< - Array<{ - id: string; - timestamp: number; - summary: string; - participantName: string; - participantColor: string; - type: 'delegation' | 'response' | 'synthesis' | 'error'; - elapsedTimeMs?: number; - tokenCount?: number; - cost?: number; - fullResponse?: string; - }> - >; - addHistoryEntry: ( - id: string, - entry: { - timestamp: number; - summary: string; - participantName: string; - participantColor: string; - type: 'delegation' | 'response' | 'synthesis' | 'error'; - elapsedTimeMs?: number; - tokenCount?: number; - cost?: number; - fullResponse?: string; - } - ) => Promise<{ - id: string; - timestamp: number; - summary: string; - participantName: string; - participantColor: string; - type: 'delegation' | 'response' | 'synthesis' | 'error'; - elapsedTimeMs?: number; - tokenCount?: number; - cost?: number; - fullResponse?: string; - }>; - deleteHistoryEntry: (groupChatId: string, entryId: string) => Promise; - clearHistory: (id: string) => Promise; - getHistoryFilePath: (id: string) => Promise; - - // Export - getImages: (id: string) => Promise>; - - // Events - onMessage: ( - callback: ( - groupChatId: string, - message: { - timestamp: string; - from: string; - content: string; - } - ) => void - ) => () => void; - onStateChange: ( - callback: ( - groupChatId: string, - state: 'idle' | 'moderator-thinking' | 'agent-working' - ) => void - ) => () => void; - onParticipantsChanged: ( - callback: ( - groupChatId: string, - participants: Array<{ - name: string; - agentId: string; - sessionId: string; - addedAt: number; - }> - ) => void - ) => () => void; - onModeratorUsage: ( - callback: ( - groupChatId: string, - usage: { - contextUsage: number; - totalCost: number; - tokenCount: number; - } - ) => void - ) => () => void; - onHistoryEntry: ( - callback: ( - groupChatId: string, - entry: { - id: string; - timestamp: number; - summary: string; - participantName: string; - participantColor: string; - type: 'delegation' | 'response' | 'synthesis' | 'error'; - elapsedTimeMs?: number; - tokenCount?: number; - cost?: number; - fullResponse?: string; - } - ) => void - ) => () => void; - onParticipantState?: ( - callback: (groupChatId: string, participantName: string, state: 'idle' | 'working') => void - ) => () => void; - onModeratorSessionIdChanged?: ( - callback: (groupChatId: string, sessionId: string) => void - ) => () => void; - }; - leaderboard: { - submit: (data: { - email: string; - displayName: string; - githubUsername?: string; - twitterHandle?: string; - linkedinHandle?: string; - discordUsername?: string; - blueskyHandle?: string; - badgeLevel: number; - badgeName: string; - // Stats fields are optional for profile-only submissions (multi-device safe) - cumulativeTimeMs?: number; - totalRuns?: number; - longestRunMs?: number; - longestRunDate?: string; - currentRunMs?: number; - theme?: string; - clientToken?: string; - authToken?: string; - // Keyboard mastery data (aligned with RunMaestro.ai server schema) - keyboardMasteryLevel?: number; - keyboardMasteryTitle?: string; - keyboardCoveragePercent?: number; - keyboardKeysUnlocked?: number; - keyboardTotalKeys?: number; - // Delta mode for multi-device aggregation - deltaMs?: number; - deltaRuns?: number; - clientTotalTimeMs?: number; - }) => Promise<{ - success: boolean; - message: string; - pendingEmailConfirmation?: boolean; - error?: string; - authTokenRequired?: boolean; - }>; - pollAuthStatus: (clientToken: string) => Promise<{ - status: 'pending' | 'confirmed' | 'expired' | 'error'; - authToken?: string; - message?: string; - error?: string; - }>; - resendConfirmation: (data: { email: string; clientToken: string }) => Promise<{ - success: boolean; - message?: string; - error?: string; - }>; - get: (options?: { limit?: number }) => Promise<{ - success: boolean; - entries?: Array<{ - rank: number; - displayName: string; - githubUsername?: string; - avatarUrl?: string; - badgeLevel: number; - badgeName: string; - cumulativeTimeMs: number; - totalRuns: number; - }>; - error?: string; - }>; - getLongestRuns: (options?: { limit?: number }) => Promise<{ - success: boolean; - entries?: Array<{ - rank: number; - displayName: string; - githubUsername?: string; - avatarUrl?: string; - longestRunMs: number; - runDate: string; - }>; - error?: string; - }>; - }; - speckit: { - getMetadata: () => Promise<{ - success: boolean; - metadata?: { - lastRefreshed: string; - commitSha: string; - sourceVersion: string; - sourceUrl: string; - }; - error?: string; - }>; - getPrompts: () => Promise<{ - success: boolean; - commands?: Array<{ - id: string; - command: string; - description: string; - prompt: string; - isCustom: boolean; - isModified: boolean; - }>; - error?: string; - }>; - getCommand: (slashCommand: string) => Promise<{ - success: boolean; - command?: { - id: string; - command: string; - description: string; - prompt: string; - isCustom: boolean; - isModified: boolean; - }; - error?: string; - }>; - savePrompt: ( - id: string, - content: string - ) => Promise<{ - success: boolean; - error?: string; - }>; - resetPrompt: (id: string) => Promise<{ - success: boolean; - prompt?: string; - error?: string; - }>; - refresh: () => Promise<{ - success: boolean; - metadata?: { - lastRefreshed: string; - commitSha: string; - sourceVersion: string; - sourceUrl: string; - }; - error?: string; - }>; - }; - openspec: { - getMetadata: () => Promise<{ - success: boolean; - metadata?: { - lastRefreshed: string; - commitSha: string; - sourceVersion: string; - sourceUrl: string; - }; - error?: string; - }>; - getPrompts: () => Promise<{ - success: boolean; - commands?: Array<{ - id: string; - command: string; - description: string; - prompt: string; - isCustom: boolean; - isModified: boolean; - }>; - error?: string; - }>; - getCommand: (slashCommand: string) => Promise<{ - success: boolean; - command?: { - id: string; - command: string; - description: string; - prompt: string; - isCustom: boolean; - isModified: boolean; - } | null; - error?: string; - }>; - savePrompt: ( - id: string, - content: string - ) => Promise<{ - success: boolean; - error?: string; - }>; - resetPrompt: (id: string) => Promise<{ - success: boolean; - prompt?: string; - error?: string; - }>; - refresh: () => Promise<{ - success: boolean; - metadata?: { - lastRefreshed: string; - commitSha: string; - sourceVersion: string; - sourceUrl: string; - }; - error?: string; - }>; - }; -} - -declare global { - interface Window { - maestro: MaestroAPI; - } -} diff --git a/src/main/preload/agents.ts b/src/main/preload/agents.ts new file mode 100644 index 00000000..55272fa0 --- /dev/null +++ b/src/main/preload/agents.ts @@ -0,0 +1,189 @@ +/** + * Preload API for agent management + * + * Provides the window.maestro.agents namespace for: + * - Detecting available agents (Claude Code, Codex, OpenCode, etc.) + * - Managing agent configurations and custom paths + * - Getting agent capabilities + * - Discovering slash commands and models + */ + +import { ipcRenderer } from 'electron'; + +/** + * Capability flags that determine what features are available for each agent. + * This is a simplified version for the renderer - full definition in agent-capabilities.ts + */ +export interface AgentCapabilities { + supportsResume: boolean; + supportsReadOnlyMode: boolean; + supportsJsonOutput: boolean; + supportsSessionId: boolean; + supportsImageInput: boolean; + supportsImageInputOnResume: boolean; + supportsSlashCommands: boolean; + supportsSessionStorage: boolean; + supportsCostTracking: boolean; + supportsUsageStats: boolean; + supportsBatchMode: boolean; + requiresPromptToStart: boolean; + supportsStreaming: boolean; + supportsResultMessages: boolean; + supportsModelSelection: boolean; + supportsStreamJsonInput: boolean; +} + +/** + * Agent configuration + */ +export interface AgentConfig { + id: string; + name: string; + command: string; + args?: string[]; + available: boolean; + path?: string; + capabilities?: AgentCapabilities; +} + +/** + * Agent refresh result + */ +export interface AgentRefreshResult { + agents: AgentConfig[]; + debugInfo: unknown; +} + +/** + * Creates the agents API object for preload exposure + */ +export function createAgentsApi() { + return { + /** + * Detect available agents + */ + detect: (sshRemoteId?: string): Promise => + ipcRenderer.invoke('agents:detect', sshRemoteId), + + /** + * Refresh agent detection (optionally for a specific agent) + */ + refresh: (agentId?: string, sshRemoteId?: string): Promise => + ipcRenderer.invoke('agents:refresh', agentId, sshRemoteId), + + /** + * Get a specific agent's configuration + */ + get: (agentId: string): Promise => + ipcRenderer.invoke('agents:get', agentId), + + /** + * Get an agent's capabilities + */ + getCapabilities: (agentId: string): Promise => + ipcRenderer.invoke('agents:getCapabilities', agentId), + + /** + * Get an agent's full configuration + */ + getConfig: (agentId: string): Promise> => + ipcRenderer.invoke('agents:getConfig', agentId), + + /** + * Set an agent's configuration + */ + setConfig: (agentId: string, config: Record): Promise => + ipcRenderer.invoke('agents:setConfig', agentId, config), + + /** + * Get a specific configuration value for an agent + */ + getConfigValue: (agentId: string, key: string): Promise => + ipcRenderer.invoke('agents:getConfigValue', agentId, key), + + /** + * Set a specific configuration value for an agent + */ + setConfigValue: (agentId: string, key: string, value: unknown): Promise => + ipcRenderer.invoke('agents:setConfigValue', agentId, key, value), + + /** + * Set a custom path for an agent + */ + setCustomPath: (agentId: string, customPath: string | null): Promise => + ipcRenderer.invoke('agents:setCustomPath', agentId, customPath), + + /** + * Get the custom path for an agent + */ + getCustomPath: (agentId: string): Promise => + ipcRenderer.invoke('agents:getCustomPath', agentId), + + /** + * Get all custom paths for all agents + */ + getAllCustomPaths: (): Promise> => + ipcRenderer.invoke('agents:getAllCustomPaths'), + + /** + * Set custom CLI arguments that are appended to all agent invocations + */ + setCustomArgs: (agentId: string, customArgs: string | null): Promise => + ipcRenderer.invoke('agents:setCustomArgs', agentId, customArgs), + + /** + * Get custom CLI arguments for an agent + */ + getCustomArgs: (agentId: string): Promise => + ipcRenderer.invoke('agents:getCustomArgs', agentId), + + /** + * Get all custom arguments for all agents + */ + getAllCustomArgs: (): Promise> => + ipcRenderer.invoke('agents:getAllCustomArgs'), + + /** + * Set custom environment variables that are passed to all agent invocations + */ + setCustomEnvVars: ( + agentId: string, + customEnvVars: Record | null + ): Promise => ipcRenderer.invoke('agents:setCustomEnvVars', agentId, customEnvVars), + + /** + * Get custom environment variables for an agent + */ + getCustomEnvVars: (agentId: string): Promise | null> => + ipcRenderer.invoke('agents:getCustomEnvVars', agentId), + + /** + * Get all custom environment variables for all agents + */ + getAllCustomEnvVars: (): Promise>> => + ipcRenderer.invoke('agents:getAllCustomEnvVars'), + + /** + * Discover available models for agents that support model selection + * (e.g., OpenCode with Ollama) + */ + getModels: (agentId: string, forceRefresh?: boolean): Promise => + ipcRenderer.invoke('agents:getModels', agentId, forceRefresh), + + /** + * Discover available slash commands for an agent by spawning it briefly + * Returns array of command names (e.g., ['compact', 'help', 'my-custom-command']) + */ + discoverSlashCommands: ( + agentId: string, + cwd: string, + customPath?: string + ): Promise => + ipcRenderer.invoke('agents:discoverSlashCommands', agentId, cwd, customPath), + }; +} + +/** + * TypeScript type for the agents API + */ +export type AgentsApi = ReturnType; diff --git a/src/main/preload/attachments.ts b/src/main/preload/attachments.ts new file mode 100644 index 00000000..b8c1d841 --- /dev/null +++ b/src/main/preload/attachments.ts @@ -0,0 +1,96 @@ +/** + * Preload API for attachments + * + * Provides the window.maestro.attachments namespace for: + * - Per-session image storage for scratchpad + * - Saving, loading, deleting attachments + * - Listing attachments for a session + */ + +import { ipcRenderer } from 'electron'; + +/** + * Response from attachment operations + */ +export interface AttachmentResponse { + success: boolean; + error?: string; +} + +/** + * Response from loading an attachment + */ +export interface AttachmentLoadResponse { + success: boolean; + data?: string; + error?: string; +} + +/** + * Response from listing attachments + */ +export interface AttachmentListResponse { + success: boolean; + files?: string[]; + error?: string; +} + +/** + * Response from getting attachment path + */ +export interface AttachmentPathResponse { + success: boolean; + path?: string; + error?: string; +} + +/** + * Creates the attachments API object for preload exposure + */ +export function createAttachmentsApi() { + return { + /** + * Save an attachment for a session + * @param sessionId - Session ID + * @param base64Data - Base64 encoded file data + * @param filename - Filename to save as + */ + save: (sessionId: string, base64Data: string, filename: string): Promise => + ipcRenderer.invoke('attachments:save', sessionId, base64Data, filename), + + /** + * Load an attachment for a session + * @param sessionId - Session ID + * @param filename - Filename to load + */ + load: (sessionId: string, filename: string): Promise => + ipcRenderer.invoke('attachments:load', sessionId, filename), + + /** + * Delete an attachment for a session + * @param sessionId - Session ID + * @param filename - Filename to delete + */ + delete: (sessionId: string, filename: string): Promise => + ipcRenderer.invoke('attachments:delete', sessionId, filename), + + /** + * List all attachments for a session + * @param sessionId - Session ID + */ + list: (sessionId: string): Promise => + ipcRenderer.invoke('attachments:list', sessionId), + + /** + * Get the filesystem path for session attachments + * @param sessionId - Session ID + */ + getPath: (sessionId: string): Promise => + ipcRenderer.invoke('attachments:getPath', sessionId), + }; +} + +/** + * TypeScript type for the attachments API + */ +export type AttachmentsApi = ReturnType; diff --git a/src/main/preload/autorun.ts b/src/main/preload/autorun.ts new file mode 100644 index 00000000..6bab259c --- /dev/null +++ b/src/main/preload/autorun.ts @@ -0,0 +1,174 @@ +/** + * Preload API for Auto Run operations + * + * Provides the window.maestro.autorun, playbooks, and marketplace namespaces for: + * - Auto Run document management + * - Playbook CRUD operations + * - Marketplace playbook browsing and importing + */ + +import { ipcRenderer } from 'electron'; + +/** + * Playbook document configuration + */ +export interface PlaybookDocument { + filename: string; + resetOnCompletion: boolean; +} + +/** + * Worktree settings for playbook + */ +export interface WorktreeSettings { + branchNameTemplate: string; + createPROnCompletion: boolean; + prTargetBranch?: string; +} + +/** + * Playbook definition + */ +export interface Playbook { + name: string; + documents: PlaybookDocument[]; + loopEnabled: boolean; + maxLoops?: number | null; + prompt: string; + worktreeSettings?: WorktreeSettings; +} + +/** + * Creates the Auto Run API object for preload exposure + */ +export function createAutorunApi() { + return { + listDocs: (folderPath: string, sshRemoteId?: string) => + ipcRenderer.invoke('autorun:listDocs', folderPath, sshRemoteId), + + hasDocuments: (folderPath: string): Promise<{ hasDocuments: boolean }> => + ipcRenderer.invoke('autorun:hasDocuments', folderPath), + + readDoc: (folderPath: string, filename: string, sshRemoteId?: string) => + ipcRenderer.invoke('autorun:readDoc', folderPath, filename, sshRemoteId), + + writeDoc: (folderPath: string, filename: string, content: string, sshRemoteId?: string) => + ipcRenderer.invoke('autorun:writeDoc', folderPath, filename, content, sshRemoteId), + + saveImage: (folderPath: string, docName: string, base64Data: string, extension: string) => + ipcRenderer.invoke('autorun:saveImage', folderPath, docName, base64Data, extension), + + deleteImage: (folderPath: string, relativePath: string) => + ipcRenderer.invoke('autorun:deleteImage', folderPath, relativePath), + + listImages: (folderPath: string, docName: string) => + ipcRenderer.invoke('autorun:listImages', folderPath, docName), + + deleteFolder: (projectPath: string) => ipcRenderer.invoke('autorun:deleteFolder', projectPath), + + watchFolder: ( + folderPath: string, + sshRemoteId?: string + ): Promise<{ isRemote?: boolean; message?: string }> => + ipcRenderer.invoke('autorun:watchFolder', folderPath, sshRemoteId), + + unwatchFolder: (folderPath: string) => ipcRenderer.invoke('autorun:unwatchFolder', folderPath), + + onFileChanged: ( + handler: (data: { folderPath: string; filename: string; eventType: string }) => void + ) => { + const wrappedHandler = ( + _event: Electron.IpcRendererEvent, + data: { folderPath: string; filename: string; eventType: string } + ) => handler(data); + ipcRenderer.on('autorun:fileChanged', wrappedHandler); + return () => ipcRenderer.removeListener('autorun:fileChanged', wrappedHandler); + }, + + createBackup: (folderPath: string, filename: string) => + ipcRenderer.invoke('autorun:createBackup', folderPath, filename), + + restoreBackup: (folderPath: string, filename: string) => + ipcRenderer.invoke('autorun:restoreBackup', folderPath, filename), + + deleteBackups: (folderPath: string) => ipcRenderer.invoke('autorun:deleteBackups', folderPath), + + createWorkingCopy: ( + folderPath: string, + filename: string, + loopNumber: number + ): Promise<{ workingCopyPath: string; originalPath: string }> => + ipcRenderer.invoke('autorun:createWorkingCopy', folderPath, filename, loopNumber), + }; +} + +/** + * Creates the Playbooks API object for preload exposure + */ +export function createPlaybooksApi() { + return { + list: (sessionId: string) => ipcRenderer.invoke('playbooks:list', sessionId), + + create: (sessionId: string, playbook: Playbook) => + ipcRenderer.invoke('playbooks:create', sessionId, playbook), + + update: ( + sessionId: string, + playbookId: string, + updates: Partial + ) => ipcRenderer.invoke('playbooks:update', sessionId, playbookId, updates), + + delete: (sessionId: string, playbookId: string) => + ipcRenderer.invoke('playbooks:delete', sessionId, playbookId), + + deleteAll: (sessionId: string) => ipcRenderer.invoke('playbooks:deleteAll', sessionId), + + export: (sessionId: string, playbookId: string, autoRunFolderPath: string) => + ipcRenderer.invoke('playbooks:export', sessionId, playbookId, autoRunFolderPath), + + import: (sessionId: string, autoRunFolderPath: string) => + ipcRenderer.invoke('playbooks:import', sessionId, autoRunFolderPath), + }; +} + +/** + * Creates the Marketplace API object for preload exposure + */ +export function createMarketplaceApi() { + return { + getManifest: () => ipcRenderer.invoke('marketplace:getManifest'), + + refreshManifest: () => ipcRenderer.invoke('marketplace:refreshManifest'), + + getDocument: (playbookPath: string, filename: string) => + ipcRenderer.invoke('marketplace:getDocument', playbookPath, filename), + + getReadme: (playbookPath: string) => ipcRenderer.invoke('marketplace:getReadme', playbookPath), + + importPlaybook: ( + playbookId: string, + targetFolderName: string, + autoRunFolderPath: string, + sessionId: string, + sshRemoteId?: string + ) => + ipcRenderer.invoke( + 'marketplace:importPlaybook', + playbookId, + targetFolderName, + autoRunFolderPath, + sessionId, + sshRemoteId + ), + + onManifestChanged: (handler: () => void) => { + const wrappedHandler = () => handler(); + ipcRenderer.on('marketplace:manifestChanged', wrappedHandler); + return () => ipcRenderer.removeListener('marketplace:manifestChanged', wrappedHandler); + }, + }; +} + +export type AutorunApi = ReturnType; +export type PlaybooksApi = ReturnType; +export type MarketplaceApi = ReturnType; diff --git a/src/main/preload/commands.ts b/src/main/preload/commands.ts new file mode 100644 index 00000000..03be4f47 --- /dev/null +++ b/src/main/preload/commands.ts @@ -0,0 +1,132 @@ +/** + * Preload API for slash commands + * + * Provides the window.maestro.speckit and window.maestro.openspec namespaces for: + * - Spec-Kit slash commands + * - OpenSpec slash commands + */ + +import { ipcRenderer } from 'electron'; + +/** + * Command metadata + */ +export interface CommandMetadata { + lastRefreshed: string; + commitSha: string; + sourceVersion: string; + sourceUrl: string; +} + +/** + * Command definition + */ +export interface CommandDefinition { + id: string; + command: string; + description: string; + prompt: string; + isCustom: boolean; + isModified: boolean; +} + +/** + * Creates the Spec-Kit API object for preload exposure + */ +export function createSpeckitApi() { + return { + getMetadata: (): Promise<{ + success: boolean; + metadata?: CommandMetadata; + error?: string; + }> => ipcRenderer.invoke('speckit:getMetadata'), + + getPrompts: (): Promise<{ + success: boolean; + commands?: CommandDefinition[]; + error?: string; + }> => ipcRenderer.invoke('speckit:getPrompts'), + + getCommand: ( + slashCommand: string + ): Promise<{ + success: boolean; + command?: CommandDefinition | null; + error?: string; + }> => ipcRenderer.invoke('speckit:getCommand', slashCommand), + + savePrompt: ( + id: string, + content: string + ): Promise<{ + success: boolean; + error?: string; + }> => ipcRenderer.invoke('speckit:savePrompt', id, content), + + resetPrompt: ( + id: string + ): Promise<{ + success: boolean; + prompt?: string; + error?: string; + }> => ipcRenderer.invoke('speckit:resetPrompt', id), + + refresh: (): Promise<{ + success: boolean; + metadata?: CommandMetadata; + error?: string; + }> => ipcRenderer.invoke('speckit:refresh'), + }; +} + +/** + * Creates the OpenSpec API object for preload exposure + */ +export function createOpenspecApi() { + return { + getMetadata: (): Promise<{ + success: boolean; + metadata?: CommandMetadata; + error?: string; + }> => ipcRenderer.invoke('openspec:getMetadata'), + + getPrompts: (): Promise<{ + success: boolean; + commands?: CommandDefinition[]; + error?: string; + }> => ipcRenderer.invoke('openspec:getPrompts'), + + getCommand: ( + slashCommand: string + ): Promise<{ + success: boolean; + command?: CommandDefinition | null; + error?: string; + }> => ipcRenderer.invoke('openspec:getCommand', slashCommand), + + savePrompt: ( + id: string, + content: string + ): Promise<{ + success: boolean; + error?: string; + }> => ipcRenderer.invoke('openspec:savePrompt', id, content), + + resetPrompt: ( + id: string + ): Promise<{ + success: boolean; + prompt?: string; + error?: string; + }> => ipcRenderer.invoke('openspec:resetPrompt', id), + + refresh: (): Promise<{ + success: boolean; + metadata?: CommandMetadata; + error?: string; + }> => ipcRenderer.invoke('openspec:refresh'), + }; +} + +export type SpeckitApi = ReturnType; +export type OpenspecApi = ReturnType; diff --git a/src/main/preload/context.ts b/src/main/preload/context.ts new file mode 100644 index 00000000..288ac8d9 --- /dev/null +++ b/src/main/preload/context.ts @@ -0,0 +1,66 @@ +/** + * Preload API for context operations + * + * Provides the window.maestro.context namespace for: + * - Session context transfer and grooming + * - Context retrieval from stored sessions + */ + +import { ipcRenderer } from 'electron'; + +/** + * Message structure from stored sessions + */ +export interface StoredMessage { + type: string; + role?: string; + content: string; + timestamp: string; + uuid: string; + toolUse?: unknown; +} + +/** + * Response from getStoredSession + */ +export interface StoredSessionResponse { + messages: StoredMessage[]; + total: number; + hasMore: boolean; +} + +/** + * Creates the context API object for preload exposure + */ +export function createContextApi() { + return { + // Get context from a stored agent session + getStoredSession: ( + agentId: string, + projectRoot: string, + sessionId: string + ): Promise => + ipcRenderer.invoke('context:getStoredSession', agentId, projectRoot, sessionId), + + // Single-call grooming (recommended) - spawns batch process and returns response + groomContext: (projectRoot: string, agentType: string, prompt: string): Promise => + ipcRenderer.invoke('context:groomContext', projectRoot, agentType, prompt), + + // Cancel all active grooming sessions + cancelGrooming: (): Promise => ipcRenderer.invoke('context:cancelGrooming'), + + // DEPRECATED: Create a temporary session for context grooming + createGroomingSession: (projectRoot: string, agentType: string): Promise => + ipcRenderer.invoke('context:createGroomingSession', projectRoot, agentType), + + // DEPRECATED: Send grooming prompt to a session and get response + sendGroomingPrompt: (sessionId: string, prompt: string): Promise => + ipcRenderer.invoke('context:sendGroomingPrompt', sessionId, prompt), + + // Clean up a temporary grooming session + cleanupGroomingSession: (sessionId: string): Promise => + ipcRenderer.invoke('context:cleanupGroomingSession', sessionId), + }; +} + +export type ContextApi = ReturnType; diff --git a/src/main/preload/debug.ts b/src/main/preload/debug.ts new file mode 100644 index 00000000..ab6e4ec0 --- /dev/null +++ b/src/main/preload/debug.ts @@ -0,0 +1,66 @@ +/** + * Preload API for debug and document graph operations + * + * Provides the window.maestro.debug and window.maestro.documentGraph namespaces for: + * - Debug package generation + * - Document graph file watching + */ + +import { ipcRenderer } from 'electron'; + +/** + * Debug package options + */ +export interface DebugPackageOptions { + includeLogs?: boolean; + includeErrors?: boolean; + includeSessions?: boolean; + includeGroupChats?: boolean; + includeBatchState?: boolean; +} + +/** + * Document graph file change event + */ +export interface DocumentGraphChange { + filePath: string; + eventType: 'add' | 'change' | 'unlink'; +} + +/** + * Creates the Debug API object for preload exposure + */ +export function createDebugApi() { + return { + createPackage: (options?: DebugPackageOptions) => + ipcRenderer.invoke('debug:createPackage', options), + + previewPackage: () => ipcRenderer.invoke('debug:previewPackage'), + }; +} + +/** + * Creates the Document Graph API object for preload exposure + */ +export function createDocumentGraphApi() { + return { + watchFolder: (rootPath: string) => ipcRenderer.invoke('documentGraph:watchFolder', rootPath), + + unwatchFolder: (rootPath: string) => + ipcRenderer.invoke('documentGraph:unwatchFolder', rootPath), + + onFilesChanged: ( + handler: (data: { rootPath: string; changes: DocumentGraphChange[] }) => void + ) => { + const wrappedHandler = ( + _event: Electron.IpcRendererEvent, + data: { rootPath: string; changes: DocumentGraphChange[] } + ) => handler(data); + ipcRenderer.on('documentGraph:filesChanged', wrappedHandler); + return () => ipcRenderer.removeListener('documentGraph:filesChanged', wrappedHandler); + }, + }; +} + +export type DebugApi = ReturnType; +export type DocumentGraphApi = ReturnType; diff --git a/src/main/preload/files.ts b/src/main/preload/files.ts new file mode 100644 index 00000000..1206cc18 --- /dev/null +++ b/src/main/preload/files.ts @@ -0,0 +1,108 @@ +/** + * Preload API for file operations + * + * Provides the window.maestro.tempfile, history, and cli namespaces for: + * - Temporary file operations + * - History persistence + * - CLI activity monitoring + */ + +import { ipcRenderer } from 'electron'; + +/** + * History entry + */ +export interface HistoryEntry { + id: string; + type: 'AUTO' | 'USER'; + timestamp: number; + summary: string; + fullResponse?: string; + agentSessionId?: string; + projectPath: string; + sessionId?: string; + sessionName?: string; + contextUsage?: number; + usageStats?: { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + totalCostUsd: number; + contextWindow: number; + }; + success?: boolean; + elapsedTimeMs?: number; + validated?: boolean; +} + +/** + * Creates the tempfile API object for preload exposure + */ +export function createTempfileApi() { + return { + write: (content: string, filename?: string) => + ipcRenderer.invoke('tempfile:write', content, filename), + read: (filePath: string) => ipcRenderer.invoke('tempfile:read', filePath), + delete: (filePath: string) => ipcRenderer.invoke('tempfile:delete', filePath), + }; +} + +/** + * Creates the history API object for preload exposure + */ +export function createHistoryApi() { + return { + getAll: (projectPath?: string, sessionId?: string) => + ipcRenderer.invoke('history:getAll', projectPath, sessionId), + + getAllPaginated: (options?: { + projectPath?: string; + sessionId?: string; + pagination?: { limit?: number; offset?: number }; + }) => ipcRenderer.invoke('history:getAllPaginated', options), + + add: (entry: HistoryEntry) => ipcRenderer.invoke('history:add', entry), + + clear: (projectPath?: string) => ipcRenderer.invoke('history:clear', projectPath), + + delete: (entryId: string, sessionId?: string) => + ipcRenderer.invoke('history:delete', entryId, sessionId), + + update: (entryId: string, updates: { validated?: boolean }, sessionId?: string) => + ipcRenderer.invoke('history:update', entryId, updates, sessionId), + + updateSessionName: (agentSessionId: string, sessionName: string) => + ipcRenderer.invoke('history:updateSessionName', agentSessionId, sessionName), + + getFilePath: (sessionId: string) => ipcRenderer.invoke('history:getFilePath', sessionId), + + listSessions: () => ipcRenderer.invoke('history:listSessions'), + + onExternalChange: (handler: () => void) => { + const wrappedHandler = () => handler(); + ipcRenderer.on('history:externalChange', wrappedHandler); + return () => ipcRenderer.removeListener('history:externalChange', wrappedHandler); + }, + + reload: () => ipcRenderer.invoke('history:reload'), + }; +} + +/** + * Creates the CLI activity API object for preload exposure + */ +export function createCliApi() { + return { + getActivity: () => ipcRenderer.invoke('cli:getActivity'), + onActivityChange: (handler: () => void) => { + const wrappedHandler = () => handler(); + ipcRenderer.on('cli:activityChange', wrappedHandler); + return () => ipcRenderer.removeListener('cli:activityChange', wrappedHandler); + }, + }; +} + +export type TempfileApi = ReturnType; +export type HistoryApi = ReturnType; +export type CliApi = ReturnType; diff --git a/src/main/preload/fs.ts b/src/main/preload/fs.ts new file mode 100644 index 00000000..0c9e1e47 --- /dev/null +++ b/src/main/preload/fs.ts @@ -0,0 +1,125 @@ +/** + * Preload API for filesystem operations + * + * Provides the window.maestro.fs namespace for: + * - Reading directories and files + * - File stats and sizes + * - Writing, renaming, and deleting files + * - SSH remote support for all operations + */ + +import { ipcRenderer } from 'electron'; + +/** + * Directory entry information + */ +export interface DirectoryEntry { + name: string; + isDirectory: boolean; + path: string; +} + +/** + * File stat information + */ +export interface FileStat { + size: number; + createdAt: string; + modifiedAt: string; + isDirectory: boolean; + isFile: boolean; +} + +/** + * Directory size information + */ +export interface DirectorySizeInfo { + totalSize: number; + fileCount: number; + folderCount: number; +} + +/** + * Item count information + */ +export interface ItemCountInfo { + fileCount: number; + folderCount: number; +} + +/** + * Creates the filesystem API object for preload exposure + */ +export function createFsApi() { + return { + /** + * Get the user's home directory + */ + homeDir: (): Promise => ipcRenderer.invoke('fs:homeDir'), + + /** + * Read directory contents + */ + readDir: (dirPath: string, sshRemoteId?: string): Promise => + ipcRenderer.invoke('fs:readDir', dirPath, sshRemoteId), + + /** + * Read file contents + */ + readFile: (filePath: string, sshRemoteId?: string): Promise => + ipcRenderer.invoke('fs:readFile', filePath, sshRemoteId), + + /** + * Write file contents + */ + writeFile: (filePath: string, content: string): Promise<{ success: boolean }> => + ipcRenderer.invoke('fs:writeFile', filePath, content), + + /** + * Get file/directory stats + */ + stat: (filePath: string, sshRemoteId?: string): Promise => + ipcRenderer.invoke('fs:stat', filePath, sshRemoteId), + + /** + * Get directory size information + */ + directorySize: (dirPath: string, sshRemoteId?: string): Promise => + ipcRenderer.invoke('fs:directorySize', dirPath, sshRemoteId), + + /** + * Fetch an image from URL and return as base64 + */ + fetchImageAsBase64: (url: string): Promise => + ipcRenderer.invoke('fs:fetchImageAsBase64', url), + + /** + * Rename a file or directory + */ + rename: ( + oldPath: string, + newPath: string, + sshRemoteId?: string + ): Promise<{ success: boolean }> => + ipcRenderer.invoke('fs:rename', oldPath, newPath, sshRemoteId), + + /** + * Delete a file or directory + */ + delete: ( + targetPath: string, + options?: { recursive?: boolean; sshRemoteId?: string } + ): Promise<{ success: boolean }> => ipcRenderer.invoke('fs:delete', targetPath, options), + + /** + * Count files and folders in a directory + */ + countItems: (dirPath: string, sshRemoteId?: string): Promise => + ipcRenderer.invoke('fs:countItems', dirPath, sshRemoteId), + }; +} + +/** + * TypeScript type for the filesystem API + */ +export type FsApi = ReturnType; diff --git a/src/main/preload/git.ts b/src/main/preload/git.ts new file mode 100644 index 00000000..ee792ced --- /dev/null +++ b/src/main/preload/git.ts @@ -0,0 +1,359 @@ +/** + * Preload API for git operations + * + * Provides the window.maestro.git namespace for: + * - Git status, diff, branch operations + * - Git log and commit viewing + * - Git worktree operations for Auto Run parallelization + * - GitHub CLI integration (PR creation, gists) + * - SSH remote support for all operations + */ + +import { ipcRenderer } from 'electron'; + +/** + * Git worktree information + */ +export interface WorktreeInfo { + success: boolean; + exists?: boolean; + isWorktree?: boolean; + currentBranch?: string; + repoRoot?: string; + error?: string; +} + +/** + * Git worktree list entry + */ +export interface WorktreeEntry { + path: string; + head: string; + branch: string | null; + isBare: boolean; +} + +/** + * Git subdirectory scan result + */ +export interface GitSubdirEntry { + path: string; + name: string; + isWorktree: boolean; + branch: string | null; + repoRoot: string | null; +} + +/** + * Git log entry + */ +export interface GitLogEntry { + hash: string; + shortHash: string; + author: string; + date: string; + refs: string[]; + subject: string; +} + +/** + * Discovered worktree event data + */ +export interface WorktreeDiscoveredData { + sessionId: string; + worktree: { + path: string; + name: string; + branch: string | null; + }; +} + +/** + * Creates the git API object for preload exposure + */ +export function createGitApi() { + return { + /** + * Get git status for a repository + */ + status: (cwd: string, sshRemoteId?: string, remoteCwd?: string): Promise => + ipcRenderer.invoke('git:status', cwd, sshRemoteId, remoteCwd), + + /** + * Get git diff for a repository or specific file + */ + diff: (cwd: string, file?: string, sshRemoteId?: string, remoteCwd?: string): Promise => + ipcRenderer.invoke('git:diff', cwd, file, sshRemoteId, remoteCwd), + + /** + * Check if a directory is a git repository + */ + isRepo: (cwd: string, sshRemoteId?: string, remoteCwd?: string): Promise => + ipcRenderer.invoke('git:isRepo', cwd, sshRemoteId, remoteCwd), + + /** + * Get git diff numstat + */ + numstat: ( + cwd: string, + sshRemoteId?: string, + remoteCwd?: string + ): Promise<{ stdout: string; stderr: string }> => + ipcRenderer.invoke('git:numstat', cwd, sshRemoteId, remoteCwd), + + /** + * Get current branch name + */ + branch: ( + cwd: string, + sshRemoteId?: string, + remoteCwd?: string + ): Promise<{ stdout: string; stderr: string }> => + ipcRenderer.invoke('git:branch', cwd, sshRemoteId, remoteCwd), + + /** + * Get list of all branches + */ + branches: ( + cwd: string, + sshRemoteId?: string, + remoteCwd?: string + ): Promise<{ stdout: string; stderr: string }> => + ipcRenderer.invoke('git:branches', cwd, sshRemoteId, remoteCwd), + + /** + * Get list of tags + */ + tags: ( + cwd: string, + sshRemoteId?: string, + remoteCwd?: string + ): Promise<{ stdout: string; stderr: string }> => + ipcRenderer.invoke('git:tags', cwd, sshRemoteId, remoteCwd), + + /** + * Get remote URL + */ + remote: ( + cwd: string, + sshRemoteId?: string, + remoteCwd?: string + ): Promise<{ stdout: string; stderr: string }> => + ipcRenderer.invoke('git:remote', cwd, sshRemoteId, remoteCwd), + + /** + * Get comprehensive git info (branch, remote, ahead/behind, changes) + */ + info: ( + cwd: string, + sshRemoteId?: string, + remoteCwd?: string + ): Promise<{ + branch: string; + remote: string; + behind: number; + ahead: number; + uncommittedChanges: number; + }> => ipcRenderer.invoke('git:info', cwd, sshRemoteId, remoteCwd), + + /** + * Get git log with optional limit and search + */ + log: ( + cwd: string, + options?: { limit?: number; search?: string } + ): Promise<{ + entries: GitLogEntry[]; + error: string | null; + }> => ipcRenderer.invoke('git:log', cwd, options), + + /** + * Get commit count + */ + commitCount: (cwd: string): Promise<{ count: number; error: string | null }> => + ipcRenderer.invoke('git:commitCount', cwd), + + /** + * Show a specific commit + */ + show: (cwd: string, hash: string): Promise<{ stdout: string; stderr: string }> => + ipcRenderer.invoke('git:show', cwd, hash), + + /** + * Show file content at a specific ref + */ + showFile: ( + cwd: string, + ref: string, + filePath: string + ): Promise<{ content?: string; error?: string }> => + ipcRenderer.invoke('git:showFile', cwd, ref, filePath), + + // Git worktree operations for Auto Run parallelization + // All worktree operations support SSH remote execution via optional sshRemoteId parameter + + /** + * Get worktree information + */ + worktreeInfo: (worktreePath: string, sshRemoteId?: string): Promise => + ipcRenderer.invoke('git:worktreeInfo', worktreePath, sshRemoteId), + + /** + * Get the root of a git repository + */ + getRepoRoot: ( + cwd: string, + sshRemoteId?: string + ): Promise<{ success: boolean; root?: string; error?: string }> => + ipcRenderer.invoke('git:getRepoRoot', cwd, sshRemoteId), + + /** + * Setup a worktree (create if needed) + */ + worktreeSetup: ( + mainRepoCwd: string, + worktreePath: string, + branchName: string, + sshRemoteId?: string + ): Promise<{ + success: boolean; + created?: boolean; + currentBranch?: string; + requestedBranch?: string; + branchMismatch?: boolean; + error?: string; + }> => + ipcRenderer.invoke('git:worktreeSetup', mainRepoCwd, worktreePath, branchName, sshRemoteId), + + /** + * Checkout a branch in a worktree + */ + worktreeCheckout: ( + worktreePath: string, + branchName: string, + createIfMissing: boolean, + sshRemoteId?: string + ): Promise<{ + success: boolean; + hasUncommittedChanges: boolean; + error?: string; + }> => + ipcRenderer.invoke( + 'git:worktreeCheckout', + worktreePath, + branchName, + createIfMissing, + sshRemoteId + ), + + /** + * Create a GitHub PR + */ + createPR: ( + worktreePath: string, + baseBranch: string, + title: string, + body: string, + ghPath?: string + ): Promise<{ + success: boolean; + prUrl?: string; + error?: string; + }> => ipcRenderer.invoke('git:createPR', worktreePath, baseBranch, title, body, ghPath), + + /** + * Get the default branch of a repository + */ + getDefaultBranch: ( + cwd: string + ): Promise<{ success: boolean; branch?: string; error?: string }> => + ipcRenderer.invoke('git:getDefaultBranch', cwd), + + /** + * Check if GitHub CLI is installed and authenticated + */ + checkGhCli: (ghPath?: string): Promise<{ installed: boolean; authenticated: boolean }> => + ipcRenderer.invoke('git:checkGhCli', ghPath), + + /** + * Create a GitHub Gist from file content + */ + createGist: ( + filename: string, + content: string, + description: string, + isPublic: boolean, + ghPath?: string + ): Promise<{ + success: boolean; + gistUrl?: string; + error?: string; + }> => ipcRenderer.invoke('git:createGist', filename, content, description, isPublic, ghPath), + + /** + * List all worktrees for a git repository + * Supports SSH remote execution via optional sshRemoteId parameter + */ + listWorktrees: (cwd: string, sshRemoteId?: string): Promise<{ worktrees: WorktreeEntry[] }> => + ipcRenderer.invoke('git:listWorktrees', cwd, sshRemoteId), + + /** + * Scan a directory for subdirectories that are git repositories or worktrees + * Supports SSH remote execution via optional sshRemoteId parameter + */ + scanWorktreeDirectory: ( + parentPath: string, + sshRemoteId?: string + ): Promise<{ gitSubdirs: GitSubdirEntry[] }> => + ipcRenderer.invoke('git:scanWorktreeDirectory', parentPath, sshRemoteId), + + /** + * Watch a worktree directory for new worktrees + * Note: File watching is not available for SSH remote sessions. + * For remote sessions, returns isRemote: true indicating polling should be used instead. + */ + watchWorktreeDirectory: ( + sessionId: string, + worktreePath: string, + sshRemoteId?: string + ): Promise<{ + success: boolean; + error?: string; + isRemote?: boolean; + message?: string; + }> => ipcRenderer.invoke('git:watchWorktreeDirectory', sessionId, worktreePath, sshRemoteId), + + /** + * Stop watching a worktree directory + */ + unwatchWorktreeDirectory: (sessionId: string): Promise<{ success: boolean }> => + ipcRenderer.invoke('git:unwatchWorktreeDirectory', sessionId), + + /** + * Remove a worktree directory from disk + */ + removeWorktree: ( + worktreePath: string, + force?: boolean + ): Promise<{ + success: boolean; + error?: string; + hasUncommittedChanges?: boolean; + }> => ipcRenderer.invoke('git:removeWorktree', worktreePath, force), + + /** + * Subscribe to discovered worktrees + */ + onWorktreeDiscovered: (callback: (data: WorktreeDiscoveredData) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, data: WorktreeDiscoveredData) => + callback(data); + ipcRenderer.on('worktree:discovered', handler); + return () => ipcRenderer.removeListener('worktree:discovered', handler); + }, + }; +} + +/** + * TypeScript type for the git API + */ +export type GitApi = ReturnType; diff --git a/src/main/preload/groupChat.ts b/src/main/preload/groupChat.ts new file mode 100644 index 00000000..b5fdd63d --- /dev/null +++ b/src/main/preload/groupChat.ts @@ -0,0 +1,213 @@ +/** + * Preload API for group chat operations + * + * Provides the window.maestro.groupChat namespace for: + * - Group chat creation and management + * - Moderator and participant control + * - Chat history and messages + */ + +import { ipcRenderer } from 'electron'; + +/** + * Moderator configuration + */ +export interface ModeratorConfig { + customPath?: string; + customArgs?: string; + customEnvVars?: Record; +} + +/** + * Participant definition + */ +export interface Participant { + name: string; + agentId: string; + sessionId: string; + addedAt: number; +} + +/** + * Chat message + */ +export interface ChatMessage { + timestamp: string; + from: string; + content: string; +} + +/** + * History entry + */ +export interface GroupChatHistoryEntry { + id: string; + timestamp: number; + summary: string; + participantName: string; + participantColor: string; + type: 'delegation' | 'response' | 'synthesis' | 'error'; + elapsedTimeMs?: number; + tokenCount?: number; + cost?: number; + fullResponse?: string; +} + +/** + * Moderator usage stats + */ +export interface ModeratorUsage { + contextUsage: number; + totalCost: number; + tokenCount: number; +} + +/** + * Creates the Group Chat API object for preload exposure + */ +export function createGroupChatApi() { + return { + // Storage + create: (name: string, moderatorAgentId: string, moderatorConfig?: ModeratorConfig) => + ipcRenderer.invoke('groupChat:create', name, moderatorAgentId, moderatorConfig), + + list: () => ipcRenderer.invoke('groupChat:list'), + + load: (id: string) => ipcRenderer.invoke('groupChat:load', id), + + delete: (id: string) => ipcRenderer.invoke('groupChat:delete', id), + + rename: (id: string, name: string) => ipcRenderer.invoke('groupChat:rename', id, name), + + update: ( + id: string, + updates: { + name?: string; + moderatorAgentId?: string; + moderatorConfig?: ModeratorConfig; + } + ) => ipcRenderer.invoke('groupChat:update', id, updates), + + // Chat log + appendMessage: (id: string, from: string, content: string) => + ipcRenderer.invoke('groupChat:appendMessage', id, from, content), + + getMessages: (id: string) => ipcRenderer.invoke('groupChat:getMessages', id), + + saveImage: (id: string, imageData: string, filename: string) => + ipcRenderer.invoke('groupChat:saveImage', id, imageData, filename), + + // Moderator + startModerator: (id: string) => ipcRenderer.invoke('groupChat:startModerator', id), + + sendToModerator: (id: string, message: string, images?: string[], readOnly?: boolean) => + ipcRenderer.invoke('groupChat:sendToModerator', id, message, images, readOnly), + + stopModerator: (id: string) => ipcRenderer.invoke('groupChat:stopModerator', id), + + getModeratorSessionId: (id: string) => + ipcRenderer.invoke('groupChat:getModeratorSessionId', id), + + // Participants + addParticipant: (id: string, name: string, agentId: string, cwd?: string) => + ipcRenderer.invoke('groupChat:addParticipant', id, name, agentId, cwd), + + sendToParticipant: (id: string, name: string, message: string, images?: string[]) => + ipcRenderer.invoke('groupChat:sendToParticipant', id, name, message, images), + + removeParticipant: (id: string, name: string) => + ipcRenderer.invoke('groupChat:removeParticipant', id, name), + + resetParticipantContext: ( + id: string, + name: string, + cwd?: string + ): Promise<{ newAgentSessionId: string }> => + ipcRenderer.invoke('groupChat:resetParticipantContext', id, name, cwd), + + // History + getHistory: (id: string) => ipcRenderer.invoke('groupChat:getHistory', id), + + addHistoryEntry: (id: string, entry: Omit) => + ipcRenderer.invoke('groupChat:addHistoryEntry', id, entry), + + deleteHistoryEntry: (groupChatId: string, entryId: string) => + ipcRenderer.invoke('groupChat:deleteHistoryEntry', groupChatId, entryId), + + clearHistory: (id: string) => ipcRenderer.invoke('groupChat:clearHistory', id), + + getHistoryFilePath: (id: string) => ipcRenderer.invoke('groupChat:getHistoryFilePath', id), + + // Export + getImages: (id: string): Promise> => + ipcRenderer.invoke('groupChat:getImages', id), + + // Events + onMessage: (callback: (groupChatId: string, message: ChatMessage) => void) => { + const handler = (_: any, groupChatId: string, message: ChatMessage) => + callback(groupChatId, message); + ipcRenderer.on('groupChat:message', handler); + return () => ipcRenderer.removeListener('groupChat:message', handler); + }, + + onStateChange: ( + callback: ( + groupChatId: string, + state: 'idle' | 'moderator-thinking' | 'agent-working' + ) => void + ) => { + const handler = ( + _: any, + groupChatId: string, + state: 'idle' | 'moderator-thinking' | 'agent-working' + ) => callback(groupChatId, state); + ipcRenderer.on('groupChat:stateChange', handler); + return () => ipcRenderer.removeListener('groupChat:stateChange', handler); + }, + + onParticipantsChanged: ( + callback: (groupChatId: string, participants: Participant[]) => void + ) => { + const handler = (_: any, groupChatId: string, participants: Participant[]) => + callback(groupChatId, participants); + ipcRenderer.on('groupChat:participantsChanged', handler); + return () => ipcRenderer.removeListener('groupChat:participantsChanged', handler); + }, + + onModeratorUsage: (callback: (groupChatId: string, usage: ModeratorUsage) => void) => { + const handler = (_: any, groupChatId: string, usage: ModeratorUsage) => + callback(groupChatId, usage); + ipcRenderer.on('groupChat:moderatorUsage', handler); + return () => ipcRenderer.removeListener('groupChat:moderatorUsage', handler); + }, + + onHistoryEntry: (callback: (groupChatId: string, entry: GroupChatHistoryEntry) => void) => { + const handler = (_: any, groupChatId: string, entry: GroupChatHistoryEntry) => + callback(groupChatId, entry); + ipcRenderer.on('groupChat:historyEntry', handler); + return () => ipcRenderer.removeListener('groupChat:historyEntry', handler); + }, + + onParticipantState: ( + callback: (groupChatId: string, participantName: string, state: 'idle' | 'working') => void + ) => { + const handler = ( + _: any, + groupChatId: string, + participantName: string, + state: 'idle' | 'working' + ) => callback(groupChatId, participantName, state); + ipcRenderer.on('groupChat:participantState', handler); + return () => ipcRenderer.removeListener('groupChat:participantState', handler); + }, + + onModeratorSessionIdChanged: (callback: (groupChatId: string, sessionId: string) => void) => { + const handler = (_: any, groupChatId: string, sessionId: string) => + callback(groupChatId, sessionId); + ipcRenderer.on('groupChat:moderatorSessionIdChanged', handler); + return () => ipcRenderer.removeListener('groupChat:moderatorSessionIdChanged', handler); + }, + }; +} + +export type GroupChatApi = ReturnType; diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts new file mode 100644 index 00000000..b9dde3e5 --- /dev/null +++ b/src/main/preload/index.ts @@ -0,0 +1,407 @@ +/** + * Electron Preload Script + * + * This script runs in the renderer process before any web content is loaded. + * It exposes a safe subset of Electron and Node.js APIs to the renderer via contextBridge. + * + * All APIs are organized in modular files within this directory for maintainability. + */ + +import { contextBridge } from 'electron'; + +// Import all factory functions for contextBridge exposure +import { + createSettingsApi, + createSessionsApi, + createGroupsApi, + createAgentErrorApi, +} from './settings'; +import { createContextApi } from './context'; +import { createWebApi, createWebserverApi, createLiveApi } from './web'; +import { + createDialogApi, + createFontsApi, + createShellsApi, + createShellApi, + createTunnelApi, + createSyncApi, + createDevtoolsApi, + createPowerApi, + createUpdatesApi, + createAppApi, +} from './system'; +import { createSshRemoteApi } from './sshRemote'; +import { createLoggerApi } from './logger'; +import { createClaudeApi, createAgentSessionsApi } from './sessions'; +import { createTempfileApi, createHistoryApi, createCliApi } from './files'; +import { createSpeckitApi, createOpenspecApi } from './commands'; +import { createAutorunApi, createPlaybooksApi, createMarketplaceApi } from './autorun'; +import { createDebugApi, createDocumentGraphApi } from './debug'; +import { createGroupChatApi } from './groupChat'; +import { createStatsApi } from './stats'; +import { createNotificationApi } from './notifications'; +import { createLeaderboardApi } from './leaderboard'; +import { createAttachmentsApi } from './attachments'; +import { createProcessApi } from './process'; +import { createGitApi } from './git'; +import { createFsApi } from './fs'; +import { createAgentsApi } from './agents'; + +// Expose protected methods that allow the renderer process to use +// the ipcRenderer without exposing the entire object +contextBridge.exposeInMainWorld('maestro', { + // Settings API + settings: createSettingsApi(), + + // Sessions persistence API + sessions: createSessionsApi(), + + // Groups persistence API + groups: createGroupsApi(), + + // Process/Session API + process: createProcessApi(), + + // Agent Error Handling API + agentError: createAgentErrorApi(), + + // Context Merge API + context: createContextApi(), + + // Web interface API + web: createWebApi(), + + // Git API + git: createGitApi(), + + // File System API + fs: createFsApi(), + + // Web Server API + webserver: createWebserverApi(), + + // Live Session API + live: createLiveApi(), + + // Agent API + agents: createAgentsApi(), + + // Dialog API + dialog: createDialogApi(), + + // Font API + fonts: createFontsApi(), + + // Shells API (terminal shells) + shells: createShellsApi(), + + // Shell API + shell: createShellApi(), + + // Tunnel API (Cloudflare) + tunnel: createTunnelApi(), + + // SSH Remote API + sshRemote: createSshRemoteApi(), + + // Sync API + sync: createSyncApi(), + + // DevTools API + devtools: createDevtoolsApi(), + + // Power Management API + power: createPowerApi(), + + // Updates API + updates: createUpdatesApi(), + + // Logger API + logger: createLoggerApi(), + + // Claude Code sessions API (DEPRECATED) + claude: createClaudeApi(), + + // Agent Sessions API (preferred) + agentSessions: createAgentSessionsApi(), + + // Temp file API + tempfile: createTempfileApi(), + + // History API + history: createHistoryApi(), + + // CLI activity API + cli: createCliApi(), + + // Spec Kit API + speckit: createSpeckitApi(), + + // OpenSpec API + openspec: createOpenspecApi(), + + // Notification API + notification: createNotificationApi(), + + // Attachments API + attachments: createAttachmentsApi(), + + // Auto Run API + autorun: createAutorunApi(), + + // Playbooks API + playbooks: createPlaybooksApi(), + + // Marketplace API + marketplace: createMarketplaceApi(), + + // Debug Package API + debug: createDebugApi(), + + // Document Graph API + documentGraph: createDocumentGraphApi(), + + // Group Chat API + groupChat: createGroupChatApi(), + + // App lifecycle API + app: createAppApi(), + + // Stats API + stats: createStatsApi(), + + // Leaderboard API + leaderboard: createLeaderboardApi(), +}); + +// Re-export factory functions for external consumers (e.g., tests) +export { + // Settings and persistence + createSettingsApi, + createSessionsApi, + createGroupsApi, + createAgentErrorApi, + // Context + createContextApi, + // Web interface + createWebApi, + createWebserverApi, + createLiveApi, + // System utilities + createDialogApi, + createFontsApi, + createShellsApi, + createShellApi, + createTunnelApi, + createSyncApi, + createDevtoolsApi, + createPowerApi, + createUpdatesApi, + createAppApi, + // SSH Remote + createSshRemoteApi, + // Logger + createLoggerApi, + // Sessions + createClaudeApi, + createAgentSessionsApi, + // Files + createTempfileApi, + createHistoryApi, + createCliApi, + // Commands + createSpeckitApi, + createOpenspecApi, + // Auto Run + createAutorunApi, + createPlaybooksApi, + createMarketplaceApi, + // Debug + createDebugApi, + createDocumentGraphApi, + // Group Chat + createGroupChatApi, + // Stats + createStatsApi, + // Notifications + createNotificationApi, + // Leaderboard + createLeaderboardApi, + // Attachments + createAttachmentsApi, + // Process + createProcessApi, + // Git + createGitApi, + // Filesystem + createFsApi, + // Agents + createAgentsApi, +}; + +// Re-export types for TypeScript consumers +export type { + // From settings + SettingsApi, + SessionsApi, + GroupsApi, + AgentErrorApi, +} from './settings'; +export type { + // From context + ContextApi, + StoredMessage, + StoredSessionResponse, +} from './context'; +export type { + // From web + WebApi, + WebserverApi, + LiveApi, + AutoRunState, + AiTabState, +} from './web'; +export type { + // From system + DialogApi, + FontsApi, + ShellsApi, + ShellApi, + TunnelApi, + SyncApi, + DevtoolsApi, + PowerApi, + UpdatesApi, + AppApi, + ShellInfo, + UpdateStatus, +} from './system'; +export type { + // From sshRemote + SshRemoteApi, + SshRemoteConfig, + SshConfigHost, +} from './sshRemote'; +export type { + // From logger + LoggerApi, +} from './logger'; +export type { + // From sessions + ClaudeApi, + AgentSessionsApi, + NamedSessionEntry, + NamedSessionEntryWithAgent, + GlobalStatsUpdate, +} from './sessions'; +export type { + // From files + TempfileApi, + HistoryApi, + CliApi, + HistoryEntry, +} from './files'; +export type { + // From commands + SpeckitApi, + OpenspecApi, + CommandMetadata, + CommandDefinition, +} from './commands'; +export type { + // From autorun + AutorunApi, + PlaybooksApi, + MarketplaceApi, + Playbook, + PlaybookDocument, + WorktreeSettings, +} from './autorun'; +export type { + // From debug + DebugApi, + DocumentGraphApi, + DebugPackageOptions, + DocumentGraphChange, +} from './debug'; +export type { + // From groupChat + GroupChatApi, + ModeratorConfig, + Participant, + ChatMessage, + GroupChatHistoryEntry, + ModeratorUsage, +} from './groupChat'; +export type { + // From stats + StatsApi, + QueryEvent, + AutoRunSession, + AutoRunTask, + SessionCreatedEvent, + StatsAggregation, +} from './stats'; +export type { + // From notifications + NotificationApi, + NotificationShowResponse, + TtsResponse, +} from './notifications'; +export type { + // From leaderboard + LeaderboardApi, + LeaderboardSubmitData, + LeaderboardSubmitResponse, + AuthStatusResponse, + ResendConfirmationResponse, + LeaderboardEntry, + LongestRunEntry, + LeaderboardGetResponse, + LongestRunsGetResponse, + LeaderboardSyncResponse, +} from './leaderboard'; +export type { + // From attachments + AttachmentsApi, + AttachmentResponse, + AttachmentLoadResponse, + AttachmentListResponse, + AttachmentPathResponse, +} from './attachments'; +export type { + // From process + ProcessApi, + ProcessConfig, + ProcessSpawnResponse, + RunCommandConfig, + ActiveProcess, + UsageStats, + AgentError, + ToolExecutionEvent, + SshRemoteInfo, +} from './process'; +export type { + // From git + GitApi, + WorktreeInfo, + WorktreeEntry, + GitSubdirEntry, + GitLogEntry, + WorktreeDiscoveredData, +} from './git'; +export type { + // From fs + FsApi, + DirectoryEntry, + FileStat, + DirectorySizeInfo, + ItemCountInfo, +} from './fs'; +export type { + // From agents + AgentsApi, + AgentCapabilities, + AgentConfig, + AgentRefreshResult, +} from './agents'; diff --git a/src/main/preload/leaderboard.ts b/src/main/preload/leaderboard.ts new file mode 100644 index 00000000..b18c899b --- /dev/null +++ b/src/main/preload/leaderboard.ts @@ -0,0 +1,221 @@ +/** + * Preload API for leaderboard + * + * Provides the window.maestro.leaderboard namespace for: + * - Getting installation ID + * - Submitting leaderboard entries + * - Polling auth status after email confirmation + * - Resending confirmation emails + * - Fetching leaderboard data + * - Syncing stats from server + */ + +import { ipcRenderer } from 'electron'; + +/** + * Data submitted when creating/updating a leaderboard entry + */ +export interface LeaderboardSubmitData { + email: string; + displayName: string; + githubUsername?: string; + twitterHandle?: string; + linkedinHandle?: string; + discordUsername?: string; + blueskyHandle?: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + longestRunMs?: number; + longestRunDate?: string; + currentRunMs?: number; + theme?: string; + clientToken?: string; + authToken?: string; + deltaMs?: number; + deltaRuns?: number; + installationId?: string; + clientTotalTimeMs?: number; +} + +/** + * Response from leaderboard submission + */ +export interface LeaderboardSubmitResponse { + success: boolean; + message: string; + pendingEmailConfirmation?: boolean; + error?: string; + authTokenRequired?: boolean; + ranking?: { + cumulative: { + rank: number; + total: number; + previousRank: number | null; + improved: boolean; + }; + longestRun: { + rank: number; + total: number; + previousRank: number | null; + improved: boolean; + } | null; + }; + serverTotals?: { + cumulativeTimeMs: number; + totalRuns: number; + }; +} + +/** + * Response from polling auth status + */ +export interface AuthStatusResponse { + status: 'pending' | 'confirmed' | 'expired' | 'error'; + authToken?: string; + message?: string; + error?: string; +} + +/** + * Response from resending confirmation + */ +export interface ResendConfirmationResponse { + success: boolean; + message?: string; + error?: string; +} + +/** + * Leaderboard entry for cumulative time rankings + */ +export interface LeaderboardEntry { + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; +} + +/** + * Leaderboard entry for longest run rankings + */ +export interface LongestRunEntry { + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + longestRunMs: number; + runDate: string; +} + +/** + * Response from fetching leaderboard + */ +export interface LeaderboardGetResponse { + success: boolean; + entries?: LeaderboardEntry[]; + error?: string; +} + +/** + * Response from fetching longest runs leaderboard + */ +export interface LongestRunsGetResponse { + success: boolean; + entries?: LongestRunEntry[]; + error?: string; +} + +/** + * Response from syncing stats + */ +export interface LeaderboardSyncResponse { + success: boolean; + found: boolean; + message?: string; + error?: string; + errorCode?: 'EMAIL_NOT_CONFIRMED' | 'INVALID_TOKEN' | 'MISSING_FIELDS'; + data?: { + displayName: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + longestRunMs: number | null; + longestRunDate: string | null; + keyboardLevel: number | null; + coveragePercent: number | null; + ranking: { + cumulative: { rank: number; total: number }; + longestRun: { rank: number; total: number } | null; + }; + }; +} + +/** + * Creates the leaderboard API object for preload exposure + */ +export function createLeaderboardApi() { + return { + /** + * Get the unique installation ID for this Maestro installation + */ + getInstallationId: (): Promise => + ipcRenderer.invoke('leaderboard:getInstallationId'), + + /** + * Submit leaderboard entry to runmaestro.ai + * @param data - Leaderboard submission data + */ + submit: (data: LeaderboardSubmitData): Promise => + ipcRenderer.invoke('leaderboard:submit', data), + + /** + * Poll for auth token after email confirmation + * @param clientToken - Client token from initial submission + */ + pollAuthStatus: (clientToken: string): Promise => + ipcRenderer.invoke('leaderboard:pollAuthStatus', clientToken), + + /** + * Resend confirmation email (self-service auth token recovery) + * @param data - Email and client token + */ + resendConfirmation: (data: { + email: string; + clientToken: string; + }): Promise => + ipcRenderer.invoke('leaderboard:resendConfirmation', data), + + /** + * Get leaderboard entries (cumulative time rankings) + * @param options - Optional limit (default: 50) + */ + get: (options?: { limit?: number }): Promise => + ipcRenderer.invoke('leaderboard:get', options), + + /** + * Get longest runs leaderboard + * @param options - Optional limit (default: 50) + */ + getLongestRuns: (options?: { limit?: number }): Promise => + ipcRenderer.invoke('leaderboard:getLongestRuns', options), + + /** + * Sync user stats from server (for new device installations) + * @param data - Email and auth token + */ + sync: (data: { email: string; authToken: string }): Promise => + ipcRenderer.invoke('leaderboard:sync', data), + }; +} + +/** + * TypeScript type for the leaderboard API + */ +export type LeaderboardApi = ReturnType; diff --git a/src/main/preload/logger.ts b/src/main/preload/logger.ts new file mode 100644 index 00000000..23db621a --- /dev/null +++ b/src/main/preload/logger.ts @@ -0,0 +1,58 @@ +/** + * Preload API for logging operations + * + * Provides the window.maestro.logger namespace for: + * - Application logging + * - Log level management + * - Real-time log subscriptions + */ + +import { ipcRenderer } from 'electron'; +import type { MainLogLevel, SystemLogEntry } from '../../shared/logger-types'; + +/** + * Creates the logger API object for preload exposure + */ +export function createLoggerApi() { + return { + log: (level: MainLogLevel, message: string, context?: string, data?: unknown) => + ipcRenderer.invoke('logger:log', level, message, context, data), + + getLogs: (filter?: { level?: MainLogLevel; context?: string; limit?: number }) => + ipcRenderer.invoke('logger:getLogs', filter), + + clearLogs: () => ipcRenderer.invoke('logger:clearLogs'), + + setLogLevel: (level: MainLogLevel) => ipcRenderer.invoke('logger:setLogLevel', level), + + getLogLevel: () => ipcRenderer.invoke('logger:getLogLevel'), + + setMaxLogBuffer: (max: number) => ipcRenderer.invoke('logger:setMaxLogBuffer', max), + + getMaxLogBuffer: () => ipcRenderer.invoke('logger:getMaxLogBuffer'), + + // Convenience method for logging toast notifications + toast: (title: string, data?: unknown) => + ipcRenderer.invoke('logger:log', 'toast', title, 'Toast', data), + + // Convenience method for Auto Run workflow logging (cannot be turned off) + autorun: (message: string, context?: string, data?: unknown) => + ipcRenderer.invoke('logger:log', 'autorun', message, context || 'AutoRun', data), + + // Subscribe to new log entries in real-time + onNewLog: (callback: (log: SystemLogEntry) => void) => { + const handler = (_: Electron.IpcRendererEvent, log: SystemLogEntry) => callback(log); + ipcRenderer.on('logger:newLog', handler); + return () => ipcRenderer.removeListener('logger:newLog', handler); + }, + + // File logging (enabled by default on Windows for debugging) + getLogFilePath: (): Promise => ipcRenderer.invoke('logger:getLogFilePath'), + + isFileLoggingEnabled: (): Promise => ipcRenderer.invoke('logger:isFileLoggingEnabled'), + + enableFileLogging: (): Promise => ipcRenderer.invoke('logger:enableFileLogging'), + }; +} + +export type LoggerApi = ReturnType; diff --git a/src/main/preload/notifications.ts b/src/main/preload/notifications.ts new file mode 100644 index 00000000..954e1c6e --- /dev/null +++ b/src/main/preload/notifications.ts @@ -0,0 +1,73 @@ +/** + * Preload API for notifications + * + * Provides the window.maestro.notification namespace for: + * - Showing OS notifications + * - Text-to-speech (TTS) functionality + * - TTS completion events + */ + +import { ipcRenderer } from 'electron'; + +/** + * Response from showing a notification + */ +export interface NotificationShowResponse { + success: boolean; + error?: string; +} + +/** + * Response from TTS operations + */ +export interface TtsResponse { + success: boolean; + ttsId?: number; + error?: string; +} + +/** + * Creates the notification API object for preload exposure + */ +export function createNotificationApi() { + return { + /** + * Show an OS notification + * @param title - Notification title + * @param body - Notification body text + */ + show: (title: string, body: string): Promise => + ipcRenderer.invoke('notification:show', title, body), + + /** + * Speak text using system TTS + * @param text - Text to speak + * @param command - Optional TTS command (default: 'say' on macOS) + */ + speak: (text: string, command?: string): Promise => + ipcRenderer.invoke('notification:speak', text, command), + + /** + * Stop a running TTS process + * @param ttsId - ID of the TTS process to stop + */ + stopSpeak: (ttsId: number): Promise => + ipcRenderer.invoke('notification:stopSpeak', ttsId), + + /** + * Subscribe to TTS completion events + * @param handler - Callback when a TTS process completes + * @returns Cleanup function to unsubscribe + */ + onTtsCompleted: (handler: (ttsId: number) => void): (() => void) => { + const wrappedHandler = (_event: Electron.IpcRendererEvent, ttsId: number) => handler(ttsId); + ipcRenderer.on('tts:completed', wrappedHandler); + return () => ipcRenderer.removeListener('tts:completed', wrappedHandler); + }, + }; +} + +/** + * TypeScript type for the notification API + */ +export type NotificationApi = ReturnType; diff --git a/src/main/preload/process.ts b/src/main/preload/process.ts new file mode 100644 index 00000000..9619572a --- /dev/null +++ b/src/main/preload/process.ts @@ -0,0 +1,420 @@ +/** + * Preload API for process management + * + * Provides the window.maestro.process namespace for: + * - Spawning and managing agent/terminal processes + * - Writing to processes + * - Handling process events (data, exit, errors) + * - Remote command execution from web interface + * - SSH remote execution support + */ + +import { ipcRenderer } from 'electron'; + +/** + * Configuration for spawning a process + */ +export interface ProcessConfig { + sessionId: string; + toolType: string; + cwd: string; + command: string; + args: string[]; + prompt?: string; + shell?: string; + images?: string[]; // Base64 data URLs for images + // Agent-specific spawn options (used to build args via agent config) + agentSessionId?: string; // For session resume (uses agent's resumeArgs builder) + readOnlyMode?: boolean; // For read-only/plan mode (uses agent's readOnlyArgs) + modelId?: string; // For model selection (uses agent's modelArgs builder) + yoloMode?: boolean; // For YOLO/full-access mode (uses agent's yoloModeArgs) + // Stats tracking options + querySource?: 'user' | 'auto'; // Whether this query is user-initiated or from Auto Run + tabId?: string; // Tab ID for multi-tab tracking +} + +/** + * Response from spawning a process + */ +export interface ProcessSpawnResponse { + pid: number; + success: boolean; + sshRemote?: { id: string; name: string; host: string }; +} + +/** + * Configuration for running a single command + */ +export interface RunCommandConfig { + sessionId: string; + command: string; + cwd: string; + shell?: string; + sessionSshRemoteConfig?: { + enabled: boolean; + remoteId: string | null; + workingDirOverride?: string; + }; +} + +/** + * Active process information + */ +export interface ActiveProcess { + sessionId: string; + toolType: string; + pid: number; + cwd: string; + isTerminal: boolean; + isBatchMode: boolean; + startTime: number; + command?: string; + args?: string[]; +} + +/** + * Usage statistics from AI responses + */ +export interface UsageStats { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + totalCostUsd: number; + contextWindow: number; + reasoningTokens?: number; // Separate reasoning tokens (Codex o3/o4-mini) +} + +/** + * Agent error information + */ +export interface AgentError { + type: string; + message: string; + recoverable: boolean; + agentId: string; + sessionId?: string; + timestamp: number; + raw?: { + exitCode?: number; + stderr?: string; + stdout?: string; + errorLine?: string; + }; +} + +/** + * Tool execution event + */ +export interface ToolExecutionEvent { + toolName: string; + state?: unknown; + timestamp: number; +} + +/** + * SSH remote info + */ +export interface SshRemoteInfo { + id: string; + name: string; + host: string; +} + +/** + * Creates the process API object for preload exposure + */ +export function createProcessApi() { + return { + /** + * Spawn a new process (agent or terminal) + */ + spawn: (config: ProcessConfig): Promise => + ipcRenderer.invoke('process:spawn', config), + + /** + * Write data to a process stdin + */ + write: (sessionId: string, data: string): Promise => + ipcRenderer.invoke('process:write', sessionId, data), + + /** + * Send interrupt signal (Ctrl+C) to a process + */ + interrupt: (sessionId: string): Promise => + ipcRenderer.invoke('process:interrupt', sessionId), + + /** + * Kill a process + */ + kill: (sessionId: string): Promise => ipcRenderer.invoke('process:kill', sessionId), + + /** + * Resize process terminal + */ + resize: (sessionId: string, cols: number, rows: number): Promise => + ipcRenderer.invoke('process:resize', sessionId, cols, rows), + + /** + * Run a single command and capture only stdout/stderr (no PTY echo/prompts) + * Supports SSH remote execution when sessionSshRemoteConfig is provided + */ + runCommand: (config: RunCommandConfig): Promise<{ exitCode: number }> => + ipcRenderer.invoke('process:runCommand', config), + + /** + * Get all active processes from ProcessManager + */ + getActiveProcesses: (): Promise => + ipcRenderer.invoke('process:getActiveProcesses'), + + // Event listeners + + /** + * Subscribe to process data output + */ + onData: (callback: (sessionId: string, data: string) => void): (() => void) => { + const handler = (_: unknown, sessionId: string, data: string) => callback(sessionId, data); + ipcRenderer.on('process:data', handler); + return () => ipcRenderer.removeListener('process:data', handler); + }, + + /** + * Subscribe to process exit events + */ + onExit: (callback: (sessionId: string, code: number) => void): (() => void) => { + const handler = (_: unknown, sessionId: string, code: number) => callback(sessionId, code); + ipcRenderer.on('process:exit', handler); + return () => ipcRenderer.removeListener('process:exit', handler); + }, + + /** + * Subscribe to agent session ID events + */ + onSessionId: (callback: (sessionId: string, agentSessionId: string) => void): (() => void) => { + const handler = (_: unknown, sessionId: string, agentSessionId: string) => + callback(sessionId, agentSessionId); + ipcRenderer.on('process:session-id', handler); + return () => ipcRenderer.removeListener('process:session-id', handler); + }, + + /** + * Subscribe to slash commands discovered from agent + */ + onSlashCommands: ( + callback: (sessionId: string, slashCommands: string[]) => void + ): (() => void) => { + const handler = (_: unknown, sessionId: string, slashCommands: string[]) => + callback(sessionId, slashCommands); + ipcRenderer.on('process:slash-commands', handler); + return () => ipcRenderer.removeListener('process:slash-commands', handler); + }, + + /** + * Subscribe to thinking/streaming content chunks from AI agents + * Emitted when agents produce partial text events (isPartial: true) + * Renderer decides whether to display based on tab's showThinking setting + */ + onThinkingChunk: (callback: (sessionId: string, content: string) => void): (() => void) => { + const handler = (_: unknown, sessionId: string, content: string) => + callback(sessionId, content); + ipcRenderer.on('process:thinking-chunk', handler); + return () => ipcRenderer.removeListener('process:thinking-chunk', handler); + }, + + /** + * Subscribe to tool execution events + */ + onToolExecution: ( + callback: (sessionId: string, toolEvent: ToolExecutionEvent) => void + ): (() => void) => { + const handler = (_: unknown, sessionId: string, toolEvent: ToolExecutionEvent) => + callback(sessionId, toolEvent); + ipcRenderer.on('process:tool-execution', handler); + return () => ipcRenderer.removeListener('process:tool-execution', handler); + }, + + /** + * Subscribe to SSH remote execution status + * Emitted when a process starts executing via SSH on a remote host + */ + onSshRemote: ( + callback: (sessionId: string, sshRemote: SshRemoteInfo | null) => void + ): (() => void) => { + const handler = (_: unknown, sessionId: string, sshRemote: SshRemoteInfo | null) => + callback(sessionId, sshRemote); + ipcRenderer.on('process:ssh-remote', handler); + return () => ipcRenderer.removeListener('process:ssh-remote', handler); + }, + + /** + * Subscribe to remote command execution from web interface + * This allows web commands to go through the same code path as desktop commands + * inputMode is optional - if provided, renderer should use it instead of session state + */ + onRemoteCommand: ( + callback: (sessionId: string, command: string, inputMode?: 'ai' | 'terminal') => void + ): (() => void) => { + console.log( + '[Preload] Registering onRemoteCommand listener, callback type:', + typeof callback + ); + const handler = ( + _: unknown, + sessionId: string, + command: string, + inputMode?: 'ai' | 'terminal' + ) => { + console.log('[Preload] Received remote:executeCommand IPC:', { + sessionId, + command: command?.substring(0, 50), + inputMode, + }); + console.log('[Preload] About to invoke callback, callback type:', typeof callback); + try { + callback(sessionId, command, inputMode); + console.log('[Preload] Callback invoked successfully'); + } catch (error) { + console.error('[Preload] Error invoking remote command callback:', error); + } + }; + ipcRenderer.on('remote:executeCommand', handler); + return () => ipcRenderer.removeListener('remote:executeCommand', handler); + }, + + /** + * Subscribe to remote mode switch from web interface + * Forwards to desktop's toggleInputMode logic + */ + onRemoteSwitchMode: ( + callback: (sessionId: string, mode: 'ai' | 'terminal') => void + ): (() => void) => { + console.log('[Preload] Registering onRemoteSwitchMode listener'); + const handler = (_: unknown, sessionId: string, mode: 'ai' | 'terminal') => { + console.log('[Preload] Received remote:switchMode IPC:', { sessionId, mode }); + callback(sessionId, mode); + }; + ipcRenderer.on('remote:switchMode', handler); + return () => ipcRenderer.removeListener('remote:switchMode', handler); + }, + + /** + * Subscribe to remote interrupt from web interface + * Forwards to desktop's handleInterrupt logic + */ + onRemoteInterrupt: (callback: (sessionId: string) => void): (() => void) => { + const handler = (_: unknown, sessionId: string) => callback(sessionId); + ipcRenderer.on('remote:interrupt', handler); + return () => ipcRenderer.removeListener('remote:interrupt', handler); + }, + + /** + * Subscribe to remote session selection from web interface + * Forwards to desktop's setActiveSessionId logic + * Optional tabId to also switch to a specific tab within the session + */ + onRemoteSelectSession: ( + callback: (sessionId: string, tabId?: string) => void + ): (() => void) => { + console.log('[Preload] Registering onRemoteSelectSession listener'); + const handler = (_: unknown, sessionId: string, tabId?: string) => { + console.log('[Preload] Received remote:selectSession IPC:', { sessionId, tabId }); + callback(sessionId, tabId); + }; + ipcRenderer.on('remote:selectSession', handler); + return () => ipcRenderer.removeListener('remote:selectSession', handler); + }, + + /** + * Subscribe to remote tab selection from web interface + */ + onRemoteSelectTab: (callback: (sessionId: string, tabId: string) => void): (() => void) => { + const handler = (_: unknown, sessionId: string, tabId: string) => callback(sessionId, tabId); + ipcRenderer.on('remote:selectTab', handler); + return () => ipcRenderer.removeListener('remote:selectTab', handler); + }, + + /** + * Subscribe to remote new tab from web interface + */ + onRemoteNewTab: ( + callback: (sessionId: string, responseChannel: string) => void + ): (() => void) => { + const handler = (_: unknown, sessionId: string, responseChannel: string) => + callback(sessionId, responseChannel); + ipcRenderer.on('remote:newTab', handler); + return () => ipcRenderer.removeListener('remote:newTab', handler); + }, + + /** + * Send response for remote new tab + */ + sendRemoteNewTabResponse: (responseChannel: string, result: { tabId: string } | null): void => { + ipcRenderer.send(responseChannel, result); + }, + + /** + * Subscribe to remote close tab from web interface + */ + onRemoteCloseTab: (callback: (sessionId: string, tabId: string) => void): (() => void) => { + const handler = (_: unknown, sessionId: string, tabId: string) => callback(sessionId, tabId); + ipcRenderer.on('remote:closeTab', handler); + return () => ipcRenderer.removeListener('remote:closeTab', handler); + }, + + /** + * Subscribe to remote rename tab from web interface + */ + onRemoteRenameTab: ( + callback: (sessionId: string, tabId: string, newName: string) => void + ): (() => void) => { + const handler = (_: unknown, sessionId: string, tabId: string, newName: string) => + callback(sessionId, tabId, newName); + ipcRenderer.on('remote:renameTab', handler); + return () => ipcRenderer.removeListener('remote:renameTab', handler); + }, + + /** + * Subscribe to stderr from runCommand (separate stream) + */ + onStderr: (callback: (sessionId: string, data: string) => void): (() => void) => { + const handler = (_: unknown, sessionId: string, data: string) => callback(sessionId, data); + ipcRenderer.on('process:stderr', handler); + return () => ipcRenderer.removeListener('process:stderr', handler); + }, + + /** + * Subscribe to command exit from runCommand (separate from PTY exit) + */ + onCommandExit: (callback: (sessionId: string, code: number) => void): (() => void) => { + const handler = (_: unknown, sessionId: string, code: number) => callback(sessionId, code); + ipcRenderer.on('process:command-exit', handler); + return () => ipcRenderer.removeListener('process:command-exit', handler); + }, + + /** + * Subscribe to usage statistics from AI responses + */ + onUsage: (callback: (sessionId: string, usageStats: UsageStats) => void): (() => void) => { + const handler = (_: unknown, sessionId: string, usageStats: UsageStats) => + callback(sessionId, usageStats); + ipcRenderer.on('process:usage', handler); + return () => ipcRenderer.removeListener('process:usage', handler); + }, + + /** + * Subscribe to agent error events (auth expired, token exhaustion, rate limits, etc.) + */ + onAgentError: (callback: (sessionId: string, error: AgentError) => void): (() => void) => { + const handler = (_: unknown, sessionId: string, error: AgentError) => + callback(sessionId, error); + ipcRenderer.on('agent:error', handler); + return () => ipcRenderer.removeListener('agent:error', handler); + }, + }; +} + +/** + * TypeScript type for the process API + */ +export type ProcessApi = ReturnType; diff --git a/src/main/preload/sessions.ts b/src/main/preload/sessions.ts new file mode 100644 index 00000000..33d95e8e --- /dev/null +++ b/src/main/preload/sessions.ts @@ -0,0 +1,352 @@ +/** + * Preload API for agent sessions + * + * Provides the window.maestro.claude and window.maestro.agentSessions namespaces for: + * - Claude Code session storage (deprecated) + * - Generic multi-agent session storage + */ + +import { ipcRenderer } from 'electron'; + +// Helper to log deprecation warnings +const logDeprecationWarning = (method: string, replacement?: string) => { + const message = replacement + ? `[Deprecation Warning] window.maestro.claude.${method}() is deprecated. Use window.maestro.agentSessions.${replacement}() instead.` + : `[Deprecation Warning] window.maestro.claude.${method}() is deprecated. Use the agentSessions API instead.`; + console.warn(message); +}; + +/** + * Named session entry + */ +export interface NamedSessionEntry { + agentSessionId: string; + projectPath: string; + sessionName: string; + starred?: boolean; + lastActivityAt?: number; +} + +/** + * Named session entry with agent ID + */ +export interface NamedSessionEntryWithAgent extends NamedSessionEntry { + agentId: string; +} + +/** + * Global stats update + */ +export interface GlobalStatsUpdate { + totalSessions: number; + totalMessages: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheCreationTokens: number; + totalCostUsd: number; + hasCostData: boolean; + totalSizeBytes: number; + isComplete: boolean; + byProvider: Record< + string, + { + sessions: number; + messages: number; + inputTokens: number; + outputTokens: number; + costUsd: number; + hasCostData: boolean; + } + >; +} + +/** + * Creates the Claude Code sessions API object (DEPRECATED) + */ +export function createClaudeApi() { + return { + listSessions: (projectPath: string) => { + logDeprecationWarning('listSessions', 'list'); + return ipcRenderer.invoke('claude:listSessions', projectPath); + }, + listSessionsPaginated: (projectPath: string, options?: { cursor?: string; limit?: number }) => { + logDeprecationWarning('listSessionsPaginated', 'listPaginated'); + return ipcRenderer.invoke('claude:listSessionsPaginated', projectPath, options); + }, + getProjectStats: (projectPath: string) => { + logDeprecationWarning('getProjectStats'); + return ipcRenderer.invoke('claude:getProjectStats', projectPath); + }, + getSessionTimestamps: (projectPath: string): Promise<{ timestamps: string[] }> => { + logDeprecationWarning('getSessionTimestamps'); + return ipcRenderer.invoke('claude:getSessionTimestamps', projectPath); + }, + onProjectStatsUpdate: ( + callback: (stats: { + projectPath: string; + totalSessions: number; + totalMessages: number; + totalCostUsd: number; + totalSizeBytes: number; + oldestTimestamp: string | null; + processedCount: number; + isComplete: boolean; + }) => void + ) => { + logDeprecationWarning('onProjectStatsUpdate'); + const handler = (_: any, stats: any) => callback(stats); + ipcRenderer.on('claude:projectStatsUpdate', handler); + return () => ipcRenderer.removeListener('claude:projectStatsUpdate', handler); + }, + getGlobalStats: () => { + logDeprecationWarning('getGlobalStats'); + return ipcRenderer.invoke('claude:getGlobalStats'); + }, + onGlobalStatsUpdate: ( + callback: (stats: { + totalSessions: number; + totalMessages: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheCreationTokens: number; + totalCostUsd: number; + totalSizeBytes: number; + isComplete: boolean; + }) => void + ) => { + logDeprecationWarning('onGlobalStatsUpdate'); + const handler = (_: any, stats: any) => callback(stats); + ipcRenderer.on('claude:globalStatsUpdate', handler); + return () => ipcRenderer.removeListener('claude:globalStatsUpdate', handler); + }, + readSessionMessages: ( + projectPath: string, + sessionId: string, + options?: { offset?: number; limit?: number } + ) => { + logDeprecationWarning('readSessionMessages', 'read'); + return ipcRenderer.invoke('claude:readSessionMessages', projectPath, sessionId, options); + }, + searchSessions: ( + projectPath: string, + query: string, + searchMode: 'title' | 'user' | 'assistant' | 'all' + ) => { + logDeprecationWarning('searchSessions', 'search'); + return ipcRenderer.invoke('claude:searchSessions', projectPath, query, searchMode); + }, + getCommands: (projectPath: string) => { + logDeprecationWarning('getCommands'); + return ipcRenderer.invoke('claude:getCommands', projectPath); + }, + registerSessionOrigin: ( + projectPath: string, + agentSessionId: string, + origin: 'user' | 'auto', + sessionName?: string + ) => { + logDeprecationWarning('registerSessionOrigin'); + return ipcRenderer.invoke( + 'claude:registerSessionOrigin', + projectPath, + agentSessionId, + origin, + sessionName + ); + }, + updateSessionName: (projectPath: string, agentSessionId: string, sessionName: string) => { + logDeprecationWarning('updateSessionName'); + return ipcRenderer.invoke( + 'claude:updateSessionName', + projectPath, + agentSessionId, + sessionName + ); + }, + updateSessionStarred: (projectPath: string, agentSessionId: string, starred: boolean) => { + logDeprecationWarning('updateSessionStarred'); + return ipcRenderer.invoke( + 'claude:updateSessionStarred', + projectPath, + agentSessionId, + starred + ); + }, + updateSessionContextUsage: ( + projectPath: string, + agentSessionId: string, + contextUsage: number + ) => { + return ipcRenderer.invoke( + 'claude:updateSessionContextUsage', + projectPath, + agentSessionId, + contextUsage + ); + }, + getSessionOrigins: (projectPath: string) => { + logDeprecationWarning('getSessionOrigins'); + return ipcRenderer.invoke('claude:getSessionOrigins', projectPath); + }, + getAllNamedSessions: (): Promise => { + logDeprecationWarning('getAllNamedSessions'); + return ipcRenderer.invoke('claude:getAllNamedSessions'); + }, + deleteMessagePair: ( + projectPath: string, + sessionId: string, + userMessageUuid: string, + fallbackContent?: string + ) => { + logDeprecationWarning('deleteMessagePair', 'deleteMessagePair'); + return ipcRenderer.invoke( + 'claude:deleteMessagePair', + projectPath, + sessionId, + userMessageUuid, + fallbackContent + ); + }, + }; +} + +/** + * Creates the agent sessions API object (preferred API) + */ +export function createAgentSessionsApi() { + return { + list: (agentId: string, projectPath: string, sshRemoteId?: string) => + ipcRenderer.invoke('agentSessions:list', agentId, projectPath, sshRemoteId), + + listPaginated: ( + agentId: string, + projectPath: string, + options?: { cursor?: string; limit?: number }, + sshRemoteId?: string + ) => + ipcRenderer.invoke('agentSessions:listPaginated', agentId, projectPath, options, sshRemoteId), + + read: ( + agentId: string, + projectPath: string, + sessionId: string, + options?: { offset?: number; limit?: number }, + sshRemoteId?: string + ) => + ipcRenderer.invoke( + 'agentSessions:read', + agentId, + projectPath, + sessionId, + options, + sshRemoteId + ), + + search: ( + agentId: string, + projectPath: string, + query: string, + searchMode: 'title' | 'user' | 'assistant' | 'all', + sshRemoteId?: string + ) => + ipcRenderer.invoke( + 'agentSessions:search', + agentId, + projectPath, + query, + searchMode, + sshRemoteId + ), + + getPath: (agentId: string, projectPath: string, sessionId: string, sshRemoteId?: string) => + ipcRenderer.invoke('agentSessions:getPath', agentId, projectPath, sessionId, sshRemoteId), + + deleteMessagePair: ( + agentId: string, + projectPath: string, + sessionId: string, + userMessageUuid: string, + fallbackContent?: string + ) => + ipcRenderer.invoke( + 'agentSessions:deleteMessagePair', + agentId, + projectPath, + sessionId, + userMessageUuid, + fallbackContent + ), + + hasStorage: (agentId: string) => ipcRenderer.invoke('agentSessions:hasStorage', agentId), + + getAvailableStorages: () => ipcRenderer.invoke('agentSessions:getAvailableStorages'), + + getGlobalStats: () => ipcRenderer.invoke('agentSessions:getGlobalStats'), + + getAllNamedSessions: (): Promise => + ipcRenderer.invoke('agentSessions:getAllNamedSessions'), + + onGlobalStatsUpdate: (callback: (stats: GlobalStatsUpdate) => void) => { + const handler = (_: unknown, stats: GlobalStatsUpdate) => callback(stats); + ipcRenderer.on('agentSessions:globalStatsUpdate', handler); + return () => ipcRenderer.removeListener('agentSessions:globalStatsUpdate', handler); + }, + + registerSessionOrigin: ( + projectPath: string, + agentSessionId: string, + origin: 'user' | 'auto', + sessionName?: string + ) => + ipcRenderer.invoke( + 'claude:registerSessionOrigin', + projectPath, + agentSessionId, + origin, + sessionName + ), + + updateSessionName: (projectPath: string, agentSessionId: string, sessionName: string) => + ipcRenderer.invoke('claude:updateSessionName', projectPath, agentSessionId, sessionName), + + getOrigins: ( + agentId: string, + projectPath: string + ): Promise< + Record + > => ipcRenderer.invoke('agentSessions:getOrigins', agentId, projectPath), + + setSessionName: ( + agentId: string, + projectPath: string, + sessionId: string, + sessionName: string | null + ) => + ipcRenderer.invoke( + 'agentSessions:setSessionName', + agentId, + projectPath, + sessionId, + sessionName + ), + + setSessionStarred: ( + agentId: string, + projectPath: string, + sessionId: string, + starred: boolean + ) => + ipcRenderer.invoke( + 'agentSessions:setSessionStarred', + agentId, + projectPath, + sessionId, + starred + ), + }; +} + +export type ClaudeApi = ReturnType; +export type AgentSessionsApi = ReturnType; diff --git a/src/main/preload/settings.ts b/src/main/preload/settings.ts new file mode 100644 index 00000000..a42ce8fb --- /dev/null +++ b/src/main/preload/settings.ts @@ -0,0 +1,62 @@ +/** + * Preload API for settings and persistence + * + * Provides the window.maestro.settings, sessions, and groups namespaces for: + * - Application settings persistence + * - Session list persistence + * - Group list persistence + */ + +import { ipcRenderer } from 'electron'; + +/** + * Creates the settings API object for preload exposure + */ +export function createSettingsApi() { + return { + get: (key: string) => ipcRenderer.invoke('settings:get', key), + set: (key: string, value: unknown) => ipcRenderer.invoke('settings:set', key, value), + getAll: () => ipcRenderer.invoke('settings:getAll'), + }; +} + +/** + * Creates the sessions persistence API object for preload exposure + */ +export function createSessionsApi() { + return { + getAll: () => ipcRenderer.invoke('sessions:getAll'), + setAll: (sessions: any[]) => ipcRenderer.invoke('sessions:setAll', sessions), + }; +} + +/** + * Creates the groups persistence API object for preload exposure + */ +export function createGroupsApi() { + return { + getAll: () => ipcRenderer.invoke('groups:getAll'), + setAll: (groups: any[]) => ipcRenderer.invoke('groups:setAll', groups), + }; +} + +/** + * Creates the agent error handling API object for preload exposure + */ +export function createAgentErrorApi() { + return { + clearError: (sessionId: string) => ipcRenderer.invoke('agent:clearError', sessionId), + retryAfterError: ( + sessionId: string, + options?: { + prompt?: string; + newSession?: boolean; + } + ) => ipcRenderer.invoke('agent:retryAfterError', sessionId, options), + }; +} + +export type SettingsApi = ReturnType; +export type SessionsApi = ReturnType; +export type GroupsApi = ReturnType; +export type AgentErrorApi = ReturnType; diff --git a/src/main/preload/sshRemote.ts b/src/main/preload/sshRemote.ts new file mode 100644 index 00000000..97806069 --- /dev/null +++ b/src/main/preload/sshRemote.ts @@ -0,0 +1,74 @@ +/** + * Preload API for SSH remote operations + * + * Provides the window.maestro.sshRemote namespace for: + * - SSH remote configuration management + * - SSH connection testing + * - SSH config file parsing + */ + +import { ipcRenderer } from 'electron'; + +/** + * SSH remote configuration + */ +export interface SshRemoteConfig { + id: string; + name: string; + host: string; + port: number; + username: string; + privateKeyPath: string; + remoteEnv?: Record; + enabled: boolean; +} + +/** + * SSH config host entry + */ +export interface SshConfigHost { + host: string; + hostName?: string; + port?: number; + user?: string; + identityFile?: string; + proxyJump?: string; +} + +/** + * Creates the SSH remote API object for preload exposure + */ +export function createSshRemoteApi() { + return { + saveConfig: (config: { + id?: string; + name?: string; + host?: string; + port?: number; + username?: string; + privateKeyPath?: string; + remoteEnv?: Record; + enabled?: boolean; + }) => ipcRenderer.invoke('ssh-remote:saveConfig', config), + + deleteConfig: (id: string) => ipcRenderer.invoke('ssh-remote:deleteConfig', id), + + getConfigs: () => ipcRenderer.invoke('ssh-remote:getConfigs'), + + getDefaultId: () => ipcRenderer.invoke('ssh-remote:getDefaultId'), + + setDefaultId: (id: string | null) => ipcRenderer.invoke('ssh-remote:setDefaultId', id), + + test: (configOrId: string | SshRemoteConfig, agentCommand?: string) => + ipcRenderer.invoke('ssh-remote:test', configOrId, agentCommand), + + getSshConfigHosts: (): Promise<{ + success: boolean; + hosts: SshConfigHost[]; + error?: string; + configPath: string; + }> => ipcRenderer.invoke('ssh-remote:getSshConfigHosts'), + }; +} + +export type SshRemoteApi = ReturnType; diff --git a/src/main/preload/stats.ts b/src/main/preload/stats.ts new file mode 100644 index 00000000..69ea98ee --- /dev/null +++ b/src/main/preload/stats.ts @@ -0,0 +1,207 @@ +/** + * Preload API for stats operations + * + * Provides the window.maestro.stats namespace for: + * - Usage tracking and analytics + * - Query event recording + * - Auto Run session tracking + */ + +import { ipcRenderer } from 'electron'; + +/** + * Query event for recording + */ +export interface QueryEvent { + sessionId: string; + agentType: string; + source: 'user' | 'auto'; + startTime: number; + duration: number; + projectPath?: string; + tabId?: string; + isRemote?: boolean; +} + +/** + * Auto Run session for recording + */ +export interface AutoRunSession { + sessionId: string; + agentType: string; + documentPath?: string; + startTime: number; + tasksTotal?: number; + projectPath?: string; +} + +/** + * Auto Run task for recording + */ +export interface AutoRunTask { + autoRunSessionId: string; + sessionId: string; + agentType: string; + taskIndex: number; + taskContent?: string; + startTime: number; + duration: number; + success: boolean; +} + +/** + * Session lifecycle event + */ +export interface SessionCreatedEvent { + sessionId: string; + agentType: string; + projectPath?: string; + createdAt: number; + isRemote?: boolean; +} + +/** + * Aggregation result + */ +export interface StatsAggregation { + totalQueries: number; + totalDuration: number; + avgDuration: number; + byAgent: Record; + bySource: { user: number; auto: number }; + byDay: Array<{ date: string; count: number; duration: number }>; +} + +/** + * Creates the Stats API object for preload exposure + */ +export function createStatsApi() { + return { + // Record a query event (interactive conversation turn) + recordQuery: (event: QueryEvent): Promise => + ipcRenderer.invoke('stats:record-query', event), + + // Start an Auto Run session (returns session ID) + startAutoRun: (session: AutoRunSession): Promise => + ipcRenderer.invoke('stats:start-autorun', session), + + // End an Auto Run session (update duration and completed count) + endAutoRun: (id: string, duration: number, tasksCompleted: number): Promise => + ipcRenderer.invoke('stats:end-autorun', id, duration, tasksCompleted), + + // Record an Auto Run task completion + recordAutoTask: (task: AutoRunTask): Promise => + ipcRenderer.invoke('stats:record-task', task), + + // Get query events with time range and optional filters + getStats: ( + range: 'day' | 'week' | 'month' | 'year' | 'all', + filters?: { + agentType?: string; + source?: 'user' | 'auto'; + projectPath?: string; + sessionId?: string; + } + ): Promise< + Array<{ + id: string; + sessionId: string; + agentType: string; + source: 'user' | 'auto'; + startTime: number; + duration: number; + projectPath?: string; + tabId?: string; + }> + > => ipcRenderer.invoke('stats:get-stats', range, filters), + + // Get Auto Run sessions within a time range + getAutoRunSessions: ( + range: 'day' | 'week' | 'month' | 'year' | 'all' + ): Promise< + Array<{ + id: string; + sessionId: string; + agentType: string; + documentPath?: string; + startTime: number; + duration: number; + tasksTotal?: number; + tasksCompleted?: number; + projectPath?: string; + }> + > => ipcRenderer.invoke('stats:get-autorun-sessions', range), + + // Get tasks for a specific Auto Run session + getAutoRunTasks: ( + autoRunSessionId: string + ): Promise< + Array<{ + id: string; + autoRunSessionId: string; + sessionId: string; + agentType: string; + taskIndex: number; + taskContent?: string; + startTime: number; + duration: number; + success: boolean; + }> + > => ipcRenderer.invoke('stats:get-autorun-tasks', autoRunSessionId), + + // Get aggregated stats for dashboard display + getAggregation: (range: 'day' | 'week' | 'month' | 'year' | 'all'): Promise => + ipcRenderer.invoke('stats:get-aggregation', range), + + // Export query events to CSV + exportCsv: (range: 'day' | 'week' | 'month' | 'year' | 'all'): Promise => + ipcRenderer.invoke('stats:export-csv', range), + + // Subscribe to stats updates (for real-time dashboard refresh) + onStatsUpdate: (callback: () => void) => { + const handler = () => callback(); + ipcRenderer.on('stats:updated', handler); + return () => ipcRenderer.removeListener('stats:updated', handler); + }, + + // Clear old stats data (older than specified number of days) + clearOldData: ( + olderThanDays: number + ): Promise<{ + success: boolean; + deletedQueryEvents: number; + deletedAutoRunSessions: number; + deletedAutoRunTasks: number; + error?: string; + }> => ipcRenderer.invoke('stats:clear-old-data', olderThanDays), + + // Get database size in bytes + getDatabaseSize: (): Promise => ipcRenderer.invoke('stats:get-database-size'), + + // Record session creation (for lifecycle tracking) + recordSessionCreated: (event: SessionCreatedEvent): Promise => + ipcRenderer.invoke('stats:record-session-created', event), + + // Record session closure (for lifecycle tracking) + recordSessionClosed: (sessionId: string, closedAt: number): Promise => + ipcRenderer.invoke('stats:record-session-closed', sessionId, closedAt), + + // Get session lifecycle events within a time range + getSessionLifecycle: ( + range: 'day' | 'week' | 'month' | 'year' | 'all' + ): Promise< + Array<{ + id: string; + sessionId: string; + agentType: string; + projectPath?: string; + createdAt: number; + closedAt?: number; + duration?: number; + isRemote?: boolean; + }> + > => ipcRenderer.invoke('stats:get-session-lifecycle', range), + }; +} + +export type StatsApi = ReturnType; diff --git a/src/main/preload/system.ts b/src/main/preload/system.ts new file mode 100644 index 00000000..5ba5e330 --- /dev/null +++ b/src/main/preload/system.ts @@ -0,0 +1,206 @@ +/** + * Preload API for system operations + * + * Provides the window.maestro.dialog, fonts, shells, shell, tunnel, sync, devtools, power, updates, app namespaces + */ + +import { ipcRenderer } from 'electron'; + +/** + * Shell information + */ +export interface ShellInfo { + id: string; + name: string; + available: boolean; + path?: string; +} + +/** + * Update status from electron-updater + */ +export interface UpdateStatus { + status: + | 'idle' + | 'checking' + | 'available' + | 'not-available' + | 'downloading' + | 'downloaded' + | 'error'; + info?: { version: string }; + progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number }; + error?: string; +} + +/** + * Creates the dialog API object for preload exposure + */ +export function createDialogApi() { + return { + selectFolder: () => ipcRenderer.invoke('dialog:selectFolder'), + saveFile: (options: { + defaultPath?: string; + filters?: Array<{ name: string; extensions: string[] }>; + title?: string; + }) => ipcRenderer.invoke('dialog:saveFile', options), + }; +} + +/** + * Creates the fonts API object for preload exposure + */ +export function createFontsApi() { + return { + detect: () => ipcRenderer.invoke('fonts:detect'), + }; +} + +/** + * Creates the shells API object for preload exposure + */ +export function createShellsApi() { + return { + detect: (): Promise => ipcRenderer.invoke('shells:detect'), + }; +} + +/** + * Creates the shell API object for preload exposure + */ +export function createShellApi() { + return { + openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), + trashItem: (itemPath: string) => ipcRenderer.invoke('shell:trashItem', itemPath), + }; +} + +/** + * Creates the tunnel API object for preload exposure + */ +export function createTunnelApi() { + return { + isCloudflaredInstalled: () => ipcRenderer.invoke('tunnel:isCloudflaredInstalled'), + start: () => ipcRenderer.invoke('tunnel:start'), + stop: () => ipcRenderer.invoke('tunnel:stop'), + getStatus: () => ipcRenderer.invoke('tunnel:getStatus'), + }; +} + +/** + * Creates the sync API object for preload exposure + */ +export function createSyncApi() { + return { + getDefaultPath: (): Promise => ipcRenderer.invoke('sync:getDefaultPath'), + getSettings: (): Promise<{ customSyncPath?: string }> => ipcRenderer.invoke('sync:getSettings'), + getCurrentStoragePath: (): Promise => ipcRenderer.invoke('sync:getCurrentStoragePath'), + selectSyncFolder: (): Promise => ipcRenderer.invoke('sync:selectSyncFolder'), + setCustomPath: ( + customPath: string | null + ): Promise<{ + success: boolean; + migrated?: number; + errors?: string[]; + requiresRestart?: boolean; + error?: string; + }> => ipcRenderer.invoke('sync:setCustomPath', customPath), + }; +} + +/** + * Creates the devtools API object for preload exposure + */ +export function createDevtoolsApi() { + return { + open: () => ipcRenderer.invoke('devtools:open'), + close: () => ipcRenderer.invoke('devtools:close'), + toggle: () => ipcRenderer.invoke('devtools:toggle'), + }; +} + +/** + * Creates the power API object for preload exposure + */ +export function createPowerApi() { + return { + setEnabled: (enabled: boolean): Promise => + ipcRenderer.invoke('power:setEnabled', enabled), + isEnabled: (): Promise => ipcRenderer.invoke('power:isEnabled'), + getStatus: (): Promise<{ + enabled: boolean; + blocking: boolean; + reasons: string[]; + platform: 'darwin' | 'win32' | 'linux'; + }> => ipcRenderer.invoke('power:getStatus'), + addReason: (reason: string): Promise => ipcRenderer.invoke('power:addReason', reason), + removeReason: (reason: string): Promise => + ipcRenderer.invoke('power:removeReason', reason), + }; +} + +/** + * Creates the updates API object for preload exposure + */ +export function createUpdatesApi() { + return { + check: ( + includePrerelease?: boolean + ): Promise<{ + currentVersion: string; + latestVersion: string; + updateAvailable: boolean; + versionsBehind: number; + releases: Array<{ + tag_name: string; + name: string; + body: string; + html_url: string; + published_at: string; + }>; + releasesUrl: string; + error?: string; + }> => ipcRenderer.invoke('updates:check', includePrerelease), + download: (): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('updates:download'), + install: (): Promise => ipcRenderer.invoke('updates:install'), + getStatus: (): Promise => ipcRenderer.invoke('updates:getStatus'), + onStatus: (callback: (status: UpdateStatus) => void) => { + const handler = (_: any, status: UpdateStatus) => callback(status); + ipcRenderer.on('updates:status', handler); + return () => ipcRenderer.removeListener('updates:status', handler); + }, + setAllowPrerelease: (allow: boolean): Promise => + ipcRenderer.invoke('updates:setAllowPrerelease', allow), + }; +} + +/** + * Creates the app lifecycle API object for preload exposure + */ +export function createAppApi() { + return { + onQuitConfirmationRequest: (callback: () => void) => { + const handler = () => callback(); + ipcRenderer.on('app:requestQuitConfirmation', handler); + return () => ipcRenderer.removeListener('app:requestQuitConfirmation', handler); + }, + confirmQuit: () => { + ipcRenderer.send('app:quitConfirmed'); + }, + cancelQuit: () => { + ipcRenderer.send('app:quitCancelled'); + }, + }; +} + +export type DialogApi = ReturnType; +export type FontsApi = ReturnType; +export type ShellsApi = ReturnType; +export type ShellApi = ReturnType; +export type TunnelApi = ReturnType; +export type SyncApi = ReturnType; +export type DevtoolsApi = ReturnType; +export type PowerApi = ReturnType; +export type UpdatesApi = ReturnType; +export type AppApi = ReturnType; diff --git a/src/main/preload/web.ts b/src/main/preload/web.ts new file mode 100644 index 00000000..dc109a18 --- /dev/null +++ b/src/main/preload/web.ts @@ -0,0 +1,103 @@ +/** + * Preload API for web interface operations + * + * Provides the window.maestro.web, webserver, and live namespaces for: + * - Broadcasting state to web clients + * - Web server management + * - Live session management + */ + +import { ipcRenderer } from 'electron'; + +/** + * Auto Run state for broadcasting + */ +export interface AutoRunState { + isRunning: boolean; + totalTasks: number; + completedTasks: number; + currentTaskIndex: number; + isStopping?: boolean; + totalDocuments?: number; + currentDocumentIndex?: number; + totalTasksAcrossAllDocs?: number; + completedTasksAcrossAllDocs?: number; +} + +/** + * AI Tab state for broadcasting + */ +export interface AiTabState { + id: string; + agentSessionId: string | null; + name: string | null; + starred: boolean; + inputValue: string; + usageStats?: any; + createdAt: number; + state: 'idle' | 'busy'; + thinkingStartTime?: number | null; +} + +/** + * Creates the web interface API object for preload exposure + */ +export function createWebApi() { + return { + // Broadcast user input to web clients (for keeping web interface in sync) + broadcastUserInput: (sessionId: string, command: string, inputMode: 'ai' | 'terminal') => + ipcRenderer.invoke('web:broadcastUserInput', sessionId, command, inputMode), + + // Broadcast AutoRun state to web clients (for showing task progress on mobile) + broadcastAutoRunState: (sessionId: string, state: AutoRunState | null) => + ipcRenderer.invoke('web:broadcastAutoRunState', sessionId, state), + + // Broadcast tab changes to web clients (for tab sync) + broadcastTabsChange: (sessionId: string, aiTabs: AiTabState[], activeTabId: string) => + ipcRenderer.invoke('web:broadcastTabsChange', sessionId, aiTabs, activeTabId), + + // Broadcast session state change to web clients (for real-time busy/idle updates) + broadcastSessionState: ( + sessionId: string, + state: string, + additionalData?: { + name?: string; + toolType?: string; + inputMode?: string; + cwd?: string; + } + ) => ipcRenderer.invoke('web:broadcastSessionState', sessionId, state, additionalData), + }; +} + +/** + * Creates the web server API object for preload exposure + */ +export function createWebserverApi() { + return { + getUrl: () => ipcRenderer.invoke('webserver:getUrl'), + getConnectedClients: () => ipcRenderer.invoke('webserver:getConnectedClients'), + }; +} + +/** + * Creates the live session API object for preload exposure + */ +export function createLiveApi() { + return { + toggle: (sessionId: string, agentSessionId?: string) => + ipcRenderer.invoke('live:toggle', sessionId, agentSessionId), + getStatus: (sessionId: string) => ipcRenderer.invoke('live:getStatus', sessionId), + getDashboardUrl: () => ipcRenderer.invoke('live:getDashboardUrl'), + getLiveSessions: () => ipcRenderer.invoke('live:getLiveSessions'), + broadcastActiveSession: (sessionId: string) => + ipcRenderer.invoke('live:broadcastActiveSession', sessionId), + disableAll: () => ipcRenderer.invoke('live:disableAll'), + startServer: () => ipcRenderer.invoke('live:startServer'), + stopServer: () => ipcRenderer.invoke('live:stopServer'), + }; +} + +export type WebApi = ReturnType; +export type WebserverApi = ReturnType; +export type LiveApi = ReturnType;