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

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 (82) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/cli.js +4967 -4948
  3. package/dist/types/config/model-registry.d.ts +1 -1
  4. package/dist/types/config/model-resolver.d.ts +5 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -0
  6. package/dist/types/dap/client.d.ts +19 -0
  7. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  8. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  9. package/dist/types/extensibility/typebox.d.ts +4 -0
  10. package/dist/types/hindsight/client.d.ts +10 -0
  11. package/dist/types/hindsight/config.d.ts +8 -0
  12. package/dist/types/mcp/tool-bridge.d.ts +9 -0
  13. package/dist/types/mnemopi/state.d.ts +2 -0
  14. package/dist/types/modes/components/user-message.d.ts +25 -1
  15. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  16. package/dist/types/modes/interactive-mode.d.ts +7 -1
  17. package/dist/types/modes/types.d.ts +14 -1
  18. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  19. package/dist/types/sdk.d.ts +1 -0
  20. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  21. package/dist/types/session/agent-session.d.ts +78 -2
  22. package/dist/types/task/executor.d.ts +10 -0
  23. package/dist/types/task/index.d.ts +1 -0
  24. package/dist/types/task/prewalk.d.ts +3 -0
  25. package/dist/types/tools/ask.d.ts +10 -0
  26. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  27. package/dist/types/utils/changelog.d.ts +4 -6
  28. package/package.json +12 -13
  29. package/src/cli/models-cli.ts +37 -17
  30. package/src/cli/update-cli.ts +14 -1
  31. package/src/config/model-registry.ts +47 -24
  32. package/src/config/model-resolver.ts +56 -31
  33. package/src/config/settings-schema.ts +5 -0
  34. package/src/config/settings.ts +68 -5
  35. package/src/dap/client.ts +86 -7
  36. package/src/edit/diff.ts +4 -4
  37. package/src/eval/py/prelude.py +5 -5
  38. package/src/export/html/template.js +23 -9
  39. package/src/extensibility/extensions/runner.ts +9 -4
  40. package/src/extensibility/extensions/types.ts +3 -0
  41. package/src/extensibility/typebox.ts +22 -9
  42. package/src/hindsight/client.test.ts +42 -0
  43. package/src/hindsight/client.ts +40 -3
  44. package/src/hindsight/config.ts +18 -0
  45. package/src/launch/broker.ts +31 -5
  46. package/src/lsp/index.ts +1 -1
  47. package/src/main.ts +15 -5
  48. package/src/mcp/tool-bridge.ts +9 -0
  49. package/src/mnemopi/state.ts +70 -3
  50. package/src/modes/components/agent-dashboard.ts +6 -2
  51. package/src/modes/components/chat-transcript-builder.ts +13 -2
  52. package/src/modes/components/diff.ts +2 -2
  53. package/src/modes/components/plan-review-overlay.ts +20 -4
  54. package/src/modes/components/user-message.ts +100 -1
  55. package/src/modes/controllers/command-controller.ts +22 -5
  56. package/src/modes/controllers/event-controller.ts +4 -1
  57. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  58. package/src/modes/controllers/input-controller.ts +47 -12
  59. package/src/modes/controllers/selector-controller.ts +82 -6
  60. package/src/modes/interactive-mode.ts +36 -4
  61. package/src/modes/types.ts +16 -3
  62. package/src/modes/utils/ui-helpers.ts +40 -20
  63. package/src/prompts/system/workflow-notice.md +1 -1
  64. package/src/sdk.ts +134 -48
  65. package/src/session/agent-session-error-log.test.ts +59 -0
  66. package/src/session/agent-session.ts +300 -26
  67. package/src/system-prompt.ts +1 -2
  68. package/src/task/executor.ts +41 -27
  69. package/src/task/index.ts +11 -0
  70. package/src/task/prewalk.ts +6 -0
  71. package/src/task/worktree.ts +25 -11
  72. package/src/tools/ask.ts +15 -0
  73. package/src/tools/bash.ts +14 -6
  74. package/src/tools/browser.ts +1 -1
  75. package/src/tools/debug.ts +1 -1
  76. package/src/tools/eval.ts +4 -5
  77. package/src/tools/fetch.ts +1 -1
  78. package/src/tools/hub/launch.ts +7 -0
  79. package/src/tools/tool-timeouts.ts +10 -3
  80. package/src/tools/write.ts +31 -0
  81. package/src/utils/changelog.ts +54 -58
  82. package/src/utils/git.ts +57 -49
