@oh-my-pi/pi-coding-agent 17.0.6 → 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.
- package/CHANGELOG.md +65 -0
- package/dist/cli.js +4967 -4948
- package/dist/types/config/model-registry.d.ts +1 -1
- package/dist/types/config/model-resolver.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +16 -0
- package/dist/types/dap/client.d.ts +19 -0
- package/dist/types/extensibility/extensions/runner.d.ts +4 -2
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/extensibility/typebox.d.ts +4 -0
- package/dist/types/hindsight/client.d.ts +10 -0
- package/dist/types/hindsight/config.d.ts +8 -0
- package/dist/types/mcp/tool-bridge.d.ts +9 -0
- package/dist/types/mnemopi/state.d.ts +2 -0
- package/dist/types/modes/components/user-message.d.ts +25 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
- package/dist/types/modes/interactive-mode.d.ts +7 -1
- package/dist/types/modes/types.d.ts +14 -1
- package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
- package/dist/types/sdk.d.ts +1 -0
- package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +78 -2
- package/dist/types/task/executor.d.ts +10 -0
- package/dist/types/task/index.d.ts +1 -0
- package/dist/types/task/prewalk.d.ts +3 -0
- package/dist/types/tools/ask.d.ts +10 -0
- package/dist/types/tools/tool-timeouts.d.ts +8 -2
- package/dist/types/utils/changelog.d.ts +4 -6
- package/package.json +12 -13
- package/src/cli/models-cli.ts +37 -17
- package/src/cli/update-cli.ts +14 -1
- package/src/config/model-discovery.ts +3 -2
- package/src/config/model-registry.ts +53 -26
- package/src/config/model-resolver.ts +56 -31
- package/src/config/settings-schema.ts +5 -0
- package/src/config/settings.ts +68 -5
- package/src/dap/client.ts +86 -7
- package/src/edit/diff.ts +4 -4
- package/src/eval/py/prelude.py +5 -5
- package/src/export/html/template.js +23 -9
- package/src/extensibility/extensions/runner.ts +9 -4
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/extensibility/typebox.ts +22 -9
- package/src/hindsight/client.test.ts +42 -0
- package/src/hindsight/client.ts +40 -3
- package/src/hindsight/config.ts +18 -0
- package/src/launch/broker.ts +31 -5
- package/src/lsp/index.ts +1 -1
- package/src/main.ts +15 -5
- package/src/mcp/tool-bridge.ts +9 -0
- package/src/mnemopi/state.ts +70 -3
- package/src/modes/components/agent-dashboard.ts +6 -2
- package/src/modes/components/chat-transcript-builder.ts +13 -2
- package/src/modes/components/diff.ts +2 -2
- package/src/modes/components/plan-review-overlay.ts +20 -4
- package/src/modes/components/user-message.ts +100 -1
- package/src/modes/controllers/command-controller.ts +22 -5
- package/src/modes/controllers/event-controller.ts +4 -1
- package/src/modes/controllers/extension-ui-controller.ts +18 -0
- package/src/modes/controllers/input-controller.ts +47 -12
- package/src/modes/controllers/selector-controller.ts +82 -6
- package/src/modes/interactive-mode.ts +36 -4
- package/src/modes/types.ts +16 -3
- package/src/modes/utils/ui-helpers.ts +40 -20
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/sdk.ts +134 -48
- package/src/session/agent-session-error-log.test.ts +59 -0
- package/src/session/agent-session.ts +300 -26
- package/src/system-prompt.ts +1 -2
- package/src/task/executor.ts +41 -27
- package/src/task/index.ts +11 -0
- package/src/task/prewalk.ts +6 -0
- package/src/task/worktree.ts +25 -11
- package/src/tools/ask.ts +15 -0
- package/src/tools/bash.ts +14 -6
- package/src/tools/browser.ts +1 -1
- package/src/tools/debug.ts +1 -1
- package/src/tools/eval.ts +4 -5
- package/src/tools/fetch.ts +1 -1
- package/src/tools/hub/launch.ts +7 -0
- package/src/tools/tool-timeouts.ts +10 -3
- package/src/tools/write.ts +31 -0
- package/src/utils/changelog.ts +54 -58
- 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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
376
|
+
const hunks = structuredPatchHunks(oldContent, newContent, contextLines);
|
|
377
377
|
const output: string[] = [];
|
|
378
378
|
let firstChangedLine: number | undefined;
|
|
379
|
-
for (const hunk of
|
|
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;
|
package/src/eval/py/prelude.py
CHANGED
|
@@ -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., '
|
|
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('
|
|
167
|
+
output('scout_0') # Read as raw text
|
|
168
168
|
output('reviewer_0', format='json') # Read with metadata
|
|
169
|
-
output('
|
|
170
|
-
output('
|
|
171
|
-
output('
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
-
*
|
|
183
|
-
*
|
|
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
|
|
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
|
+
});
|
package/src/hindsight/client.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
}
|
package/src/hindsight/config.ts
CHANGED
|
@@ -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"),
|
package/src/launch/broker.ts
CHANGED
|
@@ -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
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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
|
|
826
|
-
|
|
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;
|