@oh-my-pi/pi-coding-agent 16.3.2 → 16.3.4

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.
Files changed (94) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/cli.js +3553 -3540
  3. package/dist/types/cli/update-cli.d.ts +1 -0
  4. package/dist/types/cli/update-cli.test.d.ts +1 -0
  5. package/dist/types/commands/update.d.ts +5 -0
  6. package/dist/types/config/config-file.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +10 -0
  8. package/dist/types/dap/client.d.ts +9 -1
  9. package/dist/types/edit/file-snapshot-store.d.ts +9 -1
  10. package/dist/types/edit/hashline/execute.d.ts +1 -1
  11. package/dist/types/edit/hashline/params.d.ts +3 -6
  12. package/dist/types/edit/renderer.d.ts +1 -0
  13. package/dist/types/extensibility/plugins/parser.d.ts +2 -1
  14. package/dist/types/hindsight/client.d.ts +15 -11
  15. package/dist/types/hindsight/client.test.d.ts +1 -0
  16. package/dist/types/mcp/smithery-registry.d.ts +1 -0
  17. package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
  18. package/dist/types/modes/components/advisor-message.d.ts +4 -2
  19. package/dist/types/modes/components/tool-execution.d.ts +9 -6
  20. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  21. package/dist/types/modes/theme/theme.d.ts +1 -1
  22. package/dist/types/session/indexed-session-storage.d.ts +2 -2
  23. package/dist/types/session/session-storage.d.ts +31 -3
  24. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
  25. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
  26. package/dist/types/ssh/connection-manager.d.ts +19 -0
  27. package/dist/types/ssh/sshfs-mount.d.ts +10 -1
  28. package/dist/types/subprocess/worker-client.d.ts +20 -6
  29. package/dist/types/tools/bash.d.ts +27 -0
  30. package/dist/types/tools/renderers.d.ts +13 -0
  31. package/dist/types/tools/ssh.d.ts +2 -0
  32. package/dist/types/utils/fetch-timeout.d.ts +4 -0
  33. package/dist/types/web/search/providers/base.d.ts +1 -0
  34. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  35. package/package.json +12 -12
  36. package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
  37. package/src/cli/models-cli.ts +19 -0
  38. package/src/cli/update-cli.test.ts +28 -0
  39. package/src/cli/update-cli.ts +35 -8
  40. package/src/cli/usage-cli.ts +34 -5
  41. package/src/commands/update.ts +8 -2
  42. package/src/config/config-file.ts +6 -6
  43. package/src/config/settings-schema.ts +10 -0
  44. package/src/dap/client.ts +134 -36
  45. package/src/edit/file-snapshot-store.ts +12 -1
  46. package/src/edit/hashline/diff.ts +4 -13
  47. package/src/edit/hashline/execute.ts +1 -1
  48. package/src/edit/hashline/params.ts +5 -12
  49. package/src/edit/renderer.ts +4 -2
  50. package/src/edit/streaming.ts +15 -5
  51. package/src/export/html/tool-views.generated.js +2 -2
  52. package/src/extensibility/plugins/installer.ts +12 -3
  53. package/src/extensibility/plugins/manager.ts +32 -8
  54. package/src/extensibility/plugins/parser.ts +7 -5
  55. package/src/extensibility/tool-event-input.ts +1 -1
  56. package/src/hindsight/client.test.ts +33 -0
  57. package/src/hindsight/client.ts +42 -22
  58. package/src/internal-urls/docs-index.generated.txt +1 -1
  59. package/src/main.ts +7 -1
  60. package/src/mcp/oauth-flow.ts +93 -4
  61. package/src/mcp/smithery-auth.ts +3 -0
  62. package/src/mcp/smithery-connect.ts +9 -0
  63. package/src/mcp/smithery-registry.test.ts +51 -0
  64. package/src/mcp/smithery-registry.ts +27 -4
  65. package/src/modes/components/advisor-message.ts +13 -10
  66. package/src/modes/components/status-line/component.ts +11 -4
  67. package/src/modes/components/tool-execution.ts +74 -8
  68. package/src/modes/components/transcript-container.ts +26 -0
  69. package/src/modes/controllers/event-controller.ts +7 -3
  70. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  71. package/src/modes/interactive-mode.ts +1 -0
  72. package/src/modes/rpc/rpc-client.ts +29 -16
  73. package/src/modes/theme/shimmer.ts +49 -15
  74. package/src/modes/theme/theme.ts +7 -0
  75. package/src/session/agent-session.ts +166 -30
  76. package/src/session/indexed-session-storage.ts +40 -3
  77. package/src/session/session-manager.ts +83 -14
  78. package/src/session/session-storage.ts +112 -26
  79. package/src/slash-commands/helpers/usage-report.ts +6 -1
  80. package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
  81. package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
  82. package/src/ssh/connection-manager.ts +44 -11
  83. package/src/ssh/sshfs-mount.ts +27 -4
  84. package/src/subprocess/worker-client.ts +161 -10
  85. package/src/tools/bash.ts +30 -1
  86. package/src/tools/grep.ts +21 -1
  87. package/src/tools/read.ts +14 -4
  88. package/src/tools/renderers.ts +13 -0
  89. package/src/tools/ssh.ts +8 -0
  90. package/src/utils/clipboard.ts +49 -12
  91. package/src/utils/fetch-timeout.ts +10 -0
  92. package/src/web/search/index.ts +8 -0
  93. package/src/web/search/providers/base.ts +1 -0
  94. package/src/web/search/providers/gemini.ts +23 -6
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.3.2",
4
+ "version": "16.3.4",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -56,17 +56,17 @@
56
56
  "@agentclientprotocol/sdk": "0.25.0",
