@oh-my-pi/pi-coding-agent 17.0.7 → 17.0.9

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 (153) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +5025 -4930
  3. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  4. package/dist/types/config/model-registry.d.ts +28 -1
  5. package/dist/types/config/model-resolver.d.ts +5 -1
  6. package/dist/types/config/settings-schema.d.ts +50 -3
  7. package/dist/types/dap/client.d.ts +19 -0
  8. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  9. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  10. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  11. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
  13. package/dist/types/extensibility/typebox.d.ts +4 -0
  14. package/dist/types/hindsight/client.d.ts +10 -0
  15. package/dist/types/hindsight/config.d.ts +8 -0
  16. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  17. package/dist/types/lsp/client.d.ts +2 -0
  18. package/dist/types/mcp/manager.d.ts +6 -2
  19. package/dist/types/mcp/render.d.ts +2 -2
  20. package/dist/types/mcp/tool-bridge.d.ts +16 -3
  21. package/dist/types/mcp/types.d.ts +6 -0
  22. package/dist/types/mnemopi/state.d.ts +2 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  24. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  25. package/dist/types/modes/components/user-message.d.ts +25 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  27. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  28. package/dist/types/modes/interactive-mode.d.ts +7 -1
  29. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  30. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  31. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  32. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  33. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  34. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  35. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  36. package/dist/types/modes/types.d.ts +14 -1
  37. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  38. package/dist/types/sdk.d.ts +1 -0
  39. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  40. package/dist/types/session/agent-session.d.ts +86 -3
  41. package/dist/types/session/session-loader.d.ts +6 -4
  42. package/dist/types/task/executor.d.ts +10 -0
  43. package/dist/types/task/index.d.ts +1 -0
  44. package/dist/types/task/prewalk.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +14 -0
  46. package/dist/types/tools/ask.d.ts +10 -0
  47. package/dist/types/tools/hub/jobs.d.ts +1 -1
  48. package/dist/types/tools/index.d.ts +3 -0
  49. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  50. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  51. package/dist/types/utils/changelog.d.ts +4 -6
  52. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  53. package/dist/types/web/search/types.d.ts +1 -1
  54. package/package.json +13 -13
  55. package/scripts/legacy-pi-virtual-module.ts +1 -1
  56. package/src/cli/grievances-cli.ts +2 -2
  57. package/src/cli/models-cli.ts +37 -17
  58. package/src/cli/update-cli.ts +14 -1
  59. package/src/cli/usage-cli.ts +1 -1
  60. package/src/config/__tests__/model-registry.test.ts +147 -0
  61. package/src/config/model-registry.ts +87 -24
  62. package/src/config/model-resolver.ts +56 -32
  63. package/src/config/settings-schema.ts +46 -3
  64. package/src/config/settings.ts +68 -5
  65. package/src/dap/client.ts +86 -7
  66. package/src/edit/diff.ts +4 -4
  67. package/src/eval/py/prelude.py +5 -5
  68. package/src/export/html/template.js +23 -9
  69. package/src/extensibility/extensions/runner.ts +9 -4
  70. package/src/extensibility/extensions/types.ts +3 -0
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  72. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
  74. package/src/extensibility/typebox.ts +22 -9
  75. package/src/hindsight/client.test.ts +42 -0
  76. package/src/hindsight/client.ts +40 -3
  77. package/src/hindsight/config.ts +18 -0
  78. package/src/internal-urls/registry-helpers.ts +40 -0
  79. package/src/launch/broker.ts +31 -5
  80. package/src/lsp/client.ts +23 -0
  81. package/src/lsp/clients/biome-client.ts +3 -4
  82. package/src/lsp/index.ts +26 -8
  83. package/src/main.ts +16 -5
  84. package/src/mcp/manager.ts +39 -8
  85. package/src/mcp/render.ts +94 -35
  86. package/src/mcp/tool-bridge.ts +116 -11
  87. package/src/mcp/types.ts +7 -0
  88. package/src/mnemopi/state.ts +70 -3
  89. package/src/modes/components/agent-dashboard.ts +6 -2
  90. package/src/modes/components/agent-hub.ts +26 -9
  91. package/src/modes/components/chat-transcript-builder.ts +13 -2
  92. package/src/modes/components/diff.ts +2 -2
  93. package/src/modes/components/oauth-selector.ts +24 -7
  94. package/src/modes/components/plan-review-overlay.ts +20 -4
  95. package/src/modes/components/settings-defs.ts +5 -2
  96. package/src/modes/components/settings-selector.ts +9 -5
  97. package/src/modes/components/tool-execution.test.ts +63 -2
  98. package/src/modes/components/user-message.ts +100 -1
  99. package/src/modes/controllers/command-controller.ts +36 -12
  100. package/src/modes/controllers/event-controller.ts +4 -1
  101. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  102. package/src/modes/controllers/input-controller.ts +47 -12
  103. package/src/modes/controllers/mcp-command-controller.ts +70 -40
  104. package/src/modes/controllers/selector-controller.ts +82 -6
  105. package/src/modes/interactive-mode.ts +39 -4
  106. package/src/modes/rpc/rpc-client.ts +94 -3
  107. package/src/modes/rpc/rpc-frame.ts +164 -4
  108. package/src/modes/rpc/rpc-messages.ts +127 -0
  109. package/src/modes/rpc/rpc-mode.ts +79 -7
  110. package/src/modes/rpc/rpc-types.ts +34 -2
  111. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  112. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  113. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  114. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  115. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  116. package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
  117. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  118. package/src/modes/types.ts +16 -3
  119. package/src/modes/utils/ui-helpers.ts +40 -20
  120. package/src/prompts/goals/guided-goal-system.md +24 -3
  121. package/src/prompts/system/workflow-notice.md +1 -1
  122. package/src/prompts/tools/task.md +12 -2
  123. package/src/sdk.ts +179 -51
  124. package/src/session/agent-session-error-log.test.ts +59 -0
  125. package/src/session/agent-session.ts +351 -46
  126. package/src/session/session-context.test.ts +224 -1
  127. package/src/session/session-context.ts +41 -2
  128. package/src/session/session-loader.ts +10 -5
  129. package/src/slash-commands/helpers/usage-report.ts +3 -1
  130. package/src/system-prompt.ts +1 -2
  131. package/src/task/executor.ts +44 -27
  132. package/src/task/index.ts +63 -8
  133. package/src/task/prewalk.ts +6 -0
  134. package/src/task/structured-subagent.ts +3 -1
  135. package/src/task/types.ts +16 -0
  136. package/src/task/worktree.ts +25 -11
  137. package/src/tools/ask.ts +15 -0
  138. package/src/tools/bash.ts +14 -6
  139. package/src/tools/browser.ts +1 -1
  140. package/src/tools/debug.ts +1 -1
  141. package/src/tools/eval.ts +4 -5
  142. package/src/tools/fetch.ts +1 -1
  143. package/src/tools/hub/index.ts +1 -1
  144. package/src/tools/hub/jobs.ts +67 -8
  145. package/src/tools/hub/launch.ts +7 -0
  146. package/src/tools/index.ts +3 -0
  147. package/src/tools/report-tool-issue.ts +79 -28
  148. package/src/tools/tool-timeouts.ts +10 -3
  149. package/src/tools/write.ts +31 -0
  150. package/src/utils/changelog.ts +54 -58
  151. package/src/utils/git.ts +57 -49
  152. package/src/web/search/providers/firecrawl.ts +46 -13
  153. package/src/web/search/types.ts +5 -1
