@oh-my-pi/pi-coding-agent 17.3.0 → 17.3.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 +12 -0
- package/dist/{CHANGELOG-66nakf5b.md → CHANGELOG-2swgsx9f.md} +12 -0
- package/dist/cli.js +3437 -3437
- 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 +13 -8
- 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 +125 -82
- 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 +1 -0
- package/src/session/claude-session-store.ts +4 -3
- package/src/task/executor.ts +6 -3
- package/src/tools/browser/launch.ts +9 -0
- package/src/utils/git.ts +27 -0
- package/src/utils/shell-snapshot.ts +5 -1
|
@@ -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 {
|
|
@@ -283,7 +283,7 @@ export class ExtensionUiController {
|
|
|
283
283
|
},
|
|
284
284
|
};
|
|
285
285
|
|
|
286
|
-
extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
|
|
286
|
+
extensionRunner.initialize(actions, contextActions, commandActions, uiContext, "tui");
|
|
287
287
|
|
|
288
288
|
// Subscribe to extension errors
|
|
289
289
|
extensionRunner.onError((error: ExtensionError) => {
|
|
@@ -512,7 +512,7 @@ export class ExtensionUiController {
|
|
|
512
512
|
},
|
|
513
513
|
};
|
|
514
514
|
|
|
515
|
-
extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
|
|
515
|
+
extensionRunner.initialize(actions, contextActions, commandActions, uiContext, "tui");
|
|
516
516
|
}
|
|
517
517
|
|
|
518
518
|
/**
|
|
@@ -359,6 +359,20 @@ function readPersistedToolNames(value: unknown): string[] | undefined {
|
|
|
359
359
|
return value as string[];
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
export function shouldEnterPlanModeOnStartup(
|
|
363
|
+
sessionManager: Pick<SessionManager, "buildSessionContext" | "getEntries">,
|
|
364
|
+
sessionSettings: Pick<Settings, "get">,
|
|
365
|
+
): boolean {
|
|
366
|
+
const hasConversationContext = sessionManager.buildSessionContext().messages.length > 0;
|
|
367
|
+
const hasExplicitMode = sessionManager.getEntries().some(entry => entry.type === "mode_change");
|
|
368
|
+
return (
|
|
369
|
+
!hasConversationContext &&
|
|
370
|
+
!hasExplicitMode &&
|
|
371
|
+
sessionSettings.get("plan.defaultOnStartup") &&
|
|
372
|
+
sessionSettings.get("plan.enabled")
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
362
376
|
/** Options for creating an InteractiveMode instance (for future API use) */
|
|
363
377
|
export interface InteractiveModeOptions {
|
|
364
378
|
/** Providers that were migrated during startup */
|
|
@@ -1140,14 +1154,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1140
1154
|
// execution handoff clear never get dragged back into plan mode. #enterPlanMode
|
|
1141
1155
|
// is idempotent and self-guards against an already-active plan/goal mode; it
|
|
1142
1156
|
// does not check plan.enabled itself.
|
|
1143
|
-
|
|
1144
|
-
const hasExplicitMode = this.sessionManager.getEntries().some(entry => entry.type === "mode_change");
|
|
1145
|
-
const isFreshSession = !hasConversationContext && !hasExplicitMode;
|
|
1146
|
-
if (
|
|
1147
|
-
isFreshSession &&
|
|
1148
|
-
this.session.settings.get("plan.defaultOnStartup") &&
|
|
1149
|
-
this.session.settings.get("plan.enabled")
|
|
1150
|
-
) {
|
|
1157
|
+
if (shouldEnterPlanModeOnStartup(this.sessionManager, this.session.settings)) {
|
|
1151
1158
|
await this.#enterPlanMode();
|
|
1152
1159
|
}
|
|
1153
1160
|
|
|
@@ -4597,7 +4604,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
4597
4604
|
this.#uiHelpers.renderSessionContext(sessionContext, options);
|
|
4598
4605
|
}
|
|
4599
4606
|
|
|
4600
|
-
/**
|
|
4607
|
+
/** Build a session context in bounded chunks so terminal input runs between event-loop turns. */
|
|
4601
4608
|
async renderSessionContextIncrementally(
|
|
4602
4609
|
sessionContext: SessionContext,
|
|
4603
4610
|
options: RenderSessionContextOptions,
|
package/src/modes/print-mode.ts
CHANGED
|
@@ -118,6 +118,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
|
|
118
118
|
}
|
|
119
119
|
// Set up extensions for print mode (no UI, no command context)
|
|
120
120
|
await initializeExtensions(session, {
|
|
121
|
+
mode: mode === "json" ? "json" : "print",
|
|
121
122
|
reportSendError: (action, err) => {
|
|
122
123
|
process.stderr.write(
|
|
123
124
|
`Extension ${action === "extension_send" ? "sendMessage" : "sendUserMessage"} failed: ${err.message}\n`,
|
|
@@ -62,6 +62,8 @@ export interface RpcClientOptions {
|
|
|
62
62
|
sessionDir?: string;
|
|
63
63
|
/** Additional CLI arguments */
|
|
64
64
|
args?: string[];
|
|
65
|
+
/** Grace period before escalating process termination (default: process utility default, 1000ms) */
|
|
66
|
+
terminationGraceMs?: number;
|
|
65
67
|
/** Custom tools owned by the embedding host and exposed over the RPC transport */
|
|
66
68
|
customTools?: RpcClientCustomTool[];
|
|
67
69
|
}
|
|
@@ -324,7 +326,7 @@ export class RpcClient {
|
|
|
324
326
|
this.#pendingHostToolCalls.clear();
|
|
325
327
|
|
|
326
328
|
try {
|
|
327
|
-
child.kill();
|
|
329
|
+
child.kill(undefined, this.options.terminationGraceMs);
|
|
328
330
|
} catch {
|
|
329
331
|
// The process may already have exited.
|
|
330
332
|
}
|
|
@@ -440,7 +442,7 @@ export class RpcClient {
|
|
|
440
442
|
|
|
441
443
|
const error = new Error("Client stopped");
|
|
442
444
|
const child = this.#process;
|
|
443
|
-
child.kill();
|
|
445
|
+
child.kill(undefined, this.options.terminationGraceMs);
|
|
444
446
|
this.#abortController.abort(error);
|
|
445
447
|
this.#process = null;
|
|
446
448
|
for (const request of this.#pendingRequests.values()) request.reject(error);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readLines } from "@oh-my-pi/pi-utils";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Claims Bun's singleton stdin reader immediately and exposes a separately readable stream.
|
|
3
5
|
* RPC startup uses this before extension discovery so in-process modules cannot steal protocol input.
|
|
@@ -36,3 +38,28 @@ export function claimRpcInput(): ReadableStream<Uint8Array> {
|
|
|
36
38
|
},
|
|
37
39
|
});
|
|
38
40
|
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parses newline-delimited RPC input without letting one malformed line stop
|
|
44
|
+
* subsequent protocol frames.
|
|
45
|
+
*/
|
|
46
|
+
export async function readRpcInputFrames(
|
|
47
|
+
input: ReadableStream<Uint8Array>,
|
|
48
|
+
onFrame: (frame: unknown) => void,
|
|
49
|
+
onParseError: (message: string) => void,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const decoder = new TextDecoder();
|
|
52
|
+
for await (const line of readLines(input)) {
|
|
53
|
+
const text = decoder.decode(line).trim();
|
|
54
|
+
if (!text) continue;
|
|
55
|
+
let parsed: unknown;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(text);
|
|
58
|
+
} catch (error: unknown) {
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
onParseError(`Failed to parse command: ${message}`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
onFrame(parsed);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { once } from "node:events";
|
|
14
14
|
import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
|
|
15
15
|
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
|
16
|
-
import { $env, isRecord,
|
|
16
|
+
import { $env, isRecord, Snowflake } from "@oh-my-pi/pi-utils";
|
|
17
17
|
import { reset as resetCapabilities } from "../../capability";
|
|
18
18
|
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
|
|
19
19
|
import {
|
|
@@ -37,7 +37,7 @@ import { initializeExtensions } from "../runtime-init";
|
|
|
37
37
|
import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
|
|
38
38
|
import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris";
|
|
39
39
|
import { MAX_RPC_FRAME_BYTES, MAX_RPC_REASSEMBLED_BYTES, RpcFrameEncoder } from "./rpc-frame";
|
|
40
|
-
import { claimRpcInput } from "./rpc-input";
|
|
40
|
+
import { claimRpcInput, readRpcInputFrames } from "./rpc-input";
|
|
41
41
|
import { pageRpcMessages, RPC_MESSAGES_PAGE_BUSY_ERROR, RpcMessagesPageError } from "./rpc-messages";
|
|
42
42
|
import { RpcSubagentRegistry, readRpcSubagentTranscript } from "./rpc-subagents";
|
|
43
43
|
import type {
|
|
@@ -933,6 +933,7 @@ export async function runRpcMode(
|
|
|
933
933
|
|
|
934
934
|
// Set up extensions with RPC-based UI context
|
|
935
935
|
await initializeExtensions(session, {
|
|
936
|
+
mode: "rpc",
|
|
936
937
|
reportSendError: (action, err) => {
|
|
937
938
|
output(error(undefined, action, err.message));
|
|
938
939
|
},
|
|
@@ -1484,23 +1485,14 @@ export async function runRpcMode(
|
|
|
1484
1485
|
// Keep the stdin reader moving: side-channel frames dispatch immediately,
|
|
1485
1486
|
// ordinary commands serialize through inputDispatcher, and bash remains
|
|
1486
1487
|
// background-dispatched so abort_bash can overtake it. Frames are read
|
|
1487
|
-
// line-by-line
|
|
1488
|
-
//
|
|
1489
|
-
//
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
try {
|
|
1496
|
-
parsed = JSON.parse(text);
|
|
1497
|
-
} catch (e: unknown) {
|
|
1498
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
1499
|
-
output(error(undefined, "parse", `Failed to parse command: ${message}`));
|
|
1500
|
-
continue;
|
|
1501
|
-
}
|
|
1502
|
-
inputDispatcher.dispatch(parsed);
|
|
1503
|
-
}
|
|
1488
|
+
// line-by-line by readRpcInputFrames so a single malformed line is reported
|
|
1489
|
+
// as an error frame and the loop keeps running instead of throwing out of
|
|
1490
|
+
// the reader and killing the whole process (issue #5194).
|
|
1491
|
+
await readRpcInputFrames(
|
|
1492
|
+
input ?? Bun.stdin.stream(),
|
|
1493
|
+
parsed => inputDispatcher.dispatch(parsed),
|
|
1494
|
+
message => output(error(undefined, "parse", message)),
|
|
1495
|
+
);
|
|
1504
1496
|
|
|
1505
1497
|
// stdin closed — RPC client is gone. Fail pending side-channel requests
|
|
1506
1498
|
// first so active/queued commands can settle, then drain accepted work.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
|
|
10
10
|
import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler";
|
|
11
|
-
import type { ExtensionError, ExtensionUIContext } from "../extensibility/extensions/types";
|
|
11
|
+
import type { ExtensionError, ExtensionMode, ExtensionUIContext } from "../extensibility/extensions/types";
|
|
12
12
|
import type { AgentSession } from "../session/agent-session";
|
|
13
13
|
import { USER_INTERRUPT_LABEL } from "../session/messages";
|
|
14
14
|
|
|
@@ -22,6 +22,8 @@ export interface InitializeExtensionsOptions {
|
|
|
22
22
|
reportRuntimeError: (error: ExtensionError) => void;
|
|
23
23
|
/** Optional shutdown hook (rpc mode signals its loop; print mode is a no-op). */
|
|
24
24
|
onShutdown?: () => void;
|
|
25
|
+
/** Pi-compatible mode exposed to extension contexts. Defaults to `"print"`. */
|
|
26
|
+
mode?: ExtensionMode;
|
|
25
27
|
/** Optional UI context (rpc supplies one; print runs headless). */
|
|
26
28
|
uiContext?: ExtensionUIContext;
|
|
27
29
|
/** Optional lifecycle hook for extension-originated messages that can start an agent turn. */
|
|
@@ -44,6 +46,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
|
|
|
44
46
|
reportSendError,
|
|
45
47
|
reportRuntimeError,
|
|
46
48
|
onShutdown,
|
|
49
|
+
mode = "print",
|
|
47
50
|
uiContext,
|
|
48
51
|
markAgentInvokingMessage,
|
|
49
52
|
trackAgentInvokingMessage,
|
|
@@ -137,6 +140,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
|
|
|
137
140
|
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
|
|
138
141
|
},
|
|
139
142
|
uiContext,
|
|
143
|
+
mode,
|
|
140
144
|
);
|
|
141
145
|
|
|
142
146
|
runner.onError(reportRuntimeError);
|