@oh-my-pi/pi-coding-agent 17.0.0 → 17.0.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 (202) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/cli.js +4006 -3966
  3. package/dist/types/advisor/advise-tool.d.ts +5 -2
  4. package/dist/types/advisor/config.d.ts +14 -6
  5. package/dist/types/advisor/runtime.d.ts +20 -11
  6. package/dist/types/autolearn/controller.d.ts +1 -0
  7. package/dist/types/cli-commands.d.ts +14 -1
  8. package/dist/types/config/model-discovery.d.ts +17 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/model-resolver.d.ts +6 -2
  11. package/dist/types/config/models-config-schema.d.ts +10 -0
  12. package/dist/types/config/models-config.d.ts +6 -0
  13. package/dist/types/config/settings-schema.d.ts +32 -7
  14. package/dist/types/config/settings.d.ts +50 -0
  15. package/dist/types/cursor.d.ts +11 -0
  16. package/dist/types/discovery/helpers.d.ts +5 -2
  17. package/dist/types/discovery/substitute-plugin-root.d.ts +20 -7
  18. package/dist/types/exec/bash-executor.d.ts +2 -0
  19. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  20. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  22. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
  23. package/dist/types/extensibility/shared-events.d.ts +6 -0
  24. package/dist/types/extensibility/utils.d.ts +2 -2
  25. package/dist/types/internal-urls/agent-protocol.d.ts +1 -0
  26. package/dist/types/lib/xai-http.d.ts +11 -0
  27. package/dist/types/mcp/transports/stdio.d.ts +13 -1
  28. package/dist/types/modes/acp/acp-agent.d.ts +3 -0
  29. package/dist/types/modes/acp/acp-mode.d.ts +6 -2
  30. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  31. package/dist/types/modes/components/assistant-message.d.ts +2 -0
  32. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +1 -0
  33. package/dist/types/modes/components/custom-editor.d.ts +23 -1
  34. package/dist/types/modes/components/hook-editor.d.ts +16 -2
  35. package/dist/types/modes/components/login-dialog.test.d.ts +1 -0
  36. package/dist/types/modes/components/model-hub.d.ts +5 -2
  37. package/dist/types/modes/components/session-selector.d.ts +4 -0
  38. package/dist/types/modes/components/tool-execution.d.ts +8 -1
  39. package/dist/types/modes/components/tool-execution.test.d.ts +1 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  42. package/dist/types/modes/interactive-mode.d.ts +6 -0
  43. package/dist/types/modes/noninteractive-dispose.test.d.ts +1 -0
  44. package/dist/types/modes/print-mode.d.ts +1 -1
  45. package/dist/types/modes/types.d.ts +10 -0
  46. package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -2
  47. package/dist/types/modes/warp-events.d.ts +24 -0
  48. package/dist/types/modes/warp-events.test.d.ts +1 -0
  49. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  50. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  51. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  52. package/dist/types/sdk.d.ts +13 -2
  53. package/dist/types/session/agent-session.d.ts +43 -11
  54. package/dist/types/session/session-history-format.d.ts +10 -0
  55. package/dist/types/session/session-manager.d.ts +14 -0
  56. package/dist/types/session/streaming-output.d.ts +2 -0
  57. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  58. package/dist/types/slash-commands/types.d.ts +4 -4
  59. package/dist/types/system-prompt.d.ts +1 -1
  60. package/dist/types/task/label.d.ts +1 -1
  61. package/dist/types/telemetry-export.d.ts +34 -9
  62. package/dist/types/tools/approval.d.ts +8 -0
  63. package/dist/types/tools/bash.d.ts +2 -0
  64. package/dist/types/tools/browser/registry.d.ts +7 -3
  65. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  66. package/dist/types/tools/essential-tools.d.ts +29 -0
  67. package/dist/types/tools/image-gen.d.ts +2 -1
  68. package/dist/types/tools/index.d.ts +4 -1
  69. package/dist/types/tools/manage-skill.d.ts +2 -0
  70. package/dist/types/tools/xdev.d.ts +11 -2
  71. package/dist/types/utils/title-generator.d.ts +2 -1
  72. package/dist/types/web/search/index.d.ts +2 -0
  73. package/dist/types/web/search/providers/base.d.ts +3 -0
  74. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  75. package/dist/types/web/search/types.d.ts +1 -1
  76. package/package.json +21 -16
  77. package/src/advisor/__tests__/advisor.test.ts +1358 -86
  78. package/src/advisor/__tests__/config.test.ts +58 -2
  79. package/src/advisor/advise-tool.ts +7 -3
  80. package/src/advisor/config.ts +76 -24
  81. package/src/advisor/runtime.ts +445 -92
  82. package/src/autolearn/controller.ts +23 -28
  83. package/src/cli/bench-cli.ts +4 -1
  84. package/src/cli/grep-cli.ts +2 -1
  85. package/src/cli-commands.ts +57 -23
  86. package/src/cli.ts +5 -1
  87. package/src/config/model-discovery.ts +81 -21
  88. package/src/config/model-registry.ts +34 -6
  89. package/src/config/model-resolver.ts +57 -12
  90. package/src/config/models-config-schema.ts +1 -0
  91. package/src/config/settings-schema.ts +43 -6
  92. package/src/config/settings.ts +405 -25
  93. package/src/cursor.ts +20 -3
  94. package/src/debug/report-bundle.ts +40 -4
  95. package/src/discovery/codex.ts +14 -4
  96. package/src/discovery/helpers.ts +28 -5
  97. package/src/discovery/substitute-plugin-root.ts +23 -7
  98. package/src/exec/bash-executor.ts +14 -5
  99. package/src/export/html/template.js +2 -0
  100. package/src/extensibility/custom-tools/loader.ts +3 -3
  101. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  102. package/src/extensibility/extensions/loader.ts +3 -3
  103. package/src/extensibility/extensions/managed-timers.ts +83 -0
  104. package/src/extensibility/extensions/runner.ts +26 -0
  105. package/src/extensibility/extensions/types.ts +18 -0
  106. package/src/extensibility/extensions/wrapper.ts +2 -1
  107. package/src/extensibility/hooks/loader.ts +3 -3
  108. package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
  109. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  110. package/src/extensibility/plugins/manager.ts +2 -2
  111. package/src/extensibility/shared-events.ts +6 -0
  112. package/src/extensibility/utils.ts +91 -25
  113. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +73 -0
  114. package/src/internal-urls/agent-protocol.ts +73 -39
  115. package/src/irc/bus.ts +22 -3
  116. package/src/launch/broker.ts +3 -2
  117. package/src/launch/client.ts +2 -2
  118. package/src/launch/presence.ts +2 -2
  119. package/src/lib/xai-http.ts +28 -2
  120. package/src/lsp/client.ts +1 -1
  121. package/src/main.ts +11 -8
  122. package/src/mcp/manager.ts +9 -3
  123. package/src/mcp/transports/stdio.ts +103 -23
  124. package/src/modes/acp/acp-agent.ts +28 -9
  125. package/src/modes/acp/acp-event-mapper.ts +8 -0
  126. package/src/modes/acp/acp-mode.ts +18 -4
  127. package/src/modes/components/advisor-config.ts +65 -3
  128. package/src/modes/components/ask-dialog.ts +1 -1
  129. package/src/modes/components/assistant-message.ts +35 -14
  130. package/src/modes/components/chat-transcript-builder.ts +2 -0
  131. package/src/modes/components/custom-editor-plugin-ctor.test.ts +36 -0
  132. package/src/modes/components/custom-editor.ts +62 -1
  133. package/src/modes/components/hook-editor.ts +35 -5
  134. package/src/modes/components/login-dialog.test.ts +56 -0
  135. package/src/modes/components/login-dialog.ts +7 -3
  136. package/src/modes/components/model-hub.ts +138 -42
  137. package/src/modes/components/plan-review-overlay.ts +51 -10
  138. package/src/modes/components/session-selector.ts +10 -0
  139. package/src/modes/components/status-line/component.test.ts +1 -0
  140. package/src/modes/components/status-line/component.ts +2 -4
  141. package/src/modes/components/status-line/segments.ts +21 -6
  142. package/src/modes/components/tool-execution.test.ts +101 -0
  143. package/src/modes/components/tool-execution.ts +117 -9
  144. package/src/modes/components/transcript-container.ts +30 -2
  145. package/src/modes/controllers/command-controller-shared.ts +1 -1
  146. package/src/modes/controllers/command-controller.ts +168 -48
  147. package/src/modes/controllers/event-controller.ts +6 -0
  148. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  149. package/src/modes/controllers/input-controller.ts +12 -12
  150. package/src/modes/controllers/selector-controller.ts +208 -46
  151. package/src/modes/interactive-mode.ts +205 -65
  152. package/src/modes/noninteractive-dispose.test.ts +60 -0
  153. package/src/modes/print-mode.ts +10 -5
  154. package/src/modes/rpc/host-tools.ts +2 -1
  155. package/src/modes/rpc/rpc-mode.ts +31 -7
  156. package/src/modes/theme/theme.ts +2 -2
  157. package/src/modes/types.ts +10 -0
  158. package/src/modes/utils/interactive-context-helpers.ts +3 -1
  159. package/src/modes/utils/transcript-render-helpers.ts +3 -2
  160. package/src/modes/warp-events.test.ts +794 -0
  161. package/src/modes/warp-events.ts +232 -0
  162. package/src/plan-mode/model-transition.test.ts +60 -0
  163. package/src/plan-mode/model-transition.ts +51 -0
  164. package/src/prompts/system/system-prompt.md +1 -1
  165. package/src/registry/agent-lifecycle.ts +133 -18
  166. package/src/sdk.ts +227 -44
  167. package/src/session/agent-session.ts +1364 -382
  168. package/src/session/session-history-format.ts +20 -5
  169. package/src/session/session-listing.ts +6 -5
  170. package/src/session/session-loader.ts +9 -0
  171. package/src/session/session-manager.ts +48 -0
  172. package/src/session/session-persistence.ts +11 -0
  173. package/src/session/streaming-output.ts +3 -1
  174. package/src/slash-commands/builtin-registry.ts +9 -0
  175. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  176. package/src/slash-commands/types.ts +4 -4
  177. package/src/system-prompt.ts +2 -2
  178. package/src/task/executor.ts +1 -1
  179. package/src/task/label.ts +2 -0
  180. package/src/telemetry-export.ts +453 -97
  181. package/src/tools/approval.ts +11 -0
  182. package/src/tools/bash.ts +71 -38
  183. package/src/tools/browser/registry.ts +9 -2
  184. package/src/tools/browser/tab-supervisor.ts +65 -10
  185. package/src/tools/browser.ts +4 -3
  186. package/src/tools/essential-tools.ts +45 -0
  187. package/src/tools/eval.ts +3 -0
  188. package/src/tools/gh.ts +169 -2
  189. package/src/tools/image-gen.ts +624 -513
  190. package/src/tools/index.ts +10 -6
  191. package/src/tools/manage-skill.ts +5 -3
  192. package/src/tools/output-meta.ts +7 -0
  193. package/src/tools/path-utils.ts +8 -5
  194. package/src/tools/read.ts +48 -3
  195. package/src/tools/write.ts +22 -4
  196. package/src/tools/xdev.ts +14 -3
  197. package/src/utils/title-generator.ts +15 -4
  198. package/src/web/search/index.ts +21 -6
  199. package/src/web/search/providers/base.ts +3 -0
  200. package/src/web/search/providers/kimi.ts +18 -12
  201. package/src/web/search/providers/xai.ts +40 -9
  202. package/src/web/search/types.ts +6 -1
