@oh-my-pi/pi-coding-agent 15.7.3 → 15.7.6
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 +69 -1
- package/dist/types/config/settings-schema.d.ts +12 -22
- package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
- package/dist/types/eval/heartbeat.d.ts +45 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
- package/dist/types/extensibility/extensions/loader.d.ts +2 -2
- package/dist/types/extensibility/extensions/runner.d.ts +2 -1
- package/dist/types/extensibility/extensions/types.d.ts +24 -5
- package/dist/types/extensibility/shared-events.d.ts +2 -2
- package/dist/types/internal-urls/local-protocol.d.ts +19 -9
- package/dist/types/internal-urls/types.d.ts +14 -0
- package/dist/types/lsp/client.d.ts +3 -0
- package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
- package/dist/types/lsp/index.d.ts +2 -0
- package/dist/types/lsp/utils.d.ts +4 -0
- package/dist/types/mcp/manager.d.ts +14 -5
- package/dist/types/modes/acp/acp-agent.d.ts +1 -1
- package/dist/types/modes/components/assistant-message.d.ts +3 -1
- package/dist/types/modes/components/custom-editor.d.ts +0 -1
- package/dist/types/modes/components/hook-selector.d.ts +7 -1
- package/dist/types/modes/controllers/command-controller.d.ts +2 -3
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
- package/dist/types/modes/interactive-mode.d.ts +2 -2
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +10 -7
- package/dist/types/session/shake-types.d.ts +3 -3
- package/dist/types/task/repair-args.d.ts +52 -0
- package/dist/types/tiny/models.d.ts +0 -14
- package/dist/types/tiny/title-client.d.ts +28 -2
- package/dist/types/tiny/title-protocol.d.ts +8 -9
- package/dist/types/tools/ask.d.ts +8 -6
- package/dist/types/tools/eval-backends.d.ts +12 -0
- package/dist/types/tools/eval-render.d.ts +52 -0
- package/dist/types/tools/eval.d.ts +2 -35
- package/dist/types/tools/find.d.ts +1 -1
- package/dist/types/tools/index.d.ts +4 -11
- package/dist/types/tools/path-utils.d.ts +7 -0
- package/dist/types/tui/output-block.d.ts +11 -10
- package/examples/extensions/README.md +1 -0
- package/examples/extensions/thinking-note.ts +13 -0
- package/package.json +9 -9
- package/scripts/build-binary.ts +0 -1
- package/src/cli.ts +59 -0
- package/src/config/model-registry.ts +33 -4
- package/src/config/settings-schema.ts +13 -24
- package/src/config/settings.ts +10 -0
- package/src/discovery/claude.ts +41 -22
- package/src/edit/index.ts +23 -3
- package/src/eval/__tests__/agent-bridge.test.ts +90 -0
- package/src/eval/__tests__/heartbeat.test.ts +84 -0
- package/src/eval/__tests__/llm-bridge.test.ts +30 -0
- package/src/eval/agent-bridge.ts +44 -38
- package/src/eval/heartbeat.ts +74 -0
- package/src/eval/js/executor.ts +13 -9
- package/src/eval/llm-bridge.ts +20 -14
- package/src/eval/py/executor.ts +14 -18
- package/src/exec/bash-executor.ts +31 -5
- package/src/extensibility/custom-tools/types.ts +2 -2
- package/src/extensibility/extensions/loader.ts +16 -18
- package/src/extensibility/extensions/runner.ts +22 -17
- package/src/extensibility/extensions/types.ts +39 -5
- package/src/extensibility/shared-events.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/internal-urls/local-protocol.ts +23 -11
- package/src/internal-urls/types.ts +15 -0
- package/src/lsp/client.ts +28 -5
- package/src/lsp/diagnostics-ledger.ts +51 -0
- package/src/lsp/index.ts +9 -22
- package/src/lsp/utils.ts +21 -0
- package/src/mcp/manager.ts +87 -4
- package/src/modes/acp/acp-agent.ts +8 -4
- package/src/modes/components/assistant-message.ts +28 -1
- package/src/modes/components/custom-editor.ts +9 -7
- package/src/modes/components/hook-selector.ts +159 -32
- package/src/modes/components/tool-execution.ts +20 -4
- package/src/modes/controllers/command-controller.ts +7 -39
- package/src/modes/controllers/event-controller.ts +38 -28
- package/src/modes/controllers/extension-ui-controller.ts +3 -2
- package/src/modes/controllers/input-controller.ts +0 -15
- package/src/modes/controllers/mcp-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +2 -1
- package/src/modes/rpc/rpc-mode.ts +17 -6
- package/src/modes/theme/theme-schema.json +30 -0
- package/src/modes/theme/theme.ts +39 -2
- package/src/modes/types.ts +2 -1
- package/src/modes/utils/ui-helpers.ts +5 -2
- package/src/prompts/system/project-prompt.md +3 -2
- package/src/prompts/system/subagent-system-prompt.md +12 -8
- package/src/prompts/system/system-prompt.md +8 -6
- package/src/prompts/tools/ask.md +2 -1
- package/src/prompts/tools/eval.md +1 -1
- package/src/session/agent-session.ts +75 -103
- package/src/session/shake-types.ts +4 -5
- package/src/slash-commands/builtin-registry.ts +2 -4
- package/src/task/executor.ts +14 -4
- package/src/task/index.ts +3 -2
- package/src/task/repair-args.ts +117 -0
- package/src/tiny/models.ts +0 -28
- package/src/tiny/title-client.ts +133 -43
- package/src/tiny/title-protocol.ts +11 -16
- package/src/tiny/worker.ts +6 -61
- package/src/tools/ask.ts +74 -32
- package/src/tools/ast-edit.ts +3 -0
- package/src/tools/ast-grep.ts +3 -0
- package/src/tools/eval-backends.ts +38 -0
- package/src/tools/eval-render.ts +750 -0
- package/src/tools/eval.ts +27 -754
- package/src/tools/find.ts +20 -6
- package/src/tools/gh.ts +1 -0
- package/src/tools/index.ts +7 -37
- package/src/tools/path-utils.ts +13 -2
- package/src/tools/read.ts +1 -0
- package/src/tools/renderers.ts +1 -1
- package/src/tools/search.ts +12 -1
- package/src/tools/write.ts +9 -1
- package/src/tui/output-block.ts +42 -79
- package/src/utils/git.ts +9 -3
|
@@ -5,7 +5,7 @@ import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
|
5
5
|
import { AgentRegistry } from "../registry/agent-registry";
|
|
6
6
|
import { parseInternalUrl } from "./parse";
|
|
7
7
|
import { validateRelativePath } from "./skill-protocol";
|
|
8
|
-
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
|
|
8
|
+
import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
|
|
9
9
|
|
|
10
10
|
export interface LocalProtocolOptions {
|
|
11
11
|
getArtifactsDir?: () => string | null;
|
|
@@ -164,13 +164,25 @@ export class LocalProtocolHandler implements ProtocolHandler {
|
|
|
164
164
|
* Returns the active local-protocol options.
|
|
165
165
|
*
|
|
166
166
|
* Resolution order:
|
|
167
|
-
* 1.
|
|
168
|
-
* that
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
167
|
+
* 1. **Caller-supplied** `context.localProtocolOptions` (the actual session
|
|
168
|
+
* that initiated the `read`/`find`/`search`/`router.resolve` call). This
|
|
169
|
+
* is what keeps `local://` reads pinned to the calling session in
|
|
170
|
+
* multi-session hosts (cmux/ACP, embedded SDK consumers) where every
|
|
171
|
+
* session registers as `kind: "main"` and "first one wins" would route
|
|
172
|
+
* to the wrong artifacts directory.
|
|
173
|
+
* 2. Explicit process-global override installed via {@link setOverride}
|
|
174
|
+
* (used by SDK consumers with a custom artifacts/session-id mapping and
|
|
175
|
+
* by code paths that do not have a calling session, e.g. TUI hyperlink
|
|
176
|
+
* resolution).
|
|
177
|
+
* 3. The first `main`-kind session in `AgentRegistry.global()`. Its
|
|
178
|
+
* `SessionManager` supplies both `getArtifactsDir` and `getSessionId`.
|
|
179
|
+
* Last-resort fallback — every caller that has a session reference
|
|
180
|
+
* SHOULD thread it through `context` so this branch is never taken in
|
|
181
|
+
* multi-session setups.
|
|
172
182
|
*/
|
|
173
|
-
static resolveOptions(): LocalProtocolOptions | undefined {
|
|
183
|
+
static resolveOptions(context?: ResolveContext): LocalProtocolOptions | undefined {
|
|
184
|
+
const fromContext = context?.localProtocolOptions;
|
|
185
|
+
if (fromContext) return fromContext;
|
|
174
186
|
const override = LocalProtocolHandler.#override;
|
|
175
187
|
if (override) return override;
|
|
176
188
|
const main = AgentRegistry.global()
|
|
@@ -184,8 +196,8 @@ export class LocalProtocolHandler implements ProtocolHandler {
|
|
|
184
196
|
};
|
|
185
197
|
}
|
|
186
198
|
|
|
187
|
-
async resolve(url: InternalUrl): Promise<InternalResource> {
|
|
188
|
-
const opts = LocalProtocolHandler.resolveOptions();
|
|
199
|
+
async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
|
|
200
|
+
const opts = LocalProtocolHandler.resolveOptions(context);
|
|
189
201
|
if (!opts) {
|
|
190
202
|
throw new Error("No session - local:// unavailable");
|
|
191
203
|
}
|
|
@@ -247,8 +259,8 @@ export class LocalProtocolHandler implements ProtocolHandler {
|
|
|
247
259
|
};
|
|
248
260
|
}
|
|
249
261
|
|
|
250
|
-
async complete(): Promise<UrlCompletion[]> {
|
|
251
|
-
const opts = LocalProtocolHandler.resolveOptions();
|
|
262
|
+
async complete(_query?: string, context?: ResolveContext): Promise<UrlCompletion[]> {
|
|
263
|
+
const opts = LocalProtocolHandler.resolveOptions(context);
|
|
252
264
|
if (!opts) return [];
|
|
253
265
|
const localRoot = path.resolve(resolveLocalRoot(opts));
|
|
254
266
|
try {
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* providing access to agent outputs and server resources without exposing filesystem paths.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { LocalProtocolOptions } from "./local-protocol";
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* Raw resource payload returned by protocol handlers. The `immutable` flag is
|
|
10
12
|
* applied by the router from {@link ProtocolHandler.immutable}, so handlers do
|
|
@@ -77,6 +79,17 @@ export interface ResolveContext {
|
|
|
77
79
|
settings?: unknown;
|
|
78
80
|
/** Caller's abort signal. */
|
|
79
81
|
signal?: AbortSignal;
|
|
82
|
+
/**
|
|
83
|
+
* Calling session's `local://` root mapping. When present, the local-protocol
|
|
84
|
+
* handler resolves the URL against THIS session's artifacts dir instead of
|
|
85
|
+
* picking the first `main`-kind session from the global `AgentRegistry`.
|
|
86
|
+
*
|
|
87
|
+
* Required for correctness in multi-session hosts (cmux/ACP, embedded SDK
|
|
88
|
+
* consumers) where multiple sessions are registered as `main` and the
|
|
89
|
+
* "first one wins" lookup picks the wrong artifacts directory — see
|
|
90
|
+
* [#1608](https://github.com/can1357/oh-my-pi/issues/1608).
|
|
91
|
+
*/
|
|
92
|
+
localProtocolOptions?: LocalProtocolOptions;
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
/**
|
|
@@ -89,6 +102,8 @@ export interface WriteContext {
|
|
|
89
102
|
cwd?: string;
|
|
90
103
|
/** Caller's abort signal. */
|
|
91
104
|
signal?: AbortSignal;
|
|
105
|
+
/** Calling session's `local://` root mapping — see {@link ResolveContext.localProtocolOptions}. */
|
|
106
|
+
localProtocolOptions?: LocalProtocolOptions;
|
|
92
107
|
}
|
|
93
108
|
|
|
94
109
|
/**
|
package/src/lsp/client.ts
CHANGED
|
@@ -415,6 +415,10 @@ export const WARMUP_TIMEOUT_MS = 5000;
|
|
|
415
415
|
/** Max time to wait for the server to report project loading completion via $/progress */
|
|
416
416
|
const PROJECT_LOAD_TIMEOUT_MS = 15_000;
|
|
417
417
|
|
|
418
|
+
/** Max time to wait for graceful LSP shutdown and process exit. */
|
|
419
|
+
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
420
|
+
const EXIT_TIMEOUT_MS = 1_000;
|
|
421
|
+
|
|
418
422
|
/**
|
|
419
423
|
* Get or create an LSP client for the given server configuration and working directory.
|
|
420
424
|
* @param config - Server configuration
|
|
@@ -768,8 +772,18 @@ export async function refreshFile(client: LspClient, filePath: string, signal?:
|
|
|
768
772
|
}
|
|
769
773
|
}
|
|
770
774
|
|
|
775
|
+
async function waitForExit(client: LspClient, timeoutMs: number): Promise<boolean> {
|
|
776
|
+
return await Promise.race([
|
|
777
|
+
client.proc.exited.then(
|
|
778
|
+
() => true,
|
|
779
|
+
() => true,
|
|
780
|
+
),
|
|
781
|
+
Bun.sleep(timeoutMs).then(() => false),
|
|
782
|
+
]);
|
|
783
|
+
}
|
|
784
|
+
|
|
771
785
|
/**
|
|
772
|
-
* Shutdown a specific client
|
|
786
|
+
* Shutdown a specific client instance using the LSP shutdown/exit handshake.
|
|
773
787
|
*/
|
|
774
788
|
async function shutdownClientInstance(client: LspClient): Promise<void> {
|
|
775
789
|
const err = new Error("LSP client shutdown");
|
|
@@ -778,13 +792,22 @@ async function shutdownClientInstance(client: LspClient): Promise<void> {
|
|
|
778
792
|
}
|
|
779
793
|
client.pendingRequests.clear();
|
|
780
794
|
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
795
|
+
const shutdownCompleted = await sendRequest(client, "shutdown", null, undefined, SHUTDOWN_TIMEOUT_MS).then(
|
|
796
|
+
() => true,
|
|
797
|
+
() => false,
|
|
798
|
+
);
|
|
799
|
+
if (shutdownCompleted) {
|
|
800
|
+
await sendNotification(client, "exit", undefined).catch(() => {});
|
|
801
|
+
if (await waitForExit(client, EXIT_TIMEOUT_MS)) return;
|
|
802
|
+
}
|
|
803
|
+
|
|
784
804
|
client.proc.kill();
|
|
785
|
-
await
|
|
805
|
+
await waitForExit(client, EXIT_TIMEOUT_MS);
|
|
786
806
|
}
|
|
787
807
|
|
|
808
|
+
/**
|
|
809
|
+
* Shutdown a specific client by key.
|
|
810
|
+
*/
|
|
788
811
|
export async function shutdownClient(key: string): Promise<void> {
|
|
789
812
|
const client = clients.get(key);
|
|
790
813
|
if (!client) return;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { FileDiagnosticsResult } from "./index";
|
|
2
|
+
import { summarizeDiagnosticMessages } from "./utils";
|
|
3
|
+
|
|
4
|
+
const DIAGNOSTIC_LOCATION_PREFIX_RE = /^.*?:\d+:\d+\s+/;
|
|
5
|
+
|
|
6
|
+
export function diagnosticIdentity(message: string): string {
|
|
7
|
+
return message.replace(DIAGNOSTIC_LOCATION_PREFIX_RE, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class DiagnosticsLedger {
|
|
11
|
+
readonly #seen = new Map<string, Set<string>>();
|
|
12
|
+
|
|
13
|
+
reduce(absPath: string, result: FileDiagnosticsResult): FileDiagnosticsResult {
|
|
14
|
+
const previous = this.#seen.get(absPath);
|
|
15
|
+
const currentIdentities = new Set<string>();
|
|
16
|
+
const fresh: string[] = [];
|
|
17
|
+
|
|
18
|
+
for (const message of result.messages) {
|
|
19
|
+
const identity = diagnosticIdentity(message);
|
|
20
|
+
currentIdentities.add(identity);
|
|
21
|
+
if (!previous?.has(identity)) {
|
|
22
|
+
fresh.push(message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (currentIdentities.size === 0) {
|
|
27
|
+
this.#seen.delete(absPath);
|
|
28
|
+
} else {
|
|
29
|
+
this.#seen.set(absPath, currentIdentities);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (fresh.length === result.messages.length) {
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
...result,
|
|
38
|
+
messages: fresh,
|
|
39
|
+
...summarizeDiagnosticMessages(fresh),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DiagnosticsLedgerOwner {
|
|
45
|
+
diagnosticsLedger?: DiagnosticsLedger;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getDiagnosticsLedger(owner: DiagnosticsLedgerOwner): DiagnosticsLedger {
|
|
49
|
+
owner.diagnosticsLedger ??= new DiagnosticsLedger();
|
|
50
|
+
return owner.diagnosticsLedger;
|
|
51
|
+
}
|
package/src/lsp/index.ts
CHANGED
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
resolveDiagnosticTargets,
|
|
80
80
|
resolveSymbolColumn,
|
|
81
81
|
sortDiagnostics,
|
|
82
|
+
summarizeDiagnosticMessages,
|
|
82
83
|
symbolKindToIcon,
|
|
83
84
|
uriToFile,
|
|
84
85
|
} from "./utils";
|
|
@@ -816,12 +817,15 @@ export interface WritethroughOptions {
|
|
|
816
817
|
onDeferredDiagnostics?: (diagnostics: FileDiagnosticsResult) => void;
|
|
817
818
|
/** Signal to cancel a pending deferred diagnostics fetch. */
|
|
818
819
|
deferredSignal?: AbortSignal;
|
|
820
|
+
/** Transform diagnostics before surfacing them after a successful fetch. */
|
|
821
|
+
transformDiagnostics?: (absPath: string, result: FileDiagnosticsResult) => FileDiagnosticsResult;
|
|
819
822
|
}
|
|
820
823
|
|
|
821
824
|
/** Internal resolved form of {@link WritethroughOptions} that the writethrough machinery operates on. */
|
|
822
825
|
type ResolvedWritethroughOptions = {
|
|
823
826
|
enableFormat: boolean;
|
|
824
827
|
enableDiagnostics: boolean;
|
|
828
|
+
transformDiagnostics?: (absPath: string, result: FileDiagnosticsResult) => FileDiagnosticsResult;
|
|
825
829
|
};
|
|
826
830
|
|
|
827
831
|
/** Per-file deferred LSP diagnostics wiring for {@link WritethroughCallback}. */
|
|
@@ -881,6 +885,7 @@ function getOrCreateWritethroughBatch(id: string, options: ResolvedWritethroughO
|
|
|
881
885
|
if (existing) {
|
|
882
886
|
existing.options.enableFormat ||= options.enableFormat;
|
|
883
887
|
existing.options.enableDiagnostics ||= options.enableDiagnostics;
|
|
888
|
+
existing.options.transformDiagnostics ??= options.transformDiagnostics;
|
|
884
889
|
return existing;
|
|
885
890
|
}
|
|
886
891
|
const batch: LspWritethroughBatchState = {
|
|
@@ -904,27 +909,6 @@ export async function flushLspWritethroughBatch(
|
|
|
904
909
|
return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal);
|
|
905
910
|
}
|
|
906
911
|
|
|
907
|
-
function summarizeDiagnosticMessages(messages: string[]): { summary: string; errored: boolean } {
|
|
908
|
-
const counts = { error: 0, warning: 0, info: 0, hint: 0 };
|
|
909
|
-
for (const message of messages) {
|
|
910
|
-
const match = message.match(/\[(error|warning|info|hint)\]/i);
|
|
911
|
-
if (!match) continue;
|
|
912
|
-
const key = match[1].toLowerCase() as keyof typeof counts;
|
|
913
|
-
counts[key] += 1;
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
const parts: string[] = [];
|
|
917
|
-
if (counts.error > 0) parts.push(`${counts.error} error(s)`);
|
|
918
|
-
if (counts.warning > 0) parts.push(`${counts.warning} warning(s)`);
|
|
919
|
-
if (counts.info > 0) parts.push(`${counts.info} info(s)`);
|
|
920
|
-
if (counts.hint > 0) parts.push(`${counts.hint} hint(s)`);
|
|
921
|
-
|
|
922
|
-
return {
|
|
923
|
-
summary: parts.length > 0 ? parts.join(", ") : "no issues",
|
|
924
|
-
errored: counts.error > 0,
|
|
925
|
-
};
|
|
926
|
-
}
|
|
927
|
-
|
|
928
912
|
function mergeDiagnostics(
|
|
929
913
|
results: Array<FileDiagnosticsResult | undefined>,
|
|
930
914
|
options: ResolvedWritethroughOptions,
|
|
@@ -1083,12 +1067,14 @@ async function runLspWritethrough(
|
|
|
1083
1067
|
|
|
1084
1068
|
// 6. Get diagnostics from all servers (wait for fresh results)
|
|
1085
1069
|
if (enableDiagnostics) {
|
|
1086
|
-
|
|
1070
|
+
const fetched = await getDiagnosticsForFile(dst, cwd, servers, {
|
|
1087
1071
|
signal: operationSignal,
|
|
1088
1072
|
minVersions,
|
|
1089
1073
|
expectedDocumentVersions,
|
|
1090
1074
|
allowUnversionedLspDiagnostics: false,
|
|
1091
1075
|
});
|
|
1076
|
+
diagnostics =
|
|
1077
|
+
fetched && options.transformDiagnostics ? options.transformDiagnostics(dst, fetched) : fetched;
|
|
1092
1078
|
}
|
|
1093
1079
|
});
|
|
1094
1080
|
} catch {
|
|
@@ -1155,6 +1141,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
|
|
|
1155
1141
|
const resolvedOptions: ResolvedWritethroughOptions = {
|
|
1156
1142
|
enableFormat: options?.enableFormat ?? false,
|
|
1157
1143
|
enableDiagnostics: options?.enableDiagnostics ?? false,
|
|
1144
|
+
transformDiagnostics: options?.transformDiagnostics,
|
|
1158
1145
|
};
|
|
1159
1146
|
if (!resolvedOptions.enableFormat && !resolvedOptions.enableDiagnostics) {
|
|
1160
1147
|
return writethroughNoop;
|
package/src/lsp/utils.ts
CHANGED
|
@@ -221,6 +221,27 @@ export function formatDiagnosticsSummary(diagnostics: Diagnostic[]): string {
|
|
|
221
221
|
return parts.length > 0 ? parts.join(", ") : "no issues";
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
export function summarizeDiagnosticMessages(messages: string[]): { summary: string; errored: boolean } {
|
|
225
|
+
const counts = { error: 0, warning: 0, info: 0, hint: 0 };
|
|
226
|
+
for (const message of messages) {
|
|
227
|
+
const match = message.match(/\[(error|warning|info|hint)\]/i);
|
|
228
|
+
if (!match) continue;
|
|
229
|
+
const key = match[1].toLowerCase() as keyof typeof counts;
|
|
230
|
+
counts[key] += 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const parts: string[] = [];
|
|
234
|
+
if (counts.error > 0) parts.push(`${counts.error} error(s)`);
|
|
235
|
+
if (counts.warning > 0) parts.push(`${counts.warning} warning(s)`);
|
|
236
|
+
if (counts.info > 0) parts.push(`${counts.info} info(s)`);
|
|
237
|
+
if (counts.hint > 0) parts.push(`${counts.hint} hint(s)`);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
summary: parts.length > 0 ? parts.join(", ") : "no issues",
|
|
241
|
+
errored: counts.error > 0,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
224
245
|
// =============================================================================
|
|
225
246
|
// Location Formatting
|
|
226
247
|
// =============================================================================
|
package/src/mcp/manager.ts
CHANGED
|
@@ -59,6 +59,27 @@ type TrackedPromise<T> = {
|
|
|
59
59
|
|
|
60
60
|
const STARTUP_TIMEOUT_MS = 250;
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Per-server reconnect-storm circuit breaker.
|
|
64
|
+
*
|
|
65
|
+
* `transport.onClose` (wired in {@link MCPManager.connectServers} and
|
|
66
|
+
* {@link MCPManager.#connectAndWireServer}) fires `reconnectServer` on every
|
|
67
|
+
* clean process exit, so a stdio MCP server that completes the
|
|
68
|
+
* `initialize` + `tools/list` handshake and then exits will pull the agent
|
|
69
|
+
* into a fork loop with no rate limit. That pathology shipped in issue #1592
|
|
70
|
+
* (a `php`-shebang MCP fork-bombing macOS, parented directly to the agent's
|
|
71
|
+
* `bun` PID via shebang exec).
|
|
72
|
+
*
|
|
73
|
+
* We keep the sliding window short — older crashes age out so a single
|
|
74
|
+
* transient failure stays cheap — but cap the burst tightly enough that the
|
|
75
|
+
* agent never spawns more than `RECONNECT_BURST_LIMIT * #doReconnect retries`
|
|
76
|
+
* (≤ 25) processes per stuck server per window. Manual `/mcp reconnect`
|
|
77
|
+
* resets the window so users can recover after fixing the underlying
|
|
78
|
+
* misconfiguration.
|
|
79
|
+
*/
|
|
80
|
+
const RECONNECT_BURST_WINDOW_MS = 30_000;
|
|
81
|
+
const RECONNECT_BURST_LIMIT = 5;
|
|
82
|
+
|
|
62
83
|
function trackPromise<T>(promise: Promise<T>): TrackedPromise<T> {
|
|
63
84
|
const tracked: TrackedPromise<T> = { promise, status: "pending" };
|
|
64
85
|
promise.then(
|
|
@@ -166,6 +187,11 @@ export class MCPManager {
|
|
|
166
187
|
#pendingReconnections = new Map<string, Promise<MCPServerConnection | null>>();
|
|
167
188
|
/** Preserved configs for reconnection after connection loss. */
|
|
168
189
|
#serverConfigs = new Map<string, MCPServerConfig>();
|
|
190
|
+
/**
|
|
191
|
+
* Timestamps of recent `reconnectServer` invocations per server, used by the
|
|
192
|
+
* crash-storm circuit breaker (see {@link RECONNECT_BURST_LIMIT}).
|
|
193
|
+
*/
|
|
194
|
+
#reconnectHistory = new Map<string, number[]>();
|
|
169
195
|
/** Monotonic epoch incremented on disconnectAll to invalidate stale reconnections. */
|
|
170
196
|
#epoch = 0;
|
|
171
197
|
|
|
@@ -666,6 +692,7 @@ export class MCPManager {
|
|
|
666
692
|
this.#sources.delete(name);
|
|
667
693
|
this.#serverConfigs.delete(name);
|
|
668
694
|
this.#pendingResourceRefresh.delete(name);
|
|
695
|
+
this.#reconnectHistory.delete(name);
|
|
669
696
|
|
|
670
697
|
const connection = this.#connections.get(name);
|
|
671
698
|
|
|
@@ -714,24 +741,80 @@ export class MCPManager {
|
|
|
714
741
|
this.#connections.clear();
|
|
715
742
|
this.#tools = [];
|
|
716
743
|
this.#subscribedResources.clear();
|
|
744
|
+
this.#reconnectHistory.clear();
|
|
717
745
|
}
|
|
718
746
|
|
|
719
747
|
/**
|
|
720
748
|
* Reconnect to a server after a connection failure.
|
|
749
|
+
*
|
|
721
750
|
* Tears down the stale connection, re-resolves auth, establishes a new
|
|
722
|
-
* connection, reloads tools, and notifies consumers.
|
|
723
|
-
*
|
|
724
|
-
*
|
|
751
|
+
* connection, reloads tools, and notifies consumers. Concurrent calls for
|
|
752
|
+
* the same server share one reconnection attempt. Returns the new
|
|
753
|
+
* connection, or `null` if reconnection failed or the per-server crash
|
|
754
|
+
* burst limit (see {@link RECONNECT_BURST_LIMIT}) is exceeded.
|
|
755
|
+
*
|
|
756
|
+
* @param options.manual - When `true`, resets the crash-burst window so a
|
|
757
|
+
* user-driven retry (e.g. `/mcp reconnect`) is never blocked by an
|
|
758
|
+
* earlier storm. Defaults to `false`; the transport `onClose` callback
|
|
759
|
+
* and the per-tool-call retry path in `tool-bridge` MUST NOT set it.
|
|
725
760
|
*/
|
|
726
|
-
async reconnectServer(name: string): Promise<MCPServerConnection | null> {
|
|
761
|
+
async reconnectServer(name: string, options?: { manual?: boolean }): Promise<MCPServerConnection | null> {
|
|
762
|
+
if (options?.manual) {
|
|
763
|
+
this.#reconnectHistory.delete(name);
|
|
764
|
+
}
|
|
765
|
+
|
|
727
766
|
const pending = this.#pendingReconnections.get(name);
|
|
728
767
|
if (pending) return pending;
|
|
729
768
|
|
|
769
|
+
if (this.#tripReconnectBreaker(name)) {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
|
|
730
773
|
const attempt = this.#doReconnect(name);
|
|
731
774
|
this.#pendingReconnections.set(name, attempt);
|
|
732
775
|
return attempt.finally(() => this.#pendingReconnections.delete(name));
|
|
733
776
|
}
|
|
734
777
|
|
|
778
|
+
/**
|
|
779
|
+
* Record a reconnect attempt against the per-server crash window and report
|
|
780
|
+
* whether the circuit breaker is now open. Sliding window: entries older
|
|
781
|
+
* than {@link RECONNECT_BURST_WINDOW_MS} are pruned before the new
|
|
782
|
+
* timestamp is appended, so a single transient failure ages out cheaply
|
|
783
|
+
* but repeated rapid crashes accumulate until the limit is hit.
|
|
784
|
+
*/
|
|
785
|
+
#tripReconnectBreaker(name: string): boolean {
|
|
786
|
+
const now = Date.now();
|
|
787
|
+
const previous = this.#reconnectHistory.get(name) ?? [];
|
|
788
|
+
const recent = previous.filter(ts => now - ts < RECONNECT_BURST_WINDOW_MS);
|
|
789
|
+
recent.push(now);
|
|
790
|
+
this.#reconnectHistory.set(name, recent);
|
|
791
|
+
|
|
792
|
+
if (recent.length > RECONNECT_BURST_LIMIT) {
|
|
793
|
+
logger.error("MCP server crashed too many times; suspending automatic reconnects", {
|
|
794
|
+
path: `mcp:${name}`,
|
|
795
|
+
crashes: recent.length,
|
|
796
|
+
windowMs: RECONNECT_BURST_WINDOW_MS,
|
|
797
|
+
});
|
|
798
|
+
// Tear down the stale connection so `getConnectionStatus()` no
|
|
799
|
+
// longer reports it as "connected" and `waitForConnection()` does
|
|
800
|
+
// not hand a closed transport to callers. Tools stay registered
|
|
801
|
+
// in `#tools` — the user can recover with `/mcp reconnect <name>`
|
|
802
|
+
// once they've fixed the underlying misconfiguration. Mirrors the
|
|
803
|
+
// teardown in `#doReconnect`: detach `onClose` first so the
|
|
804
|
+
// transport's own `close()` cannot re-arm this path.
|
|
805
|
+
const stale = this.#connections.get(name);
|
|
806
|
+
if (stale) {
|
|
807
|
+
stale.transport.onClose = undefined;
|
|
808
|
+
void stale.transport.close().catch(() => {});
|
|
809
|
+
this.#connections.delete(name);
|
|
810
|
+
}
|
|
811
|
+
this.#pendingConnections.delete(name);
|
|
812
|
+
this.#pendingToolLoads.delete(name);
|
|
813
|
+
return true;
|
|
814
|
+
}
|
|
815
|
+
return false;
|
|
816
|
+
}
|
|
817
|
+
|
|
735
818
|
async #doReconnect(name: string): Promise<MCPServerConnection | null> {
|
|
736
819
|
const oldConnection = this.#connections.get(name);
|
|
737
820
|
const config = oldConnection?.config ?? this.#serverConfigs.get(name);
|
|
@@ -47,7 +47,11 @@ import { logger, VERSION } from "@oh-my-pi/pi-utils";
|
|
|
47
47
|
import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability";
|
|
48
48
|
import { Settings } from "../../config/settings";
|
|
49
49
|
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
|
|
50
|
-
import
|
|
50
|
+
import {
|
|
51
|
+
type ExtensionUIContext,
|
|
52
|
+
type ExtensionUIDialogOptions,
|
|
53
|
+
getExtensionUISelectOptionLabel,
|
|
54
|
+
} from "../../extensibility/extensions";
|
|
51
55
|
import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
|
|
52
56
|
import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
|
|
53
57
|
import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills";
|
|
@@ -302,7 +306,7 @@ export function createAcpExtensionUiContext(
|
|
|
302
306
|
getSessionId(),
|
|
303
307
|
"select",
|
|
304
308
|
title,
|
|
305
|
-
{ type: "string", enum: options },
|
|
309
|
+
{ type: "string", enum: options.map(getExtensionUISelectOptionLabel) },
|
|
306
310
|
dialogOptions,
|
|
307
311
|
);
|
|
308
312
|
return typeof value === "string" ? value : undefined;
|
|
@@ -1981,7 +1985,7 @@ export class AcpAgent implements Agent {
|
|
|
1981
1985
|
}
|
|
1982
1986
|
if (servers.length === 0) {
|
|
1983
1987
|
record.mcpManager = undefined;
|
|
1984
|
-
await record.session.refreshMCPTools([]);
|
|
1988
|
+
await record.session.refreshMCPTools([], { activateAll: true });
|
|
1985
1989
|
return;
|
|
1986
1990
|
}
|
|
1987
1991
|
|
|
@@ -2008,7 +2012,7 @@ export class AcpAgent implements Agent {
|
|
|
2008
2012
|
}
|
|
2009
2013
|
|
|
2010
2014
|
record.mcpManager = manager;
|
|
2011
|
-
await record.session.refreshMCPTools(result.tools);
|
|
2015
|
+
await record.session.refreshMCPTools(result.tools, { activateAll: true });
|
|
2012
2016
|
}
|
|
2013
2017
|
|
|
2014
2018
|
#toMcpConfig(server: McpServer): MCPServerConfig {
|
|
@@ -2,6 +2,7 @@ import type { AssistantMessage, ImageContent, Usage } from "@oh-my-pi/pi-ai";
|
|
|
2
2
|
import { Container, Image, ImageProtocol, Markdown, Spacer, TERMINAL, Text } from "@oh-my-pi/pi-tui";
|
|
3
3
|
import { formatNumber } from "@oh-my-pi/pi-utils";
|
|
4
4
|
import { settings } from "../../config/settings";
|
|
5
|
+
import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
|
|
5
6
|
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
|
6
7
|
import { isSilentAbort } from "../../session/messages";
|
|
7
8
|
import { resolveImageOptions } from "../../tools/render-utils";
|
|
@@ -21,6 +22,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
21
22
|
message?: AssistantMessage,
|
|
22
23
|
private hideThinkingBlock = false,
|
|
23
24
|
private readonly onImageUpdate?: () => void,
|
|
25
|
+
private readonly thinkingRenderers: readonly AssistantThinkingRenderer[] = [],
|
|
24
26
|
) {
|
|
25
27
|
super();
|
|
26
28
|
|
|
@@ -131,6 +133,27 @@ export class AssistantMessageComponent extends Container {
|
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
135
|
|
|
136
|
+
#appendThinkingExtensions(contentIndex: number, thinkingIndex: number, text: string): void {
|
|
137
|
+
for (const renderer of this.thinkingRenderers) {
|
|
138
|
+
try {
|
|
139
|
+
const component = renderer(
|
|
140
|
+
{
|
|
141
|
+
contentIndex,
|
|
142
|
+
thinkingIndex,
|
|
143
|
+
text,
|
|
144
|
+
requestRender: () => this.onImageUpdate?.(),
|
|
145
|
+
},
|
|
146
|
+
theme,
|
|
147
|
+
);
|
|
148
|
+
if (component) {
|
|
149
|
+
this.#contentContainer.addChild(component);
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Ignore extension renderer failures and keep the original thinking block visible.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
134
157
|
updateContent(message: AssistantMessage): void {
|
|
135
158
|
this.#lastMessage = message;
|
|
136
159
|
|
|
@@ -146,6 +169,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
146
169
|
}
|
|
147
170
|
|
|
148
171
|
// Render content in order
|
|
172
|
+
let thinkingIndex = 0;
|
|
149
173
|
for (let i = 0; i < message.content.length; i++) {
|
|
150
174
|
const content = message.content[i];
|
|
151
175
|
if (content.type === "text" && content.text.trim()) {
|
|
@@ -166,13 +190,16 @@ export class AssistantMessageComponent extends Container {
|
|
|
166
190
|
this.#contentContainer.addChild(new Spacer(1));
|
|
167
191
|
}
|
|
168
192
|
} else {
|
|
193
|
+
const thinkingText = content.thinking.trim();
|
|
169
194
|
// Thinking traces in thinkingText color, italic
|
|
170
195
|
this.#contentContainer.addChild(
|
|
171
|
-
new Markdown(
|
|
196
|
+
new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
|
|
172
197
|
color: (text: string) => theme.fg("thinkingText", text),
|
|
173
198
|
italic: true,
|
|
174
199
|
}),
|
|
175
200
|
);
|
|
201
|
+
this.#appendThinkingExtensions(i, thinkingIndex, thinkingText);
|
|
202
|
+
thinkingIndex += 1;
|
|
176
203
|
if (hasVisibleContentAfter) {
|
|
177
204
|
this.#contentContainer.addChild(new Spacer(1));
|
|
178
205
|
}
|
|
@@ -49,7 +49,6 @@ export class CustomEditor extends Editor {
|
|
|
49
49
|
* them, skipping any occurrence inside code spans, fenced blocks, or XML sections. */
|
|
50
50
|
decorateText = (text: string): string => highlightMagicKeywords(text);
|
|
51
51
|
onEscape?: () => void;
|
|
52
|
-
shouldBypassAutocompleteOnEscape?: () => boolean;
|
|
53
52
|
onClear?: () => void;
|
|
54
53
|
onExit?: () => void;
|
|
55
54
|
onCycleThinkingLevel?: () => void;
|
|
@@ -186,12 +185,15 @@ export class CustomEditor extends Editor {
|
|
|
186
185
|
}
|
|
187
186
|
|
|
188
187
|
// Intercept configured interrupt shortcut.
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
188
|
+
// When the autocomplete popup is visible, ESC's first job is to dismiss
|
|
189
|
+
// the popup — let super.handleInput() route it to #cancelAutocomplete().
|
|
190
|
+
// The user can press ESC again afterward to fire the global interrupt
|
|
191
|
+
// handler. This matches the standard TUI/IDE pattern and prevents a
|
|
192
|
+
// single ESC from both closing an @ completion and aborting an active
|
|
193
|
+
// agent run (#1655).
|
|
194
|
+
if (this.#matchesAction(data, "app.interrupt") && this.onEscape && !this.isShowingAutocomplete()) {
|
|
195
|
+
this.onEscape();
|
|
196
|
+
return;
|
|
195
197
|
}
|
|
196
198
|
|
|
197
199
|
// Intercept configured clear shortcut
|