@@ -17,7 +17,7 @@
17
17
  * like a small validator at runtime.
18
18
  */
19
19
 
20
- import { areJsonValuesEqual } from "@oh-my-pi/pi-ai/utils/schema";
20
+ import { areJsonValuesEqual, upgradeJsonSchemaTo202012, validateJsonSchemaValue } from "@oh-my-pi/pi-ai/utils/schema";
21
21
 
22
22
  // ---------------------------------------------------------------------------
23
23
  // Type aliases — exported so `import type { Static, TSchema } from "..."`
@@ -41,6 +41,8 @@ export type TOptional<_E extends ArkSchema> = ArkSchema;
41
41
  export type TUnion<_T extends readonly ArkSchema[] = readonly ArkSchema[]> = ArkSchema;
42
42
  export type TEnum<_T extends readonly (string | number)[] = readonly (string | number)[]> = ArkSchema;
43
43
  export type TRecord<_K extends ArkSchema, _V extends ArkSchema> = ArkSchema;
44
+ /** TypeBox-compatible wrapper for raw JSON Schema documents. */
45
+ export type TUnsafe<_T = unknown> = ArkSchema;
44
46
 
45
47
  // ---------------------------------------------------------------------------
46
48
  // ArkSchema wrapper — JSON Schema object with hidden validator metadata
@@ -560,18 +562,14 @@ function tNull(opts?: Meta): ArkSchema {
560
562
  return applyMeta(createArkSchema(validator, { type: "null" }), opts);
561
563
  }
562
564
 
565
+ const identityValidator = (data: unknown): unknown => data;
566
+
563
567
  function tAny(opts?: Meta): ArkSchema {
564
- return applyMeta(
565
- createArkSchema((data: unknown) => data, {}),
566
- opts,
567
- );
568
+ return applyMeta(createArkSchema(identityValidator, {}), opts);
568
569
  }
569
570
 
570
571
  function tUnknown(opts?: Meta): ArkSchema {
571
- return applyMeta(
572
- createArkSchema((data: unknown) => data, {}),
573
- opts,
574
- );
572
+ return applyMeta(createArkSchema(identityValidator, {}), opts);
575
573
  }