57
57
  "@babel/parser": "^7.29.7",
58
58
  "@mozilla/readability": "^0.6.0",
59
- "@oh-my-pi/hashline": "16.3.2",
60
- "@oh-my-pi/omp-stats": "16.3.2",
61
- "@oh-my-pi/pi-agent-core": "16.3.2",
62
- "@oh-my-pi/pi-ai": "16.3.2",
63
- "@oh-my-pi/pi-catalog": "16.3.2",
64
- "@oh-my-pi/pi-mnemopi": "16.3.2",
65
- "@oh-my-pi/pi-natives": "16.3.2",
66
- "@oh-my-pi/pi-tui": "16.3.2",
67
- "@oh-my-pi/pi-utils": "16.3.2",
68
- "@oh-my-pi/pi-wire": "16.3.2",
69
- "@oh-my-pi/snapcompact": "16.3.2",
59
+ "@oh-my-pi/hashline": "16.3.4",
60
+ "@oh-my-pi/omp-stats": "16.3.4",
61
+ "@oh-my-pi/pi-agent-core": "16.3.4",
62
+ "@oh-my-pi/pi-ai": "16.3.4",
63
+ "@oh-my-pi/pi-catalog": "16.3.4",
64
+ "@oh-my-pi/pi-mnemopi": "16.3.4",
65
+ "@oh-my-pi/pi-natives": "16.3.4",
66
+ "@oh-my-pi/pi-tui": "16.3.4",
67
+ "@oh-my-pi/pi-utils": "16.3.4",
68
+ "@oh-my-pi/pi-wire": "16.3.4",
69
+ "@oh-my-pi/snapcompact": "16.3.4",
70
70
  "@opentelemetry/api": "^1.9.1",
71
71
  "@opentelemetry/context-async-hooks": "^2.7.1",
72
72
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -332,9 +332,15 @@ async function formatInPlace(targets: readonly string[]): Promise<void> {
332
332
  stdout: "pipe",
333
333
  stderr: "pipe",
334
334
  });
335
- const exit = await proc.exited;
335
+ // Drain both pipes concurrently with proc.exited to avoid a pipe-buffer
336
+ // deadlock — biome check can emit thousands of lines when it rewrites the
337
+ // generated registry, easily exceeding the ~64 KiB OS pipe buffer.
338
+ const [exit, , stderr] = await Promise.all([
339
+ proc.exited,
340
+ new Response(proc.stdout).text(),
341
+ new Response(proc.stderr).text(),
342
+ ]);
336
343
  if (exit !== 0) {
337
- const stderr = await new Response(proc.stderr).text();
338
344
  throw new Error(`biome check --write failed (exit ${exit}): ${stderr}`);
339
345
  }
340
346
  }
@@ -84,6 +84,14 @@ function writeLine(line = ""): void {
84
84
  process.stdout.write(`${line}\n`);
85
85
  }
86
86
 
87
+ function writeModelsConfigError(error: Error): void {
88
+ writeLine(chalk.yellow("Warning: models.yml validation failed — custom providers disabled"));
89
+ for (const line of error.message.split("\n")) {
90
+ writeLine(` ${line}`);
91
+ }
92
+ writeLine();
93
+ }
94
+
87
95
  function formatLimit(n: number | null): string {
88
96
  return n === null ? "-" : formatNumber(n);
89
97
  }
@@ -187,12 +195,23 @@ function renderProviderModels(
187
195
  }
188
196
  }
189
197
 
