@oh-my-pi/pi-coding-agent 17.3.0 → 17.3.2
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 +20 -0
- package/dist/{CHANGELOG-66nakf5b.md → CHANGELOG-fr2awajz.md} +20 -0
- package/dist/cli.js +2823 -2823
- package/dist/docs-index.generated.txt +1 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/extension-flags.d.ts +3 -3
- package/dist/types/cli/flag-tables.d.ts +0 -1
- package/dist/types/cli/setup-cli.d.ts +10 -0
- package/dist/types/cli/update-cli.d.ts +48 -9
- package/dist/types/commands/completions.d.ts +3 -0
- package/dist/types/config/claude-paths.d.ts +7 -0
- package/dist/types/discovery/agents.d.ts +6 -6
- package/dist/types/discovery/helpers.d.ts +3 -4
- package/dist/types/extensibility/extensions/runner.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +4 -0
- package/dist/types/launch/broker.d.ts +5 -1
- package/dist/types/main.d.ts +1 -1
- package/dist/types/mcp/transports/stdio.d.ts +6 -3
- package/dist/types/modes/components/footer.d.ts +3 -2
- package/dist/types/modes/interactive-mode.d.ts +2 -1
- package/dist/types/modes/rpc/rpc-client.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
- package/dist/types/modes/runtime-init.d.ts +3 -1
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
- package/dist/types/task/executor.d.ts +2 -0
- package/dist/types/utils/git.d.ts +19 -0
- package/dist/types/utils/shell-snapshot.d.ts +4 -1
- package/package.json +13 -13
- package/src/async/job-manager.ts +33 -4
- package/src/cli/args.ts +14 -3
- package/src/cli/extension-flags.ts +6 -10
- package/src/cli/flag-tables.ts +2 -10
- package/src/cli/gc-cli.ts +13 -3
- package/src/cli/setup-cli.ts +2 -2
- package/src/cli/update-cli.ts +246 -107
- package/src/commands/completions.ts +16 -14
- package/src/config/claude-paths.ts +18 -0
- package/src/config/model-registry.ts +2 -2
- package/src/config.ts +4 -3
- package/src/discovery/agents.ts +7 -7
- package/src/discovery/claude.ts +5 -6
- package/src/discovery/helpers.ts +12 -11
- package/src/extensibility/extensions/runner.ts +5 -0
- package/src/extensibility/extensions/types.ts +5 -0
- package/src/extensibility/legacy-typebox.ts +45 -4
- package/src/launch/broker.ts +26 -4
- package/src/lsp/mux/server.ts +7 -1
- package/src/main.ts +30 -3
- package/src/mcp/transports/stdio.ts +7 -3
- package/src/modes/acp/acp-agent.ts +1 -0
- package/src/modes/components/footer.ts +17 -35
- package/src/modes/components/status-line/component.ts +14 -27
- package/src/modes/controllers/extension-ui-controller.ts +2 -2
- package/src/modes/interactive-mode.ts +16 -9
- package/src/modes/print-mode.ts +1 -0
- package/src/modes/rpc/rpc-client.ts +4 -2
- package/src/modes/rpc/rpc-input.ts +27 -0
- package/src/modes/rpc/rpc-mode.ts +11 -19
- package/src/modes/runtime-init.ts +5 -1
- package/src/modes/utils/ui-helpers.ts +74 -53
- package/src/session/agent-session.ts +24 -9
- package/src/session/claude-session-store.ts +4 -3
- package/src/task/executor.ts +8 -4
- package/src/tools/browser/launch.ts +9 -0
- package/src/tools/read-format.ts +11 -10
- package/src/tools/run-scope.ts +4 -2
- package/src/utils/external-editor.ts +10 -11
- package/src/utils/git.ts +27 -0
- package/src/utils/shell-snapshot.ts +5 -1
- package/src/web/search/providers/gemini.ts +4 -9
package/src/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { CONFIG_DIR_NAME, getConfigAgentDirName, getProjectDir } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { resolveClaudePaths } from "./config/claude-paths";
|
|
5
6
|
import { expandTilde } from "./tools/path-utils";
|
|
6
7
|
|
|
7
8
|
export * from "./config/config-file";
|
|
@@ -76,12 +77,12 @@ export function getChangelogPath(): string | undefined {
|
|
|
76
77
|
// =============================================================================
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
|
-
*
|
|
80
|
-
* User-level: ~/.omp/agent, ~/.claude, ~/.codex, ~/.gemini
|
|
80
|
+
* User-level: ~/.omp/agent, Claude's active config directory, ~/.codex, ~/.gemini
|
|
81
81
|
* Project-level: .omp, .claude, .codex, .gemini
|
|
82
82
|
*/
|
|
83
83
|
const USER_CONFIG_BASES = priorityList.map(({ dir, globalAgentDir }) => ({
|
|
84
|
-
base: () =>
|
|
84
|
+
base: () =>
|
|
85
|
+
dir === ".claude" ? resolveClaudePaths().configDir : path.join(os.homedir(), globalAgentDir?.() ?? dir),
|
|
85
86
|
name: dir,
|
|
86
87
|
}));
|
|
87
88
|
|
package/src/discovery/agents.ts
CHANGED
|
@@ -67,18 +67,18 @@ const HOST_PROBE_TIMEOUT_MS = 500;
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* Run a best-effort discovery probe and return its trimmed stdout, or
|
|
70
|
-
* `undefined` when the command fails, produces no output, or exceeds
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
70
|
+
* `undefined` when the command fails, produces no output, or exceeds the
|
|
71
|
+
* timeout. On timeout the child is killed with SIGKILL so a wedged interop pipe
|
|
72
|
+
* cannot hang startup; the killed/non-zero exit is then reported as
|
|
73
|
+
* "unavailable" and discovery falls back to the Linux `$HOME`/`~/.omp`
|
|
74
|
+
* candidates.
|
|
75
75
|
*/
|
|
76
|
-
export function runHostProbe(cmd: string[]): string | undefined {
|
|
76
|
+
export function runHostProbe(cmd: string[], timeoutMs = HOST_PROBE_TIMEOUT_MS): string | undefined {
|
|
77
77
|
try {
|
|
78
78
|
const result = Bun.spawnSync(cmd, {
|
|
79
79
|
stdout: "pipe",
|
|
80
80
|
stderr: "ignore",
|
|
81
|
-
timeout:
|
|
81
|
+
timeout: timeoutMs,
|
|
82
82
|
killSignal: "SIGKILL",
|
|
83
83
|
});
|
|
84
84
|
if (result.exitCode !== 0) return undefined;
|
package/src/discovery/claude.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { type SlashCommand, slashCommandCapability } from "../capability/slash-c
|
|
|
18
18
|
import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt";
|
|
19
19
|
import { type CustomTool, toolCapability } from "../capability/tool";
|
|
20
20
|
import type { LoadContext, LoadResult } from "../capability/types";
|
|
21
|
+
import { resolveClaudePaths } from "../config/claude-paths";
|
|
21
22
|
import { settings } from "../config/settings";
|
|
22
23
|
import {
|
|
23
24
|
calculateDepth,
|
|
@@ -34,11 +35,10 @@ const DISPLAY_NAME = "Claude Code";
|
|
|
34
35
|
const PRIORITY = 80;
|
|
35
36
|
const CONFIG_DIR = ".claude";
|
|
36
37
|
|
|
37
|
-
/**
|
|
38
|
-
* Get user-level .claude path.
|
|
39
|
-
*/
|
|
38
|
+
/** Get the active user-level Claude Code directory. */
|
|
40
39
|
function getUserClaude(ctx: LoadContext): string {
|
|
41
|
-
|
|
40
|
+
const { configDir } = resolveClaudePaths(ctx.home);
|
|
41
|
+
return configDir;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
@@ -60,8 +60,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
60
60
|
const items: MCPServer[] = [];
|
|
61
61
|
const warnings: string[] = [];
|
|
62
62
|
|
|
63
|
-
const userBase =
|
|
64
|
-
const userClaudeJson = path.join(ctx.home, ".claude.json");
|
|
63
|
+
const { configDir: userBase, configFile: userClaudeJson } = resolveClaudePaths(ctx.home);
|
|
65
64
|
const userMcpJson = path.join(userBase, "mcp.json");
|
|
66
65
|
|
|
67
66
|
const projectBase = path.join(ctx.cwd, CONFIG_DIR);
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { invalidate as invalidateFsCache, readDirEntries, readFile } from "../ca
|
|
|
16
16
|
import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
|
|
17
17
|
import type { Skill, SkillFrontmatter } from "../capability/skill";
|
|
18
18
|
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
|
|
19
|
+
import { resolveClaudePaths } from "../config/claude-paths";
|
|
19
20
|
import type { MCPRequestIdFormat } from "../mcp/types";
|
|
20
21
|
import { type ConfiguredThinkingLevel, parseConfiguredThinkingLevel } from "../thinking";
|
|
21
22
|
import { normalizeToolNames } from "../tools/builtin-names";
|
|
@@ -90,10 +91,9 @@ export type SourceId = keyof typeof SOURCE_PATHS;
|
|
|
90
91
|
*/
|
|
91
92
|
export function getUserPath(ctx: LoadContext, source: SourceId, subpath: string): string | null {
|
|
92
93
|
// Native user config is profile-scoped via getAgentDir() (the active profile's
|
|
93
|
-
// agent dir), matching builtin.ts and getMCPConfigPath("user").
|
|
94
|
-
// (~/.claude, ~/.gemini, …) are intentionally not profile-scoped, so they keep
|
|
95
|
-
// resolving against ctx.home below.
|
|
94
|
+
// agent dir), matching builtin.ts and getMCPConfigPath("user").
|
|
96
95
|
if (source === "native") return path.join(getAgentDir(), subpath);
|
|
96
|
+
if (source === "claude") return path.join(resolveClaudePaths(ctx.home).configDir, subpath);
|
|
97
97
|
const paths = SOURCE_PATHS[source];
|
|
98
98
|
if (!paths.userAgent) return null;
|
|
99
99
|
return path.join(ctx.home, paths.userAgent, subpath);
|
|
@@ -903,20 +903,21 @@ export function registerPluginCacheInvalidator(invalidator: () => void): void {
|
|
|
903
903
|
}
|
|
904
904
|
|
|
905
905
|
/**
|
|
906
|
-
* List all installed Claude Code plugin roots from
|
|
907
|
-
*
|
|
908
|
-
* and optionally the nearest project-scoped registry resolved from `cwd`.
|
|
906
|
+
* List all installed Claude Code plugin roots from its active plugin cache and
|
|
907
|
+
* ~/.omp/plugins/installed_plugins.json, plus the nearest project registry when present.
|
|
909
908
|
*
|
|
910
|
-
* Results are cached per
|
|
909
|
+
* Results are cached per Claude and OMP config directories, project registry, and canonical active project.
|
|
911
910
|
*/
|
|
912
911
|
export async function listClaudePluginRoots(
|
|
913
912
|
home: string,
|
|
914
913
|
cwd?: string,
|
|
915
914
|
): Promise<{ roots: ClaudePluginRoot[]; warnings: string[] }> {
|
|
915
|
+
const claudeConfigDir = resolveClaudePaths(home).configDir;
|
|
916
|
+
const ompRegistryPath = path.join(getPluginsDir(home), "installed_plugins.json");
|
|
916
917
|
const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null;
|
|
917
918
|
const projectRoot = resolvedProjectPath ? path.dirname(path.dirname(path.dirname(resolvedProjectPath))) : cwd;
|
|
918
919
|
const activeClaudeProjectPath = projectRoot ? await canonicalClaudeProjectPath(projectRoot) : null;
|
|
919
|
-
const cacheKey = `${
|
|
920
|
+
const cacheKey = `${claudeConfigDir}:${ompRegistryPath}:${resolvedProjectPath ?? ""}:${activeClaudeProjectPath ?? ""}`;
|
|
920
921
|
const cached = pluginRootsCache.get(cacheKey);
|
|
921
922
|
if (cached) return cached;
|
|
922
923
|
|
|
@@ -926,7 +927,7 @@ export async function listClaudePluginRoots(
|
|
|
926
927
|
const canonicalClaudeProjectPaths = new Map<string, string | null>();
|
|
927
928
|
|
|
928
929
|
// ── Claude Code registry ──────────────────────────────────────────────────
|
|
929
|
-
const registryPath = path.join(
|
|
930
|
+
const registryPath = path.join(claudeConfigDir, "plugins", "installed_plugins.json");
|
|
930
931
|
const content = await readFile(registryPath);
|
|
931
932
|
|
|
932
933
|
if (content) {
|
|
@@ -983,7 +984,7 @@ export async function listClaudePluginRoots(
|
|
|
983
984
|
// In production `home` is `os.homedir()`, so `getPluginsDir(home)` resolves to the
|
|
984
985
|
// same XDG-aware path the marketplace writer uses (reads and writes always agree).
|
|
985
986
|
// Tests pass a temp dir, which short-circuits the resolver for deterministic isolation.
|
|
986
|
-
|
|
987
|
+
// Computed before the cache lookup because isolated SDK homes select distinct OMP registries.
|
|
987
988
|
const ompContent = await readFile(ompRegistryPath);
|
|
988
989
|
if (ompContent) {
|
|
989
990
|
const ompRegistry = parseClaudePluginsRegistry(ompContent);
|
|
@@ -1107,7 +1108,7 @@ export function clearClaudePluginRootsCache(): void {
|
|
|
1107
1108
|
* installing/uninstalling/enabling/disabling plugins.
|
|
1108
1109
|
*/
|
|
1109
1110
|
export function clearPluginRootsAndCaches(extraPaths?: readonly string[]): void {
|
|
1110
|
-
invalidateFsCache(path.join(
|
|
1111
|
+
invalidateFsCache(path.join(resolveClaudePaths().configDir, "plugins", "installed_plugins.json"));
|
|
1111
1112
|
invalidateFsCache(path.join(getPluginsDir(), "installed_plugins.json"));
|
|
1112
1113
|
for (const p of extraPaths ?? []) invalidateFsCache(p);
|
|
1113
1114
|
clearClaudePluginRootsCache();
|
|
@@ -42,6 +42,7 @@ import type {
|
|
|
42
42
|
ExtensionError,
|
|
43
43
|
ExtensionEvent,
|
|
44
44
|
ExtensionFlag,
|
|
45
|
+
ExtensionMode,
|
|
45
46
|
ExtensionRuntime,
|
|
46
47
|
ExtensionShortcut,
|
|
47
48
|
ExtensionUIContext,
|
|
@@ -342,6 +343,7 @@ interface ToolRegistrationScope {
|
|
|
342
343
|
|
|
343
344
|
export class ExtensionRunner {
|
|
344
345
|
#uiContext: ExtensionUIContext;
|
|
346
|
+
#mode: ExtensionMode = "print";
|
|
345
347
|
#toolApprovalPreviewWaiter?: (toolCallId: string) => Promise<void>;
|
|
346
348
|
#errorListeners: Set<ExtensionErrorListener> = new Set();
|
|
347
349
|
#getModel: () => Model | undefined = () => undefined;
|
|
@@ -525,6 +527,7 @@ export class ExtensionRunner {
|
|
|
525
527
|
contextActions: ExtensionContextActions,
|
|
526
528
|
commandContextActions?: ExtensionCommandContextActions,
|
|
527
529
|
uiContext?: ExtensionUIContext,
|
|
530
|
+
mode: ExtensionMode = "print",
|
|
528
531
|
): void {
|
|
529
532
|
// Copy actions into the shared runtime (all extension APIs reference this)
|
|
530
533
|
this.runtime.sendMessage = actions.sendMessage;
|
|
@@ -573,6 +576,7 @@ export class ExtensionRunner {
|
|
|
573
576
|
}
|
|
574
577
|
|
|
575
578
|
this.#uiContext = uiContext ?? noOpUIContext;
|
|
579
|
+
this.#mode = mode;
|
|
576
580
|
this.#initialized = true;
|
|
577
581
|
|
|
578
582
|
// Drain events buffered by emitCredentialDisabled() before initialize ran. The
|
|
@@ -953,6 +957,7 @@ export class ExtensionRunner {
|
|
|
953
957
|
const getModel = model ? () => model : this.#getModel;
|
|
954
958
|
return {
|
|
955
959
|
ui: this.#uiContext,
|
|
960
|
+
mode: this.#mode,
|
|
956
961
|
getContextUsage: () => this.#getContextUsageFn(),
|
|
957
962
|
compact: instructionsOrOptions => this.#compactFn(instructionsOrOptions),
|
|
958
963
|
getAsyncJobSnapshot: () => this.#getAsyncJobSnapshotFn(),
|
|
@@ -434,9 +434,14 @@ export interface ExtensionModelQuery {
|
|
|
434
434
|
family(model: Model): string;
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
/** Runtime host mode exposed to Pi-compatible extensions. */
|
|
438
|
+
export type ExtensionMode = "tui" | "rpc" | "json" | "print";
|
|
439
|
+
|
|
437
440
|
export interface ExtensionContext {
|
|
438
441
|
/** UI methods for user interaction */
|
|
439
442
|
ui: ExtensionUIContext;
|
|
443
|
+
/** Current run mode. Use `"tui"` to guard terminal-only UI such as custom components. */
|
|
444
|
+
mode: ExtensionMode;
|
|
440
445
|
/** Get current context usage for the active model. */
|
|
441
446
|
getContextUsage(): ContextUsage | undefined;
|
|
442
447
|
/** Get a read-only snapshot of async jobs owned by this session. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type } from "@oh-my-pi/omptype";
|
|
2
|
+
import { IR_BRAND } from "@oh-my-pi/omptype/ir";
|
|
2
3
|
import {
|
|
3
4
|
type AnySchema,
|
|
4
5
|
type ObjectOpts,
|
|
@@ -40,6 +41,44 @@ function isRuntimeSchema(value: unknown): value is AnySchema {
|
|
|
40
41
|
return typeof value === "function";
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Deep-copy a legacy `Type.Unsafe` document into a plain, structured-cloneable
|
|
46
|
+
* JSON Schema, lowering any embedded omptype schema to its wire JSON. Legacy
|
|
47
|
+
* Pi extensions were written against real TypeBox, whose `Type.*` builders
|
|
48
|
+
* return plain JSON-Schema objects; omptype's builders return callable schema
|
|
49
|
+
* values instead, which breaks two idioms extensions use inside raw documents:
|
|
50
|
+
*
|
|
51
|
+
* - Direct embedding — `Type.Unsafe({ anyOf: [Type.Array(...), Other] })`.
|
|
52
|
+
* The nested schema is a function; `structuredClone` throws
|
|
53
|
+
* `DataCloneError: The object can not be cloned.` (issue #8420) and omptype
|
|
54
|
+
* would drop its `toJsonSchema()` override during composition anyway.
|
|
55
|
+
* - Spreading — `Type.Unsafe({ ...Schema, description })`. Spreading a
|
|
56
|
+
* callable copies omptype's internal fields (`ir`, `run`, `$`, …) instead
|
|
57
|
+
* of JSON keywords. The copied `run` is a self-reference to the original
|
|
58
|
+
* schema, so its `toJsonSchema()` recovers the real wire document; the
|
|
59
|
+
* caller's own additions (everything not an omptype internal) are overlaid.
|
|
60
|
+
*/
|
|
61
|
+
function lowerEmbeddedSchemas(value: unknown): unknown {
|
|
62
|
+
if (isRuntimeSchema(value)) return value.toJsonSchema();
|
|
63
|
+
if (Array.isArray(value)) return value.map(lowerEmbeddedSchemas);
|
|
64
|
+
if (value !== null && typeof value === "object") {
|
|
65
|
+
const source = value as Record<string, unknown>;
|
|
66
|
+
const canonical = source.run;
|
|
67
|
+
if (IR_BRAND in value && isRuntimeSchema(canonical)) {
|
|
68
|
+
const base = canonical.toJsonSchema();
|
|
69
|
+
const internalKeys = new Set(Object.keys(canonical));
|
|
70
|
+
for (const key in source) {
|
|
71
|
+
if (!internalKeys.has(key)) base[key] = lowerEmbeddedSchemas(source[key]);
|
|
72
|
+
}
|
|
73
|
+
return base;
|
|
74
|
+
}
|
|
75
|
+
const result: Record<string, unknown> = {};
|
|
76
|
+
for (const key in source) result[key] = lowerEmbeddedSchemas(source[key]);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
43
82
|
function defineHidden(target: object, key: PropertyKey, value: unknown): void {
|
|
44
83
|
Object.defineProperty(target, key, {
|
|
45
84
|
value,
|
|
@@ -50,12 +89,14 @@ function defineHidden(target: object, key: PropertyKey, value: unknown): void {
|
|
|
50
89
|
|
|
51
90
|
function unsafe<T = unknown>(jsonSchema: Record<string, unknown> = {}): LegacyUnsafeSchema<T> {
|
|
52
91
|
// `document` is the verbatim wire schema; keep it isolated from the validator.
|
|
92
|
+
// `lowerEmbeddedSchemas` returns a fresh plain-JSON copy (lowering any nested
|
|
93
|
+
// omptype builder to its wire form), so it doubles as the detaching clone.
|
|
53
94
|
// `upgradeJsonSchemaTo202012` returns its input untouched when no upgrade is
|
|
54
95
|
// needed, and `validateJsonSchemaValue` then annotates that object with JIT
|
|
55
96
|
// epoch metadata and normalized keywords — which would leak into emission if
|
|
56
|
-
// the two shared a reference.
|
|
57
|
-
const document =
|
|
58
|
-
const upgradedSchema = upgradeJsonSchemaTo202012(structuredClone(
|
|
97
|
+
// the two shared a reference, so give the validator its own structured clone.
|
|
98
|
+
const document = lowerEmbeddedSchemas(jsonSchema) as Record<string, unknown>;
|
|
99
|
+
const upgradedSchema = upgradeJsonSchemaTo202012(structuredClone(document));
|
|
59
100
|
const validate = (data: unknown): T | ValidationFailure => {
|
|
60
101
|
const result = validateJsonSchemaValue(upgradedSchema, data);
|
|
61
102
|
if (result.success) return data as T;
|
|
@@ -125,7 +166,7 @@ const object = ((properties: Record<string, unknown>, opts?: ObjectOpts) => {
|
|
|
125
166
|
const document = OmpType.Object(normalizedProperties, objectOpts).toJsonSchema();
|
|
126
167
|
document.additionalProperties = isRuntimeSchema(additionalProperties)
|
|
127
168
|
? additionalProperties.toJsonSchema()
|
|
128
|
-
:
|
|
169
|
+
: lowerEmbeddedSchemas(additionalProperties);
|
|
129
170
|
return unsafe(document);
|
|
130
171
|
}
|
|
131
172
|
return OmpType.Object(normalizedProperties, normalizedOpts);
|
package/src/launch/broker.ts
CHANGED
|
@@ -37,6 +37,7 @@ const MAX_LOG_BYTES = 25 * 1024 * 1024;
|
|
|
37
37
|
const LOG_READ_BYTES = 2 * 1024 * 1024;
|
|
38
38
|
const READINESS_BUFFER_CHARS = 64 * 1024;
|
|
39
39
|
const RESTART_MAX_DELAY_MS = 30_000;
|
|
40
|
+
const RESTART_BACKOFF_BASE_MS = 1_000;
|
|
40
41
|
/**
|
|
41
42
|
* Cap on terminal (exited/failed) daemons surfaced by `list`. Active daemons
|
|
42
43
|
* are always shown in full; older history is truncated so the response stays
|
|
@@ -351,6 +352,7 @@ class DaemonBroker {
|
|
|
351
352
|
readonly #endpoint: string;
|
|
352
353
|
readonly #token: string;
|
|
353
354
|
readonly #idleGraceMs: number;
|
|
355
|
+
readonly #restartBackoffBaseMs: number;
|
|
354
356
|
readonly #records = new Map<string, ManagedDaemon>();
|
|
355
357
|
/**
|
|
356
358
|
* Names reserved by an in-flight `start` before its record lands in
|
|
@@ -371,12 +373,19 @@ class DaemonBroker {
|
|
|
371
373
|
#idleTimer: NodeJS.Timeout | undefined;
|
|
372
374
|
#shuttingDown = false;
|
|
373
375
|
|
|
374
|
-
constructor(
|
|
376
|
+
constructor(
|
|
377
|
+
projectDir: string,
|
|
378
|
+
runtimeDir: string,
|
|
379
|
+
token: string,
|
|
380
|
+
idleGraceMs: number,
|
|
381
|
+
restartBackoffBaseMs: number,
|
|
382
|
+
) {
|
|
375
383
|
this.#projectDir = projectDir;
|
|
376
384
|
this.#runtimeDir = runtimeDir;
|
|
377
385
|
this.#endpoint = daemonBrokerEndpoint(projectDir, runtimeDir);
|
|
378
386
|
this.#token = token;
|
|
379
387
|
this.#idleGraceMs = idleGraceMs;
|
|
388
|
+
this.#restartBackoffBaseMs = restartBackoffBaseMs;
|
|
380
389
|
}
|
|
381
390
|
|
|
382
391
|
async run(): Promise<void> {
|
|
@@ -955,7 +964,10 @@ class DaemonBroker {
|
|
|
955
964
|
record.snapshot.readyAt = undefined;
|
|
956
965
|
record.snapshot.readyMatch = undefined;
|
|
957
966
|
record.snapshot.state = "restarting";
|
|
958
|
-
const delay = Math.min(
|
|
967
|
+
const delay = Math.min(
|
|
968
|
+
this.#restartBackoffBaseMs * 2 ** Math.min(record.consecutiveFailures, 5),
|
|
969
|
+
RESTART_MAX_DELAY_MS,
|
|
970
|
+
);
|
|
959
971
|
record.log?.append(
|
|
960
972
|
`\n[daemon exited${exitCode === undefined ? "" : ` with code ${exitCode}`}; restarting in ${delay}ms]\n`,
|
|
961
973
|
);
|
|
@@ -1347,8 +1359,13 @@ class DaemonBroker {
|
|
|
1347
1359
|
}
|
|
1348
1360
|
}
|
|
1349
1361
|
|
|
1362
|
+
export interface DaemonBrokerStartOptions {
|
|
1363
|
+
/** Base of the exponential child-restart backoff. */
|
|
1364
|
+
restartBackoffBaseMs?: number;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1350
1367
|
/** Start the detached project or global daemon broker selected by the CLI worker host. */
|
|
1351
|
-
export async function startDaemonBrokerFromEnvironment(): Promise<void> {
|
|
1368
|
+
export async function startDaemonBrokerFromEnvironment(options: DaemonBrokerStartOptions = {}): Promise<void> {
|
|
1352
1369
|
const projectDir = process.env[DAEMON_PROJECT_DIR_ENV];
|
|
1353
1370
|
const runtimeDir = process.env[DAEMON_RUNTIME_DIR_ENV];
|
|
1354
1371
|
if (!projectDir || !runtimeDir) throw new Error("Daemon broker environment is incomplete");
|
|
@@ -1358,13 +1375,18 @@ export async function startDaemonBrokerFromEnvironment(): Promise<void> {
|
|
|
1358
1375
|
delete process.env[DAEMON_IDLE_GRACE_ENV];
|
|
1359
1376
|
const parsedGrace = rawGrace === undefined ? DEFAULT_IDLE_GRACE_MS : Number.parseInt(rawGrace, 10);
|
|
1360
1377
|
const idleGraceMs = Number.isFinite(parsedGrace) && parsedGrace >= 0 ? parsedGrace : DEFAULT_IDLE_GRACE_MS;
|
|
1378
|
+
const requestedRestartBackoffBaseMs = options.restartBackoffBaseMs ?? RESTART_BACKOFF_BASE_MS;
|
|
1379
|
+
const restartBackoffBaseMs =
|
|
1380
|
+
Number.isFinite(requestedRestartBackoffBaseMs) && requestedRestartBackoffBaseMs >= 0
|
|
1381
|
+
? requestedRestartBackoffBaseMs
|
|
1382
|
+
: RESTART_BACKOFF_BASE_MS;
|
|
1361
1383
|
await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
|
|
1362
1384
|
const lease = await acquireBrokerLease(runtimeDir);
|
|
1363
1385
|
if (!lease) return;
|
|
1364
1386
|
setProcessName("omp daemon broker");
|
|
1365
1387
|
const token = (await Bun.file(path.join(runtimeDir, TOKEN_FILE)).text()).trim();
|
|
1366
1388
|
if (!token) throw new Error("Daemon broker token is empty");
|
|
1367
|
-
const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs);
|
|
1389
|
+
const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs, restartBackoffBaseMs);
|
|
1368
1390
|
const cancelCleanup = postmortem.register("daemon-broker", () => broker.shutdown());
|
|
1369
1391
|
try {
|
|
1370
1392
|
await broker.run();
|
package/src/lsp/mux/server.ts
CHANGED
|
@@ -687,7 +687,13 @@ export class LspMuxServer {
|
|
|
687
687
|
server.pending.set(id, { resolveInternal: resolve });
|
|
688
688
|
try {
|
|
689
689
|
await this.#writeServer(server, { jsonrpc: "2.0", id, method: "shutdown", params: null });
|
|
690
|
-
|
|
690
|
+
const timeout = Promise.withResolvers<void>();
|
|
691
|
+
const timer = setTimeout(timeout.resolve, SHUTDOWN_BUDGET_MS);
|
|
692
|
+
try {
|
|
693
|
+
await Promise.race([promise, timeout.promise]);
|
|
694
|
+
} finally {
|
|
695
|
+
clearTimeout(timer);
|
|
696
|
+
}
|
|
691
697
|
await this.#writeServer(server, { jsonrpc: "2.0", method: "exit" });
|
|
692
698
|
} catch (error) {
|
|
693
699
|
logger.warn("LSP mux graceful server shutdown failed", { server: server.key, error: String(error) });
|
package/src/main.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
} from "@oh-my-pi/pi-utils";
|
|
24
24
|
import chalk from "@oh-my-pi/pi-utils/chalk";
|
|
25
25
|
import { reset as resetCapabilities } from "./capability";
|
|
26
|
-
import { type Args, reportUnrecognizedFlags } from "./cli/args";
|
|
26
|
+
import { type Args, reportUnrecognizedFlags, validateToolNames } from "./cli/args";
|
|
27
27
|
import { applyExtensionFlags, type ExtensionFlagSink } from "./cli/extension-flags";
|
|
28
28
|
import { processFileArguments } from "./cli/file-processor";
|
|
29
29
|
import { buildInitialMessage } from "./cli/initial-message";
|
|
@@ -349,7 +349,7 @@ export interface AcpSessionFactoryOptions {
|
|
|
349
349
|
sessionDir?: string;
|
|
350
350
|
authStorage: AuthStorage;
|
|
351
351
|
modelRegistry: ModelRegistry;
|
|
352
|
-
parsedArgs: Pick<Args, "apiKey" | "trustedExtensions">;
|
|
352
|
+
parsedArgs: Pick<Args, "apiKey" | "trustedExtensions" | "tools">;
|
|
353
353
|
rawArgs: string[];
|
|
354
354
|
createSession: (options: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
|
|
355
355
|
}
|
|
@@ -425,7 +425,27 @@ export function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSess
|
|
|
425
425
|
if (args.parsedArgs.apiKey && !args.baseOptions.model && nextSession.model) {
|
|
426
426
|
args.authStorage.setRuntimeApiKey(nextSession.model.provider, args.parsedArgs.apiKey);
|
|
427
427
|
}
|
|
428
|
-
|
|
428
|
+
const runner = nextSession.extensionRunner;
|
|
429
|
+
const reparsedArgs = applyExtensionFlags(
|
|
430
|
+
runner
|
|
431
|
+
? {
|
|
432
|
+
getFlags: () => runner.getFlags(),
|
|
433
|
+
setFlagValue: (name, value) => {
|
|
434
|
+
runner.setFlagValue(name, value);
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
: undefined,
|
|
438
|
+
args.rawArgs,
|
|
439
|
+
);
|
|
440
|
+
const requestedTools = reparsedArgs?.tools ?? args.parsedArgs.tools;
|
|
441
|
+
if (requestedTools) {
|
|
442
|
+
try {
|
|
443
|
+
validateToolNames(requestedTools, nextSession.getAllToolNames());
|
|
444
|
+
} catch (error) {
|
|
445
|
+
await nextSession.dispose();
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
429
449
|
return nextSession;
|
|
430
450
|
};
|
|
431
451
|
}
|
|
@@ -1685,6 +1705,13 @@ export async function runRootCommand(
|
|
|
1685
1705
|
preloadedExtensions: extensionsResult,
|
|
1686
1706
|
});
|
|
1687
1707
|
|
|
1708
|
+
try {
|
|
1709
|
+
validateToolNames(initialArgs.tools, session.getAllToolNames());
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
await session.dispose();
|
|
1712
|
+
throw error;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1688
1715
|
// Cold-revive support: a `parked` subagent ref restored from disk (Agent Hub
|
|
1689
1716
|
// scan, collab mirror, resumed process) has a sessionFile but no in-memory
|
|
1690
1717
|
// reviver, so `ensureLive` (IRC sends, hub focus) would refuse it. Install a
|
|
@@ -495,7 +495,7 @@ function signalStdioProcess(
|
|
|
495
495
|
|
|
496
496
|
/**
|
|
497
497
|
* Terminate an MCP stdio subprocess: SIGTERM (process-group when `detached`
|
|
498
|
-
* on POSIX, direct child otherwise), wait up to `
|
|
498
|
+
* on POSIX, direct child otherwise), wait up to `termGraceMs` for a
|
|
499
499
|
* cooperative exit, then escalate to SIGKILL — waiting up to `KILL_GRACE_MS`
|
|
500
500
|
* more only when the leader itself hadn't already exited. A detached
|
|
501
501
|
* leader's cooperative exit does not prove the whole process group is gone
|
|
@@ -508,15 +508,19 @@ function signalStdioProcess(
|
|
|
508
508
|
* `detached`/`platform` pair: `StdioTransport.connect()` derives `detached`
|
|
509
509
|
* from `resolveStdioSpawnCommand()`, which is tied to the host's real
|
|
510
510
|
* `process.platform`, so a POSIX detached session cannot be reproduced
|
|
511
|
-
* end-to-end through `connect()` on a non-Linux dev/CI host.
|
|
511
|
+
* end-to-end through `connect()` on a non-Linux dev/CI host. `termGraceMs`
|
|
512
|
+
* preserves the production grace by default while allowing those real
|
|
513
|
+
* subprocess tests to cover the same transition without sleeping for a
|
|
514
|
+
* production-length shutdown window.
|
|
512
515
|
*/
|
|
513
516
|
export async function terminateStdioProcess(
|
|
514
517
|
proc: KillableSubprocess,
|
|
515
518
|
detached: boolean,
|
|
516
519
|
platform: NodeJS.Platform = process.platform,
|
|
520
|
+
termGraceMs = TERM_GRACE_MS,
|
|
517
521
|
): Promise<void> {
|
|
518
522
|
signalStdioProcess(proc, detached, "SIGTERM", platform);
|
|
519
|
-
const exitedOnTerm = await waitForProcessExit(proc.exited,
|
|
523
|
+
const exitedOnTerm = await waitForProcessExit(proc.exited, termGraceMs);
|
|
520
524
|
// A non-detached transport has no process group beyond the leader itself:
|
|
521
525
|
// once it exits, there is nothing left to signal. A detached transport's
|
|
522
526
|
// leader exiting is NOT proof the group is empty — a grandchild it spawned
|
|
@@ -2419,6 +2419,7 @@ export class AcpAgent implements Agent {
|
|
|
2419
2419
|
compact: instructionsOrOptions => runExtensionCompact(record.session, instructionsOrOptions),
|
|
2420
2420
|
},
|
|
2421
2421
|
uiContext,
|
|
2422
|
+
"rpc",
|
|
2422
2423
|
);
|
|
2423
2424
|
await extensionRunner.emit({ type: "session_start" });
|
|
2424
2425
|
record.extensionsConfigured = true;
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import * as path from "node:path";
|
|
3
1
|
import { stripVTControlCharacters } from "node:util";
|
|
4
2
|
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
5
3
|
import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
@@ -17,7 +15,7 @@ import { formatContextUsage, getContextUsageLevel, getContextUsageThemeColor } f
|
|
|
17
15
|
*/
|
|
18
16
|
export class FooterComponent implements Component {
|
|
19
17
|
#cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
|
|
20
|
-
#
|
|
18
|
+
#gitUnwatch: (() => void) | null = null;
|
|
21
19
|
#onBranchChange: (() => void) | null = null;
|
|
22
20
|
#autoCompactEnabled: boolean = true;
|
|
23
21
|
#extensionStatuses: Map<string, string> = new Map();
|
|
@@ -44,8 +42,9 @@ export class FooterComponent implements Component {
|
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
45
|
+
* Watch the repository HEAD for branch changes; invokes the callback so the
|
|
46
|
+
* footer repaints with the new branch. Uses `git.head.watch` (stat-poll) —
|
|
47
|
+
* see that helper for why `fs.watch` cannot track git's atomic HEAD swaps.
|
|
49
48
|
*/
|
|
50
49
|
watchBranch(onBranchChange: () => void): void {
|
|
51
50
|
this.#onBranchChange = onBranchChange;
|
|
@@ -53,46 +52,29 @@ export class FooterComponent implements Component {
|
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
#setupGitWatcher(): void {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.#gitWatcher.close();
|
|
59
|
-
this.#gitWatcher = null;
|
|
60
|
-
}
|
|
55
|
+
this.#gitUnwatch?.();
|
|
56
|
+
this.#gitUnwatch = null;
|
|
61
57
|
|
|
62
58
|
if (!settings.get("git.enabled")) return;
|
|
59
|
+
const repository = git.repo.resolveSync(getProjectDir());
|
|
60
|
+
if (!repository) return;
|
|
63
61
|
|
|
64
|
-
|
|
65
|
-
.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
const watchPath = head.isReftable ? path.join(head.gitDir, "reftable") : head.headPath;
|
|
73
|
-
this.#gitWatcher = fs.watch(watchPath, () => {
|
|
74
|
-
this.#cachedBranch = undefined; // Invalidate cache
|
|
75
|
-
if (this.#onBranchChange) {
|
|
76
|
-
this.#onBranchChange();
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
} catch {
|
|
80
|
-
// Silently fail if we can't watch
|
|
81
|
-
}
|
|
82
|
-
})
|
|
83
|
-
.catch(() => {
|
|
84
|
-
this.#cachedBranch = null;
|
|
62
|
+
try {
|
|
63
|
+
this.#gitUnwatch = git.head.watch(repository, () => {
|
|
64
|
+
this.#cachedBranch = undefined; // Invalidate cache
|
|
65
|
+
this.#onBranchChange?.();
|
|
85
66
|
});
|
|
67
|
+
} catch {
|
|
68
|
+
// Silently fail if we can't watch
|
|
69
|
+
}
|
|
86
70
|
}
|
|
87
71
|
|
|
88
72
|
/**
|
|
89
73
|
* Clean up the file watcher
|
|
90
74
|
*/
|
|
91
75
|
dispose(): void {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
this.#gitWatcher = null;
|
|
95
|
-
}
|
|
76
|
+
this.#gitUnwatch?.();
|
|
77
|
+
this.#gitUnwatch = null;
|
|
96
78
|
}
|
|
97
79
|
|
|
98
80
|
invalidate(): void {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
1
|
import * as path from "node:path";
|
|
3
2
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
4
3
|
import type { AssistantMessage, UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
|
|
@@ -320,8 +319,7 @@ export class StatusLineComponent implements Component {
|
|
|
320
319
|
// dropped rather than overwrite the value the newer resolve committed.
|
|
321
320
|
// Mirrors #jjCacheGeneration / #getJjBranch in this file.
|
|
322
321
|
#branchCacheGeneration = 0;
|
|
323
|
-
#
|
|
324
|
-
#gitWatcherErrorListener: (() => void) | undefined = undefined;
|
|
322
|
+
#gitUnwatch: (() => void) | null = null;
|
|
325
323
|
#gitWatcherUnavailable = false;
|
|
326
324
|
#onBranchChange: (() => void) | null = null;
|
|
327
325
|
#disposed = false;
|
|
@@ -665,40 +663,29 @@ export class StatusLineComponent implements Component {
|
|
|
665
663
|
return;
|
|
666
664
|
}
|
|
667
665
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
666
|
+
// git swaps HEAD via `HEAD.lock` + atomic rename. That both unlinks the
|
|
667
|
+
// HEAD inode (freezing a file-bound `fs.watch` after the first switch —
|
|
668
|
+
// issue #8412) and, on Bun/Linux, permanently wedges an inotify-backed
|
|
669
|
+
// directory watch after the first rename event (oven-sh/bun#24875).
|
|
670
|
+
// `git.head.watch` stat-polls the HEAD path (or the reftable dir), which
|
|
671
|
+
// survives inode swaps on every platform. A vanished repo surfaces as a
|
|
672
|
+
// stat change too, so there is no separate watcher error path.
|
|
672
673
|
try {
|
|
673
|
-
const
|
|
674
|
-
if (this.#disposed || this.#
|
|
674
|
+
const unwatch = git.head.watch(repository, () => {
|
|
675
|
+
if (this.#disposed || this.#gitUnwatch !== unwatch) return;
|
|
675
676
|
this.invalidateGitCaches();
|
|
676
677
|
this.#onBranchChange?.();
|
|
677
678
|
});
|
|
678
|
-
|
|
679
|
-
if (this.#gitWatcher !== watcher) return;
|
|
680
|
-
this.#retireGitWatcher();
|
|
681
|
-
this.#gitWatcherUnavailable = true;
|
|
682
|
-
if (this.#disposed) return;
|
|
683
|
-
this.invalidateGitCaches();
|
|
684
|
-
this.#onBranchChange?.();
|
|
685
|
-
};
|
|
686
|
-
this.#gitWatcher = watcher;
|
|
687
|
-
this.#gitWatcherErrorListener = onError;
|
|
688
|
-
watcher.on("error", onError);
|
|
679
|
+
this.#gitUnwatch = unwatch;
|
|
689
680
|
} catch {
|
|
690
681
|
this.#gitWatcherUnavailable = true;
|
|
691
682
|
}
|
|
692
683
|
}
|
|
693
684
|
|
|
694
685
|
#retireGitWatcher(): void {
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
this.#gitWatcherErrorListener = undefined;
|
|
699
|
-
if (!watcher) return;
|
|
700
|
-
if (onError) watcher.off("error", onError);
|
|
701
|
-
watcher.close();
|
|
686
|
+
const unwatch = this.#gitUnwatch;
|
|
687
|
+
this.#gitUnwatch = null;
|
|
688
|
+
unwatch?.();
|
|
702
689
|
}
|
|
703
690
|
|
|
704
691
|
dispose(): void {
|