576
574
 
577
575
  function tNever(opts?: Meta): ArkSchema {
@@ -920,6 +918,21 @@ export const Type = {
920
918
  Null: tNull,
921
919
  Any: tAny,
922
920
  Unknown: tUnknown,
921
+ Unsafe<T = unknown>(jsonSchema: Record<string, unknown> = {}): TUnsafe<T> {
922
+ // Validate against the same draft-2020-12 upgrade the wire/tool-call
923
+ // path applies, so legacy draft-07 documents (tuple `items`, etc.)
924
+ // behave identically in `safeParse` and `validateToolArguments`.
925
+ const upgradedSchema = upgradeJsonSchemaTo202012(jsonSchema);
926
+ const validator = (data: unknown): unknown => {
927
+ const result = validateJsonSchemaValue(upgradedSchema, data);
928
+ if (result.success) return data;
929
+ const messages = result.issues.map(issue =>
930
+ issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message,
931
+ );
932
+ return validationFailure(messages.join("; ") || "Invalid value");
933
+ };
934
+ return createArkSchema(validator, jsonSchema);
935
+ },
923
936
  Never: tNever,
924
937
  Literal: tLiteral,
925
938
  Union: tUnion,
@@ -31,3 +31,45 @@ describe("HindsightApi fetch cancellation", () => {
31
31
  expect(requestSignal?.reason).toBe(caller.signal.reason);
32
32
  });
33
33
  });
34
+
35
+ describe("HindsightApi per-op timeouts", () => {
36
+ afterEach(() => {
37
+ vi.restoreAllMocks();
38
+ });
39
+
40
+ it("reports the effective per-op deadline in the timeout error", async () => {
41
+ vi.spyOn(globalThis, "fetch").mockImplementation(
42
+ Object.assign(
43
+ async (): Promise<Response> => {
44
+ throw new DOMException("The operation timed out.", "TimeoutError");
45
+ },
46
+ { preconnect: globalThis.fetch.preconnect },
47
+ ),
48
+ );
49
+
50
+ const client = new HindsightApi({
51
+ baseUrl: "https://hindsight.example",
52
+ timeouts: { reflect: 90_000, recall: 15_000 },
53
+ });
54
+
55
+ await expect(client.reflect("bank", "q")).rejects.toThrow("reflect request timed out after 90s");
56
+ await expect(client.recall("bank", "q")).rejects.toThrow("recall request timed out after 15s");
57
+ });
58
+
59
+ it("falls back to the client request default for ops without an override", async () => {
60
+ vi.spyOn(globalThis, "fetch").mockImplementation(
61
+ Object.assign(
62
+ async (): Promise<Response> => {
63
+ throw new DOMException("The operation timed out.", "TimeoutError");
64
+ },
65
+ { preconnect: globalThis.fetch.preconnect },
66
+ ),
67
+ );
68
+ const client = new HindsightApi({
69
+ baseUrl: "https://hindsight.example",
70
+ timeouts: { request: 45_000 },
71
+ });
72
+
73
+ await expect(client.createBank("bank")).rejects.toThrow("createBank request timed out after 45s");
74
+ });
75
+ });
@@ -13,17 +13,32 @@ import type { HindsightConfig } from "./config";
13
13
 
14
14
  const USER_AGENT = "oh-my-pi-coding-agent";
15
15
  const DEFAULT_USER_AGENT = USER_AGENT;
16
- const HINDSIGHT_REQUEST_TIMEOUT_MS = 30_000;
16
+ /** Fallback deadlines (ms) applied when the caller supplies no override. */
17
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
18
+ const DEFAULT_REFLECT_TIMEOUT_MS = 120_000;
19
+ const DEFAULT_RECALL_TIMEOUT_MS = 30_000;
20
+ const DEFAULT_RETAIN_TIMEOUT_MS = 60_000;
17
21
 
18
22
  export type Budget = "low" | "mid" | "high" | string;
19
23
  export type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
20
24
  export type UpdateMode = "replace" | "append";
21
25
  export type ConsolidationState = "failed" | "pending" | "done";
22
26
 
27
+ /** Per-operation request deadlines (ms). Each falls back to a built-in default. */
28
+ export interface HindsightTimeouts {
29
+ /** Default deadline for ops without a specific override. */
30
+ request?: number;
31
+ reflect?: number;
32
+ recall?: number;
33
+ retain?: number;
34
+ }
35
+
23
36
  export interface HindsightApiOptions {
24
37
  baseUrl: string;
25
38
  apiKey?: string;
26
39
  userAgent?: string;
40
+ /** Per-op deadlines; unset entries fall back to built-in defaults. */
41
+ timeouts?: HindsightTimeouts;
27
42
  }
28
43
 
29
44
  /** Caller cancellation shared by Hindsight request option bags. */
