@oh-my-pi/pi-coding-agent 15.9.1 → 15.9.3

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 (57) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  3. package/dist/types/commands/dry-balance.d.ts +31 -0
  4. package/dist/types/config/model-registry.d.ts +2 -0
  5. package/dist/types/config/models-config-schema.d.ts +3 -0
  6. package/dist/types/config/settings.d.ts +11 -0
  7. package/dist/types/discovery/helpers.d.ts +1 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  9. package/dist/types/hindsight/bank.d.ts +17 -9
  10. package/dist/types/hindsight/mental-models.d.ts +1 -1
  11. package/dist/types/hindsight/state.d.ts +9 -3
  12. package/dist/types/mcp/manager.d.ts +1 -1
  13. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  14. package/dist/types/session/agent-session.d.ts +9 -0
  15. package/dist/types/session/auth-storage.d.ts +2 -2
  16. package/dist/types/task/types.d.ts +2 -0
  17. package/dist/types/tools/index.d.ts +16 -0
  18. package/dist/types/tools/path-utils.d.ts +11 -0
  19. package/package.json +9 -9
  20. package/src/cli/dry-balance-cli.ts +823 -0
  21. package/src/cli-commands.ts +1 -0
  22. package/src/commands/dry-balance.ts +43 -0
  23. package/src/config/model-registry.ts +6 -0
  24. package/src/config/models-config-schema.ts +2 -0
  25. package/src/config/settings.ts +38 -0
  26. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  27. package/src/discovery/github.ts +37 -1
  28. package/src/discovery/helpers.ts +3 -1
  29. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  30. package/src/hindsight/backend.ts +184 -35
  31. package/src/hindsight/bank.ts +32 -22
  32. package/src/hindsight/mental-models.ts +1 -1
  33. package/src/hindsight/state.ts +21 -7
  34. package/src/internal-urls/docs-index.generated.ts +4 -4
  35. package/src/internal-urls/omp-protocol.ts +8 -2
  36. package/src/mcp/manager.ts +40 -21
  37. package/src/modes/components/transcript-container.ts +14 -3
  38. package/src/modes/components/tree-selector.ts +29 -2
  39. package/src/modes/controllers/input-controller.ts +8 -2
  40. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  41. package/src/prompts/agents/explore.md +1 -0
  42. package/src/prompts/agents/librarian.md +1 -0
  43. package/src/prompts/dry-balance-bench.md +8 -0
  44. package/src/sdk.ts +82 -9
  45. package/src/session/agent-session.ts +66 -7
  46. package/src/session/auth-storage.ts +4 -0
  47. package/src/task/executor.ts +6 -2
  48. package/src/task/index.ts +8 -7
  49. package/src/task/types.ts +2 -0
  50. package/src/tools/bash.ts +3 -4
  51. package/src/tools/index.ts +16 -0
  52. package/src/tools/job.ts +3 -3
  53. package/src/tools/memory-reflect.ts +2 -2
  54. package/src/tools/path-utils.ts +21 -0
  55. package/src/tools/search.ts +18 -1
  56. package/src/utils/file-mentions.ts +7 -107
  57. package/src/utils/title-generator.ts +58 -37
@@ -64,9 +64,15 @@ export class OmpProtocolHandler implements ProtocolHandler {
64
64
  throw new Error("Path traversal (..) is not allowed in omp:// URLs");
65
65
  }
66
66
 
