@oh-my-pi/pi-coding-agent 17.1.7 → 17.1.8
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 +35 -0
- package/dist/{CHANGELOG-5k4dq4g6.md → CHANGELOG-vt8ene9g.md} +35 -0
- package/dist/cli.js +4087 -7122
- package/dist/template-dys3vk5b.js +1671 -0
- package/dist/template-f8wx9vfn.css +1355 -0
- package/dist/template-qat058wr.html +55 -0
- package/dist/tool-views.generated-jdfmzwmn.js +35 -0
- package/dist/types/advisor/advise-tool.d.ts +0 -7
- package/dist/types/advisor/runtime.d.ts +0 -9
- package/dist/types/async/job-manager.d.ts +11 -0
- package/dist/types/cleanse/agent.d.ts +19 -0
- package/dist/types/cleanse/balance.d.ts +7 -0
- package/dist/types/cleanse/checkers.d.ts +13 -0
- package/dist/types/cleanse/index.d.ts +16 -0
- package/dist/types/cleanse/loop.d.ts +16 -0
- package/dist/types/cleanse/parsers.d.ts +13 -0
- package/dist/types/cleanse/progress.d.ts +14 -0
- package/dist/types/cleanse/types.d.ts +64 -0
- package/dist/types/commands/cleanse.d.ts +23 -0
- package/dist/types/export/html/index.d.ts +2 -0
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
- package/dist/types/modes/interactive-mode.d.ts +4 -3
- package/dist/types/session/async-job-delivery.d.ts +8 -0
- package/dist/types/session/model-controls.d.ts +3 -3
- package/dist/types/task/isolation-ownership.d.ts +34 -0
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
- package/dist/types/tools/browser/tab-worker.d.ts +1 -4
- package/dist/types/tui/output-block.d.ts +5 -5
- package/package.json +16 -12
- package/scripts/bundle-dist.ts +3 -1
- package/src/advisor/advise-tool.ts +0 -11
- package/src/advisor/runtime.ts +0 -12
- package/src/async/job-manager.ts +30 -7
- package/src/cleanse/agent.ts +226 -0
- package/src/cleanse/balance.ts +79 -0
- package/src/cleanse/checkers.ts +996 -0
- package/src/cleanse/index.ts +190 -0
- package/src/cleanse/loop.ts +51 -0
- package/src/cleanse/parsers.ts +726 -0
- package/src/cleanse/progress.ts +50 -0
- package/src/cleanse/prompts/assignment.md +47 -0
- package/src/cleanse/types.ts +72 -0
- package/src/cli/update-cli.ts +3 -0
- package/src/cli/usage-cli.ts +29 -4
- package/src/cli/worktree-cli.ts +28 -11
- package/src/cli-commands.ts +1 -0
- package/src/cli.ts +2 -1
- package/src/commands/cleanse.ts +45 -0
- package/src/discovery/claude-plugins.ts +144 -34
- package/src/export/html/index.ts +17 -10
- package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
- package/src/launch/broker.ts +14 -4
- package/src/modes/acp/acp-agent.ts +12 -9
- package/src/modes/acp/acp-event-mapper.ts +38 -4
- package/src/modes/components/settings-selector.ts +9 -1
- package/src/modes/interactive-mode.ts +36 -47
- package/src/modes/prompt-action-autocomplete.ts +5 -3
- package/src/prompts/goals/guided-goal-interview.md +41 -6
- package/src/prompts/system/vibe-mode-active.md +4 -1
- package/src/prompts/tools/browser.md +1 -0
- package/src/prompts/tools/goal.md +1 -1
- package/src/session/agent-session.ts +28 -4
- package/src/session/async-job-delivery.ts +8 -0
- package/src/session/model-controls.ts +4 -4
- package/src/session/session-advisors.ts +2 -7
- package/src/session/session-history-format.ts +31 -6
- package/src/slash-commands/builtin-registry.ts +5 -2
- package/src/task/isolation-ownership.ts +106 -0
- package/src/task/worktree.ts +8 -0
- package/src/tools/ask.ts +3 -3
- package/src/tools/ast-edit.ts +9 -2
- package/src/tools/browser/cmux/cmux-tab.ts +9 -14
- package/src/tools/browser/tab-worker.ts +12 -35
- package/src/tools/browser.ts +2 -2
- package/src/tools/gh-renderer.ts +3 -3
- package/src/tools/index.ts +10 -1
- package/src/tools/write.ts +35 -0
- package/src/tui/code-cell.ts +4 -4
- package/src/tui/output-block.ts +25 -8
- package/src/utils/shell-snapshot-fn-env.sh +5 -2
- package/src/web/search/render.ts +2 -2
- package/dist/types/goals/guided-setup.d.ts +0 -30
- package/src/goals/guided-setup.ts +0 -171
- package/src/prompts/goals/guided-goal-system.md +0 -33
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const BAR_WIDTH = 16;
|
|
2
|
+
|
|
3
|
+
/** Minimal output contract used by the interactive cleanse progress reporter. */
|
|
4
|
+
export interface CleanseProgressOutput {
|
|
5
|
+
isTTY?: boolean;
|
|
6
|
+
write(text: string): boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Renders completed repair workers on one transient terminal line. */
|
|
10
|
+
export interface CleanseProgressReporter {
|
|
11
|
+
readonly interactive: boolean;
|
|
12
|
+
start(total: number): void;
|
|
13
|
+
complete(): void;
|
|
14
|
+
finish(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Create the TTY-only worker completion reporter used by `omp cleanse`. */
|
|
18
|
+
export function createCleanseProgressReporter(output: CleanseProgressOutput = process.stdout): CleanseProgressReporter {
|
|
19
|
+
const interactive = output.isTTY === true;
|
|
20
|
+
let total = 0;
|
|
21
|
+
let completed = 0;
|
|
22
|
+
let rendered = false;
|
|
23
|
+
|
|
24
|
+
const render = (): void => {
|
|
25
|
+
if (!interactive || total === 0) return;
|
|
26
|
+
const ratio = Math.min(completed / total, 1);
|
|
27
|
+
const filled = Math.round(ratio * BAR_WIDTH);
|
|
28
|
+
const bar = `${"█".repeat(filled)}${"░".repeat(BAR_WIDTH - filled)}`;
|
|
29
|
+
output.write(`\rRepairing [${bar}] ${completed}/${total}\x1b[K`);
|
|
30
|
+
rendered = true;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
interactive,
|
|
35
|
+
start(nextTotal) {
|
|
36
|
+
total = Math.max(nextTotal, 0);
|
|
37
|
+
completed = 0;
|
|
38
|
+
render();
|
|
39
|
+
},
|
|
40
|
+
complete() {
|
|
41
|
+
completed = Math.min(completed + 1, total);
|
|
42
|
+
render();
|
|
43
|
+
},
|
|
44
|
+
finish() {
|
|
45
|
+
if (!rendered) return;
|
|
46
|
+
output.write("\n");
|
|
47
|
+
rendered = false;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<critical>
|
|
2
|
+
- You MUST fix every assigned diagnostic at its root cause.
|
|
3
|
+
- You MUST stay inside the write scope below.
|
|
4
|
+
- You NEVER suppress valid diagnostics to make checks pass.
|
|
5
|
+
- You NEVER delegate or spawn another agent.
|
|
6
|
+
- You NEVER run project-wide checks or formatters; the orchestrator reruns them.
|
|
7
|
+
</critical>
|
|
8
|
+
|
|
9
|
+
# Assignment
|
|
10
|
+
|
|
11
|
+
Repair wave {{wave}}, worker {{worker}}.
|
|
12
|
+
|
|
13
|
+
## Write scope
|
|
14
|
+
|
|
15
|
+
{{write_scope}}
|
|
16
|
+
|
|
17
|
+
Read related code freely. Project-level diagnostics MAY require the smallest necessary edit outside named files; use `hub` before touching a peer-owned file.
|
|
18
|
+
|
|
19
|
+
## Diagnostics
|
|
20
|
+
|
|
21
|
+
{{diagnostics}}
|
|
22
|
+
|
|
23
|
+
## Checker commands
|
|
24
|
+
|
|
25
|
+
{{checker_commands}}
|
|
26
|
+
|
|
27
|
+
## Concurrent peer ownership
|
|
28
|
+
|
|
29
|
+
{{peer_assignments}}
|
|
30
|
+
|
|
31
|
+
<workflow>
|
|
32
|
+
1. Inspect every assigned location and its local context.
|
|
33
|
+
2. Fix causes, not emitted text or checker configuration.
|
|
34
|
+
3. Preserve behavior unless a diagnostic proves it wrong.
|
|
35
|
+
4. Run only targeted checks for assigned files when available.
|
|
36
|
+
5. Re-read changed sections and resolve every assigned item.
|
|
37
|
+
</workflow>
|
|
38
|
+
|
|
39
|
+
<completeness>
|
|
40
|
+
- Done means every assigned diagnostic has a concrete fix.
|
|
41
|
+
- Blocked items MUST name the missing prerequisite in the final result.
|
|
42
|
+
- Final output MUST list changed files and unresolved items.
|
|
43
|
+
</completeness>
|
|
44
|
+
|
|
45
|
+
<critical>
|
|
46
|
+
You MUST leave peer-owned files untouched and complete the full assignment.
|
|
47
|
+
</critical>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Severity normalized across checker-specific output formats. */
|
|
2
|
+
export type CleanseSeverity = "error" | "warning" | "info";
|
|
3
|
+
|
|
4
|
+
/** One actionable problem reported by a project checker. */
|
|
5
|
+
export interface CleanseDiagnostic {
|
|
6
|
+
checker: string;
|
|
7
|
+
file?: string;
|
|
8
|
+
line?: number;
|
|
9
|
+
column?: number;
|
|
10
|
+
endLine?: number;
|
|
11
|
+
endColumn?: number;
|
|
12
|
+
code?: string;
|
|
13
|
+
severity: CleanseSeverity;
|
|
14
|
+
message: string;
|
|
15
|
+
suggestion?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Result metadata for one checker invocation. */
|
|
19
|
+
export interface CleanseCheckResult {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
language: string;
|
|
23
|
+
cwd: string;
|
|
24
|
+
command: string;
|
|
25
|
+
exitCode: number | null;
|
|
26
|
+
diagnostics: CleanseDiagnostic[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Checker discovery result omitted because its required executable was unavailable. */
|
|
30
|
+
export interface SkippedCleanseCheck {
|
|
31
|
+
label: string;
|
|
32
|
+
language: string;
|
|
33
|
+
reason: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Aggregate diagnostics from every checker selected for a project. */
|
|
37
|
+
export interface CleanseDiagnosticReport {
|
|
38
|
+
checks: CleanseCheckResult[];
|
|
39
|
+
diagnostics: CleanseDiagnostic[];
|
|
40
|
+
skipped: SkippedCleanseCheck[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** All diagnostics attached to one file, which scheduling never splits across agents. */
|
|
44
|
+
export interface CleanseFileIssues {
|
|
45
|
+
file?: string;
|
|
46
|
+
diagnostics: CleanseDiagnostic[];
|
|
47
|
+
weight: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One weighted, file-disjoint workload handed to a subagent. */
|
|
51
|
+
export interface CleanseAssignment {
|
|
52
|
+
index: number;
|
|
53
|
+
groups: CleanseFileIssues[];
|
|
54
|
+
weight: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Settled result from one cleanse subagent. */
|
|
58
|
+
export interface CleanseAgentOutcome {
|
|
59
|
+
name: string;
|
|
60
|
+
success: boolean;
|
|
61
|
+
output: string;
|
|
62
|
+
error?: string;
|
|
63
|
+
resolvedModel?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Final state after one bounded repair wave and verification pass. */
|
|
67
|
+
export interface CleanseLoopResult {
|
|
68
|
+
status: "clean" | "stalled" | "cancelled";
|
|
69
|
+
waves: number;
|
|
70
|
+
report: CleanseDiagnosticReport;
|
|
71
|
+
outcomes: CleanseAgentOutcome[];
|
|
72
|
+
}
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -231,6 +231,9 @@ export async function downloadVerifiedBinary(options: VerifiedBinaryDownloadOpti
|
|
|
231
231
|
await fs.promises.chmod(options.targetPath, 0o755);
|
|
232
232
|
} catch (err) {
|
|
233
233
|
await unlinkIfExists(options.targetPath);
|
|
234
|
+
if (isTimeoutError(err)) {
|
|
235
|
+
throw new Error("Timed out downloading release binary after 15 minutes", { cause: err });
|
|
236
|
+
}
|
|
234
237
|
throw err;
|
|
235
238
|
}
|
|
236
239
|
}
|
package/src/cli/usage-cli.ts
CHANGED
|
@@ -560,9 +560,34 @@ function formatReloginDeadline(
|
|
|
560
560
|
* automatically (refresh failure, upstream invalidation). Rows the user
|
|
561
561
|
* replaced or deleted deliberately are lifecycle noise, not lost capacity.
|
|
562
562
|
*/
|
|
563
|
-
function isActionableDisable(summary: DisabledCredentialSummary): boolean {
|
|
563
|
+
function isActionableDisable(summary: DisabledCredentialSummary, activeAccounts: UsageAccountIdentity[] = []): boolean {
|
|
564
564
|
if (summary.type !== "oauth") return false;
|
|
565
|
-
|
|
565
|
+
if (/^(replaced by|deleted by user)/i.test(summary.cause)) return false;
|
|
566
|
+
|
|
567
|
+
// Do not display tombstone if there is an active account for the same provider
|
|
568
|
+
// matching the same identity (email, accountId, or org).
|
|
569
|
+
const summaryEmail = summary.email?.toLowerCase();
|
|
570
|
+
const summaryAccountId = summary.accountId?.toLowerCase();
|
|
571
|
+
const summaryOrgId = summary.orgId?.toLowerCase();
|
|
572
|
+
|
|
573
|
+
const matchesActive = activeAccounts.some(account => {
|
|
574
|
+
if (account.provider !== summary.provider) return false;
|
|
575
|
+
|
|
576
|
+
const accountEmail = account.email?.toLowerCase();
|
|
577
|
+
const accountAccountId = account.accountId?.toLowerCase();
|
|
578
|
+
const accountOrgId = account.orgId?.toLowerCase();
|
|
579
|
+
|
|
580
|
+
// If email or accountId match, it's the same identity
|
|
581
|
+
if (summaryEmail && accountEmail && summaryEmail === accountEmail) return true;
|
|
582
|
+
if (summaryAccountId && accountAccountId && summaryAccountId === accountAccountId) return true;
|
|
583
|
+
|
|
584
|
+
// Fallback: if orgId matches and neither email nor accountId contradicts
|
|
585
|
+
if (summaryOrgId && accountOrgId && summaryOrgId === accountOrgId) return true;
|
|
586
|
+
|
|
587
|
+
return false;
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
return !matchesActive;
|
|
566
591
|
}
|
|
567
592
|
|
|
568
593
|
/** Human-sized disable cause: the upstream `error_description` when embedded, else the first clause. */
|
|
@@ -610,7 +635,7 @@ export function formatUsageBreakdown(
|
|
|
610
635
|
}
|
|
611
636
|
const disabledByProvider = new Map<string, DisabledCredentialSummary[]>();
|
|
612
637
|
for (const summary of disabled) {
|
|
613
|
-
if (!isActionableDisable(summary)) continue;
|
|
638
|
+
if (!isActionableDisable(summary, accounts)) continue;
|
|
614
639
|
const list = disabledByProvider.get(summary.provider) ?? [];
|
|
615
640
|
list.push(summary);
|
|
616
641
|
disabledByProvider.set(summary.provider, list);
|
|
@@ -1018,7 +1043,7 @@ export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
|
|
|
1018
1043
|
const stats = computeProviderWindowStats(filteredReports.filter(peer => peer.provider === report.provider));
|
|
1019
1044
|
if (stats.length > 0) capacity[report.provider] = stats;
|
|
1020
1045
|
}
|
|
1021
|
-
let disabledForJson = disabled.filter(isActionableDisable);
|
|
1046
|
+
let disabledForJson = disabled.filter(summary => isActionableDisable(summary, accounts));
|
|
1022
1047
|
if (redaction) {
|
|
1023
1048
|
disabledForJson = disabledForJson.map(summary => ({
|
|
1024
1049
|
...summary,
|
package/src/cli/worktree-cli.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* `<parent-repo>/.git/worktrees/<name>/`.
|
|
9
9
|
* - **Task-isolation dirs** (`task/worktree.ts`): a wrapper dir with a
|
|
10
10
|
* compact `m` subdir mounted/cloned by `natives.isoStart`. Legacy `merged`
|
|
11
|
-
* subdirs are still recognized.
|
|
12
|
-
*
|
|
11
|
+
* subdirs are still recognized. `ensureIsolation` writes an ownership
|
|
12
|
+
* marker naming the live omp process; a
|
|
13
|
+
* sandbox whose owner is still running is reported `live` and never
|
|
14
|
+
* removed without `--all`, so `clear` reclaims only crashed leftovers.
|
|
13
15
|
*
|
|
14
16
|
* Legacy entries from before the encoding change keep working because git still
|
|
15
17
|
* tracks them by branch name. This command exists to GC them on demand.
|
|
@@ -18,6 +20,7 @@ import * as fs from "node:fs/promises";
|
|
|
18
20
|
import * as path from "node:path";
|
|
19
21
|
import { getWorktreesDir, isEnoent } from "@oh-my-pi/pi-utils";
|
|
20
22
|
import chalk from "chalk";
|
|
23
|
+
import { hasLiveIsolationOwner, ISOLATION_OWNER_FILE } from "../task/isolation-ownership";
|
|
21
24
|
import * as git from "../utils/git";
|
|
22
25
|
|
|
23
26
|
type WorktreeKind = "pr-checkout" | "task-isolation" | "empty" | "stray";
|
|
@@ -211,16 +214,30 @@ async function classifyDir(dir: string): Promise<WorktreeEntry | null> {
|
|
|
211
214
|
if (gitStat?.isFile()) {
|
|
212
215
|
return classifyPrCheckout(dir, gitEntry);
|
|
213
216
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
// A task-isolation sandbox is identified by its ownership marker — written
|
|
218
|
+
// before the backend materialises the mount — or by the `m`/`merged` mount
|
|
219
|
+
// dir itself (legacy dirs and crashed pre-marker runs). Recognizing the
|
|
220
|
+
// marker alone keeps an in-progress sandbox from being mistaken for a stray
|
|
221
|
+
// during the window between marker creation and mount materialisation.
|
|
222
|
+
let isIsolation = await Bun.file(path.join(dir, ISOLATION_OWNER_FILE)).exists();
|
|
223
|
+
if (!isIsolation) {
|
|
224
|
+
for (const mountDir of TASK_ISOLATION_MOUNT_DIRS) {
|
|
225
|
+
const mountStat = await fs.stat(path.join(dir, mountDir)).catch(() => null);
|
|
226
|
+
if (mountStat?.isDirectory()) {
|
|
227
|
+
isIsolation = true;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
222
231
|
}
|
|
223
|
-
return null;
|
|
232
|
+
if (!isIsolation) return null;
|
|
233
|
+
const live = await hasLiveIsolationOwner(dir);
|
|
234
|
+
return {
|
|
235
|
+
path: dir,
|
|
236
|
+
kind: "task-isolation",
|
|
237
|
+
// Only after confirming no live owner is the "no live task" claim true.
|
|
238
|
+
// A running subagent's sandbox stays live so `clear` won't delete it.
|
|
239
|
+
orphanReason: live ? undefined : "task-isolation leftover (no live task owns it)",
|
|
240
|
+
};
|
|
224
241
|
}
|
|
225
242
|
|
|
226
243
|
async function classifyPrCheckout(dir: string, gitEntry: string): Promise<WorktreeEntry> {
|
package/src/cli-commands.ts
CHANGED
|
@@ -18,6 +18,7 @@ export const commands: CommandEntry[] = [
|
|
|
18
18
|
{ name: "auth-gateway", load: () => import("./commands/auth-gateway").then(m => m.default) },
|
|
19
19
|
{ name: "agents", load: () => import("./commands/agents").then(m => m.default) },
|
|
20
20
|
{ name: "bench", load: () => import("./commands/bench").then(m => m.default) },
|
|
21
|
+
{ name: "cleanse", load: () => import("./commands/cleanse").then(m => m.default) },
|
|
21
22
|
{ name: "commit", load: () => import("./commands/commit").then(m => m.default) },
|
|
22
23
|
{ name: "completions", load: () => import("./commands/completions").then(m => m.default) },
|
|
23
24
|
{ name: "__complete", load: () => import("./commands/complete").then(m => m.default) },
|
package/src/cli.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
VERSION,
|
|
26
26
|
} from "@oh-my-pi/pi-utils/dirs";
|
|
27
27
|
import { interceptUnhandledRejections } from "@oh-my-pi/pi-utils/postmortem";
|
|
28
|
+
import { setProcessName } from "@oh-my-pi/pi-utils/process-name";
|
|
28
29
|
import { declareWorkerHostEntry, installWorkerInbox, isWorkerHostSelector } from "@oh-my-pi/pi-utils/worker-host";
|
|
29
30
|
import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias";
|
|
30
31
|
import { extractProfileFlags } from "./cli/profile-bootstrap";
|
|
@@ -42,7 +43,7 @@ if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
|
|
|
42
43
|
process.exit(1);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
setProcessName(APP_NAME);
|
|
46
47
|
|
|
47
48
|
// `Bun.build`-API compiled Windows executables report `import.meta.main ===
|
|
48
49
|
// false`: the standalone loader keys the entry module with native backslashes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { postmortem } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import { Command, Flags } from "@oh-my-pi/pi-utils/cli";
|
|
3
|
+
import { runCleanseCommand } from "../cleanse";
|
|
4
|
+
import { CliUsageError } from "../cli/usage-error";
|
|
5
|
+
|
|
6
|
+
export default class Cleanse extends Command {
|
|
7
|
+
static description = "Detect and fix project diagnostics with weighted parallel subagents";
|
|
8
|
+
|
|
9
|
+
static flags = {
|
|
10
|
+
agents: Flags.integer({
|
|
11
|
+
char: "n",
|
|
12
|
+
description: "Maximum number of file-disjoint subagents",
|
|
13
|
+
default: 8,
|
|
14
|
+
}),
|
|
15
|
+
model: Flags.string({
|
|
16
|
+
char: "m",
|
|
17
|
+
description: "Subagent model selector",
|
|
18
|
+
default: "@smol",
|
|
19
|
+
}),
|
|
20
|
+
tests: Flags.boolean({
|
|
21
|
+
char: "t",
|
|
22
|
+
description: "Also run configured project test suites",
|
|
23
|
+
default: false,
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
static examples = [
|
|
28
|
+
"omp cleanse",
|
|
29
|
+
"omp cleanse -n 4",
|
|
30
|
+
"omp cleanse -m opus",
|
|
31
|
+
"omp cleanse -t",
|
|
32
|
+
"omp cleanse --agents 12 --model anthropic/claude-opus-4-6",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
async run(): Promise<void> {
|
|
36
|
+
const { flags } = await this.parse(Cleanse);
|
|
37
|
+
if (flags.agents <= 0) throw new CliUsageError("--agents must be a positive integer");
|
|
38
|
+
const result = await runCleanseCommand({
|
|
39
|
+
maxAgents: flags.agents,
|
|
40
|
+
model: flags.model,
|
|
41
|
+
includeTests: flags.tests,
|
|
42
|
+
});
|
|
43
|
+
await postmortem.quit(result.exitCode);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -41,6 +41,20 @@ interface ResolvedPluginDir {
|
|
|
41
41
|
warnings: string[];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
interface ResolvedMCPConfig {
|
|
45
|
+
/** On-disk config file to read, or null when servers are inline or nothing applies. */
|
|
46
|
+
path: string | null;
|
|
47
|
+
/** Server map declared inline in the plugin manifest, or null when the source is a file. */
|
|
48
|
+
inlineServers: Record<string, unknown> | null;
|
|
49
|
+
/** Path recorded as each discovered server's capability source. */
|
|
50
|
+
sourcePath: string;
|
|
51
|
+
/** Directory that relative stdio `command`/`cwd` values resolve against. */
|
|
52
|
+
baseDir: string;
|
|
53
|
+
/** True when a plugin manifest named this source, false for the conventional fallback. */
|
|
54
|
+
declared: boolean;
|
|
55
|
+
warnings: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
async function readPluginManifest(root: ClaudePluginRoot): Promise<ClaudePluginManifest | null> {
|
|
45
59
|
const manifestPath = path.join(root.path, ".claude-plugin", "plugin.json");
|
|
46
60
|
const raw = await readFile(manifestPath);
|
|
@@ -375,50 +389,144 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
|
|
|
375
389
|
// MCP Servers
|
|
376
390
|
// =============================================================================
|
|
377
391
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
392
|
+
/**
|
|
393
|
+
* Unwrap a parsed MCP config file to its server map. Supports the nested
|
|
394
|
+
* `{ mcpServers: { … } }` project shape and the flat `{ name: cfg, … }`
|
|
395
|
+
* marketplace-plugin shape. Returns null when a `mcpServers` field is present
|
|
396
|
+
* but not an object map (malformed) so the caller skips the file.
|
|
397
|
+
*/
|
|
398
|
+
function extractServerMap(obj: Record<string, unknown>): Record<string, unknown> | null {
|
|
399
|
+
if (isRecord(obj.mcpServers)) return obj.mcpServers;
|
|
400
|
+
if (!("mcpServers" in obj)) return obj;
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
384
403
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
404
|
+
/**
|
|
405
|
+
* Resolve where a plugin's MCP servers come from, honoring the manifest's
|
|
406
|
+
* `mcpServers` field before the conventional root `.mcp.json`.
|
|
407
|
+
*
|
|
408
|
+
* `.omp-plugin/plugin.json` takes precedence over `.claude-plugin/plugin.json`.
|
|
409
|
+
* The field may be an inline object (the server map itself) or a string path to
|
|
410
|
+
* a config file within the plugin root; a path escaping the root is rejected
|
|
411
|
+
* with a warning. When no manifest declares the field, `<root>/.mcp.json` is the
|
|
412
|
+
* fallback source.
|
|
413
|
+
*/
|
|
414
|
+
async function resolvePluginMCPConfig(root: ClaudePluginRoot): Promise<ResolvedMCPConfig> {
|
|
415
|
+
const fallback = path.join(root.path, ".mcp.json");
|
|
416
|
+
for (const manifestDir of [".omp-plugin", ".claude-plugin"]) {
|
|
417
|
+
const manifestPath = path.join(root.path, manifestDir, "plugin.json");
|
|
418
|
+
const raw = await readFile(manifestPath);
|
|
419
|
+
if (raw === null) continue;
|
|
389
420
|
|
|
390
421
|
let parsed: unknown;
|
|
391
422
|
try {
|
|
392
423
|
parsed = JSON.parse(raw);
|
|
393
424
|
} catch {
|
|
394
|
-
warnings.push(`[claude-plugins] Invalid JSON in ${mcpPath}`);
|
|
395
|
-
logger.warn(`[claude-plugins] Invalid JSON in ${mcpPath}`);
|
|
396
425
|
continue;
|
|
397
426
|
}
|
|
427
|
+
if (!isRecord(parsed)) continue;
|
|
428
|
+
const pointer = parsed.mcpServers;
|
|
429
|
+
|
|
430
|
+
// Inline object form: the manifest value is the server map itself, rooted
|
|
431
|
+
// at the plugin directory (Claude's ${CLAUDE_PLUGIN_ROOT} base).
|
|
432
|
+
if (isRecord(pointer)) {
|
|
433
|
+
return {
|
|
434
|
+
path: null,
|
|
435
|
+
inlineServers: pointer,
|
|
436
|
+
sourcePath: manifestPath,
|
|
437
|
+
baseDir: root.path,
|
|
438
|
+
declared: true,
|
|
439
|
+
warnings: [],
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// File-pointer form: resolve the named config file within the plugin root.
|
|
444
|
+
if (typeof pointer === "string") {
|
|
445
|
+
const configured = pointer.trim();
|
|
446
|
+
if (configured.length === 0) continue;
|
|
447
|
+
const resolved = path.resolve(root.path, configured);
|
|
448
|
+
if (!isWithinPluginRoot(root.path, resolved)) {
|
|
449
|
+
return {
|
|
450
|
+
path: null,
|
|
451
|
+
inlineServers: null,
|
|
452
|
+
sourcePath: manifestPath,
|
|
453
|
+
baseDir: root.path,
|
|
454
|
+
declared: true,
|
|
455
|
+
warnings: [
|
|
456
|
+
`[claude-plugins] Ignoring mcpServers path outside plugin root for ${root.id}: ${configured}`,
|
|
457
|
+
],
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
path: resolved,
|
|
462
|
+
inlineServers: null,
|
|
463
|
+
sourcePath: resolved,
|
|
464
|
+
baseDir: path.dirname(resolved),
|
|
465
|
+
declared: true,
|
|
466
|
+
warnings: [],
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
path: fallback,
|
|
473
|
+
inlineServers: null,
|
|
474
|
+
sourcePath: fallback,
|
|
475
|
+
baseDir: path.dirname(fallback),
|
|
476
|
+
declared: false,
|
|
477
|
+
warnings: [],
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
|
|
482
|
+
const items: MCPServer[] = [];
|
|
483
|
+
const warnings: string[] = [];
|
|
398
484
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
485
|
+
const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
|
|
486
|
+
warnings.push(...rootWarnings);
|
|
487
|
+
|
|
488
|
+
for (const root of roots) {
|
|
489
|
+
const resolved = await resolvePluginMCPConfig(root);
|
|
490
|
+
warnings.push(...resolved.warnings);
|
|
491
|
+
|
|
492
|
+
let servers: Record<string, unknown> | null;
|
|
493
|
+
if (resolved.inlineServers) {
|
|
494
|
+
servers = resolved.inlineServers;
|
|
495
|
+
} else if (resolved.path !== null) {
|
|
496
|
+
const raw = await readFile(resolved.path);
|
|
497
|
+
if (raw === null) {
|
|
498
|
+
// The conventional fallback is optional, but a manifest that names a
|
|
499
|
+
// missing file is an authoring error that would otherwise register
|
|
500
|
+
// zero servers with no explanation.
|
|
501
|
+
if (resolved.declared) {
|
|
502
|
+
const warning = `[claude-plugins] Missing mcpServers file declared by ${root.id}: ${resolved.path}`;
|
|
503
|
+
warnings.push(warning);
|
|
504
|
+
logger.warn(warning);
|
|
505
|
+
}
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
let parsed: unknown;
|
|
510
|
+
try {
|
|
511
|
+
parsed = JSON.parse(raw);
|
|
512
|
+
} catch {
|
|
513
|
+
warnings.push(`[claude-plugins] Invalid JSON in ${resolved.path}`);
|
|
514
|
+
logger.warn(`[claude-plugins] Invalid JSON in ${resolved.path}`);
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
// Two file shapes are supported:
|
|
518
|
+
// nested: { "mcpServers": { name: cfg, ... } } (OMP/Claude Code project shape)
|
|
519
|
+
// flat: { name: cfg, ... } (Claude marketplace plugin shape)
|
|
520
|
+
if (!isRecord(parsed)) continue;
|
|
521
|
+
servers = extractServerMap(parsed);
|
|
417
522
|
} else {
|
|
418
523
|
continue;
|
|
419
524
|
}
|
|
525
|
+
if (servers === null) continue;
|
|
420
526
|
|
|
421
|
-
|
|
527
|
+
const { sourcePath, baseDir } = resolved;
|
|
528
|
+
for (const serverName in servers) {
|
|
529
|
+
const serverCfg = servers[serverName];
|
|
422
530
|
if (!serverCfg || typeof serverCfg !== "object" || Array.isArray(serverCfg)) continue;
|
|
423
531
|
const raw = serverCfg as {
|
|
424
532
|
enabled?: boolean;
|
|
@@ -437,7 +545,9 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
437
545
|
// occasionally ship .mcp.json entries with neither, which would register a useless
|
|
438
546
|
// server and surface as a connection error at runtime.
|
|
439
547
|
if (typeof raw.command !== "string" && typeof raw.url !== "string") {
|
|
440
|
-
warnings.push(
|
|
548
|
+
warnings.push(
|
|
549
|
+
`[claude-plugins] Skipping MCP server "${serverName}" in ${sourcePath}: missing command or url`,
|
|
550
|
+
);
|
|
441
551
|
continue;
|
|
442
552
|
}
|
|
443
553
|
const namespacedName = root.plugin ? `${root.plugin}:${serverName}` : serverName;
|
|
@@ -446,7 +556,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
446
556
|
const substitutedCwd = raw.cwd !== undefined ? substitutePluginRoot(raw.cwd, root.path) : undefined;
|
|
447
557
|
// Root relative command/cwd at the plugin's config directory, not the
|
|
448
558
|
// session cwd (MCP stdio spawning resolves relative values there).
|
|
449
|
-
const rooted = resolvePluginStdioPaths({ command: substitutedCommand, cwd: substitutedCwd },
|
|
559
|
+
const rooted = resolvePluginStdioPaths({ command: substitutedCommand, cwd: substitutedCwd }, baseDir);
|
|
450
560
|
const server: MCPServer = {
|
|
451
561
|
name: namespacedName,
|
|
452
562
|
...(raw.enabled !== undefined && { enabled: raw.enabled }),
|
|
@@ -460,7 +570,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
460
570
|
...(raw.auth !== undefined && { auth: raw.auth }),
|
|
461
571
|
...(raw.oauth !== undefined && { oauth: raw.oauth }),
|
|
462
572
|
...(raw.type !== undefined && { transport: raw.type as MCPServer["transport"] }),
|
|
463
|
-
_source: createSourceMeta(PROVIDER_ID,
|
|
573
|
+
_source: createSourceMeta(PROVIDER_ID, sourcePath, root.scope),
|
|
464
574
|
};
|
|
465
575
|
items.push(server);
|
|
466
576
|
}
|
package/src/export/html/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as fs from "node:fs
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { AgentState } from "@oh-my-pi/pi-agent-core";
|
|
4
4
|
import { APP_NAME, isEnoent } from "@oh-my-pi/pi-utils";
|
|
@@ -7,31 +7,38 @@ import type { SessionEntry, SessionHeader } from "../../session/session-entries"
|
|
|
7
7
|
import { loadEntriesFromFile } from "../../session/session-loader";
|
|
8
8
|
import { SessionManager } from "../../session/session-manager";
|
|
9
9
|
import type { ExportThemeNames } from "./args";
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
import
|
|
10
|
+
import templateCssPath from "./template.css" with { type: "file" };
|
|
11
|
+
import templateHtmlPath from "./template.html" with { type: "file" };
|
|
12
|
+
import templateJsPath from "./template.js" with { type: "file" };
|
|
13
13
|
// Pre-built React tool renderers: built by `gen:tool-views` (`bun run gen:tool-views`),
|
|
14
14
|
// run automatically by root `prepare` on install and by `prepack` at publish.
|
|
15
|
-
import
|
|
15
|
+
import toolViewsJsPath from "./tool-views.generated.js" with { type: "file" };
|
|
16
16
|
import { webExportThemeVars } from "./web-palette";
|
|
17
17
|
|
|
18
18
|
export { type ExportThemeNames, parseExportArgs } from "./args";
|
|
19
19
|
|
|
20
20
|
let cachedTemplate: string | undefined;
|
|
21
|
+
/** Resolve a Bun file-loader value without parsing Windows drive letters as URL schemes. */
|
|
22
|
+
export function resolveBundledHtmlAssetPath(assetPath: string, moduleDir: string = import.meta.dir): string {
|
|
23
|
+
if (path.isAbsolute(assetPath) || path.win32.isAbsolute(assetPath)) return assetPath;
|
|
24
|
+
return path.resolve(moduleDir, assetPath);
|
|
25
|
+
}
|
|
21
26
|
|
|
22
27
|
/** Compose the standalone export template: minified CSS, tool renderers, and viewer JS inlined. */
|
|
23
28
|
export function getTemplate(): string {
|
|
24
29
|
if (cachedTemplate) return cachedTemplate;
|
|
30
|
+
const templateCss = fs.readFileSync(resolveBundledHtmlAssetPath(templateCssPath), "utf8");
|
|
31
|
+
const templateHtml = fs.readFileSync(resolveBundledHtmlAssetPath(templateHtmlPath as unknown as string), "utf8");
|
|
32
|
+
const templateJs = fs.readFileSync(resolveBundledHtmlAssetPath(templateJsPath), "utf8");
|
|
33
|
+
const toolViewsJs = fs.readFileSync(resolveBundledHtmlAssetPath(toolViewsJsPath), "utf8");
|
|
25
34
|
const minifiedCss = templateCss
|
|
26
35
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
27
36
|
.replace(/\s+/g, " ")
|
|
28
37
|
.replace(/\s*([{}:;,])\s*/g, "$1")
|
|
29
38
|
.trim();
|
|
30
39
|
// Function replacements so `$'`, `$&`, `$$`, etc. inside the embedded
|
|
31
|
-
// CSS/JS are not interpreted as substitution patterns.
|
|
32
|
-
|
|
33
|
-
// every *.html import as HTMLBundle (TS can't vary types by import attribute).
|
|
34
|
-
cachedTemplate = (templateHtml as unknown as string)
|
|
40
|
+
// CSS/JS are not interpreted as substitution patterns.
|
|
41
|
+
cachedTemplate = templateHtml
|
|
35
42
|
.replace("<template-css/>", () => `<style>${minifiedCss}</style>`)
|
|
36
43
|
.replace("<template-tool-views/>", () => `<script>${toolViewsJs}</script>`)
|
|
37
44
|
.replace("<template-js/>", () => `<script>${templateJs}</script>`);
|
|
@@ -207,7 +214,7 @@ async function collectSubSessionsFromDir(
|
|
|
207
214
|
): Promise<void> {
|
|
208
215
|
let names: string[];
|
|
209
216
|
try {
|
|
210
|
-
names = await fs.readdir(dir);
|
|
217
|
+
names = await fs.promises.readdir(dir);
|
|
211
218
|
} catch (err) {
|
|
212
219
|
if (isEnoent(err)) return;
|
|
213
220
|
throw err;
|