@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/cli.js +2449 -2455
  3. package/dist/types/advisor/runtime.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +20 -0
  5. package/dist/types/export/share.d.ts +8 -1
  6. package/dist/types/mcp/transports/stdio.d.ts +12 -1
  7. package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
  8. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  9. package/dist/types/secrets/obfuscator.d.ts +3 -3
  10. package/dist/types/utils/shell-snapshot.d.ts +10 -0
  11. package/dist/types/web/search/providers/perplexity.d.ts +17 -3
  12. package/package.json +12 -12
  13. package/src/advisor/__tests__/advisor.test.ts +114 -0
  14. package/src/advisor/runtime.ts +129 -1
  15. package/src/config/model-registry.ts +12 -4
  16. package/src/config/settings-schema.ts +24 -0
  17. package/src/exec/bash-executor.ts +44 -0
  18. package/src/export/share.ts +51 -28
  19. package/src/internal-urls/docs-index.generated.txt +1 -1
  20. package/src/mcp/transports/stdio.ts +20 -4
  21. package/src/modes/components/custom-editor.test.ts +22 -0
  22. package/src/modes/components/custom-editor.ts +10 -1
  23. package/src/modes/components/footer.ts +4 -3
  24. package/src/modes/components/status-line/component.ts +5 -1
  25. package/src/modes/components/status-line/context-thresholds.ts +11 -3
  26. package/src/modes/components/status-line/segments.ts +1 -1
  27. package/src/modes/components/status-line/types.ts +1 -0
  28. package/src/modes/controllers/command-controller.ts +1 -0
  29. package/src/prompts/system/system-prompt.md +5 -5
  30. package/src/prompts/tools/bash.md +2 -2
  31. package/src/prompts/tools/search.md +1 -0
  32. package/src/secrets/obfuscator.ts +3 -9
  33. package/src/session/agent-session.ts +33 -2
  34. package/src/slash-commands/builtin-registry.ts +2 -1
  35. package/src/utils/shell-snapshot.ts +63 -1
  36. 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; tries a secret gist first, then the share server. */
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
- /** Resolve the subprocess argv used to launch an MCP stdio server. */
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>` (e.g. `5.1%/1M`), matching the
6
- * status line's context gauge so subagent and footer renderers stay in sync.
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, thinking text, and
38
- * tool-call arguments/intent/rawBlock. Signatures and redacted-thinking bytes
39
- * are opaque provider-replay data and pass through byte-identical.
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, but keep auto
39
- * provider selection credential-gated so a configured provider keeps priority
40
- * over the anonymous fallback.
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.14",
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.14",
52
- "@oh-my-pi/omp-stats": "16.1.14",
53
- "@oh-my-pi/pi-agent-core": "16.1.14",
54
- "@oh-my-pi/pi-ai": "16.1.14",
55
- "@oh-my-pi/pi-catalog": "16.1.14",
56
- "@oh-my-pi/pi-mnemopi": "16.1.14",
57
- "@oh-my-pi/pi-natives": "16.1.14",
58
- "@oh-my-pi/pi-tui": "16.1.14",
59
- "@oh-my-pi/pi-utils": "16.1.14",
60
- "@oh-my-pi/pi-wire": "16.1.14",
61
- "@oh-my-pi/snapcompact": "16.1.14",
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&amp;SECRET&lt;TOKEN&gt;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);
@@ -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 md = formatSessionHistoryMarkdown(delta, {
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(strategy);
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
- : strategy === "offline"
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
  }