@oh-my-pi/pi-coding-agent 16.3.11 → 16.3.13
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 +3176 -3087
- package/dist/types/advisor/runtime.d.ts +11 -0
- package/dist/types/config/keybindings.d.ts +9 -4
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/settings-schema.d.ts +6 -0
- package/dist/types/config/settings.d.ts +3 -1
- package/dist/types/discovery/helpers.d.ts +9 -0
- package/dist/types/exec/bash-executor.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +11 -2
- package/dist/types/extensibility/shared-events.d.ts +2 -2
- package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
- package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
- package/dist/types/mnemopi/state.d.ts +7 -3
- package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -1
- package/dist/types/modes/components/read-tool-group.d.ts +1 -0
- package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -1
- package/dist/types/modes/rpc/rpc-client.d.ts +11 -5
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/modes/utils/context-usage.d.ts +0 -12
- package/dist/types/modes/workflow.d.ts +5 -1
- package/dist/types/session/agent-session.d.ts +8 -4
- package/dist/types/system-prompt.d.ts +1 -1
- package/dist/types/tools/bash-interactive.d.ts +1 -1
- package/dist/types/tools/bash-skill-urls.d.ts +1 -0
- package/dist/types/tools/bash.d.ts +2 -1
- package/dist/types/tools/browser/launch.d.ts +1 -0
- package/dist/types/tools/grep.d.ts +2 -0
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/path-utils.d.ts +24 -0
- package/dist/types/tools/read.d.ts +3 -0
- package/dist/types/tools/renderers.d.ts +12 -5
- package/dist/types/tools/ssh.d.ts +4 -1
- package/dist/types/tools/write.d.ts +1 -0
- package/dist/types/utils/local-date.d.ts +2 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +145 -0
- package/src/advisor/runtime.ts +19 -0
- package/src/config/api-key-resolver.ts +7 -2
- package/src/config/keybindings.ts +62 -10
- package/src/config/model-registry.ts +94 -20
- package/src/config/settings-schema.ts +11 -1
- package/src/config/settings.ts +59 -21
- package/src/discovery/builtin.ts +2 -1
- package/src/discovery/claude-plugins.ts +167 -46
- package/src/discovery/helpers.ts +16 -1
- package/src/edit/renderer.ts +20 -6
- package/src/eval/js/worker-core.ts +163 -6
- package/src/exec/bash-executor.ts +14 -9
- package/src/extensibility/extensions/runner.ts +1 -0
- package/src/extensibility/extensions/types.ts +13 -2
- package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
- package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
- package/src/extensibility/shared-events.ts +2 -2
- package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/registry-helpers.ts +9 -6
- package/src/mnemopi/state.ts +19 -5
- package/src/modes/acp/acp-agent.ts +69 -8
- package/src/modes/acp/acp-event-mapper.ts +1 -1
- package/src/modes/components/model-selector.ts +30 -6
- package/src/modes/components/read-tool-group.ts +5 -1
- package/src/modes/components/settings-defs.ts +1 -1
- package/src/modes/components/status-line/component.ts +14 -2
- package/src/modes/components/tool-execution.ts +28 -24
- package/src/modes/controllers/command-controller.ts +13 -23
- package/src/modes/controllers/event-controller.ts +12 -12
- package/src/modes/controllers/extension-ui-controller.test.ts +16 -0
- package/src/modes/controllers/extension-ui-controller.ts +7 -35
- package/src/modes/controllers/input-controller.ts +23 -57
- package/src/modes/controllers/mcp-command-controller.ts +10 -9
- package/src/modes/controllers/selector-controller.ts +16 -5
- package/src/modes/github-ref-autocomplete.ts +75 -0
- package/src/modes/interactive-mode.ts +97 -12
- package/src/modes/prompt-action-autocomplete.ts +35 -0
- package/src/modes/rpc/rpc-client.ts +42 -13
- package/src/modes/rpc/rpc-mode.ts +21 -19
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/context-usage.ts +58 -5
- package/src/modes/utils/hotkeys-markdown.ts +2 -1
- package/src/modes/utils/ui-helpers.ts +2 -2
- package/src/modes/workflow.ts +14 -8
- package/src/prompts/agents/plan.md +0 -1
- package/src/prompts/agents/reviewer.md +0 -1
- package/src/prompts/system/plan-mode-active.md +5 -2
- package/src/prompts/system/system-prompt.md +1 -2
- package/src/prompts/system/workflow-notice.md +69 -50
- package/src/prompts/tools/bash.md +18 -7
- package/src/prompts/tools/grep.md +2 -1
- package/src/prompts/tools/memory-edit.md +2 -0
- package/src/prompts/tools/read.md +4 -3
- package/src/sdk.ts +11 -0
- package/src/session/agent-session.ts +136 -19
- package/src/system-prompt.ts +3 -2
- package/src/tools/bash-interactive.ts +1 -1
- package/src/tools/bash-skill-urls.ts +39 -7
- package/src/tools/bash.ts +69 -39
- package/src/tools/browser/launch.ts +31 -4
- package/src/tools/grep.ts +105 -21
- package/src/tools/image-gen.ts +1 -1
- package/src/tools/index.ts +11 -0
- package/src/tools/memory-edit.ts +3 -1
- package/src/tools/path-utils.ts +46 -1
- package/src/tools/read.ts +135 -57
- package/src/tools/renderers.ts +13 -5
- package/src/tools/ssh.ts +10 -3
- package/src/tools/tts.ts +1 -1
- package/src/tools/write.ts +26 -0
- package/src/utils/local-date.ts +7 -0
- package/src/utils/open.ts +36 -10
|
@@ -46,6 +46,9 @@ interface SshRenderArgs {
|
|
|
46
46
|
command?: string;
|
|
47
47
|
timeout?: number;
|
|
48
48
|
}
|
|
49
|
+
/** Whether the painted call args still carry the streamed raw-JSON buffer —
|
|
50
|
+
* the shape that renders the `⏳ SSH: […]` / `$ …` placeholder. */
|
|
51
|
+
declare function hasStreamedRenderArgs(args: unknown): boolean;
|
|
49
52
|
interface SshRenderContext {
|
|
50
53
|
/** Visual lines for truncated output (pre-computed by tool-execution) */
|
|
51
54
|
visualLines?: string[];
|
|
@@ -68,7 +71,7 @@ export declare const sshToolRenderer: {
|
|
|
68
71
|
renderContext?: SshRenderContext;
|
|
69
72
|
}, uiTheme: Theme, args?: SshRenderArgs): Component;
|
|
70
73
|
mergeCallAndResult: boolean;
|
|
71
|
-
forceFirstResultViewportRepaint:
|
|
74
|
+
forceFirstResultViewportRepaint: typeof hasStreamedRenderArgs;
|
|
72
75
|
forceResultViewportRepaintOnSettle: boolean;
|
|
73
76
|
};
|
|
74
77
|
export {};
|
|
@@ -62,5 +62,6 @@ export declare const writeToolRenderer: {
|
|
|
62
62
|
isError?: boolean;
|
|
63
63
|
}, options: RenderResultOptions, uiTheme: Theme, args?: WriteRenderArgs): Component;
|
|
64
64
|
mergeCallAndResult: boolean;
|
|
65
|
+
forceFirstResultViewportRepaint: (args: unknown, options: RenderResultOptions) => boolean;
|
|
65
66
|
};
|
|
66
67
|
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "16.3.
|
|
4
|
+
"version": "16.3.13",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -56,17 +56,17 @@
|
|
|
56
56
|
"@agentclientprotocol/sdk": "0.25.0",
|
|
57
57
|
"@babel/parser": "^7.29.7",
|
|
58
58
|
"@mozilla/readability": "^0.6.0",
|
|
59
|
-
"@oh-my-pi/hashline": "16.3.
|
|
60
|
-
"@oh-my-pi/omp-stats": "16.3.
|
|
61
|
-
"@oh-my-pi/pi-agent-core": "16.3.
|
|
62
|
-
"@oh-my-pi/pi-ai": "16.3.
|
|
63
|
-
"@oh-my-pi/pi-catalog": "16.3.
|
|
64
|
-
"@oh-my-pi/pi-mnemopi": "16.3.
|
|
65
|
-
"@oh-my-pi/pi-natives": "16.3.
|
|
66
|
-
"@oh-my-pi/pi-tui": "16.3.
|
|
67
|
-
"@oh-my-pi/pi-utils": "16.3.
|
|
68
|
-
"@oh-my-pi/pi-wire": "16.3.
|
|
69
|
-
"@oh-my-pi/snapcompact": "16.3.
|
|
59
|
+
"@oh-my-pi/hashline": "16.3.13",
|
|
60
|
+
"@oh-my-pi/omp-stats": "16.3.13",
|
|
61
|
+
"@oh-my-pi/pi-agent-core": "16.3.13",
|
|
62
|
+
"@oh-my-pi/pi-ai": "16.3.13",
|
|
63
|
+
"@oh-my-pi/pi-catalog": "16.3.13",
|
|
64
|
+
"@oh-my-pi/pi-mnemopi": "16.3.13",
|
|
65
|
+
"@oh-my-pi/pi-natives": "16.3.13",
|
|
66
|
+
"@oh-my-pi/pi-tui": "16.3.13",
|
|
67
|
+
"@oh-my-pi/pi-utils": "16.3.13",
|
|
68
|
+
"@oh-my-pi/pi-wire": "16.3.13",
|
|
69
|
+
"@oh-my-pi/snapcompact": "16.3.13",
|
|
70
70
|
"@opentelemetry/api": "^1.9.1",
|
|
71
71
|
"@opentelemetry/context-async-hooks": "^2.7.1",
|
|
72
72
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
|
@@ -1294,6 +1294,151 @@ describe("advisor", () => {
|
|
|
1294
1294
|
expect(failures).toHaveLength(2);
|
|
1295
1295
|
});
|
|
1296
1296
|
|
|
1297
|
+
it("calls onTurnError with state.error before retrying the batch", async () => {
|
|
1298
|
+
const promptInputs: string[] = [];
|
|
1299
|
+
const turnErrors: unknown[] = [];
|
|
1300
|
+
const events: string[] = [];
|
|
1301
|
+
const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
|
|
1302
|
+
let promptCalls = 0;
|
|
1303
|
+
const agent: AdvisorAgent = {
|
|
1304
|
+
prompt: async input => {
|
|
1305
|
+
promptCalls++;
|
|
1306
|
+
promptInputs.push(input);
|
|
1307
|
+
events.push(`prompt:${promptCalls}`);
|
|
1308
|
+
state.error = promptCalls === 1 ? "provider failed" : undefined;
|
|
1309
|
+
},
|
|
1310
|
+
abort: () => {},
|
|
1311
|
+
reset: () => {
|
|
1312
|
+
state.error = undefined;
|
|
1313
|
+
},
|
|
1314
|
+
state,
|
|
1315
|
+
};
|
|
1316
|
+
const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
|
|
1317
|
+
const host: AdvisorRuntimeHost = {
|
|
1318
|
+
snapshotMessages: () => messages,
|
|
1319
|
+
enqueueAdvice: () => {},
|
|
1320
|
+
onTurnError: error => {
|
|
1321
|
+
turnErrors.push(error);
|
|
1322
|
+
events.push(`hook:${error instanceof Error ? error.message : String(error)}`);
|
|
1323
|
+
},
|
|
1324
|
+
};
|
|
1325
|
+
const runtime = new AdvisorRuntime(agent, host, 1);
|
|
1326
|
+
|
|
1327
|
+
runtime.onTurnEnd(messages);
|
|
1328
|
+
await runtime.waitForCatchup(1000, 1);
|
|
1329
|
+
|
|
1330
|
+
expect(promptInputs).toHaveLength(2);
|
|
1331
|
+
expect(turnErrors).toHaveLength(1);
|
|
1332
|
+
const error = turnErrors[0];
|
|
1333
|
+
if (!(error instanceof Error)) throw new Error("expected advisor turn error");
|
|
1334
|
+
expect(error.message).toBe("provider failed");
|
|
1335
|
+
expect(events).toEqual(["prompt:1", "hook:provider failed", "prompt:2"]);
|
|
1336
|
+
expect(runtime.backlog).toBe(0);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
it("calls onTurnError for each consecutive failure including the dropped third turn", async () => {
|
|
1340
|
+
const promptInputs: string[] = [];
|
|
1341
|
+
const turnErrors: unknown[] = [];
|
|
1342
|
+
const failures: unknown[] = [];
|
|
1343
|
+
const events: string[] = [];
|
|
1344
|
+
const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
|
|
1345
|
+
let promptCalls = 0;
|
|
1346
|
+
const agent: AdvisorAgent = {
|
|
1347
|
+
prompt: async input => {
|
|
1348
|
+
promptCalls++;
|
|
1349
|
+
promptInputs.push(input);
|
|
1350
|
+
events.push(`prompt:${promptCalls}`);
|
|
1351
|
+
state.error = `provider failed ${promptCalls}`;
|
|
1352
|
+
},
|
|
1353
|
+
abort: () => {},
|
|
1354
|
+
reset: () => {
|
|
1355
|
+
state.error = undefined;
|
|
1356
|
+
},
|
|
1357
|
+
state,
|
|
1358
|
+
};
|
|
1359
|
+
const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
|
|
1360
|
+
const host: AdvisorRuntimeHost = {
|
|
1361
|
+
snapshotMessages: () => messages,
|
|
1362
|
+
enqueueAdvice: () => {},
|
|
1363
|
+
onTurnError: error => {
|
|
1364
|
+
turnErrors.push(error);
|
|
1365
|
+
events.push(`hook:${error instanceof Error ? error.message : String(error)}`);
|
|
1366
|
+
},
|
|
1367
|
+
notifyFailure: error => {
|
|
1368
|
+
failures.push(error);
|
|
1369
|
+
events.push(`notify:${error instanceof Error ? error.message : String(error)}`);
|
|
1370
|
+
},
|
|
1371
|
+
};
|
|
1372
|
+
const runtime = new AdvisorRuntime(agent, host, 1);
|
|
1373
|
+
|
|
1374
|
+
runtime.onTurnEnd(messages);
|
|
1375
|
+
await runtime.waitForCatchup(1000, 1);
|
|
1376
|
+
|
|
1377
|
+
expect(promptInputs).toHaveLength(3);
|
|
1378
|
+
expect(turnErrors.map(error => (error instanceof Error ? error.message : String(error)))).toEqual([
|
|
1379
|
+
"provider failed 1",
|
|
1380
|
+
"provider failed 2",
|
|
1381
|
+
"provider failed 3",
|
|
1382
|
+
]);
|
|
1383
|
+
expect(failures).toHaveLength(1);
|
|
1384
|
+
const failure = failures[0];
|
|
1385
|
+
if (!(failure instanceof Error)) throw new Error("expected advisor failure error");
|
|
1386
|
+
expect(failure.message).toBe("provider failed 3");
|
|
1387
|
+
expect(events).toEqual([
|
|
1388
|
+
"prompt:1",
|
|
1389
|
+
"hook:provider failed 1",
|
|
1390
|
+
"prompt:2",
|
|
1391
|
+
"hook:provider failed 2",
|
|
1392
|
+
"prompt:3",
|
|
1393
|
+
"hook:provider failed 3",
|
|
1394
|
+
"notify:provider failed 3",
|
|
1395
|
+
]);
|
|
1396
|
+
expect(runtime.backlog).toBe(0);
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
it("continues retrying when onTurnError rejects", async () => {
|
|
1400
|
+
const promptInputs: string[] = [];
|
|
1401
|
+
const turnErrors: unknown[] = [];
|
|
1402
|
+
const events: string[] = [];
|
|
1403
|
+
const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
|
|
1404
|
+
let promptCalls = 0;
|
|
1405
|
+
const agent: AdvisorAgent = {
|
|
1406
|
+
prompt: async input => {
|
|
1407
|
+
promptCalls++;
|
|
1408
|
+
promptInputs.push(input);
|
|
1409
|
+
events.push(`prompt:${promptCalls}`);
|
|
1410
|
+
state.error = promptCalls === 1 ? "provider failed" : undefined;
|
|
1411
|
+
},
|
|
1412
|
+
abort: () => {},
|
|
1413
|
+
reset: () => {
|
|
1414
|
+
state.error = undefined;
|
|
1415
|
+
},
|
|
1416
|
+
state,
|
|
1417
|
+
};
|
|
1418
|
+
const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
|
|
1419
|
+
const host: AdvisorRuntimeHost = {
|
|
1420
|
+
snapshotMessages: () => messages,
|
|
1421
|
+
enqueueAdvice: () => {},
|
|
1422
|
+
onTurnError: async error => {
|
|
1423
|
+
turnErrors.push(error);
|
|
1424
|
+
events.push(`hook:${error instanceof Error ? error.message : String(error)}`);
|
|
1425
|
+
throw new Error("hook failed");
|
|
1426
|
+
},
|
|
1427
|
+
};
|
|
1428
|
+
const runtime = new AdvisorRuntime(agent, host, 1);
|
|
1429
|
+
|
|
1430
|
+
runtime.onTurnEnd(messages);
|
|
1431
|
+
await runtime.waitForCatchup(1000, 1);
|
|
1432
|
+
|
|
1433
|
+
expect(promptInputs).toHaveLength(2);
|
|
1434
|
+
expect(turnErrors).toHaveLength(1);
|
|
1435
|
+
const error = turnErrors[0];
|
|
1436
|
+
if (!(error instanceof Error)) throw new Error("expected advisor turn error");
|
|
1437
|
+
expect(error.message).toBe("provider failed");
|
|
1438
|
+
expect(events).toEqual(["prompt:1", "hook:provider failed", "prompt:2"]);
|
|
1439
|
+
expect(runtime.backlog).toBe(0);
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1297
1442
|
it("rolls advisor state back after each failed prompt so retries don't replay duplicate turns", async () => {
|
|
1298
1443
|
// The real `Agent` appends the user batch + a synthetic `stopReason: "error"`
|
|
1299
1444
|
// assistant turn before `state.error` is read. Without rollback, the runtime's
|
package/src/advisor/runtime.ts
CHANGED
|
@@ -48,6 +48,17 @@ export interface AdvisorRuntimeHost {
|
|
|
48
48
|
* one that routes `advise()` results back to the primary.
|
|
49
49
|
*/
|
|
50
50
|
beginAdvisorUpdate?(): void;
|
|
51
|
+
/**
|
|
52
|
+
* Called with the error of every failed advisor turn, before the retry sleep
|
|
53
|
+
* or the dropped-after-3 path. Lets the host apply credential-level remedies
|
|
54
|
+
* the advisor loop lacks: the in-stream a/b/c auth retry rotates through
|
|
55
|
+
* sibling credentials within one request but never blocks the LAST failing
|
|
56
|
+
* one — the primary agent's retry pipeline does that via
|
|
57
|
+
* `markUsageLimitReached`, so without this hook the advisor re-picks the
|
|
58
|
+
* same usage-limited account on every retry. Errors thrown here are logged
|
|
59
|
+
* and swallowed.
|
|
60
|
+
*/
|
|
61
|
+
onTurnError?(error: unknown): Promise<void> | void;
|
|
51
62
|
/** Surface a non-recovering advisor failure to the host UI without adding model-visible context. */
|
|
52
63
|
notifyFailure?(error: unknown): void;
|
|
53
64
|
}
|
|
@@ -352,6 +363,14 @@ export class AdvisorRuntime {
|
|
|
352
363
|
if (this.#epoch !== epoch) continue;
|
|
353
364
|
this.#rollbackFailedTurn(messageSnapshot);
|
|
354
365
|
logger.debug("advisor turn failed", { err: String(err) });
|
|
366
|
+
try {
|
|
367
|
+
await this.host.onTurnError?.(err);
|
|
368
|
+
} catch (hookErr) {
|
|
369
|
+
logger.debug("advisor onTurnError hook failed", { err: String(hookErr) });
|
|
370
|
+
}
|
|
371
|
+
// The hook awaits; a reset during it invalidates this batch like the
|
|
372
|
+
// prompt await above — drop it instead of requeueing stale content.
|
|
373
|
+
if (this.#epoch !== epoch) continue;
|
|
355
374
|
this.#consecutiveFailures++;
|
|
356
375
|
if (this.#consecutiveFailures >= 3) {
|
|
357
376
|
logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
|
|
@@ -49,7 +49,7 @@ export function createApiKeyResolver(
|
|
|
49
49
|
options: ApiKeyResolverOptions = {},
|
|
50
50
|
): ApiKeyResolver {
|
|
51
51
|
const { sessionId, baseUrl, modelId } = options;
|
|
52
|
-
return async ({ lastChance, error, signal }) => {
|
|
52
|
+
return async ({ lastChance, error, signal, previousKey }) => {
|
|
53
53
|
if (error === undefined) {
|
|
54
54
|
return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, modelId });
|
|
55
55
|
}
|
|
@@ -59,7 +59,12 @@ export function createApiKeyResolver(
|
|
|
59
59
|
// sibling exists we switch immediately; the precise no-sibling backoff
|
|
60
60
|
// is owned by `markUsageLimitReached` (default + server usage-report
|
|
61
61
|
// reset) and the outer whole-turn retry layer.
|
|
62
|
-
await registry.authStorage.rotateSessionCredential(provider, sessionId, {
|
|
62
|
+
await registry.authStorage.rotateSessionCredential(provider, sessionId, {
|
|
63
|
+
error,
|
|
64
|
+
modelId,
|
|
65
|
+
signal,
|
|
66
|
+
apiKey: previousKey,
|
|
67
|
+
});
|
|
63
68
|
return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, modelId });
|
|
64
69
|
}
|
|
65
70
|
return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, modelId, forceRefresh: true, signal });
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
TUI_KEYBINDINGS,
|
|
10
10
|
KeybindingsManager as TuiKeybindingsManager,
|
|
11
11
|
} from "@oh-my-pi/pi-tui";
|
|
12
|
-
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
12
|
+
import { getActiveProfile, getAgentDir, getProfileRootDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
13
13
|
import { JSONC, YAML } from "bun";
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -375,6 +375,12 @@ interface KeybindingsConfigPaths {
|
|
|
375
375
|
writeBackPath: string;
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
/** Controls inherited keybinding lookup when creating a manager for a named profile. */
|
|
379
|
+
export interface KeybindingsCreateOptions {
|
|
380
|
+
/** Default-profile agent directory whose keybindings are merged before profile-specific bindings. */
|
|
381
|
+
inheritedAgentDir?: string;
|
|
382
|
+
}
|
|
383
|
+
|
|
378
384
|
/**
|
|
379
385
|
* Load raw config from a file synchronously.
|
|
380
386
|
* Returns parsed JSON/YAML or null if file doesn't exist or is invalid.
|
|
@@ -428,6 +434,48 @@ function resolveKeybindingsConfigPaths(agentDir: string): KeybindingsConfigPaths
|
|
|
428
434
|
return { readPath: ymlPath, writeBackPath: ymlPath };
|
|
429
435
|
}
|
|
430
436
|
|
|
437
|
+
function mergeKeybindingsConfig(
|
|
438
|
+
inheritedConfig: KeybindingsConfig,
|
|
439
|
+
profileConfig: KeybindingsConfig,
|
|
440
|
+
): KeybindingsConfig {
|
|
441
|
+
return { ...inheritedConfig, ...profileConfig };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function resolveInheritedAgentDir(agentDir: string, options: KeybindingsCreateOptions): string | undefined {
|
|
445
|
+
const inheritedAgentDir =
|
|
446
|
+
options.inheritedAgentDir ?? (getActiveProfile() ? path.join(getProfileRootDir(undefined), "agent") : undefined);
|
|
447
|
+
if (!inheritedAgentDir) return undefined;
|
|
448
|
+
if (path.resolve(inheritedAgentDir) === path.resolve(agentDir)) return undefined;
|
|
449
|
+
return inheritedAgentDir;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function loadMergedKeybindingsConfig(
|
|
453
|
+
agentDir: string,
|
|
454
|
+
options: KeybindingsCreateOptions,
|
|
455
|
+
): {
|
|
456
|
+
config: KeybindingsConfig;
|
|
457
|
+
profilePath: string;
|
|
458
|
+
inheritedPath: string | undefined;
|
|
459
|
+
} {
|
|
460
|
+
const profilePaths = resolveKeybindingsConfigPaths(agentDir);
|
|
461
|
+
const profile = loadKeybindingsConfig(profilePaths.readPath, profilePaths.writeBackPath);
|
|
462
|
+
const inheritedAgentDir = resolveInheritedAgentDir(agentDir, options);
|
|
463
|
+
if (!inheritedAgentDir) {
|
|
464
|
+
return { config: profile.config, profilePath: profile.persistedPath, inheritedPath: undefined };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const inheritedPaths = resolveKeybindingsConfigPaths(inheritedAgentDir);
|
|
468
|
+
// Read-only: a named-profile process must never write migration output into
|
|
469
|
+
// the default profile's agent dir. Name migration still applies in-memory;
|
|
470
|
+
// the on-disk migration happens when the default profile itself launches.
|
|
471
|
+
const inherited = loadKeybindingsConfig(inheritedPaths.readPath, undefined);
|
|
472
|
+
return {
|
|
473
|
+
config: mergeKeybindingsConfig(inherited.config, profile.config),
|
|
474
|
+
profilePath: profile.persistedPath,
|
|
475
|
+
inheritedPath: inherited.persistedPath,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
431
479
|
/**
|
|
432
480
|
* Load and migrate keybindings config.
|
|
433
481
|
* Legacy JSON is read for compatibility, but successful write-back goes to YAML.
|
|
@@ -499,22 +547,23 @@ function keyConfigValue(keys: KeyId[]): KeyId | KeyId[] {
|
|
|
499
547
|
*/
|
|
500
548
|
export class KeybindingsManager extends TuiKeybindingsManager {
|
|
501
549
|
#configPath: string | undefined;
|
|
550
|
+
#inheritedConfigPath: string | undefined;
|
|
502
551
|
#userBindings: KeybindingsConfig;
|
|
503
552
|
|
|
504
|
-
constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
|
|
553
|
+
constructor(userBindings: KeybindingsConfig = {}, configPath?: string, inheritedConfigPath?: string) {
|
|
505
554
|
super(KEYBINDINGS, userBindings);
|
|
506
555
|
this.#configPath = configPath;
|
|
556
|
+
this.#inheritedConfigPath = inheritedConfigPath;
|
|
507
557
|
this.#userBindings = userBindings;
|
|
508
558
|
}
|
|
509
559
|
|
|
510
560
|
/**
|
|
511
|
-
* Create from config
|
|
561
|
+
* Create from config files at agentDir/keybindings.yml and the default profile.
|
|
512
562
|
* Legacy keybindings.json is migrated to keybindings.yml on load.
|
|
513
563
|
*/
|
|
514
|
-
static create(agentDir: string = getAgentDir()): KeybindingsManager {
|
|
515
|
-
const {
|
|
516
|
-
const
|
|
517
|
-
const manager = new KeybindingsManager(userBindings, persistedPath);
|
|
564
|
+
static create(agentDir: string = getAgentDir(), options: KeybindingsCreateOptions = {}): KeybindingsManager {
|
|
565
|
+
const { config: userBindings, profilePath, inheritedPath } = loadMergedKeybindingsConfig(agentDir, options);
|
|
566
|
+
const manager = new KeybindingsManager(userBindings, profilePath, inheritedPath);
|
|
518
567
|
// Set globally so getKeybindings() returns this manager
|
|
519
568
|
setKeybindings(manager);
|
|
520
569
|
return manager;
|
|
@@ -528,12 +577,15 @@ export class KeybindingsManager extends TuiKeybindingsManager {
|
|
|
528
577
|
}
|
|
529
578
|
|
|
530
579
|
/**
|
|
531
|
-
* Reload keybindings from the config
|
|
580
|
+
* Reload keybindings from the config files.
|
|
532
581
|
*/
|
|
533
582
|
reload(): void {
|
|
534
583
|
if (!this.#configPath) return;
|
|
535
|
-
const { config } =
|
|
536
|
-
|
|
584
|
+
const { config: inheritedConfig } = this.#inheritedConfigPath
|
|
585
|
+
? KeybindingsManager.#loadFromFile(this.#inheritedConfigPath)
|
|
586
|
+
: { config: {} };
|
|
587
|
+
const { config: profileConfig } = KeybindingsManager.#loadFromFile(this.#configPath);
|
|
588
|
+
this.setUserBindings(mergeKeybindingsConfig(inheritedConfig, profileConfig));
|
|
537
589
|
}
|
|
538
590
|
|
|
539
591
|
setUserBindings(userBindings: KeybindingsConfig): void {
|
|
@@ -54,6 +54,12 @@ const LOCAL_PROVIDER_PLACEHOLDERS = new Set<string>(["llama-cpp-local", "lm-stud
|
|
|
54
54
|
* so a successful fast path does not leave an armed timeout signal for concurrent GC.
|
|
55
55
|
*/
|
|
56
56
|
const RUNTIME_DYNAMIC_MODEL_FETCH_TIMEOUT_MS = 15_000;
|
|
57
|
+
// Built-in discovery preflight mirror of the catalog model-manager's private
|
|
58
|
+
// cache timings (model-manager.ts: DEFAULT_CACHE_TTL_MS / NON_AUTHORITATIVE_RETRY_MS).
|
|
59
|
+
// Built-in descriptors never override cacheTtlMs, so agreeing with these values
|
|
60
|
+
// makes the OAuth-refresh preflight fire exactly when the manager will fetch.
|
|
61
|
+
const BUILT_IN_DISCOVERY_CACHE_TTL_MS = 2 * 60 * 60 * 1000;
|
|
62
|
+
const BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS = 5 * 60 * 1000;
|
|
57
63
|
|
|
58
64
|
import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai";
|
|
59
65
|
import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
|
|
@@ -1560,12 +1566,11 @@ export class ModelRegistry {
|
|
|
1560
1566
|
): Promise<BuiltInDiscoveryResult> {
|
|
1561
1567
|
// Skip providers already handled by configured discovery (e.g. user-configured ollama with discovery.type)
|
|
1562
1568
|
const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(p => p.provider));
|
|
1563
|
-
const managerOptions =
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
});
|
|
1569
|
+
const managerOptions = await this.#collectBuiltInModelManagerOptions(
|
|
1570
|
+
strategy,
|
|
1571
|
+
providerFilter,
|
|
1572
|
+
configuredDiscoveryProviders,
|
|
1573
|
+
);
|
|
1569
1574
|
if (managerOptions.length === 0) {
|
|
1570
1575
|
return { models: [], authoritativeProviders: new Set() };
|
|
1571
1576
|
}
|
|
@@ -1583,7 +1588,49 @@ export class ModelRegistry {
|
|
|
1583
1588
|
return { models, authoritativeProviders };
|
|
1584
1589
|
}
|
|
1585
1590
|
|
|
1586
|
-
async #
|
|
1591
|
+
async #resolveBuiltInDiscoveryApiKey(
|
|
1592
|
+
providerId: string,
|
|
1593
|
+
strategy: ModelRefreshStrategy,
|
|
1594
|
+
cacheProviderId: string,
|
|
1595
|
+
): Promise<string | undefined> {
|
|
1596
|
+
const peekedKey = await this.#peekApiKeyForProvider(providerId);
|
|
1597
|
+
if (isAuthenticated(peekedKey) || strategy === "offline") {
|
|
1598
|
+
return peekedKey;
|
|
1599
|
+
}
|
|
1600
|
+
const oauthCredentials = getOAuthCredentialsForProvider(this.authStorage, providerId);
|
|
1601
|
+
if (oauthCredentials.length === 0) {
|
|
1602
|
+
return peekedKey;
|
|
1603
|
+
}
|
|
1604
|
+
if (strategy === "online-if-uncached") {
|
|
1605
|
+
// Mirror shouldFetchRemoteSources: built-in managers use the catalog's
|
|
1606
|
+
// default TTL, so only refresh when the manager will actually fetch.
|
|
1607
|
+
const cache = readModelCache<Api>(
|
|
1608
|
+
cacheProviderId,
|
|
1609
|
+
BUILT_IN_DISCOVERY_CACHE_TTL_MS,
|
|
1610
|
+
Date.now,
|
|
1611
|
+
this.#cacheDbPath,
|
|
1612
|
+
);
|
|
1613
|
+
const cacheAgeMs = cache ? Date.now() - cache.updatedAt : Number.POSITIVE_INFINITY;
|
|
1614
|
+
if (cache?.fresh && (cache.authoritative || cacheAgeMs < BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS)) {
|
|
1615
|
+
return peekedKey;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
try {
|
|
1619
|
+
return await this.getApiKeyForProvider(providerId);
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
logger.debug("OAuth refresh failed during model discovery preflight", {
|
|
1622
|
+
provider: providerId,
|
|
1623
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1624
|
+
});
|
|
1625
|
+
return peekedKey;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
async #collectBuiltInModelManagerOptions(
|
|
1630
|
+
strategy: ModelRefreshStrategy,
|
|
1631
|
+
providerFilter: ReadonlySet<string> | undefined,
|
|
1632
|
+
configuredDiscoveryProviders: ReadonlySet<string>,
|
|
1633
|
+
): Promise<ModelManagerOptions<Api>[]> {
|
|
1587
1634
|
const specialProviderDescriptors: Array<{
|
|
1588
1635
|
providerId: string;
|
|
1589
1636
|
resolveKey: (value: string | undefined) => string | undefined;
|
|
@@ -1622,20 +1669,33 @@ export class ModelRegistry {
|
|
|
1622
1669
|
},
|
|
1623
1670
|
];
|
|
1624
1671
|
const disabledProviders = getDisabledProviderIdsFromSettings();
|
|
1625
|
-
const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(
|
|
1626
|
-
|
|
1672
|
+
const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(descriptor => {
|
|
1673
|
+
if (disabledProviders.has(descriptor.providerId)) return false;
|
|
1674
|
+
if (configuredDiscoveryProviders.has(descriptor.providerId)) return false;
|
|
1675
|
+
return providerFilter ? providerFilter.has(descriptor.providerId) : true;
|
|
1676
|
+
});
|
|
1677
|
+
const enabledSpecialProviderDescriptors = specialProviderDescriptors.filter(descriptor => {
|
|
1678
|
+
if (disabledProviders.has(descriptor.providerId)) return false;
|
|
1679
|
+
if (configuredDiscoveryProviders.has(descriptor.providerId)) return false;
|
|
1680
|
+
return providerFilter ? providerFilter.has(descriptor.providerId) : true;
|
|
1681
|
+
});
|
|
1682
|
+
const standardProviderKeys = await Promise.all(
|
|
1683
|
+
standardProviderDescriptors.map(descriptor => {
|
|
1684
|
+
const discoveryBaseUrl =
|
|
1685
|
+
this.#runtimeProviderOverrides.get(descriptor.providerId)?.baseUrl ??
|
|
1686
|
+
this.#providerOverrides.get(descriptor.providerId)?.baseUrl ??
|
|
1687
|
+
this.getProviderBaseUrl(descriptor.providerId);
|
|
1688
|
+
const cacheProviderId =
|
|
1689
|
+
descriptor.createModelManagerOptions({ baseUrl: discoveryBaseUrl, fetch: this.#fetch })
|
|
1690
|
+
.cacheProviderId ?? descriptor.providerId;
|
|
1691
|
+
return this.#resolveBuiltInDiscoveryApiKey(descriptor.providerId, strategy, cacheProviderId);
|
|
1692
|
+
}),
|
|
1627
1693
|
);
|
|
1628
|
-
const
|
|
1629
|
-
descriptor =>
|
|
1694
|
+
const specialKeys = await Promise.all(
|
|
1695
|
+
enabledSpecialProviderDescriptors.map(descriptor =>
|
|
1696
|
+
this.#resolveBuiltInDiscoveryApiKey(descriptor.providerId, strategy, descriptor.providerId),
|
|
1697
|
+
),
|
|
1630
1698
|
);
|
|
1631
|
-
// Use peekApiKey to avoid OAuth token refresh during discovery.
|
|
1632
|
-
// The token is only needed if the dynamic fetch fires (cache miss),
|
|
1633
|
-
// and failures there are handled gracefully.
|
|
1634
|
-
const peekKey = (descriptor: { providerId: string }) => this.#peekApiKeyForProvider(descriptor.providerId);
|
|
1635
|
-
const [standardProviderKeys, specialKeys] = await Promise.all([
|
|
1636
|
-
Promise.all(standardProviderDescriptors.map(peekKey)),
|
|
1637
|
-
Promise.all(enabledSpecialProviderDescriptors.map(peekKey)),
|
|
1638
|
-
]);
|
|
1639
1699
|
const options: ModelManagerOptions<Api>[] = [];
|
|
1640
1700
|
for (let i = 0; i < standardProviderDescriptors.length; i++) {
|
|
1641
1701
|
const descriptor = standardProviderDescriptors[i];
|
|
@@ -1670,7 +1730,12 @@ export class ModelRegistry {
|
|
|
1670
1730
|
}
|
|
1671
1731
|
// Append runtime model managers registered by extensions via fetchDynamicModels.
|
|
1672
1732
|
for (const { options: managerOpts } of this.#runtimeModelManagers.values()) {
|
|
1673
|
-
|
|
1733
|
+
if (
|
|
1734
|
+
!configuredDiscoveryProviders.has(managerOpts.providerId) &&
|
|
1735
|
+
(!providerFilter || providerFilter.has(managerOpts.providerId))
|
|
1736
|
+
) {
|
|
1737
|
+
options.push(managerOpts);
|
|
1738
|
+
}
|
|
1674
1739
|
}
|
|
1675
1740
|
return options;
|
|
1676
1741
|
}
|
|
@@ -2266,6 +2331,15 @@ export class ModelRegistry {
|
|
|
2266
2331
|
return true;
|
|
2267
2332
|
}
|
|
2268
2333
|
|
|
2334
|
+
/**
|
|
2335
|
+
* Clear the cooldown suppression for one selector after an explicit user selection.
|
|
2336
|
+
*/
|
|
2337
|
+
clearSuppressedSelector(selector: string): void {
|
|
2338
|
+
this.#suppressedSelectors.delete(
|
|
2339
|
+
normalizeSuppressedSelector(selector, (provider, id) => this.find(provider, id) !== undefined),
|
|
2340
|
+
);
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2269
2343
|
/**
|
|
2270
2344
|
* Clear all cooldown suppressions recorded via {@link suppressSelector}.
|
|
2271
2345
|
* Used to reset retry-fallback cooldown state without a full {@link refresh}.
|
|
@@ -1367,7 +1367,17 @@ export const SETTINGS_SCHEMA = {
|
|
|
1367
1367
|
description: "Allow retry recovery to switch to configured fallback models",
|
|
1368
1368
|
},
|
|
1369
1369
|
},
|
|
1370
|
-
"retry.fallbackChains": {
|
|
1370
|
+
"retry.fallbackChains": {
|
|
1371
|
+
type: "record",
|
|
1372
|
+
default: {} as Record<string, string[]>,
|
|
1373
|
+
ui: {
|
|
1374
|
+
tab: "model",
|
|
1375
|
+
group: "Retry & Fallback",
|
|
1376
|
+
label: "Retry Fallback Chains",
|
|
1377
|
+
description:
|
|
1378
|
+
'JSON object mapping model roles to ordered fallback model selectors, e.g. {"default":["openai/gpt-4o-mini"]}.',
|
|
1379
|
+
},
|
|
1380
|
+
},
|
|
1371
1381
|
"retry.fallbackRevertPolicy": {
|
|
1372
1382
|
type: "enum",
|
|
1373
1383
|
values: ["cooldown-expiry", "never"] as const,
|