@gajae-code/coding-agent 0.11.3 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/types/cli/worktree-cli.d.ts +8 -25
  3. package/dist/types/commands/gc.d.ts +4 -0
  4. package/dist/types/commands/worktree.d.ts +7 -32
  5. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  6. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  7. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  8. package/dist/types/modes/components/tool-transcript-format.d.ts +19 -0
  9. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  10. package/dist/types/sdk/broker/broker.d.ts +1 -0
  11. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  12. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  13. package/dist/types/sdk/broker/internal.d.ts +3 -0
  14. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  15. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  16. package/dist/types/tools/ask.d.ts +39 -12
  17. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  18. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  19. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  20. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  21. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  22. package/dist/types/tools/path-utils.d.ts +9 -14
  23. package/dist/types/tools/render-utils.d.ts +1 -8
  24. package/dist/types/tools/resource-gc.d.ts +14 -0
  25. package/dist/types/utils/image-loading.d.ts +23 -0
  26. package/dist/types/utils/image-resize.d.ts +2 -0
  27. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  28. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  29. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  30. package/dist/types/web/scrapers/types.d.ts +1 -5
  31. package/package.json +24 -8
  32. package/scripts/run-threat-model-check.ts +60 -0
  33. package/src/cli/worktree-cli.ts +9 -287
  34. package/src/cli.ts +10 -9
  35. package/src/commands/gc.ts +11 -1
  36. package/src/commands/sdk.ts +3 -2
  37. package/src/commands/worktree.ts +10 -53
  38. package/src/config/model-profiles.ts +15 -15
  39. package/src/eval/js/shared/runtime.ts +1 -1
  40. package/src/gjc-runtime/gc-render.ts +19 -1
  41. package/src/gjc-runtime/gc-runtime.ts +68 -3
  42. package/src/gjc-runtime/state-argv.ts +292 -0
  43. package/src/gjc-runtime/state-runtime.ts +42 -210
  44. package/src/modes/components/custom-editor.ts +103 -32
  45. package/src/modes/components/custom-provider-wizard.ts +26 -1
  46. package/src/modes/components/session-observer-overlay.ts +10 -23
  47. package/src/modes/components/tool-transcript-format.ts +72 -0
  48. package/src/modes/components/transcript-viewer-overlay.ts +42 -2
  49. package/src/modes/controllers/input-controller.ts +98 -37
  50. package/src/modes/controllers/selector-controller.ts +1 -1
  51. package/src/modes/interactive-mode.ts +13 -2
  52. package/src/sdk/broker/broker.ts +139 -32
  53. package/src/sdk/broker/discovery.ts +125 -0
  54. package/src/sdk/broker/ensure.ts +103 -19
  55. package/src/sdk/broker/internal.ts +10 -0
  56. package/src/sdk/broker/session-index.ts +228 -15
  57. package/src/sdk/broker/state-version.ts +5 -2
  58. package/src/sdk/bus/telegram-daemon.ts +50 -10
  59. package/src/session/internal/managed-session-scope.ts +15 -1
  60. package/src/tools/ask.ts +216 -41
  61. package/src/tools/bash-allowed-prefixes.ts +79 -29
  62. package/src/tools/browser/launch.ts +3 -1
  63. package/src/tools/browser/readable.ts +1 -1
  64. package/src/tools/browser/screenshot-format.ts +34 -0
  65. package/src/tools/browser/tab-protocol.ts +3 -0
  66. package/src/tools/browser/tab-supervisor.ts +125 -122
  67. package/src/tools/browser/tab-worker-entry.ts +33 -14
  68. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  69. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  70. package/src/tools/browser/tab-worker.ts +7 -5
  71. package/src/tools/path-utils.ts +42 -35
  72. package/src/tools/read.ts +7 -1
  73. package/src/tools/render-utils.ts +1 -27
  74. package/src/tools/resource-gc.ts +123 -34
  75. package/src/utils/image-loading.ts +85 -34
  76. package/src/utils/image-resize.ts +16 -9
  77. package/src/utils/pasted-image-loading.ts +514 -0
  78. package/src/utils/pasted-image-path.ts +223 -98
  79. package/src/web/scrapers/html-to-markdown.ts +68 -0
  80. package/src/web/scrapers/readthedocs.ts +1 -1
  81. package/src/web/scrapers/types.ts +1 -68
  82. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -44,11 +44,25 @@ export interface ResourceGcRegistration {
44
44
  export declare function registerResourceGcSession(reg: ResourceGcRegistration): () => void;
45
45
  export declare function sweepOnce(d?: ResourceGcDeps): Promise<void>;
46
46
  export declare function __setResourceGcDepsForTest(overrides: Partial<ResourceGcDeps>): void;
47
+ export declare function __setResourceGcSchedulerNowForTest(now: () => number): void;
47
48
  export declare function __runResourceGcTickForTest(): Promise<void>;
49
+ export declare function __runResourceGcTimerCallbackForTest(owner: {
50
+ generation: number;
51
+ token: number;
52
+ }, deadline: number): Promise<void>;
48
53
  export declare function __getResourceGcStateForTest(): {
49
54
  timerActive: boolean;
50
55
  sessionCount: number;
51
56
  rssWarningActive: boolean;
52
57
  inProgress: boolean;
58
+ generation: number;
59
+ pendingDeadline: number | null;
60
+ pendingOwner: {
61
+ generation: number;
62
+ token: number;
63
+ } | null;
64
+ deferredDeadline: number | null;
65
+ deferredGeneration: number | null;
66
+ activeGeneration: number | null;
53
67
  };
54
68
  export declare function __resetResourceGcForTest(): void;
@@ -8,6 +8,24 @@ export interface LoadImageInputOptions {
8
8
  maxBytes?: number;
9
9
  resolvedPath?: string;
10
10
  detectedMimeType?: string;
11
+ signal?: AbortSignal;
12
+ }
13
+ export interface LoadImageInputBytesOptions {
14
+ inputBuffer: Buffer;
15
+ resolvedPath: string;
16
+ mimeType: string;
17
+ autoResize: boolean;
18
+ maxBytes?: number;
19
+ maxOutputBytes?: number;
20
+ signal?: AbortSignal;
21
+ }
22
+ export interface TransformedImageInput {
23
+ resolvedPath: string;
24
+ mimeType: string;
25
+ buffer: Uint8Array;
26
+ textNote: string;
27
+ dimensionNote?: string;
28
+ bytes: number;
11
29
  }
12
30
  export interface LoadedImageInput {
13
31
  resolvedPath: string;
@@ -23,4 +41,9 @@ export declare class ImageInputTooLargeError extends Error {
23
41
  constructor(bytes: number, maxBytes: number);
24
42
  }
25
43
  export declare function ensureSupportedImageInput(image: ImageContent): Promise<ImageContent | null>;
44
+ /** Transform trusted source bytes without materializing a base64 payload. */
45
+ export declare function transformImageInputBytes(options: LoadImageInputBytesOptions): Promise<TransformedImageInput>;
46
+ export declare function materializeImageInput(transformed: TransformedImageInput): LoadedImageInput;
47
+ /** Build a model image payload from bytes already read through a trusted descriptor. */
48
+ export declare function loadImageInputBytes(options: LoadImageInputBytesOptions): Promise<LoadedImageInput>;
26
49
  export declare function loadImageInput(options: LoadImageInputOptions): Promise<LoadedImageInput | null>;
@@ -16,6 +16,7 @@ export interface ResizedImage {
16
16
  wasResized: boolean;
17
17
  get data(): string;
18
18
  }
19
+ export declare const DEFAULT_IMAGE_RESIZE_MAX_BYTES: number;
19
20
  /**
20
21
  * Resize and recompress an image to fit within the specified max dimensions and file size.
21
22
  *
@@ -31,6 +32,7 @@ export interface ResizedImage {
31
32
  * Backed by `Bun.Image`: a chainable native pipeline that runs decode/transform/encode
32
33
  * off the JS thread when the terminal (`.bytes()`) is awaited.
33
34
  */
35
+ export declare function resizeImageBuffer(inputBuffer: Buffer, inputMimeType: string, options?: ImageResizeOptions): Promise<ResizedImage>;
34
36
  export declare function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage>;
35
37
  /**
36
38
  * Format a dimension note for resized images.
@@ -0,0 +1,39 @@
1
+ import type { ImageContent } from "@gajae-code/ai";
2
+ import { type LoadedImageInput, transformImageInputBytes } from "./image-loading";
3
+ export declare const MAX_PASTED_IMAGE_SOURCE_BYTES: number;
4
+ export declare const MAX_PASTED_IMAGE_OUTPUT_BYTES: number;
5
+ export declare const MAX_PASTED_IMAGE_DIMENSION = 20000;
6
+ export declare const MAX_PASTED_IMAGE_PIXELS = 40000000;
7
+ export declare const MAX_PASTED_IMAGE_DECODED_BYTES: number;
8
+ export type PastedImageBatchErrorCode = "too-many" | "unsafe-path" | "unsupported-image" | "source-aggregate-too-large" | "output-aggregate-too-large" | "dimensions-too-large";
9
+ export declare class PastedImageBatchError extends Error {
10
+ readonly code: PastedImageBatchErrorCode;
11
+ readonly imagePath?: string | undefined;
12
+ constructor(code: PastedImageBatchErrorCode, message: string, imagePath?: string | undefined);
13
+ }
14
+ export interface LoadedPastedImageBatch {
15
+ images: ImageContent[];
16
+ loadedInputs: LoadedImageInput[];
17
+ sourcePaths: string[];
18
+ }
19
+ export interface PastedImageLoadingDependencies {
20
+ /** Synchronization hook used to exercise pathname swaps after descriptor open. */
21
+ afterDescriptorOpen?: (imagePath: string, index: number) => Promise<void> | void;
22
+ beforeDescriptorRead?: (imagePath: string, index: number) => Promise<void> | void;
23
+ transformImageBytes?: typeof transformImageInputBytes;
24
+ }
25
+ export type PastedImageSourcePolicy = "confirmed" | "automatic-temp";
26
+ export interface LoadPastedImageBatchOptions {
27
+ paths: readonly string[];
28
+ autoResize: boolean;
29
+ signal: AbortSignal;
30
+ sourcePolicy?: PastedImageSourcePolicy;
31
+ maxImageBytes?: number;
32
+ maxSourceBytes?: number;
33
+ maxOutputBytes?: number;
34
+ maxDimension?: number;
35
+ maxPixels?: number;
36
+ maxDecodedBytes?: number;
37
+ dependencies?: PastedImageLoadingDependencies;
38
+ }
39
+ export declare function loadPastedImageBatch(options: LoadPastedImageBatchOptions): Promise<LoadedPastedImageBatch>;
@@ -1,9 +1,8 @@
1
+ export declare const MAX_PASTED_IMAGE_COUNT = 16;
2
+ export declare const MAX_PASTED_IMAGE_PASTE_CHARACTERS: number;
3
+ export declare const MAX_PASTED_IMAGE_PATH_CHARACTERS: number;
1
4
  export interface DecodePastedPathOptions {
2
- /**
3
- * Platform whose path semantics apply when decoding (shell unescaping,
4
- * `file://` drive letters / UNC hosts). Defaults to `process.platform`;
5
- * injectable so tests can pin the win32 contract from any host.
6
- */
5
+ /** Platform whose path and shell semantics apply. Defaults to `process.platform`. */
7
6
  platform?: NodeJS.Platform;
8
7
  /** Home directory for `~/` expansion. Defaults to `os.homedir()`. */
9
8
  homedir?: string;
@@ -12,21 +11,24 @@ export interface ResolvePastedImagePathOptions extends DecodePastedPathOptions {
12
11
  /** Base directory for relative paths. Defaults to `process.cwd()`. */
13
12
  cwd?: string;
14
13
  }
15
- /**
16
- * Decode pasted text into a filesystem path candidate. No filesystem access.
17
- *
18
- * Handles terminal drag-drop shell escaping (`\ `, `\(`, ...; skipped on
19
- * win32 where `\` is the path separator), quoted paths, `file://` URIs
20
- * (drive-letter, `file://localhost`, and UNC forms on win32), and `~/`
21
- * expansion. Returns `undefined` when the text is empty, spans multiple
22
- * lines, or is an invalid `file://` URI.
23
- */
14
+ export type PastedImagePathParseResult = {
15
+ kind: "paths";
16
+ paths: string[];
17
+ requiresConfirmation: boolean;
18
+ } | {
19
+ kind: "too-many";
20
+ maxCandidates: number;
21
+ };
22
+ /** Decode one pasted path candidate without filesystem access. */
24
23
  export declare function decodePastedPathCandidate(text: string, options?: DecodePastedPathOptions): string | undefined;
24
+ /** Tokenize a bounded complete path list without filesystem access. */
25
+ export declare function decodePastedPathCandidates(text: string, options?: DecodePastedPathOptions, maxCandidates?: number): string[] | undefined;
26
+ /** Resolve one recognized clipboard-temp image path lexically, before any filesystem access. */
27
+ export declare function resolvePastedImagePath(text: string, options?: ResolvePastedImagePathOptions): string | undefined;
25
28
  /**
26
- * Returns the resolved path when the whole pasted text is a recognized clipboard
27
- * temp path to an existing image file, otherwise `undefined` (the paste is
28
- * inserted as text). Arbitrary saved image paths are not auto-attached here;
29
- * users attach them explicitly with `@path/to/image.png`.
29
+ * Parse a complete image path paste without filesystem access. A single path
30
+ * keeps the clipboard-temp-only policy; multi-path saved-image pastes require
31
+ * explicit confirmation before any path is opened.
30
32
  */
31
- export declare function resolvePastedImagePath(text: string, options?: ResolvePastedImagePathOptions): string | undefined;
33
+ export declare function parsePastedImagePaths(text: string, options?: ResolvePastedImagePathOptions): PastedImagePathParseResult | undefined;
32
34
  export declare function formatPastedImageReference(placeholder: string, imagePath: string): string;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Convert HTML to markdown using Turndown with GFM support.
3
+ * Strips script/style tags before conversion.
4
+ */
5
+ export declare function htmlToBasicMarkdown(html: string): Promise<string>;
@@ -44,11 +44,7 @@ export interface LoadPageResult {
44
44
  * Fetch a page with timeout and size limit
45
45
  */
46
46
  export declare function loadPage(url: string, options?: LoadPageOptions): Promise<LoadPageResult>;
47
- /**
48
- * Convert HTML to markdown using Turndown with GFM support.
49
- * Strips script/style tags before conversion.
50
- */
51
- export declare function htmlToBasicMarkdown(html: string): Promise<string>;
47
+ export { htmlToBasicMarkdown } from "./html-to-markdown";
52
48
  /**
53
49
  * Build a RenderResult from markdown content. Calls finalizeOutput internally.
54
50
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/coding-agent",
4
- "version": "0.11.3",
4
+ "version": "0.11.4",
5
5
  "description": "Gajae Code CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -53,13 +53,13 @@
53
53
  "@agentclientprotocol/sdk": "1.2.1",
54
54
  "@babel/parser": "^7.29.3",
55
55
  "@mozilla/readability": "^0.6.0",
56
- "@gajae-code/stats": "0.11.3",
57
- "@gajae-code/agent-core": "0.11.3",
58
- "@gajae-code/ai": "0.11.3",
59
- "@gajae-code/bridge-client": "0.11.3",
60
- "@gajae-code/natives": "0.11.3",
61
- "@gajae-code/tui": "0.11.3",
62
- "@gajae-code/utils": "0.11.3",
56
+ "@gajae-code/stats": "0.11.4",
57
+ "@gajae-code/agent-core": "0.11.4",
58
+ "@gajae-code/ai": "0.11.4",
59
+ "@gajae-code/bridge-client": "0.11.4",
60
+ "@gajae-code/natives": "0.11.4",
61
+ "@gajae-code/tui": "0.11.4",
62
+ "@gajae-code/utils": "0.11.4",
63
63
  "@puppeteer/browsers": "^2.13.0",
64
64
  "@types/turndown": "5.0.6",
65
65
  "@xterm/headless": "^6.0.0",
@@ -171,6 +171,14 @@
171
171
  "types": "./dist/types/capability/*.d.ts",
172
172
  "import": "./src/capability/*.ts"
173
173
  },
174
+ "./cli/worktree-cli": {
175
+ "types": "./dist/types/cli/worktree-cli.d.ts",
176
+ "import": "./src/cli/worktree-cli.ts"
177
+ },
178
+ "./cli/worktree-cli.js": {
179
+ "types": "./dist/types/cli/worktree-cli.d.ts",
180
+ "import": "./src/cli/worktree-cli.ts"
181
+ },
174
182
  "./cli/*": {
175
183
  "types": "./dist/types/cli/*.d.ts",
176
184
  "import": "./src/cli/*.ts"
@@ -187,6 +195,14 @@
187
195
  "./commands/ultraqa": null,
188
196
  "./commands/ultrawork": null,
189
197
  "./commands/performance-goal": null,
198
+ "./commands/worktree": {
199
+ "types": "./dist/types/commands/worktree.d.ts",
200
+ "import": "./src/commands/worktree.ts"
201
+ },
202
+ "./commands/worktree.js": {
203
+ "types": "./dist/types/commands/worktree.d.ts",
204
+ "import": "./src/commands/worktree.ts"
205
+ },
190
206
  "./commands/*": {
191
207
  "types": "./dist/types/commands/*.d.ts",
192
208
  "import": "./src/commands/*.ts"
@@ -14,6 +14,61 @@ function assert(condition: unknown, message: string): asserts condition {
14
14
  if (!condition) throw new Error(message);
15
15
  }
16
16
 
17
+ async function sourceFiles(root: string): Promise<string[]> {
18
+ const files: string[] = [];
19
+ for (const entry of await fs.readdir(root, { withFileTypes: true })) {
20
+ const file = path.join(root, entry.name);
21
+ if (entry.isDirectory()) files.push(...(await sourceFiles(file)));
22
+ else if (/\.(?:ts|tsx|js|mjs|cjs)$/.test(entry.name)) files.push(file);
23
+ }
24
+ return files;
25
+ }
26
+
27
+ function hasDiscoverySignalAuthority(source: string): boolean {
28
+ return /(?:process\.)?kill\s*\(\s*(?:discovery|brokerDiscovery|record)\.pid\s*,\s*["']SIG(?:TERM|KILL)["']/m.test(
29
+ source,
30
+ );
31
+ }
32
+
33
+ async function assertNoDiscoverySignalAuthority(): Promise<void> {
34
+ const roots = [path.resolve(import.meta.dir, "../src/sdk"), path.resolve(import.meta.dir, "../test")];
35
+ const offenders: string[] = [];
36
+ for (const root of roots) {
37
+ for (const file of await sourceFiles(root)) {
38
+ if (hasDiscoverySignalAuthority(await fs.readFile(file, "utf8")))
39
+ offenders.push(path.relative(process.cwd(), file));
40
+ }
41
+ }
42
+ assert(offenders.length === 0, `Discovery-derived TERM/KILL authority remains in: ${offenders.join(", ")}`);
43
+ assert(
44
+ hasDiscoverySignalAuthority('process.kill(discovery.pid, "SIGTERM")'),
45
+ "Synthetic forbidden discovery signal was not detected",
46
+ );
47
+ assert(
48
+ !hasDiscoverySignalAuthority("process.kill(discovery.pid, 0)"),
49
+ "PID-zero observation was escalated to signal authority",
50
+ );
51
+ }
52
+
53
+ async function assertFixtureControlsStayTestOnly(): Promise<void> {
54
+ const productionRoot = path.resolve(import.meta.dir, "../src");
55
+ const offenders: string[] = [];
56
+ for (const file of await sourceFiles(productionRoot)) {
57
+ const source = await fs.readFile(file, "utf8");
58
+ if (/\b(?:GSF1|SSH1)\b/.test(source)) offenders.push(path.relative(process.cwd(), file));
59
+ }
60
+ assert(
61
+ offenders.length === 0,
62
+ `Fixture control frames are reachable from production source: ${offenders.join(", ")}`,
63
+ );
64
+ const ensureSource = await fs.readFile(path.resolve(import.meta.dir, "../src/sdk/broker/ensure.ts"), "utf8");
65
+ assert(
66
+ ensureSource.includes("startFixtureBrokerCommandWithLeaseForTest"),
67
+ "Dedicated fixture launch boundary is missing its explicit ForTest label",
68
+ );
69
+ assert(!/GJC_.*(?:GSF1|SSH1|SELF_REAP)/.test(ensureSource), "Production environment can select fixture controls");
70
+ }
71
+
17
72
  async function rejectedWebSocket(url: string): Promise<void> {
18
73
  const ws = new WebSocket(url);
19
74
  await new Promise<void>((resolve, reject) => {
@@ -132,6 +187,11 @@ async function main(): Promise<void> {
132
187
  assert(!discovery.url.includes("0.0.0.0"), "non-loopback broker URL exists");
133
188
  await rejectedWebSocket(`${discovery.url}/?token=wrong-token`);
134
189
  },
190
+ "broker-discovery-derived-signal-authority-forbidden": assertNoDiscoverySignalAuthority,
191
+ "broker-pid-zero-observation-allowed": async () => {
192
+ assert(!hasDiscoverySignalAuthority("process.kill(discovery.pid, 0)"), "PID-zero observation was rejected");
193
+ },
194
+ "broker-fixture-control-nonproduction-reachability": assertFixtureControlsStayTestOnly,
135
195
  };
136
196
  for (const check of manifest.checks) {
137
197
  try {
@@ -1,291 +1,13 @@
1
1
  /**
2
- * CLI handler for `gjc worktree` — list and clean up agent-managed worktrees.
2
+ * Tombstone for the removed legacy worktree cleanup API.
3
3
  *
4
- * Layout under `~/.gjc/wt/`:
5
- *
6
- * - **PR-checkout worktrees** (`tools/gh.ts`): a regular git worktree dir
7
- * containing a `.git` *file* that points back at
8
- * `<parent-repo>/.git/worktrees/<name>/`.
9
- * - **Task-isolation dirs** (`task/worktree.ts`): a wrapper dir with a
10
- * `merged` subdir mounted/cloned by `natives.isoStart`. These are ephemeral
11
- * — `ensureIsolation` always `rm -rf`s the base before re-creating it, so
12
- * any leftover on disk is a leak from a crashed run.
13
- *
14
- * Legacy entries from before the encoding change keep working because git still
15
- * tracks them by branch name. This command exists to GC them on demand.
4
+ * The recursive-deletion implementation that previously lived here
5
+ * (`listWorktrees`/`clearWorktrees`) was deliberately removed and does not
6
+ * come back through this subpath. This module exists only to fail imports
7
+ * with actionable migration guidance.
16
8
  */
17
- import * as fs from "node:fs/promises";
18
- import * as path from "node:path";
19
- import { getWorktreesDir, isEnoent } from "@gajae-code/utils";
20
- import chalk from "chalk";
21
- import * as git from "../utils/git";
22
-
23
- type WorktreeKind = "pr-checkout" | "task-isolation" | "empty" | "stray";
24
-
25
- export interface WorktreeEntry {
26
- /** Absolute path to the worktree dir (or stray container) under `~/.gjc/wt/`. */
27
- path: string;
28
- /** Classification of what we found on disk. */
29
- kind: WorktreeKind;
30
- /** Parent repo root, when this is a registered git worktree. */
31
- parentRepo?: string;
32
- /** Branch name extracted from the parent's tracking file, when available. */
33
- branch?: string;
34
- /** When set, the entry is unhealthy and `gjc worktree clear` will remove it. */
35
- orphanReason?: string;
36
- }
37
-
38
- export interface ListWorktreesOptions {
39
- json: boolean;
40
- }
41
-
42
- export interface ClearWorktreesOptions {
43
- /** Remove every entry, including live PR-checkout worktrees. */
44
- all: boolean;
45
- /** Print what would be removed without touching the filesystem. */
46
- dryRun: boolean;
47
- json: boolean;
48
- }
49
-
50
- export async function listWorktrees(options: ListWorktreesOptions): Promise<void> {
51
- const entries = await scanWorktrees();
52
- if (options.json) {
53
- console.log(JSON.stringify(entries, null, 2));
54
- return;
55
- }
56
- if (entries.length === 0) {
57
- console.log(chalk.dim(`No agent-managed worktrees found under ${getWorktreesDir()}.`));
58
- return;
59
- }
60
- let live = 0;
61
- let orphaned = 0;
62
- for (const entry of entries) {
63
- const tag = entry.orphanReason ? chalk.yellow("orphaned") : chalk.green("live ");
64
- const detail = formatEntryDetail(entry);
65
- console.log(`${tag} ${entry.path}`);
66
- if (detail) console.log(` ${chalk.dim(detail)}`);
67
- if (entry.orphanReason) orphaned += 1;
68
- else live += 1;
69
- }
70
- console.log(chalk.dim(`\n${live} live · ${orphaned} orphaned · ${entries.length} total`));
71
- }
72
-
73
- export async function clearWorktrees(options: ClearWorktreesOptions): Promise<void> {
74
- const entries = await scanWorktrees();
75
- const targets = options.all ? entries : entries.filter(entry => entry.orphanReason !== undefined);
76
-
77
- if (targets.length === 0) {
78
- if (options.json) {
79
- console.log(JSON.stringify({ removed: 0, kept: entries.length }));
80
- } else {
81
- console.log(chalk.dim(options.all ? "No worktrees to remove." : "No orphaned worktrees to remove."));
82
- }
83
- return;
84
- }
85
-
86
- if (options.dryRun) {
87
- if (options.json) {
88
- console.log(JSON.stringify({ wouldRemove: targets.map(t => t.path) }, null, 2));
89
- } else {
90
- for (const target of targets) {
91
- console.log(`${chalk.yellow("would remove")} ${target.path}`);
92
- }
93
- console.log(chalk.dim(`\n${targets.length} dir${targets.length === 1 ? "" : "s"} would be removed.`));
94
- }
95
- return;
96
- }
97
-
98
- const results: { path: string; ok: boolean; error?: string }[] = [];
99
- const parentsToPrune = new Set<string>();
100
- for (const target of targets) {
101
- try {
102
- if (target.kind === "pr-checkout" && target.parentRepo && !target.orphanReason) {
103
- // Live worktree: ask git to remove it cleanly. If git refuses (locked,
104
- // dirty, etc.), fall back to fs.rm and rely on `worktree prune` to
105
- // clean the bookkeeping on the parent side.
106
- const removed = await git.worktree.tryRemove(target.parentRepo, target.path, { force: true });
107
- if (!removed) {
108
- await fs.rm(target.path, { recursive: true, force: true });
109
- parentsToPrune.add(target.parentRepo);
110
- }
111
- } else {
112
- await fs.rm(target.path, { recursive: true, force: true });
113
- if (target.parentRepo) parentsToPrune.add(target.parentRepo);
114
- }
115
- results.push({ path: target.path, ok: true });
116
- } catch (err) {
117
- results.push({ path: target.path, ok: false, error: err instanceof Error ? err.message : String(err) });
118
- }
119
- }
120
-
121
- // Best-effort: drop stale entries from each affected parent's `.git/worktrees/`.
122
- for (const parent of parentsToPrune) {
123
- try {
124
- await git.worktree.prune(parent);
125
- } catch {
126
- /* parent repo may already be gone or pruned — ignore */
127
- }
128
- }
129
-
130
- const succeeded = results.filter(r => r.ok).length;
131
- const failed = results.length - succeeded;
132
-
133
- if (options.json) {
134
- console.log(JSON.stringify({ removed: succeeded, failed, results }, null, 2));
135
- if (failed > 0) process.exitCode = 1;
136
- return;
137
- }
138
-
139
- for (const result of results) {
140
- if (result.ok) {
141
- console.log(`${chalk.green("removed")} ${result.path}`);
142
- } else {
143
- console.log(`${chalk.red("failed ")} ${result.path}`);
144
- if (result.error) console.log(` ${chalk.dim(result.error)}`);
145
- }
146
- }
147
- console.log(chalk.dim(`\n${succeeded} removed${failed > 0 ? ` · ${chalk.red(`${failed} failed`)}` : ""}`));
148
- if (failed > 0) process.exitCode = 1;
149
- }
150
-
151
- // ───────────────────────────────────────────────────────────────────────────
152
- // Scanner
153
- // ───────────────────────────────────────────────────────────────────────────
154
-
155
- async function scanWorktrees(): Promise<WorktreeEntry[]> {
156
- const root = getWorktreesDir();
157
- let topLevel: string[];
158
- try {
159
- topLevel = await fs.readdir(root);
160
- } catch (err) {
161
- if (isEnoent(err)) return [];
162
- throw err;
163
- }
164
-
165
- const entries: WorktreeEntry[] = [];
166
- for (const name of topLevel) {
167
- const dir = path.join(root, name);
168
- const stat = await fs.stat(dir).catch(() => null);
169
- if (!stat?.isDirectory()) continue;
170
-
171
- const direct = await classifyDir(dir);
172
- if (direct) {
173
- entries.push(direct);
174
- continue;
175
- }
176
-
177
- // Legacy nesting: ~/.gjc/wt/<encoded-project>/<branch-or-id>
178
- let children: string[];
179
- try {
180
- children = await fs.readdir(dir);
181
- } catch {
182
- continue;
183
- }
184
- let nested = 0;
185
- for (const child of children) {
186
- const childDir = path.join(dir, child);
187
- const childStat = await fs.stat(childDir).catch(() => null);
188
- if (!childStat?.isDirectory()) continue;
189
- const childClassified = await classifyDir(childDir);
190
- if (childClassified) {
191
- entries.push(childClassified);
192
- nested += 1;
193
- }
194
- }
195
- if (nested === 0) {
196
- entries.push({
197
- path: dir,
198
- kind: children.length === 0 ? "empty" : "stray",
199
- orphanReason: children.length === 0 ? "empty directory" : "no recognizable worktree contents",
200
- });
201
- }
202
- }
203
- return entries;
204
- }
205
-
206
- async function classifyDir(dir: string): Promise<WorktreeEntry | null> {
207
- const gitEntry = path.join(dir, ".git");
208
- const gitStat = await fs.stat(gitEntry).catch(() => null);
209
- if (gitStat?.isFile()) {
210
- return classifyPrCheckout(dir, gitEntry);
211
- }
212
- const mergedStat = await fs.stat(path.join(dir, "merged")).catch(() => null);
213
- if (mergedStat?.isDirectory()) {
214
- return {
215
- path: dir,
216
- kind: "task-isolation",
217
- orphanReason: "task-isolation leftover (no live task owns it)",
218
- };
219
- }
220
- return null;
221
- }
222
-
223
- async function classifyPrCheckout(dir: string, gitEntry: string): Promise<WorktreeEntry> {
224
- let contents: string;
225
- try {
226
- contents = await fs.readFile(gitEntry, "utf8");
227
- } catch (err) {
228
- return {
229
- path: dir,
230
- kind: "pr-checkout",
231
- orphanReason: `cannot read .git file: ${err instanceof Error ? err.message : String(err)}`,
232
- };
233
- }
234
- const match = /^gitdir:\s*(.+?)\s*$/m.exec(contents);
235
- const parentGitDir = match?.[1];
236
- if (!parentGitDir) {
237
- return { path: dir, kind: "pr-checkout", orphanReason: "malformed .git file (no gitdir line)" };
238
- }
239
- // parentGitDir is `<parent-repo>/.git/worktrees/<name>`; back out the repo root.
240
- const parentRepo = path.dirname(path.dirname(path.dirname(parentGitDir)));
241
- const branch = await readWorktreeBranch(path.join(parentGitDir, "HEAD"));
242
-
243
- const parentDirStat = await fs.stat(parentGitDir).catch(() => null);
244
- if (!parentDirStat?.isDirectory()) {
245
- return {
246
- path: dir,
247
- kind: "pr-checkout",
248
- parentRepo,
249
- branch,
250
- orphanReason: "parent repo no longer tracks this worktree",
251
- };
252
- }
253
- const parentRepoStat = await fs.stat(parentRepo).catch(() => null);
254
- if (!parentRepoStat?.isDirectory()) {
255
- return {
256
- path: dir,
257
- kind: "pr-checkout",
258
- parentRepo,
259
- branch,
260
- orphanReason: "parent repo missing",
261
- };
262
- }
263
- return { path: dir, kind: "pr-checkout", parentRepo, branch };
264
- }
265
-
266
- async function readWorktreeBranch(headFile: string): Promise<string | undefined> {
267
- try {
268
- const head = (await fs.readFile(headFile, "utf8")).trim();
269
- const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
270
- return refMatch?.[1];
271
- } catch {
272
- return undefined;
273
- }
274
- }
9
+ export {};
275
10
 
276
- function formatEntryDetail(entry: WorktreeEntry): string {
277
- const parts: string[] = [];
278
- if (entry.kind === "pr-checkout") {
279
- const repo = entry.parentRepo ? path.basename(entry.parentRepo) : "unknown repo";
280
- const branch = entry.branch ?? "unknown branch";
281
- parts.push(`${repo} · ${branch}`);
282
- } else if (entry.kind === "task-isolation") {
283
- parts.push("task-isolation sandbox");
284
- } else if (entry.kind === "empty") {
285
- parts.push("legacy project shell");
286
- } else {
287
- parts.push("unrecognized contents");
288
- }
289
- if (entry.orphanReason) parts.push(entry.orphanReason);
290
- return parts.join(" — ");
291
- }
11
+ throw new Error(
12
+ "@gajae-code/coding-agent/cli/worktree-cli was deliberately removed: the legacy worktree cleanup API (listWorktrees/clearWorktrees) is gone. Inspect leftover managed worktrees under ~/.gjc/wt manually and use `git worktree remove` or `git worktree prune` instead.",
13
+ );
package/src/cli.ts CHANGED
@@ -9,6 +9,7 @@ import { Args, type CliConfig, Command, type CommandEntry, Flags, run } from "@g
9
9
  import { APP_NAME, formatBunRuntimeError, MIN_BUN_VERSION, VERSION } from "@gajae-code/utils/dirs";
10
10
  import { runFixtureReport } from "./cli/fixture-report";
11
11
  import { isTmuxOwnerIsolationCliArgv, runTmuxOwnerIsolationCliFromStdin } from "./gjc-runtime/tmux-owner-isolation-cli";
12
+ import { smokeTestTabWorker } from "./tools/browser/tab-worker-smoke";
12
13
 
13
14
  if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
14
15
  process.stderr.write(
@@ -252,16 +253,15 @@ function isSubcommand(first: string | undefined): boolean {
252
253
  }
253
254
 
254
255
  /**
255
- * Smoke-test entry. Spawns the stats sync worker, pings it, exits.
256
+ * Smoke-test entry. Spawns the stats sync worker and the browser tab worker, then verifies their protocol.
256
257
  *
257
- * Purpose: catch the silent worker-load regressions that hit compiled
258
- * binaries (issues #1011 and #1027). Neither `--version` nor
259
- * `stats --summary` actually spawns a Worker on a fresh install — the
260
- * sync path early-returns when no session files exist. This probe is the
261
- * minimal end-to-end test that proves `new Worker(...)` resolves and the
262
- * bundled worker module evaluates successfully. Wired into
263
- * `scripts/install-tests/run-ci.sh` so binary / source-link / tarball
264
- * installs all exercise it on every CI run.
258
+ * Purpose: catch silent compiled-worker load regressions (issues #1011,
259
+ * #1027, and #2598). Neither `--version` nor `stats --summary` spawns both
260
+ * worker entries on a fresh install. This probe proves each bundled worker
261
+ * module resolves and evaluates; the tab-worker probe also completes its
262
+ * bootstrap/closed protocol without launching a browser. Wired into
263
+ * `scripts/install-tests/run-ci.sh` so binary / source-link / tarball installs
264
+ * exercise it on every CI run.
265
265
  */
266
266
  async function runSmokeTest(): Promise<void> {
267
267
  const { smokeTestSyncWorker } = await import("@gajae-code/stats");
@@ -278,6 +278,7 @@ async function runSmokeTest(): Promise<void> {
278
278
  if (typeof h02ScoreSequenceFuzzy !== "function" || typeof h01FindBestFuzzyMatch !== "function") {
279
279
  throw new Error("smoke-test: native fuzzy exports missing from embedded addon");
280
280
  }
281
+ await smokeTestTabWorker();
281
282
  process.stdout.write("smoke-test: ok\n");
282
283
  }
283
284