package/src/dap/client.ts CHANGED
@@ -75,6 +75,10 @@ export class DapClient {
75
75
  #reverseRequestHandlers = new Map<string, DapReverseRequestHandler>();
76
76
  #adapterExited = false;
77
77
  #pendingWriteExitRejectors = new Set<() => void>();
78
+ /** Rejectors for in-flight {@link waitForEvent} calls, woken when the
79
+ * transport closes so an event that can never arrive fails fast instead of
80
+ * waiting out its own timeout. */
81
+ #eventWaiterRejectors = new Set<(error: Error) => void>();
78
82
 
79
83
  constructor(
80
84
  adapter: DapResolvedAdapter,
@@ -188,12 +192,17 @@ export class DapClient {
188
192
  });
189
193
 
190
194
  try {
191
- const { readable, writeSink, socket } = await waitForTcpTransport(
192
- host,
193
- port,
194
- socketReadyTimeoutMs ?? SOCKET_READY_TIMEOUT_MS,
195
- proc,
196
- );
195
+ // Wait for the adapter to announce it is listening on `port` before
196
+ // connecting. Without this gate the first connect can land in the
197
+ // window where a just-released reservation port still accepts
198
+ // connections (WSL2 mirrored networking, issue #6055): the transport
199
+ // then binds to a ghost of the reservation listener instead of the
200
+ // adapter. Draining stdout here also avoids a pipe-buffer deadlock —
201
+ // in tcp mode the DAP protocol flows over the socket, so nothing else
202
+ // consumes the adapter's stdout.
203
+ const readyTimeoutMs = socketReadyTimeoutMs ?? SOCKET_READY_TIMEOUT_MS;
204
+ await waitForTcpServerListening(proc, port, readyTimeoutMs);
205
+ const { readable, writeSink, socket } = await waitForTcpTransport(host, port, readyTimeoutMs, proc);
197
206
  const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket, port });
