@oh-my-pi/pi-coding-agent 16.5.1 → 16.5.2
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 +65 -0
- package/dist/cli.js +3442 -3408
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
- package/dist/types/eval/backend.d.ts +3 -3
- package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
- package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
- package/dist/types/goals/guided-setup.d.ts +12 -0
- package/dist/types/internal-urls/history-protocol.d.ts +3 -2
- package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
- package/dist/types/mcp/oauth-discovery.d.ts +2 -0
- package/dist/types/mcp/oauth-flow.d.ts +2 -0
- package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
- package/dist/types/modes/components/agent-hub.d.ts +10 -0
- package/dist/types/modes/components/dynamic-border.d.ts +5 -3
- package/dist/types/modes/components/login-dialog.d.ts +2 -0
- package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
- package/dist/types/modes/components/read-tool-group.d.ts +0 -2
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/session/messages.d.ts +15 -0
- package/dist/types/tools/grep.d.ts +0 -2
- package/dist/types/tools/read.d.ts +0 -4
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +136 -49
- package/src/advisor/runtime.ts +16 -34
- package/src/autoresearch/dashboard.ts +2 -2
- package/src/cli/config-cli.ts +15 -3
- package/src/config/settings-schema.ts +10 -0
- package/src/cursor.ts +2 -0
- package/src/discovery/claude-plugins.ts +9 -3
- package/src/discovery/omp-plugins.ts +6 -2
- package/src/discovery/substitute-plugin-root.ts +32 -0
- package/src/eval/__tests__/prelude-agent.test.ts +20 -0
- package/src/eval/backend.ts +3 -3
- package/src/eval/py/__tests__/prelude.test.ts +72 -0
- package/src/eval/py/prelude.py +28 -1
- package/src/exec/bash-executor.ts +30 -43
- package/src/extensibility/extensions/wrapper.ts +18 -18
- package/src/extensibility/plugins/bun-git-cache.ts +91 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
- package/src/extensibility/plugins/manager.ts +7 -7
- package/src/goals/guided-setup.ts +29 -1
- package/src/internal-urls/history-protocol.ts +95 -15
- package/src/internal-urls/registry-helpers.ts +50 -1
- package/src/launch/broker.ts +38 -25
- package/src/mcp/oauth-discovery.ts +20 -1
- package/src/mcp/oauth-flow.ts +3 -1
- package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
- package/src/modes/components/agent-dashboard.ts +2 -2
- package/src/modes/components/agent-hub.ts +15 -2
- package/src/modes/components/agent-transcript-viewer.ts +2 -2
- package/src/modes/components/chat-transcript-builder.ts +4 -3
- package/src/modes/components/dynamic-border.ts +9 -6
- package/src/modes/components/extensions/extension-list.ts +2 -2
- package/src/modes/components/hook-selector.ts +10 -4
- package/src/modes/components/login-dialog.ts +5 -0
- package/src/modes/components/mcp-add-wizard.ts +5 -0
- package/src/modes/components/plan-review-overlay.ts +11 -11
- package/src/modes/components/read-tool-group.ts +1 -8
- package/src/modes/controllers/input-controller.ts +4 -2
- package/src/modes/controllers/mcp-command-controller.ts +6 -7
- package/src/modes/controllers/selector-controller.ts +9 -2
- package/src/modes/controllers/todo-command-controller.ts +18 -14
- package/src/modes/interactive-mode.ts +7 -3
- package/src/modes/prompt-action-autocomplete.ts +6 -1
- package/src/modes/types.ts +1 -1
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/eval.md +2 -2
- package/src/prompts/tools/grep.md +1 -2
- package/src/prompts/tools/read.md +2 -4
- package/src/sdk.ts +28 -31
- package/src/session/agent-session.ts +44 -22
- package/src/session/messages.test.ts +66 -0
- package/src/session/messages.ts +37 -0
- package/src/system-prompt.test.ts +36 -0
- package/src/system-prompt.ts +1 -1
- package/src/tools/browser/registry.ts +17 -3
- package/src/tools/eval.ts +14 -9
- package/src/tools/gh.ts +3 -1
- package/src/tools/grep.ts +5 -45
- package/src/tools/path-utils.ts +7 -1
- package/src/tools/read.ts +23 -74
- package/src/utils/title-generator.ts +10 -6
- package/src/web/search/providers/perplexity-auth.ts +20 -11
- package/src/web/search/providers/perplexity.ts +14 -2
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import type { GitSource } from "./git-url";
|
|
6
|
+
|
|
7
|
+
interface CommandResult {
|
|
8
|
+
readonly exitCode: number;
|
|
9
|
+
readonly stdout: string;
|
|
10
|
+
readonly stderr: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function runCommand(command: string[], cwd: string): Promise<CommandResult> {
|
|
14
|
+
const proc = Bun.spawn(command, {
|
|
15
|
+
cwd,
|
|
16
|
+
stdin: "ignore",
|
|
17
|
+
stdout: "pipe",
|
|
18
|
+
stderr: "pipe",
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
});
|
|
21
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
22
|
+
proc.exited,
|
|
23
|
+
new Response(proc.stdout).text(),
|
|
24
|
+
new Response(proc.stderr).text(),
|
|
25
|
+
]);
|
|
26
|
+
return { exitCode, stdout, stderr };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeRepositoryUrl(repository: string): string {
|
|
30
|
+
const withoutFragment = repository.replace(/^git\+/i, "").replace(/#.*$/, "");
|
|
31
|
+
const scpLike = withoutFragment.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
|
|
32
|
+
if (scpLike && !withoutFragment.includes("://")) {
|
|
33
|
+
const host = scpLike[1]?.toLowerCase() ?? "";
|
|
34
|
+
const repoPath = (scpLike[2] ?? "").replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
|
|
35
|
+
return `ssh://${host}/${repoPath}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const parsed = new URL(withoutFragment);
|
|
40
|
+
const repoPath = parsed.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
|
|
41
|
+
return `${parsed.protocol.toLowerCase()}//${parsed.host.toLowerCase()}/${repoPath}`;
|
|
42
|
+
} catch {
|
|
43
|
+
return withoutFragment.replace(/\/+$/g, "").replace(/\.git$/i, "");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Fetches current heads and tags into Bun's matching cached bare clone before a plugin update. */
|
|
48
|
+
export async function refreshBunGitCache(source: GitSource, cwd: string): Promise<void> {
|
|
49
|
+
const cacheResult = await runCommand(["bun", "pm", "cache"], cwd);
|
|
50
|
+
if (cacheResult.exitCode !== 0) {
|
|
51
|
+
throw new Error(`bun pm cache failed: ${cacheResult.stderr}`);
|
|
52
|
+
}
|
|
53
|
+
const cacheDir = cacheResult.stdout.trim();
|
|
54
|
+
if (!cacheDir) {
|
|
55
|
+
throw new Error("bun pm cache returned an empty cache path");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let entries: Dirent[];
|
|
59
|
+
try {
|
|
60
|
+
entries = await fs.readdir(cacheDir, { withFileTypes: true });
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (isEnoent(err)) return;
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const repositoryUrl = normalizeRepositoryUrl(source.repo);
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (!entry.isDirectory() || !entry.name.endsWith(".git")) continue;
|
|
69
|
+
const repositoryDir = path.join(cacheDir, entry.name);
|
|
70
|
+
const originResult = await runCommand(["git", "-C", repositoryDir, "config", "--get", "remote.origin.url"], cwd);
|
|
71
|
+
if (originResult.exitCode !== 0 || normalizeRepositoryUrl(originResult.stdout.trim()) !== repositoryUrl) continue;
|
|
72
|
+
|
|
73
|
+
const fetchResult = await runCommand(
|
|
74
|
+
[
|
|
75
|
+
"git",
|
|
76
|
+
"-C",
|
|
77
|
+
repositoryDir,
|
|
78
|
+
"fetch",
|
|
79
|
+
"--force",
|
|
80
|
+
"--prune",
|
|
81
|
+
"origin",
|
|
82
|
+
"+refs/heads/*:refs/heads/*",
|
|
83
|
+
"+refs/tags/*:refs/tags/*",
|
|
84
|
+
],
|
|
85
|
+
cwd,
|
|
86
|
+
);
|
|
87
|
+
if (fetchResult.exitCode !== 0) {
|
|
88
|
+
throw new Error(`Failed to refresh Bun's git cache for ${source.host}/${source.path}: ${fetchResult.stderr}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -1135,10 +1135,9 @@ async function realpathOrSelfUncached(p: string): Promise<string> {
|
|
|
1135
1135
|
* Extension-local bare dependency entries are also included so their relative
|
|
1136
1136
|
* children receive the reload mtime tag; bare imports inside those dependencies
|
|
1137
1137
|
* remain native Bun resolutions to avoid taking over full third-party graphs.
|
|
1138
|
-
* CommonJS
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
* specifier inside `bun build --compile` binaries).
|
|
1138
|
+
* CommonJS modules reached through `require()` stay on Bun's native loader.
|
|
1139
|
+
* The only exception is a module whose bare requires resolve to native addons:
|
|
1140
|
+
* those require a synchronous hook that pins the addon to an absolute path.
|
|
1142
1141
|
*/
|
|
1143
1142
|
async function collectExtensionModules(entryRealPath: string): Promise<Map<string, string>> {
|
|
1144
1143
|
const modules = new Map<string, string>();
|
|
@@ -1159,32 +1158,40 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin
|
|
|
1159
1158
|
let source: string;
|
|
1160
1159
|
try {
|
|
1161
1160
|
source = await Bun.file(file).text();
|
|
1162
|
-
if (nativeAddonLoaderModulePaths.has(file)) {
|
|
1163
|
-
// CJS requires cannot await an async onLoad hook. Resolve and
|
|
1164
|
-
// rewrite native-addon paths before installing its sync hook.
|
|
1165
|
-
source = await rewriteExtensionNativeAddonRequires(source, file);
|
|
1166
|
-
}
|
|
1167
1161
|
} catch {
|
|
1168
1162
|
continue;
|
|
1169
1163
|
}
|
|
1170
1164
|
modules.set(file, source);
|
|
1171
1165
|
const dir = path.dirname(file);
|
|
1172
1166
|
const specifiers = new Set<string>();
|
|
1167
|
+
const requiredSpecifiers = new Set<string>();
|
|
1173
1168
|
for (const match of source.matchAll(EXTENSION_GRAPH_SPECIFIER_REGEX)) {
|
|
1174
1169
|
if (match[2]) specifiers.add(match[2]);
|
|
1175
1170
|
}
|
|
1176
1171
|
for (const match of source.matchAll(NATIVE_ADDON_REQUIRE_SPECIFIER_REGEX)) {
|
|
1177
|
-
if (match[2])
|
|
1172
|
+
if (match[2]) {
|
|
1173
|
+
specifiers.add(match[2]);
|
|
1174
|
+
requiredSpecifiers.add(match[2]);
|
|
1175
|
+
}
|
|
1178
1176
|
}
|
|
1179
1177
|
for (const specifier of specifiers) {
|
|
1180
1178
|
try {
|
|
1181
1179
|
let resolved: string | null = null;
|
|
1182
1180
|
let nextFollowsBareDependencies = followBareDependencies;
|
|
1181
|
+
const isRequired = requiredSpecifiers.has(specifier);
|
|
1183
1182
|
if (specifier.startsWith(".")) {
|
|
1184
1183
|
const candidate = Bun.resolveSync(specifier, dir);
|
|
1185
|
-
|
|
1184
|
+
if (
|
|
1185
|
+
hasSourceModuleExtension(candidate) &&
|
|
1186
|
+
(!isRequired || (await moduleRequiresNativeAddon(candidate)))
|
|
1187
|
+
) {
|
|
1188
|
+
resolved = await realpathOrSelf(candidate);
|
|
1189
|
+
}
|
|
1186
1190
|
} else if (specifier.startsWith("#")) {
|
|
1187
|
-
|
|
1191
|
+
const candidate = await resolvePackageImportSpecifier(specifier, file);
|
|
1192
|
+
if (candidate && (!isRequired || (await moduleRequiresNativeAddon(candidate)))) {
|
|
1193
|
+
resolved = candidate;
|
|
1194
|
+
}
|
|
1188
1195
|
} else if (
|
|
1189
1196
|
followBareDependencies &&
|
|
1190
1197
|
isBareExtensionDependencySpecifier(specifier) &&
|
|
@@ -1206,14 +1213,17 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin
|
|
|
1206
1213
|
isHookableEntry && isCommonJsEntry && dependencyEntry
|
|
1207
1214
|
? await moduleRequiresNativeAddon(dependencyEntry)
|
|
1208
1215
|
: false;
|
|
1209
|
-
if (isHookableEntry && dependencyEntry && (!isCommonJsEntry || hookCommonJsEntry)) {
|
|
1216
|
+
if (isHookableEntry && dependencyEntry && ((!isRequired && !isCommonJsEntry) || hookCommonJsEntry)) {
|
|
1210
1217
|
resolved = await realpathOrSelf(dependencyEntry);
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1218
|
+
}
|
|
1219
|
+
if (resolved && hookCommonJsEntry) {
|
|
1220
|
+
nativeAddonLoaderModulePaths.add(resolved);
|
|
1214
1221
|
}
|
|
1215
1222
|
nextFollowsBareDependencies = false;
|
|
1216
1223
|
}
|
|
1224
|
+
if (resolved && isRequired) {
|
|
1225
|
+
nativeAddonLoaderModulePaths.add(resolved);
|
|
1226
|
+
}
|
|
1217
1227
|
if (resolved && !modules.has(resolved)) {
|
|
1218
1228
|
const queuedFollowsBareDependencies = queuedFollowBareDependencies.get(resolved) ?? false;
|
|
1219
1229
|
const mergedFollowsBareDependencies = queuedFollowsBareDependencies || nextFollowsBareDependencies;
|
|
@@ -1225,6 +1235,12 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin
|
|
|
1225
1235
|
}
|
|
1226
1236
|
}
|
|
1227
1237
|
}
|
|
1238
|
+
for (const modulePath of nativeAddonLoaderModulePaths) {
|
|
1239
|
+
const source = modules.get(modulePath);
|
|
1240
|
+
if (source !== undefined) {
|
|
1241
|
+
modules.set(modulePath, await rewriteExtensionNativeAddonRequires(source, modulePath));
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1228
1244
|
return modules;
|
|
1229
1245
|
}
|
|
1230
1246
|
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
logger,
|
|
13
13
|
} from "@oh-my-pi/pi-utils";
|
|
14
14
|
import { withExitGuard } from "../utils";
|
|
15
|
+
import { refreshBunGitCache } from "./bun-git-cache";
|
|
15
16
|
import { type GitSource, parseGitUrl } from "./git-url";
|
|
16
17
|
import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "./legacy-pi-compat";
|
|
17
18
|
import { resolvePluginManifestEntries } from "./loader";
|
|
@@ -521,14 +522,13 @@ export class PluginManager {
|
|
|
521
522
|
|
|
522
523
|
// Step 2: refresh the git lockfile pin when re-installing an existing
|
|
523
524
|
// git plugin. `bun install <spec>` is a no-op when the spec matches the
|
|
524
|
-
// lockfile entry
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
// move. First-time installs skip this — the initial `bun install` already
|
|
530
|
-
// fetched HEAD. Rollback is handled by the outer catch.
|
|
525
|
+
// lockfile entry, while `bun update <name>` resolves through Bun's bare
|
|
526
|
+
// clone cache. Fetch the matching cache clone first so a stale cached
|
|
527
|
+
// ref cannot silently preserve the old pin (#3063, #5401). First-time
|
|
528
|
+
// installs skip this because the initial `bun install` populated the
|
|
529
|
+
// cache from the remote. Rollback is handled by the outer catch.
|
|
531
530
|
if (gitSource && existingActualName) {
|
|
531
|
+
await refreshBunGitCache(gitSource, getPluginsDir());
|
|
532
532
|
const updateProc = Bun.spawn(["bun", "update", actualName], {
|
|
533
533
|
cwd: getPluginsDir(),
|
|
534
534
|
stdin: "ignore",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
|
|
2
2
|
import type { Tool } from "@oh-my-pi/pi-ai";
|
|
3
|
-
import { prompt } from "@oh-my-pi/pi-utils";
|
|
3
|
+
import { prompt, Snowflake } from "@oh-my-pi/pi-utils";
|
|
4
4
|
import { extractTextContent, extractToolCall, parseJsonPayload } from "../commit/utils";
|
|
5
5
|
import guidedGoalInterviewPrompt from "../prompts/goals/guided-goal-interview.md" with { type: "text" };
|
|
6
6
|
import guidedGoalSystemPrompt from "../prompts/goals/guided-goal-system.md" with { type: "text" };
|
|
@@ -37,6 +37,21 @@ export type GuidedGoalTurnResult =
|
|
|
37
37
|
export interface GuidedGoalTurnOptions {
|
|
38
38
|
messages: readonly GuidedGoalMessage[];
|
|
39
39
|
signal?: AbortSignal;
|
|
40
|
+
/**
|
|
41
|
+
* Stable Codex transport session id reused across every turn of one
|
|
42
|
+
* interview. `handleGuidedGoalCommand` runs up to six turns; minting a fresh
|
|
43
|
+
* id per turn opens a new websocket-only Codex socket each time (kept in
|
|
44
|
+
* `providerSessionState` until session dispose), which can trip
|
|
45
|
+
* `websocket_connection_limit_reached` and drop back to the SSE path this
|
|
46
|
+
* fix avoids. Callers pass one id for the whole interview; omitted for
|
|
47
|
+
* one-shot callers, which mint a unique id per call.
|
|
48
|
+
*/
|
|
49
|
+
sideSessionId?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Mint a guided-goal Codex side-session id keyed off the main session id. */
|
|
53
|
+
export function newGuidedGoalSessionId(session: AgentSession): string {
|
|
54
|
+
return `${session.sessionId}:guided-goal:${Snowflake.next()}`;
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
function parseGuidedGoalPayload(value: unknown): GuidedGoalTurnResult {
|
|
@@ -106,6 +121,19 @@ export async function runGuidedGoalTurn(
|
|
|
106
121
|
reasoning: toReasoningEffort(thinkingLevel),
|
|
107
122
|
disableReasoning: shouldDisableReasoning(thinkingLevel),
|
|
108
123
|
toolChoice: { type: "tool", name: RESPOND_TOOL_NAME },
|
|
124
|
+
// Route through the session's provider transport so websocket-only Codex
|
|
125
|
+
// models (gpt-5.6-luna/sol/terra) get a websocket session instead of
|
|
126
|
+
// falling back to SSE — the Codex SSE /responses endpoint does not serve
|
|
127
|
+
// those ids and rejects the turn with "Model not found" (#5304, same class
|
|
128
|
+
// as the /btw regression in #5213). The side session id is minted once per
|
|
129
|
+
// interview and reused across turns so a multi-question interview shares one
|
|
130
|
+
// Codex socket instead of opening a fresh one each turn; it stays distinct
|
|
131
|
+
// from the main session id so the oneshot's append-only turn state never
|
|
132
|
+
// pollutes the main conversation.
|
|
133
|
+
sessionId: options.sideSessionId ?? newGuidedGoalSessionId(session),
|
|
134
|
+
promptCacheKey: session.sessionId,
|
|
135
|
+
preferWebsockets: session.preferWebsockets,
|
|
136
|
+
providerSessionState: session.providerSessionState,
|
|
109
137
|
},
|
|
110
138
|
{ telemetry: resolveTelemetry(session.agent.telemetry, session.sessionId), oneshotKind: "guided_goal_setup" },
|
|
111
139
|
);
|
|
@@ -5,14 +5,22 @@
|
|
|
5
5
|
* in-memory message array; parked refs (session disposed, sessionFile
|
|
6
6
|
* retained) load read-only from the JSONL session file — no writer, no lock.
|
|
7
7
|
*
|
|
8
|
+
* Agents that are no longer in the `AgentRegistry` — one-shot helpers
|
|
9
|
+
* unregistered after `finalizeSubagentLifecycle` (`keepAlive: false`, e.g. the
|
|
10
|
+
* `eval` `agent()` bridge), agents released via the Agent Hub / vibe kill, or
|
|
11
|
+
* any agent after a session resume — remain reachable: `resolve`, `complete`,
|
|
12
|
+
* and the index all fall back to scanning artifacts dirs for `<id>.jsonl`,
|
|
13
|
+
* mirroring how `agent://` reads `.md` outputs straight off disk.
|
|
14
|
+
*
|
|
8
15
|
* URL forms:
|
|
9
|
-
* - history:// - Index of all registry agents (id, status, kind, last activity)
|
|
16
|
+
* - history:// - Index of all registry + on-disk agents (id, status, kind, last activity)
|
|
10
17
|
* - history://<agentId> - Concise markdown transcript of that agent
|
|
11
18
|
*/
|
|
12
19
|
import type { AgentRef } from "../registry/agent-registry";
|
|
13
20
|
import { AgentRegistry } from "../registry/agent-registry";
|
|
14
21
|
import { formatSessionHistoryMarkdown } from "../session/session-history-format";
|
|
15
22
|
import { loadSessionMessagesReadOnly } from "../session/session-loader";
|
|
23
|
+
import { sessionFilesFromDisk } from "./registry-helpers";
|
|
16
24
|
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
|
|
17
25
|
|
|
18
26
|
/** Humanize a last-activity timestamp as `Ns/Nm/Nh/Nd ago`. */
|
|
@@ -27,11 +35,21 @@ function formatAgo(timestamp: number): string {
|
|
|
27
35
|
return `${Math.floor(hours / 24)}d ago`;
|
|
28
36
|
}
|
|
29
37
|
|
|
38
|
+
/** One row of the history index — either a registered ref or a disk-only transcript. */
|
|
39
|
+
interface IndexEntry {
|
|
40
|
+
id: string;
|
|
41
|
+
status: string;
|
|
42
|
+
kind: string;
|
|
43
|
+
parent: string;
|
|
44
|
+
lastActivity: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
/**
|
|
31
48
|
* Handler for history:// URLs.
|
|
32
49
|
*
|
|
33
|
-
* Resolves agent ids against the global AgentRegistry,
|
|
34
|
-
* for
|
|
50
|
+
* Resolves agent ids against the global AgentRegistry, then falls back to
|
|
51
|
+
* on-disk `.jsonl` transcripts, serving read-only history for live, parked,
|
|
52
|
+
* and unregistered agents alike.
|
|
35
53
|
*/
|
|
36
54
|
export class HistoryProtocolHandler implements ProtocolHandler {
|
|
37
55
|
readonly scheme = "history";
|
|
@@ -45,7 +63,7 @@ export class HistoryProtocolHandler implements ProtocolHandler {
|
|
|
45
63
|
const visible = registry.list().filter(ref => ref.kind !== "advisor");
|
|
46
64
|
|
|
47
65
|
if (!agentId) {
|
|
48
|
-
const content = this.#renderIndex(visible);
|
|
66
|
+
const content = await this.#renderIndex(visible);
|
|
49
67
|
return {
|
|
50
68
|
url: url.href,
|
|
51
69
|
content,
|
|
@@ -61,7 +79,13 @@ export class HistoryProtocolHandler implements ProtocolHandler {
|
|
|
61
79
|
const lower = agentId.toLowerCase();
|
|
62
80
|
ref = visible.find(candidate => candidate.id.toLowerCase() === lower);
|
|
63
81
|
}
|
|
82
|
+
|
|
64
83
|
if (!ref) {
|
|
84
|
+
// Registry miss — the agent may have been unregistered or lost on resume.
|
|
85
|
+
// Serve its transcript straight from disk if the session file persists.
|
|
86
|
+
const disk = await this.#resolveFromDisk(agentId);
|
|
87
|
+
if (disk) return { ...disk, url: url.href };
|
|
88
|
+
|
|
65
89
|
const known = visible.map(candidate => candidate.id);
|
|
66
90
|
const knownStr = known.length > 0 ? known.join(", ") : "none";
|
|
67
91
|
throw new Error(`Unknown agent: ${agentId}\nKnown agents: ${knownStr}\nList all with history://`);
|
|
@@ -76,6 +100,10 @@ export class HistoryProtocolHandler implements ProtocolHandler {
|
|
|
76
100
|
messages = await loadSessionMessagesReadOnly(ref.sessionFile);
|
|
77
101
|
notes.push(`Source: session file (read-only, ${ref.status})`);
|
|
78
102
|
} else {
|
|
103
|
+
// No live session and no retained sessionFile — try the disk scan before
|
|
104
|
+
// giving up, in case the transcript lingers under an artifacts dir.
|
|
105
|
+
const disk = await this.#resolveFromDisk(ref.id);
|
|
106
|
+
if (disk) return { ...disk, url: url.href };
|
|
79
107
|
throw new Error(`Agent ${ref.id} has no transcript: session is gone and no session file was retained`);
|
|
80
108
|
}
|
|
81
109
|
|
|
@@ -90,29 +118,81 @@ export class HistoryProtocolHandler implements ProtocolHandler {
|
|
|
90
118
|
};
|
|
91
119
|
}
|
|
92
120
|
|
|
93
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Load a transcript for `agentId` from an on-disk `.jsonl` session file,
|
|
123
|
+
* matched case-insensitively. Returns `undefined` when no file is found.
|
|
124
|
+
*/
|
|
125
|
+
async #resolveFromDisk(agentId: string): Promise<InternalResource | undefined> {
|
|
126
|
+
const files = await sessionFilesFromDisk();
|
|
127
|
+
const lower = agentId.toLowerCase();
|
|
128
|
+
let matchedId: string | undefined;
|
|
129
|
+
let sessionFile: string | undefined;
|
|
130
|
+
for (const [id, file] of files) {
|
|
131
|
+
if (id === agentId || id.toLowerCase() === lower) {
|
|
132
|
+
matchedId = id;
|
|
133
|
+
sessionFile = file;
|
|
134
|
+
if (id === agentId) break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!matchedId || !sessionFile) return undefined;
|
|
138
|
+
const messages = await loadSessionMessagesReadOnly(sessionFile);
|
|
139
|
+
const content = formatSessionHistoryMarkdown(messages, { title: `${matchedId} (on disk)` });
|
|
140
|
+
return {
|
|
141
|
+
url: "",
|
|
142
|
+
content,
|
|
143
|
+
contentType: "text/markdown",
|
|
144
|
+
size: Buffer.byteLength(content, "utf-8"),
|
|
145
|
+
sourcePath: sessionFile,
|
|
146
|
+
notes: ["Source: session file (read-only, unregistered)"],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async #renderIndex(refs: AgentRef[]): Promise<string> {
|
|
151
|
+
const entries: IndexEntry[] = refs.map(ref => ({
|
|
152
|
+
id: ref.id,
|
|
153
|
+
status: ref.status,
|
|
154
|
+
kind: ref.kind,
|
|
155
|
+
parent: ref.parentId ?? "—",
|
|
156
|
+
lastActivity: formatAgo(ref.lastActivity),
|
|
157
|
+
}));
|
|
158
|
+
// Merge on-disk transcripts for agents absent from the registry.
|
|
159
|
+
const registered = new Set(refs.map(ref => ref.id));
|
|
160
|
+
const disk = await sessionFilesFromDisk();
|
|
161
|
+
for (const id of disk.keys()) {
|
|
162
|
+
if (registered.has(id)) continue;
|
|
163
|
+
entries.push({ id, status: "on disk", kind: "—", parent: "—", lastActivity: "—" });
|
|
164
|
+
}
|
|
165
|
+
|
|
94
166
|
const lines: string[] = ["# Agents", ""];
|
|
95
|
-
if (
|
|
167
|
+
if (entries.length === 0) {
|
|
96
168
|
lines.push("No agents registered.");
|
|
97
169
|
return `${lines.join("\n")}\n`;
|
|
98
170
|
}
|
|
99
171
|
lines.push("| id | status | kind | parent | last activity |", "|---|---|---|---|---|");
|
|
100
|
-
for (const
|
|
101
|
-
lines.push(
|
|
102
|
-
`| ${ref.id} | ${ref.status} | ${ref.kind} | ${ref.parentId ?? "—"} | ${formatAgo(ref.lastActivity)} |`,
|
|
103
|
-
);
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
lines.push(`| ${entry.id} | ${entry.status} | ${entry.kind} | ${entry.parent} | ${entry.lastActivity} |`);
|
|
104
174
|
}
|
|
105
175
|
lines.push("", "Read a transcript with `read history://<id>`.");
|
|
106
176
|
return `${lines.join("\n")}\n`;
|
|
107
177
|
}
|
|
108
178
|
|
|
109
179
|
async complete(): Promise<UrlCompletion[]> {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
180
|
+
const completions: UrlCompletion[] = [];
|
|
181
|
+
const seen = new Set<string>();
|
|
182
|
+
for (const ref of AgentRegistry.global().list()) {
|
|
183
|
+
if (ref.kind === "advisor") continue;
|
|
184
|
+
seen.add(ref.id);
|
|
185
|
+
completions.push({
|
|
114
186
|
value: ref.id,
|
|
115
187
|
description: `${ref.status} · ${ref.kind}${ref.parentId ? ` · parent ${ref.parentId}` : ""}`,
|
|
116
|
-
})
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const disk = await sessionFilesFromDisk();
|
|
191
|
+
for (const id of disk.keys()) {
|
|
192
|
+
if (seen.has(id)) continue;
|
|
193
|
+
seen.add(id);
|
|
194
|
+
completions.push({ value: id, description: "on disk" });
|
|
195
|
+
}
|
|
196
|
+
return completions;
|
|
117
197
|
}
|
|
118
198
|
}
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Shared helpers for internal-url protocol handlers that resolve IDs against
|
|
3
3
|
* registered agent sessions.
|
|
4
4
|
*/
|
|
5
|
+
|
|
6
|
+
import type { Dirent } from "node:fs";
|
|
7
|
+
import * as fs from "node:fs/promises";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
5
10
|
import { AgentRegistry } from "../registry/agent-registry";
|
|
6
11
|
|
|
7
12
|
const extraArtifactsDirs = new Set<string>();
|
|
@@ -35,9 +40,53 @@ export function artifactsDirsFromRegistry(): string[] {
|
|
|
35
40
|
if (!dirs.includes(dir)) dirs.push(dir);
|
|
36
41
|
};
|
|
37
42
|
for (const ref of AgentRegistry.global().list()) {
|
|
38
|
-
addDir(ref.session?.sessionManager
|
|
43
|
+
addDir(ref.session?.sessionManager?.getArtifactsDir());
|
|
39
44
|
if (ref.sessionFile) addDir(ref.sessionFile.slice(0, -6));
|
|
40
45
|
}
|
|
41
46
|
for (const dir of extraArtifactsDirs) addDir(dir);
|
|
42
47
|
return dirs;
|
|
43
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Recursively scan artifacts dirs for agent session transcripts, keyed by
|
|
52
|
+
* agent id (the `.jsonl` basename). Used by `history://` so transcripts of
|
|
53
|
+
* agents no longer in the registry (unregistered one-shot helpers, released
|
|
54
|
+
* agents, or any agent after session resume) remain reachable — mirroring how
|
|
55
|
+
* `agent://` reads `.md` outputs straight off disk.
|
|
56
|
+
*
|
|
57
|
+
* Layout follows `task/index.ts`: a subagent's transcript is
|
|
58
|
+
* `<artifactsDir>/<AgentId>.jsonl`, and its own children nest one level deeper
|
|
59
|
+
* under `<artifactsDir>/<AgentId>/<AgentId>.<ChildId>.jsonl`. Advisor
|
|
60
|
+
* transcripts (`__advisor*.jsonl`) are observability-only and excluded;
|
|
61
|
+
* EPERM-rewrite backups (`.bak`) are skipped. When the same id appears in
|
|
62
|
+
* multiple dirs, the first hit wins (registry dirs are scanned first).
|
|
63
|
+
*/
|
|
64
|
+
export async function sessionFilesFromDisk(): Promise<Map<string, string>> {
|
|
65
|
+
const found = new Map<string, string>();
|
|
66
|
+
const seenDirs = new Set<string>();
|
|
67
|
+
const scan = async (dir: string, depth: number): Promise<void> => {
|
|
68
|
+
if (depth > 8 || seenDirs.has(dir)) return;
|
|
69
|
+
seenDirs.add(dir);
|
|
70
|
+
let entries: Dirent[];
|
|
71
|
+
try {
|
|
72
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (isEnoent(err) || (err as NodeJS.ErrnoException).code === "ENOTDIR") return;
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
await scan(path.join(dir, entry.name), depth + 1);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!entry.isFile()) continue;
|
|
83
|
+
const name = entry.name;
|
|
84
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
85
|
+
if (name.startsWith("__advisor")) continue;
|
|
86
|
+
const id = name.slice(0, -".jsonl".length);
|
|
87
|
+
if (!found.has(id)) found.set(id, path.join(dir, name));
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
for (const dir of artifactsDirsFromRegistry()) await scan(dir, 0);
|
|
91
|
+
return found;
|
|
92
|
+
}
|
package/src/launch/broker.ts
CHANGED
|
@@ -84,9 +84,6 @@ interface DaemonLogRead {
|
|
|
84
84
|
function quoteShellArg(value: string): string {
|
|
85
85
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
86
86
|
}
|
|
87
|
-
function quoteCmdArg(value: string): string {
|
|
88
|
-
return `"${value.replaceAll('"', '""')}"`;
|
|
89
|
-
}
|
|
90
87
|
|
|
91
88
|
function terminalState(state: DaemonSnapshot["state"]): boolean {
|
|
92
89
|
return state === "exited" || state === "failed";
|
|
@@ -434,6 +431,13 @@ class DaemonBroker {
|
|
|
434
431
|
if (spec.detached && spec.pty) {
|
|
435
432
|
throw new Error("A detached daemon cannot allocate a PTY");
|
|
436
433
|
}
|
|
434
|
+
if (
|
|
435
|
+
spec.pty &&
|
|
436
|
+
process.platform === "win32" &&
|
|
437
|
+
[".bat", ".cmd"].includes(path.extname(spec.application).toLowerCase())
|
|
438
|
+
) {
|
|
439
|
+
throw new Error('Windows batch files require application "cmd.exe" with the batch path after "/c"');
|
|
440
|
+
}
|
|
437
441
|
const existing = this.#records.get(spec.name);
|
|
438
442
|
if (existing) await this.#refreshDetached(existing);
|
|
439
443
|
if (existing && !terminalState(existing.snapshot.state)) {
|
|
@@ -520,38 +524,47 @@ class DaemonBroker {
|
|
|
520
524
|
}
|
|
521
525
|
|
|
522
526
|
async #launchPty(record: ManagedDaemon, generation: number): Promise<void> {
|
|
523
|
-
const pidPath = path.join(record.dir, "process.pid");
|
|
524
|
-
await fs.rm(pidPath, { force: true });
|
|
525
|
-
const argv = [record.spec.application, ...record.spec.args];
|
|
526
|
-
const command =
|
|
527
|
-
process.platform === "win32"
|
|
528
|
-
? argv.map(quoteCmdArg).join(" ")
|
|
529
|
-
: [`printf '%s' "$$" > ${quoteShellArg(pidPath)}`, `exec ${argv.map(quoteShellArg).join(" ")}`].join("; ");
|
|
530
527
|
const session = new PtySession();
|
|
531
528
|
record.pty = session;
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
.
|
|
529
|
+
const options = {
|
|
530
|
+
cwd: record.spec.cwd,
|
|
531
|
+
env: workerEnvFromParent({ TERM: "xterm-256color", ...record.spec.env }),
|
|
532
|
+
cols: DAEMON_PTY_COLUMNS,
|
|
533
|
+
rows: DAEMON_PTY_ROWS,
|
|
534
|
+
};
|
|
535
|
+
const onChunk = (error: Error | null, chunk: string): void => {
|
|
536
|
+
if (generation !== record.generation) return;
|
|
537
|
+
if (error) record.log?.append(`PTY output error: ${error.message}\n`);
|
|
538
|
+
if (chunk) this.#onOutput(record, generation, chunk);
|
|
539
|
+
};
|
|
540
|
+
let run: Promise<PtyRunResult>;
|
|
541
|
+
if (process.platform === "win32") {
|
|
542
|
+
run = session.startArgv(
|
|
535
543
|
{
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
cols: DAEMON_PTY_COLUMNS,
|
|
540
|
-
rows: DAEMON_PTY_ROWS,
|
|
541
|
-
shell,
|
|
544
|
+
application: record.spec.application,
|
|
545
|
+
args: record.spec.args,
|
|
546
|
+
...options,
|
|
542
547
|
},
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
548
|
+
onChunk,
|
|
549
|
+
);
|
|
550
|
+
} else {
|
|
551
|
+
const pidPath = path.join(record.dir, "process.pid");
|
|
552
|
+
await fs.rm(pidPath, { force: true });
|
|
553
|
+
const argv = [record.spec.application, ...record.spec.args];
|
|
554
|
+
const command = [
|
|
555
|
+
`printf '%s' "$$" > ${quoteShellArg(pidPath)}`,
|
|
556
|
+
`exec ${argv.map(quoteShellArg).join(" ")}`,
|
|
557
|
+
].join("; ");
|
|
558
|
+
run = session.start({ command, shell: process.env.SHELL, ...options }, onChunk);
|
|
559
|
+
}
|
|
560
|
+
void run
|
|
549
561
|
.then(result => this.#onPtyExit(record, generation, result))
|
|
550
562
|
.catch(error =>
|
|
551
563
|
this.#settle(record, generation, undefined, error instanceof Error ? error.message : String(error)),
|
|
552
564
|
);
|
|
553
565
|
|
|
554
566
|
if (process.platform === "win32") return;
|
|
567
|
+
const pidPath = path.join(record.dir, "process.pid");
|
|
555
568
|
const deadline = Date.now() + 5_000;
|
|
556
569
|
const pidFile = Bun.file(pidPath);
|
|
557
570
|
while (Date.now() < deadline && generation === record.generation) {
|
|
@@ -11,10 +11,23 @@ export interface OAuthEndpoints {
|
|
|
11
11
|
authorizationUrl: string;
|
|
12
12
|
tokenUrl: string;
|
|
13
13
|
clientId?: string;
|
|
14
|
+
/** Dynamic client registration endpoint advertised by the authorization server. */
|
|
15
|
+
registrationUrl?: string;
|
|
14
16
|
scopes?: string;
|
|
15
17
|
resource?: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
function readRegistrationUrl(metadata: Record<string, unknown>): string | undefined {
|
|
21
|
+
const value =
|
|
22
|
+
metadata.registration_endpoint ??
|
|
23
|
+
metadata.registrationEndpoint ??
|
|
24
|
+
metadata.registration_url ??
|
|
25
|
+
metadata.registrationUrl ??
|
|
26
|
+
metadata.registration_uri ??
|
|
27
|
+
metadata.registrationUri;
|
|
28
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
export interface AuthDetectionResult {
|
|
19
32
|
requiresAuth: boolean;
|
|
20
33
|
authType?: "oauth" | "apikey" | "unknown";
|
|
@@ -102,7 +115,7 @@ export function extractOAuthEndpoints(error: Error): OAuthEndpoints | null {
|
|
|
102
115
|
(obj.resource_uri as string | undefined) ||
|
|
103
116
|
(obj.resourceUri as string | undefined);
|
|
104
117
|
|
|
105
|
-
return { authorizationUrl, tokenUrl, clientId, scopes, resource };
|
|
118
|
+
return { authorizationUrl, tokenUrl, registrationUrl: readRegistrationUrl(obj), clientId, scopes, resource };
|
|
106
119
|
};
|
|
107
120
|
|
|
108
121
|
const clientIdFromAuthUrl = (authorizationUrl: string): string | undefined => {
|
|
@@ -175,6 +188,10 @@ export function extractOAuthEndpoints(error: Error): OAuthEndpoints | null {
|
|
|
175
188
|
return {
|
|
176
189
|
authorizationUrl,
|
|
177
190
|
tokenUrl,
|
|
191
|
+
registrationUrl:
|
|
192
|
+
challengeValues.get("registration_endpoint") ||
|
|
193
|
+
challengeValues.get("registration_url") ||
|
|
194
|
+
challengeValues.get("registration_uri"),
|
|
178
195
|
clientId: challengeValues.get("client_id") || clientIdFromAuthUrl(authorizationUrl),
|
|
179
196
|
scopes: challengeValues.get("scope") || challengeValues.get("scopes") || scopeFromAuthUrl(authorizationUrl),
|
|
180
197
|
resource,
|
|
@@ -415,6 +432,7 @@ export async function discoverOAuthEndpoints(
|
|
|
415
432
|
return {
|
|
416
433
|
authorizationUrl: String(metadata.authorization_endpoint),
|
|
417
434
|
tokenUrl: String(metadata.token_endpoint),
|
|
435
|
+
registrationUrl: readRegistrationUrl(metadata),
|
|
418
436
|
clientId:
|
|
419
437
|
typeof metadata.client_id === "string"
|
|
420
438
|
? metadata.client_id
|
|
@@ -438,6 +456,7 @@ export async function discoverOAuthEndpoints(
|
|
|
438
456
|
return {
|
|
439
457
|
authorizationUrl: oauthData.authorization_url || String(oauthData.authorizationUrl),
|
|
440
458
|
tokenUrl: oauthData.token_url || String(oauthData.tokenUrl),
|
|
459
|
+
registrationUrl: readRegistrationUrl(oauthData),
|
|
441
460
|
clientId:
|
|
442
461
|
typeof oauthData.client_id === "string"
|
|
443
462
|
? oauthData.client_id
|