@oh-my-pi/pi-coding-agent 15.2.1 → 15.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -1
- package/dist/types/cli/worktree-cli.d.ts +26 -0
- package/dist/types/commands/worktree.d.ts +34 -0
- package/dist/types/config/settings-schema.d.ts +23 -0
- package/dist/types/modes/theme/shimmer.d.ts +15 -7
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/yield-queue.d.ts +24 -0
- package/dist/types/slash-commands/helpers/format.d.ts +1 -1
- package/dist/types/task/worktree.d.ts +0 -1
- package/dist/types/tools/browser/launch.d.ts +2 -0
- package/dist/types/utils/git.d.ts +1 -0
- package/package.json +7 -7
- package/src/autoresearch/storage.ts +14 -2
- package/src/cli/worktree-cli.ts +291 -0
- package/src/cli.ts +1 -0
- package/src/commands/worktree.ts +56 -0
- package/src/config/settings-schema.ts +16 -0
- package/src/discovery/builtin.ts +30 -0
- package/src/modes/components/mcp-add-wizard.ts +4 -3
- package/src/modes/components/settings-selector.ts +23 -10
- package/src/modes/components/welcome.ts +77 -35
- package/src/modes/controllers/mcp-command-controller.ts +4 -3
- package/src/modes/theme/shimmer.ts +161 -30
- package/src/modes/utils/ui-helpers.ts +31 -13
- package/src/prompts/tools/async-result.md +5 -2
- package/src/sdk.ts +95 -21
- package/src/session/agent-session.ts +28 -0
- package/src/session/yield-queue.ts +155 -0
- package/src/slash-commands/helpers/format.ts +4 -1
- package/src/task/worktree.ts +2 -7
- package/src/tools/browser/launch.ts +63 -51
- package/src/tools/gh.ts +35 -32
- package/src/utils/git.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.2.3] - 2026-05-22
|
|
6
|
+
### Breaking Changes
|
|
7
|
+
|
|
8
|
+
- Changed PR and task-isolation worktree directory layout to hash-based `~/.omp/wt/<identifier>-<path-hash>` style paths, replacing the previous nested encoded-repo layout
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Added `omp worktree` command (alias `wt`) to list and manage agent-managed worktrees under `~/.omp/wt`
|
|
13
|
+
- Added `omp worktree clear` to remove orphaned worktree directories, with `--all` to include live PR-checkouts, `--dry-run` for preview, and `--json` reporting
|
|
14
|
+
- Added machine-readable JSON output to `omp worktree list` for scripted inspection
|
|
15
|
+
- Added `display.shimmer` appearance setting with `classic`, `kitt` (Knight Rider K.I.T.T. scanner), and `disabled` modes
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Changed the welcome intro animation to a 3-second eased gradient sweep with a diagonal shine highlight across the logo
|
|
20
|
+
- Changed background job completion follow-ups to batch multiple finished jobs into a single `async-result` message, showing each completed job and its result in one place
|
|
21
|
+
- Changed MCP notification follow-ups to combine multiple resource updates into a single consolidated message and suppress duplicate server/uri entries
|
|
22
|
+
- Updated PR checkout to reuse `hashPath`-based worktree roots when creating and scanning worktrees for cleanup
|
|
23
|
+
- Updated `worktree` cleanup logic to gracefully prune parent git metadata after removing worktree directories
|
|
24
|
+
- Reworked working-message shimmer animation for 60fps rendering: ANSI sequences are coalesced per same-tier run instead of emitted per code point, palettes compile once and cache per active theme, and the band position is now fractional so motion is smooth at any frame rate
|
|
25
|
+
- Switched MCP health-check and "Connecting to…" spinners from hard-coded ASCII (`|/-\`) to `theme.spinnerFrames` so they pick up the active symbol preset (braille on unicode/nerd, ASCII when explicitly themed)
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Fixed PR checkout failures when the default worktree path was already registered or occupied by stale leftovers by automatically selecting suffixed alternatives (`-2`, `-3`, etc.)
|
|
30
|
+
- Fixed Memory tab in the settings UI not revealing or hiding the Hindsight-only rows (`Hindsight API URL`, `Hindsight Bank ID`, `Hindsight Scoping`, etc.) when `Memory Backend` was switched via the inline submenu. The selector now rebuilds its item list after every change so condition-gated rows appear/disappear immediately instead of requiring a tab switch
|
|
31
|
+
|
|
32
|
+
## [15.2.2] - 2026-05-22
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Fixed `RULES.md` not being injected. The documented sticky-rules file at `~/.omp/agent/RULES.md` and `<repo>/.omp/RULES.md` was never read by any discovery provider; only `.omp/rules/*.md` was scanned. The native provider now loads both as always-apply rules so they re-attach every turn as documented ([#1266](https://github.com/can1357/oh-my-pi/issues/1266)).
|
|
37
|
+
|
|
5
38
|
## [15.2.1] - 2026-05-21
|
|
6
39
|
|
|
7
40
|
### Fixed
|
|
@@ -8505,4 +8538,4 @@ Initial public release.
|
|
|
8505
8538
|
- Git branch display in footer
|
|
8506
8539
|
- Message queueing during streaming responses
|
|
8507
8540
|
- OAuth integration for Gmail and Google Calendar access
|
|
8508
|
-
- HTML export with syntax highlighting and collapsible sections
|
|
8541
|
+
- HTML export with syntax highlighting and collapsible sections
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
type WorktreeKind = "pr-checkout" | "task-isolation" | "empty" | "stray";
|
|
2
|
+
export interface WorktreeEntry {
|
|
3
|
+
/** Absolute path to the worktree dir (or stray container) under `~/.omp/wt/`. */
|
|
4
|
+
path: string;
|
|
5
|
+
/** Classification of what we found on disk. */
|
|
6
|
+
kind: WorktreeKind;
|
|
7
|
+
/** Parent repo root, when this is a registered git worktree. */
|
|
8
|
+
parentRepo?: string;
|
|
9
|
+
/** Branch name extracted from the parent's tracking file, when available. */
|
|
10
|
+
branch?: string;
|
|
11
|
+
/** When set, the entry is unhealthy and `omp worktree clear` will remove it. */
|
|
12
|
+
orphanReason?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ListWorktreesOptions {
|
|
15
|
+
json: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface ClearWorktreesOptions {
|
|
18
|
+
/** Remove every entry, including live PR-checkout worktrees. */
|
|
19
|
+
all: boolean;
|
|
20
|
+
/** Print what would be removed without touching the filesystem. */
|
|
21
|
+
dryRun: boolean;
|
|
22
|
+
json: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare function listWorktrees(options: ListWorktreesOptions): Promise<void>;
|
|
25
|
+
export declare function clearWorktrees(options: ClearWorktreesOptions): Promise<void>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List and clean up agent-managed git worktrees under `~/.omp/wt`.
|
|
3
|
+
*/
|
|
4
|
+
import { Command } from "@oh-my-pi/pi-utils/cli";
|
|
5
|
+
export default class Worktree extends Command {
|
|
6
|
+
static description: string;
|
|
7
|
+
static aliases: string[];
|
|
8
|
+
static args: {
|
|
9
|
+
action: import("@oh-my-pi/pi-utils/cli").ArgDescriptor & {
|
|
10
|
+
description: string;
|
|
11
|
+
required: false;
|
|
12
|
+
options: string[];
|
|
13
|
+
default: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
static flags: {
|
|
17
|
+
all: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"boolean"> & {
|
|
18
|
+
description: string;
|
|
19
|
+
default: boolean;
|
|
20
|
+
};
|
|
21
|
+
"dry-run": import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"boolean"> & {
|
|
22
|
+
char: string;
|
|
23
|
+
description: string;
|
|
24
|
+
default: boolean;
|
|
25
|
+
};
|
|
26
|
+
json: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"boolean"> & {
|
|
27
|
+
char: string;
|
|
28
|
+
description: string;
|
|
29
|
+
default: boolean;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
static examples: string[];
|
|
33
|
+
run(): Promise<void>;
|
|
34
|
+
}
|
|
@@ -648,6 +648,29 @@ export declare const SETTINGS_SCHEMA: {
|
|
|
648
648
|
readonly type: "number";
|
|
649
649
|
readonly default: 3;
|
|
650
650
|
};
|
|
651
|
+
readonly "display.shimmer": {
|
|
652
|
+
readonly type: "enum";
|
|
653
|
+
readonly values: readonly ["classic", "kitt", "disabled"];
|
|
654
|
+
readonly default: "classic";
|
|
655
|
+
readonly ui: {
|
|
656
|
+
readonly tab: "appearance";
|
|
657
|
+
readonly label: "Shimmer";
|
|
658
|
+
readonly description: "Animation style for working/loading messages";
|
|
659
|
+
readonly options: readonly [{
|
|
660
|
+
readonly value: "classic";
|
|
661
|
+
readonly label: "Classic";
|
|
662
|
+
readonly description: "Soft cosine wave sweeping across the text";
|
|
663
|
+
}, {
|
|
664
|
+
readonly value: "kitt";
|
|
665
|
+
readonly label: "KITT Scanner";
|
|
666
|
+
readonly description: "Knight Rider 1982 red light bouncing left-right";
|
|
667
|
+
}, {
|
|
668
|
+
readonly value: "disabled";
|
|
669
|
+
readonly label: "Disabled";
|
|
670
|
+
readonly description: "No animation; static muted text";
|
|
671
|
+
}];
|
|
672
|
+
};
|
|
673
|
+
};
|
|
651
674
|
readonly "display.showTokenUsage": {
|
|
652
675
|
readonly type: "boolean";
|
|
653
676
|
readonly default: false;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { Theme, ThemeColor } from "./theme";
|
|
2
|
-
type ShimmerTheme = Pick<Theme, "bold" | "fg">;
|
|
2
|
+
type ShimmerTheme = Pick<Theme, "bold" | "fg" | "getFgAnsi">;
|
|
3
3
|
/** Three-tier color stack a shimmer character cycles through as the band sweeps. */
|
|
4
4
|
export interface ShimmerPalette {
|
|
5
|
-
/** Color for chars outside / at the edge of the band (intensity < 0.
|
|
5
|
+
/** Color for chars outside / at the edge of the band (intensity < ~0.22). */
|
|
6
6
|
low: ThemeColor;
|
|
7
|
-
/** Color for chars approaching the crest (0.
|
|
7
|
+
/** Color for chars approaching the crest (~0.22 ≤ intensity < ~0.65). */
|
|
8
8
|
mid: ThemeColor;
|
|
9
|
-
/** Color at the band's crest (intensity
|
|
9
|
+
/** Color at the band's crest (intensity ≥ ~0.65). */
|
|
10
10
|
high: ThemeColor;
|
|
11
11
|
/** Whether to bold the crest tier. Default `false`. */
|
|
12
12
|
bold?: boolean;
|
|
@@ -18,9 +18,17 @@ export interface ShimmerSegment {
|
|
|
18
18
|
}
|
|
19
19
|
export declare const DEFAULT_SHIMMER_PALETTE: ShimmerPalette;
|
|
20
20
|
/**
|
|
21
|
-
* Apply a shimmer sweep across one or more segments, treating them as a
|
|
22
|
-
* continuous string for band positioning. Each segment can supply
|
|
23
|
-
* palette so the gradient stays in lockstep while the colors
|
|
21
|
+
* Apply a shimmer sweep across one or more segments, treating them as a
|
|
22
|
+
* single continuous string for band positioning. Each segment can supply
|
|
23
|
+
* its own palette so the gradient stays in lockstep while the colors
|
|
24
|
+
* differ.
|
|
25
|
+
*
|
|
26
|
+
* Performance shape (per call, dominant cost):
|
|
27
|
+
* - One `Date.now()` read.
|
|
28
|
+
* - One `compile()` lookup per segment (Symbol-keyed cache slot, hot path
|
|
29
|
+
* skipped after first frame).
|
|
30
|
+
* - One ANSI open/close pair per **run of same-tier chars**, not per char.
|
|
31
|
+
* - No per-char allocations beyond the run buffer.
|
|
24
32
|
*/
|
|
25
33
|
export declare function shimmerSegments(segments: readonly ShimmerSegment[], theme: ShimmerTheme): string;
|
|
26
34
|
export declare function shimmerText(text: string, theme: ShimmerTheme, palette?: ShimmerPalette): string;
|
|
@@ -45,6 +45,7 @@ import type { ClientBridge } from "./client-bridge";
|
|
|
45
45
|
import { type CustomMessage } from "./messages";
|
|
46
46
|
import type { BranchSummaryEntry, NewSessionOptions, SessionContext, SessionManager } from "./session-manager";
|
|
47
47
|
import { ToolChoiceQueue } from "./tool-choice-queue";
|
|
48
|
+
import { YieldQueue } from "./yield-queue";
|
|
48
49
|
/** Session-specific events that extend the core AgentEvent */
|
|
49
50
|
export type AgentSessionEvent = AgentEvent | {
|
|
50
51
|
type: "auto_compaction_start";
|
|
@@ -264,6 +265,7 @@ export declare class AgentSession {
|
|
|
264
265
|
readonly agent: Agent;
|
|
265
266
|
readonly sessionManager: SessionManager;
|
|
266
267
|
readonly settings: Settings;
|
|
268
|
+
readonly yieldQueue: YieldQueue;
|
|
267
269
|
readonly configWarnings: string[];
|
|
268
270
|
readonly rawSseDebugBuffer: RawSseDebugBuffer;
|
|
269
271
|
constructor(config: AgentSessionConfig);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
2
|
+
export interface YieldDispatcher<P> {
|
|
3
|
+
/** Drop entries already delivered through another path. Called per-entry at flush time. */
|
|
4
|
+
isStale?(entry: P): boolean;
|
|
5
|
+
/** Produce one batched AgentMessage from non-stale entries. Return null to skip. */
|
|
6
|
+
build(survivors: P[]): AgentMessage | null;
|
|
7
|
+
}
|
|
8
|
+
export interface YieldQueueOptions {
|
|
9
|
+
isStreaming: () => boolean;
|
|
10
|
+
injectStreaming(msg: AgentMessage): void;
|
|
11
|
+
injectIdle(messages: AgentMessage[]): Promise<void>;
|
|
12
|
+
scheduleIdleFlush(run: () => Promise<void>): void;
|
|
13
|
+
}
|
|
14
|
+
type YieldFlushMode = "streaming" | "idle";
|
|
15
|
+
export declare class YieldQueue {
|
|
16
|
+
#private;
|
|
17
|
+
constructor(options: YieldQueueOptions);
|
|
18
|
+
register<P>(kind: string, dispatcher: YieldDispatcher<P>): () => void;
|
|
19
|
+
enqueue<P>(kind: string, entry: P): void;
|
|
20
|
+
has(kind?: string): boolean;
|
|
21
|
+
flush(mode: YieldFlushMode): Promise<void>;
|
|
22
|
+
clear(): void;
|
|
23
|
+
}
|
|
24
|
+
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Theme } from "../../modes/theme/theme";
|
|
2
2
|
/** Format a millisecond duration as a coarse-grained human label. */
|
|
3
3
|
export declare function formatDuration(ms: number): string;
|
|
4
|
-
type ProgressBarTheme = Pick<Theme, "bold" | "fg">;
|
|
4
|
+
type ProgressBarTheme = Pick<Theme, "bold" | "fg" | "getFgAnsi">;
|
|
5
5
|
/**
|
|
6
6
|
* Render an ASCII progress bar with a trailing percent label.
|
|
7
7
|
* `fraction` is clamped to `[0, 1]`. `undefined` renders a dotted placeholder.
|
|
@@ -19,7 +19,6 @@ export interface WorktreeBaseline {
|
|
|
19
19
|
baseline: RepoBaseline;
|
|
20
20
|
}>;
|
|
21
21
|
}
|
|
22
|
-
export declare function getEncodedProjectName(cwd: string): string;
|
|
23
22
|
export declare function getRepoRoot(cwd: string): Promise<string>;
|
|
24
23
|
export declare function getGitNoIndexNullPath(): string;
|
|
25
24
|
export declare function captureBaseline(repoRoot: string): Promise<WorktreeBaseline>;
|
|
@@ -48,6 +48,8 @@ export interface UserAgentSession {
|
|
|
48
48
|
override: UserAgentOverride;
|
|
49
49
|
browserSession: CDPSession | null;
|
|
50
50
|
}
|
|
51
|
+
/** Builds the browser-page stealth bootstrap source for regression tests. */
|
|
52
|
+
export declare function buildStealthInjectionScriptForTest(scripts?: readonly string[]): string;
|
|
51
53
|
/** Apply stealth patches + UA override to a headless page. Idempotent within a tab. */
|
|
52
54
|
export declare function applyStealthPatches(browser: Browser, page: Page, state: {
|
|
53
55
|
browserSession: CDPSession | null;
|
|
@@ -263,6 +263,7 @@ export declare const worktree: {
|
|
|
263
263
|
signal?: AbortSignal;
|
|
264
264
|
}): Promise<boolean>;
|
|
265
265
|
list(cwd: string, signal?: AbortSignal): Promise<GitWorktreeEntry[]>;
|
|
266
|
+
prune(cwd: string, signal?: AbortSignal): Promise<void>;
|
|
266
267
|
};
|
|
267
268
|
export declare const patch: {
|
|
268
269
|
/** Apply a patch file. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "15.2.
|
|
4
|
+
"version": "15.2.3",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -47,12 +47,12 @@
|
|
|
47
47
|
"@agentclientprotocol/sdk": "0.21.0",
|
|
48
48
|
"@babel/parser": "^7.29.3",
|
|
49
49
|
"@mozilla/readability": "^0.6.0",
|
|
50
|
-
"@oh-my-pi/omp-stats": "15.2.
|
|
51
|
-
"@oh-my-pi/pi-agent-core": "15.2.
|
|
52
|
-
"@oh-my-pi/pi-ai": "15.2.
|
|
53
|
-
"@oh-my-pi/pi-natives": "15.2.
|
|
54
|
-
"@oh-my-pi/pi-tui": "15.2.
|
|
55
|
-
"@oh-my-pi/pi-utils": "15.2.
|
|
50
|
+
"@oh-my-pi/omp-stats": "15.2.3",
|
|
51
|
+
"@oh-my-pi/pi-agent-core": "15.2.3",
|
|
52
|
+
"@oh-my-pi/pi-ai": "15.2.3",
|
|
53
|
+
"@oh-my-pi/pi-natives": "15.2.3",
|
|
54
|
+
"@oh-my-pi/pi-tui": "15.2.3",
|
|
55
|
+
"@oh-my-pi/pi-utils": "15.2.3",
|
|
56
56
|
"@puppeteer/browsers": "^2.13.0",
|
|
57
57
|
"@types/turndown": "5.0.6",
|
|
58
58
|
"@xterm/headless": "^6.0.0",
|
|
@@ -2,10 +2,22 @@ import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { getAutoresearchDbPath, getAutoresearchProjectDir, logger } from "@oh-my-pi/pi-utils";
|
|
5
|
-
import { getEncodedProjectName } from "../task/worktree";
|
|
6
5
|
import * as git from "../utils/git";
|
|
7
6
|
import type { ASIData, ExperimentStatus, MetricDirection, NumericMetricMap } from "./types";
|
|
8
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Encode an absolute project path into a single filesystem-safe segment.
|
|
10
|
+
*
|
|
11
|
+
* Used to key per-project autoresearch state under `~/.omp/autoresearch/`.
|
|
12
|
+
* The `--…--` wrapper is historical — existing on-disk state depends on it,
|
|
13
|
+
* so changing the format here would orphan every prior autoresearch DB.
|
|
14
|
+
* Not collision-free for pathological inputs (`/a/b` vs `/a-b`) but matches
|
|
15
|
+
* the rest of the codebase and stays human-readable for `ls`.
|
|
16
|
+
*/
|
|
17
|
+
function encodeProjectKey(repoRoot: string): string {
|
|
18
|
+
return `--${repoRoot.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
19
|
+
}
|
|
20
|
+
|
|
9
21
|
export interface SessionRow {
|
|
10
22
|
id: number;
|
|
11
23
|
name: string;
|
|
@@ -561,7 +573,7 @@ export async function openAutoresearchStorageIfExists(cwd: string): Promise<Auto
|
|
|
561
573
|
async function resolveAutoresearchPaths(cwd: string): Promise<{ dbPath: string; projectDir: string }> {
|
|
562
574
|
const override = process.env.OMP_AUTORESEARCH_DB_DIR;
|
|
563
575
|
const repoRoot = (await git.repo.root(cwd)) ?? cwd;
|
|
564
|
-
const encoded =
|
|
576
|
+
const encoded = encodeProjectKey(repoRoot);
|
|
565
577
|
if (override) {
|
|
566
578
|
return {
|
|
567
579
|
dbPath: path.join(override, `${encoded}.db`),
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI handler for `omp worktree` — list and clean up agent-managed worktrees.
|
|
3
|
+
*
|
|
4
|
+
* Layout under `~/.omp/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.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs/promises";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { getWorktreesDir, isEnoent } from "@oh-my-pi/pi-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 `~/.omp/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 `omp 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: ~/.omp/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
|
+
}
|
|
275
|
+
|
|
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
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -46,6 +46,7 @@ const commands: CommandEntry[] = [
|
|
|
46
46
|
{ name: "ssh", load: () => import("./commands/ssh").then(m => m.default) },
|
|
47
47
|
{ name: "stats", load: () => import("./commands/stats").then(m => m.default) },
|
|
48
48
|
{ name: "update", load: () => import("./commands/update").then(m => m.default) },
|
|
49
|
+
{ name: "worktree", load: () => import("./commands/worktree").then(m => m.default), aliases: ["wt"] },
|
|
49
50
|
{ name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] },
|
|
50
51
|
];
|
|
51
52
|
|