@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +3442 -3408
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  5. package/dist/types/eval/backend.d.ts +3 -3
  6. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  7. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  8. package/dist/types/goals/guided-setup.d.ts +12 -0
  9. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  10. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  11. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  12. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  13. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  14. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  15. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  16. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  17. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  18. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  19. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  20. package/dist/types/modes/interactive-mode.d.ts +1 -0
  21. package/dist/types/modes/types.d.ts +1 -0
  22. package/dist/types/session/messages.d.ts +15 -0
  23. package/dist/types/tools/grep.d.ts +0 -2
  24. package/dist/types/tools/read.d.ts +0 -4
  25. package/package.json +12 -12
  26. package/src/advisor/__tests__/advisor.test.ts +136 -49
  27. package/src/advisor/runtime.ts +16 -34
  28. package/src/autoresearch/dashboard.ts +2 -2
  29. package/src/cli/config-cli.ts +15 -3
  30. package/src/config/settings-schema.ts +10 -0
  31. package/src/cursor.ts +2 -0
  32. package/src/discovery/claude-plugins.ts +9 -3
  33. package/src/discovery/omp-plugins.ts +6 -2
  34. package/src/discovery/substitute-plugin-root.ts +32 -0
  35. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  36. package/src/eval/backend.ts +3 -3
  37. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  38. package/src/eval/py/prelude.py +28 -1
  39. package/src/exec/bash-executor.ts +30 -43
  40. package/src/extensibility/extensions/wrapper.ts +18 -18
  41. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  43. package/src/extensibility/plugins/manager.ts +7 -7
  44. package/src/goals/guided-setup.ts +29 -1
  45. package/src/internal-urls/history-protocol.ts +95 -15
  46. package/src/internal-urls/registry-helpers.ts +50 -1
  47. package/src/launch/broker.ts +38 -25
  48. package/src/mcp/oauth-discovery.ts +20 -1
  49. package/src/mcp/oauth-flow.ts +3 -1
  50. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  51. package/src/modes/components/agent-dashboard.ts +2 -2
  52. package/src/modes/components/agent-hub.ts +15 -2
  53. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  54. package/src/modes/components/chat-transcript-builder.ts +4 -3
  55. package/src/modes/components/dynamic-border.ts +9 -6
  56. package/src/modes/components/extensions/extension-list.ts +2 -2
  57. package/src/modes/components/hook-selector.ts +10 -4
  58. package/src/modes/components/login-dialog.ts +5 -0
  59. package/src/modes/components/mcp-add-wizard.ts +5 -0
  60. package/src/modes/components/plan-review-overlay.ts +11 -11
  61. package/src/modes/components/read-tool-group.ts +1 -8
  62. package/src/modes/controllers/input-controller.ts +4 -2
  63. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  64. package/src/modes/controllers/selector-controller.ts +9 -2
  65. package/src/modes/controllers/todo-command-controller.ts +18 -14
  66. package/src/modes/interactive-mode.ts +7 -3
  67. package/src/modes/prompt-action-autocomplete.ts +6 -1
  68. package/src/modes/types.ts +1 -1
  69. package/src/prompts/system/system-prompt.md +1 -0
  70. package/src/prompts/tools/eval.md +2 -2
  71. package/src/prompts/tools/grep.md +1 -2
  72. package/src/prompts/tools/read.md +2 -4
  73. package/src/sdk.ts +28 -31
  74. package/src/session/agent-session.ts +44 -22
  75. package/src/session/messages.test.ts +66 -0
  76. package/src/session/messages.ts +37 -0
  77. package/src/system-prompt.test.ts +36 -0
  78. package/src/system-prompt.ts +1 -1
  79. package/src/tools/browser/registry.ts +17 -3
  80. package/src/tools/eval.ts +14 -9
  81. package/src/tools/gh.ts +3 -1
  82. package/src/tools/grep.ts +5 -45
  83. package/src/tools/path-utils.ts +7 -1
  84. package/src/tools/read.ts +23 -74
  85. package/src/utils/title-generator.ts +10 -6
  86. package/src/web/search/providers/perplexity-auth.ts +20 -11
  87. 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 dependency entries stay native too, with one exception: napi-rs
1139
- * style loaders whose bare requires resolve to `.node` addons are hooked so
1140
- * their requires can be pinned to absolute paths (unresolvable by bare
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]) specifiers.add(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
- resolved = hasSourceModuleExtension(candidate) ? await realpathOrSelf(candidate) : null;
1184
+ if (
1185
+ hasSourceModuleExtension(candidate) &&
1186
+ (!isRequired || (await moduleRequiresNativeAddon(candidate)))
1187
+ ) {
1188
+ resolved = await realpathOrSelf(candidate);
1189
+ }
1186
1190
  } else if (specifier.startsWith("#")) {
1187
- resolved = await resolvePackageImportSpecifier(specifier, file);
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
- if (hookCommonJsEntry) {
1212
- nativeAddonLoaderModulePaths.add(resolved);
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 it never re-resolves the remote ref — so re-running
525
- // `omp plugin install github:owner/repo` would silently keep the user on
526
- // the original resolved commit even after upstream moved (#3063).
527
- // `bun update <name>` re-resolves the ref against the remote and
528
- // rewrites the pin; SHA-pinned refs stay put because the commit can't
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, serving transcripts
34
- * for both live and parked agents.
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
- #renderIndex(refs: AgentRef[]): string {
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 (refs.length === 0) {
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 ref of refs) {
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
- return AgentRegistry.global()
111
- .list()
112
- .filter(ref => ref.kind !== "advisor")
113
- .map(ref => ({
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.getArtifactsDir());
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
+ }
@@ -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 shell = process.platform === "win32" ? process.env.COMSPEC : process.env.SHELL;
533
- void session
534
- .start(
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
- command,
537
- cwd: record.spec.cwd,
538
- env: workerEnvFromParent({ TERM: "xterm-256color", ...record.spec.env }),
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
- (error, chunk) => {
544
- if (generation !== record.generation) return;
545
- if (error) record.log?.append(`PTY output error: ${error.message}\n`);
546
- if (chunk) this.#onOutput(record, generation, chunk);
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