@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.
- package/CHANGELOG.md +29 -1
- package/dist/types/cli/dry-balance-cli.d.ts +104 -0
- package/dist/types/commands/dry-balance.d.ts +31 -0
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +3 -0
- package/dist/types/config/settings.d.ts +11 -0
- package/dist/types/discovery/helpers.d.ts +1 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
- package/dist/types/hindsight/bank.d.ts +17 -9
- package/dist/types/hindsight/mental-models.d.ts +1 -1
- package/dist/types/hindsight/state.d.ts +9 -3
- package/dist/types/mcp/manager.d.ts +1 -1
- package/dist/types/modes/components/transcript-container.d.ts +3 -2
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/session/auth-storage.d.ts +2 -2
- package/dist/types/task/types.d.ts +2 -0
- package/dist/types/tools/index.d.ts +16 -0
- package/dist/types/tools/path-utils.d.ts +11 -0
- package/package.json +9 -9
- package/src/cli/dry-balance-cli.ts +823 -0
- package/src/cli-commands.ts +1 -0
- package/src/commands/dry-balance.ts +43 -0
- package/src/config/model-registry.ts +6 -0
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings.ts +38 -0
- package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
- package/src/discovery/github.ts +37 -1
- package/src/discovery/helpers.ts +3 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
- package/src/hindsight/backend.ts +184 -35
- package/src/hindsight/bank.ts +32 -22
- package/src/hindsight/mental-models.ts +1 -1
- package/src/hindsight/state.ts +21 -7
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/internal-urls/omp-protocol.ts +8 -2
- package/src/mcp/manager.ts +40 -21
- package/src/modes/components/transcript-container.ts +14 -3
- package/src/modes/components/tree-selector.ts +29 -2
- package/src/modes/controllers/input-controller.ts +8 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
- package/src/prompts/agents/explore.md +1 -0
- package/src/prompts/agents/librarian.md +1 -0
- package/src/prompts/dry-balance-bench.md +8 -0
- package/src/sdk.ts +82 -9
- package/src/session/agent-session.ts +66 -7
- package/src/session/auth-storage.ts +4 -0
- package/src/task/executor.ts +6 -2
- package/src/task/index.ts +8 -7
- package/src/task/types.ts +2 -0
- package/src/tools/bash.ts +3 -4
- package/src/tools/index.ts +16 -0
- package/src/tools/job.ts +3 -3
- package/src/tools/memory-reflect.ts +2 -2
- package/src/tools/path-utils.ts +21 -0
- package/src/tools/search.ts +18 -1
- package/src/utils/file-mentions.ts +7 -107
- 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
|
|
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 =
|
|
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);
|
package/src/mcp/manager.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
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 (
|
|
1195
|
-
resolved
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
|
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
|
-
|
|
445
|
-
|
|
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,
|
|
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 = [
|
|
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
|
-
|
|
82
|
-
|
|
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(
|
|
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.#
|
|
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);
|
|
@@ -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 {
|
|
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
|
|
439
|
-
|
|
440
|
-
const
|
|
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
|
|
1172
|
-
// parent's manager via `AsyncJobManager.instance()`
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
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 =
|
|
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
|
-
*
|
|
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
|
-
|
|
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 =>
|
|
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 =>
|
|
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
|
-
|
|
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 =
|
|
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);
|