@@ -66,3 +66,76 @@ it("agent:// resolves a depth-2 subagent's .md output while its session is live
66
66
  const resource = await new AgentProtocolHandler().resolve(new URL(`agent://${grandchildId}`) as never);
67
67
  expect(resource.content).toBe("full report content");
68
68
  });
69
+
70
+ it("agent:// slash form resolves a nested subagent child (hierarchy separator)", async () => {
71
+ const root = tempDir.path();
72
+ const rootSessionFile = path.join(root, "slash-session.jsonl");
73
+ const rootArtifactsDir = rootSessionFile.slice(0, -6);
74
+ await fs.mkdir(rootArtifactsDir, { recursive: true });
75
+ const sharedArtifactManager = new ArtifactManager(rootArtifactsDir);
76
+
77
+ // Parent subagent adopts the root ArtifactManager; its own children are
78
+ // written one level deeper under its sessionFile-derived dir, dot-qualified.
79
+ const parentSessionFile = path.join(rootArtifactsDir, "Parent.jsonl");
80
+ const parentOwnDir = parentSessionFile.slice(0, -6);
81
+ await fs.mkdir(parentOwnDir, { recursive: true });
82
+ await fs.writeFile(path.join(parentOwnDir, "Parent.Child.md"), "child capsule");
83
+ // Parent output may be in the root dir; the nested child must still win.
84
+ await fs.writeFile(path.join(rootArtifactsDir, "Parent.md"), JSON.stringify({ Child: "wrong base output" }));
85
+
86
+ const fakeSession = {
87
+ sessionManager: { getArtifactsDir: () => sharedArtifactManager.dir },
88
+ } as unknown as AgentSession;
89
+ const registry = AgentRegistry.global();
90
+ registry.register({
91
+ id: "Main",
92
+ displayName: "main",
93
+ kind: "main",
94
+ session: fakeSession,
95
+ sessionFile: rootSessionFile,
96
+ });
97
+ registry.register({
98
+ id: "Parent",
99
+ displayName: "sub",
100
+ kind: "sub",
101
+ parentId: "Main",
102
+ session: fakeSession,
103
+ sessionFile: parentSessionFile,
104
+ });
105
+
106
+ const handler = new AgentProtocolHandler();
107
+ // Slash form is a hierarchy hop, not a jq extraction.
108
+ const slash = await handler.resolve(new URL("agent://Parent/Child") as never);
109
+ expect(slash.content).toBe("child capsule");
110
+ expect(slash.contentType).toBe("text/markdown");
111
+ // The canonical dotted id resolves to the same output.
112
+ const dotted = await handler.resolve(new URL("agent://Parent.Child") as never);
113
+ expect(dotted.content).toBe("child capsule");
114
+ });
115
+
116
+ it("agent:// path form falls back to JSON extraction when no nested output matches", async () => {
117
+ const root = tempDir.path();
118
+ const rootSessionFile = path.join(root, "json-session.jsonl");
119
+ const rootArtifactsDir = rootSessionFile.slice(0, -6);
120
+ await fs.mkdir(rootArtifactsDir, { recursive: true });
121
+ const sharedArtifactManager = new ArtifactManager(rootArtifactsDir);
122
+ await fs.writeFile(path.join(rootArtifactsDir, "Worker.md"), JSON.stringify({ result: { ok: true } }));
123
+
124
+ const fakeSession = {
125
+ sessionManager: { getArtifactsDir: () => sharedArtifactManager.dir },
126
+ } as unknown as AgentSession;
127
+ const registry = AgentRegistry.global();
128
+ registry.register({
129
+ id: "Main",
130
+ displayName: "main",
131
+ kind: "main",
132
+ session: fakeSession,
133
+ sessionFile: rootSessionFile,
134
+ });
135
+
136
+ const handler = new AgentProtocolHandler();
137
+ // `result` names no nested output, so the path extracts JSON from Worker.md.
138
+ const extracted = await handler.resolve(new URL("agent://Worker/result") as never);
139
+ expect(extracted.contentType).toBe("application/json");
140
+ expect(JSON.parse(extracted.content)).toEqual({ ok: true });
141
+ });
@@ -8,7 +8,11 @@
8
8
  *