@@ -216,11 +231,17 @@ interface RequestOptions {
216
231
  /** Return null instead of throwing on a 404 response. */
217
232
  allow404?: boolean;
218
233
  signal?: AbortSignal;
234
+ /** Op deadline (ms); defaults to the client's request timeout. */
235
+ timeoutMs?: number;
219
236
  }
220
237
 
221
238
  export class HindsightApi {
222
239
  #baseUrl: string;
223
240
  #headers: Record<string, string>;
241
+ #reflectTimeoutMs: number;
242
+ #recallTimeoutMs: number;
243
+ #retainTimeoutMs: number;
244
+ #requestTimeoutMs: number;
224
245
 
225
246
  constructor(options: HindsightApiOptions) {
226
247
  this.#baseUrl = options.baseUrl.replace(/\/+$/, "");
@@ -231,6 +252,11 @@ export class HindsightApi {
231
252
  if (options.apiKey) {
232
253
  this.#headers.Authorization = `Bearer ${options.apiKey}`;
233
254
  }
255
+ const timeouts = options.timeouts;
256
+ this.#requestTimeoutMs = timeouts?.request ?? DEFAULT_REQUEST_TIMEOUT_MS;
257
+ this.#reflectTimeoutMs = timeouts?.reflect ?? DEFAULT_REFLECT_TIMEOUT_MS;
258
+ this.#recallTimeoutMs = timeouts?.recall ?? DEFAULT_RECALL_TIMEOUT_MS;
259
+ this.#retainTimeoutMs = timeouts?.retain ?? DEFAULT_RETAIN_TIMEOUT_MS;
234
260
  }
235
261
 
236
262
  async retain(bankId: string, content: string, options?: RetainOptions): Promise<RetainResponse> {
@@ -251,6 +277,7 @@ export class HindsightApi {
251
277
  {
252
278
  body: { items: [item], async: options?.async },
253
279
  signal: options?.signal,
280
+ timeoutMs: this.#retainTimeoutMs,
254
281
  },
255
282
  );
256
283
  }
@@ -282,6 +309,7 @@ export class HindsightApi {
282
309
  async: options?.async,
283
310
  },
284
311
  signal: options?.signal,
312
+ timeoutMs: this.#retainTimeoutMs,
285
313
  },
286
314
  );
287
315
  }
@@ -301,6 +329,7 @@ export class HindsightApi {
301
329
  tags_match: options?.tagsMatch,
302
330
  },
303
331
  signal: options?.signal,
332
+ timeoutMs: this.#recallTimeoutMs,
304
333
  },
305
334
  );
306
335
  }
@@ -319,6 +348,7 @@ export class HindsightApi {
319
348
  tags_match: options?.tagsMatch,
320
349
  },
321
350
  signal: options?.signal,
351
+ timeoutMs: this.#reflectTimeoutMs,
322
352
  },
323
353
  );
324
354
  }
@@ -506,10 +536,11 @@ export class HindsightApi {
506
536
  if (qs) url += `?${qs}`;
507
537
  }
508
538
 
539
+ const timeoutMs = opts?.timeoutMs ?? this.#requestTimeoutMs;
509
540
  const init: RequestInit = {
510
541
  method,
511
542
  headers: this.#headers,
512
- signal: withTimeoutSignal(HINDSIGHT_REQUEST_TIMEOUT_MS, opts?.signal),
543
+ signal: withTimeoutSignal(timeoutMs, opts?.signal),
513
544
  };
514
545
  if (opts?.body !== undefined) {
515
546
  init.body = JSON.stringify(pruneUndefined(opts.body));
@@ -520,7 +551,7 @@ export class HindsightApi {
520
551
  response = await fetch(url, init);
521
552
  } catch (err) {
522
553
  const message = isTimeoutError(err)
523
- ? `${operation} request timed out after 30s`
554
+ ? `${operation} request timed out after ${Math.round(timeoutMs / 1000)}s`
524
555
  : `${operation} request failed: ${err instanceof Error ? err.message : String(err)}`;
525
556
  throw new HindsightError(message, undefined, err);
526
557
  }
@@ -639,5 +670,11 @@ export function createHindsightClient(config: HindsightConfig & { hindsightApiUr
639
670
  baseUrl: config.hindsightApiUrl,
640
671
  apiKey: config.hindsightApiToken ?? undefined,
641
672
  userAgent: USER_AGENT,
673
+ timeouts: {
674
+ request: config.requestTimeoutMs,
675
+ reflect: config.reflectTimeoutMs,
676
+ recall: config.recallTimeoutMs,
677
+ retain: config.retainTimeoutMs,
678
+ },
642
679
  });
643
680
  }