198
207
  proc.exited.then(() => client.#handleProcessExit());
199
208
  void client.#startMessageReader();
@@ -389,6 +398,7 @@ export class DapClient {
389
398
  let timeout: NodeJS.Timeout | undefined;
390
399
  const cleanup = () => {
391
400
  unsubscribe();
401
+ this.#eventWaiterRejectors.delete(closeHandler);
392
402
  if (timeout) clearTimeout(timeout);
393
403
  if (signal) {
394
404
  signal.removeEventListener("abort", abortHandler);
@@ -398,6 +408,10 @@ export class DapClient {
398
408
  cleanup();
399
409
  reject(signal?.reason instanceof Error ? signal.reason : new ToolAbortError());
400
410
  };
411
+ const closeHandler = (error: Error) => {
412
+ cleanup();
413
+ reject(error);
414
+ };
401
415
  const unsubscribe = this.onEvent(event, body => {
402
416
  const typedBody = body as TBody;
403
417
  if (predicate && !predicate(typedBody)) {
@@ -406,6 +420,7 @@ export class DapClient {
406
420
  cleanup();
407
421
  resolve(typedBody);
408
422
  });
423
+ this.#eventWaiterRejectors.add(closeHandler);
409
424
  if (signal) {
410
425
  signal.addEventListener("abort", abortHandler, { once: true });
411
426
  }
@@ -580,6 +595,7 @@ export class DapClient {
580
595
 
581
596
  const framer = new MessageFramer(this.#messageBuffer);
582
597
 
598
+ let closeError: Error | undefined;
583
599
  try {
584
600
  while (true) {
585
601
  const { done, value } = await reader.read();
@@ -619,13 +635,19 @@ export class DapClient {
619
635
  }
620
636
  }
621
637
  } catch (error) {
622
- this.#rejectPendingRequests(new Error(`DAP connection closed: ${toErrorMessage(error)}`));
638
+ closeError = new Error(`DAP connection closed: ${toErrorMessage(error)}`);
623
639
  } finally {
624
640
  // Persist any unparsed remainder so a restarted reader resumes mid-message.
625
641
  this.#messageBuffer = framer.remainder();
626
642
  reader.releaseLock();
627
643
  this.#isReading = false;
628
644
  }
645
+ // The transport is gone once the reader loop exits — on a thrown error
646
+ // or a clean stream end (a socket the peer dropped after we wrote, e.g.
647
+ // the WSL2-mirrored ghost-accept race in issue #6055). Fail every
648
+ // in-flight request and event waiter so callers see an immediate error
649
+ // instead of waiting out their own timeout.
650
+ this.#failConnection(closeError ?? new Error(`DAP connection closed: ${this.adapter.name} transport ended`));
629
651
  }
630
652
 
631
653
  #handleResponse(message: DapResponseMessage): void {
@@ -712,7 +734,19 @@ export class DapClient {
712
734
  ? `DAP adapter exited (code ${exitCode}): ${stderr}`
713
735
  : `DAP adapter exited unexpectedly (code ${exitCode})`,
714
736
  );
737
+ this.#failConnection(error);
738
+ }
739
+
740
+ /** Reject every in-flight request and wake every event waiter with `error`.
741
+ * Called when the transport dies (reader end, socket close, adapter exit)
742
+ * so nothing sits pending until its own timeout. */
743
+ #failConnection(error: Error): void {
715
744
  this.#rejectPendingRequests(error);
745
+ const waiters = Array.from(this.#eventWaiterRejectors);
746
+ this.#eventWaiterRejectors.clear();
747
+ for (const reject of waiters) {
748
+ reject(error);
749
+ }
716
750
  }
717
751
 
718
752
  #rejectPendingRequests(error: Error): void {
@@ -826,6 +860,51 @@ async function waitForTcpTransport(
826
860
  throw new Error(`TCP port ${host}:${port} was not ready after ${timeoutMs}ms`);
827
861
  }
828
862
 
863
+ /**
864
+ * Give the adapter a chance to announce it is listening on `port` before the
865
+ * first connect. vscode-js-debug prints `Debug server listening at HOST:PORT`
866
+ * to stdout from inside its `listen()` callback; waiting for the port to appear
867
+ * there means we only connect once the child genuinely owns the reserved port,
868
+ * which closes the WSL2-mirrored ghost-accept window (issue #6055) at its root.
869
+ *
870
+ * Best-effort: resolves on the banner, on process exit, or on timeout — the
871
+ * subsequent connect loop and `proc.exitCode` checks surface real failures, so
872
+ * an adapter that never prints a banner still proceeds (just without the gate).
873
+ * Also drains stdout for the wait's duration: in tcp mode the DAP protocol
874
+ * flows over the socket, so nothing else consumes the adapter's stdout.
875
+ *
876
+ * Exported so tests can drive the gate deterministically with a synthetic stdout.
877
+ */
878
+ export async function waitForTcpServerListening(
879
+ proc: { stdout: ReadableStream<Uint8Array>; exitCode: number | null },
880
+ port: number,
881
+ timeoutMs: number,
882
+ ): Promise<void> {
883
+ const ready = Promise.withResolvers<void>();
884
+ const portText = String(port);
885
+ void (async () => {
886
+ try {
887
+ const decoder = new TextDecoder();
888
+ let buffered = "";
889
+ for await (const chunk of proc.stdout) {
890
+ buffered += decoder.decode(chunk, { stream: true });
891
+ if (buffered.includes(portText)) {
892
+ ready.resolve();
893
+ }
894
+ // Keep only the tail relevant for banner matching so a chatty
895
+ // adapter cannot grow this buffer without bound.
896
+ if (buffered.length > 4096) {
897
+ buffered = buffered.slice(-1024);
898
+ }
899
+ }
900
+ } catch {
901
+ /* stdout errored — the connect loop surfaces the real failure */
902
+ }
903
+ ready.resolve();
904
+ })();
905
+ await Promise.race([ready.promise, Bun.sleep(timeoutMs)]);
906
+ }
907
+
829
908
  interface SocketTransport {
830
909
  readable: ReadableStream<Uint8Array>;
831
910
  writeSink: DapWriteSink;
package/src/edit/diff.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Provides diff string generation and the replace-mode edit logic
5
5
  * used when not in patch mode.
6
6
  */
7
- import * as Diff from "diff";
7
+ import { diffLines, structuredPatchHunks } from "@oh-my-pi/pi-natives";
8
8
  import { resolveToCwd } from "../tools/path-utils";
9
9
  import { type BlockContextSource, findBlockContextLines } from "../utils/block-context";
10
10
  import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace";
@@ -235,7 +235,7 @@ export function generateDiffString(
235
235
  contextLines = 2,
236
236
  source: BlockContextSource = {},
237
237
  ): DiffResult {
238
- const parts = Diff.diffLines(oldContent, newContent);
238
+ const parts = diffLines(oldContent, newContent);
239
239
  const output: string[] = [];
240
240
 
241
241
  let oldLineNum = 1;
@@ -373,10 +373,10 @@ export function generateUnifiedDiffString(
373
373
  contextLines = 3,
374
374
  source: BlockContextSource = {},
375
375
  ): DiffResult {
376
- const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines });
376
+ const hunks = structuredPatchHunks(oldContent, newContent, contextLines);
377
377
  const output: string[] = [];
378
378
  let firstChangedLine: number | undefined;
379
- for (const hunk of patch.hunks) {
379
+ for (const hunk of hunks) {
380
380
  output.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
381
381
  let oldLine = hunk.oldStart;
382
382
  let newLine = hunk.newStart;
@@ -153,7 +153,7 @@ if "__omp_prelude_loaded__" not in globals():
153
153
  """Read task/agent output by ID. Returns text or JSON depending on format.
154
154
 
155
155
  Args:
156
- *ids: Output IDs to read (e.g., 'explore_0', 'reviewer_1')
156
+ *ids: Output IDs to read (e.g., 'scout_0', 'reviewer_1')
157
157
  format: 'raw' (default), 'json' (dict with metadata), 'stripped' (no ANSI)
158
158
  query: jq-like query for JSON outputs (e.g., '.endpoints[0].file')
159
159
  offset: Line number to start reading from (1-indexed)
@@ -164,11 +164,11 @@ if "__omp_prelude_loaded__" not in globals():
164
164
  Multiple IDs: list of dict with 'id' and 'content'/'data' keys
165
165
 
166
166
  Examples:
167
- output('explore_0') # Read as raw text
167
+ output('scout_0') # Read as raw text
168
168
  output('reviewer_0', format='json') # Read with metadata
169
- output('explore_0', query='.files[0]') # Extract JSON field
170
- output('explore_0', offset=10, limit=20) # Lines 10-29
171
- output('explore_0', 'reviewer_1') # Read multiple outputs
169
+ output('scout_0', query='.files[0]') # Extract JSON field
170
+ output('scout_0', offset=10, limit=20) # Lines 10-29
171
+ output('scout_0', 'reviewer_1') # Read multiple outputs
172
172
  """
173
173
  # Prefer PI_ARTIFACTS_DIR so subagents resolve through the parent's
174
174
  # shared artifacts dir; fall back to deriving from PI_SESSION_FILE
@@ -102,14 +102,18 @@
102
102
  }
103
103
  }
104
104
 
105
- // Sort children by timestamp
106
- function sortChildren(node) {
105
+ // Sort children by timestamp. Use an explicit stack so valid, deep
106
+ // conversation chains do not exhaust the browser call stack.
107
+ const sortStack = [...roots];
108
+ while (sortStack.length > 0) {
109
+ const node = sortStack.pop();
107
110
  node.children.sort((a, b) =>
108
111
  new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
109
112
  );
110
- node.children.forEach(sortChildren);
113
+ for (let i = node.children.length - 1; i >= 0; i--) {
114
+ sortStack.push(node.children[i]);
115
+ }
111
116
  }
112
- roots.forEach(sortChildren);
113
117
 
114
118
  return roots;
115
119
  }
@@ -157,17 +161,27 @@
157
161
  const result = [];
158
162
  const multipleRoots = roots.length > 1;
159
163
 
160
- // Mark which subtrees contain the active leaf
164
+ // Mark which subtrees contain the active leaf. Use iterative post-order
165
+ // traversal so valid, deep conversation chains do not exhaust the
166
+ // browser call stack.
161
167
  const containsActive = new Map();
162
- function markActive(node) {
168
+ const allNodes = [];
169
+ const activeStack = [...roots];
170
+ while (activeStack.length > 0) {
171
+ const node = activeStack.pop();
172
+ allNodes.push(node);
173
+ for (let i = node.children.length - 1; i >= 0; i--) {
174
+ activeStack.push(node.children[i]);
175
+ }
176
+ }
177
+ for (let i = allNodes.length - 1; i >= 0; i--) {
178
+ const node = allNodes[i];
163
179
  let has = activePathIds.has(node.entry.id);
164
180
  for (const child of node.children) {
165
- if (markActive(child)) has = true;
181
+ if (containsActive.get(child)) has = true;
166
182
  }
167
183
  containsActive.set(node, has);
168
- return has;
169
184
  }
170
- roots.forEach(markActive);
171
185
 
172
186
  // Stack: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]
173
187
  const stack = [];
@@ -179,17 +179,22 @@ export type SwitchSessionHandler = (sessionPath: string) => Promise<{ cancelled:
179
179
  export type ShutdownHandler = () => void;
180
180
 
181
181
  /**
182
- * Helper function to emit session_shutdown event to extensions.
183
- * Returns true if the event was emitted, false if there were no handlers.
182
+ * Emit `session_shutdown` and clear timers owned by an extension runner.
183
+ *
184
+ * Returns whether any shutdown handlers were present. Timer cleanup runs even
185
+ * when a handler fails so extension background work cannot outlive its host.
184
186
  */
185
187
  export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner | undefined): Promise<boolean> {
186
- if (extensionRunner?.hasHandlers("session_shutdown")) {
188
+ if (!extensionRunner) return false;
189
+ try {
190
+ if (!extensionRunner.hasHandlers("session_shutdown")) return false;
187
191
  await extensionRunner.emit({
188
192
  type: "session_shutdown",
189
193
  });
190
194
  return true;
195
+ } finally {
196
+ extensionRunner.clearManagedTimers();
191
197
  }
192
- return false;
193
198
  }
194
199
 
195
200
  const noOpUIContext: ExtensionUIContext = {
@@ -543,6 +543,9 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
543
543
  /** Tool approval tier. Defaults to `"exec"` when omitted.
544
544
  * `"read"`: read-only operations. `"write"`: mutations. `"exec"`: code execution. */
545
545
  approval?: ToolApproval;
546
+ /** Structured-output strict grammar opt-in/out. `false` is meaningful: OpenAI-family
547
+ * serializers preserve an explicit `strict: false` on the wire (#4336/#4340). */
548
+ strict?: boolean;
546
549
  /** MCP server name for discovery/search metadata when this tool fronts an MCP server. */
547
550
  mcpServerName?: string;
548
551
  /** Original MCP tool name for discovery/search metadata. */
@@ -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"),
@@ -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/index.ts CHANGED
@@ -1565,7 +1565,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1565
1565
  _context?: AgentToolContext,
1566
1566
  ): Promise<AgentToolResult<LspToolDetails>> {
1567
1567
  const { action, file, line, symbol, query, new_name, apply, timeout } = params;
1568
- const timeoutSec = clampTimeout("lsp", timeout);
1568
+ const timeoutSec = clampTimeout("lsp", timeout, this.session.settings.get("tools.maxTimeout"));
1569
1569
  const timeoutSignal = AbortSignal.timeout(timeoutSec * 1000);
1570
1570
  const callerSignal = signal;
1571
1571
  signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;