198
+ const configError = modelRegistry.getError();
199
+
190
200
  if (json) {
201
+ if (configError) {
202
+ process.stderr.write(
203
+ `Warning: models.yml validation failed — custom providers disabled\n${configError.message}\n`,
204
+ );
205
+ }
191
206
  const output: ModelsJson = { models: filtered.slice().sort(byProviderThenId).map(toModelJson) };
192
207
  writeLine(JSON.stringify(output));
193
208
  return;
194
209
  }
195
210
 
211
+ if (configError) {
212
+ writeModelsConfigError(configError);
213
+ }
214
+
196
215
  if (available.length === 0) {
197
216
  writeLine("No models available. Set API keys in environment variables.");
198
217
  return;
@@ -0,0 +1,28 @@
1
+ import { afterEach, describe, expect, it, vi } from "bun:test";
2
+ import { runUpdateCommand } from "./update-cli";
3
+
4
+ type FetchInput = string | URL | Request;
5
+ type FetchInit = RequestInit | BunFetchRequestInit;
6
+
7
+ describe("runUpdateCommand fetch cancellation", () => {
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ it("checks release metadata with a timeout signal", async () => {
13
+ let requestSignal: AbortSignal | undefined;
14
+ vi.spyOn(console, "log").mockImplementation(() => {});
15
+ const fetchStub = Object.assign(
16
+ async (_input: FetchInput, init?: FetchInit) => {
17
+ requestSignal = init?.signal ?? undefined;
18
+ return Response.json({ version: "999.0.0" });
19
+ },
20
+ { preconnect: globalThis.fetch.preconnect },
21
+ );
22
+ vi.spyOn(globalThis, "fetch").mockImplementation(fetchStub);
23
+
24
+ await runUpdateCommand({ force: false, check: true });
25
+
26
+ expect(requestSignal).toBeInstanceOf(AbortSignal);
27
+ });
28
+ });
@@ -12,6 +12,7 @@ import { $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
12
12
  import { $ } from "bun";
13
13
  import chalk from "chalk";
14
14
  import { theme } from "../modes/theme/theme";
15
+ import { isTimeoutError, withTimeoutSignal } from "../utils/fetch-timeout";
15
16
 
16
17
  const REPO = "can1357/oh-my-pi";
17
18
  const PACKAGE = "@oh-my-pi/pi-coding-agent";
@@ -29,6 +30,8 @@ const MISE_TOOL = "github:can1357/oh-my-pi";
29
30
  * See #1686.
30
31
  */
31
32
  const NPM_REGISTRY = "https://registry.npmjs.org/";
33
+ const RELEASE_METADATA_TIMEOUT_MS = 30_000;
34
+ const BINARY_DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
32
35
 
33
36
  /**
34
37
  * Core native addon package. Bumped in lock-step with {@link PACKAGE} so the
@@ -82,7 +85,7 @@ export interface BinaryReplacementOptions {
82
85
  * Parse update subcommand arguments.
83
86
  * Returns undefined if not an update command.
84
87
  */
85
- export function parseUpdateArgs(args: string[]): { force: boolean; check: boolean } | undefined {
88
+ export function parseUpdateArgs(args: string[]): { force: boolean; check: boolean; plugins: boolean } | undefined {
86
89
  if (args.length === 0 || args[0] !== "update") {
87
90
  return undefined;
88
91
  }
@@ -90,6 +93,7 @@ export function parseUpdateArgs(args: string[]): { force: boolean; check: boolea
90
93
  return {
91
94
  force: args.includes("--force") || args.includes("-f"),
92
95
  check: args.includes("--check") || args.includes("-c"),
96
+ plugins: args.includes("--plugins") || args.includes("-l"),
93
97
  };
94
98
  }
95
99
 
@@ -239,7 +243,17 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
239
243
  * Uses npm instead of GitHub API to avoid unauthenticated rate limiting.
240
244
  */
241
245
  async function getLatestRelease(): Promise<ReleaseInfo> {
242
- const response = await fetch(`${NPM_REGISTRY}${PACKAGE}/latest`);
246
+ let response: Response;
247
+ try {
248
+ response = await fetch(`${NPM_REGISTRY}${PACKAGE}/latest`, {
249
+ signal: withTimeoutSignal(RELEASE_METADATA_TIMEOUT_MS),
250
+ });
251
+ } catch (err) {
252
+ if (isTimeoutError(err)) {
253
+ throw new Error("Timed out fetching release info after 30s", { cause: err });
254
+ }
255
+ throw err;
256
+ }
243
257
  if (!response.ok) {
244
258
  throw new Error(`Failed to fetch release info: ${response.statusText}`);
245
259
  }
@@ -832,7 +846,18 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
832
846
  const backupPath = `${targetPath}.${Date.now()}.${process.pid}.bak`;
833
847
  console.log(chalk.dim(`Downloading ${binaryName}…`));
834
848
 
835
- const response = await fetch(url, { redirect: "follow" });
849
+ let response: Response;
850
+ try {
851
+ response = await fetch(url, {
852
+ redirect: "follow",
853
+ signal: withTimeoutSignal(BINARY_DOWNLOAD_TIMEOUT_MS),
854
+ });
855
+ } catch (err) {
856
+ if (isTimeoutError(err)) {
857
+ throw new Error("Timed out downloading release binary after 15 minutes", { cause: err });
858
+ }
859
+ throw err;
860
+ }
836
861
  if (!response.ok || !response.body) {
837
862
  throw new Error(`Download failed: ${response.statusText}`);
838
863
  }
@@ -914,12 +939,14 @@ ${chalk.bold("Usage:")}
914
939
  ${APP_NAME} update [options]
915
940
 
916
941
  ${chalk.bold("Options:")}
917
- -c, --check Check for updates without installing
918
- -f, --force Force reinstall even if up to date
942
+ -c, --check Check for updates without installing
943
+ -f, --force Force reinstall even if up to date
944
+ -l, --plugins Update installed plugins
919
945
 
920
946
  ${chalk.bold("Examples:")}
921
- ${APP_NAME} update Update to latest version
922
- ${APP_NAME} update --check Check if updates are available
923
- ${APP_NAME} update --force Force reinstall
947
+ ${APP_NAME} update Update to latest version
948
+ ${APP_NAME} update --check Check if updates are available
949
+ ${APP_NAME} update --force Force reinstall
950
+ ${APP_NAME} update -l Update installed plugins
924
951
  `);
925
952
  }
@@ -370,6 +370,29 @@ function formatLimitLine(limit: UsageLimit, labelWidth: number, nowMs: number):
370
370
  return lines;
371
371
  }
372
372
 
373
+ interface ProviderLimitTemplate {
374
+ id: string;
375
+ title: string;
376
+ }
377
+
378
+ function collectProviderLimitTemplates(reports: UsageReport[]): ProviderLimitTemplate[] {
379
+ const seen = new Set<string>();
380
+ const templates: ProviderLimitTemplate[] = [];
381
+ for (const report of reports) {
382
+ for (const limit of report.limits) {
383
+ if (seen.has(limit.id)) continue;
384
+ seen.add(limit.id);
385
+ templates.push({ id: limit.id, title: limitTitle(limit) });
386
+ }
387
+ }
388
+ return templates;
389
+ }
390
+
391
+ function formatMissingLimitLine(template: ProviderLimitTemplate, labelWidth: number): string {
392
+ const padded = template.title.padEnd(labelWidth);
393
+ return ` ${chalk.dim("○")} ${padded} ${chalk.dim("·".repeat(BAR_WIDTH))} ${chalk.dim("not reported")}`;
394
+ }
395
+
373
396
  /** Per-window capacity stat: how much account quota is burned and left. */
374
397
  export interface ProviderWindowStat {
375
398
  /** Compact window label, e.g. "5h", "7d". */
@@ -474,9 +497,8 @@ export function formatUsageBreakdown(
474
497
  for (const note of providerNotes)
475
498
  lines.push(` ${chalk.dim(sanitizeText(note.replace(/[\r\n]+/g, " ").replace(/\t/g, " ")))}`);
476
499
 
477
- const labelWidth = providerReports
478
- .flatMap(report => report.limits)
479
- .reduce((max, limit) => Math.max(max, limitTitle(limit).length), 0);
500
+ const providerLimitTemplates = collectProviderLimitTemplates(providerReports);
501
+ const labelWidth = providerLimitTemplates.reduce((max, template) => Math.max(max, template.title.length), 0);
480
502
 
481
503
  providerReports.forEach((report, index) => {
482
504
  lines.push(` ${formatAccountHeader(report, index, nowMs, redaction)}`);
@@ -484,8 +506,15 @@ export function formatUsageBreakdown(
484
506
  lines.push(` ${chalk.dim("no limits reported")}`);
485
507
  return;
486
508
  }
487
- for (const limit of report.limits) {
488
- lines.push(...formatLimitLine(limit, labelWidth, nowMs));
509
+ const limitsById = new Map<string, UsageLimit>();
510
+ for (const limit of report.limits) limitsById.set(limit.id, limit);
511
+ for (const template of providerLimitTemplates) {
512
+ const limit = limitsById.get(template.id);
513
+ if (limit) {
514
+ lines.push(...formatLimitLine(limit, labelWidth, nowMs));
515
+ } else {
516
+ lines.push(formatMissingLimitLine(template, labelWidth));
517
+ }
489
518
  }
490
519
  });
491
520
 
@@ -2,7 +2,8 @@
2
2
  * Check for and install updates.
3
3
  */
4
4
  import { Command, Flags } from "@oh-my-pi/pi-utils/cli";
5
- import { runUpdateCommand } from "../cli/update-cli";
5
+ import * as pluginCli from "../cli/plugin-cli";
6
+ import * as updateCli from "../cli/update-cli";
6
7
  import { initTheme } from "../modes/theme/theme";
7
8
 
8
9
  export default class Update extends Command {
@@ -11,11 +12,16 @@ export default class Update extends Command {
11
12
  static flags = {
12
13
  force: Flags.boolean({ char: "f", description: "Force update", default: false }),
13
14
  check: Flags.boolean({ char: "c", description: "Check for updates without installing", default: false }),
15
+ plugins: Flags.boolean({ char: "l", description: "Update installed plugins", default: false }),
14
16
  };
15
17
 
16
18
  async run(): Promise<void> {
17
19
  const { flags } = await this.parse(Update);
18
20
  await initTheme();
19
- await runUpdateCommand({ force: flags.force, check: flags.check });
21
+ if (flags.plugins) {
22
+ await pluginCli.runPluginCommand({ action: "upgrade", args: [], flags: {} });
23
+ } else {
24
+ await updateCli.runUpdateCommand({ force: flags.force, check: flags.check });
25
+ }
20
26
  }
21
27
  }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
4
- import type { Type } from "arktype";
4
+ import { ArkErrors, type Type } from "arktype";
5
5
  import { JSONC, YAML } from "bun";
6
6
 
7
7
  /** Minimal subset of the AJV ConfigSchemaError shape this module actually relies on. */
@@ -220,11 +220,11 @@ export class ConfigFile<T> implements IConfigFile<T> {
220
220
  }
221
221
 
222
222
  const checked = this.schema(parsed);
223
- if (checked instanceof Error) {
224
- const schemaErrors: ConfigSchemaError[] = [];
225
- // arktype errors are Error instances with a message property
226
- // Extract the error message as a single schema error
227
- schemaErrors.push({ instancePath: "root", message: checked.message });
223
+ if (checked instanceof ArkErrors) {
224
+ const schemaErrors: ConfigSchemaError[] = checked.map(error => ({
225
+ instancePath: error.path.length === 0 ? "root" : error.path.join("."),
226
+ message: error.problem,
227
+ }));
228
228
  const error = new ConfigError(this.id, schemaErrors);
229
229
  logger.warn("Failed to parse config file", { path: this.path(), error });
230
230
  return this.#storeCache({ error, status: "error" });
@@ -4337,6 +4337,16 @@ export const SETTINGS_SCHEMA = {
4337
4337
  description: "Providers that web_search should never use, even as fallbacks",
4338
4338
  },
4339
4339
  },
4340
+ "providers.webSearchGeminiModel": {
4341
+ type: "string",
4342
+ default: undefined,
4343
+ ui: {
4344
+ tab: "providers",
4345
+ group: "Services",
4346
+ label: "Gemini web_search model",
4347
+ description: "Model ID for Gemini Google Search grounding. Defaults to gemini-2.5-flash.",
4348
+ },
4349
+ },
4340
4350
  "providers.antigravityEndpoint": {
4341
4351
  type: "enum",
4342
4352
  values: ["auto", "production", "sandbox"] as const,
package/src/dap/client.ts CHANGED
@@ -17,6 +17,14 @@ import type {
17
17
  interface DapSpawnOptions {
18
18
  adapter: DapResolvedAdapter;
19
19
  cwd: string;
20
+ /**
21
+ * Cap on how long the socket-mode helpers wait for the adapter to open its
22
+ * socket (unix) or dial back into our listener (TCP). Exposed for tests;
23
+ * production callers rely on the default.
24
+ *
25
+ * @internal
26
+ */
27
+ socketReadyTimeoutMs?: number;
20
28
  }
21
29
 
22
30
  /** Minimal write interface shared by Bun.FileSink and Bun TCP sockets. */
@@ -29,13 +37,14 @@ type DapEventHandler = (body: unknown, event: DapEventMessage) => void | Promise
29
37
  type DapReverseRequestHandler = (args: unknown) => unknown | Promise<unknown>;
30
38
 
31
39
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
32
-
33
- async function writeMessage(sink: DapWriteSink, message: DapRequestMessage | DapResponseMessage): Promise<void> {
34
- const content = JSON.stringify(message);
35
- sink.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n`);
36
- sink.write(content);
37
- await sink.flush();
38
- }
40
+ /**
41
+ * Hard cap on a single message write. A wedged adapter stdin used to hang the
42
+ * whole client forever; on hitting this cap the client disposes itself so the
43
+ * next request fails fast instead of piling more work onto a broken adapter.
44
+ */
45
+ const WRITE_MESSAGE_TIMEOUT_MS = 30_000;
46
+ /** Default wait for socket-mode adapters to become reachable. */
47
+ const SOCKET_READY_TIMEOUT_MS = 10_000;
39
48
 
40
49
  function toErrorMessage(value: unknown): string {
41
50
  if (value instanceof Error) return value.message;
@@ -62,6 +71,8 @@ export class DapClient {
62
71
  #eventHandlers = new Map<string, Set<DapEventHandler>>();
63
72
  #anyEventHandlers = new Set<DapEventHandler>();
64
73
  #reverseRequestHandlers = new Map<string, DapReverseRequestHandler>();
74
+ #adapterExited = false;
75
+ #pendingWriteExitRejectors = new Set<() => void>();
65
76
 
66
77
  constructor(
67
78
  adapter: DapResolvedAdapter,
@@ -75,11 +86,15 @@ export class DapClient {
75
86
  this.#readable = options?.readable ?? (proc.stdout as ReadableStream<Uint8Array>);
76
87
  this.#writeSink = options?.writeSink ?? proc.stdin;
77
88
  this.#socket = options?.socket;
89
+ this.proc.exited.then(
90
+ () => this.#rejectPendingWritesForExit(),
91
+ () => this.#rejectPendingWritesForExit(),
92
+ );
78
93
  }
79
94
 
80
- static async spawn({ adapter, cwd }: DapSpawnOptions): Promise<DapClient> {
95
+ static async spawn({ adapter, cwd, socketReadyTimeoutMs }: DapSpawnOptions): Promise<DapClient> {
81
96
  if (adapter.connectMode === "socket") {
82
- return DapClient.#spawnSocket({ adapter, cwd });
97
+ return DapClient.#spawnSocket({ adapter, cwd, socketReadyTimeoutMs });
83
98
  }
84
99
  // Merge non-interactive env and start in a new session (detached → setsid)
85
100
  // so the adapter process tree has no controlling terminal. Without this,
@@ -108,17 +123,18 @@ export class DapClient {
108
123
  * Linux: connect to a unix domain socket via --listen=unix:<path>
109
124
  * macOS/other: the adapter dials into our TCP listener via --client-addr
110
125
  */
111
- static async #spawnSocket({ adapter, cwd }: DapSpawnOptions): Promise<DapClient> {
126
+ static async #spawnSocket({ adapter, cwd, socketReadyTimeoutMs }: DapSpawnOptions): Promise<DapClient> {
112
127
  const env = {
113
128
  ...Bun.env,
114
129
  ...NON_INTERACTIVE_ENV,
115
130
  };
131
+ const timeoutMs = socketReadyTimeoutMs ?? SOCKET_READY_TIMEOUT_MS;
116
132
  const isLinux = process.platform === "linux";
117
133
 
118
134
  if (isLinux) {
119
- return DapClient.#spawnSocketUnix({ adapter, cwd, env });
135
+ return DapClient.#spawnSocketUnix({ adapter, cwd, env, timeoutMs });
120
136
  }
121
- return DapClient.#spawnSocketClientAddr({ adapter, cwd, env });
137
+ return DapClient.#spawnSocketClientAddr({ adapter, cwd, env, timeoutMs });
122
138
  }
123
139
 
124
140
  /** Linux: spawn adapter with --listen=unix:<path>, then connect to the socket. */
@@ -126,10 +142,12 @@ export class DapClient {
126
142
  adapter,
127
143
  cwd,
128
144
  env,
145
+ timeoutMs,
129
146
  }: {
130
147
  adapter: DapResolvedAdapter;
131
148
  cwd: string;
132
149
  env: Record<string, string | undefined>;
150
+ timeoutMs: number;
133
151
  }): Promise<DapClient> {
134
152
  const socketPath = `/tmp/dap-${adapter.name}-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`;
135
153
  const proc = ptree.spawn([adapter.resolvedCommand, ...adapter.args, `--listen=unix:${socketPath}`], {
@@ -139,13 +157,23 @@ export class DapClient {
139
157
  detached: true,
140
158
  });
141
159
 
142
- await waitForCondition(() => isUnixSocketReady(socketPath), 10_000, proc);
143
-
144
- const { readable, writeSink, socket } = await connectSocket({ unix: socketPath });
145
- const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
146
- proc.exited.then(() => client.#handleProcessExit());
147
- void client.#startMessageReader();
148
- return client;
160
+ // If waitForCondition throws (timeout, or adapter exited early) or the
161
+ // socket connect fails, we must not leak the detached adapter process.
162
+ try {
163
+ await waitForCondition(() => isUnixSocketReady(socketPath), timeoutMs, proc);
164
+ const { readable, writeSink, socket } = await connectSocket({ unix: socketPath });
165
+ const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
166
+ proc.exited.then(() => client.#handleProcessExit());
167
+ void client.#startMessageReader();
168
+ return client;
169
+ } catch (error) {
170
+ try {
171
+ proc.kill();
172
+ } catch {
173
+ /* proc may already be dead */
174
+ }
175
+ throw error;
176
+ }
149
177
  }
150
178
 
151
179
  /** macOS/other: listen on a random TCP port, spawn adapter with --client-addr, accept connection. */
@@ -153,10 +181,12 @@ export class DapClient {
153
181
  adapter,
154
182
  cwd,
155
183
  env,
184
+ timeoutMs,
156
185
  }: {
157
186
  adapter: DapResolvedAdapter;
158
187
  cwd: string;
159
188
  env: Record<string, string | undefined>;
189
+ timeoutMs: number;
160
190
  }): Promise<DapClient> {
161
191
  const { promise: connPromise, resolve: resolveConn } = Promise.withResolvers<Bun.Socket<undefined>>();
162
192
 
@@ -182,25 +212,32 @@ export class DapClient {
182
212
  detached: true,
183
213
  });
184
214
 
185
- // Wait for dlv to connect (with timeout)
186
- let rawSocket: Bun.Socket<undefined>;
215
+ // Wait for the adapter to dial back. On timeout (or any other failure
216
+ // before we've wired up the client) kill `proc` — otherwise the detached
217
+ // adapter process is orphaned.
187
218
  const { promise: timeoutPromise, reject: rejectTimeout } = Promise.withResolvers<never>();
188
219
  const connectTimeout = setTimeout(
189
- () => rejectTimeout(new Error(`${adapter.name} did not connect within 10s`)),
190
- 10_000,
220
+ () => rejectTimeout(new Error(`${adapter.name} did not connect within ${timeoutMs}ms`)),
221
+ timeoutMs,
191
222
  );
192
223
  try {
193
- rawSocket = await Promise.race([connPromise, timeoutPromise]);
224
+ const rawSocket = await Promise.race([connPromise, timeoutPromise]);
225
+ const { readable, writeSink, socket } = wrapBunSocket(rawSocket);
226
+ const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
227
+ proc.exited.then(() => client.#handleProcessExit());
228
+ void client.#startMessageReader();
229
+ return client;
230
+ } catch (error) {
231
+ try {
232
+ proc.kill();
233
+ } catch {
234
+ /* proc may already be dead */
235
+ }
236
+ throw error;
194
237
  } finally {
195
238
  clearTimeout(connectTimeout);
196
239
  server.stop();
197
240
  }
198
-
199
- const { readable, writeSink, socket } = wrapBunSocket(rawSocket);
200
- const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
201
- proc.exited.then(() => client.#handleProcessExit());
202
- void client.#startMessageReader();
203
- return client;
204
241
  }
205
242
 
206
243
  get capabilities(): DapCapabilities | undefined {
@@ -309,6 +346,12 @@ export class DapClient {
309
346
  arguments: args,
310
347
  };
311
348
  const { promise, resolve, reject } = Promise.withResolvers<TBody>();
349
+ // Suppress "unhandled rejection" if the request timer or abort fires
350
+ // before the caller's `await` subscribes — e.g. while #writeMessage is
351
+ // still racing a wedged stdin flush. The caller's own `await` still
352
+ // receives the rejection normally; this handler is a passive guard.
353
+ promise.catch(() => {});
354
+
312
355
  let timeout: NodeJS.Timeout | undefined;
313
356
  const cleanup = () => {
314
357
  if (timeout) clearTimeout(timeout);
@@ -342,13 +385,15 @@ export class DapClient {
342
385
  },
343
386
  });
344
387
  this.#lastActivity = Date.now();
345
- try {
346
- await writeMessage(this.#writeSink, request);
347
- } catch (error) {
388
+ // Fire the write in the background. Awaiting it here would let a wedged
389
+ // stdin flush block the caller's `timeoutMs`; if it fails, propagate the
390
+ // failure into `promise` — the timer or abort may still win the race.
391
+ void this.#writeMessage(request).catch(error => {
392
+ if (!this.#pendingRequests.has(requestSeq)) return;
348
393
  this.#pendingRequests.delete(requestSeq);
349
394
  cleanup();
350
- throw error;
351
- }
395
+ reject(error);
396
+ });
352
397
  return promise;
353
398
  }
354
399
 
@@ -362,7 +407,60 @@ export class DapClient {
362
407
  ...(message ? { message } : {}),
363
408
  ...(body !== undefined ? { body } : {}),
364
409
  };
365
- await writeMessage(this.#writeSink, response);
410
+ await this.#writeMessage(response);
411
+ }
412
+
413
+ /**
414
+ * Framed write to the adapter, bounded by {@link WRITE_MESSAGE_TIMEOUT_MS}
415
+ * and by adapter exit. Without this bound a wedged adapter stdin used to
416
+ * hang the whole client forever. On timeout or exit-before-flush the client
417
+ * disposes itself and rethrows.
418
+ */
419
+ async #writeMessage(message: DapRequestMessage | DapResponseMessage): Promise<void> {
420
+ const content = JSON.stringify(message);
421
+ this.#writeSink.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n`);
422
+ this.#writeSink.write(content);
423
+ const flushResult = this.#writeSink.flush();
424
+ if (!(flushResult instanceof Promise)) return;
425
+
426
+ if (this.#adapterExited) {
427
+ throw new Error(`DAP adapter ${this.adapter.name} exited before write completed`);
428
+ }
429
+
430
+ const { promise: guardPromise, reject: guardReject, resolve: guardResolve } = Promise.withResolvers<void>();
431
+ const timer = setTimeout(
432
+ () =>
433
+ guardReject(
434
+ new Error(`DAP adapter ${this.adapter.name} write timed out after ${WRITE_MESSAGE_TIMEOUT_MS}ms`),
435
+ ),
436
+ WRITE_MESSAGE_TIMEOUT_MS,
437
+ );
438
+ const rejectOnExit = () => {
439
+ guardReject(new Error(`DAP adapter ${this.adapter.name} exited before write completed`));
440
+ };
441
+ this.#pendingWriteExitRejectors.add(rejectOnExit);
442
+
443
+ try {
444
+ await Promise.race([flushResult, guardPromise]);
445
+ } catch (error) {
446
+ // The client is now known-broken. Kick off dispose in the background;
447
+ // callers will see subsequent sendRequest calls fail fast.
448
+ void this.dispose();
449
+ throw error;
450
+ } finally {
451
+ clearTimeout(timer);
452
+ this.#pendingWriteExitRejectors.delete(rejectOnExit);
453
+ // Release the guard so any late timeout callback becomes a no-op.
454
+ guardResolve();
455
+ }
456
+ }
457
+
458
+ #rejectPendingWritesForExit(): void {
459
+ this.#adapterExited = true;
460
+ for (const reject of this.#pendingWriteExitRejectors) {
461
+ reject();
462
+ }
463
+ this.#pendingWriteExitRejectors.clear();
366
464
  }
367
465
 
368
466
  async dispose(): Promise<void> {
@@ -132,12 +132,23 @@ export function recordSeenLines(
132
132
  * can reject edits anchored on lines the model never saw. Best-effort: a no-op
133
133
  * when the body has no numbered rows or the snapshot already aged out. `tag`
134
134
  * must be the tag returned when this exact content was recorded.
135
+ *
136
+ * `excludedLines` prunes 1-indexed line numbers whose displayed text was
137
+ * column-truncated (or otherwise not shown in full). A column-clipped row
138
+ * still carries a `NN:` prefix — the parser sees the number and would
139
+ * otherwise mark the line "seen" even though only its prefix ever reached
140
+ * the model. Producers that apply per-line column truncation MUST supply
141
+ * the clipped line set so the patcher's seen-line guard keeps rejecting
142
+ * edits against those lines until a full-width read of them occurs.
135
143
  */
136
144
  export function recordSeenLinesFromBody(
137
145
  session: FileSnapshotStoreOwner,
138
146
  absolutePath: string,
139
147
  tag: string,
140
148
  body: string,
149
+ excludedLines?: ReadonlySet<number>,
141
150
  ): void {
142
- recordSeenLines(session, absolutePath, tag, parseSeenLinesFromHashlineBody(body));
151
+ const parsed = parseSeenLinesFromHashlineBody(body);
152
+ const filtered = excludedLines && excludedLines.size > 0 ? parsed.filter(line => !excludedLines.has(line)) : parsed;
153
+ recordSeenLines(session, absolutePath, tag, filtered);
143
154
  }