@oh-my-pi/pi-coding-agent 17.3.4 → 17.3.7
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 +88 -0
- package/dist/{CHANGELOG-trcc215s.md → CHANGELOG-1hmwwt45.md} +88 -0
- package/dist/cli.js +3156 -3163
- package/dist/docs-index.generated.txt +1 -1
- package/dist/types/cli/stats-cli.d.ts +1 -6
- package/dist/types/commands/stats.d.ts +4 -0
- package/dist/types/config/model-discovery.d.ts +2 -0
- package/dist/types/config/model-registry.d.ts +6 -1
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/debug/report-bundle.d.ts +7 -2
- package/dist/types/extensibility/extensions/loader.d.ts +2 -0
- package/dist/types/extensibility/extensions/runner.d.ts +24 -6
- package/dist/types/extensibility/extensions/types.d.ts +60 -0
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
- package/dist/types/launch/presence.d.ts +11 -0
- package/dist/types/lsp/client.d.ts +22 -0
- package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
- package/dist/types/mcp/config.d.ts +3 -1
- package/dist/types/modes/components/tool-execution.d.ts +3 -1
- package/dist/types/registry/agent-lifecycle.d.ts +11 -0
- package/dist/types/registry/agent-registry.d.ts +4 -0
- package/dist/types/session/agent-session-types.d.ts +0 -2
- package/dist/types/session/agent-session.d.ts +20 -0
- package/dist/types/session/date-cwd-reminder.d.ts +22 -0
- package/dist/types/session/irc-bridge.d.ts +2 -0
- package/dist/types/session/messages.d.ts +4 -0
- package/dist/types/session/session-loader.d.ts +18 -11
- package/dist/types/session/session-maintenance.d.ts +6 -2
- package/dist/types/session/session-manager.d.ts +19 -3
- package/dist/types/session/session-tools.d.ts +0 -1
- package/dist/types/session/turn-recovery.d.ts +7 -7
- package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
- package/dist/types/tools/browser/attach.d.ts +16 -0
- package/dist/types/tools/browser/launch.d.ts +2 -2
- package/dist/types/tools/browser.d.ts +1 -1
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/file-write-fallback.d.ts +124 -0
- package/dist/types/tools/hub/jobs.d.ts +6 -0
- package/dist/types/tools/hub/types.d.ts +6 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/path-utils.d.ts +23 -0
- package/dist/types/vibe/runtime.d.ts +3 -3
- package/package.json +13 -13
- package/src/auto-thinking/classifier.ts +37 -23
- package/src/cli/models-cli.ts +1 -1
- package/src/cli/stats-cli.ts +6 -72
- package/src/commands/stats.ts +7 -4
- package/src/commit/analysis/conventional.ts +15 -9
- package/src/commit/analysis/summary.ts +15 -9
- package/src/commit/changelog/generate.ts +15 -9
- package/src/commit/map-reduce/map-phase.ts +3 -19
- package/src/commit/map-reduce/reduce-phase.ts +15 -9
- package/src/config/model-discovery.ts +3 -3
- package/src/config/model-registry.ts +29 -16
- package/src/config/settings-schema.ts +13 -0
- package/src/debug/report-bundle.ts +25 -59
- package/src/discovery/claude-plugins.ts +2 -1
- package/src/discovery/claude.ts +4 -2
- package/src/discovery/helpers.ts +5 -0
- package/src/edit/hashline/filesystem.ts +9 -3
- package/src/edit/modes/patch.ts +31 -5
- package/src/edit/renderer.ts +48 -19
- package/src/extensibility/extensions/loader.ts +20 -5
- package/src/extensibility/extensions/runner.ts +239 -24
- package/src/extensibility/extensions/types.ts +62 -0
- package/src/extensibility/extensions/wrapper.ts +26 -11
- package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
- package/src/extensibility/plugins/marketplace/manager.ts +15 -18
- package/src/irc/bus.ts +1 -1
- package/src/launch/broker.ts +8 -1
- package/src/launch/presence.ts +77 -1
- package/src/lsp/client.ts +35 -3
- package/src/lsp/clients/biome-client.ts +47 -93
- package/src/lsp/servers.ts +17 -11
- package/src/lsp/tool.ts +2 -4
- package/src/lsp/workspace-diagnostics.ts +24 -2
- package/src/lsp/writethrough.ts +20 -10
- package/src/mcp/config.ts +50 -6
- package/src/memories/index.ts +29 -25
- package/src/mnemopi/backend.ts +13 -11
- package/src/modes/components/ask-dialog.ts +25 -5
- package/src/modes/components/tool-execution.ts +10 -6
- package/src/modes/components/welcome.ts +4 -1
- package/src/modes/controllers/extension-ui-controller.ts +52 -32
- package/src/modes/controllers/selector-controller.ts +6 -0
- package/src/modes/controllers/tan-command-controller.ts +1 -0
- package/src/modes/interactive-mode.ts +49 -10
- package/src/modes/theme/defaults/birch.json +2 -2
- package/src/prompts/system/checkpoint-active-notice.md +5 -0
- package/src/prompts/system/date-cwd-reminder.md +3 -0
- package/src/prompts/system/project-prompt.md +0 -1
- package/src/prompts/system/rewind-report.md +1 -3
- package/src/prompts/tools/browser.md +1 -0
- package/src/prompts/tools/rewind.md +1 -13
- package/src/registry/agent-lifecycle.ts +34 -0
- package/src/registry/agent-registry.ts +27 -2
- package/src/registry/persisted-agents.ts +40 -20
- package/src/sdk.ts +32 -2
- package/src/session/agent-session-types.ts +0 -2
- package/src/session/agent-session.ts +178 -46
- package/src/session/date-cwd-reminder.ts +77 -0
- package/src/session/irc-bridge.ts +5 -0
- package/src/session/messages.ts +5 -1
- package/src/session/session-loader.ts +106 -64
- package/src/session/session-maintenance.ts +189 -115
- package/src/session/session-manager.ts +142 -35
- package/src/session/session-persistence.ts +4 -4
- package/src/session/session-storage.ts +18 -5
- package/src/session/session-tools.ts +5 -10
- package/src/session/turn-recovery.ts +102 -20
- package/src/session/unexpected-stop-classifier.ts +33 -21
- package/src/slash-commands/builtin-session.ts +1 -1
- package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
- package/src/system-prompt.ts +0 -5
- package/src/task/executor.ts +4 -15
- package/src/task/persisted-revive.ts +3 -6
- package/src/tools/ask.ts +10 -3
- package/src/tools/browser/attach.ts +80 -25
- package/src/tools/browser/launch.ts +44 -15
- package/src/tools/browser/relay/daemon.ts +3 -7
- package/src/tools/browser/render.ts +1 -1
- package/src/tools/browser/shared-daemon.ts +3 -7
- package/src/tools/browser.ts +5 -5
- package/src/tools/builtin-names.ts +7 -3
- package/src/tools/checkpoint.ts +1 -7
- package/src/tools/file-write-fallback.ts +467 -0
- package/src/tools/hub/index.ts +1 -1
- package/src/tools/hub/jobs.ts +20 -3
- package/src/tools/hub/types.ts +6 -0
- package/src/tools/index.ts +1 -0
- package/src/tools/path-utils.ts +79 -0
- package/src/tools/render-utils.ts +53 -21
- package/src/tools/think.ts +15 -3
- package/src/tts/speech-enhancer.ts +19 -14
- package/src/utils/commit-message-generator.ts +14 -12
- package/src/utils/title-generator.ts +23 -19
- package/src/vibe/runtime.ts +32 -17
|
@@ -14,6 +14,7 @@ import type { Settings } from "../../config/settings";
|
|
|
14
14
|
import type { Theme } from "../../modes/theme/theme";
|
|
15
15
|
import { type ApprovalMode, formatApprovalPrompt, resolveApproval, truncateForPrompt } from "../../tools/approval";
|
|
16
16
|
import { defaultLoadModeForToolName } from "../../tools/essential-tools";
|
|
17
|
+
import { withFileMutationSession } from "../../tools/file-write-fallback";
|
|
17
18
|
import { normalizeToolEventInput, resolveToolEventInput } from "../tool-event-input";
|
|
18
19
|
import { applyToolProxy } from "../tool-proxy";
|
|
19
20
|
import type { ExtensionRunner } from "./runner";
|
|
@@ -205,15 +206,18 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
205
206
|
let effectiveParams = params;
|
|
206
207
|
if (!loopEmittedToolCall && this.runner.hasHandlers("tool_call")) {
|
|
207
208
|
try {
|
|
208
|
-
const callResult = (await this.runner.emitToolCall(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
209
|
+
const callResult = (await this.runner.emitToolCall(
|
|
210
|
+
{
|
|
211
|
+
type: "tool_call",
|
|
212
|
+
toolName: this.tool.name,
|
|
213
|
+
toolCallId,
|
|
214
|
+
input: normalizeToolEventInput(
|
|
215
|
+
this.tool.name,
|
|
216
|
+
resolveToolEventInput(this.tool, toolEventArgs(params, context)),
|
|
217
|
+
),
|
|
218
|
+
},
|
|
219
|
+
signal,
|
|
220
|
+
)) as ToolCallEventResult | undefined;
|
|
217
221
|
|
|
218
222
|
if (callResult?.block) {
|
|
219
223
|
const reason = callResult.reason || "Tool execution was blocked by an extension";
|
|
@@ -265,7 +269,10 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
265
269
|
|
|
266
270
|
if (approvalCheck.required) {
|
|
267
271
|
const scheduledCall = context?.toolCall?.toolCalls[context.toolCall.index];
|
|
268
|
-
if (
|
|
272
|
+
if (
|
|
273
|
+
scheduledCall?.id === toolCallId &&
|
|
274
|
+
(scheduledCall.name === this.tool.name || scheduledCall.name === this.tool.customWireName)
|
|
275
|
+
) {
|
|
269
276
|
await untilAborted(signal, () => this.runner.waitForToolApprovalPreview(toolCallId));
|
|
270
277
|
}
|
|
271
278
|
|
|
@@ -343,7 +350,15 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
343
350
|
let executionError: Error | undefined;
|
|
344
351
|
|
|
345
352
|
try {
|
|
346
|
-
|
|
353
|
+
// A denied file write or delete inside this tool can be brokered to an
|
|
354
|
+
// extension handler, and that registry is PROCESS-WIDE — so the session is
|
|
355
|
+
// named here, the one place where every tool's execution and the runner
|
|
356
|
+
// that owns the handlers are both in scope (`sdk.ts` wraps the whole tool
|
|
357
|
+
// registry with this class whenever a runner exists). Inert with no
|
|
358
|
+
// fallback registered: no scope is entered.
|
|
359
|
+
result = await withFileMutationSession(this.runner.sessionId, () =>
|
|
360
|
+
this.tool.execute(toolCallId, effectiveParams, signal, onUpdate, context),
|
|
361
|
+
);
|
|
347
362
|
} catch (err) {
|
|
348
363
|
executionError = err instanceof Error ? err : new Error(String(err));
|
|
349
364
|
result = {
|
|
@@ -134,6 +134,10 @@ export const getModels = getBundledModels;
|
|
|
134
134
|
*
|
|
135
135
|
* Legacy `/compat` callers pass {@link SimpleStreamOptions}; routing through
|
|
136
136
|
* `streamSimple` preserves option normalization before provider dispatch.
|
|
137
|
+
*
|
|
138
|
+
* Transient-failure retry (overload, rate-limit, 5xx) is the **caller's
|
|
139
|
+
* responsibility**. Oneshot callers that collect the full result before acting
|
|
140
|
+
* should wrap with `retryTransientCompletion` from `@oh-my-pi/pi-ai`.
|
|
137
141
|
*/
|
|
138
142
|
export function streamSimpleOpenAIResponses(
|
|
139
143
|
model: Model<"openai-responses">,
|
|
@@ -11,6 +11,7 @@ import * as os from "node:os";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
|
|
13
13
|
import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils";
|
|
14
|
+
import { expandTilde } from "../../../tools/path-utils";
|
|
14
15
|
import { normalizePluginRuntimeConfig } from "../runtime-config";
|
|
15
16
|
import type { PluginRuntimeConfig } from "../types";
|
|
16
17
|
|
|
@@ -107,12 +108,11 @@ export class MarketplaceManager {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
const sourceType = classifySource(source);
|
|
110
|
-
const normalizedSource =
|
|
111
|
-
sourceType === "local"
|
|
112
|
-
? path.resolve(source.startsWith("~/") ? path.join(os.homedir(), source.slice(2)) : source)
|
|
113
|
-
: source;
|
|
111
|
+
const normalizedSource = sourceType === "local" ? path.resolve(expandTilde(source)) : source;
|
|
114
112
|
|
|
115
|
-
const catalogPath = path.
|
|
113
|
+
const catalogPath = path.resolve(
|
|
114
|
+
expandTilde(path.join(this.#opts.marketplacesCacheDir, catalog.name, "marketplace.json")),
|
|
115
|
+
);
|
|
116
116
|
|
|
117
117
|
// Persist the fetched catalog so subsequent reads don't require re-fetching.
|
|
118
118
|
await Bun.write(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
@@ -174,11 +174,13 @@ export class MarketplaceManager {
|
|
|
174
174
|
await promoteCloneToCache(clonePath, this.#opts.marketplacesCacheDir, catalog.name);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
// Overwrite cached catalog
|
|
178
|
-
|
|
177
|
+
// Overwrite the cached catalog and migrate legacy home-relative registry entries.
|
|
178
|
+
const catalogPath = path.resolve(expandTilde(existing.catalogPath));
|
|
179
|
+
await Bun.write(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
179
180
|
|
|
180
181
|
const updatedEntry: MarketplaceRegistryEntry = {
|
|
181
182
|
...existing,
|
|
183
|
+
catalogPath,
|
|
182
184
|
updatedAt: new Date().toISOString(),
|
|
183
185
|
};
|
|
184
186
|
|
|
@@ -893,14 +895,13 @@ export class MarketplaceManager {
|
|
|
893
895
|
}
|
|
894
896
|
|
|
895
897
|
async #readCatalog(entry: MarketplaceRegistryEntry): Promise<MarketplaceCatalog> {
|
|
898
|
+
const catalogPath = path.resolve(expandTilde(entry.catalogPath));
|
|
896
899
|
try {
|
|
897
|
-
const content = await Bun.file(
|
|
898
|
-
return parseMarketplaceCatalog(content,
|
|
900
|
+
const content = await Bun.file(catalogPath).text();
|
|
901
|
+
return parseMarketplaceCatalog(content, catalogPath);
|
|
899
902
|
} catch (err) {
|
|
900
903
|
if (isEnoent(err)) {
|
|
901
|
-
throw new Error(
|
|
902
|
-
`Marketplace catalog not found at ${entry.catalogPath}. Try: /marketplace update ${entry.name}`,
|
|
903
|
-
);
|
|
904
|
+
throw new Error(`Marketplace catalog not found at ${catalogPath}. Try: /marketplace update ${entry.name}`);
|
|
904
905
|
}
|
|
905
906
|
throw err;
|
|
906
907
|
}
|
|
@@ -918,14 +919,10 @@ export class MarketplaceManager {
|
|
|
918
919
|
*/
|
|
919
920
|
#resolveMarketplaceRoot(entry: MarketplaceRegistryEntry): string {
|
|
920
921
|
if (entry.sourceType === "local") {
|
|
921
|
-
|
|
922
|
-
const expanded = entry.sourceUri.startsWith("~/")
|
|
923
|
-
? path.join(os.homedir(), entry.sourceUri.slice(2))
|
|
924
|
-
: entry.sourceUri;
|
|
925
|
-
return path.resolve(expanded);
|
|
922
|
+
return path.resolve(expandTilde(entry.sourceUri));
|
|
926
923
|
}
|
|
927
924
|
// For git/github/url sources, the catalog lives at <cloneDir>/marketplace.json
|
|
928
925
|
// under marketplacesCacheDir/<name>/; parent = <marketplacesCacheDir>/<name>/
|
|
929
|
-
return path.dirname(entry.catalogPath);
|
|
926
|
+
return path.dirname(path.resolve(expandTilde(entry.catalogPath)));
|
|
930
927
|
}
|
|
931
928
|
}
|
package/src/irc/bus.ts
CHANGED
|
@@ -276,7 +276,7 @@ export class IrcBus {
|
|
|
276
276
|
if (liveness) {
|
|
277
277
|
const { registry, senderId } = liveness;
|
|
278
278
|
const hasRunningSender = (from?: string): boolean =>
|
|
279
|
-
registry.listVisibleTo(senderId).some(ref => ref
|
|
279
|
+
registry.listVisibleTo(senderId).some(ref => registry.isRunning(ref) && (!from || ref.id === from));
|
|
280
280
|
const check = filter.from ? () => hasRunningSender(filter.from) : () => hasRunningSender();
|
|
281
281
|
unsubscribeLiveness = registry.onChange(() => {
|
|
282
282
|
if (!check()) {
|
package/src/launch/broker.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { hostHasInheritableConsole } from "../eval/py/spawn-options";
|
|
|
8
8
|
import { truncateHead, truncateHeadBytes, truncateTail, truncateTailBytes } from "../session/streaming-output";
|
|
9
9
|
import { workerEnvFromParent } from "../subprocess/worker-client";
|
|
10
10
|
import { daemonBrokerEndpoint } from "./paths";
|
|
11
|
-
import { hasLiveDaemonProjectPresence } from "./presence";
|
|
11
|
+
import { hasLiveDaemonProjectPresence, pruneDeadDaemonRuntimeDirs } from "./presence";
|
|
12
12
|
import {
|
|
13
13
|
DAEMON_IDLE_GRACE_ENV,
|
|
14
14
|
DAEMON_PROJECT_DIR_ENV,
|
|
@@ -1384,6 +1384,13 @@ export async function startDaemonBrokerFromEnvironment(options: DaemonBrokerStar
|
|
|
1384
1384
|
const lease = await acquireBrokerLease(runtimeDir);
|
|
1385
1385
|
if (!lease) return;
|
|
1386
1386
|
setProcessName("omp daemon broker");
|
|
1387
|
+
// Reclaim sibling daemon scopes left behind by dead brokers (issue #8674).
|
|
1388
|
+
// Detached and non-throwing so it never delays clients connecting to us.
|
|
1389
|
+
void pruneDeadDaemonRuntimeDirs(runtimeDir).catch(error => {
|
|
1390
|
+
logger.warn("Daemon runtime prune failed", {
|
|
1391
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1392
|
+
});
|
|
1393
|
+
});
|
|
1387
1394
|
const token = (await Bun.file(path.join(runtimeDir, TOKEN_FILE)).text()).trim();
|
|
1388
1395
|
if (!token) throw new Error("Daemon broker token is empty");
|
|
1389
1396
|
const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs, restartBackoffBaseMs);
|
package/src/launch/presence.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
1
2
|
import * as fs from "node:fs/promises";
|
|
2
3
|
import * as path from "node:path";
|
|
3
|
-
import { isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { isEisdir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
|
|
4
5
|
import { daemonRuntimeDir } from "./paths";
|
|
5
6
|
|
|
6
7
|
const CLIENTS_DIR = "clients";
|
|
8
|
+
const BROKER_PID_FILE = "broker.pid";
|
|
9
|
+
const GLOBAL_DAEMON_DIR = "global";
|
|
10
|
+
/**
|
|
11
|
+
* Grace before a dead daemon runtime dir becomes prune-eligible. Guards against
|
|
12
|
+
* deleting a scope whose owning omp process is mid-startup (token written, broker
|
|
13
|
+
* not yet spawned, presence not yet registered). The leak this reclaims is a
|
|
14
|
+
* weeks-scale accumulation, so a few minutes of slack costs nothing.
|
|
15
|
+
*/
|
|
16
|
+
const DAEMON_RUNTIME_STALE_GRACE_MS = 5 * 60_000;
|
|
7
17
|
|
|
8
18
|
/** Handle keeping one omp process registered in a project daemon scope. */
|
|
9
19
|
export interface DaemonProjectPresence {
|
|
@@ -80,3 +90,69 @@ export async function hasLiveDaemonProjectPresence(runtimeDir: string): Promise<
|
|
|
80
90
|
}
|
|
81
91
|
return live;
|
|
82
92
|
}
|
|
93
|
+
|
|
94
|
+
/** Whether a runtime dir's recorded broker PID is still alive. */
|
|
95
|
+
async function hasLiveDaemonBroker(runtimeDir: string): Promise<boolean> {
|
|
96
|
+
let raw: unknown;
|
|
97
|
+
try {
|
|
98
|
+
raw = await Bun.file(path.join(runtimeDir, BROKER_PID_FILE)).json();
|
|
99
|
+
} catch {
|
|
100
|
+
return false; // Missing or malformed broker.pid => no owning broker.
|
|
101
|
+
}
|
|
102
|
+
if (typeof raw !== "object" || raw === null || !("pid" in raw) || typeof raw.pid !== "number") {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
process.kill(raw.pid, 0);
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Remove sibling project daemon runtime directories whose broker is dead and
|
|
115
|
+
* whose client-presence set is empty, reclaiming the disk that short-lived
|
|
116
|
+
* project directories leave behind (issue #8674).
|
|
117
|
+
*
|
|
118
|
+
* Best-effort and non-throwing: a scope is deleted only when its `broker.pid`
|
|
119
|
+
* is absent/dead, no live client presence remains, and it has been untouched
|
|
120
|
+
* for {@link DAEMON_RUNTIME_STALE_GRACE_MS}. The caller's own `currentRuntimeDir`
|
|
121
|
+
* and the machine-global daemon container are always skipped.
|
|
122
|
+
*/
|
|
123
|
+
export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Promise<void> {
|
|
124
|
+
const root = path.dirname(currentRuntimeDir);
|
|
125
|
+
if (path.basename(root) === GLOBAL_DAEMON_DIR) return;
|
|
126
|
+
const current = path.resolve(currentRuntimeDir);
|
|
127
|
+
let entries: Dirent[];
|
|
128
|
+
try {
|
|
129
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (!isEnoent(error)) {
|
|
132
|
+
logger.warn("Failed to scan daemon runtime root for pruning", {
|
|
133
|
+
root,
|
|
134
|
+
error: error instanceof Error ? error.message : String(error),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (!entry.isDirectory() || entry.name === GLOBAL_DAEMON_DIR) continue;
|
|
142
|
+
const dir = path.join(root, entry.name);
|
|
143
|
+
if (path.resolve(dir) === current) continue;
|
|
144
|
+
try {
|
|
145
|
+
const stat = await fs.stat(dir);
|
|
146
|
+
if (now - stat.mtimeMs < DAEMON_RUNTIME_STALE_GRACE_MS) continue;
|
|
147
|
+
if (await hasLiveDaemonBroker(dir)) continue;
|
|
148
|
+
if (await hasLiveDaemonProjectPresence(dir)) continue;
|
|
149
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (isEnoent(error)) continue;
|
|
152
|
+
logger.warn("Failed to prune dead daemon runtime dir", {
|
|
153
|
+
dir,
|
|
154
|
+
error: error instanceof Error ? error.message : String(error),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/lsp/client.ts
CHANGED
|
@@ -62,13 +62,32 @@ export function setIdleTimeout(ms: number | null | undefined): void {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Whether a client may be reaped by the idle checker.
|
|
67
|
+
*
|
|
68
|
+
* A client with in-flight requests is *busy*, never idle. `lastActivity` is
|
|
69
|
+
* stamped when a request is written, not while it is outstanding, so a single
|
|
70
|
+
* request that runs longer than the idle timeout used to look like silence:
|
|
71
|
+
* the checker tore the client down mid-flight and `shutdownClientInstance`
|
|
72
|
+
* rejected the caller's still-pending promise with "LSP client shutdown"
|
|
73
|
+
* (issue #8390). Requests that settle refresh `lastActivity`, so a client
|
|
74
|
+
* becomes eligible again only after the final one lands and the full idle
|
|
75
|
+
* window then elapses.
|
|
76
|
+
*
|
|
77
|
+
* Exported for tests; the idle checker is the only production caller.
|
|
78
|
+
*/
|
|
79
|
+
export function isIdleClient(client: LspClient, now: number, timeoutMs: number): boolean {
|
|
80
|
+
if (client.pendingRequests.size > 0) return false;
|
|
81
|
+
return now - client.lastActivity > timeoutMs;
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
function startIdleChecker(): void {
|
|
66
85
|
if (idleCheckInterval) return;
|
|
67
86
|
idleCheckInterval = setInterval(() => {
|
|
68
87
|
if (!idleTimeoutMs) return;
|
|
69
88
|
const now = Date.now();
|
|
70
89
|
for (const [key, client] of Array.from(clients.entries())) {
|
|
71
|
-
if (
|
|
90
|
+
if (isIdleClient(client, now, idleTimeoutMs)) {
|
|
72
91
|
void shutdownClient(key);
|
|
73
92
|
}
|
|
74
93
|
}
|
|
@@ -746,7 +765,13 @@ function commandBasename(command: string): string {
|
|
|
746
765
|
return separator === -1 ? command : command.slice(separator + 1);
|
|
747
766
|
}
|
|
748
767
|
|
|
749
|
-
|
|
768
|
+
/**
|
|
769
|
+
* True when this client speaks the rust-analyzer protocol, detected by the
|
|
770
|
+
* command basename (`rust-analyzer[.exe]`) of the configured or resolved
|
|
771
|
+
* binary. Callers use it to gate rust-analyzer-only requests such as
|
|
772
|
+
* `rust-analyzer/reloadWorkspace` (see {@link reloadServer}).
|
|
773
|
+
*/
|
|
774
|
+
export function isRustAnalyzerClient(client: LspClient): boolean {
|
|
750
775
|
return (
|
|
751
776
|
commandBasename(client.config.command) === "rust-analyzer" ||
|
|
752
777
|
(client.config.resolvedCommand ? commandBasename(client.config.resolvedCommand) === "rust-analyzer" : false)
|
|
@@ -1456,15 +1481,22 @@ export async function sendRequest(
|
|
|
1456
1481
|
}
|
|
1457
1482
|
}
|
|
1458
1483
|
|
|
1459
|
-
// Register pending request with timeout wrapper
|
|
1484
|
+
// Register pending request with timeout wrapper.
|
|
1485
|
+
// Settling stamps `lastActivity`: the idle window must be measured from when
|
|
1486
|
+
// the exchange finished, not from when it started. Without this a request
|
|
1487
|
+
// that outlives the timeout would leave the client instantly reapable the
|
|
1488
|
+
// moment it lands, so the next idle sweep would kill a server that had just
|
|
1489
|
+
// answered (issue #8390).
|
|
1460
1490
|
client.pendingRequests.set(id, {
|
|
1461
1491
|
resolve: result => {
|
|
1462
1492
|
if (timeout) clearTimeout(timeout);
|
|
1493
|
+
client.lastActivity = Date.now();
|
|
1463
1494
|
cleanup();
|
|
1464
1495
|
resolve(result);
|
|
1465
1496
|
},
|
|
1466
1497
|
reject: err => {
|
|
1467
1498
|
if (timeout) clearTimeout(timeout);
|
|
1499
|
+
client.lastActivity = Date.now();
|
|
1468
1500
|
cleanup();
|
|
1469
1501
|
reject(err);
|
|
1470
1502
|
},
|
|
@@ -14,14 +14,21 @@ interface BiomeJsonOutput {
|
|
|
14
14
|
diagnostics: BiomeDiagnostic[];
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* A single diagnostic from Biome's `--reporter=json` output (Biome 2.x).
|
|
19
|
+
*
|
|
20
|
+
* Positions are 1-indexed `{ line, column }` pairs; the path is a plain string
|
|
21
|
+
* relative to the CLI's cwd. Older releases used byte-offset `span`s, which
|
|
22
|
+
* this client no longer parses.
|
|
23
|
+
*/
|
|
17
24
|
interface BiomeDiagnostic {
|
|
18
25
|
category: string; // e.g., "lint/correctness/noUnusedVariables"
|
|
19
|
-
severity: "error" | "warning" | "info" | "hint"
|
|
20
|
-
|
|
26
|
+
severity: string; // "error" | "warning" | "info" | "hint"
|
|
27
|
+
message: string;
|
|
21
28
|
location?: {
|
|
22
|
-
path?:
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
path?: string;
|
|
30
|
+
start?: { line: number; column: number };
|
|
31
|
+
end?: { line: number; column: number };
|
|
25
32
|
};
|
|
26
33
|
}
|
|
27
34
|
|
|
@@ -29,43 +36,6 @@ interface BiomeDiagnostic {
|
|
|
29
36
|
// Helpers
|
|
30
37
|
// =============================================================================
|
|
31
38
|
|
|
32
|
-
/**
|
|
33
|
-
* Convert byte offsets to line:column positions in a single pass over the source.
|
|
34
|
-
*/
|
|
35
|
-
function offsetsToPositions(source: string, offsets: number[]): Map<number, { line: number; column: number }> {
|
|
36
|
-
const sorted = [...new Set(offsets)].sort((a, b) => a - b);
|
|
37
|
-
const result = new Map<number, { line: number; column: number }>();
|
|
38
|
-
let line = 1;
|
|
39
|
-
let column = 1;
|
|
40
|
-
let byteIndex = 0;
|
|
41
|
-
let next = 0;
|
|
42
|
-
|
|
43
|
-
for (const ch of source) {
|
|
44
|
-
if (next >= sorted.length) break;
|
|
45
|
-
const cp = ch.codePointAt(0) as number;
|
|
46
|
-
const byteLen = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
|
|
47
|
-
while (next < sorted.length && byteIndex + byteLen > sorted[next]) {
|
|
48
|
-
result.set(sorted[next], { line, column });
|
|
49
|
-
next++;
|
|
50
|
-
}
|
|
51
|
-
if (ch === "\n") {
|
|
52
|
-
line++;
|
|
53
|
-
column = 1;
|
|
54
|
-
} else {
|
|
55
|
-
column++;
|
|
56
|
-
}
|
|
57
|
-
byteIndex += byteLen;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Offsets at or past end-of-file map to the final position.
|
|
61
|
-
while (next < sorted.length) {
|
|
62
|
-
result.set(sorted[next], { line, column });
|
|
63
|
-
next++;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return result;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
39
|
/**
|
|
70
40
|
* Parse Biome severity to LSP DiagnosticSeverity.
|
|
71
41
|
*/
|
|
@@ -176,8 +146,6 @@ export class BiomeClient implements LinterClient {
|
|
|
176
146
|
* Parse Biome's JSON output into LSP Diagnostics.
|
|
177
147
|
*/
|
|
178
148
|
#parseJsonOutput(jsonOutput: string, targetFile: string): Diagnostic[] {
|
|
179
|
-
const diagnostics: Diagnostic[] = [];
|
|
180
|
-
|
|
181
149
|
let parsed: BiomeJsonOutput;
|
|
182
150
|
try {
|
|
183
151
|
parsed = JSON.parse(jsonOutput);
|
|
@@ -186,61 +154,36 @@ export class BiomeClient implements LinterClient {
|
|
|
186
154
|
cwd: this.cwd,
|
|
187
155
|
file: targetFile,
|
|
188
156
|
});
|
|
189
|
-
return
|
|
157
|
+
return [];
|
|
190
158
|
}
|
|
191
159
|
|
|
160
|
+
const emitted = parsed.diagnostics ?? [];
|
|
192
161
|
const target = path.resolve(targetFile);
|
|
193
|
-
const
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const location = diag.location;
|
|
199
|
-
if (!location?.path?.file) continue;
|
|
200
|
-
|
|
201
|
-
// Resolve file path
|
|
202
|
-
const diagFile = path.isAbsolute(location.path.file)
|
|
203
|
-
? location.path.file
|
|
204
|
-
: path.join(this.cwd, location.path.file);
|
|
205
|
-
|
|
206
|
-
// Only include diagnostics for the target file
|
|
207
|
-
if (path.resolve(diagFile) !== target) {
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
162
|
+
const diagnostics: Diagnostic[] = [];
|
|
163
|
+
// Biome's JSON reporter is experimental and may reshape its output in
|
|
164
|
+
// patch releases. Track whether any diagnostic carried a usable location
|
|
165
|
+
// so a schema drift surfaces as a warning instead of a silent empty list.
|
|
166
|
+
let sawUsableLocation = false;
|
|
210
167
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
}
|
|
168
|
+
for (const diag of emitted) {
|
|
169
|
+
const location = diag.location;
|
|
170
|
+
const filePath = location?.path;
|
|
171
|
+
if (!filePath) continue;
|
|
172
|
+
sawUsableLocation = true;
|
|
218
173
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
positionsBySource.set(source, offsetsToPositions(source, offsets));
|
|
222
|
-
}
|
|
174
|
+
// Biome reports paths relative to its cwd.
|
|
175
|
+
const diagFile = path.isAbsolute(filePath) ? filePath : path.join(this.cwd, filePath);
|
|
223
176
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
let startLine = 1;
|
|
227
|
-
let startColumn = 1;
|
|
228
|
-
let endLine = 1;
|
|
229
|
-
let endColumn = 1;
|
|
177
|
+
// Only include diagnostics for the target file.
|
|
178
|
+
if (path.resolve(diagFile) !== target) continue;
|
|
230
179
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
if (endPos) {
|
|
240
|
-
endLine = endPos.line;
|
|
241
|
-
endColumn = endPos.column;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
180
|
+
// Biome positions are 1-indexed; LSP ranges are 0-indexed.
|
|
181
|
+
const start = location.start;
|
|
182
|
+
const end = location.end ?? start;
|
|
183
|
+
const startLine = start?.line ?? 1;
|
|
184
|
+
const startColumn = start?.column ?? 1;
|
|
185
|
+
const endLine = end?.line ?? startLine;
|
|
186
|
+
const endColumn = end?.column ?? startColumn;
|
|
244
187
|
|
|
245
188
|
diagnostics.push({
|
|
246
189
|
range: {
|
|
@@ -248,12 +191,23 @@ export class BiomeClient implements LinterClient {
|
|
|
248
191
|
end: { line: endLine - 1, character: endColumn - 1 },
|
|
249
192
|
},
|
|
250
193
|
severity: parseSeverity(diag.severity),
|
|
251
|
-
message: diag.
|
|
194
|
+
message: diag.message,
|
|
252
195
|
source: "biome",
|
|
253
196
|
code: diag.category,
|
|
254
197
|
});
|
|
255
198
|
}
|
|
256
199
|
|
|
200
|
+
// Non-empty output whose diagnostics all lacked a recognizable location
|
|
201
|
+
// means the reporter schema changed out from under us — warn loudly
|
|
202
|
+
// instead of masking the regression as "no lint issues".
|
|
203
|
+
if (emitted.length > 0 && !sawUsableLocation) {
|
|
204
|
+
warnBiomeOnce(
|
|
205
|
+
`schema:${this.cwd}`,
|
|
206
|
+
"Biome diagnostics had no recognizable location; reporter schema may have changed",
|
|
207
|
+
{ cwd: this.cwd, file: targetFile, count: emitted.length },
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
257
211
|
return diagnostics;
|
|
258
212
|
}
|
|
259
213
|
|
package/src/lsp/servers.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getActiveClients,
|
|
5
5
|
getActiveOrPendingClient,
|
|
6
6
|
getOrCreateClient,
|
|
7
|
+
isRustAnalyzerClient,
|
|
7
8
|
type LspServerStatus,
|
|
8
9
|
notifySaved,
|
|
9
10
|
sendNotification,
|
|
@@ -256,17 +257,22 @@ export function isMethodNotFoundError(err: unknown): boolean {
|
|
|
256
257
|
|
|
257
258
|
export async function reloadServer(client: LspClient, serverName: string, signal?: AbortSignal): Promise<string> {
|
|
258
259
|
throwIfAborted(signal);
|
|
259
|
-
// rust-analyzer exposes a real reload request.
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// method
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
260
|
+
// rust-analyzer exposes a real reload request. Only rust-analyzer implements
|
|
261
|
+
// it, so gate the request on the binary (or registered name) rather than
|
|
262
|
+
// probing every server: some servers (Roslyn) crash the whole process on an
|
|
263
|
+
// unknown method instead of replying with method-not-found — killing the
|
|
264
|
+
// server `lsp reload` was meant to refresh (issue #8571, dotnet/roslyn#84890).
|
|
265
|
+
// A caller cancel or tool timeout must propagate, never be mistaken for an
|
|
266
|
+
// unsupported method and swallowed into a bogus "Restarted" (issue #6369).
|
|
267
|
+
if (isRustAnalyzerClient(client) || serverName === "rust-analyzer") {
|
|
268
|
+
try {
|
|
269
|
+
await sendRequest(client, "rust-analyzer/reloadWorkspace", undefined, signal);
|
|
270
|
+
return `Reloaded ${serverName}`;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
throwIfAborted(signal);
|
|
273
|
+
if (!isMethodNotFoundError(err)) throw err;
|
|
274
|
+
// Method not supported — fall through to the generic reload.
|
|
275
|
+
}
|
|
270
276
|
}
|
|
271
277
|
// workspace/didChangeConfiguration is a notification per spec; sending it
|
|
272
278
|
// as a request hangs until the tool deadline on servers that route it to
|
package/src/lsp/tool.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
ensureFileOpen,
|
|
22
22
|
getActiveClients,
|
|
23
23
|
getOrCreateClient,
|
|
24
|
+
isRustAnalyzerClient,
|
|
24
25
|
type LspServerStatus,
|
|
25
26
|
refreshFile,
|
|
26
27
|
sendNotification,
|
|
@@ -1075,10 +1076,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
|
|
1075
1076
|
try {
|
|
1076
1077
|
const client = await getOrCreateClient(serverConfig, this.session.cwd, undefined, signal);
|
|
1077
1078
|
const targetFile = resolvedFile;
|
|
1078
|
-
const isRustAnalyzerServer =
|
|
1079
|
-
serverName === "rust-analyzer" ||
|
|
1080
|
-
path.basename(serverConfig.command) === "rust-analyzer" ||
|
|
1081
|
-
(serverConfig.resolvedCommand ? path.basename(serverConfig.resolvedCommand) === "rust-analyzer" : false);
|
|
1079
|
+
const isRustAnalyzerServer = isRustAnalyzerClient(client) || serverName === "rust-analyzer";
|
|
1082
1080
|
const needsProjectIndex =
|
|
1083
1081
|
targetFile !== null && PROJECT_INDEXED_ACTIONS.has(action) && isProjectAwareLspServer(serverConfig);
|
|
1084
1082
|
const rustWorkspaceWait =
|
|
@@ -114,6 +114,17 @@ async function detectProjectType(cwd: string, signal?: AbortSignal): Promise<Pro
|
|
|
114
114
|
return { type: "unknown", description: "Unknown project type" };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/** Interpret an empty checker result without mistaking a crash for a clean workspace. */
|
|
118
|
+
export function interpretEmptyDiagnosticsResult(
|
|
119
|
+
exitCode: number,
|
|
120
|
+
signalCode: string | null,
|
|
121
|
+
command: readonly string[],
|
|
122
|
+
): string {
|
|
123
|
+
if (exitCode === 0) return "No issues found";
|
|
124
|
+
const detail = signalCode ? `was killed by ${signalCode}` : `exited with code ${exitCode}`;
|
|
125
|
+
return `Failed to run ${command.join(" ")}: the checker ${detail} without reporting anything, so the workspace was not verified`;
|
|
126
|
+
}
|
|
127
|
+
|
|
117
128
|
/** Run workspace diagnostics command and parse output */
|
|
118
129
|
export async function runWorkspaceDiagnostics(
|
|
119
130
|
cwd: string,
|
|
@@ -146,11 +157,22 @@ export async function runWorkspaceDiagnostics(
|
|
|
146
157
|
new Response(proc.stdout).text(),
|
|
147
158
|
new Response(proc.stderr).text(),
|
|
148
159
|
]);
|
|
149
|
-
await proc.exited;
|
|
160
|
+
const exitCode = await proc.exited;
|
|
150
161
|
throwIfAborted(signal);
|
|
151
162
|
const combined = (stdout + stderr).trim();
|
|
152
163
|
if (!combined) {
|
|
153
|
-
|
|
164
|
+
// A checker that exits non-zero without writing a single byte never
|
|
165
|
+
// inspected the workspace: it failed to start (missing toolchain),
|
|
166
|
+
// crashed, or was killed (OOM). Reporting "No issues found" there
|
|
167
|
+
// tells the agent the workspace is clean when nothing actually
|
|
168
|
+
// checked it. A non-zero exit *with* output is the normal way
|
|
169
|
+
// tsc/cargo/pyright report diagnostics and still falls through to
|
|
170
|
+
// the branch below. Mirrors the exit-status gate
|
|
171
|
+
// `resolveGoWorkspaceDiagnosticsCommand` already applies above.
|
|
172
|
+
return {
|
|
173
|
+
output: interpretEmptyDiagnosticsResult(exitCode, proc.signalCode, projectType.command),
|
|
174
|
+
projectType,
|
|
175
|
+
};
|
|
154
176
|
}
|
|
155
177
|
// Limit output length
|
|
156
178
|
const lines = combined.split("\n");
|