@oh-my-pi/pi-coding-agent 16.1.14 → 16.1.15
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 +21 -0
- package/dist/cli.js +2449 -2455
- package/dist/types/advisor/runtime.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +20 -0
- package/dist/types/export/share.d.ts +8 -1
- package/dist/types/mcp/transports/stdio.d.ts +12 -1
- package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
- package/dist/types/modes/components/status-line/types.d.ts +1 -0
- package/dist/types/secrets/obfuscator.d.ts +3 -3
- package/dist/types/utils/shell-snapshot.d.ts +10 -0
- package/dist/types/web/search/providers/perplexity.d.ts +17 -3
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +114 -0
- package/src/advisor/runtime.ts +129 -1
- package/src/config/model-registry.ts +12 -4
- package/src/config/settings-schema.ts +24 -0
- package/src/exec/bash-executor.ts +44 -0
- package/src/export/share.ts +51 -28
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/transports/stdio.ts +20 -4
- package/src/modes/components/custom-editor.test.ts +22 -0
- package/src/modes/components/custom-editor.ts +10 -1
- package/src/modes/components/footer.ts +4 -3
- package/src/modes/components/status-line/component.ts +5 -1
- package/src/modes/components/status-line/context-thresholds.ts +11 -3
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/controllers/command-controller.ts +1 -0
- package/src/prompts/system/system-prompt.md +5 -5
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/search.md +1 -0
- package/src/secrets/obfuscator.ts +3 -9
- package/src/session/agent-session.ts +33 -2
- package/src/slash-commands/builtin-registry.ts +2 -1
- package/src/utils/shell-snapshot.ts +63 -1
- package/src/web/search/providers/perplexity.ts +18 -6
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
2
|
+
import { type SecretObfuscator } from "../secrets/obfuscator";
|
|
2
3
|
/** Minimal slice of `Agent` the runtime drives — satisfied by pi-agent-core `Agent`. */
|
|
3
4
|
export interface AdvisorAgent {
|
|
4
5
|
prompt(input: string): Promise<void>;
|
|
@@ -13,6 +14,8 @@ export interface AdvisorRuntimeHost {
|
|
|
13
14
|
snapshotMessages(): AgentMessage[];
|
|
14
15
|
/** Surface one advice note to the primary (enqueues into the session YieldQueue). */
|
|
15
16
|
enqueueAdvice(note: string, severity?: "nit" | "concern" | "blocker"): void;
|
|
17
|
+
/** Redact primary transcript bytes before they reach the advisor model. */
|
|
18
|
+
obfuscator?: SecretObfuscator;
|
|
16
19
|
/**
|
|
17
20
|
* Pre-prompt context maintenance for the advisor's own append-only context.
|
|
18
21
|
* Promotes the advisor model to a larger sibling when its context nears the
|
|
@@ -1636,6 +1636,26 @@ export declare const SETTINGS_SCHEMA: {
|
|
|
1636
1636
|
readonly description: "Share viewer/upload base used by /share (encrypted blob upload + viewer; links are <base>/<id>#<key>)";
|
|
1637
1637
|
};
|
|
1638
1638
|
};
|
|
1639
|
+
readonly "share.store": {
|
|
1640
|
+
readonly type: "enum";
|
|
1641
|
+
readonly values: readonly ["blob", "gist"];
|
|
1642
|
+
readonly default: "blob";
|
|
1643
|
+
readonly ui: {
|
|
1644
|
+
readonly tab: "interaction";
|
|
1645
|
+
readonly group: "Collab";
|
|
1646
|
+
readonly label: "Share Store";
|
|
1647
|
+
readonly description: "Where /share uploads the encrypted session blob";
|
|
1648
|
+
readonly options: readonly [{
|
|
1649
|
+
readonly value: "blob";
|
|
1650
|
+
readonly label: "Encrypted Blob";
|
|
1651
|
+
readonly description: "Upload to the share server (no GitHub account needed; avoids gist API rate limits)";
|
|
1652
|
+
}, {
|
|
1653
|
+
readonly value: "gist";
|
|
1654
|
+
readonly label: "GitHub Gist";
|
|
1655
|
+
readonly description: "Push to a secret gist (needs authenticated gh), falling back to the share server";
|
|
1656
|
+
}];
|
|
1657
|
+
};
|
|
1658
|
+
};
|
|
1639
1659
|
readonly "share.redactSecrets": {
|
|
1640
1660
|
readonly type: "boolean";
|
|
1641
1661
|
readonly default: true;
|
|
@@ -6,9 +6,16 @@ import { type SessionData } from "./html";
|
|
|
6
6
|
export { DEFAULT_SHARE_URL };
|
|
7
7
|
/** Hard cap for blobs accepted by the share server (mirrors relay shareMaxBytes). */
|
|
8
8
|
export declare const SERVER_MAX_SEALED_BYTES = 1000000;
|
|
9
|
+
export type ShareStore = "blob" | "gist";
|
|
9
10
|
export interface ShareSessionOptions {
|
|
10
11
|
/** Share server/viewer base URL; defaults to {@link DEFAULT_SHARE_URL}. */
|
|
11
12
|
serverUrl?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Where to upload the sealed blob. `"blob"` (default) posts to the share
|
|
15
|
+
* server; `"gist"` pushes to a secret GitHub gist first (needs an
|
|
16
|
+
* authenticated `gh`) and falls back to the server.
|
|
17
|
+
*/
|
|
18
|
+
store?: ShareStore;
|
|
12
19
|
/** Agent state for system prompt + tool descriptions in the snapshot. */
|
|
13
20
|
state?: AgentState;
|
|
14
21
|
/**
|
|
@@ -36,7 +43,7 @@ export interface ShareSessionResult {
|
|
|
36
43
|
}
|
|
37
44
|
/** Build the snapshot that gets sealed and uploaded, redacted when an obfuscator is provided. */
|
|
38
45
|
export declare function buildShareSnapshot(sm: SessionManager, options?: ShareSessionOptions): SessionData;
|
|
39
|
-
/** Share the session;
|
|
46
|
+
/** Share the session; uploads to the share server unless `options.store` is `"gist"`. */
|
|
40
47
|
export declare function shareSession(sm: SessionManager, options?: ShareSessionOptions): Promise<ShareSessionResult>;
|
|
41
48
|
/** Strip trailing slashes so `<base>/<id>` composes cleanly. */
|
|
42
49
|
export declare function normalizeShareServerUrl(serverUrl?: string): string;
|
|
@@ -16,7 +16,18 @@ export interface ResolveStdioSpawnOptions {
|
|
|
16
16
|
env: Record<string, string | undefined>;
|
|
17
17
|
platform?: NodeJS.Platform;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the subprocess argv used to launch an MCP stdio server.
|
|
21
|
+
*
|
|
22
|
+
* On Windows, our PATH/PATHEXT walk may return `null` for a bare command
|
|
23
|
+
* (e.g. `npx`) — `Bun.env.PATH` empty under a restricted parent process,
|
|
24
|
+
* UNC/network mounts that reject `fs.access`, locked-down shells. The
|
|
25
|
+
* legacy fallback handed `Bun.spawn` the bare name, but `CreateProcess`
|
|
26
|
+
* only appends `.exe` for extensionless names — `.cmd`/`.bat` are never
|
|
27
|
+
* tried, so `npx` (which exists only as `npx.cmd` on Windows) crashes the
|
|
28
|
+
* subprocess immediately. When the resolver can't pin the command down,
|
|
29
|
+
* route through `cmd.exe /d /s /c` so Windows's own PATHEXT lookup runs.
|
|
30
|
+
*/
|
|
20
31
|
export declare function resolveStdioSpawnCommand(config: MCPStdioServerConfig, options: ResolveStdioSpawnOptions): Promise<StdioSpawnCommand>;
|
|
21
32
|
/** Minimal write surface of `Subprocess.stdin` we need for framed sends. */
|
|
22
33
|
interface FrameSink {
|
|
@@ -2,8 +2,9 @@ import type { ThemeColor } from "../../../modes/theme/theme";
|
|
|
2
2
|
export type ContextUsageLevel = "normal" | "warning" | "purple" | "error";
|
|
3
3
|
export declare function getContextUsageLevel(contextPercent: number, contextWindow: number): ContextUsageLevel;
|
|
4
4
|
/**
|
|
5
|
-
* Format context usage as `<percent>%/<window>`
|
|
6
|
-
*
|
|
5
|
+
* Format context usage as `<percent>%/<window>` when the model window is known.
|
|
6
|
+
* Unknown windows render as `<tokens>/?`, because `0.0%/0` suggests a real
|
|
7
|
+
* empty context instead of missing provider metadata.
|
|
7
8
|
*/
|
|
8
|
-
export declare function formatContextUsage(contextPercent: number | null | undefined, contextWindow: number): string;
|
|
9
|
+
export declare function formatContextUsage(contextPercent: number | null | undefined, contextWindow: number, usedTokens?: number): string;
|
|
9
10
|
export declare function getContextUsageThemeColor(level: ContextUsageLevel): ThemeColor;
|
|
@@ -72,6 +72,7 @@ export interface SegmentContext {
|
|
|
72
72
|
};
|
|
73
73
|
/** Context usage percent, or null when unknown (e.g. right after compaction). */
|
|
74
74
|
contextPercent: number | null;
|
|
75
|
+
contextTokens: number;
|
|
75
76
|
contextWindow: number;
|
|
76
77
|
autoCompactEnabled: boolean;
|
|
77
78
|
subagentCount: number;
|
|
@@ -34,9 +34,9 @@ export declare class SecretObfuscator {
|
|
|
34
34
|
export declare function deobfuscateSessionContext(sessionContext: SessionContext, obfuscator: SecretObfuscator | undefined): SessionContext;
|
|
35
35
|
export declare function deobfuscateAgentMessages(obfuscator: SecretObfuscator, messages: AgentMessage[]): AgentMessage[];
|
|
36
36
|
/**
|
|
37
|
-
* Restore placeholders in assistant content: visible text
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Restore placeholders in assistant content: visible text and tool-call
|
|
38
|
+
* arguments/intent/rawBlock. Thinking and signatures are opaque
|
|
39
|
+
* provider-replay/hidden-reasoning data and pass through byte-identical.
|
|
40
40
|
*/
|
|
41
41
|
export declare function deobfuscateAssistantContent(obfuscator: SecretObfuscator, content: AssistantMessage["content"]): AssistantMessage["content"];
|
|
42
42
|
/**
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip alias definitions brush's whitespace-only expander cannot execute.
|
|
3
|
+
*
|
|
4
|
+
* Returns the rewritten snapshot plus the list of dropped alias names so the
|
|
5
|
+
* caller can surface them in the debug log.
|
|
6
|
+
*/
|
|
7
|
+
export declare function sanitizeSnapshotForBrush(content: string): {
|
|
8
|
+
content: string;
|
|
9
|
+
dropped: string[];
|
|
10
|
+
};
|
|
1
11
|
/**
|
|
2
12
|
* Create a shell snapshot, caching the result.
|
|
3
13
|
* Returns the path to the snapshot file, or null if creation failed.
|
|
@@ -33,11 +33,25 @@ export declare function searchPerplexity(params: PerplexitySearchParams): Promis
|
|
|
33
33
|
export declare class PerplexityProvider extends SearchProvider {
|
|
34
34
|
readonly id = "perplexity";
|
|
35
35
|
readonly label = "Perplexity";
|
|
36
|
+
/**
|
|
37
|
+
* Auto-chain admission. Requires a direct Perplexity credential
|
|
38
|
+
* (`PERPLEXITY_COOKIES`, OAuth session, or `PERPLEXITY_API_KEY`).
|
|
39
|
+
*
|
|
40
|
+
* OpenRouter auth is intentionally NOT accepted here: silently using
|
|
41
|
+
* OpenRouter's `perplexity/sonar-pro` whenever any OpenRouter key is
|
|
42
|
+
* configured surprises users (and bills them) for a path they never
|
|
43
|
+
* asked for. The auto chain skips Perplexity in that case and falls
|
|
44
|
+
* through to the next configured provider. Users who DO want the
|
|
45
|
+
* OpenRouter-backed Perplexity path can still opt in by setting
|
|
46
|
+
* `webSearch: perplexity` explicitly — see {@link isExplicitlyAvailable}.
|
|
47
|
+
*/
|
|
36
48
|
isAvailable(authStorage: AuthStorage): boolean;
|
|
37
49
|
/**
|
|
38
|
-
* Perplexity accepts anonymous browser-style ask requests,
|
|
39
|
-
*
|
|
40
|
-
*
|
|
50
|
+
* Perplexity accepts anonymous browser-style ask requests, and the
|
|
51
|
+
* OpenRouter-backed `perplexity/sonar-pro` path is opt-in through
|
|
52
|
+
* explicit selection. Keep auto-chain admission credential-gated so a
|
|
53
|
+
* configured provider keeps priority over the anonymous/OpenRouter
|
|
54
|
+
* fallbacks.
|
|
41
55
|
*/
|
|
42
56
|
isExplicitlyAvailable(_authStorage: AuthStorage): boolean;
|
|
43
57
|
search(params: SearchParams): Promise<SearchResponse>;
|
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.1.
|
|
4
|
+
"version": "16.1.15",
|
|
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",
|
|
@@ -48,17 +48,17 @@
|
|
|
48
48
|
"@agentclientprotocol/sdk": "0.25.0",
|
|
49
49
|
"@babel/parser": "^7.29.7",
|
|
50
50
|
"@mozilla/readability": "^0.6.0",
|
|
51
|
-
"@oh-my-pi/hashline": "16.1.
|
|
52
|
-
"@oh-my-pi/omp-stats": "16.1.
|
|
53
|
-
"@oh-my-pi/pi-agent-core": "16.1.
|
|
54
|
-
"@oh-my-pi/pi-ai": "16.1.
|
|
55
|
-
"@oh-my-pi/pi-catalog": "16.1.
|
|
56
|
-
"@oh-my-pi/pi-mnemopi": "16.1.
|
|
57
|
-
"@oh-my-pi/pi-natives": "16.1.
|
|
58
|
-
"@oh-my-pi/pi-tui": "16.1.
|
|
59
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
60
|
-
"@oh-my-pi/pi-wire": "16.1.
|
|
61
|
-
"@oh-my-pi/snapcompact": "16.1.
|
|
51
|
+
"@oh-my-pi/hashline": "16.1.15",
|
|
52
|
+
"@oh-my-pi/omp-stats": "16.1.15",
|
|
53
|
+
"@oh-my-pi/pi-agent-core": "16.1.15",
|
|
54
|
+
"@oh-my-pi/pi-ai": "16.1.15",
|
|
55
|
+
"@oh-my-pi/pi-catalog": "16.1.15",
|
|
56
|
+
"@oh-my-pi/pi-mnemopi": "16.1.15",
|
|
57
|
+
"@oh-my-pi/pi-natives": "16.1.15",
|
|
58
|
+
"@oh-my-pi/pi-tui": "16.1.15",
|
|
59
|
+
"@oh-my-pi/pi-utils": "16.1.15",
|
|
60
|
+
"@oh-my-pi/pi-wire": "16.1.15",
|
|
61
|
+
"@oh-my-pi/snapcompact": "16.1.15",
|
|
62
62
|
"@opentelemetry/api": "^1.9.1",
|
|
63
63
|
"@opentelemetry/context-async-hooks": "^2.7.1",
|
|
64
64
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
|
@@ -3,6 +3,7 @@ import type { AgentMessage, AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core
|
|
|
3
3
|
import { type } from "arktype";
|
|
4
4
|
import { createAdvisorMessageCard } from "../../modes/components/advisor-message";
|
|
5
5
|
import { getThemeByName } from "../../modes/theme/theme";
|
|
6
|
+
import { SecretObfuscator } from "../../secrets/obfuscator";
|
|
6
7
|
import { formatSessionHistoryMarkdown } from "../../session/session-history-format";
|
|
7
8
|
import { YieldQueue } from "../../session/yield-queue";
|
|
8
9
|
import {
|
|
@@ -447,6 +448,119 @@ describe("advisor", () => {
|
|
|
447
448
|
expect(promptInputs[0]).not.toContain("note");
|
|
448
449
|
});
|
|
449
450
|
|
|
451
|
+
it("obfuscates session updates before prompting the advisor", async () => {
|
|
452
|
+
const secret = "ADVISOR_SECRET_TOKEN_123";
|
|
453
|
+
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
|
|
454
|
+
const placeholder = obfuscator.obfuscate(secret);
|
|
455
|
+
const promptInputs: string[] = [];
|
|
456
|
+
const agent = makeAgent(promptInputs);
|
|
457
|
+
const messages: AgentMessage[] = [{ role: "user", content: `token ${secret}`, timestamp: 1 } as AgentMessage];
|
|
458
|
+
const host: AdvisorRuntimeHost = {
|
|
459
|
+
snapshotMessages: () => messages,
|
|
460
|
+
enqueueAdvice: () => {},
|
|
461
|
+
obfuscator,
|
|
462
|
+
};
|
|
463
|
+
const runtime = new AdvisorRuntime(agent, host);
|
|
464
|
+
|
|
465
|
+
runtime.onTurnEnd();
|
|
466
|
+
await Promise.resolve();
|
|
467
|
+
|
|
468
|
+
expect(promptInputs).toHaveLength(1);
|
|
469
|
+
expect(promptInputs[0]).toContain(placeholder);
|
|
470
|
+
expect(promptInputs[0]).not.toContain(secret);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("redacts expanded primary context before XML escaping", async () => {
|
|
474
|
+
const secret = "ADVISOR&SECRET<TOKEN>123";
|
|
475
|
+
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
|
|
476
|
+
const placeholder = obfuscator.obfuscate(secret);
|
|
477
|
+
const promptInputs: string[] = [];
|
|
478
|
+
const agent = makeAgent(promptInputs);
|
|
479
|
+
const messages: AgentMessage[] = [
|
|
480
|
+
{
|
|
481
|
+
role: "custom",
|
|
482
|
+
customType: "plan-mode-context",
|
|
483
|
+
content: `Plan mode carries ${secret}`,
|
|
484
|
+
display: false,
|
|
485
|
+
timestamp: 1,
|
|
486
|
+
} as AgentMessage,
|
|
487
|
+
];
|
|
488
|
+
const host: AdvisorRuntimeHost = {
|
|
489
|
+
snapshotMessages: () => messages,
|
|
490
|
+
enqueueAdvice: () => {},
|
|
491
|
+
obfuscator,
|
|
492
|
+
};
|
|
493
|
+
const runtime = new AdvisorRuntime(agent, host);
|
|
494
|
+
|
|
495
|
+
runtime.onTurnEnd();
|
|
496
|
+
await Promise.resolve();
|
|
497
|
+
|
|
498
|
+
expect(promptInputs).toHaveLength(1);
|
|
499
|
+
expect(promptInputs[0]).toContain(placeholder);
|
|
500
|
+
expect(promptInputs[0]).not.toContain(secret);
|
|
501
|
+
expect(promptInputs[0]).not.toContain("ADVISOR&SECRET<TOKEN>123");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("redacts file-mention paths before formatting", async () => {
|
|
505
|
+
const secret = "MENTION_SECRET_TOKEN_123";
|
|
506
|
+
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
|
|
507
|
+
const placeholder = obfuscator.obfuscate(secret);
|
|
508
|
+
const promptInputs: string[] = [];
|
|
509
|
+
const agent = makeAgent(promptInputs);
|
|
510
|
+
const messages: AgentMessage[] = [
|
|
511
|
+
{
|
|
512
|
+
role: "fileMention",
|
|
513
|
+
files: [{ path: `notes/${secret}.txt`, content: "ignored" }],
|
|
514
|
+
timestamp: 1,
|
|
515
|
+
} as unknown as AgentMessage,
|
|
516
|
+
];
|
|
517
|
+
const host: AdvisorRuntimeHost = {
|
|
518
|
+
snapshotMessages: () => messages,
|
|
519
|
+
enqueueAdvice: () => {},
|
|
520
|
+
obfuscator,
|
|
521
|
+
};
|
|
522
|
+
const runtime = new AdvisorRuntime(agent, host);
|
|
523
|
+
|
|
524
|
+
runtime.onTurnEnd();
|
|
525
|
+
await Promise.resolve();
|
|
526
|
+
|
|
527
|
+
expect(promptInputs).toHaveLength(1);
|
|
528
|
+
expect(promptInputs[0]).toContain(placeholder);
|
|
529
|
+
expect(promptInputs[0]).not.toContain(secret);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("redacts nested async-result job labels before formatting", async () => {
|
|
533
|
+
const secret = "JOB_LABEL_SECRET_TOKEN_123";
|
|
534
|
+
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
|
|
535
|
+
const placeholder = obfuscator.obfuscate(secret);
|
|
536
|
+
const promptInputs: string[] = [];
|
|
537
|
+
const agent = makeAgent(promptInputs);
|
|
538
|
+
const messages: AgentMessage[] = [
|
|
539
|
+
{
|
|
540
|
+
role: "custom",
|
|
541
|
+
customType: "async-result",
|
|
542
|
+
content: "",
|
|
543
|
+
details: { jobs: [{ label: `bash: echo ${secret}`, jobId: "j1" }] },
|
|
544
|
+
display: true,
|
|
545
|
+
attribution: "agent",
|
|
546
|
+
timestamp: 1,
|
|
547
|
+
} as unknown as AgentMessage,
|
|
548
|
+
];
|
|
549
|
+
const host: AdvisorRuntimeHost = {
|
|
550
|
+
snapshotMessages: () => messages,
|
|
551
|
+
enqueueAdvice: () => {},
|
|
552
|
+
obfuscator,
|
|
553
|
+
};
|
|
554
|
+
const runtime = new AdvisorRuntime(agent, host);
|
|
555
|
+
|
|
556
|
+
runtime.onTurnEnd();
|
|
557
|
+
await Promise.resolve();
|
|
558
|
+
|
|
559
|
+
expect(promptInputs).toHaveLength(1);
|
|
560
|
+
expect(promptInputs[0]).toContain(placeholder);
|
|
561
|
+
expect(promptInputs[0]).not.toContain(secret);
|
|
562
|
+
});
|
|
563
|
+
|
|
450
564
|
it("expands plan-mode context once, then collapses an unchanged re-injection", async () => {
|
|
451
565
|
const promptInputs: string[] = [];
|
|
452
566
|
const agent = makeAgent(promptInputs);
|
package/src/advisor/runtime.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
2
2
|
import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
|
|
3
|
+
import type { AssistantMessage, ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
3
4
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { obfuscateToolArguments, type SecretObfuscator } from "../secrets/obfuscator";
|
|
4
6
|
import { formatSessionHistoryMarkdown, PRIMARY_CONTEXT_CUSTOM_TYPES } from "../session/session-history-format";
|
|
5
7
|
|
|
6
8
|
/** Minimal slice of `Agent` the runtime drives — satisfied by pi-agent-core `Agent`. */
|
|
@@ -16,6 +18,8 @@ export interface AdvisorRuntimeHost {
|
|
|
16
18
|
snapshotMessages(): AgentMessage[];
|
|
17
19
|
/** Surface one advice note to the primary (enqueues into the session YieldQueue). */
|
|
18
20
|
enqueueAdvice(note: string, severity?: "nit" | "concern" | "blocker"): void;
|
|
21
|
+
/** Redact primary transcript bytes before they reach the advisor model. */
|
|
22
|
+
obfuscator?: SecretObfuscator;
|
|
19
23
|
/**
|
|
20
24
|
* Pre-prompt context maintenance for the advisor's own append-only context.
|
|
21
25
|
* Promotes the advisor model to a larger sibling when its context nears the
|
|
@@ -174,7 +178,9 @@ export class AdvisorRuntime {
|
|
|
174
178
|
.map(m => this.#dedupContextMessage(m));
|
|
175
179
|
this.#lastCount = all.length;
|
|
176
180
|
if (delta.length === 0) return null;
|
|
177
|
-
const
|
|
181
|
+
const obfuscator = this.host.obfuscator;
|
|
182
|
+
const formattedDelta = obfuscator?.hasSecrets() ? obfuscateAdvisorDelta(obfuscator, delta) : delta;
|
|
183
|
+
const md = formatSessionHistoryMarkdown(formattedDelta, {
|
|
178
184
|
includeThinking: true,
|
|
179
185
|
includeToolIntent: true,
|
|
180
186
|
watchedRoles: true,
|
|
@@ -304,3 +310,125 @@ export class AdvisorRuntime {
|
|
|
304
310
|
}
|
|
305
311
|
}
|
|
306
312
|
}
|
|
313
|
+
|
|
314
|
+
type TextualContent = string | readonly (TextContent | ImageContent)[];
|
|
315
|
+
|
|
316
|
+
function obfuscateTextualContent(obfuscator: SecretObfuscator, content: TextualContent): TextualContent {
|
|
317
|
+
if (typeof content === "string") return obfuscator.obfuscate(content);
|
|
318
|
+
let changed = false;
|
|
319
|
+
const result = content.map((block): TextContent | ImageContent => {
|
|
320
|
+
if (block.type !== "text") return block;
|
|
321
|
+
const text = obfuscator.obfuscate(block.text);
|
|
322
|
+
if (text === block.text) return block;
|
|
323
|
+
changed = true;
|
|
324
|
+
return { ...block, text };
|
|
325
|
+
});
|
|
326
|
+
return changed ? result : content;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function obfuscateAssistantMessage(obfuscator: SecretObfuscator, message: AssistantMessage): AssistantMessage {
|
|
330
|
+
let changed = false;
|
|
331
|
+
const content = message.content.map((block): AssistantMessage["content"][number] => {
|
|
332
|
+
if (block.type === "text") {
|
|
333
|
+
const text = obfuscator.obfuscate(block.text);
|
|
334
|
+
if (text === block.text) return block;
|
|
335
|
+
changed = true;
|
|
336
|
+
return { ...block, text };
|
|
337
|
+
}
|
|
338
|
+
if (block.type === "toolCall") {
|
|
339
|
+
const args = obfuscateToolArguments(obfuscator, block.arguments);
|
|
340
|
+
if (args === block.arguments) return block;
|
|
341
|
+
changed = true;
|
|
342
|
+
return { ...block, arguments: args };
|
|
343
|
+
}
|
|
344
|
+
return block;
|
|
345
|
+
});
|
|
346
|
+
return changed ? { ...message, content } : message;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function obfuscateDetails(
|
|
350
|
+
obfuscator: SecretObfuscator,
|
|
351
|
+
details: Record<string, unknown> | undefined,
|
|
352
|
+
): Record<string, unknown> | undefined {
|
|
353
|
+
if (!details) return details;
|
|
354
|
+
// Walk strings at every depth: `customOneLiner` renders nested fields
|
|
355
|
+
// (e.g. `async-result` reads `details.jobs[].label`/`jobId`), so a shallow
|
|
356
|
+
// pass leaks any secret a background job's label happens to contain.
|
|
357
|
+
return obfuscateToolArguments(obfuscator, details);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function obfuscateAdvisorMessage(obfuscator: SecretObfuscator, message: AgentMessage): AgentMessage {
|
|
361
|
+
switch (message.role) {
|
|
362
|
+
case "user":
|
|
363
|
+
case "developer":
|
|
364
|
+
case "toolResult": {
|
|
365
|
+
const content = obfuscateTextualContent(obfuscator, message.content as TextualContent);
|
|
366
|
+
return content === message.content ? message : ({ ...(message as object), content } as AgentMessage);
|
|
367
|
+
}
|
|
368
|
+
case "assistant":
|
|
369
|
+
return obfuscateAssistantMessage(obfuscator, message as AssistantMessage) as AgentMessage;
|
|
370
|
+
case "custom":
|
|
371
|
+
case "hookMessage": {
|
|
372
|
+
const msg = message as AgentMessage & {
|
|
373
|
+
content: TextualContent;
|
|
374
|
+
details?: Record<string, unknown>;
|
|
375
|
+
};
|
|
376
|
+
const content = obfuscateTextualContent(obfuscator, msg.content);
|
|
377
|
+
const details = obfuscateDetails(obfuscator, msg.details);
|
|
378
|
+
if (content === msg.content && details === msg.details) return message;
|
|
379
|
+
return { ...(message as object), content, details } as AgentMessage;
|
|
380
|
+
}
|
|
381
|
+
case "bashExecution": {
|
|
382
|
+
const msg = message as AgentMessage & { command: string; output: string };
|
|
383
|
+
const command = obfuscator.obfuscate(msg.command);
|
|
384
|
+
const output = obfuscator.obfuscate(msg.output);
|
|
385
|
+
return command === msg.command && output === msg.output
|
|
386
|
+
? message
|
|
387
|
+
: ({ ...(message as object), command, output } as AgentMessage);
|
|
388
|
+
}
|
|
389
|
+
case "pythonExecution": {
|
|
390
|
+
const msg = message as AgentMessage & { code: string; output: string };
|
|
391
|
+
const code = obfuscator.obfuscate(msg.code);
|
|
392
|
+
const output = obfuscator.obfuscate(msg.output);
|
|
393
|
+
return code === msg.code && output === msg.output
|
|
394
|
+
? message
|
|
395
|
+
: ({ ...(message as object), code, output } as AgentMessage);
|
|
396
|
+
}
|
|
397
|
+
case "branchSummary": {
|
|
398
|
+
const msg = message as AgentMessage & { summary: string };
|
|
399
|
+
const summary = obfuscator.obfuscate(msg.summary);
|
|
400
|
+
return summary === msg.summary ? message : ({ ...(message as object), summary } as AgentMessage);
|
|
401
|
+
}
|
|
402
|
+
case "compactionSummary": {
|
|
403
|
+
const msg = message as AgentMessage & { summary: string };
|
|
404
|
+
const summary = obfuscator.obfuscate(msg.summary);
|
|
405
|
+
return summary === msg.summary ? message : ({ ...(message as object), summary } as AgentMessage);
|
|
406
|
+
}
|
|
407
|
+
case "fileMention": {
|
|
408
|
+
const msg = message as AgentMessage & {
|
|
409
|
+
files: Array<{ path: string; content: string; image?: unknown }>;
|
|
410
|
+
};
|
|
411
|
+
let changed = false;
|
|
412
|
+
const files = msg.files.map(file => {
|
|
413
|
+
const path = obfuscator.obfuscate(file.path);
|
|
414
|
+
const content = obfuscator.obfuscate(file.content);
|
|
415
|
+
if (path === file.path && content === file.content) return file;
|
|
416
|
+
changed = true;
|
|
417
|
+
return { ...file, path, content };
|
|
418
|
+
});
|
|
419
|
+
return changed ? ({ ...(message as object), files } as AgentMessage) : message;
|
|
420
|
+
}
|
|
421
|
+
default:
|
|
422
|
+
return message;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function obfuscateAdvisorDelta(obfuscator: SecretObfuscator, messages: AgentMessage[]): AgentMessage[] {
|
|
427
|
+
let changed = false;
|
|
428
|
+
const result = messages.map(message => {
|
|
429
|
+
const next = obfuscateAdvisorMessage(obfuscator, message);
|
|
430
|
+
if (next !== message) changed = true;
|
|
431
|
+
return next;
|
|
432
|
+
});
|
|
433
|
+
return changed ? result : messages;
|
|
434
|
+
}
|
|
@@ -992,6 +992,7 @@ export class ModelRegistry {
|
|
|
992
992
|
});
|
|
993
993
|
continue;
|
|
994
994
|
}
|
|
995
|
+
const configStale = this.#isDiscoveryCacheOlderThanModelsConfig(cache.updatedAt);
|
|
995
996
|
const models = this.#applyProviderModelOverrides(
|
|
996
997
|
providerConfig.provider,
|
|
997
998
|
this.#normalizeDiscoverableModels(
|
|
@@ -1007,7 +1008,7 @@ export class ModelRegistry {
|
|
|
1007
1008
|
provider: providerConfig.provider,
|
|
1008
1009
|
status: "cached",
|
|
1009
1010
|
optional: providerConfig.optional ?? false,
|
|
1010
|
-
stale: !cache.fresh || !cache.authoritative,
|
|
1011
|
+
stale: !cache.fresh || !cache.authoritative || configStale,
|
|
1011
1012
|
fetchedAt: cache.updatedAt,
|
|
1012
1013
|
models: models.map(model => model.id),
|
|
1013
1014
|
});
|
|
@@ -1259,12 +1260,19 @@ export class ModelRegistry {
|
|
|
1259
1260
|
return providerConfig.provider;
|
|
1260
1261
|
}
|
|
1261
1262
|
|
|
1263
|
+
#isDiscoveryCacheOlderThanModelsConfig(cacheUpdatedAt: number): boolean {
|
|
1264
|
+
const configMtime = this.#modelsConfigFile.getMtimeMs();
|
|
1265
|
+
return configMtime !== null && cacheUpdatedAt < Math.floor(configMtime);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1262
1268
|
async #discoverProviderModels(
|
|
1263
1269
|
providerConfig: DiscoveryProviderConfig,
|
|
1264
1270
|
strategy: ModelRefreshStrategy,
|
|
1265
1271
|
): Promise<Model<Api>[]> {
|
|
1266
1272
|
const cacheProviderId = this.#configuredDiscoveryCacheProviderId(providerConfig);
|
|
1267
1273
|
const cached = readModelCache<Api>(cacheProviderId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath);
|
|
1274
|
+
const cacheOlderThanConfig = cached !== null && this.#isDiscoveryCacheOlderThanModelsConfig(cached.updatedAt);
|
|
1275
|
+
const effectiveStrategy = strategy === "online-if-uncached" && cacheOlderThanConfig ? "online" : strategy;
|
|
1268
1276
|
const requiresAuth = !this.#keylessProviders.has(providerConfig.provider);
|
|
1269
1277
|
if (requiresAuth) {
|
|
1270
1278
|
const apiKey = await this.#peekApiKeyForProvider(providerConfig.provider);
|
|
@@ -1311,12 +1319,12 @@ export class ModelRegistry {
|
|
|
1311
1319
|
cacheTtlMs: 24 * 60 * 60 * 1000,
|
|
1312
1320
|
fetchDynamicModels,
|
|
1313
1321
|
});
|
|
1314
|
-
const result = await manager.refresh(
|
|
1322
|
+
const result = await manager.refresh(effectiveStrategy);
|
|
1315
1323
|
const status = discoveryError
|
|
1316
1324
|
? result.models.length > 0
|
|
1317
1325
|
? "cached"
|
|
1318
1326
|
: "unavailable"
|
|
1319
|
-
:
|
|
1327
|
+
: effectiveStrategy === "offline"
|
|
1320
1328
|
? cached
|
|
1321
1329
|
? "cached"
|
|
1322
1330
|
: "idle"
|
|
@@ -1327,7 +1335,7 @@ export class ModelRegistry {
|
|
|
1327
1335
|
provider: providerId,
|
|
1328
1336
|
status,
|
|
1329
1337
|
optional: providerConfig.optional ?? false,
|
|
1330
|
-
stale: result.stale || status === "cached",
|
|
1338
|
+
stale: result.stale || status === "cached" || (cacheOlderThanConfig && status !== "ok"),
|
|
1331
1339
|
fetchedAt: discoveryError ? cached?.updatedAt : Date.now(),
|
|
1332
1340
|
models: result.models.map(model => model.id),
|
|
1333
1341
|
error: discoveryError,
|
|
@@ -1592,6 +1592,30 @@ export const SETTINGS_SCHEMA = {
|
|
|
1592
1592
|
},
|
|
1593
1593
|
},
|
|
1594
1594
|
|
|
1595
|
+
"share.store": {
|
|
1596
|
+
type: "enum",
|
|
1597
|
+
values: ["blob", "gist"] as const,
|
|
1598
|
+
default: "blob",
|
|
1599
|
+
ui: {
|
|
1600
|
+
tab: "interaction",
|
|
1601
|
+
group: "Collab",
|
|
1602
|
+
label: "Share Store",
|
|
1603
|
+
description: "Where /share uploads the encrypted session blob",
|
|
1604
|
+
options: [
|
|
1605
|
+
{
|
|
1606
|
+
value: "blob",
|
|
1607
|
+
label: "Encrypted Blob",
|
|
1608
|
+
description: "Upload to the share server (no GitHub account needed; avoids gist API rate limits)",
|
|
1609
|
+
},
|
|
1610
|
+
{
|
|
1611
|
+
value: "gist",
|
|
1612
|
+
label: "GitHub Gist",
|
|
1613
|
+
description: "Push to a secret gist (needs authenticated gh), falling back to the share server",
|
|
1614
|
+
},
|
|
1615
|
+
],
|
|
1616
|
+
},
|
|
1617
|
+
},
|
|
1618
|
+
|
|
1595
1619
|
"share.redactSecrets": {
|
|
1596
1620
|
type: "boolean",
|
|
1597
1621
|
default: true,
|
|
@@ -59,6 +59,42 @@ const shellSessionQuarantines = new Map<string, Promise<unknown>>();
|
|
|
59
59
|
/** Session keys with a command currently in flight on the persistent Shell. */
|
|
60
60
|
const shellSessionsInUse = new Set<string>();
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Shells retained past their turn because a background (`nohup`/`&`) job is
|
|
64
|
+
* still running. A per-call `:async:` Shell is normally dropped at teardown,
|
|
65
|
+
* which SIGKILLs its children via kill-on-drop. Keeping the reference alive lets
|
|
66
|
+
* the process survive across turns; the Shell is dropped once its last
|
|
67
|
+
* background job exits (reaped by the poll loop below). Children stay
|
|
68
|
+
* kill-on-drop, so they still die when the harness tears the Shell down on exit.
|
|
69
|
+
*/
|
|
70
|
+
const retainedShells = new Set<Shell>();
|
|
71
|
+
const RETAIN_REAP_INTERVAL_MS = 5_000;
|
|
72
|
+
|
|
73
|
+
async function retainShellWithLiveBackgroundJobs(shell: Shell): Promise<void> {
|
|
74
|
+
let live: number;
|
|
75
|
+
try {
|
|
76
|
+
live = await shell.liveBackgroundJobCount();
|
|
77
|
+
} catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (live <= 0) return;
|
|
81
|
+
retainedShells.add(shell);
|
|
82
|
+
const interval = setInterval(() => {
|
|
83
|
+
void shell
|
|
84
|
+
.liveBackgroundJobCount()
|
|
85
|
+
.then(remaining => {
|
|
86
|
+
if (remaining > 0) return;
|
|
87
|
+
clearInterval(interval);
|
|
88
|
+
retainedShells.delete(shell);
|
|
89
|
+
})
|
|
90
|
+
.catch(() => {
|
|
91
|
+
clearInterval(interval);
|
|
92
|
+
retainedShells.delete(shell);
|
|
93
|
+
});
|
|
94
|
+
}, RETAIN_REAP_INTERVAL_MS);
|
|
95
|
+
interval.unref?.();
|
|
96
|
+
}
|
|
97
|
+
|
|
62
98
|
function quarantineShellSession(
|
|
63
99
|
sessionKey: string,
|
|
64
100
|
runPromise: Promise<ShellRunResult>,
|
|
@@ -411,6 +447,14 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
411
447
|
// `:async:` keys are per-job (jobId is unique), so the Shell would
|
|
412
448
|
// otherwise stay in the process-global map forever after completion.
|
|
413
449
|
shellSessions.delete(sessionKey);
|
|
450
|
+
// Dropping the only reference to a per-call `:async:` Shell SIGKILLs
|
|
451
|
+
// any `nohup`/`&` children (kill-on-drop). If the command left a live
|
|
452
|
+
// background job, retain the Shell so the process survives across
|
|
453
|
+
// turns; it is reaped once its last job exits and still dies with the
|
|
454
|
+
// harness. Skip on resetSession (cancel/error) — those tear down.
|
|
455
|
+
if (!resetSession && shellSession) {
|
|
456
|
+
await retainShellWithLiveBackgroundJobs(shellSession);
|
|
457
|
+
}
|
|
414
458
|
}
|
|
415
459
|
}
|
|
416
460
|
}
|