67
- const content = EMBEDDED_DOCS[normalized];
67
+ const docPath =
68
+ normalized === "docs" ? "" : normalized.startsWith("docs/") ? normalized.slice("docs/".length) : normalized;
69
+ if (!docPath) {
70
+ return this.#listDocs(url);
71
+ }
72
+
73
+ const content = EMBEDDED_DOCS[docPath];
68
74
  if (content === undefined) {
69
- const lookup = normalized.replace(/\.md$/, "");
75
+ const lookup = docPath.replace(/\.md$/, "");
70
76
  const suggestions = EMBEDDED_DOC_FILENAMES.filter(
71
77
  f => f.includes(lookup) || lookup.includes(f.replace(/\.md$/, "")),
72
78
  ).slice(0, 5);
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import * as path from "node:path";
8
8
  import * as url from "node:url";
9
- import type { TSchema } from "@oh-my-pi/pi-ai";
9
+ import { isDefinitiveOAuthFailure, type TSchema } from "@oh-my-pi/pi-ai";
10
10
  import { logger } from "@oh-my-pi/pi-utils";
11
11
  import type { SourceMeta } from "../capability/types";
12
12
  import { resolveConfigValue } from "../config/resolve-config-value";
@@ -1184,29 +1184,48 @@ export class MCPManager {
1184
1184
  await this.#authStorage.set(credentialId, refreshedCredential);
1185
1185
  credential = refreshedCredential;
1186
1186
  } catch (refreshError) {
1187
- logger.warn("MCP OAuth refresh failed, using existing token", {
1188
- credentialId,
1189
- error: refreshError,
1190
- });
1187
+ const errorMsg = refreshError instanceof Error ? refreshError.message : String(refreshError);
1188
+ if (isDefinitiveOAuthFailure(errorMsg)) {
1189
+ // `invalid_grant` / `invalid_token` / 401 from the token endpoint means
1190
+ // the server has retired this credential — keeping the stale access
1191
+ // token would just re-fail with 401 on every MCP request and leave a
1192
+ // poisoned row in agent.db that survives restarts. Drop it now so the
1193
+ // next connect attempt surfaces a clean "needs reauth" failure and
1194
+ // the user can recover with `/mcp reauth <server>` (or `/mcp unauth`
1195
+ // to forget the server entirely).
1196
+ logger.warn("MCP OAuth refresh failed definitively; cleared credential", {
1197
+ credentialId,
1198
+ error: errorMsg,
1199
+ });
1200
+ await this.#authStorage.remove(credentialId);
1201
+ credential = undefined;
1202
+ } else {
1203
+ logger.warn("MCP OAuth refresh failed, using existing token", {
1204
+ credentialId,
1205
+ error: refreshError,
1206
+ });
1207
+ }
1191
1208
  }
1192
1209
  }
1193
1210
 
1194
- if (resolved.type === "http" || resolved.type === "sse") {
1195
- resolved = {
1196
- ...resolved,
1197
- headers: {
1198
- ...resolved.headers,
1199
- Authorization: `Bearer ${credential.access}`,
1200
- },
1201
- };
1202
- } else {
1203
- resolved = {
1204
- ...resolved,
1205
- env: {
1206
- ...resolved.env,
1207
- OAUTH_ACCESS_TOKEN: credential.access,
1208
- },
1209
- };
1211
+ if (credential?.type === "oauth") {
1212
+ if (resolved.type === "http" || resolved.type === "sse") {
1213
+ resolved = {
1214
+ ...resolved,
1215
+ headers: {
1216
+ ...resolved.headers,
1217
+ Authorization: `Bearer ${credential.access}`,
1218
+ },
1219
+ };
1220
+ } else {
1221
+ resolved = {
1222
+ ...resolved,
1223
+ env: {
1224
+ ...resolved.env,
1225
+ OAUTH_ACCESS_TOKEN: credential.access,
1226
+ },
1227
+ };
1228
+ }
1210
1229
  }
1211
1230
  }