@@ -42,6 +42,15 @@ export interface HindsightConfig {
42
42
 
43
43
  debug: boolean;
44
44
 
45
+ /** Default per-request client deadline (ms) for ops without a specific override. */
46
+ requestTimeoutMs: number;
47
+ /** Client deadline (ms) for reflect (agentic synthesis; costlier than a metadata fetch). */
48
+ reflectTimeoutMs: number;
49
+ /** Client deadline (ms) for recall. */
50
+ recallTimeoutMs: number;
51
+ /** Client deadline (ms) for retain / retainBatch. */
52
+ retainTimeoutMs: number;
53
+
45
54
  mentalModelsEnabled: boolean;
46
55
  mentalModelAutoSeed: boolean;
47
56
  mentalModelRefreshIntervalMs: number;
@@ -115,6 +124,10 @@ export function loadHindsightConfig(settings: Settings, env: NodeJS.ProcessEnv =
115
124
  const recallContextTurnsEnv = envInt(env.HINDSIGHT_RECALL_CONTEXT_TURNS);
116
125
  const recallMaxQueryCharsEnv = envInt(env.HINDSIGHT_RECALL_MAX_QUERY_CHARS);
117
126
  const retainEveryNTurnsEnv = envInt(env.HINDSIGHT_RETAIN_EVERY_N_TURNS);
127
+ const requestTimeoutMsEnv = envInt(env.HINDSIGHT_REQUEST_TIMEOUT_MS);
128
+ const reflectTimeoutMsEnv = envInt(env.HINDSIGHT_REFLECT_TIMEOUT_MS);
129
+ const recallTimeoutMsEnv = envInt(env.HINDSIGHT_RECALL_TIMEOUT_MS);
130
+ const retainTimeoutMsEnv = envInt(env.HINDSIGHT_RETAIN_TIMEOUT_MS);
118
131
 
119
132
  // Read from settings (each falls back to its schema default).
120
133
  const settingsRetainMode = pickRetainMode(settings.get("hindsight.retainMode"));
@@ -158,6 +171,11 @@ export function loadHindsightConfig(settings: Settings, env: NodeJS.ProcessEnv =
158
171
 
159
172
  debug: debugEnv ?? settings.get("hindsight.debug"),
160
173
 
174
+ requestTimeoutMs: requestTimeoutMsEnv ?? settings.get("hindsight.requestTimeoutMs"),
175
+ reflectTimeoutMs: reflectTimeoutMsEnv ?? settings.get("hindsight.reflectTimeoutMs"),
176
+ recallTimeoutMs: recallTimeoutMsEnv ?? settings.get("hindsight.recallTimeoutMs"),
177
+ retainTimeoutMs: retainTimeoutMsEnv ?? settings.get("hindsight.retainTimeoutMs"),
178
+
161
179
  mentalModelsEnabled: settings.get("hindsight.mentalModelsEnabled"),
162
180
  mentalModelAutoSeed: settings.get("hindsight.mentalModelAutoSeed"),
163
181
  mentalModelRefreshIntervalMs: settings.get("hindsight.mentalModelRefreshIntervalMs"),
@@ -90,3 +90,43 @@ export async function sessionFilesFromDisk(): Promise<Map<string, string>> {
90
90
  for (const dir of artifactsDirsFromRegistry()) await scan(dir, 0);
91
91
  return found;
92
92
  }
93
+
94
+ /**
95
+ * Availability half of the `history://` resolution semantics: true when a
96
+ * transcript for `agentId` can be served from a registered ref's live session
97
+ * or retained session file, or from an on-disk `.jsonl` under a known
98
+ * artifacts dir. Hint surfaces use this so they only advertise
99
+ * `history://<agentId>` links that `HistoryProtocolHandler` can actually
100
+ * resolve. A retained sessionFile path is verified on disk before it counts,
101
+ * and probing never throws: a stale path or unreadable artifacts subtree
102
+ * reads as unavailable instead of disturbing the caller's delivery path.
103
+ */
104
+ export async function hasResolvableTranscript(agentId: string): Promise<boolean> {
105
+ try {
106
+ const registry = AgentRegistry.global();
107
+ const lower = agentId.toLowerCase();
108
+ let ref = registry.get(agentId);
109
+ if (ref?.kind === "advisor") ref = undefined;
110
+ ref ??= registry.list().find(candidate => candidate.kind !== "advisor" && candidate.id.toLowerCase() === lower);
111
+ if (ref?.session) return true;
112
+ if (ref?.sessionFile && (await isReadableFile(ref.sessionFile))) return true;
113
+ const files = await sessionFilesFromDisk();
114
+ for (const id of files.keys()) {
115
+ if (id.toLowerCase() === lower) return true;
116
+ }
117
+ } catch {
118
+ // Availability probing is advisory; any filesystem failure means the
119
+ // transcript cannot be promised, never that delivery should fail.
120
+ }
121
+ return false;
122
+ }
123
+
124
+ /** True when `file` exists and is a regular file; never throws. */
125
+ async function isReadableFile(file: string): Promise<boolean> {
126
+ try {
127
+ const stat = await fs.stat(file);
128
+ return stat.isFile();
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
@@ -491,8 +491,17 @@ class DaemonBroker {
491
491
  await this.#launch(record);
492
492
  let readyTimedOut = false;
493
493
  if (spec.ready && !terminalState(record.snapshot.state)) {
494
- const ready = await this.#waitUntil(record, () => record.snapshot.state === "ready", spec.ready.timeoutMs);
495
- readyTimedOut = !ready && !terminalState(record.snapshot.state);
494
+ // Wake on the sticky readyAt marker or any terminal state, not the live
495
+ // state: a fast process flips starting→ready→exited within one poll
496
+ // interval, so sampling `state === "ready"` never observes readiness even
497
+ // though #markReady durably recorded readyAt. A pre-ready exit must also
498
+ // wake the wait rather than block for the full timeout.
499
+ const ready = await this.#waitUntil(
500
+ record,
501
+ () => record.snapshot.readyAt !== undefined || terminalState(record.snapshot.state),
502
+ spec.ready.timeoutMs,
503
+ );
504
+ readyTimedOut = !ready;
496
505
  }
497
506
  await record.persistQueue;
498
507
  return { op: "start", daemon: record.snapshot, readyTimedOut };
@@ -748,6 +757,11 @@ class DaemonBroker {
748
757
  const uptime = Date.now() - record.snapshot.startedAt;
749
758
  record.consecutiveFailures = uptime >= 30_000 ? 0 : record.consecutiveFailures + 1;
750
759
  record.snapshot.restartCount++;
760
+ // Readiness belongs to the exited generation; clear it before the backoff
761
+ // so start / for:"ready" waits don't treat a dead service as ready during
762
+ // the restart window (readyAt is re-set by #launch once the child is up).
763
+ record.snapshot.readyAt = undefined;
764
+ record.snapshot.readyMatch = undefined;
751
765
  record.snapshot.state = "restarting";
752
766
  const delay = Math.min(1_000 * 2 ** Math.min(record.consecutiveFailures, 5), RESTART_MAX_DELAY_MS);
753
767
  record.log?.append(
@@ -812,6 +826,12 @@ class DaemonBroker {
812
826
  throw new Error(`Invalid wait regex: ${error instanceof Error ? error.message : String(error)}`);
813
827
  }
814
828
  }
829
+ // Readiness was actually observed: the sticky readyAt survives a fast
830
+ // ready→exit, a live "ready" state, or a "running" daemon with no ready spec.
831
+ const readyObserved = (): boolean =>
832
+ record.snapshot.readyAt !== undefined ||
833
+ record.snapshot.state === "ready" ||
834
+ (record.snapshot.state === "running" && !record.spec.ready);
815
835
  const condition = (): boolean => {
816
836
  if (pattern) {
817
837
  const match = pattern.exec(record.readinessBuffer);
@@ -820,10 +840,16 @@ class DaemonBroker {
820
840
  return true;
821
841
  }
822
842
  if (operation.for === "exit") return terminalState(record.snapshot.state);
823
- return record.snapshot.state === "ready" || (record.snapshot.state === "running" && !record.spec.ready);
843
+ // Wake on observed readiness or any terminal state so the wait never
844
+ // blocks for the full timeout; success is judged by readyObserved below.
845
+ return readyObserved() || terminalState(record.snapshot.state);
824
846
  };
825
- const reached = condition() || (await this.#waitUntil(record, condition, operation.timeoutMs));
826
- return { op: "wait", daemon: record.snapshot, matched, timedOut: !reached };
847
+ const woke = condition() || (await this.#waitUntil(record, condition, operation.timeoutMs));
848
+ // A for:"ready" wait that woke on a terminal exit without ever observing
849
+ // readiness is still "not ready" — surface it as timed out so callers and the
850
+ // renderer don't chain work against a dead process.
851
+ const timedOut = operation.for === "ready" && !pattern ? !readyObserved() : !woke;
852
+ return { op: "wait", daemon: record.snapshot, matched, timedOut };
827
853
  }
828
854
 
829
855
  async #send(operation: Extract<DaemonOperation, { op: "send" }>): Promise<DaemonRpcResult> {
package/src/lsp/client.ts CHANGED
@@ -846,6 +846,29 @@ export async function getOrCreateClient(
846
846
  return clientPromise;
847
847
  }
848
848
 
849
+ /** Return an active or already-starting client without starting a language server. */
850
+ export async function getActiveOrPendingClient(
851
+ config: ServerConfig,
852
+ cwd: string,
853
+ signal?: AbortSignal,
854
+ ): Promise<LspClient | undefined> {
855
+ throwIfAborted(signal);
856
+ const client = clients.get(`${config.command}:${cwd}`);
857
+ if (client) {
858
+ client.lastActivity = Date.now();
859
+ return client;
860
+ }
861
+
862
+ const pending = clientLocks.get(`${config.command}:${cwd}`);
863
+ if (!pending) return undefined;
864
+ try {
865
+ return await untilAborted(signal, pending);
866
+ } catch {
867
+ throwIfAborted(signal);
868
+ return undefined;
869
+ }
870
+ }
871
+
849
872
  /**
850
873
  * Ensure a file is opened in the LSP client.
851
874
  * Sends didOpen notification if the file is not already tracked.
@@ -2,7 +2,7 @@
2
2
  * Biome CLI-based linter client.
3
3
  * Uses Biome's CLI with JSON output instead of LSP (which has stale diagnostics issues).
4
4
  */
5
- import path from "node:path";
5
+ import * as path from "node:path";
6
6
  import { logger } from "@oh-my-pi/pi-utils";
7
7
  import type { Diagnostic, DiagnosticSeverity, LinterClient, ServerConfig } from "../../lsp/types";
8
8
 
@@ -141,14 +141,13 @@ export class BiomeClient implements LinterClient {
141
141
  ) {}
142
142
 
143
143
  async format(filePath: string, content: string): Promise<string> {
144
- // Write content to file first
144
+ // Keep the standalone LinterClient contract: callers supply the content to
145
+ // format, regardless of what is currently on disk.
145
146
  await Bun.write(filePath, content);
146
147
 
147
- // Run biome format --write
148
148
  const result = await runBiome(["format", "--write", filePath], this.cwd, this.config.resolvedCommand);
149
149
 
150
150
  if (result.success) {
151
- // Read back formatted content
152
151
  return await Bun.file(filePath).text();
153
152
  }
154
153
 
package/src/lsp/index.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  ensureFileOpen,
21
21
  FileChangeType,
22
22
  getActiveClients,
23
+ getActiveOrPendingClient,
23
24
  getOrCreateClient,
24
25
  type LspServerStatus,
25
26
  notifySaved,
@@ -211,6 +212,7 @@ async function syncFileContent(
211
212
  cwd: string,
212
213
  servers: Array<[string, ServerConfig]>,
213
214
  signal?: AbortSignal,
215
+ createMissing = true,
214
216
  ): Promise<void> {
215
217
  throwIfAborted(signal);
216
218
  await Promise.allSettled(
@@ -219,11 +221,15 @@ async function syncFileContent(
219
221
  if (serverConfig.createClient) {
220
222
  return;
221
223
  }
222
- const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
224
+ const client = createMissing
225
+ ? await getOrCreateClient(serverConfig, cwd, undefined, signal)
226
+ : await getActiveOrPendingClient(serverConfig, cwd, signal);
227
+ if (!client) return;
223
228
  throwIfAborted(signal);
224
229
  await syncContent(client, absolutePath, content, signal);
225
230
  }),
226
231
  );
232
+ throwIfAborted(signal);
227
233
  }
228
234
 
229
235
  /**
@@ -239,6 +245,7 @@ async function notifyFileSaved(
239
245
  cwd: string,
240
246
  servers: Array<[string, ServerConfig]>,
241
247
  signal?: AbortSignal,
248
+ createMissing = true,
242
249
  ): Promise<void> {
243
250
  throwIfAborted(signal);
244
251
  await Promise.allSettled(
@@ -247,10 +254,14 @@ async function notifyFileSaved(
247
254
  if (serverConfig.createClient) {
248
255
  return;
249
256
  }
250
- const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
257
+ const client = createMissing
258
+ ? await getOrCreateClient(serverConfig, cwd, undefined, signal)
259
+ : await getActiveOrPendingClient(serverConfig, cwd, signal);
260
+ if (!client) return;
251
261
  await notifySaved(client, absolutePath, signal);
252
262
  }),
253
263
  );
264
+ throwIfAborted(signal);
254
265
  }
255
266
 
256
267
  // Cache config per cwd to avoid repeated file I/O
@@ -1333,7 +1344,8 @@ async function runLspWritethrough(
1333
1344
  // Capture diagnostic versions BEFORE syncing to detect stale diagnostics
1334
1345
  // Bound client creation by the writethrough budget: a hung/broken server
1335
1346
  // must not add its full init wait (30s default) to every edit.
1336
- const minVersions = enableDiagnostics ? await captureDiagnosticVersions(cwd, servers, 5_000, signal) : undefined;
1347
+ const minVersionsPromise = enableDiagnostics ? captureDiagnosticVersions(cwd, servers, 5_000, signal) : undefined;
1348
+ let minVersions = useCustomFormatter ? undefined : await minVersionsPromise;
1337
1349
  let expectedDocumentVersions: ServerVersionMap | undefined;
1338
1350
 
1339
1351
  let formatter: FileFormatResult | undefined;
@@ -1353,13 +1365,19 @@ async function runLspWritethrough(
1353
1365
  operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
1354
1366
  await untilAborted(operationSignal, async () => {
1355
1367
  if (useCustomFormatter) {
1356
- // Custom linters (e.g. Biome CLI) require on-disk input.
1368
+ // Custom linters operate on on-disk input; the shared pre-write also
1369
+ // supports implementations that inspect the file before formatting.
1357
1370
  await writeContent(content);
1358
- finalContent = await formatContent(dst, content, cwd, customLinterServers, operationSignal);
1371
+ const [formattedContent, capturedVersions] = await Promise.all([
1372
+ formatContent(dst, content, cwd, customLinterServers, operationSignal),
1373
+ minVersionsPromise,
1374
+ ]);
1375
+ finalContent = formattedContent;
1376
+ minVersions = capturedVersions;
1359
1377
  formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
1360
1378
  await writeContent(finalContent);
1361
1379
  await notifyWriteCommitted(operationSignal);
1362
- await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
1380
+ await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal, enableDiagnostics);
1363
1381
  } else {
1364
1382
  // 1. Sync original content to LSP servers
1365
1383
  await syncFileContent(dst, content, cwd, lspServers, operationSignal);
@@ -1385,7 +1403,7 @@ async function runLspWritethrough(
1385
1403
  }
1386
1404
 
1387
1405
  // 5. Notify saved to LSP servers
1388
- await notifyFileSaved(dst, cwd, lspServers, operationSignal);
1406
+ await notifyFileSaved(dst, cwd, lspServers, operationSignal, !useCustomFormatter || enableDiagnostics);
1389
1407
  });
1390
1408
  synced = true;
1391
1409
  } catch {
@@ -1565,7 +1583,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1565
1583
  _context?: AgentToolContext,
1566
1584
  ): Promise<AgentToolResult<LspToolDetails>> {
1567
1585
  const { action, file, line, symbol, query, new_name, apply, timeout } = params;
1568
- const timeoutSec = clampTimeout("lsp", timeout);
1586
+ const timeoutSec = clampTimeout("lsp", timeout, this.session.settings.get("tools.maxTimeout"));
1569
1587
  const timeoutSignal = AbortSignal.timeout(timeoutSec * 1000);
1570
1588
  const callerSignal = signal;
1571
1589
  signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
package/src/main.ts CHANGED
@@ -134,6 +134,7 @@ async function checkForNewVersion(currentVersion: string): Promise<string | unde
134
134
  // embedders need project-level opt-outs for reminder/prelude prompt injection.
135
135
  const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [
136
136
  "task.isolation.mode",
137
+ "task.isolation.apply",
137
138
  "task.isolation.merge",
138
139
  "task.isolation.commits",
139
140
  "task.eager",
@@ -882,6 +883,7 @@ export async function buildSessionOptions(
882
883
  cliProvider: parsed.provider,
883
884
  cliModel: parsed.model,
884
885
  modelRegistry,
886
+ availableModels: modelRegistry.getAvailable(),
885
887
  settings: activeSettings,
886
888
  preferences: modelMatchPreferences,
887
889
  });
@@ -954,13 +956,22 @@ export async function buildSessionOptions(
954
956
  if (resolved.warning) {
955
957
  process.stderr.write(`${chalk.yellow(`Warning: ${resolved.warning}`)}\n`);
956
958
  }
959
+ // Prewalk is an optional optimization (off by default): switch to a fast
960
+ // model at the first edit. If its hand-off target can't be resolved or has
961
+ // no configured auth, warn and leave prewalk unarmed rather than aborting
962
+ // startup and locking the user out of the app (issue #6064).
957
963
  if (resolved.error || !resolved.model) {
958
- throw new Error(resolved.error ?? `Model "${parsed.prewalkInto ?? DEFAULT_PREWALK_TARGET}" not found`);
959
- }
960
- if (!modelRegistry.hasConfiguredAuth(resolved.model)) {
961
- throw new Error(`No API key for ${resolved.model.provider}/${resolved.model.id}`);
964
+ const target = parsed.prewalkInto ?? DEFAULT_PREWALK_TARGET;
965
+ process.stderr.write(
966
+ `${chalk.yellow(`Warning: prewalk disabled — ${resolved.error ?? `model "${target}" not found`}`)}\n`,
967
+ );
968
+ } else if (!modelRegistry.hasConfiguredAuth(resolved.model)) {
969
+ process.stderr.write(
970
+ `${chalk.yellow(`Warning: prewalk disabled — no API key for ${resolved.model.provider}/${resolved.model.id}`)}\n`,
971
+ );
972
+ } else {
973
+ options.prewalk = { target: resolved.model, thinkingLevel: resolved.thinkingLevel };
962
974
  }
963
- options.prewalk = { target: resolved.model, thinkingLevel: resolved.thinkingLevel };
964
975
  }
965
976
 
966
977
  if (parsed.planYoloInto !== undefined && !parsed.planYolo) {