9
9
  * URL forms:
10
10
  * - agent://<id> - Full output content
11
- * - agent://<id>/<path> - JSON extraction via path form
11
+ * - agent://<id>/<child> - Nested subagent output (hierarchy separator; the
12
+ * registry allocates a subagent's own children as dot-qualified ids, so
13
+ * `agent://Parent/Child` resolves `Parent.Child.md`)
14
+ * - agent://<id>/<path> - JSON extraction via path form (fallback when no
15
+ * nested output matches the path)
12
16
  * - agent://<id>?q=<query> - JSON extraction via query form
13
17
  */
14
18
  import * as fs from "node:fs/promises";
@@ -44,65 +48,56 @@ export class AgentProtocolHandler implements ProtocolHandler {
44
48
  }
45
49
 
46
50
  const dirs = artifactsDirsFromRegistry();
47
-
48
51
  if (dirs.length === 0) {
49
52
  throw new Error("No session - agent outputs unavailable");
50
53
  }
51
54
 
52
- let foundPath: string | undefined;
53
- let anyDirExists = false;
54
- const availableIds = new Set<string>();
55
-
56
- for (const dir of dirs) {
57
- try {
58
- await fs.stat(dir);
59
- anyDirExists = true;
60
- } catch (err) {
61
- if (isEnoent(err)) continue;
62
- throw err;
63
- }
64
- const candidate = path.join(dir, `${outputId}.md`);
55
+ // A subagent allocates its own children as dot-qualified ids
56
+ // (`Parent.Child`), so the slash path form is first tried as a hierarchy
57
+ // separator: `agent://Parent/Child` resolves `Parent.Child.md`. Only when
58
+ // no such nested output exists does the path fall back to jq-style JSON
59
+ // extraction on `<outputId>.md`. Query form (`?q=`) is always extraction.
60
+ const pathSegments = hasPathExtraction ? urlPath.split("/").filter(Boolean) : [];
61
+ const decodedSegments = pathSegments.map(segment => {
65
62
  try {
66
- await fs.stat(candidate);
67
- foundPath = candidate;
68
- break;
69
- } catch (err) {
70
- if (!isEnoent(err)) throw err;
71
- try {
72
- const files = await fs.readdir(dir);
73
- for (const f of files) {
74
- if (f.endsWith(".md")) availableIds.add(f.replace(/\.md$/, ""));
75
- }
76
- } catch {
77
- // Listing failures are non-fatal; continue searching.
78
- }
63
+ return decodeURIComponent(segment);
64
+ } catch {
65
+ return segment;
79
66
  }
80
- }
67
+ });
68
+ const nestedId =
69
+ decodedSegments.length > 0 && decodedSegments.every(segment => !segment.includes("."))
70
+ ? [outputId, ...decodedSegments].join(".")
71
+ : undefined;
81
72
 