1212
1231
  } catch (error) {
@@ -1,4 +1,4 @@
1
- import { type Component, Container, TERMINAL } from "@oh-my-pi/pi-tui";
1
+ import { type Component, Container, type NativeScrollbackLiveRegion, TERMINAL } from "@oh-my-pi/pi-tui";
2
2
 
3
3
  const kSnapshot = Symbol("transcript.frozenRender");
4
4
 
@@ -34,7 +34,7 @@ interface SnapshotCarrier {
34
34
  * and any drift reconciles safely. On terminals that can rebuild history this
35
35
  * freezing is unnecessary, so it renders every block live for full fidelity.
36
36
  */
37
- export class TranscriptContainer extends Container {
37
+ export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion {
38
38
  // Bumped to invalidate every block's snapshot at once; a snapshot is only
39
39
  // honored when its stored generation still matches.
40
40
  #generation = 0;
@@ -43,6 +43,10 @@ export class TranscriptContainer extends Container {
43
43
  // predate content that finalized in the same coalesced frame that appended the
44
44
  // block now below it — so it must recompute once on the live→frozen transition.
45
45
  #prevLiveChild: Component | undefined;
46
+ // Local line index where the current bottom-most block begins in the most
47
+ // recent render. TUI extends the native-scrollback pinned region from this
48
+ // point through the live block and the root chrome rendered below it.
49
+ #nativeScrollbackLiveRegionStart: number | undefined;
46
50
 
47
51
  override invalidate(): void {
48
52
  // A theme/global invalidation forces a full recompute on the rebuild that
@@ -56,6 +60,10 @@ export class TranscriptContainer extends Container {
56
60
  super.clear();
57
61
  }
58
62
 
63
+ getNativeScrollbackLiveRegionStart(): number | undefined {
64
+ return this.#nativeScrollbackLiveRegionStart;
65
+ }
66
+
59
67
  /**
60
68
  * Retire all frozen snapshots so the next render reflects each block's current
61
69
  * state. Call at reconciliation checkpoints (prompt submit) where the whole
@@ -68,6 +76,7 @@ export class TranscriptContainer extends Container {
68
76
 
69
77
  override render(width: number): string[] {
70
78
  width = Math.max(1, width);
79
+ this.#nativeScrollbackLiveRegionStart = undefined;
71
80
  if (!TERMINAL.eagerEraseScrollbackRisk) return super.render(width);
72
81
 
73
82
  const lines: string[] = [];
@@ -77,7 +86,9 @@ export class TranscriptContainer extends Container {
77
86
  this.#prevLiveChild = liveChild;
78
87
  for (let i = 0; i < this.children.length; i++) {
79
88
  const child = this.children[i]! as Component & SnapshotCarrier;
80
- if (child !== liveChild) {
89
+ if (child === liveChild) {
90
+ this.#nativeScrollbackLiveRegionStart = lines.length;
91
+ } else {
81
92
  const snapshot = child[kSnapshot];
82
93
  // Replay the block's last render from while it was live. A stale
83
94
  // generation (post-thaw) or width mismatch (resize in flight, an
@@ -441,8 +441,35 @@ class TreeList implements Component {
441
441
  const lines: string[] = [];
442
442
 
443
443
  if (this.#filteredNodes.length === 0) {
444
- lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width));
445
- lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.#getFilterLabel()}`), width));
444
+ // Three empty-state shapes:
445
+ // - flatNodes empty → no entries at all (truly fresh session).
446
+ // - search query rejects everything → tell the user the search is the cause.
447
+ // - filter mode rejects everything → tell the user the filter is the cause and
448
+ // how to widen it. Otherwise fresh sessions whose only persisted entries are
449
+ // `model_change` + `thinking_level_change` (both hidden by the default filter)
450
+ // read as "broken /tree" — see #1909.
451
+ if (this.#flatNodes.length === 0) {
452
+ lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width));
453
+ lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.#getFilterLabel()}`), width));
454
+ } else if (this.#searchQuery.length > 0) {
455
+ lines.push(truncateToWidth(theme.fg("muted", ` No entries match search "${this.#searchQuery}"`), width));
456
+ lines.push(truncateToWidth(theme.fg("muted", " Press Backspace to clear the search"), width));
457
+ lines.push(
458
+ truncateToWidth(theme.fg("muted", ` (0/${this.#flatNodes.length})${this.#getFilterLabel()}`), width),
459
+ );
460
+ } else {
461
+ const filterLabel = this.#getFilterLabel().trim() || "[default]";
462
+ lines.push(
463
+ truncateToWidth(
464
+ theme.fg("muted", ` ${this.#flatNodes.length} entries hidden by the current filter ${filterLabel}`),
465
+ width,
466
+ ),
467
+ );
468
+ lines.push(truncateToWidth(theme.fg("muted", " Press Alt+A to show all, Alt+D for default"), width));
469
+ lines.push(
470
+ truncateToWidth(theme.fg("muted", ` (0/${this.#flatNodes.length})${this.#getFilterLabel()}`), width),
471
+ );
472
+ }
446
473
  return lines;
447
474
  }
448
475
 
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
3
- import { $env, sanitizeText } from "@oh-my-pi/pi-utils";
3
+ import { $env, logger, sanitizeText } from "@oh-my-pi/pi-utils";
4
4
  import { getRoleInfo } from "../../config/model-registry";
5
5
  import { isSettingsInitialized, settings } from "../../config/settings";
6
6
  import { renderSegmentTrack } from "../../modes/components/segment-track";
@@ -406,7 +406,13 @@ export class InputController {
406
406
  }
407
407
  }
408
408
  })
409
- .catch(() => {});
409
+ .catch(err => {
410
+ logger.warn("title-generator: uncaught auto-title error", {
411
+ sessionId: this.ctx.session.sessionId,
412
+ reason: "uncaught-auto-title-error",
413
+ error: err instanceof Error ? err.message : String(err),
414
+ });
415
+ });
410
416
  }
411
417
 
412
418
  if (this.ctx.onInputCallback) {
@@ -1,6 +1,6 @@
1
1
  import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
2
  import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types";
3
- import { Input, matchesKey, truncateToWidth } from "@oh-my-pi/pi-tui";
3
+ import { Input, matchesKey, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
4
4
  import { getAgentDbPath } from "@oh-my-pi/pi-utils";
5
5
  import { OAuthSelectorComponent } from "../../components/oauth-selector";
6
6
  import { theme } from "../../theme/theme";
@@ -15,6 +15,10 @@ const CALLBACK_SERVER_PROVIDERS: Partial<Record<OAuthProvider, true>> = {
15
15
  "google-antigravity": true,
16
16
  };
17
17
 
18
+ function loginUrlLink(url: string): string {
19
+ return `\x1b]8;;${url}\x07Open login URL\x1b]8;;\x07`;
20
+ }
21
+
18
22
  interface PromptState {
19
23
  message: string;
20
24
  placeholder?: string;
@@ -33,6 +37,7 @@ export class SignInTab implements SetupTab {
33
37
  #authStorage: AuthStorage;
34
38
  #selector: OAuthSelectorComponent;
35
39
  #statusLines: string[] = [];
40
+ #authUrl: string | undefined;
36
41
  #prompt: PromptState | undefined;
37
42
  #promptResolve: ((value: string) => void) | undefined;
38
43
  #loginAbort: AbortController | undefined;
@@ -72,22 +77,31 @@ export class SignInTab implements SetupTab {
72
77
  }
73
78
 
74
79
  render(width: number): string[] {
75
- const lines = [theme.fg("muted", "Pick a provider to sign in — you can connect more than one."), ""];
80
+ const lines: string[] = [];
76
81
  if (this.#loggingInProvider) {
77
- lines.push(theme.bold(`Signing in to ${this.#loggingInProvider}`), "");
82
+ lines.push(theme.bold(`Signing in to ${this.#loggingInProvider}`));
78
83
  } else {
84
+ lines.push(theme.fg("muted", "Pick a provider to sign in — you can connect more than one."), "");
79
85
  lines.push(...this.#selector.render(width));
80
86
  }
81
- if (this.#statusLines.length > 0) {
82
- lines.push("", ...this.#statusLines.map(line => truncateToWidth(line, width)));
87
+
88
+ const urlLines = this.#authUrl ? wrapTextWithAnsi(theme.fg("dim", this.#authUrl), width) : [];
89
+ if (this.#authUrl) {
90
+ lines.push(theme.fg("accent", `Browser login: ${loginUrlLink(this.#authUrl)}`), ...urlLines.slice(0, 2));
83
91
  }
84
92
  if (this.#prompt) {
85
- lines.push("", theme.fg("warning", this.#prompt.message));
93
+ lines.push(theme.fg("warning", this.#prompt.message));
86
94
  if (this.#prompt.placeholder) {
87
95
  lines.push(theme.fg("dim", this.#prompt.placeholder));
88
96
  }
89
97
  lines.push(this.#prompt.input.render(width)[0] ?? "");
90
98
  }
99
+ if (urlLines.length > 2) {
100
+ lines.push(...urlLines);
101
+ }
102
+ if (this.#statusLines.length > 0) {
103
+ lines.push(...this.#statusLines.flatMap(line => wrapTextWithAnsi(line, width)));
104
+ }
91
105
  return lines;
92
106
  }
93
107
 
@@ -109,6 +123,7 @@ export class SignInTab implements SetupTab {
109
123
  this.#selector.stopValidation();
110
124
  this.#loggingInProvider = providerId;
111
125
  this.#statusLines = [theme.fg("dim", "Starting OAuth flow…")];
126
+ this.#authUrl = undefined;
112
127
  this.#loginAbort = new AbortController();
113
128
  this.host.restoreFocus();
114
129
  this.host.requestRender();
@@ -116,7 +131,8 @@ export class SignInTab implements SetupTab {
116
131
  await this.#authStorage.login(providerId as OAuthProvider, {
117
132
  signal: this.#loginAbort.signal,
118
133
  onAuth: info => {
119
- this.#statusLines.push(theme.fg("accent", `Open this URL: ${info.url}`));
134
+ this.#authUrl = info.url;
135
+ this.#statusLines = [];
120
136
  if (info.instructions) {
121
137
  this.#statusLines.push(theme.fg("warning", info.instructions));
122
138
  }
@@ -140,6 +156,7 @@ export class SignInTab implements SetupTab {
140
156
  theme.fg("success", `${theme.status.success} Signed in to ${providerId}`),
141
157
  theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`),
142
158
  ];
159
+ this.#authUrl = undefined;
143
160
  this.#loggingInProvider = undefined;
144
161
  this.#loginAbort = undefined;
145
162
  this.#selector.stopValidation();
@@ -150,12 +167,14 @@ export class SignInTab implements SetupTab {
150
167
  if (this.#disposed) return;
151
168
  if (this.#loginAbort?.signal.aborted) {
152
169
  this.#statusLines = [theme.fg("dim", "Login cancelled.")];
170
+ this.#authUrl = undefined;
153
171
  } else {
154
172
  const message = error instanceof Error ? error.message : String(error);
155
173
  this.#statusLines = [
156
174
  theme.fg("error", `Login failed: ${message}`),
157
175
  theme.fg("dim", "Choose another provider or press Esc to continue."),
158
176
  ];
177
+ this.#authUrl = undefined;
159
178
  }
160
179
  this.#loggingInProvider = undefined;
161
180
  this.#loginAbort = undefined;
@@ -174,6 +193,7 @@ export class SignInTab implements SetupTab {
174
193
  this.#resolvePrompt(value);
175
194
  };
176
195
  input.onEscape = () => {
196
+ this.#loginAbort?.abort();
177
197
  this.#resolvePrompt("");
178
198
  };
179
199
  this.host.setFocus(input);
@@ -4,6 +4,7 @@ description: Fast read-only codebase scout returning compressed context for hand
4
4
  tools: read, search, find, web_search
5
5
  model: pi/smol
6
6
  thinking-level: med
7
+ read-summarize: false
7
8
  output:
8
9
  properties:
9
10
  summary:
@@ -4,6 +4,7 @@ description: Researches external libraries and APIs by reading source code. Retu
4
4
  tools: read, search, find, bash, lsp, web_search, ast_grep
5
5
  model: pi/smol
6
6
  thinking-level: minimal
7
+ read-summarize: false
7
8
  output:
8
9
  properties:
9
10
  answer:
@@ -0,0 +1,8 @@
1
+ Write a 20-line poem about balancing OAuth accounts across many providers.
2
+
3
+ Form:
4
+ - Exactly 20 lines, no title, no stanza breaks.
5
+ - Each line is terse and image-driven, in the spirit of haiku: 7 words or fewer, no end punctuation.
6
+ - Let the imagery carry the theme — tokens, scopes, refresh cycles, expiry, consent, revocation — rather than naming them literally.
7
+
8
+ Output only the 20 lines. No preamble, no commentary, no code fences.
package/src/sdk.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  extractRetryHint,
28
28
  getAgentDbPath,
29
29
  getAgentDir,
30
+ getAuthBrokerSnapshotCachePath,
30
31
  getProjectDir,
31
32
  logger,
32
33
  postmortem,
@@ -101,7 +102,15 @@ import {
101
102
  } from "./secrets";
102
103
  import { AgentSession } from "./session/agent-session";
103
104
  import { resolveAuthBrokerConfig } from "./session/auth-broker-config";
104
- import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./session/auth-storage";
105
+ import {
106
+ AuthBrokerClient,
107
+ AuthStorage,
108
+ DEFAULT_SNAPSHOT_CACHE_TTL_MS,
109
+ RemoteAuthCredentialStore,
110
+ readAuthBrokerSnapshotCache,
111
+ type SnapshotResponse,
112
+ writeAuthBrokerSnapshotCache,
113
+ } from "./session/auth-storage";
105
114
  import { type CustomMessage, convertToLlm, wrapSteeringForModel } from "./session/messages";
106
115
  import { getRestorableSessionModels, SessionManager } from "./session/session-manager";
107
116
  import { closeAllConnections } from "./ssh/connection-manager";
@@ -418,6 +427,17 @@ function getDefaultAgentDir(): string {
418
427
  return getAgentDir();
419
428
  }
420
429
 
430
+ function resolveSnapshotTtlMs(): number {
431
+ const raw = process.env.OMP_AUTH_BROKER_SNAPSHOT_TTL_MS;
432
+ if (raw === undefined) return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
433
+ const value = raw.trim();
434
+ if (value === "") return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
435
+ const ttlMs = Number(value);
436
+ if (Number.isFinite(ttlMs) && ttlMs >= 0) return ttlMs;
437
+ logger.warn("Invalid OMP_AUTH_BROKER_SNAPSHOT_TTL_MS; using default", { value: raw });
438
+ return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
439
+ }
440
+
421
441
  // Discovery Functions
422
442
 
423
443
  /**
@@ -435,9 +455,42 @@ export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir(
435
455
  const brokerConfig = await resolveAuthBrokerConfig();
436
456
  if (brokerConfig) {
437
457
  const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
438
- const initialResult = await client.fetchSnapshot();
439
- if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot");
440
- const store = new RemoteAuthCredentialStore({ client, initialSnapshot: initialResult.snapshot });
458
+ const ttlMs = resolveSnapshotTtlMs();
459
+ const cachePath = getAuthBrokerSnapshotCachePath();
460
+ const persist =
461
+ ttlMs > 0
462
+ ? (snapshot: SnapshotResponse): void => {
463
+ void writeAuthBrokerSnapshotCache({
464
+ path: cachePath,
465
+ token: brokerConfig.token,
466
+ url: brokerConfig.url,
467
+ snapshot,
468
+ }).catch(error => {
469
+ logger.debug("auth-broker snapshot cache write failed", { error: String(error) });
470
+ });
471
+ }
472
+ : undefined;
473
+
474
+ let initialSnapshot: SnapshotResponse | undefined;
475
+ if (ttlMs > 0) {
476
+ initialSnapshot =
477
+ (await readAuthBrokerSnapshotCache({
478
+ path: cachePath,
479
+ token: brokerConfig.token,
480
+ url: brokerConfig.url,
481
+ ttlMs,
482
+ }).catch(error => {
483
+ logger.debug("auth-broker snapshot cache read failed", { error: String(error) });
484
+ return null;
485
+ })) ?? undefined;
486
+ }
487
+ if (!initialSnapshot) {
488
+ const initialResult = await client.fetchSnapshot();
489
+ if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot");
490
+ initialSnapshot = initialResult.snapshot;
491
+ persist?.(initialSnapshot);
492
+ }
493
+ const store = new RemoteAuthCredentialStore({ client, initialSnapshot, onSnapshot: persist });
441
494
  // Refresh + usage hooks live on RemoteAuthCredentialStore; AuthStorage
442
495
  // discovers them automatically when no explicit option overrides them.
443
496
  const storage = new AuthStorage(store, {
@@ -1168,12 +1221,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1168
1221
 
1169
1222
  return preview;
1170
1223
  };
1171
- // Only top-level sessions own an AsyncJobManager. Subagents reach the
1172
- // parent's manager via `AsyncJobManager.instance()` (set below), so creating
1173
- // a second instance here just to leave it orphaned wastes a constructor and
1174
- // risks accidental disposal of the parent's manager on subagent teardown.
1224
+ // Only the first top-level session in a process owns an AsyncJobManager.
1225
+ // Subagents inherit the parent's manager via `AsyncJobManager.instance()`
1226
+ // (set below), and any additional top-level session spun up in-process
1227
+ // (e.g. the agent-creation architect in `agent-dashboard.ts`) must share
1228
+ // the live singleton — otherwise its dispose path would clobber the
1229
+ // owning session's manager and break the `task`/`bash` async paths
1230
+ // (issue #1923). The `instance()` guard means later sessions also skip
1231
+ // constructing an orphaned manager that nothing would ever route to.
1175
1232
  const asyncJobManager =
1176
- backgroundJobsEnabled && !options.parentTaskPrefix
1233
+ backgroundJobsEnabled && !options.parentTaskPrefix && !AsyncJobManager.instance()
1177
1234
  ? new AsyncJobManager({
1178
1235
  maxRunningJobs: asyncMaxJobs,
1179
1236
  onJobComplete: async (jobId, result, job) => {
@@ -1192,6 +1249,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1192
1249
  })
1193
1250
  : undefined;
1194
1251
 
1252
+ const scopedAsyncJobManager = asyncJobManager ?? (options.parentTaskPrefix ? AsyncJobManager.instance() : undefined);
1253
+
1195
1254
  const agentRegistry = options.agentRegistry ?? AgentRegistry.global();
1196
1255
  const resolvedAgentId = options.agentId ?? options.parentTaskPrefix ?? MAIN_AGENT_ID;
1197
1256
  const resolvedAgentDisplayName =
@@ -1293,6 +1352,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1293
1352
  authStorage,
1294
1353
  modelRegistry,
1295
1354
  getTelemetry: () => agent?.telemetry,
1355
+ // Subagents inherit the singleton (the parent's manager) so their bash/task
1356
+ // completions still flow into the spawning conversation's yieldQueue.
1357
+ // Secondary in-process top-level sessions (no parentTaskPrefix, no
1358
+ // constructed manager because the singleton was already installed) leave
1359
+ // this undefined so tools and session job snapshots refuse async work
1360
+ // instead of silently routing into the owning session (issue #1923).
1361
+ asyncJobManager: scopedAsyncJobManager,
1296
1362
  };
1297
1363
 
1298
1364
  // Wire process-wide internal URL singletons owned by their real classes.
@@ -2049,6 +2115,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2049
2115
  // AsyncJobManager on teardown; subagents inherit the parent's and
2050
2116
  // **MUST NOT** tear it down.
2051
2117
  ownedAsyncJobManager: asyncJobManager,
2118
+ asyncJobManager: scopedAsyncJobManager,
2052
2119
  scopedModels: options.scopedModels,
2053
2120
  promptTemplates,
2054
2121
  slashCommands,
@@ -2262,6 +2329,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2262
2329
  await session.dispose();
2263
2330
  } else {
2264
2331
  if (hasRegistered) agentRegistry.unregister(resolvedAgentId);
2332
+ if (asyncJobManager) {
2333
+ if (AsyncJobManager.instance() === asyncJobManager) {
2334
+ AsyncJobManager.setInstance(undefined);
2335
+ }
2336
+ await asyncJobManager.dispose({ timeoutMs: 3_000 });
2337
+ }
2265
2338
  await disposeKernelSessionsByOwner(evalKernelOwnerId);
2266
2339
  }
2267
2340
  } catch (cleanupError) {
@@ -15,6 +15,7 @@
15
15
 
16
16
  import * as crypto from "node:crypto";
17
17
  import * as fs from "node:fs";
18
+ import * as os from "node:os";
18
19
  import * as path from "node:path";
19
20
  import { scheduler } from "node:timers/promises";
20
21
  import { isPromise } from "node:util/types";
@@ -94,6 +95,7 @@ import {
94
95
  isUnexpectedSocketCloseMessage,
95
96
  logger,
96
97
  prompt,
98
+ relativePathWithinRoot,
97
99
  Snowflake,
98
100
  } from "@oh-my-pi/pi-utils";
99
101
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
@@ -366,6 +368,15 @@ export interface AgentSessionConfig {
366
368
  * **MUST NOT** dispose it on their own teardown.
367
369
  */
368
370
  ownedAsyncJobManager?: AsyncJobManager;
371
+ /**
372
+ * AsyncJobManager reachable by this session for scoped job actions.
373
+ *
374
+ * Top-level owners receive their own manager, subagents receive the inherited
375
+ * parent manager, and secondary in-process top-level sessions receive
376
+ * `undefined` so job snapshots and ACP drains cannot observe the primary's
377
+ * state.
378
+ */
379
+ asyncJobManager?: AsyncJobManager;
369
380
  /** Agent identity (registry id like "Main" or "Alice") used for IRC routing. */
370
381
  agentId?: string;
371
382
  /** Shared agent registry (for forwarding IRC observations to the main session UI). */
@@ -890,6 +901,14 @@ export class AgentSession {
890
901
  * this undefined and **MUST NOT** dispose the global instance on teardown.
891
902
  */
892
903
  readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
904
+ /**
905
+ * AsyncJobManager scoped to this session for introspection/cancellation.
906
+ *
907
+ * This differs from `#ownedAsyncJobManager`: subagents can inherit a parent
908
+ * manager for their own owner id, while secondary top-level sessions are left
909
+ * undefined to avoid reading the primary's jobs.
910
+ */
911
+ readonly #asyncJobManager: AsyncJobManager | undefined;
893
912
  #pendingPythonMessages: PythonExecutionMessage[] = [];
894
913
  #activeEvalExecutions = new Set<Promise<unknown>>();
895
914
  #evalExecutionDisposing = false;
@@ -1080,6 +1099,7 @@ export class AgentSession {
1080
1099
  this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
1081
1100
  this.#parentEvalSessionId = config.parentEvalSessionId;
1082
1101
  this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
1102
+ this.#asyncJobManager = config.asyncJobManager ?? config.ownedAsyncJobManager;
1083
1103
  this.#scopedModels = config.scopedModels ?? [];
1084
1104
  if (config.thinkingLevel === AUTO_THINKING) {
1085
1105
  // `auto` is session-level: keep the flag and show a provisional concrete
@@ -1373,7 +1393,7 @@ export class AgentSession {
1373
1393
  }
1374
1394
 
1375
1395
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
1376
- const manager = AsyncJobManager.instance();
1396
+ const manager = this.#asyncJobManager;
1377
1397
  if (!manager) return null;
1378
1398
  const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
1379
1399
  const running = manager.getRunningJobs(ownerFilter).map(job => ({
@@ -1398,11 +1418,20 @@ export class AgentSession {
1398
1418
  * Cancel async jobs registered by *this* agent only. Used by lifecycle
1399
1419
  * transitions (newSession, switchSession, handoff, dispose) so a subagent
1400
1420
  * cleans up its own background work without touching its parent's jobs.
1401
- * No-op when no manager is installed or this session has no agent id.
1421
+ *
1422
+ * Cancellation runs against this session's scoped manager. Subagents have
1423
+ * unique agent ids and inherit the parent's manager to clean up their own
1424
+ * jobs. A secondary in-process top-level session gets no scoped manager,
1425
+ * because it defaults to `MAIN_AGENT_ID`; reaching through the global
1426
+ * singleton would tear down the owning primary session's bash/task jobs at
1427
+ * dispose time (issue #1923).
1428
+ *
1429
+ * No-op when no manager is reachable or this session has no agent id.
1402
1430
  */
1403
1431
  #cancelOwnAsyncJobs(): void {
1404
1432
  if (!this.#agentId) return;
1405
- AsyncJobManager.instance()?.cancelAll({ ownerId: this.#agentId });
1433
+ const manager = this.#asyncJobManager;
1434
+ manager?.cancelAll({ ownerId: this.#agentId });
1406
1435
  }
1407
1436
 
1408
1437
  // =========================================================================
@@ -2128,12 +2157,31 @@ export class AgentSession {
2128
2157
  if (this.#pendingTtsrInjections.length === 0) return undefined;
2129
2158
  const rules = this.#pendingTtsrInjections;
2130
2159
  const content = rules
2131
- .map(r => prompt.render(ttsrInterruptTemplate, { name: r.name, path: r.path, content: r.content }))
2160
+ .map(r =>
2161
+ prompt.render(ttsrInterruptTemplate, {
2162
+ name: r.name,
2163
+ path: this.#displayRulePath(r.path),
2164
+ content: r.content,
2165
+ }),
2166
+ )
2132
2167
  .join("\n\n");
2133
2168
  this.#pendingTtsrInjections = [];
2134
2169
  return { content, rules };
2135
2170
  }
2136
2171
 
2172
+ /**
2173
+ * Render a rule's file path for model-facing TTSR injections without leaking
2174
+ * the absolute home directory: cwd-relative when the rule lives in the
2175
+ * project, `~`-relative when it lives under home, else the raw path.
2176
+ */
2177
+ #displayRulePath(rulePath: string): string {
2178
+ const cwdRel = relativePathWithinRoot(this.sessionManager.getCwd(), rulePath);
2179
+ if (cwdRel) return cwdRel;
2180
+ const homeRel = relativePathWithinRoot(os.homedir(), rulePath);
2181
+ if (homeRel) return `~/${homeRel}`;
2182
+ return rulePath;
2183
+ }
2184
+
2137
2185
  #addPendingTtsrInjections(rules: Rule[]): void {
2138
2186
  const seen = new Set(this.#pendingTtsrInjections.map(rule => rule.name));
2139
2187
  for (const rule of rules) {
@@ -2186,7 +2234,13 @@ export class AgentSession {
2186
2234
  if (!rules || rules.length === 0) return undefined;
2187
2235
  this.#perToolTtsrInjections.delete(ctx.toolCall.id);
2188
2236
  const reminder = rules
2189
- .map(r => prompt.render(ttsrToolReminderTemplate, { name: r.name, path: r.path, content: r.content }))
2237
+ .map(r =>
2238
+ prompt.render(ttsrToolReminderTemplate, {
2239
+ name: r.name,
2240
+ path: this.#displayRulePath(r.path),
2241
+ content: r.content,
2242
+ }),
2243
+ )
2190
2244
  .join("\n\n");
2191
2245
  // The TTSR manager was already claimed at bucket time; only persistence remains.
2192
2246
  const ruleNames = rules.map(r => r.name.trim()).filter(n => n.length > 0);
@@ -2990,8 +3044,13 @@ export class AgentSession {
2990
3044
  this.#releasePowerAssertion();
2991
3045
  await this.sessionManager.close();
2992
3046
  this.#closeAllProviderSessions("dispose");
2993
- const hindsightState = this.setHindsightSessionState(undefined);
3047
+ // Flush the retain queue BEFORE clearing the session's pointer so
3048
+ // `HindsightRetainQueue.#doFlush` still sees `session.getHindsightSessionState() === state`.
3049
+ // Reversed, the spliced batch survives just long enough to fail the
3050
+ // identity check and get dropped with a `session vanished` warning.
3051
+ const hindsightState = this.getHindsightSessionState();
2994
3052
  await hindsightState?.flushRetainQueue();
3053
+ this.setHindsightSessionState(undefined);
2995
3054
  hindsightState?.dispose();
2996
3055
  const mnemopiState = setMnemopiSessionState(this, undefined);
2997
3056
  mnemopiState?.dispose();
@@ -3069,7 +3128,7 @@ export class AgentSession {
3069
3128
  }
3070
3129
 
3071
3130
  async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise<boolean> {
3072
- const manager = AsyncJobManager.instance();
3131
+ const manager = this.#asyncJobManager;
3073
3132
  if (!manager) return false;
3074
3133
  const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
3075
3134
  const before = manager.getDeliveryState(ownerFilter);