pi-session-cleanup 1.0.0 → 1.1.1
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 +13 -0
- package/README.md +20 -2
- package/package.json +66 -66
- package/src/agent-target.ts +361 -0
- package/src/index.ts +12 -3
- package/src/session-agent.ts +103 -78
- package/src/session-cleanup-command.ts +268 -268
- package/src/session-delete.ts +165 -11
- package/src/session-entry.ts +23 -0
- package/src/session-format.ts +98 -98
- package/src/session-nix-command.ts +353 -84
- package/src/session-quit-shutdown.ts +40 -0
- package/src/session-selection.ts +167 -167
- package/src/session-sort.ts +137 -137
- package/src/session-source.ts +32 -32
- package/src/tui/agent-target-picker.ts +306 -0
- package/src/tui/session-cleanup-picker.ts +592 -592
- package/src/types-shims.d.ts +41 -9
- package/src/types.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.1 - 2026-05-22
|
|
4
|
+
|
|
5
|
+
- Sends session files to available desktop trash providers (`trash`, `trash-put`, `gio trash`, or `kioclient move`) before permanent deletion.
|
|
6
|
+
- Warns `/nix quit` users when trash providers are unavailable and requires confirmation before permanent current-session deletion fallback.
|
|
7
|
+
- Added coverage for trash-provider selection and `/nix` deletion warning behavior.
|
|
8
|
+
|
|
9
|
+
## 1.1.0 - 2026-05-04
|
|
10
|
+
|
|
11
|
+
- Added `/nix quit` for confirmed current-session deletion during graceful Pi shutdown
|
|
12
|
+
- Added `/nix agent [name]` for starting fresh sessions with persisted target-agent metadata
|
|
13
|
+
- Added `/nix` argument completions, explicit help output, and safer compatibility warnings for unsupported Pi builds
|
|
14
|
+
- Documented destructive `/nix` safeguards and added targeted release-readiness coverage for the new session flows
|
|
15
|
+
|
|
3
16
|
## 1.0.0 - 2026-03-05
|
|
4
17
|
|
|
5
18
|
- Renamed extension from its previous package name to `pi-session-cleanup`
|
package/README.md
CHANGED
|
@@ -14,6 +14,9 @@ Interactive session cleanup extension for the [Pi coding agent](https://github.c
|
|
|
14
14
|
- **Scope Filtering** — View only orphaned sessions or all historical sessions
|
|
15
15
|
- **Batch Selection Controls** — Multi-select with Space, select all with `a`, keyboard navigation
|
|
16
16
|
- **Safe Delete Flow** — Excludes the currently active session and uses trash-first deletion with unlink fallback
|
|
17
|
+
- **Fresh Session Shortcut** — `/nix` starts a fresh session and removes the previous session after confirmation
|
|
18
|
+
- **Target Agent Handoff** — `/nix agent [name]` starts a fresh session with persisted active-agent metadata
|
|
19
|
+
- **Quit Cleanup Flow** — `/nix quit` schedules current-session deletion during Pi's graceful shutdown event
|
|
17
20
|
- **Improved Modal UX** — Centered overlay with bordered layout, concise single-line legend, status summary, and automatic icon fallback
|
|
18
21
|
|
|
19
22
|
## Installation
|
|
@@ -51,6 +54,10 @@ pi install git:github.com/MasuRii/pi-session-cleanup
|
|
|
51
54
|
| `/session-cleanup current` | — | Opens modal with sessions from the current directory |
|
|
52
55
|
| `/session-cleanup all` | — | Opens modal showing all sessions |
|
|
53
56
|
| `/session-cleanup help` | — | Displays usage help |
|
|
57
|
+
| `/nix` | — | Starts a fresh session after confirmation and deletes the previous session |
|
|
58
|
+
| `/nix quit` | — | Deletes the current session during graceful shutdown and quits Pi |
|
|
59
|
+
| `/nix agent` | `[name]` | Starts a fresh session with a selected or explicitly named target agent |
|
|
60
|
+
| `/nix help` | — | Displays `/nix` usage help |
|
|
54
61
|
|
|
55
62
|
**Scopes:**
|
|
56
63
|
|
|
@@ -58,6 +65,16 @@ pi install git:github.com/MasuRii/pi-session-cleanup
|
|
|
58
65
|
- **`current`** — Shows sessions from the current working directory
|
|
59
66
|
- **`all`** — Shows all historical sessions across all directories
|
|
60
67
|
|
|
68
|
+
### `/nix` Fresh Session Workflow
|
|
69
|
+
|
|
70
|
+
`/nix` is destructive by design and always asks for confirmation before deleting any session file.
|
|
71
|
+
|
|
72
|
+
- **`/nix`** starts a new session with the current agent and deletes the previous session only after `ctx.newSession()` succeeds.
|
|
73
|
+
- **`/nix agent [name]`** starts a new session with the selected target agent and writes an `active_agent` session entry so Pi can resume that agent context. Without `[name]`, the command opens an interactive agent picker; with `[name]`, it validates the name before continuing.
|
|
74
|
+
- **`/nix quit`** requires Pi builds that expose `ctx.shutdown()`. It schedules deletion of the current session and performs the delete from the `session_shutdown` event, so the session file is not removed until Pi has begun graceful shutdown.
|
|
75
|
+
|
|
76
|
+
Target agents are discovered from the nearest project agent folders (`.omp/agents`, `.pi/agents`, `.claude/agents`) plus user agent folders (`~/.omp/agents`, `$PI_CODING_AGENT_DIR/agents`, `~/.claude/agents`). If `pi-agent-router` is installed with custom `agentDiscovery` paths, those paths are reused.
|
|
77
|
+
|
|
61
78
|
### Modal Controls
|
|
62
79
|
|
|
63
80
|
When the session picker modal is open:
|
|
@@ -79,8 +96,9 @@ The extension includes multiple safety mechanisms:
|
|
|
79
96
|
|
|
80
97
|
1. **Active Session Protection** — The currently active session is never shown in the list and cannot be deleted
|
|
81
98
|
2. **Trash-First Deletion** — Sessions are moved to trash first; only falls back to permanent deletion if trash is unavailable
|
|
82
|
-
3. **Confirmation Required** — The modal requires explicit `Enter` keypress to proceed with deletion
|
|
83
|
-
4. **
|
|
99
|
+
3. **Confirmation Required** — The modal requires explicit `Enter` keypress to proceed with deletion, and `/nix` commands require `ctx.ui.confirm()` approval
|
|
100
|
+
4. **Graceful Quit Guard** — `/nix quit` refuses to delete anything when the active Pi build does not expose `ctx.shutdown()`
|
|
101
|
+
5. **Escapable** — `Esc` or `q` immediately cancels without any changes
|
|
84
102
|
|
|
85
103
|
## Configuration
|
|
86
104
|
|
package/package.json
CHANGED
|
@@ -1,66 +1,66 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-session-cleanup",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Pi extension for interactive batch session cleanup and safe deletion.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./index.ts",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./index.ts"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"index.ts",
|
|
12
|
-
"src",
|
|
13
|
-
"config/config.example.json",
|
|
14
|
-
"README.md",
|
|
15
|
-
"CHANGELOG.md",
|
|
16
|
-
"LICENSE"
|
|
17
|
-
],
|
|
18
|
-
"scripts": {
|
|
19
|
-
"build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
|
|
20
|
-
"lint": "npm run build",
|
|
21
|
-
"test:clean": "node -e \"require('node:fs').rmSync('.test-dist', { recursive: true, force: true })\"",
|
|
22
|
-
"pretest": "npm run test:clean",
|
|
23
|
-
"test": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.test.json && node --test .test-dist/test/*.test.js",
|
|
24
|
-
"posttest": "npm run test:clean",
|
|
25
|
-
"check": "npm run lint && npm run test",
|
|
26
|
-
"package:dry-run": "npm pack --dry-run"
|
|
27
|
-
},
|
|
28
|
-
"keywords": [
|
|
29
|
-
"pi-package",
|
|
30
|
-
"pi",
|
|
31
|
-
"pi-extension",
|
|
32
|
-
"session",
|
|
33
|
-
"cleanup",
|
|
34
|
-
"delete",
|
|
35
|
-
"pi-coding-agent",
|
|
36
|
-
"pi-tui",
|
|
37
|
-
"session-management",
|
|
38
|
-
"tui",
|
|
39
|
-
"safe-delete"
|
|
40
|
-
],
|
|
41
|
-
"author": "MasuRii",
|
|
42
|
-
"license": "MIT",
|
|
43
|
-
"engines": {
|
|
44
|
-
"node": ">=20"
|
|
45
|
-
},
|
|
46
|
-
"publishConfig": {
|
|
47
|
-
"access": "public"
|
|
48
|
-
},
|
|
49
|
-
"pi": {
|
|
50
|
-
"extensions": [
|
|
51
|
-
"./index.ts"
|
|
52
|
-
]
|
|
53
|
-
},
|
|
54
|
-
"peerDependencies": {
|
|
55
|
-
"@
|
|
56
|
-
"@
|
|
57
|
-
},
|
|
58
|
-
"repository": {
|
|
59
|
-
"type": "git",
|
|
60
|
-
"url": "git+https://github.com/MasuRii/pi-session-cleanup.git"
|
|
61
|
-
},
|
|
62
|
-
"bugs": {
|
|
63
|
-
"url": "https://github.com/MasuRii/pi-session-cleanup/issues"
|
|
64
|
-
},
|
|
65
|
-
"homepage": "https://github.com/MasuRii/pi-session-cleanup#readme"
|
|
66
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-session-cleanup",
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "Pi extension for interactive batch session cleanup and safe deletion.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.ts",
|
|
12
|
+
"src",
|
|
13
|
+
"config/config.example.json",
|
|
14
|
+
"README.md",
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
|
|
20
|
+
"lint": "npm run build",
|
|
21
|
+
"test:clean": "node -e \"require('node:fs').rmSync('.test-dist', { recursive: true, force: true })\"",
|
|
22
|
+
"pretest": "npm run test:clean",
|
|
23
|
+
"test": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.test.json && node --test .test-dist/test/*.test.js",
|
|
24
|
+
"posttest": "npm run test:clean",
|
|
25
|
+
"check": "npm run lint && npm run test",
|
|
26
|
+
"package:dry-run": "npm pack --dry-run"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"pi-package",
|
|
30
|
+
"pi",
|
|
31
|
+
"pi-extension",
|
|
32
|
+
"session",
|
|
33
|
+
"cleanup",
|
|
34
|
+
"delete",
|
|
35
|
+
"pi-coding-agent",
|
|
36
|
+
"pi-tui",
|
|
37
|
+
"session-management",
|
|
38
|
+
"tui",
|
|
39
|
+
"safe-delete"
|
|
40
|
+
],
|
|
41
|
+
"author": "MasuRii",
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"pi": {
|
|
50
|
+
"extensions": [
|
|
51
|
+
"./index.ts"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@earendil-works/pi-coding-agent": "^0.75.4",
|
|
56
|
+
"@earendil-works/pi-tui": "^0.75.4"
|
|
57
|
+
},
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "git+https://github.com/MasuRii/pi-session-cleanup.git"
|
|
61
|
+
},
|
|
62
|
+
"bugs": {
|
|
63
|
+
"url": "https://github.com/MasuRii/pi-session-cleanup/issues"
|
|
64
|
+
},
|
|
65
|
+
"homepage": "https://github.com/MasuRii/pi-session-cleanup#readme"
|
|
66
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PROJECT_SOURCE_DIRS = [".omp/agents", ".pi/agents", ".claude/agents"];
|
|
9
|
+
const DEFAULT_USER_SOURCE_DIRS = ["{home}/.omp/agents", "{agentDir}/agents", "{home}/.claude/agents"];
|
|
10
|
+
const ROUTER_CONFIG_FILE_NAME = "config.json";
|
|
11
|
+
const ROUTER_EXTENSION_NAME = "pi-agent-router";
|
|
12
|
+
const PI_AGENT_DIR_ENV_VAR = "PI_CODING_AGENT_DIR";
|
|
13
|
+
|
|
14
|
+
const SESSION_EXTENSION_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
15
|
+
|
|
16
|
+
type AgentMode = "primary" | "subagent" | "all";
|
|
17
|
+
|
|
18
|
+
interface AgentDiscoveryConfig {
|
|
19
|
+
projectSourceDirs: string[];
|
|
20
|
+
userSourceDirs: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SelectableAgent {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
mode?: AgentMode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface AgentSelectionMenu {
|
|
30
|
+
labels: string[];
|
|
31
|
+
valueByLabel: Map<string, string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeStringArray(value: unknown): string[] | null {
|
|
35
|
+
if (!Array.isArray(value)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const normalized = value
|
|
40
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
41
|
+
.map((entry) => entry.trim())
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
|
|
44
|
+
return normalized.length > 0 ? normalized : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toRecord(value: unknown): Record<string, unknown> | null {
|
|
48
|
+
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function expandHomeDirectory(configuredDir: string, homeDirectory: string): string {
|
|
52
|
+
if (configuredDir === "~") {
|
|
53
|
+
return homeDirectory;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (configuredDir.startsWith("~/") || configuredDir.startsWith("~\\")) {
|
|
57
|
+
return join(homeDirectory, stripLeadingPathSeparators(configuredDir.slice(1)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return configuredDir;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolvePiAgentDir(): string {
|
|
64
|
+
const configuredDir = process.env[PI_AGENT_DIR_ENV_VAR]?.trim();
|
|
65
|
+
if (configuredDir) {
|
|
66
|
+
return expandHomeDirectory(configuredDir, homedir());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return join(homedir(), ".pi", "agent");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveRouterConfigCandidates(): string[] {
|
|
73
|
+
const agentDir = resolvePiAgentDir();
|
|
74
|
+
const extensionParentDir = dirname(SESSION_EXTENSION_ROOT);
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
join(extensionParentDir, ROUTER_EXTENSION_NAME, ROUTER_CONFIG_FILE_NAME),
|
|
78
|
+
join(agentDir, "extensions", ROUTER_EXTENSION_NAME, ROUTER_CONFIG_FILE_NAME),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function loadAgentDiscoveryConfig(): AgentDiscoveryConfig {
|
|
83
|
+
for (const configPath of resolveRouterConfigCandidates()) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as unknown;
|
|
86
|
+
const config = toRecord(parsed);
|
|
87
|
+
const agentDiscovery = toRecord(config?.agentDiscovery);
|
|
88
|
+
const projectSourceDirs = normalizeStringArray(agentDiscovery?.projectSourceDirs);
|
|
89
|
+
const userSourceDirs = normalizeStringArray(agentDiscovery?.userSourceDirs);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
projectSourceDirs: projectSourceDirs ?? [...DEFAULT_PROJECT_SOURCE_DIRS],
|
|
93
|
+
userSourceDirs: userSourceDirs ?? [...DEFAULT_USER_SOURCE_DIRS],
|
|
94
|
+
};
|
|
95
|
+
} catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
projectSourceDirs: [...DEFAULT_PROJECT_SOURCE_DIRS],
|
|
102
|
+
userSourceDirs: [...DEFAULT_USER_SOURCE_DIRS],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stripLeadingPathSeparators(value: string): string {
|
|
107
|
+
return value.replace(/^[\\/]+/, "");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveConfiguredUserPath(rawPath: string): string {
|
|
111
|
+
const trimmed = rawPath.trim();
|
|
112
|
+
if (!trimmed) {
|
|
113
|
+
return resolve(resolvePiAgentDir(), "agents");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (trimmed === "~") {
|
|
117
|
+
return homedir();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
121
|
+
return join(homedir(), stripLeadingPathSeparators(trimmed.slice(1)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (trimmed === "{home}") {
|
|
125
|
+
return homedir();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (trimmed.startsWith("{home}/") || trimmed.startsWith("{home}\\")) {
|
|
129
|
+
return join(homedir(), stripLeadingPathSeparators(trimmed.slice("{home}".length)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (trimmed === "{agentDir}") {
|
|
133
|
+
return resolvePiAgentDir();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (trimmed.startsWith("{agentDir}/") || trimmed.startsWith("{agentDir}\\")) {
|
|
137
|
+
return join(resolvePiAgentDir(), stripLeadingPathSeparators(trimmed.slice("{agentDir}".length)));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return resolve(trimmed);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isDirectory(path: string): boolean {
|
|
144
|
+
try {
|
|
145
|
+
readdirSync(path);
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function findNearestProjectAgentDirs(cwd: string, projectSourceDirs: readonly string[]): string[] {
|
|
153
|
+
let currentDir = resolve(cwd);
|
|
154
|
+
|
|
155
|
+
while (true) {
|
|
156
|
+
const candidates = projectSourceDirs
|
|
157
|
+
.map((sourceDir) => resolve(currentDir, sourceDir))
|
|
158
|
+
.filter((candidate) => isDirectory(candidate));
|
|
159
|
+
|
|
160
|
+
if (candidates.length > 0) {
|
|
161
|
+
return candidates;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const parentDir = dirname(currentDir);
|
|
165
|
+
if (parentDir === currentDir) {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
currentDir = parentDir;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseAgentMode(value: unknown): AgentMode | undefined {
|
|
174
|
+
if (typeof value !== "string") {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const normalized = value.trim().toLowerCase();
|
|
179
|
+
if (normalized === "primary" || normalized === "subagent" || normalized === "all") {
|
|
180
|
+
return normalized;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseFrontmatter(content: string): Record<string, string> | null {
|
|
187
|
+
const normalized = content.replace(/\r\n/g, "\n");
|
|
188
|
+
if (!normalized.startsWith("---\n")) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const end = normalized.indexOf("\n---", 4);
|
|
193
|
+
if (end === -1) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const frontmatter: Record<string, string> = {};
|
|
198
|
+
const lines = normalized.slice(4, end).split("\n");
|
|
199
|
+
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
const separatorIndex = line.indexOf(":");
|
|
202
|
+
if (separatorIndex === -1) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
207
|
+
const value = line.slice(separatorIndex + 1).trim().replace(/^['\"]|['\"]$/g, "");
|
|
208
|
+
if (key) {
|
|
209
|
+
frontmatter[key] = value;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return frontmatter;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function parseAgentFile(filePath: string): SelectableAgent | null {
|
|
217
|
+
try {
|
|
218
|
+
const content = readFileSync(filePath, "utf-8");
|
|
219
|
+
const frontmatter = parseFrontmatter(content);
|
|
220
|
+
if (!frontmatter?.name) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
name: frontmatter.name,
|
|
226
|
+
description: frontmatter.description || `Agent ${frontmatter.name}`,
|
|
227
|
+
mode: parseAgentMode(frontmatter.mode),
|
|
228
|
+
};
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function loadAgentsFromDir(dirPath: string): SelectableAgent[] {
|
|
235
|
+
try {
|
|
236
|
+
return readdirSync(dirPath)
|
|
237
|
+
.filter((entry) => entry.endsWith(".md"))
|
|
238
|
+
.map((entry) => parseAgentFile(join(dirPath, entry)))
|
|
239
|
+
.filter((agent): agent is SelectableAgent => Boolean(agent));
|
|
240
|
+
} catch {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function truncateDescription(description: string, maxLength = 72): string {
|
|
246
|
+
const normalized = description.trim().replace(/\s+/g, " ");
|
|
247
|
+
if (normalized.length <= maxLength) {
|
|
248
|
+
return normalized;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function formatModeBadge(agent: SelectableAgent): string {
|
|
255
|
+
return `[${agent.mode ?? "primary"}]`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function formatCurrentMarker(currentAgentName: string | null, candidateAgentName: string): string {
|
|
259
|
+
return currentAgentName === candidateAgentName ? "●" : "○";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function buildAgentSelectionLabel(
|
|
263
|
+
agent: SelectableAgent,
|
|
264
|
+
currentAgentName: string | null,
|
|
265
|
+
): string {
|
|
266
|
+
return [
|
|
267
|
+
formatCurrentMarker(currentAgentName, agent.name),
|
|
268
|
+
agent.name,
|
|
269
|
+
formatModeBadge(agent),
|
|
270
|
+
"—",
|
|
271
|
+
truncateDescription(agent.description),
|
|
272
|
+
].join(" ");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function buildAgentSelectionMenu(
|
|
276
|
+
agents: readonly SelectableAgent[],
|
|
277
|
+
currentAgentName: string | null,
|
|
278
|
+
): AgentSelectionMenu {
|
|
279
|
+
const labels: string[] = [];
|
|
280
|
+
const valueByLabel = new Map<string, string>();
|
|
281
|
+
|
|
282
|
+
for (const agent of agents) {
|
|
283
|
+
const label = buildAgentSelectionLabel(agent, currentAgentName);
|
|
284
|
+
labels.push(label);
|
|
285
|
+
valueByLabel.set(label, agent.name);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
labels,
|
|
290
|
+
valueByLabel,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function discoverSelectableAgents(cwd: string): SelectableAgent[] {
|
|
295
|
+
const config = loadAgentDiscoveryConfig();
|
|
296
|
+
const projectAgentDirs = findNearestProjectAgentDirs(cwd, config.projectSourceDirs);
|
|
297
|
+
const userAgentDirs = config.userSourceDirs
|
|
298
|
+
.map((sourceDir) => resolveConfiguredUserPath(sourceDir))
|
|
299
|
+
.filter((candidate) => isDirectory(candidate));
|
|
300
|
+
|
|
301
|
+
const byName = new Map<string, SelectableAgent>();
|
|
302
|
+
const precedenceOrder = [
|
|
303
|
+
...userAgentDirs.slice().reverse(),
|
|
304
|
+
...projectAgentDirs.slice().reverse(),
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
for (const sourceDir of precedenceOrder) {
|
|
308
|
+
const agents = loadAgentsFromDir(sourceDir);
|
|
309
|
+
for (const agent of agents) {
|
|
310
|
+
byName.set(agent.name, agent);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return [...byName.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function resolveTargetAgentForSessionNix(
|
|
318
|
+
ctx: ExtensionCommandContext,
|
|
319
|
+
input: string | undefined,
|
|
320
|
+
currentAgentName: string | null,
|
|
321
|
+
): Promise<SelectableAgent | null | undefined> {
|
|
322
|
+
const agents = discoverSelectableAgents(ctx.cwd);
|
|
323
|
+
if (agents.length === 0) {
|
|
324
|
+
ctx.ui.notify(
|
|
325
|
+
"No agents were discovered. Check pi-agent-router agent directories before using /nix agent.",
|
|
326
|
+
"warning",
|
|
327
|
+
);
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (input) {
|
|
332
|
+
const matchedAgent = agents.find((agent) => agent.name === input);
|
|
333
|
+
if (!matchedAgent) {
|
|
334
|
+
const agentNames = agents.map((agent) => agent.name).join(", ");
|
|
335
|
+
ctx.ui.notify(`Unknown agent: ${input}\nAvailable agents: ${agentNames}`, "warning");
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return matchedAgent;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!ctx.hasUI) {
|
|
343
|
+
ctx.ui.notify("/nix agent requires an explicit agent name in non-interactive mode.", "warning");
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const { showAgentTargetPicker } = await import("./tui/agent-target-picker.js");
|
|
348
|
+
const selectedAgentName = await showAgentTargetPicker(ctx, agents, currentAgentName);
|
|
349
|
+
|
|
350
|
+
if (!selectedAgentName) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const selectedAgent = agents.find((agent) => agent.name === selectedAgentName);
|
|
355
|
+
if (!selectedAgent) {
|
|
356
|
+
ctx.ui.notify("Unknown agent selection. Please try again.", "warning");
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return selectedAgent;
|
|
361
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import { SESSION_CLEANUP_COMMAND, SESSION_NIX_COMMAND } from "./constants.js";
|
|
4
4
|
import {
|
|
5
5
|
getSessionCleanupArgumentCompletions,
|
|
6
6
|
handleSessionCleanupCommand,
|
|
7
7
|
} from "./session-cleanup-command.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getSessionNixArgumentCompletions,
|
|
10
|
+
handleSessionNixCommand,
|
|
11
|
+
} from "./session-nix-command.js";
|
|
12
|
+
import { flushScheduledSessionDeletionForQuit } from "./session-quit-shutdown.js";
|
|
9
13
|
|
|
10
14
|
export default function sessionCleanupExtension(pi: ExtensionAPI): void {
|
|
15
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
16
|
+
await flushScheduledSessionDeletionForQuit(ctx);
|
|
17
|
+
});
|
|
18
|
+
|
|
11
19
|
pi.registerCommand(SESSION_CLEANUP_COMMAND, {
|
|
12
20
|
description:
|
|
13
21
|
"Batch-select previous sessions and delete them with confirmation.",
|
|
@@ -19,7 +27,8 @@ export default function sessionCleanupExtension(pi: ExtensionAPI): void {
|
|
|
19
27
|
|
|
20
28
|
pi.registerCommand(SESSION_NIX_COMMAND, {
|
|
21
29
|
description:
|
|
22
|
-
"Start a
|
|
30
|
+
"Start a fresh session, switch to a target agent, or delete the current session and quit Pi.",
|
|
31
|
+
getArgumentCompletions: getSessionNixArgumentCompletions,
|
|
23
32
|
handler: async (args, ctx) => {
|
|
24
33
|
await handleSessionNixCommand(args, ctx);
|
|
25
34
|
},
|