82
- if (!anyDirExists) {
73
+ const scan = await this.#findOutput(dirs, nestedId ? [nestedId, outputId] : [outputId]);
74
+ if (!scan.anyDirExists) {
83
75
  throw new Error("No artifacts directory found");
84
76
  }
85
-
86
- if (!foundPath) {
87
- const availableStr = availableIds.size > 0 ? [...availableIds].join(", ") : "none";
88
- throw new Error(`Not found: ${outputId}\nAvailable: ${availableStr}`);
77
+ if (!scan.foundPath) {
78
+ const target = nestedId ?? outputId;
79
+ const availableStr = scan.availableIds.size > 0 ? [...scan.availableIds].join(", ") : "none";
80
+ throw new Error(`Not found: ${target}\nAvailable: ${availableStr}`);
89
81
  }
90
82
 
91
- const rawContent = await Bun.file(foundPath).text();
83
+ const rawContent = await Bun.file(scan.foundPath).text();
92
84
  const notes: string[] = [];
93
85
  let content = rawContent;
94
86
  let contentType: InternalResource["contentType"] = "text/markdown";
95
87
 
96
- if (hasPathExtraction || hasQueryExtraction) {
88
+ // Extraction applies only when the URL did NOT resolve to a nested output
89
+ // (a slash that named a real child is a hierarchy hop, not a jq path).
90
+ const extract = hasQueryExtraction || (hasPathExtraction && scan.matchedId !== nestedId);
91
+ if (extract) {
97
92
  let jsonValue: unknown;
98
93
  try {
99
94
  jsonValue = JSON.parse(rawContent);
100
95
  } catch (err) {
101
96
  const message = err instanceof Error ? err.message : String(err);
102
- throw new Error(`Output ${outputId} is not valid JSON: ${message}`);
97
+ throw new Error(`Output ${scan.matchedId} is not valid JSON: ${message}`);
103
98
  }
104
99
 
105
- const query = hasPathExtraction ? pathToQuery(urlPath) : queryParam!;
100
+ const query = hasQueryExtraction ? queryParam! : pathToQuery(urlPath);
106
101
  if (query) {
107
102
  const extracted = applyQuery(jsonValue, query);
108
103
  try {
@@ -122,11 +117,50 @@ export class AgentProtocolHandler implements ProtocolHandler {
122
117
  content,
123
118
  contentType,
124
119
  size: Buffer.byteLength(content, "utf-8"),
125
- sourcePath: foundPath,
120
+ sourcePath: scan.foundPath,
126
121
  notes,
127
122
  };
128
123
  }
129
124
 
125
+ /**
126
+ * Scan every registered artifacts dir for the first `<id>.md` among
127
+ * `candidateIds` (tried in order, so a hierarchy match wins over the base
128
+ * id). Returns the resolved path and the id it matched, plus the set of
129
+ * available ids gathered from the scanned dirs for the not-found message.
130
+ */
131
+ async #findOutput(
132
+ dirs: string[],
133
+ candidateIds: string[],
134
+ ): Promise<{ foundPath?: string; matchedId?: string; anyDirExists: boolean; availableIds: Set<string> }> {
135
+ // Build a full id→path map across every registered dir before picking, so
136
+ // candidate priority is global: a nested id in a deeper dir must win over
137
+ // the base id even when the base id's dir is scanned first.
138
+ const byId = new Map<string, string>();
139
+ let anyDirExists = false;
140
+ for (const dir of dirs) {
141
+ let files: string[];
142
+ try {
143
+ files = await fs.readdir(dir);
144
+ } catch (err) {
145
+ if (isEnoent(err)) continue;
146
+ throw err;
147
+ }
148
+ anyDirExists = true;
149
+ for (const f of files) {
150
+ if (!f.endsWith(".md")) continue;
151
+ const id = f.slice(0, -3);
152
+ if (!byId.has(id)) byId.set(id, path.join(dir, f));
153
+ }
154
+ }
155
+ for (const id of candidateIds) {
156
+ const foundPath = byId.get(id);
157
+ if (foundPath) {
158
+ return { foundPath, matchedId: id, anyDirExists, availableIds: new Set(byId.keys()) };
159
+ }
160
+ }
161
+ return { anyDirExists, availableIds: new Set(byId.keys()) };
162
+ }
163
+
130
164
  async complete(): Promise<UrlCompletion[]> {
131
165
  const ids = new Set<string>();
132
166
  for (const dir of artifactsDirsFromRegistry()) {
package/src/irc/bus.ts CHANGED
@@ -127,12 +127,31 @@ export class IrcBus {
127
127
  };
128
128
  }
129
129
 
130
+ // A `parked` recipient always needs the lifecycle to revive it — this is
131
+ // read from *this* bus's registry, so it holds for any registry. The
132
+ // mid-park / adopted checks below query the lifecycle's own state, which
133
+ // only describes the registry it manages: consult them only when the
134
+ // lifecycle owns this bus's registry, otherwise a custom-registry bus
135
+ // (fallen back to the global manager) would gate a live recipient on
136
+ // unrelated global park state. Main/non-adopted live peers skip the gate,
137
+ // and pending waiters still win without a session.
138
+ const lifecycle = this.#lifecycle();
139
+ const lifecycleOwnsRegistry = lifecycle.manages(this.#registry);
140
+ const needsLifecycleGate =
141
+ ref.status === "parked" ||
142
+ (lifecycleOwnsRegistry && (lifecycle.isParking(message.to) || lifecycle.has(message.to)));
143
+
144
+ const priorSession = ref.session;
130
145
  let revived = false;
131
- if (ref.status === "parked") {
146
+ if (needsLifecycleGate) {
132
147
  try {
133
- await this.#lifecycle().ensureLive(message.to);
134
- revived = true;
148
+ const liveSession = await lifecycle.ensureLive(message.to);
149
+ // Revival = we did not keep the same live instance (parked start, or
150
+ // park completed and a fresh session was rebuilt).
151
+ revived = !priorSession || liveSession !== priorSession;
135
152
  } catch (error) {
153
+ // Not revivable / released / revive failed. Do not buffer: a permanent
154
+ // failure must not inflate unread counts or pretend delivery is pending.
136
155
  return {
137
156
  to: message.to,
138
157
  outcome: "failed",
@@ -3,7 +3,7 @@ import * as net from "node:net";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { Process, type PtyRunResult, PtySession } from "@oh-my-pi/pi-natives";
6
- import { isEexist, isEnoent, logger, postmortem, sanitizeText } from "@oh-my-pi/pi-utils";
6
+ import { isEexist, isEnoent, logger, postmortem, procmgr, sanitizeText } from "@oh-my-pi/pi-utils";
7
7
  import { truncateHead, truncateHeadBytes, truncateTail, truncateTailBytes } from "../session/streaming-output";
8
8
  import { workerEnvFromParent } from "../subprocess/worker-client";
9
9
  import { daemonBrokerEndpoint } from "./paths";
@@ -555,7 +555,8 @@ class DaemonBroker {
555
555
  `printf '%s' "$$" > ${quoteShellArg(pidPath)}`,
556
556
  `exec ${argv.map(quoteShellArg).join(" ")}`,
557
557
  ].join("; ");
558
- run = session.start({ command, shell: process.env.SHELL, ...options }, onChunk);
558
+ const shell = procmgr.getShellConfig().shell;
559
+ run = session.start({ command, shell, ...options }, onChunk);
559
560
  }
560
561
  void run
561
562
  .then(result => this.#onPtyExit(record, generation, result))
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as net from "node:net";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { isEexist, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
5
+ import { isEexist, isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
6
6
  import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client";
7
7
  import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths";
8
8
  import {
@@ -49,7 +49,7 @@ async function canonicalProjectDir(projectDir: string): Promise<string> {
49
49
  try {
50
50
  return await fs.realpath(resolved);
51
51
  } catch (error) {
52
- if (isEnoent(error)) return resolved;
52
+ if (isEnoent(error) || isEisdir(error)) return resolved;
53
53
  throw error;
54
54
  }
55
55
  }
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { isEnoent, postmortem } from "@oh-my-pi/pi-utils";
3
+ import { isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
4
4
  import { daemonRuntimeDir } from "./paths";
5
5
 
6
6
  const CLIENTS_DIR = "clients";
@@ -15,7 +15,7 @@ async function canonicalProjectDir(projectDir: string): Promise<string> {
15
15
  try {
16
16
  return await fs.realpath(resolved);
17
17
  } catch (error) {
18
- if (isEnoent(error)) return resolved;
18
+ if (isEnoent(error) || isEisdir(error)) return resolved;
19
19
  throw error;
20
20
  }
21
21
  }
@@ -16,7 +16,14 @@ export function ohMyPiXAIUserAgent(): string {
16
16
  return "oh-my-pi/xai";
17
17
  }
18
18
 
19
- type XAIProvider = "xai-oauth" | "xai";
19
+ /** xAI provider ids supported by shared HTTP tool transport resolution. */
20
+ export type XAIHttpProvider = "xai-oauth" | "xai";
21
+
22
+ /** Resolved endpoint and configured headers for an xAI HTTP tool request. */
23
+ export interface XAIHttpTransport {
24
+ baseURL: string;
25
+ headers?: Record<string, string>;
26
+ }
20
27
 
21
28
  /**
22
29
  * Resolve the HTTP base URL for an xAI tool call.
@@ -48,7 +55,11 @@ type XAIProvider = "xai-oauth" | "xai";
48
55
  * let xai-oauth entries hijack a xai tool call (or vice versa) when the
49
56
  * same model id ships under both descriptors.
50
57
  */
51
- function resolveXAIBaseURL(modelRegistry: ModelRegistry, provider: XAIProvider, modelId: string | undefined): string {
58
+ function resolveXAIBaseURL(
59
+ modelRegistry: ModelRegistry,
60
+ provider: XAIHttpProvider,
61
+ modelId: string | undefined,
62
+ ): string {
52
63
  if (modelId) {
53
64
  const merged = modelRegistry.getAll().find(m => m.id === modelId && m.provider === provider);
54
65
  if (merged?.baseUrl) {
@@ -68,6 +79,21 @@ function resolveXAIBaseURL(modelRegistry: ModelRegistry, provider: XAIProvider,
68
79
  }
69
80
  return ($env.XAI_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
70
81
  }
82
+ /**
83
+ * Resolve an xAI tool endpoint and its provider/model header overrides.
84
+ */
85
+ export function resolveXAIHttpTransport(
86
+ modelRegistry: ModelRegistry,
87
+ provider: XAIHttpProvider,
88
+ modelId?: string,
89
+ ): XAIHttpTransport {
90
+ return {
91
+ baseURL: resolveXAIBaseURL(modelRegistry, provider, modelId),
92
+ headers:
93
+ (modelId ? modelRegistry.find(provider, modelId)?.headers : undefined) ??
94
+ modelRegistry.getProviderHeaders(provider),
95
+ };
96
+ }
71
97
 
72
98
  /**
73
99
  * Resolve xAI credentials for HTTP tool calls.
package/src/lsp/client.ts CHANGED
@@ -428,7 +428,7 @@ async function handleConfigurationRequest(client: LspClient, message: LspJsonRpc
428
428
  const items = params?.items ?? [];
429
429
  const result = items.map(item => {
430
430
  const section = item.section ?? "";
431
- return client.config.settings?.[section] ?? {};
431
+ return client.config.settings?.[section] ?? null;
432
432
  });
433
433
  await sendResponse(client, message.id, result, "workspace/configuration");
434
434
  }
package/src/main.ts CHANGED
@@ -59,6 +59,7 @@ import type { PrintModeOptions } from "./modes/print-mode";
59
59
  import { CURRENT_SETUP_VERSION } from "./modes/setup-version";
60
60
  import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
61
61
  import type { SubmittedUserInput } from "./modes/types";
62
+ import { createWarpEventBridgeExtension } from "./modes/warp-events";
62
63
  import { AgentLifecycleManager } from "./registry/agent-lifecycle";
63
64
  import {
64
65
  type CreateAgentSessionOptions,
@@ -76,7 +77,7 @@ import { executeBuiltinSlashCommand } from "./slash-commands/builtin-registry";
76
77
  import { shouldShowStartupSplash } from "./startup-splash";
77
78
  import { discoverTitleSystemPromptFile, resolvePromptInput } from "./system-prompt";
78
79
  import { createPersistedSubagentReviverFactory } from "./task/persisted-revive";
79
- import { initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export";
80
+ import { createTelemetryExportConfig, initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export";
80
81
  import { concreteThinkingLevel, parseConfiguredThinkingLevel } from "./thinking";
81
82
  import type { LspStartupServerInfo } from "./tools";
82
83
  import {
@@ -1350,15 +1351,13 @@ export async function runRootCommand(
1350
1351
  sessionOptions.hasUI = isInteractive || mode === "rpc-ui";
1351
1352
  sessionOptions.settings = settingsInstance;
1352
1353
 
1353
- // OTEL: register the global OTLP trace exporter when an OTLP endpoint is
1354
- // configured via env, then switch on the agent loop's telemetry so its
1355
- // GenAI spans (invoke_agent / chat / execute_tool) are actually emitted.
1356
- // Both are no-ops when OTEL_EXPORTER_OTLP_ENDPOINT is unset. An empty config
1357
- // is enough to enable telemetry — content capture is governed by the
1358
- // standard OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT env var.
1354
+ // OTEL: register global OTLP exporters when an endpoint is configured via
1355
+ // env, then switch on the agent loop's telemetry hooks so traces, run-level
1356
+ // metrics, and structured logs have source events to export. Content capture
1357
+ // remains governed by OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT.
1359
1358
  await logger.time("initTelemetryExport", initTelemetryExport);
1360
1359
  if (isTelemetryExportEnabled()) {
1361
- sessionOptions.telemetry = {};
1360
+ sessionOptions.telemetry = createTelemetryExportConfig(sessionOptions.telemetry);
1362
1361
  }
1363
1362
 
1364
1363
  // Handle CLI --api-key as runtime override (not persisted)
@@ -1407,6 +1406,10 @@ export async function runRootCommand(
1407
1406
  // string-flag value such as `--target @notes.md` is the flag's value, not a
1408
1407
  // file — and the same result is handed to createAgentSession via
1409
1408
  // `preloadedExtensions` so the discovery work is not repeated.
1409
+ if (isInteractive) {
1410
+ sessionOptions.extensions = [...(sessionOptions.extensions ?? []), createWarpEventBridgeExtension()];
1411
+ }
1412
+
1410
1413
  const eventBus = new EventBus();
1411
1414
  const extensionsResult = await loadSessionExtensions(sessionOptions, cwd, settingsInstance, eventBus);
1412
1415
  const extensionFlagSink: ExtensionFlagSink = {
@@ -572,8 +572,14 @@ export class MCPManager {
572
572
  };
573
573
  }
574
574
 
575
+ /**
576
+ * Ownership is matched via `mcpServerName`, never a `mcp__${name}_` name
577
+ * prefix: tool names are lossy-sanitized, so one server's sanitized name
578
+ * can prefix another's (`atlassian` vs `atlassian:atlassian`) and a name
579
+ * with sanitized characters never prefix-matches its own tools at all.
580
+ */
575
581
  #replaceServerTools(name: string, tools: CustomTool<TSchema, MCPToolDetails>[]): void {
576
- this.#tools = this.#tools.filter(t => !t.name.startsWith(`mcp__${name}_`));
582
+ this.#tools = this.#tools.filter(t => t.mcpServerName !== name);
577
583
  this.#tools.push(...tools);
578
584
  // Stable sort by name so reconnect order does not perturb the array.
579
585
  // See `sortMCPToolsByName` for the cache-stability rationale.
@@ -762,8 +768,8 @@ export class MCPManager {
762
768
  }
763
769
 
764
770
  // Remove tools from this server and notify consumers
765
- const hadTools = this.#tools.some(t => t.name.startsWith(`mcp__${name}_`));
766
- this.#tools = this.#tools.filter(t => !t.name.startsWith(`mcp__${name}_`));
771
+ const hadTools = this.#tools.some(t => t.mcpServerName === name);
772
+ this.#tools = this.#tools.filter(t => t.mcpServerName !== name);
767
773
  if (hadTools) this.#onToolsChanged?.(this.#tools);
768
774
 
769
775
  // Notify prompt consumers so stale commands are cleared
@@ -54,6 +54,18 @@ export interface StdioSpawnCommand {
54
54
  * grandchildren keep stdout routed through our pipe (#3544).
55
55
  */
56
56
  detached: boolean;
57
+ /**
58
+ * Pass argv to `Bun.spawn` verbatim (Windows only), suppressing the
59
+ * default libuv backslash-quoting.
60
+ *
61
+ * Set when `cmd` already holds a `cmd.exe /d /e:ON /v:OFF /c "<line>"`
62
+ * command line escaped for `cmd.exe`'s parser (see `buildCmdExeArgv`).
63
+ * libuv's quoting targets `CommandLineToArgvW`, not `cmd.exe`, so letting
64
+ * it re-quote a batch launch would corrupt arguments and re-open the
65
+ * `%VAR%` / quote-injection holes the escaping closes (BatBadBut,
66
+ * CVE-2024-24576).
67
+ */
68
+ windowsVerbatimArguments?: boolean;
57
69
  }
58
70
 
59
71
  /** Inputs used to resolve platform-specific stdio spawn behavior. */
@@ -203,23 +215,6 @@ async function resolveWindowsNpmShimCommand(
203
215
  };
204
216
  }
205
217
 
206
- function quoteCmdArg(value: string): string {
207
- if (value.length === 0) return '""';
208
- let result = '"';
209
- for (const char of value) {
210
- if (char === '"') {
211
- result += '^"';
212
- } else if (char === "^") {
213
- result += "^^";
214
- } else if (char === "%") {
215
- result += "^%";
216
- } else {
217
- result += char;
218
- }
219
- }
220
- return `${result}"`;
221
- }
222
-
223
218
  function isWindowsBatchCommand(command: string): boolean {
224
219
  return WINDOWS_BATCH_EXTENSIONS.has(path.extname(command).toLowerCase());
225
220
  }
@@ -229,10 +224,93 @@ function resolveComSpec(env: Record<string, string | undefined>): string {
229
224
  return comspec && comspec.length > 0 ? comspec : "cmd.exe";
230
225
  }
231
226
 
232
- /** `cmd /s /c` strips one outer quote pair; keep inner argv quotes intact. */
233
- function buildCmdExeCommand(command: string, args: readonly string[]): string {
234
- const quotedCommand = [command, ...args].map(quoteCmdArg).join(" ");
235
- return `"${quotedCommand}"`;
227
+ // Argument bytes cmd.exe delivers unchanged without quoting. Anything outside
228
+ // this set (spaces, quotes, `%`, shell metacharacters, non-ASCII) forces the
229
+ // quoted+escaped path below. Mirrors the fuzz-tested allow-list from Zig's
230
+ // BatBadBut mitigation.
231
+ const CMD_SAFE_ARG = /^[A-Za-z0-9#$*+\-./:?@\\_]+$/;
232
+
233
+ /**
234
+ * Escape the interior of a `cmd.exe`-quoted token: neutralize `%VAR%` expansion
235
+ * and double any backslash run that precedes a quote (including the caller's
236
+ * closing quote) so `CommandLineToArgvW` delivers the backslashes literally.
237
+ *
238
+ * `cmd.exe` re-parses the whole `/c` string and expands `%…%` *before* the
239
+ * batch shim's own argv split runs, so both the command path and every argument
240
+ * must pass through this. Percent → `%%cd:~,%` (which expands to nothing,
241
+ * leaving a literal `%`) and `"` → `""` are the documented BatBadBut mitigation
242
+ * (CVE-2024-24576). The caller supplies the surrounding double quotes.
243
+ *
244
+ * @see https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/
245
+ */
246
+ function escapeCmdQuotedInterior(value: string): string {
247
+ let out = "";
248
+ let backslashes = 0;
249
+ for (const ch of value) {
250
+ if (ch === "\\") {
251
+ backslashes += 1;
252
+ out += ch;
253
+ } else if (ch === '"') {
254
+ out += "\\".repeat(backslashes);
255
+ out += '""';
256
+ backslashes = 0;
257
+ } else if (ch === "%") {
258
+ out += "%%cd:~,%";
259
+ backslashes = 0;
260
+ } else {
261
+ backslashes = 0;
262
+ out += ch;
263
+ }
264
+ }
265
+ // Double the trailing backslash run so it stays literal before the closing
266
+ // quote the caller appends.
267
+ out += "\\".repeat(backslashes);
268
+ return out;
269
+ }
270
+
271
+ /** Reject bytes that cannot round-trip through `cmd.exe`'s `/c` command line. */
272
+ function assertCmdBatchToken(value: string, kind: "command" | "argument"): void {
273
+ // NUL/LF act as an end-of-command marker and CR is stripped, so any of them
274
+ // would silently truncate or corrupt the launch.
275
+ if (/[\0\r\n]/.test(value)) {
276
+ throw new Error(`Windows batch MCP ${kind} cannot contain NUL, CR, or LF characters`);
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Escape one argument for `cmd.exe`'s command-line pre-parse so a `.cmd`/`.bat`
282
+ * shim receives it verbatim. Quotes only when the argument is empty, ends in a
283
+ * backslash, or holds a byte outside {@link CMD_SAFE_ARG}; the quoted body is
284
+ * escaped by {@link escapeCmdQuotedInterior}.
285
+ *
286
+ * @throws when the argument contains NUL, CR, or LF (see {@link assertCmdBatchToken}).
287
+ */
288
+ function escapeCmdBatchArg(arg: string): string {
289
+ assertCmdBatchToken(arg, "argument");
290
+ const needsQuotes = arg.length === 0 || arg.endsWith("\\") || !CMD_SAFE_ARG.test(arg);
291
+ // An unquoted arg is pure allow-list bytes (no `%`, `"`, or trailing `\`), so
292
+ // it needs no interior escaping.
293
+ return needsQuotes ? `"${escapeCmdQuotedInterior(arg)}"` : arg;
294
+ }
295
+
296
+ /**
297
+ * Build the `cmd.exe` argv for a Windows `.cmd`/`.bat` (or unresolved bare)
298
+ * MCP command.
299
+ *
300
+ * The trailing element is a single `/c` string wrapped in an outer quote pair
301
+ * that `cmd.exe` strips (its opening-quote rule). The command token is always
302
+ * quoted and, like every argument, escaped so a `%` in the resolved path (e.g.
303
+ * `C:\work\%TOKEN%\server.cmd`) is not expanded before the shim launches.
304
+ * `/e:ON` keeps command extensions on (required for the `%%cd:~,%` trick) and
305
+ * `/v:OFF` disables delayed expansion. The result MUST be spawned with
306
+ * `windowsVerbatimArguments` so libuv passes it through unmodified.
307
+ */
308
+ function buildCmdExeArgv(comspec: string, command: string, args: readonly string[]): string[] {
309
+ assertCmdBatchToken(command, "command");
310
+ let line = `""${escapeCmdQuotedInterior(command)}"`;
311
+ for (const arg of args) line += ` ${escapeCmdBatchArg(arg)}`;
312
+ line += '"';
313
+ return [comspec, "/d", "/e:ON", "/v:OFF", "/c", line];
236
314
  }
237
315
 
238
316
  /**
@@ -245,7 +323,7 @@ function buildCmdExeCommand(command: string, args: readonly string[]): string {
245
323
  * only appends `.exe` for extensionless names — `.cmd`/`.bat` are never
246
324
  * tried, so `npx` (which exists only as `npx.cmd` on Windows) crashes the
247
325
  * subprocess immediately. When the resolver can't pin the command down,
248
- * route through `cmd.exe /d /s /c` so Windows's own PATHEXT lookup runs.
326
+ * route through `cmd.exe` so Windows's own PATHEXT lookup runs.
249
327
  */
250
328
  export async function resolveStdioSpawnCommand(
251
329
  config: MCPStdioServerConfig,
@@ -271,9 +349,10 @@ export async function resolveStdioSpawnCommand(
271
349
  if (!needsCmdExe) return { cmd: [resolvedCommand, ...args], windowsHide, detached };
272
350
 
273
351
  return {
274
- cmd: [resolveComSpec(options.env), "/d", "/s", "/c", buildCmdExeCommand(resolvedCommand, args)],
352
+ cmd: buildCmdExeArgv(resolveComSpec(options.env), resolvedCommand, args),
275
353
  windowsHide,
276
354
  detached,
355
+ windowsVerbatimArguments: true,
277
356
  };
278
357
  }
279
358
 
@@ -388,6 +467,7 @@ export class StdioTransport implements MCPTransport {
388
467
  stderr: "pipe",
389
468
  windowsHide: spawnCommand.windowsHide,
390
469
  detached: spawnCommand.detached,
470
+ windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments,
391
471
  });
392
472
 
393
473
  this.#connected = true;