From 92fac5f3ed229b8f36216a3c14b6f8802e619013 Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Mon, 26 Jan 2026 18:52:18 +0200 Subject: [PATCH 1/8] fix(opencode): fix task auto-notification and remove any types - Fix critical bug: task completion events now route to parent session (changed session_id: session.id to session_id: ctx.sessionID in task.ts) - Remove 'any' type annotations from catch blocks - Replace e?.name checks with proper type guards for AbortError - Convert Global paths to getters for test isolation - Fix log directory initialization order - Clean up Tauri dependencies from lock file --- bun.lock | 95 ------------------- packages/opencode/src/agent/agent.ts | 7 +- packages/opencode/src/global/index.ts | 26 +++-- packages/opencode/src/project/project.ts | 42 +++++--- packages/opencode/src/session/processor.ts | 24 +++-- packages/opencode/src/session/tools.ts | 7 +- packages/opencode/src/tool/task.ts | 2 +- packages/opencode/src/util/log.ts | 14 +-- packages/opencode/src/util/tasks.ts | 7 +- packages/opencode/test/preload.ts | 22 ++++- .../opencode/test/project/project.test.ts | 5 + 11 files changed, 107 insertions(+), 144 deletions(-) diff --git a/bun.lock b/bun.lock index 4116a796543..06e496aadda 100644 --- a/bun.lock +++ b/bun.lock @@ -180,35 +180,6 @@ "cloudflare": "5.2.0", }, }, - "packages/desktop": { - "name": "@opencode-ai/desktop", - "version": "1.1.34", - "dependencies": { - "@opencode-ai/app": "workspace:*", - "@opencode-ai/ui": "workspace:*", - "@solid-primitives/storage": "catalog:", - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-dialog": "~2", - "@tauri-apps/plugin-http": "~2", - "@tauri-apps/plugin-notification": "~2", - "@tauri-apps/plugin-opener": "^2", - "@tauri-apps/plugin-os": "~2", - "@tauri-apps/plugin-process": "~2", - "@tauri-apps/plugin-shell": "~2", - "@tauri-apps/plugin-store": "~2", - "@tauri-apps/plugin-updater": "~2", - "@tauri-apps/plugin-window-state": "~2", - "solid-js": "catalog:", - }, - "devDependencies": { - "@actions/artifact": "4.0.0", - "@tauri-apps/cli": "^2", - "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", - "typescript": "~5.6.2", - "vite": "catalog:", - }, - }, "packages/enterprise": { "name": "@opencode-ai/enterprise", "version": "1.1.34", @@ -710,8 +681,6 @@ "@azure/core-client": ["@azure/core-client@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w=="], - "@azure/core-http": ["@azure/core-http@3.0.5", "", { "dependencies": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-tracing": "1.0.0-preview.13", "@azure/core-util": "^1.1.1", "@azure/logger": "^1.0.0", "@types/node-fetch": "^2.5.0", "@types/tunnel": "^0.0.3", "form-data": "^4.0.0", "node-fetch": "^2.6.7", "process": "^0.11.10", "tslib": "^2.2.0", "tunnel": "^0.0.6", "uuid": "^8.3.0", "xml2js": "^0.5.0" } }, "sha512-T8r2q/c3DxNu6mEJfPuJtptUVqwchxzjj32gKcnMi06rdiVONS9rar7kT9T2Am+XvER7uOzpsP79WsqNbdgdWg=="], - "@azure/core-http-compat": ["@azure/core-http-compat@2.3.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-client": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0" } }, "sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g=="], "@azure/core-lro": ["@azure/core-lro@2.7.2", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-util": "^1.2.0", "@azure/logger": "^1.0.0", "tslib": "^2.6.2" } }, "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw=="], @@ -1198,8 +1167,6 @@ "@opencode-ai/console-resource": ["@opencode-ai/console-resource@workspace:packages/console/resource"], - "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], - "@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"], "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], @@ -1720,50 +1687,8 @@ "@tauri-apps/api": ["@tauri-apps/api@2.9.0", "", {}, "sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw=="], - "@tauri-apps/cli": ["@tauri-apps/cli@2.9.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.9.4", "@tauri-apps/cli-darwin-x64": "2.9.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.4", "@tauri-apps/cli-linux-arm64-gnu": "2.9.4", "@tauri-apps/cli-linux-arm64-musl": "2.9.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.9.4", "@tauri-apps/cli-linux-x64-gnu": "2.9.4", "@tauri-apps/cli-linux-x64-musl": "2.9.4", "@tauri-apps/cli-win32-arm64-msvc": "2.9.4", "@tauri-apps/cli-win32-ia32-msvc": "2.9.4", "@tauri-apps/cli-win32-x64-msvc": "2.9.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-pvylWC9QckrOS9ATWXIXcgu7g2hKK5xTL5ZQyZU/U0n9l88SEFGcWgLQNa8WZmd+wWIOWhkxOFcOl3i6ubDNNw=="], - - "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9rHkMVtbMhe0AliVbrGpzMahOBg3rwV46JYRELxR9SN6iu1dvPOaMaiC4cP6M/aD1424ziXnnMdYU06RAH8oIw=="], - - "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-VT9ymNuT06f5TLjCZW2hfSxbVtZDhORk7CDUDYiq5TiSYQdxkl8MVBy0CCFFcOk4QAkUmqmVUA9r3YZ/N/vPRQ=="], - - "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.9.4", "", { "os": "linux", "cpu": "arm" }, "sha512-tTWkEPig+2z3Rk0zqZYfjUYcgD+aSm72wdrIhdYobxbQZOBw0zfn50YtWv+av7bm0SHvv75f0l7JuwgZM1HFow=="], - - "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-ql6vJ611qoqRYHxkKPnb2vHa27U+YRKRmIpLMMBeZnfFtZ938eao7402AQCH1mO2+/8ioUhbpy9R/ZcLTXVmkg=="], - - "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-vg7yNn7ICTi6hRrcA/6ff2UpZQP7un3xe3SEld5QM0prgridbKAiXGaCKr3BnUBx/rGXegQlD/wiLcWdiiraSw=="], - - "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.9.4", "", { "os": "linux", "cpu": "none" }, "sha512-l8L+3VxNk6yv5T/Z/gv5ysngmIpsai40B9p6NQQyqYqxImqYX37pqREoEBl1YwG7szGnDibpWhidPrWKR59OJA=="], - - "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-PepPhCXc/xVvE3foykNho46OmCyx47E/aG676vKTVp+mqin5d+IBqDL6wDKiGNT5OTTxKEyNlCQ81Xs2BQhhqA=="], - - "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-zcd1QVffh5tZs1u1SCKUV/V7RRynebgYUNWHuV0FsIF1MjnULUChEXhAhug7usCDq4GZReMJOoXa6rukEozWIw=="], - - "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-/7ZhnP6PY04bEob23q8MH/EoDISdmR1wuNm0k9d5HV7TDMd2GGCDa8dPXA4vJuglJKXIfXqxFmZ4L+J+MO42+w=="], - - "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.9.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-1LmAfaC4Cq+3O1Ir1ksdhczhdtFSTIV51tbAGtbV/mr348O+M52A/xwCCXQank0OcdBxy5BctqkMtuZnQvA8uQ=="], - - "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg=="], - - "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ=="], - - "@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg=="], - - "@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="], - - "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="], - - "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], - - "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], - - "@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Xod+pRcFxmOWFWEnqH5yZcA7qwAMuaaDkMR1Sply+F8VfBj++CGnj2xf5UoialmjZ2Cvd8qrvSCbU+7GgNVsKQ=="], - "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ckGSEzZ5Ii4Hf2D5x25Oqnm2Zf9MfDWAzR+volY0z/OOBz6aucPKEY0F649JvQ0Vupku6UJo7ugpGRDOFOunkA=="], - "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.9.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg=="], - - "@tauri-apps/plugin-window-state": ["@tauri-apps/plugin-window-state@2.4.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw=="], - "@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -1862,8 +1787,6 @@ "@types/tsscmp": ["@types/tsscmp@1.0.2", "", {}, "sha512-cy7BRSU8GYYgxjcx0Py+8lo5MthuDhlyu076KUcYzVNXL23luYgRHkMG2fIFEc6neckeh/ntP82mw+U4QjZq+g=="], - "@types/tunnel": ["@types/tunnel@0.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA=="], - "@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -4052,14 +3975,6 @@ "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], - "@azure/core-http/@azure/abort-controller": ["@azure/abort-controller@1.1.0", "", { "dependencies": { "tslib": "^2.2.0" } }, "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw=="], - - "@azure/core-http/@azure/core-tracing": ["@azure/core-tracing@1.0.0-preview.13", "", { "dependencies": { "@opentelemetry/api": "^1.0.1", "tslib": "^2.2.0" } }, "sha512-KxDlhXyMlh2Jhj2ykX6vNEU0Vou4nHr025KoSEiz7cS3BNiHNaZcdECk/DmLkEB0as5T7b/TpRcehJ5yV6NeXQ=="], - - "@azure/core-http/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], - - "@azure/core-http/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], - "@azure/core-xml/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -4204,10 +4119,6 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], - - "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], - "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/types": "3.4.2" } }, "sha512-I5baLVi/ynLEOZoWSAMlACHNnG+yw5HDmse0oe+GW6U1u+ULdEB3UHiVWaHoJSSONV7tlcVxuaMy74sREDkSvg=="], "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], @@ -4576,8 +4487,6 @@ "@aws-sdk/xml-builder/fast-xml-parser/strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], - "@azure/core-http/xml2js/sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="], - "@azure/core-xml/fast-xml-parser/strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -4814,8 +4723,6 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], - "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], - "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AG8vnSi1W2pbgR2B911EfGqtLE9c4hQBYkv/x7Z+Kt0VxhgQKcW7UNDVYsu9YxwV6u+OJrvdJrMq6DNWoBjihQ=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="], @@ -5170,8 +5077,6 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], - "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], - "@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 51073f50f3f..61ff47bfff4 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -324,8 +324,11 @@ export namespace Agent { for await (const part of result.fullStream) { if (part.type === "error") throw part.error } - } catch (e: any) { - if (e?.name === "AbortError" || (e instanceof DOMException && e.name === "AbortError")) { + } catch (e) { + if (typeof e === "object" && e !== null && "name" in e && e.name === "AbortError") { + throw e + } + if (e instanceof DOMException && e.name === "AbortError") { throw e } throw e diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 0fcad2c7d96..07b76405cbb 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -1,14 +1,10 @@ import fs from "fs/promises" -import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import path from "path" import os from "os" +import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" const app = "opencode" -const data = path.join(xdgData!, app) -const cache = path.join(xdgCache!, app) -const state = path.join(xdgState!, app) - let initialized = false export async function init() { @@ -53,17 +49,27 @@ export const Global = { get home() { return process.env.OPENCODE_TEST_HOME || os.homedir() }, - data, - bin: path.join(data, "bin"), - log: path.join(data, "log"), - cache, + get data() { + return path.join(xdgData!, app) + }, + get bin() { + return path.join(this.data, "bin") + }, + get log() { + return path.join(this.data, "log") + }, + get cache() { + return path.join(xdgCache!, app) + }, // Resolve config relative to OPENCODE_TEST_HOME when set get config() { return process.env.OPENCODE_TEST_HOME ? path.join(process.env.OPENCODE_TEST_HOME, ".config", app) : path.join(xdgConfig!, app) }, - state, + get state() { + return path.join(xdgState!, app) + }, // Allow overriding models.dev URL for offline deployments get modelsDevUrl() { return process.env.OPENCODE_MODELS_URL || "https://models.dev" diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 163943d1b46..ac352d8bab6 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -63,10 +63,13 @@ export namespace Project { const gitBinary = Bun.which("git") // cached id calculation - let id = await Bun.file(path.join(git, "opencode")) - .text() - .then((x) => x.trim()) - .catch(() => undefined) + let id + try { + const content = await Bun.file(path.join(git, "opencode")).text() + id = content.trim() + } catch { + id = undefined + } if (!gitBinary) { return { @@ -110,15 +113,6 @@ export namespace Project { } } - if (!id) { - return { - id: "global", - worktree: sandbox, - sandbox: sandbox, - vcs: "git", - } - } - const top = await $`git rev-parse --show-toplevel` .quiet() .nothrow() @@ -136,8 +130,28 @@ export namespace Project { } } + let worktree = top + const stat = await Bun.file(git) + .stat() + .catch(() => null) + if (stat?.isFile) { + const gitDirContent = await Bun.file(git) + .text() + .then((x) => x.trim()) + const gitDir = gitDirContent.replace("gitdir: ", "") + const gitCommonDirPath = path.join(gitDir, "commondir") + if (await Bun.file(gitCommonDirPath).exists()) { + const commonDir = + path.dirname(gitDir) + + "/" + + (await Bun.file(gitCommonDirPath) + .text() + .then((x) => x.trim())) + worktree = path.dirname(path.resolve(commonDir)) + } + } + sandbox = top - const worktree = top return { id, diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b5595c257f2..58ddb96ab7c 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -341,22 +341,26 @@ export namespace SessionProcessor { } if (needsCompaction) break } - } catch (e: any) { - if (e?.name === "AbortError" || (e instanceof DOMException && e.name === "AbortError")) { + } catch (e) { + if (typeof e === "object" && e !== null && "name" in e && e.name === "AbortError") { + throw e + } + if (e instanceof DOMException && e.name === "AbortError") { throw e } throw e } - } catch (e: any) { + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)) log.error("process", { - error: e, - stack: JSON.stringify(e.stack), + error, + stack: error.stack, }) - const error = MessageV2.fromError(e, { providerID: input.model.providerID }) - const retry = SessionRetry.retryable(error) + const parsedError = MessageV2.fromError(error, { providerID: input.model.providerID }) + const retry = SessionRetry.retryable(parsedError) if (retry !== undefined) { attempt++ - const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined) + const delay = SessionRetry.delay(attempt, parsedError.name === "APIError" ? parsedError : undefined) SessionStatus.set(input.sessionID, { type: "retry", attempt, @@ -364,14 +368,14 @@ export namespace SessionProcessor { next: Date.now() + delay, }) await SessionRetry.sleep(delay, input.abort).catch((err) => { - if (err?.name === "AbortError") { + if (typeof err === "object" && err !== null && "name" in err && err.name === "AbortError") { log.info("Sleep aborted, checking signal before retry") } }) if (input.abort.aborted) break continue } - input.assistantMessage.error = error + input.assistantMessage.error = parsedError Bus.publish(Session.Event.Error, { sessionID: input.assistantMessage.sessionID, error: input.assistantMessage.error, diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 6d1f1875c87..b61c26953dd 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -104,8 +104,11 @@ export async function resolveTools(input: ResolveToolsInput): Promise { const taskMetadata: TaskMetadata = { agent_type: agent.name, description: params.description, - session_id: session.id, + session_id: ctx.sessionID, start_time: startTime, release_slot: result.releaseSlot, } diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index f2c5391d0be..c8e4c1bd75d 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -62,14 +62,14 @@ export namespace Log { export async function init(options: Options) { if (options.level) level = options.level - cleanup(sanitizePath(Global.Path.log)) + const logDir = sanitizePath(Global.Path.log) + if (!options.print) { + await fs.mkdir(logDir, { recursive: true }).catch(() => {}) + cleanup(logDir) + } if (options.print) return - logpath = sanitizePath( - path.join( - sanitizePath(Global.Path.log), - options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log", - ), - ) + const filename = options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log" + logpath = sanitizePath(path.join(logDir, filename)) const logfile = Bun.file(logpath) await fs.truncate(logpath).catch(() => {}) const writer = logfile.writer() diff --git a/packages/opencode/src/util/tasks.ts b/packages/opencode/src/util/tasks.ts index c025c9fda13..16895fce531 100644 --- a/packages/opencode/src/util/tasks.ts +++ b/packages/opencode/src/util/tasks.ts @@ -19,8 +19,11 @@ export namespace BackgroundTasks { */ export function spawn(task: Promise): void { const wrapped = task - .catch((err: any) => { - if (err?.name !== "AbortError" && !(err instanceof DOMException && err.name === "AbortError")) { + .catch((err) => { + if ( + !(typeof err === "object" && err !== null && "name" in err && err.name === "AbortError") && + !(err instanceof DOMException && err.name === "AbortError") + ) { log.error("background task failed", { error: err }) } }) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 819166c94c6..bab3943bf6a 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -5,13 +5,13 @@ import path from "path" import fs from "fs/promises" import fsSync from "fs" import { afterAll } from "bun:test" -const { Global } = await import("../src/global") const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid) await fs.mkdir(dir, { recursive: true }) afterAll(() => { fsSync.rmSync(dir, { recursive: true, force: true }) }) + // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills const testHome = path.join(dir, "home") @@ -23,6 +23,21 @@ process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") +// Now safe to import Global - xdg-basedir will use the environment vars we just set +const { Global } = await import("../src/global") + +// Create an empty config file at OPENCODE_TEST_HOME/.config/opencode/opencode.json +// This ensures tests don't load the user's actual config files that may contain plugins +// and other configuration that conflicts with test isolation +const testConfigDir = path.join(testHome, ".config", "opencode") +await fs.mkdir(testConfigDir, { recursive: true }) +const testConfigPath = path.join(testConfigDir, "opencode.json") +const testConfig = { + $schema: "https://opencode.ai/config.json", + plugin: [], +} +await Bun.write(testConfigPath, JSON.stringify(testConfig, null, 2)) + // Pre-fetch models.json so tests don't need the macro fallback // Also write the cache version file to prevent global/index.ts from clearing the cache const cacheDir = path.join(dir, "cache", "opencode") @@ -57,6 +72,11 @@ delete process.env["FIREWORKS_API_KEY"] delete process.env["CEREBRAS_API_KEY"] delete process.env["SAMBANOVA_API_KEY"] +// Clear config env vars to prevent loading user's config files from tests +delete process.env["OPENCODE_CONFIG"] +delete process.env["OPENCODE_CONFIG_DIR"] +delete process.env["OPENCODE_CONFIG_CONTENT"] + // Now safe to import from src/ const { Log } = await import("../src/util/log") diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index d44e606746e..2d4709aedf3 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -56,6 +56,8 @@ describe("Project.fromDirectory with worktrees", () => { await using tmp = await tmpdir({ git: true }) const worktreePath = path.join(tmp.path, "..", "worktree-test") + await $`rm -rf ${worktreePath}`.quiet() + await $`git worktree prune`.cwd(tmp.path).quiet() await $`git worktree add ${worktreePath} -b test-branch`.cwd(tmp.path).quiet() const { project, sandbox } = await Project.fromDirectory(worktreePath) @@ -73,6 +75,9 @@ describe("Project.fromDirectory with worktrees", () => { const worktree1 = path.join(tmp.path, "..", "worktree-1") const worktree2 = path.join(tmp.path, "..", "worktree-2") + await $`rm -rf ${worktree1}`.quiet() + await $`rm -rf ${worktree2}`.quiet() + await $`git worktree prune`.cwd(tmp.path).quiet() await $`git worktree add ${worktree1} -b branch-1`.cwd(tmp.path).quiet() await $`git worktree add ${worktree2} -b branch-2`.cwd(tmp.path).quiet() From de3d5cfa5f379ff3846ca4189ee74a88bfab018a Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Mon, 26 Jan 2026 20:12:05 +0200 Subject: [PATCH 2/8] fix(project): use fs.stat instead of Bun.file for directory detection Bun.file().stat() throws "Directories cannot be read like files" when called on a directory. The .git path is a directory in normal repos (only a file in worktrees). Using Node.js fs.stat() works correctly with both. Also fixed stat.isFile to stat.isFile() (method call vs property). --- packages/opencode/src/project/project.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index ac352d8bab6..e7bde945ddb 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,5 +1,5 @@ import z from "zod" -import fs from "fs/promises" +import fs, { stat as fsStat } from "fs/promises" import { Filesystem } from "../util/filesystem" import path from "path" import { $ } from "bun" @@ -131,10 +131,8 @@ export namespace Project { } let worktree = top - const stat = await Bun.file(git) - .stat() - .catch(() => null) - if (stat?.isFile) { + const stat = await fsStat(git).catch(() => null) + if (stat?.isFile()) { const gitDirContent = await Bun.file(git) .text() .then((x) => x.trim()) From ea0d19f0d8ba780ae1ad14986c3a0d32be22e1a9 Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Mon, 26 Jan 2026 20:46:20 +0200 Subject: [PATCH 3/8] feat(opencode): add lightweight TUI (oclite) with raw ANSI terminal (#27) --- packages/opencode/package.json | 1 + packages/opencode/script/build-lite.ts | 59 ++++++++ packages/opencode/src/cli/lite/index.ts | 137 ++++++++++++++++++ packages/opencode/src/cli/lite/input.ts | 125 ++++++++++++++++ packages/opencode/src/cli/lite/session.ts | 113 +++++++++++++++ packages/opencode/src/cli/lite/spinner.ts | 32 ++++ packages/opencode/src/cli/lite/terminal.ts | 82 +++++++++++ packages/opencode/src/cli/lite/ui.ts | 38 +++++ .../opencode/test/cli/lite/session.test.ts | 30 ++++ 9 files changed, 617 insertions(+) create mode 100644 packages/opencode/script/build-lite.ts create mode 100644 packages/opencode/src/cli/lite/index.ts create mode 100644 packages/opencode/src/cli/lite/input.ts create mode 100644 packages/opencode/src/cli/lite/session.ts create mode 100644 packages/opencode/src/cli/lite/spinner.ts create mode 100644 packages/opencode/src/cli/lite/terminal.ts create mode 100644 packages/opencode/src/cli/lite/ui.ts create mode 100644 packages/opencode/test/cli/lite/session.test.ts diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 822b581dd73..ac13261d7ee 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -9,6 +9,7 @@ "typecheck": "tsgo --noEmit", "test": "bun test", "build": "bun run script/build.ts", + "build:lite": "bun run script/build-lite.ts", "dev": "bun run --conditions=browser ./src/index.ts", "random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'", "clean": "echo 'Cleaning up...' && rm -rf node_modules dist", diff --git a/packages/opencode/script/build-lite.ts b/packages/opencode/script/build-lite.ts new file mode 100644 index 00000000000..cd1040e1e1e --- /dev/null +++ b/packages/opencode/script/build-lite.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env bun +import path from "path" +import fs from "fs" +import { $ } from "bun" + +const ROOT = path.resolve(import.meta.dirname, "..") +const DIST = path.join(ROOT, "dist", "oclite-darwin-arm64") +const BIN = path.join(DIST, "bin") +const HOME_BIN = path.join(process.env.HOME!, "bin") + +async function build() { + console.log("Building oclite...") + + // Clean + await fs.promises.rm(DIST, { recursive: true, force: true }) + await fs.promises.mkdir(BIN, { recursive: true }) + + // Build + const result = await Bun.build({ + entrypoints: [path.join(ROOT, "src/cli/lite/index.ts")], + outdir: BIN, + target: "bun", + minify: true, + sourcemap: "none", + naming: "oclite", + }) + + if (!result.success) { + console.error("Build failed:") + for (const log of result.logs) { + console.error(log) + } + process.exit(1) + } + + // Make executable + await $`chmod +x ${path.join(BIN, "oclite")}` + + // Copy to ~/bin + await fs.promises.mkdir(HOME_BIN, { recursive: true }) + await fs.promises.copyFile(path.join(BIN, "oclite"), path.join(HOME_BIN, "oclite")) + + // Sign for macOS + await $`codesign --force --deep --sign - ${path.join(HOME_BIN, "oclite")}`.quiet().nothrow() + await $`xattr -cr ${path.join(HOME_BIN, "oclite")}`.quiet().nothrow() + + // Get size + const stat = await fs.promises.stat(path.join(HOME_BIN, "oclite")) + const sizeMB = (stat.size / 1024 / 1024).toFixed(2) + + console.log(`✓ Built oclite (${sizeMB} MB)`) + console.log(`✓ Installed to ~/bin/oclite`) + console.log("\nRun with: ~/bin/oclite") +} + +build().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/packages/opencode/src/cli/lite/index.ts b/packages/opencode/src/cli/lite/index.ts new file mode 100644 index 00000000000..a3c1825ef32 --- /dev/null +++ b/packages/opencode/src/cli/lite/index.ts @@ -0,0 +1,137 @@ +#!/usr/bin/env bun +import { cursor, clear, fg, style, write, screen } from "./terminal" +import { parseKey, LineEditor } from "./input" +import { Spinner } from "./spinner" +import { chat } from "./session" + +const PROMPT = `${fg.cyan}>${style.reset} ` + +async function main() { + // Check TTY + if (!process.stdin.isTTY) { + console.error("oclite requires a TTY") + process.exit(1) + } + + // Setup + write(screen.alt) + write(clear.screen) + write(cursor.home) + + // Header + write(`${fg.brightCyan}${style.bold}oclite${style.reset} ${fg.gray}v0.1.0${style.reset}\n`) + write(`${fg.gray}Type /help for commands, Ctrl+C to exit${style.reset}\n\n`) + + // Input setup + const editor = new LineEditor() + process.stdin.setRawMode(true) + process.stdin.resume() + + // Render prompt + editor.render(PROMPT) + + // Handle input + process.stdin.on("data", async (data: Buffer) => { + const key = parseKey(data) + + // Ctrl+C to exit + if (key.ctrl && key.name === "c") { + cleanup() + process.exit(0) + } + + const result = editor.handle(key) + + if (result !== null) { + write("\n") + + if (result.startsWith("/")) { + handleCommand(result) + } else if (result.trim()) { + await handleMessage(result) + } + + editor.render(PROMPT) + } else { + editor.render(PROMPT) + } + }) + + // Cleanup on exit + function cleanup() { + write(cursor.show) + write(screen.main) + process.stdin.setRawMode(false) + } + + process.on("exit", cleanup) + process.on("SIGINT", () => { + cleanup() + process.exit(0) + }) +} + +function handleCommand(cmd: string) { + const command = cmd.slice(1).toLowerCase().trim() + + if (command === "help") { + write(`${fg.yellow}Commands:${style.reset}\n`) + write(` /help - Show this help\n`) + write(` /clear - Clear screen\n`) + write(` /quit - Exit oclite\n`) + write("\n") + return + } + + if (command === "clear") { + write(clear.screen) + write(cursor.home) + write(`${fg.brightCyan}${style.bold}oclite${style.reset} ${fg.gray}v0.1.0${style.reset}\n\n`) + return + } + + if (command === "quit" || command === "exit") { + process.exit(0) + } + + write(`${fg.red}Unknown command: ${cmd}${style.reset}\n\n`) +} + +async function handleMessage(message: string) { + const spinner = new Spinner("Thinking") + spinner.start() + + let first = true + try { + for await (const chunk of chat(message)) { + if (first) { + spinner.stop(true) + first = false + } + + if (chunk.type === "text" && chunk.content) { + write(chunk.content) + } + + if (chunk.type === "tool_start" && chunk.tool) { + write(`\n${fg.yellow}▶ ${chunk.tool}${style.reset}\n`) + } + + if (chunk.type === "tool_end" && chunk.tool) { + write(`${fg.green}✓ ${chunk.tool}${style.reset}\n`) + } + + if (chunk.type === "error" && chunk.content) { + write(`\n${fg.red}Error: ${chunk.content}${style.reset}\n`) + } + } + } catch (err) { + if (first) spinner.stop(false) + const msg = err instanceof Error ? err.message : String(err) + write(`\n${fg.red}Error: ${msg}${style.reset}\n`) + } + + write("\n") +} + +main().catch(console.error) diff --git a/packages/opencode/src/cli/lite/input.ts b/packages/opencode/src/cli/lite/input.ts new file mode 100644 index 00000000000..d97a1ccdca4 --- /dev/null +++ b/packages/opencode/src/cli/lite/input.ts @@ -0,0 +1,125 @@ +import { cursor, write } from "./terminal" + +export interface Key { + name: string + char?: string + ctrl?: boolean + meta?: boolean +} + +export function parseKey(data: Buffer): Key { + const s = data.toString() + + // Control characters + if (s === "\x03") return { name: "c", ctrl: true } + if (s === "\x04") return { name: "d", ctrl: true } + if (s === "\x0c") return { name: "l", ctrl: true } + + // Special keys + if (s === "\r" || s === "\n") return { name: "return" } + if (s === "\x7f" || s === "\b") return { name: "backspace" } + if (s === "\t") return { name: "tab" } + if (s === "\x1b") return { name: "escape" } + + // Arrow keys + if (s === "\x1b[A") return { name: "up" } + if (s === "\x1b[B") return { name: "down" } + if (s === "\x1b[C") return { name: "right" } + if (s === "\x1b[D") return { name: "left" } + + // Home/End + if (s === "\x1b[H" || s === "\x1b[1~") return { name: "home" } + if (s === "\x1b[F" || s === "\x1b[4~") return { name: "end" } + + // Delete + if (s === "\x1b[3~") return { name: "delete" } + + // Regular character + if (s.length === 1 && s >= " ") return { name: "char", char: s } + + return { name: "unknown", char: s } +} + +export class LineEditor { + line = "" + cursor = 0 + history: string[] = [] + historyIndex = -1 + + render(prompt: string) { + write(`\r${prompt}${this.line}${" ".repeat(10)}\r${prompt}`) + if (this.cursor > 0) { + write(cursor.forward(this.cursor)) + } + } + + handle(key: Key): string | null { + if (key.name === "return") { + const result = this.line + if (result.trim()) { + this.history.push(result) + } + this.line = "" + this.cursor = 0 + this.historyIndex = -1 + return result + } + + if (key.name === "backspace" && this.cursor > 0) { + this.line = this.line.slice(0, this.cursor - 1) + this.line.slice(this.cursor) + this.cursor-- + } + + if (key.name === "delete" && this.cursor < this.line.length) { + this.line = this.line.slice(0, this.cursor) + this.line.slice(this.cursor + 1) + } + + if (key.name === "left" && this.cursor > 0) { + this.cursor-- + } + + if (key.name === "right" && this.cursor < this.line.length) { + this.cursor++ + } + + if (key.name === "home") { + this.cursor = 0 + } + + if (key.name === "end") { + this.cursor = this.line.length + } + + if (key.name === "up" && this.history.length > 0) { + if (this.historyIndex < this.history.length - 1) { + this.historyIndex++ + this.line = this.history[this.history.length - 1 - this.historyIndex] + this.cursor = this.line.length + } + } + + if (key.name === "down") { + if (this.historyIndex > 0) { + this.historyIndex-- + this.line = this.history[this.history.length - 1 - this.historyIndex] + this.cursor = this.line.length + } else if (this.historyIndex === 0) { + this.historyIndex = -1 + this.line = "" + this.cursor = 0 + } + } + + if (key.name === "char" && key.char) { + this.line = this.line.slice(0, this.cursor) + key.char + this.line.slice(this.cursor) + this.cursor++ + } + + // Ctrl+L to clear + if (key.ctrl && key.name === "l") { + return "/clear" + } + + return null + } +} diff --git a/packages/opencode/src/cli/lite/session.ts b/packages/opencode/src/cli/lite/session.ts new file mode 100644 index 00000000000..1d2e2d7a02a --- /dev/null +++ b/packages/opencode/src/cli/lite/session.ts @@ -0,0 +1,113 @@ +import { Session } from "../../session" +import { SessionPrompt } from "../../session/prompt" +import { Agent } from "../../agent/agent" +import { Provider } from "../../provider/provider" +import { Instance } from "../../project/instance" +import { Log } from "../../util/log" +import { Bus } from "../../bus" +import { MessageV2 } from "../../session/message-v2" + +const log = Log.create({ service: "lite.session" }) + +export interface ChatChunk { + type: "text" | "tool_start" | "tool_end" | "error" | "done" + content?: string + tool?: string + input?: Record + output?: string +} + +interface ChatOptions { + model?: string + agent?: string + sessionID?: string +} + +export async function* chat(message: string, options?: ChatOptions): AsyncGenerator { + const sessionID = await (async () => { + if (options?.sessionID) return options.sessionID + const session = await Session.createNext({ directory: Instance.directory }) + return session.id + })() + + const agent = options?.agent || (await Agent.defaultAgent()) + const modelParam = options?.model ? Provider.parseModel(options.model) : undefined + + const buffer: ChatChunk[] = [] + let lastTextBuffer = "" + + const unsubscribe = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { + const part = event.properties.part + const sessionMatch = part.sessionID === sessionID + + if (!sessionMatch) return + + if (part.type === "text" && event.properties.delta) { + lastTextBuffer += event.properties.delta + buffer.push({ type: "text", content: event.properties.delta }) + } else if (part.type === "tool") { + if (part.state.status === "running") { + buffer.push({ + type: "tool_start", + tool: part.tool, + input: part.state.input, + }) + } else if (part.state.status === "completed") { + const output = part.state.output + buffer.push({ + type: "tool_end", + tool: part.tool, + output, + }) + } + } + }) + + try { + const parts = [{ type: "text" as const, text: message }] + await SessionPrompt.prompt({ + sessionID, + agent, + model: modelParam, + parts, + }) + + for (const chunk of buffer) { + yield chunk + } + + yield { type: "done" } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : undefined + log.error("chat error", { error: errorMessage, stack, sessionID }) + yield { type: "error", content: errorMessage } + } finally { + unsubscribe() + } +} + +export async function getOrCreateSession(title?: string): Promise { + const session = await Session.createNext({ + directory: Instance.directory, + title, + }) + return session.id +} + +export async function getSession(sessionID: string): Promise { + try { + return await Session.get(sessionID) + } catch { + return null + } +} + +export async function listSessions(): Promise { + const sessions: Session.Info[] = [] + for await (const session of Session.list()) { + sessions.push(session) + if (sessions.length >= 10) break + } + return sessions.reverse() +} diff --git a/packages/opencode/src/cli/lite/spinner.ts b/packages/opencode/src/cli/lite/spinner.ts new file mode 100644 index 00000000000..e0bcc613993 --- /dev/null +++ b/packages/opencode/src/cli/lite/spinner.ts @@ -0,0 +1,32 @@ +import { cursor, clear, fg, style, write } from "./terminal" + +const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + +export class Spinner { + private frame = 0 + private interval: ReturnType | null = null + private text: string + + constructor(text = "Processing") { + this.text = text + } + + start() { + write(cursor.hide) + this.interval = setInterval(() => { + this.frame = (this.frame + 1) % frames.length + write(`\r${clear.line}${fg.cyan}${frames[this.frame]}${style.reset} ${fg.gray}${this.text}${style.reset}`) + }, 80) + } + + update(text: string) { + this.text = text + } + + stop(success = true) { + if (this.interval) clearInterval(this.interval) + const icon = success ? `${fg.green}✓${style.reset}` : `${fg.red}✗${style.reset}` + write(`\r${clear.line}${icon} ${this.text}\n`) + write(cursor.show) + } +} diff --git a/packages/opencode/src/cli/lite/terminal.ts b/packages/opencode/src/cli/lite/terminal.ts new file mode 100644 index 00000000000..a85f1b19f61 --- /dev/null +++ b/packages/opencode/src/cli/lite/terminal.ts @@ -0,0 +1,82 @@ +// Raw ANSI terminal utilities - zero dependencies + +export const ESC = "\x1b" + +export const cursor = { + save: `${ESC}[s`, + restore: `${ESC}[u`, + hide: `${ESC}[?25l`, + show: `${ESC}[?25h`, + to: (row: number, col: number) => `${ESC}[${row};${col}H`, + up: (n = 1) => `${ESC}[${n}A`, + down: (n = 1) => `${ESC}[${n}B`, + forward: (n = 1) => `${ESC}[${n}C`, + back: (n = 1) => `${ESC}[${n}D`, + toColumn: (col: number) => `${ESC}[${col}G`, + home: `${ESC}[H`, +} + +export const clear = { + line: `${ESC}[2K`, + lineEnd: `${ESC}[K`, + lineStart: `${ESC}[1K`, + screen: `${ESC}[2J`, + screenEnd: `${ESC}[J`, + screenStart: `${ESC}[1J`, +} + +export const style = { + reset: `${ESC}[0m`, + bold: `${ESC}[1m`, + dim: `${ESC}[2m`, + italic: `${ESC}[3m`, + underline: `${ESC}[4m`, +} + +export const fg = { + black: `${ESC}[30m`, + red: `${ESC}[31m`, + green: `${ESC}[32m`, + yellow: `${ESC}[33m`, + blue: `${ESC}[34m`, + magenta: `${ESC}[35m`, + cyan: `${ESC}[36m`, + white: `${ESC}[37m`, + gray: `${ESC}[90m`, + brightRed: `${ESC}[91m`, + brightGreen: `${ESC}[92m`, + brightYellow: `${ESC}[93m`, + brightBlue: `${ESC}[94m`, + brightMagenta: `${ESC}[95m`, + brightCyan: `${ESC}[96m`, + brightWhite: `${ESC}[97m`, +} + +export const bg = { + black: `${ESC}[40m`, + red: `${ESC}[41m`, + green: `${ESC}[42m`, + yellow: `${ESC}[43m`, + blue: `${ESC}[44m`, + magenta: `${ESC}[45m`, + cyan: `${ESC}[46m`, + white: `${ESC}[47m`, +} + +export const screen = { + alt: `${ESC}[?1049h`, + main: `${ESC}[?1049l`, +} + +// Helper to write to stdout +export function write(s: string) { + process.stdout.write(s) +} + +// Get terminal dimensions +export function size() { + return { + rows: process.stdout.rows || 24, + cols: process.stdout.columns || 80, + } +} diff --git a/packages/opencode/src/cli/lite/ui.ts b/packages/opencode/src/cli/lite/ui.ts new file mode 100644 index 00000000000..000eafb0249 --- /dev/null +++ b/packages/opencode/src/cli/lite/ui.ts @@ -0,0 +1,38 @@ +import { cursor, write } from "./terminal" + +export interface Layout { + render: () => void +} + +export class Box implements Layout { + constructor( + private width: number, + private height: number, + private title = "", + ) {} + + render() { + const top = `┌${"─".repeat(this.width - 2)}┐` + const middle = `│${" ".repeat(this.width - 2)}│` + const bottom = `└${"─".repeat(this.width - 2)}┘` + + write(top) + for (let i = 0; i < this.height - 2; i++) { + write(`\n${middle}`) + } + write(`\n${bottom}`) + } +} + +export function text(content: string, x = 0, y = 0) { + write(cursor.to(y + 1, x + 1)) + write(content) +} + +export function border(width: number, height: number) { + const top = `┌${"─".repeat(width - 2)}┐` + const middle = `│${" ".repeat(width - 2)}│` + const bottom = `└${"─".repeat(width - 2)}┘` + + return [top, ...Array(height - 2).fill(middle), bottom] +} diff --git a/packages/opencode/test/cli/lite/session.test.ts b/packages/opencode/test/cli/lite/session.test.ts new file mode 100644 index 00000000000..7d412b0d653 --- /dev/null +++ b/packages/opencode/test/cli/lite/session.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, mock } from "bun:test" +import { Session } from "../../../src/session" +import { SessionPrompt } from "../../../src/session/prompt" +import { Agent } from "../../../src/agent/agent" +import { Provider } from "../../../src/provider/provider" +import { chat, getOrCreateSession, getSession, listSessions } from "../../../src/cli/lite/session" + +describe("session", () => { + it("exports ChatChunk interface types", () => { + const chunk: any = { type: "text", content: "hello" } + expect(chunk.type).toBe("text") + expect(chunk.content).toBe("hello") + }) + + it("exports chat async generator function", () => { + expect(typeof chat).toBe("function") + }) + + it("exports getOrCreateSession function", () => { + expect(typeof getOrCreateSession).toBe("function") + }) + + it("exports getSession function", () => { + expect(typeof getSession).toBe("function") + }) + + it("exports listSessions function", () => { + expect(typeof listSessions).toBe("function") + }) +}) From c00947d1b6f6f908af8e859d536b048c57c7c073 Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Mon, 26 Jan 2026 20:46:27 +0200 Subject: [PATCH 4/8] fix(tool): inherit parent session directory in subagents (#50) --- packages/opencode/src/tool/task.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index a737bfc2c69..41d89a38171 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -166,8 +166,14 @@ export const TaskTool = Tool.define("task", async (initCtx) => { if (found) return found } - return await Session.create({ + const parentSession = await Session.get(ctx.sessionID).catch(() => null) + if (!parentSession?.directory) { + throw new Error("Parent session not found or has no directory") + } + + return await Session.createNext({ parentID: ctx.sessionID, + directory: parentSession.directory, title: params.description + ` (@${agent.name} subagent)`, permission: [ { From c7fbf117f226b00fabed746c123caf07fb3c9e18 Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Mon, 26 Jan 2026 20:56:57 +0200 Subject: [PATCH 5/8] fix(provider): add comprehensive null checks in auth.ts (#52) --- packages/opencode/src/provider/auth.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index e6681ff0891..daf0c6ee2f2 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -54,11 +54,14 @@ export namespace ProviderAuth { export const authorize = fn( z.object({ providerID: z.string(), - method: z.number(), + method: z.number().int().nonnegative(), }), async (input): Promise => { const auth = await state().then((s) => s.methods[input.providerID]) + if (!auth) return undefined + if (input.method < 0 || input.method >= auth.methods.length) return undefined const method = auth.methods[input.method] + if (!method) return undefined if (method.type === "oauth") { const result = await method.authorize() await state().then((s) => (s.pending[input.providerID] = result)) @@ -92,13 +95,13 @@ export namespace ProviderAuth { } if (result?.type === "success") { - if ("key" in result) { + if ("key" in result && result.key) { await Auth.set(input.providerID, { type: "api", key: result.key, }) } - if ("refresh" in result) { + if ("refresh" in result && result.refresh) { const info: Auth.Info = { type: "oauth", access: result.access, From 7d4dd7f46f0f9de35a2da6b474e928e196e5c23d Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Mon, 26 Jan 2026 20:57:01 +0200 Subject: [PATCH 6/8] feat(cli): add task icons with color coding (#21) --- .../opencode/src/cli/cmd/tui/util/icons.ts | 83 ++++++++++++++++ .../test/cli/cmd/tui/util/icons.test.ts | 95 +++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/tui/util/icons.ts create mode 100644 packages/opencode/test/cli/cmd/tui/util/icons.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/util/icons.ts b/packages/opencode/src/cli/cmd/tui/util/icons.ts new file mode 100644 index 00000000000..7c8a4ff69a3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/icons.ts @@ -0,0 +1,83 @@ +/** + * Task state icons and color system for terminal UI. + * Provides visual feedback for task status, agent roles, and UI elements. + */ + +export namespace Icons { + /** + * ANSI color codes for terminal output + */ + export const colors = { + green: "\x1b[32m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + dim: "\x1b[2m", + red: "\x1b[31m", + magenta: "\x1b[35m", + reset: "\x1b[0m", + } + + /** + * Task status icons with their default colors + */ + export const icons = { + taskComplete: "✓", + taskPending: "□", + taskRunning: "●", + taskProgress: "◇", + agentRole: "🟪", + } + + /** + * Status colors lookup table (module-level for performance) + */ + const statusColors: Record = { + completed: colors.green, + pending: colors.dim, + running: colors.green, + progress: colors.yellow, + } + + /** + * Status icons lookup table (module-level for performance) + */ + const statusIcons: Record = { + completed: icons.taskComplete, + pending: icons.taskPending, + running: icons.taskRunning, + progress: icons.taskProgress, + } + + /** + * Returns a colored task status icon based on the given status + * @param status - The task status: 'completed', 'pending', 'running', or 'progress' + * @returns The colored icon as a string, or pending icon as safe fallback + */ + export function taskIcon(status: "completed" | "pending" | "running" | "progress"): string { + const color = statusColors[status] + const icon = statusIcons[status] + + if (!color || !icon) return `${colors.dim}${icons.taskPending}${colors.reset}` + return `${color}${icon}${colors.reset}` + } + + /** + * Returns a colored agent name badge in cyan + * @param name - The agent name + * @returns The colored agent name badge, or empty string if name is empty + */ + export function agentBadge(name: string): string { + if (!name) return "" + return `${colors.cyan}${name}${colors.reset}` + } + + /** + * Returns a colored role badge with a purple icon prefix + * @param role - The role name + * @returns The colored role badge with icon, or empty string if role is empty + */ + export function roleBadge(role: string): string { + if (!role) return "" + return `${colors.magenta}${icons.agentRole}${colors.reset} ${role}` + } +} diff --git a/packages/opencode/test/cli/cmd/tui/util/icons.test.ts b/packages/opencode/test/cli/cmd/tui/util/icons.test.ts new file mode 100644 index 00000000000..1eaaaed5660 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/util/icons.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for task state icons and color system + */ + +import { describe, it, expect } from "bun:test" +import { Icons } from "../../../../../src/cli/cmd/tui/util/icons" + +describe("Icons.taskIcon", () => { + it("returns green checkmark for completed status", () => { + const result = Icons.taskIcon("completed") + expect(result).toContain(Icons.colors.green) + expect(result).toContain(Icons.icons.taskComplete) + expect(result).toContain(Icons.colors.reset) + }) + + it("returns dim square for pending status", () => { + const result = Icons.taskIcon("pending") + expect(result).toContain(Icons.colors.dim) + expect(result).toContain(Icons.icons.taskPending) + expect(result).toContain(Icons.colors.reset) + }) + + it("returns green circle for running status", () => { + const result = Icons.taskIcon("running") + expect(result).toContain(Icons.colors.green) + expect(result).toContain(Icons.icons.taskRunning) + expect(result).toContain(Icons.colors.reset) + }) + + it("returns yellow diamond for progress status", () => { + const result = Icons.taskIcon("progress") + expect(result).toContain(Icons.colors.yellow) + expect(result).toContain(Icons.icons.taskProgress) + expect(result).toContain(Icons.colors.reset) + }) + + it("returns safe fallback for invalid status", () => { + const result = Icons.taskIcon("invalid" as any) + expect(result).toContain(Icons.colors.dim) + expect(result).toContain(Icons.icons.taskPending) + }) +}) + +describe("Icons.agentBadge", () => { + it("returns empty string for empty name", () => { + const result = Icons.agentBadge("") + expect(result).toBe("") + }) + + it("returns colored badge for valid name", () => { + const result = Icons.agentBadge("DevAgent") + expect(result).toContain(Icons.colors.cyan) + expect(result).toContain("DevAgent") + expect(result).toContain(Icons.colors.reset) + }) + + it("preserves name content exactly", () => { + const name = "MySpecialAgent123" + const result = Icons.agentBadge(name) + expect(result).toContain(name) + }) + + it("handles names with special characters", () => { + const name = "agent-name_2" + const result = Icons.agentBadge(name) + expect(result).toContain(name) + }) +}) + +describe("Icons.roleBadge", () => { + it("returns empty string for empty role", () => { + const result = Icons.roleBadge("") + expect(result).toBe("") + }) + + it("returns colored badge with icon for valid role", () => { + const result = Icons.roleBadge("developer") + expect(result).toContain(Icons.colors.magenta) + expect(result).toContain(Icons.icons.agentRole) + expect(result).toContain("developer") + expect(result).toContain(Icons.colors.reset) + }) + + it("preserves role content exactly", () => { + const role = "ProductManager" + const result = Icons.roleBadge(role) + expect(result).toContain(role) + }) + + it("includes icon separator in output", () => { + const result = Icons.roleBadge("admin") + const parts = result.split(" ") + expect(parts.length).toBeGreaterThanOrEqual(2) + }) +}) From e9226db61fa5eb25de03713f177f51ce4f3847e3 Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Mon, 26 Jan 2026 20:57:06 +0200 Subject: [PATCH 7/8] feat(cli): add metrics formatting utilities (#20) --- packages/opencode/src/cli/metrics.ts | 46 +++++ packages/opencode/test/cli/metrics.test.ts | 225 +++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 packages/opencode/src/cli/metrics.ts create mode 100644 packages/opencode/test/cli/metrics.test.ts diff --git a/packages/opencode/src/cli/metrics.ts b/packages/opencode/src/cli/metrics.ts new file mode 100644 index 00000000000..981a6eeaefc --- /dev/null +++ b/packages/opencode/src/cli/metrics.ts @@ -0,0 +1,46 @@ +export interface AgentMetrics { + name: string + activity: string + toolUses: number + tokens: number + startedAt: number +} + +export function formatTokens(count: number): string { + if (count < 0) return "0 tokens" + if (count === 0) return "0 tokens" + if (count === 1) return "1 token" + if (count < 1000) return `${count} tokens` + + if (count >= 1000000) { + const millions = Math.round(count / 100000) / 10 + return `${millions}m tokens` + } + + const thousands = count / 1000 + const rounded = Math.round(thousands * 10) / 10 + + return `${rounded}k tokens` +} + +export function formatDuration(ms: number): string { + if (ms < 0) return "just started" + + const seconds = Math.floor(ms / 1000) + + if (seconds <= 0) return "just started" + if (seconds < 60) return `~${seconds}s` + + const minutes = Math.floor(seconds / 60) + return `~${minutes}m` +} + +export function formatAgentMetrics(agent: AgentMetrics): string { + const now = Date.now() + const duration = formatDuration(now - agent.startedAt) + const tokens = formatTokens(agent.tokens) + const safeActivity = agent.activity.replace(/\x1b\[[0-9;]*m/g, "").replace(/[\x00-\x1f\x7f]/g, "") + const tools = `${agent.toolUses} tool uses` + + return `${agent.name}: ◇ ${safeActivity}… · ${tools} · ${tokens}` +} diff --git a/packages/opencode/test/cli/metrics.test.ts b/packages/opencode/test/cli/metrics.test.ts new file mode 100644 index 00000000000..c6d92fdb83d --- /dev/null +++ b/packages/opencode/test/cli/metrics.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, test } from "bun:test" +import { formatTokens, formatDuration, formatAgentMetrics, type AgentMetrics } from "../../src/cli/metrics" + +describe("cli.metrics", () => { + describe("formatTokens", () => { + test("returns zero tokens string for zero", () => { + expect(formatTokens(0)).toBe("0 tokens") + }) + + test("handles negative token counts", () => { + expect(formatTokens(-1)).toBe("0 tokens") + expect(formatTokens(-100)).toBe("0 tokens") + expect(formatTokens(-1000000)).toBe("0 tokens") + }) + + test("formats singular token correctly", () => { + expect(formatTokens(1)).toBe("1 token") + }) + + test("formats tokens under 1000 without scaling", () => { + expect(formatTokens(2)).toBe("2 tokens") + expect(formatTokens(100)).toBe("100 tokens") + expect(formatTokens(999)).toBe("999 tokens") + }) + + test("formats thousands with k suffix", () => { + expect(formatTokens(1000)).toBe("1k tokens") + expect(formatTokens(1500)).toBe("1.5k tokens") + expect(formatTokens(10000)).toBe("10k tokens") + }) + + test("formats large numbers with k suffix", () => { + expect(formatTokens(93700)).toBe("93.7k tokens") + expect(formatTokens(99999)).toBe("100k tokens") + expect(formatTokens(100000)).toBe("100k tokens") + }) + + test("rounds to one decimal place", () => { + expect(formatTokens(1234)).toBe("1.2k tokens") + expect(formatTokens(1567)).toBe("1.6k tokens") + expect(formatTokens(1999)).toBe("2k tokens") + }) + + test("formats millions with m suffix", () => { + expect(formatTokens(1000000)).toBe("1m tokens") + expect(formatTokens(1500000)).toBe("1.5m tokens") + expect(formatTokens(5000000)).toBe("5m tokens") + expect(formatTokens(10000000)).toBe("10m tokens") + expect(formatTokens(99999999)).toBe("100m tokens") + }) + }) + + describe("formatDuration", () => { + test("handles future timestamps gracefully", () => { + expect(formatDuration(-1000)).toBe("just started") + expect(formatDuration(-500)).toBe("just started") + expect(formatDuration(-1)).toBe("just started") + }) + + test("returns just started for zero and near-zero milliseconds", () => { + expect(formatDuration(0)).toBe("just started") + expect(formatDuration(500)).toBe("just started") + }) + + test("formats seconds under 60", () => { + expect(formatDuration(1000)).toBe("~1s") + expect(formatDuration(30000)).toBe("~30s") + expect(formatDuration(59000)).toBe("~59s") + }) + + test("formats minutes", () => { + expect(formatDuration(60000)).toBe("~1m") + expect(formatDuration(90000)).toBe("~1m") + expect(formatDuration(120000)).toBe("~2m") + expect(formatDuration(300000)).toBe("~5m") + }) + + test("formats longer durations in minutes", () => { + expect(formatDuration(600000)).toBe("~10m") + expect(formatDuration(3600000)).toBe("~60m") + }) + + test("handles boundary values", () => { + expect(formatDuration(999)).toBe("just started") + expect(formatDuration(1000)).toBe("~1s") + expect(formatDuration(59999)).toBe("~59s") + expect(formatDuration(60000)).toBe("~1m") + }) + }) + + describe("formatAgentMetrics", () => { + test("formats agent metrics with all components", () => { + const now = Date.now() + const agent: AgentMetrics = { + name: "developer", + activity: "Thinking", + toolUses: 68, + tokens: 93700, + startedAt: now - 60000, + } + + const result = formatAgentMetrics(agent) + + expect(result).toContain("developer:") + expect(result).toContain("◇") + expect(result).toContain("Thinking…") + expect(result).toContain("68 tool uses") + expect(result).toContain("93.7k tokens") + }) + + test("formats with same duration calculation regardless of time elapsed", () => { + const now = Date.now() + const agent: AgentMetrics = { + name: "explore", + activity: "Searching", + toolUses: 5, + tokens: 5000, + startedAt: now - 120000, + } + + const result = formatAgentMetrics(agent) + expect(result).toContain("explore:") + expect(result).toContain("Searching…") + expect(result).toContain("5 tool uses") + expect(result).toContain("5k tokens") + }) + + test("uses standard format with diamond symbol", () => { + const now = Date.now() + const agent: AgentMetrics = { + name: "test-agent", + activity: "Running", + toolUses: 42, + tokens: 12500, + startedAt: now - 30000, + } + + const result = formatAgentMetrics(agent) + const expected = "test-agent: ◇ Running… · 42 tool uses · 12.5k tokens" + + expect(result).toContain("test-agent:") + expect(result).toContain("◇") + expect(result).toContain("Running…") + expect(result).toContain("42 tool uses") + expect(result).toContain("12.5k tokens") + }) + + test("handles just started duration correctly", () => { + const now = Date.now() + const agent: AgentMetrics = { + name: "fresh", + activity: "Initializing", + toolUses: 0, + tokens: 0, + startedAt: now - 100, + } + + const result = formatAgentMetrics(agent) + expect(result).toContain("fresh:") + expect(result).toContain("Initializing…") + expect(result).toContain("0 tool uses") + expect(result).toContain("0 tokens") + }) + + test("formats multiple agents independently", () => { + const now = Date.now() + const agents: AgentMetrics[] = [ + { + name: "developer", + activity: "Writing", + toolUses: 10, + tokens: 5000, + startedAt: now - 30000, + }, + { + name: "explore", + activity: "Searching", + toolUses: 25, + tokens: 15000, + startedAt: now - 60000, + }, + ] + + const results = agents.map(formatAgentMetrics) + + expect(results[0]).toContain("developer:") + expect(results[0]).toContain("Writing…") + expect(results[1]).toContain("explore:") + expect(results[1]).toContain("Searching…") + }) + + test("strips ANSI escape sequences from activity", () => { + const now = Date.now() + const agent: AgentMetrics = { + name: "test", + activity: "Write\x1b[31mRed\x1b[0m Text", + toolUses: 5, + tokens: 1000, + startedAt: now - 10000, + } + + const result = formatAgentMetrics(agent) + + expect(result).not.toContain("\x1b") + expect(result).toContain("WriteRed Text…") + }) + + test("handles null bytes and control characters", () => { + const now = Date.now() + const agent: AgentMetrics = { + name: "test", + activity: "Safe\x00Activity\x7fNow", + toolUses: 3, + tokens: 500, + startedAt: now - 5000, + } + + const result = formatAgentMetrics(agent) + + expect(result).not.toContain("\x00") + expect(result).not.toContain("\x7f") + expect(result).toContain("SafeActivityNow…") + }) + }) +}) From a90cb42a6125434a9b58b3b3139adee658f53da5 Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Mon, 26 Jan 2026 21:03:46 +0200 Subject: [PATCH 8/8] fix(project): handle empty commits and worktree path resolution --- packages/opencode/src/project/project.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index e7bde945ddb..d1b0deae391 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -96,12 +96,12 @@ export namespace Project { ) .catch(() => undefined) - if (!roots) { + if (!roots || roots.length === 0) { return { id: "global", worktree: sandbox, sandbox: sandbox, - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), + vcs: "git", } } @@ -139,13 +139,11 @@ export namespace Project { const gitDir = gitDirContent.replace("gitdir: ", "") const gitCommonDirPath = path.join(gitDir, "commondir") if (await Bun.file(gitCommonDirPath).exists()) { - const commonDir = - path.dirname(gitDir) + - "/" + - (await Bun.file(gitCommonDirPath) - .text() - .then((x) => x.trim())) - worktree = path.dirname(path.resolve(commonDir)) + const commonDirContent = await Bun.file(gitCommonDirPath) + .text() + .then((x) => x.trim()) + const commonGitDir = path.resolve(gitDir, commonDirContent) + worktree = path.dirname(commonGitDir) } }