@oh-my-pi/pi-coding-agent 15.13.0 → 15.13.1
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 +1656 -613
- package/dist/cli.js +12765 -12731
- package/dist/types/autolearn/managed-skills.d.ts +1 -1
- package/dist/types/capability/mcp.d.ts +2 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/flag-tables.d.ts +126 -0
- package/dist/types/cli/profile-alias.d.ts +29 -0
- package/dist/types/cli/profile-bootstrap.d.ts +55 -0
- package/dist/types/commands/launch.d.ts +6 -0
- package/dist/types/config/model-roles.d.ts +3 -2
- package/dist/types/config/settings-schema.d.ts +2 -0
- package/dist/types/edit/file-snapshot-store.d.ts +14 -0
- package/dist/types/extensibility/extensions/runner.d.ts +11 -0
- package/dist/types/mcp/manager.d.ts +5 -1
- package/dist/types/mcp/oauth-credentials.d.ts +17 -0
- package/dist/types/mcp/oauth-flow.d.ts +41 -0
- package/dist/types/mcp/types.d.ts +2 -0
- package/dist/types/modes/components/background-tan-message.d.ts +9 -0
- package/dist/types/modes/components/mcp-add-wizard.d.ts +9 -5
- package/dist/types/modes/interactive-mode.d.ts +4 -0
- package/dist/types/modes/types.d.ts +3 -0
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/session/messages.d.ts +8 -0
- package/dist/types/session/session-manager.d.ts +6 -0
- package/dist/types/tools/builtin-names.d.ts +2 -0
- package/dist/types/tools/index.d.ts +3 -2
- package/dist/types/utils/external-editor.d.ts +11 -1
- package/package.json +12 -12
- package/src/autolearn/managed-skills.ts +3 -5
- package/src/capability/mcp.ts +2 -1
- package/src/cli/args.ts +61 -103
- package/src/cli/completion-gen.ts +2 -2
- package/src/cli/flag-tables.ts +270 -0
- package/src/cli/profile-alias.ts +338 -0
- package/src/cli/profile-bootstrap.ts +243 -0
- package/src/cli.ts +83 -16
- package/src/commands/launch.ts +7 -0
- package/src/config/mcp-schema.json +4 -0
- package/src/config/model-roles.ts +17 -4
- package/src/config/settings-schema.ts +2 -0
- package/src/discovery/builtin.ts +15 -9
- package/src/discovery/helpers.ts +25 -0
- package/src/discovery/mcp-json.ts +1 -0
- package/src/discovery/omp-extension-roots.ts +2 -2
- package/src/edit/file-snapshot-store.ts +43 -0
- package/src/eval/__tests__/agent-bridge.test.ts +3 -2
- package/src/eval/__tests__/helpers-local-roots.test.ts +1 -1
- package/src/eval/js/shared/runtime.ts +54 -0
- package/src/extensibility/extensions/runner.ts +25 -2
- package/src/goals/runtime.ts +4 -1
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/mcp/manager.ts +108 -71
- package/src/mcp/oauth-credentials.ts +104 -0
- package/src/mcp/oauth-flow.ts +67 -0
- package/src/mcp/types.ts +2 -0
- package/src/modes/components/agent-hub.ts +6 -0
- package/src/modes/components/background-tan-message.ts +36 -0
- package/src/modes/components/mcp-add-wizard.ts +17 -10
- package/src/modes/components/model-selector.ts +50 -6
- package/src/modes/components/tool-execution.ts +12 -0
- package/src/modes/controllers/input-controller.ts +21 -10
- package/src/modes/controllers/mcp-command-controller.ts +184 -112
- package/src/modes/controllers/tan-command-controller.ts +27 -11
- package/src/modes/interactive-mode.ts +6 -0
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/ui-helpers.ts +6 -0
- package/src/prompts/bench.md +9 -4
- package/src/sdk.ts +6 -5
- package/src/session/agent-session.ts +30 -1
- package/src/session/messages.ts +9 -0
- package/src/session/session-manager.ts +7 -2
- package/src/tiny/text.ts +5 -1
- package/src/tools/ast-grep.ts +5 -1
- package/src/tools/builtin-names.ts +35 -0
- package/src/tools/index.ts +3 -2
- package/src/tools/read.ts +9 -0
- package/src/tools/search.ts +5 -1
- package/src/tts/tts-worker.ts +13 -5
- package/src/utils/external-editor.ts +15 -2
- package/src/utils/title-generator.ts +1 -1
- package/src/workspace-tree.ts +46 -6
- package/dist/types/utils/tools-manager.test.d.ts +0 -1
- package/src/utils/tools-manager.test.ts +0 -25
package/src/cli.ts
CHANGED
|
@@ -15,8 +15,17 @@ try {
|
|
|
15
15
|
* lightweight CLI runner from pi-utils.
|
|
16
16
|
*/
|
|
17
17
|
import type { CliConfig } from "@oh-my-pi/pi-utils/cli";
|
|
18
|
-
import {
|
|
19
|
-
|
|
18
|
+
import {
|
|
19
|
+
APP_NAME,
|
|
20
|
+
getActiveProfile,
|
|
21
|
+
MIN_BUN_VERSION,
|
|
22
|
+
resolveProfileEnv,
|
|
23
|
+
setProfile,
|
|
24
|
+
VERSION,
|
|
25
|
+
} from "@oh-my-pi/pi-utils/dirs";
|
|
26
|
+
import { declareWorkerHostEntry } from "@oh-my-pi/pi-utils/worker-host";
|
|
27
|
+
import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias";
|
|
28
|
+
import { extractProfileFlags } from "./cli/profile-bootstrap";
|
|
20
29
|
|
|
21
30
|
if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
|
|
22
31
|
process.stderr.write(
|
|
@@ -27,11 +36,11 @@ if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
|
|
|
27
36
|
|
|
28
37
|
process.title = APP_NAME;
|
|
29
38
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
39
|
+
// Worker-host entry declaration (Worker threads and worker subprocesses
|
|
40
|
+
// re-enter `Bun.main` with a hidden argv selector instead of loading separate
|
|
41
|
+
// worker entrypoints) happens inside `runCli` after profile bootstrap:
|
|
42
|
+
// `@oh-my-pi/pi-utils/env` eagerly loads `.env` from the agent directory at
|
|
43
|
+
// import time, so it must not be imported before `setProfile` runs.
|
|
35
44
|
|
|
36
45
|
async function showHelp(config: CliConfig): Promise<void> {
|
|
37
46
|
const { renderRootHelp } = await import("@oh-my-pi/pi-utils/cli");
|
|
@@ -196,15 +205,67 @@ async function runTinyWorker(): Promise<void> {
|
|
|
196
205
|
|
|
197
206
|
/** Run the CLI with the given argv (no `process.argv` prefix). */
|
|
198
207
|
export async function runCli(argv: string[]): Promise<void> {
|
|
199
|
-
|
|
200
|
-
|
|
208
|
+
let resolvedArgv = argv;
|
|
209
|
+
try {
|
|
210
|
+
const extracted = extractProfileFlags(resolvedArgv);
|
|
211
|
+
resolvedArgv = extracted.argv;
|
|
212
|
+
if (extracted.profile !== undefined) {
|
|
213
|
+
setProfile(extracted.profile);
|
|
214
|
+
} else {
|
|
215
|
+
// No explicit --profile: activate any OMP_PROFILE/PI_PROFILE inherited
|
|
216
|
+
// from the environment. Module-load resolution deliberately swallows an
|
|
217
|
+
// invalid value to avoid an uncaught throw before this try/catch is in
|
|
218
|
+
// scope (see `readProfileFromEnvSafe` in dirs.ts), and callers may set
|
|
219
|
+
// OMP_PROFILE after importing this module (profile aliases/tests). Surfacing
|
|
220
|
+
// validation here turns `OMP_PROFILE=.. omp --version` into a clean error;
|
|
221
|
+
// calling setProfile keeps every later path helper on the env-selected
|
|
222
|
+
// profile instead of the default agent directory.
|
|
223
|
+
setProfile(resolveProfileEnv(process.env.OMP_PROFILE, process.env.PI_PROFILE));
|
|
224
|
+
}
|
|
225
|
+
if (extracted.aliasName !== undefined) {
|
|
226
|
+
const profile = extracted.profile ?? getActiveProfile();
|
|
227
|
+
if (!profile) {
|
|
228
|
+
throw new Error("--alias requires --profile <name> or OMP_PROFILE");
|
|
229
|
+
}
|
|
230
|
+
const result = await installProfileAlias({
|
|
231
|
+
profile,
|
|
232
|
+
aliasName: extracted.aliasName,
|
|
233
|
+
command: resolveProfileAliasCommandFromProcess(),
|
|
234
|
+
});
|
|
235
|
+
process.stdout.write(
|
|
236
|
+
`Created ${result.aliasName} for profile ${result.profile} in ${result.configPath}\n` +
|
|
237
|
+
`Restart your shell or run: ${result.reloadedWith}\n` +
|
|
238
|
+
`Then use: ${result.aliasName} update, ${result.aliasName} --version, or ${result.aliasName}\n`,
|
|
239
|
+
);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
244
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
245
|
+
process.exitCode = 1;
|
|
201
246
|
return;
|
|
202
247
|
}
|
|
203
|
-
|
|
248
|
+
|
|
249
|
+
// Worker-thread entry dispatch must run before the first `await`: the
|
|
250
|
+
// stats sync worker's buffering onmessage handler is installed in the
|
|
251
|
+
// synchronous prefix of `runWorkerEntrypoint`, and Bun flushes the
|
|
252
|
+
// worker's parked initial messages as soon as the entry module's
|
|
253
|
+
// top-level evaluation finishes.
|
|
254
|
+
if (TINY_WORKER_ARGS.has(resolvedArgv[0] ?? "")) {
|
|
204
255
|
await runTinyWorker();
|
|
205
256
|
return;
|
|
206
257
|
}
|
|
207
|
-
if (await runWorkerEntrypoint(
|
|
258
|
+
if (await runWorkerEntrypoint(resolvedArgv[0])) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Declare this module as the worker-host entry now that the active profile
|
|
263
|
+
// is resolved. The worker-host module is side-effect-free; importing
|
|
264
|
+
// `@oh-my-pi/pi-utils/env` here would snapshot the wrong agent `.env`.
|
|
265
|
+
declareWorkerHostEntry();
|
|
266
|
+
|
|
267
|
+
if (resolvedArgv[0] === "--smoke-test") {
|
|
268
|
+
await runSmokeTest();
|
|
208
269
|
return;
|
|
209
270
|
}
|
|
210
271
|
const [{ run }, { commands, resolveCliArgv }] = await Promise.all([
|
|
@@ -213,7 +274,7 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
213
274
|
]);
|
|
214
275
|
// --help and --version are handled by run() directly, don't rewrite those.
|
|
215
276
|
// Everything else that isn't a known subcommand routes to "launch".
|
|
216
|
-
const resolved = resolveCliArgv(
|
|
277
|
+
const resolved = resolveCliArgv(resolvedArgv);
|
|
217
278
|
if ("error" in resolved) {
|
|
218
279
|
process.stderr.write(`error: ${resolved.error}\n`);
|
|
219
280
|
process.exitCode = 1;
|
|
@@ -226,7 +287,13 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
226
287
|
// lowering) builds to fail, and the entrypoint needs nothing after this.
|
|
227
288
|
// The catch mirrors what an unhandled TLA rejection produced: error dump to
|
|
228
289
|
// stderr, exit code 1. Success paths resolve without touching the exit code.
|
|
229
|
-
runCli(
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
290
|
+
// Guarded so importing `runCli` (profile CLI tests, SDK embedding) does not
|
|
291
|
+
// launch the agent as a side effect. Worker threads re-enter this module as
|
|
292
|
+
// their entry with `import.meta.main === false`, so the worker-host dispatch
|
|
293
|
+
// is admitted via `!Bun.isMainThread`.
|
|
294
|
+
if (import.meta.main || !Bun.isMainThread) {
|
|
295
|
+
runCli(process.argv.slice(2)).catch((err: unknown) => {
|
|
296
|
+
process.stderr.write(`${Bun.inspect(err, { colors: process.stderr.isTTY === true })}\n`);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
});
|
|
299
|
+
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -49,6 +49,12 @@ export default class Index extends Command {
|
|
|
49
49
|
"allow-home": Flags.boolean({
|
|
50
50
|
description: "Allow starting in ~ without auto-switching to a temp dir",
|
|
51
51
|
}),
|
|
52
|
+
profile: Flags.string({
|
|
53
|
+
description: "Use an isolated profile for auth, sessions, settings, and caches",
|
|
54
|
+
}),
|
|
55
|
+
alias: Flags.string({
|
|
56
|
+
description: "Create a shell shortcut for the selected profile and exit",
|
|
57
|
+
}),
|
|
52
58
|
cwd: Flags.string({
|
|
53
59
|
description: "Directory to start in (overrides the launch cwd)",
|
|
54
60
|
}),
|
|
@@ -151,6 +157,7 @@ export default class Index extends Command {
|
|
|
151
157
|
`# Include files in initial message\n ${APP_NAME} @prompt.md @image.png "What color is the sky?"`,
|
|
152
158
|
`# Non-interactive mode (process and exit)\n ${APP_NAME} -p "List all .ts files in src/"`,
|
|
153
159
|
`# Continue previous session\n ${APP_NAME} --continue "What did we discuss?"`,
|
|
160
|
+
`# Create a shell shortcut for a work profile\n ${APP_NAME} --profile work --alias omp-work`,
|
|
154
161
|
`# Use different model (fuzzy matching)\n ${APP_NAME} --model opus "Help me refactor this code"`,
|
|
155
162
|
`# Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o`,
|
|
156
163
|
`# Export a session file to HTML\n ${APP_NAME} --export ~/.omp/agent/sessions/--path--/session.jsonl`,
|
|
@@ -89,6 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"callbackPath": {
|
|
91
91
|
"type": "string"
|
|
92
|
+
},
|
|
93
|
+
"prompt": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"description": "OAuth `prompt` parameter sent during authorization (default: \"consent\" so the provider always shows its account/consent screen; set to \"\" to omit)."
|
|
92
96
|
}
|
|
93
97
|
},
|
|
94
98
|
"description": "Explicit OAuth client settings for servers that need them during /mcp reauth or initial connect."
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
|
|
6
6
|
import type { Settings } from "./settings";
|
|
7
7
|
|
|
8
|
-
export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "task";
|
|
8
|
+
export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "title" | "task";
|
|
9
9
|
|
|
10
10
|
export interface ModelRoleInfo {
|
|
11
11
|
tag?: string;
|
|
12
12
|
name: string;
|
|
13
13
|
color?: ThemeColor;
|
|
14
|
+
/** If true, the role is functional but not shown in the model selector UI. */
|
|
15
|
+
hidden?: boolean;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
|
|
@@ -21,12 +23,22 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
|
|
|
21
23
|
plan: { tag: "PLAN", name: "Architect", color: "muted" },
|
|
22
24
|
designer: { tag: "DESIGNER", name: "Designer", color: "muted" },
|
|
23
25
|
commit: { tag: "COMMIT", name: "Commit", color: "dim" },
|
|
26
|
+
title: { tag: "TITLE", name: "Title", color: "dim", hidden: true },
|
|
24
27
|
task: { tag: "TASK", name: "Subtask", color: "muted" },
|
|
25
28
|
};
|
|
26
29
|
|
|
27
|
-
export const MODEL_ROLE_IDS: ModelRole[] = [
|
|
30
|
+
export const MODEL_ROLE_IDS: ModelRole[] = [
|
|
31
|
+
"default",
|
|
32
|
+
"smol",
|
|
33
|
+
"slow",
|
|
34
|
+
"vision",
|
|
35
|
+
"plan",
|
|
36
|
+
"designer",
|
|
37
|
+
"commit",
|
|
38
|
+
"title",
|
|
39
|
+
"task",
|
|
40
|
+
];
|
|
28
41
|
|
|
29
|
-
/** Alias for ModelRoleInfo - used for both built-in and custom roles */
|
|
30
42
|
export type RoleInfo = ModelRoleInfo;
|
|
31
43
|
|
|
32
44
|
/**
|
|
@@ -37,7 +49,7 @@ export type RoleInfo = ModelRoleInfo;
|
|
|
37
49
|
* entries across settings.
|
|
38
50
|
*/
|
|
39
51
|
export function getKnownRoleIds(settings: Settings): string[] {
|
|
40
|
-
const roles = [
|
|
52
|
+
const roles = MODEL_ROLE_IDS.filter(role => !MODEL_ROLES[role as ModelRole]?.hidden) as string[];
|
|
41
53
|
const seen = new Set<string>(roles);
|
|
42
54
|
const addRole = (role: string) => {
|
|
43
55
|
if (seen.has(role)) return;
|
|
@@ -65,6 +77,7 @@ export function getRoleInfo(role: string, settings: Settings): RoleInfo {
|
|
|
65
77
|
tag: builtIn?.tag,
|
|
66
78
|
name: configured.name || builtIn?.name || role,
|
|
67
79
|
color: configured.color && isValidThemeColor(configured.color) ? configured.color : builtIn?.color,
|
|
80
|
+
hidden: configured.hidden ?? builtIn?.hidden,
|
|
68
81
|
};
|
|
69
82
|
}
|
|
70
83
|
|
package/src/discovery/builtin.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Primary provider for OMP native configs. Supports all capabilities.
|
|
5
5
|
*/
|
|
6
6
|
import * as path from "node:path";
|
|
7
|
-
import { logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
|
|
7
|
+
import { getAgentDir, logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
|
|
8
8
|
import { YAML } from "bun";
|
|
9
9
|
import { getManagedSkillsDir, MANAGED_SKILLS_PROVIDER_ID } from "../autolearn/managed-skills";
|
|
10
10
|
import { registerProvider } from "../capability";
|
|
@@ -61,7 +61,9 @@ async function getConfigDirs(ctx: LoadContext): Promise<Array<{ dir: string; lev
|
|
|
61
61
|
if (projectDir) {
|
|
62
62
|
result.push({ dir: projectDir, level: "project" });
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
// Native user config is profile-scoped: getAgentDir() points at the active
|
|
65
|
+
// profile's agent dir (~/.omp/profiles/<name>/agent), like sessions and MCP.
|
|
66
|
+
const userDir = await ifNonEmptyDir(getAgentDir());
|
|
65
67
|
if (userDir) {
|
|
66
68
|
result.push({ dir: userDir, level: "user" });
|
|
67
69
|
}
|
|
@@ -178,6 +180,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
178
180
|
redirectUri?: string;
|
|
179
181
|
callbackPort?: number;
|
|
180
182
|
callbackPath?: string;
|
|
183
|
+
prompt?: string;
|
|
181
184
|
}
|
|
182
185
|
| undefined,
|
|
183
186
|
transport: serverConfig.type as "stdio" | "sse" | "http" | undefined,
|
|
@@ -187,11 +190,14 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
187
190
|
return result;
|
|
188
191
|
};
|
|
189
192
|
|
|
193
|
+
// User scope tracks the active profile via getAgentDir() (not ctx.home), so it
|
|
194
|
+
// stays in sync with getMCPConfigPath("user") and the /mcp config writer.
|
|
195
|
+
const userAgentDir = getAgentDir();
|
|
190
196
|
const paths = [
|
|
191
197
|
{ path: path.join(ctx.cwd, PATHS.projectDir, "mcp.json"), level: "project" as const },
|
|
192
198
|
{ path: path.join(ctx.cwd, PATHS.projectDir, ".mcp.json"), level: "project" as const },
|
|
193
|
-
{ path: path.join(
|
|
194
|
-
{ path: path.join(
|
|
199
|
+
{ path: path.join(userAgentDir, "mcp.json"), level: "user" as const },
|
|
200
|
+
{ path: path.join(userAgentDir, ".mcp.json"), level: "user" as const },
|
|
195
201
|
];
|
|
196
202
|
|
|
197
203
|
const contents = await Promise.allSettled(
|
|
@@ -226,7 +232,7 @@ registerProvider<MCPServer>(mcpCapability.id, {
|
|
|
226
232
|
async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemPrompt>> {
|
|
227
233
|
const items: SystemPrompt[] = [];
|
|
228
234
|
|
|
229
|
-
const userPath = path.join(
|
|
235
|
+
const userPath = path.join(getAgentDir(), "SYSTEM.md");
|
|
230
236
|
const userContent = await readFile(userPath);
|
|
231
237
|
if (userContent) {
|
|
232
238
|
items.push({
|
|
@@ -277,7 +283,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
|
277
283
|
|
|
278
284
|
// User-level scan from ~/.omp/agent/skills/
|
|
279
285
|
const userScan = scanSkillsFromDir(ctx, {
|
|
280
|
-
dir: path.join(
|
|
286
|
+
dir: path.join(getAgentDir(), "skills"),
|
|
281
287
|
providerId: PROVIDER_ID,
|
|
282
288
|
level: "user",
|
|
283
289
|
requireDescription: true,
|
|
@@ -297,7 +303,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
|
297
303
|
const MANAGED_SKILLS_PRIORITY = 5;
|
|
298
304
|
async function loadManagedSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
299
305
|
return scanSkillsFromDir(ctx, {
|
|
300
|
-
dir: getManagedSkillsDir(
|
|
306
|
+
dir: getManagedSkillsDir(),
|
|
301
307
|
providerId: MANAGED_SKILLS_PROVIDER_ID,
|
|
302
308
|
level: "user",
|
|
303
309
|
requireDescription: true,
|
|
@@ -373,7 +379,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
|
|
|
373
379
|
// the current turn so they keep hold across long conversations".
|
|
374
380
|
// User scope: ~/.omp/agent/RULES.md
|
|
375
381
|
// Project scope: nearest .omp/RULES.md walking up from cwd to repoRoot
|
|
376
|
-
const userRulesFile = path.join(
|
|
382
|
+
const userRulesFile = path.join(getAgentDir(), "RULES.md");
|
|
377
383
|
const userRule = await loadStickyRulesFile(userRulesFile, "user");
|
|
378
384
|
if (userRule) items.push(userRule);
|
|
379
385
|
|
|
@@ -890,7 +896,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
|
|
890
896
|
const items: ContextFile[] = [];
|
|
891
897
|
const warnings: string[] = [];
|
|
892
898
|
|
|
893
|
-
const userPath = path.join(
|
|
899
|
+
const userPath = path.join(getAgentDir(), "AGENTS.md");
|
|
894
900
|
const userContent = await readFile(userPath);
|
|
895
901
|
if (userContent) {
|
|
896
902
|
items.push({
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
|
5
5
|
import { FileType, glob } from "@oh-my-pi/pi-natives";
|
|
6
6
|
import {
|
|
7
7
|
CONFIG_DIR_NAME,
|
|
8
|
+
getAgentDir,
|
|
8
9
|
getConfigDirName,
|
|
9
10
|
getPluginsDir,
|
|
10
11
|
getProjectDir,
|
|
@@ -86,6 +87,11 @@ export type SourceId = keyof typeof SOURCE_PATHS;
|
|
|
86
87
|
* Get user-level path for a source.
|
|
87
88
|
*/
|
|
88
89
|
export function getUserPath(ctx: LoadContext, source: SourceId, subpath: string): string | null {
|
|
90
|
+
// Native user config is profile-scoped via getAgentDir() (the active profile's
|
|
91
|
+
// agent dir), matching builtin.ts and getMCPConfigPath("user"). External tools
|
|
92
|
+
// (~/.claude, ~/.gemini, …) are intentionally not profile-scoped, so they keep
|
|
93
|
+
// resolving against ctx.home below.
|
|
94
|
+
if (source === "native") return path.join(getAgentDir(), subpath);
|
|
89
95
|
const paths = SOURCE_PATHS[source];
|
|
90
96
|
if (!paths.userAgent) return null;
|
|
91
97
|
return path.join(ctx.home, paths.userAgent, subpath);
|
|
@@ -569,6 +575,25 @@ export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: strin
|
|
|
569
575
|
const indexFiles = [...globIndexFiles, ...linkedFiles.indexFiles];
|
|
570
576
|
const packageJsonFiles = [...globPackageJsonFiles, ...linkedFiles.packageJsonFiles];
|
|
571
577
|
|
|
578
|
+
// The native glob walker runs with follow_links=false, so a symlinked extension
|
|
579
|
+
// directory is yielded as a Symlink entry but never descended into: its inner
|
|
580
|
+
// index.{ts,js}/package.json are invisible to the `*/...` patterns above.
|
|
581
|
+
// Detect top-level symlinked directories and synthesize the equivalent subdir
|
|
582
|
+
// matches so the resolution below treats them like real directories. Symlinked
|
|
583
|
+
// *files* already match, because the native file-type filter resolves a
|
|
584
|
+
// symlink's target type for File filters.
|
|
585
|
+
const topLevelEntries = await readDirEntries(dir);
|
|
586
|
+
for (const entry of topLevelEntries) {
|
|
587
|
+
if (!entry.isSymbolicLink()) continue;
|
|
588
|
+
// readDirEntries follows the symlink: a link to a file/dangling link yields [].
|
|
589
|
+
const subEntries = await readDirEntries(path.join(dir, entry.name));
|
|
590
|
+
const hasEntry = (name: string): boolean =>
|
|
591
|
+
subEntries.some(e => e.name === name && (e.isFile() || e.isSymbolicLink()));
|
|
592
|
+
if (hasEntry("package.json")) packageJsonFiles.push({ path: `${entry.name}/package.json` });
|
|
593
|
+
if (hasEntry("index.ts")) indexFiles.push({ path: `${entry.name}/index.ts` });
|
|
594
|
+
else if (hasEntry("index.js")) indexFiles.push({ path: `${entry.name}/index.js` });
|
|
595
|
+
}
|
|
596
|
+
|
|
572
597
|
// Process direct files
|
|
573
598
|
for (const match of directFiles) {
|
|
574
599
|
if (match.path.includes("/")) continue;
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import * as fs from "node:fs/promises";
|
|
19
19
|
import * as path from "node:path";
|
|
20
|
-
import { isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
|
|
20
|
+
import { getAgentDir, isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
|
|
21
21
|
import { readDirEntries, readFile } from "../capability/fs";
|
|
22
22
|
import type { LoadContext } from "../capability/types";
|
|
23
23
|
import { getEnabledPlugins } from "../extensibility/plugins/loader";
|
|
@@ -82,7 +82,7 @@ interface ScopeDirs {
|
|
|
82
82
|
function scopeDirs(ctx: LoadContext): ScopeDirs {
|
|
83
83
|
return {
|
|
84
84
|
project: path.join(ctx.cwd, ".omp"),
|
|
85
|
-
user:
|
|
85
|
+
user: getAgentDir(),
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
88
|
|
|
@@ -89,3 +89,46 @@ export async function recordFileSnapshot(
|
|
|
89
89
|
return undefined;
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Leading line-number prefix the hashline/summary/grep formatters stamp on
|
|
95
|
+
* every displayed body line: `NN:` or a collapsed summary `NN-MM:` from `read`,
|
|
96
|
+
* optionally preceded by a grep `*` (match) / space (context) marker from
|
|
97
|
+
* `search`/`ast-grep`. Anchored at line start, so source content after the
|
|
98
|
+
* colon never matches.
|
|
99
|
+
*/
|
|
100
|
+
const HASHLINE_LINE_PREFIX = /^[ *]?(\d+)(?:-(\d+))?:/;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The 1-indexed file lines a hashline-formatted body actually displayed.
|
|
104
|
+
* Single `NN:` rows contribute that line; a collapsed summary `NN-MM:` row
|
|
105
|
+
* (a `{ .. }` brace pair) contributes only its boundary lines `NN` and `MM` —
|
|
106
|
+
* the elided interior was never shown, so editing inside it must be rejected.
|
|
107
|
+
*/
|
|
108
|
+
export function parseSeenLinesFromHashlineBody(body: string): number[] {
|
|
109
|
+
const seen: number[] = [];
|
|
110
|
+
for (const row of body.split("\n")) {
|
|
111
|
+
const match = HASHLINE_LINE_PREFIX.exec(row);
|
|
112
|
+
if (!match) continue;
|
|
113
|
+
seen.push(Number(match[1]));
|
|
114
|
+
if (match[2] !== undefined) seen.push(Number(match[2]));
|
|
115
|
+
}
|
|
116
|
+
return seen;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Attach the lines a read displayed to the snapshot it minted, so the patcher
|
|
121
|
+
* can reject edits anchored on lines the model never saw. Best-effort: a no-op
|
|
122
|
+
* when the body has no numbered rows or the snapshot already aged out. `tag`
|
|
123
|
+
* must be the tag returned when this exact content was recorded.
|
|
124
|
+
*/
|
|
125
|
+
export function recordSeenLinesFromBody(
|
|
126
|
+
session: FileSnapshotStoreOwner,
|
|
127
|
+
absolutePath: string,
|
|
128
|
+
tag: string,
|
|
129
|
+
body: string,
|
|
130
|
+
): void {
|
|
131
|
+
const seen = parseSeenLinesFromHashlineBody(body);
|
|
132
|
+
if (seen.length === 0) return;
|
|
133
|
+
getFileSnapshotStore(session).recordSeenLines(canonicalSnapshotKey(absolutePath), tag, seen);
|
|
134
|
+
}
|
|
@@ -679,13 +679,14 @@ describe("agent() through eval runtimes", () => {
|
|
|
679
679
|
cost: 0,
|
|
680
680
|
durationMs: i * 10,
|
|
681
681
|
});
|
|
682
|
-
await Bun.sleep(
|
|
682
|
+
await Bun.sleep(40);
|
|
683
683
|
}
|
|
684
684
|
return singleResult(options, { output: "done" });
|
|
685
685
|
});
|
|
686
686
|
|
|
687
687
|
const ops: string[] = [];
|
|
688
|
-
|
|
688
|
+
// Timing invariant (keep, do not re-tighten): total mock work (20*40ms = 800ms) > idle window (250ms) > scheduling jitter (~tens of ms).
|
|
689
|
+
using idle = new IdleTimeout(250);
|
|
689
690
|
const result = await runEvalAgent(
|
|
690
691
|
{ prompt: "investigate" },
|
|
691
692
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { TempDir } from "@oh-my-pi/pi-utils";
|
|
3
|
+
import { TempDir } from "@oh-my-pi/pi-utils/temp";
|
|
4
4
|
import { createHelpers, type HelperContext } from "../js/shared/helpers";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -342,9 +342,63 @@ export class JsRuntime {
|
|
|
342
342
|
// Prelude assigns console bridge + short aliases (`read`, `write`, `tool`, `display`, ...)
|
|
343
343
|
// onto globalThis. Must run after helpers are in place.
|
|
344
344
|
indirectEval(JAVASCRIPT_PRELUDE_SOURCE);
|
|
345
|
+
RUN_HOOK_RESOLVERS.add(() => this.#als.getStore()?.hooks);
|
|
346
|
+
patchStdioOnce();
|
|
345
347
|
}
|
|
346
348
|
}
|
|
347
349
|
|
|
350
|
+
/** Resolvers for each live runtime's active-run hooks (one per JsRuntime instance). */
|
|
351
|
+
const RUN_HOOK_RESOLVERS = new Set<() => RuntimeHooks | undefined>();
|
|
352
|
+
|
|
353
|
+
/** Streams whose `write` the runtime has already wrapped (patch-once guard). */
|
|
354
|
+
const PATCHED_STDIO_STREAMS = new WeakSet<NodeJS.WriteStream>();
|
|
355
|
+
|
|
356
|
+
/** Hooks for whichever registered runtime currently has an active run, if any. */
|
|
357
|
+
function activeRunHooks(): RuntimeHooks | undefined {
|
|
358
|
+
for (const resolve of RUN_HOOK_RESOLVERS) {
|
|
359
|
+
const hooks = resolve();
|
|
360
|
+
if (hooks) return hooks;
|
|
361
|
+
}
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Wrap `process.stdout` / `process.stderr` `write` exactly once per process so
|
|
367
|
+
* user `process.stdout.write(...)` lands in the active run's text sink. Models
|
|
368
|
+
* reach for it out of Node habit, but `process` is intentionally the host
|
|
369
|
+
* worker's real object (see {@link JsRuntime} `#install`), so unrouted writes
|
|
370
|
+
* escape to the worker's own stdio and never reach the cell — and `write()`
|
|
371
|
+
* returns a boolean, so a cell ending in `process.stdout.write("x")` captured
|
|
372
|
+
* `true` while losing the text. Patch only the `write` method (never replace
|
|
373
|
+
* `process`), preserve exact bytes (no trailing newline), and fall through to
|
|
374
|
+
* the real stream when no run is active so the worker's own logging is intact.
|
|
375
|
+
*/
|
|
376
|
+
function patchStdioOnce(): void {
|
|
377
|
+
const streams: NodeJS.WriteStream[] = [process.stdout, process.stderr];
|
|
378
|
+
for (const stream of streams) {
|
|
379
|
+
if (!stream || PATCHED_STDIO_STREAMS.has(stream)) continue;
|
|
380
|
+
PATCHED_STDIO_STREAMS.add(stream);
|
|
381
|
+
const original = stream.write.bind(stream) as (...args: unknown[]) => boolean;
|
|
382
|
+
const routed = (chunk: unknown, encoding?: unknown, callback?: unknown): boolean => {
|
|
383
|
+
const hooks = activeRunHooks();
|
|
384
|
+
if (!hooks) return original(chunk, encoding, callback);
|
|
385
|
+
const cb = typeof encoding === "function" ? encoding : callback;
|
|
386
|
+
const enc = typeof encoding === "string" ? (encoding as BufferEncoding) : undefined;
|
|
387
|
+
hooks.onText(chunkToString(chunk, enc));
|
|
388
|
+
if (typeof cb === "function") (cb as (error?: Error | null) => void)();
|
|
389
|
+
return true;
|
|
390
|
+
};
|
|
391
|
+
stream.write = routed as unknown as typeof stream.write;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Coerce a `write()` chunk to text, honoring an explicit encoding for byte chunks. */
|
|
396
|
+
function chunkToString(chunk: unknown, encoding?: BufferEncoding): string {
|
|
397
|
+
if (typeof chunk === "string") return chunk;
|
|
398
|
+
if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString(encoding ?? "utf8");
|
|
399
|
+
return String(chunk);
|
|
400
|
+
}
|
|
401
|
+
|
|
348
402
|
function formatConsoleArgs(args: unknown[]): string {
|
|
349
403
|
return args
|
|
350
404
|
.map(arg => (typeof arg === "string" ? arg : util.inspect(arg, { depth: 6, colors: false, breakLength: 120 })))
|
|
@@ -71,6 +71,28 @@ export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void {
|
|
|
71
71
|
extensionHandlerTimeoutMs = timeoutMs;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Dedicated cap for `session_shutdown` handlers. The generic 30s budget is
|
|
76
|
+
* appropriate for events extensions can observe (e.g. `session_start`,
|
|
77
|
+
* `before_provider_request`), but `session_shutdown` is fire-and-forget
|
|
78
|
+
* teardown — extensions receive no result and the user has already asked to
|
|
79
|
+
* leave. A hung handler (e.g. an extension waiting on a stuck IPC pipe to a
|
|
80
|
+
* companion app) MUST NOT hold Ctrl+C / `/exit` hostage for the full window.
|
|
81
|
+
* See issue #2600.
|
|
82
|
+
*/
|
|
83
|
+
export const SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS = 2_000;
|
|
84
|
+
let sessionShutdownHandlerTimeoutMs = SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS;
|
|
85
|
+
|
|
86
|
+
export function testSetSessionShutdownHandlerTimeoutMs(timeoutMs: number): void {
|
|
87
|
+
sessionShutdownHandlerTimeoutMs = timeoutMs;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Per-event handler budget. Defaults to the generic cap; `session_shutdown`
|
|
91
|
+
* uses its own short cap so teardown stays prompt. */
|
|
92
|
+
function handlerTimeoutForEvent(eventType: string): number {
|
|
93
|
+
return eventType === "session_shutdown" ? sessionShutdownHandlerTimeoutMs : extensionHandlerTimeoutMs;
|
|
94
|
+
}
|
|
95
|
+
|
|
74
96
|
const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
|
|
75
97
|
|
|
76
98
|
const MAX_PENDING_CREDENTIAL_DISABLED = 32;
|
|
@@ -576,7 +598,7 @@ export class ExtensionRunner {
|
|
|
576
598
|
event,
|
|
577
599
|
ctx,
|
|
578
600
|
ext,
|
|
579
|
-
|
|
601
|
+
handlerTimeoutForEvent(event.type),
|
|
580
602
|
);
|
|
581
603
|
|
|
582
604
|
if (this.#isSessionBeforeEvent(event) && handlerResult) {
|
|
@@ -907,7 +929,8 @@ export class ExtensionRunner {
|
|
|
907
929
|
messages.push(result.message);
|
|
908
930
|
}
|
|
909
931
|
if (result.systemPrompt !== undefined) {
|
|
910
|
-
currentSystemPrompt =
|
|
932
|
+
currentSystemPrompt =
|
|
933
|
+
typeof result.systemPrompt === "string" ? [result.systemPrompt] : result.systemPrompt;
|
|
911
934
|
systemPromptModified = true;
|
|
912
935
|
}
|
|
913
936
|
}
|
package/src/goals/runtime.ts
CHANGED
|
@@ -356,7 +356,10 @@ export class GoalRuntime {
|
|
|
356
356
|
this.#wallClock.lastAccountedAt += wallSeconds * 1000;
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
-
|
|
359
|
+
// Persisting wall-clock-only accounting on every tool event bloats /goal sessions with full
|
|
360
|
+
// objective snapshots. Keep the in-memory/UI state fresh, but persist only token/budget changes.
|
|
361
|
+
const shouldPersistUsage = tokenDelta > 0 || flippedToBudgetLimited;
|
|
362
|
+
await this.#commitState(state, { persist: shouldPersistUsage ? "goal" : undefined });
|
|
360
363
|
|
|
361
364
|
if (state.goal.status !== "budget-limited") {
|
|
362
365
|
this.#budgetReportedFor = undefined;
|